Skip to content

Commit

Permalink
Add response stub to resource route calls when single fetch is enabled
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 committed Apr 30, 2024
1 parent 56e0a3f commit cc1aed7
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/slow-peaches-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/server-runtime": patch
---

Pass `response` stub to resource route handlerså when single fetch is enabled
41 changes: 41 additions & 0 deletions docs/guides/single-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,42 @@ export function loader() {
}
```

#### Response Stub and Resource Routes

Ad discussed above, the `headers` export is deprecated in favor of a new [`response` stub][responsestub] passed to your `loader` and `action` functions. This is somewhat confusing in resource routes, though, because you get to return the _actual_ `Response` - there's no real need for a "stub" concept because there's no merging results from multiple loaders into a single Response:

```tsx filename=routes/resource.tsx
// Using your own Response is the most straightforward approach
export async function loader() {
const data = await getData();
return json(data, {
status: 200,
headers: {
"X-Custom": "whatever",
},
});
}
```

To keep things consistent, resource route `loader`/`action` functions will still receive a `response` stub and you can use it if you need to (maybe to share code amongst non-resource-route handlers):

```tsx filename=routes/resource.tsx
// But you can still set values on the response stubstraightforward approach
export async function loader({
response,
}: LoaderFunctionArgs) {
const data = await getData();
response.status = 200;
response.headers.set("X-Custom", "whatever");
return json(data);
}
```

It's best to try to avoid using the `response` stub _and also_ returning a `Response` with custom status/headers, but if you do, the following logic will apply":

- The `Response` instance status will take priority over any `response` stub status
- Headers operations on the `response` stub `headers` will be re-played on the returned `Response` headers instance

[future-flags]: ../file-conventions/remix-config#future
[should-revalidate]: ../route/should-revalidate
[entry-server]: ../file-conventions/entry.server
Expand All @@ -309,3 +345,8 @@ export function loader() {
[headers]: ../route/headers
[mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
[resource-routes]: ../guides/resource-routes
[responsestub]: #headers

```
```
69 changes: 69 additions & 0 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,75 @@ test.describe("single-fetch", () => {
console.warn = oldConsoleWarn;
});

test("processes response stub onto resource routes returning raw data", async () => {
let fixture = await createFixture(
{
config: {
future: {
unstable_singleFetch: true,
},
},
files: {
...files,
"app/routes/resource.tsx": js`
import { json } from '@remix-run/node';
export function loader({ response }) {
response.status = 201;
response.headers.set('X-Response-Stub', 'yes')
return { message: "RESOURCE" };
}
`,
},
},
ServerMode.Development
);
let res = await fixture.requestResource("/resource");
expect(res.status).toBe(201);
expect(res.headers.get("X-Response-Stub")).toBe("yes");
expect(await res.json()).toEqual({
message: "RESOURCE",
});
});

test("processes response stub onto resource routes returning responses", async () => {
let fixture = await createFixture(
{
config: {
future: {
unstable_singleFetch: true,
},
},
files: {
...files,
"app/routes/resource.tsx": js`
import { json } from '@remix-run/node';
export function loader({ response }) {
response.status = 200; // ignored
response.headers.set('X-Response-Stub', 'yes')
return json({ message: "RESOURCE" }, {
// This one takes precedence
status: 201,
headers: {
'X-Response': 'yes'
},
});
}
`,
},
},
ServerMode.Development
);
let res = await fixture.requestResource("/resource");
expect(res.status).toBe(201);
expect(res.headers.get("X-Response")).toBe("yes");
expect(res.headers.get("X-Response-Stub")).toBe("yes");
expect(await res.json()).toEqual({
message: "RESOURCE",
});
});

test.describe("client loaders", () => {
test("when no routes have client loaders", async ({ page }) => {
let fixture = await createFixture(
Expand Down
42 changes: 34 additions & 8 deletions packages/remix-server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ import {
getResponseStubs,
getSingleFetchDataStrategy,
getSingleFetchRedirect,
getSingleFetchResourceRouteDataStrategy,
mergeResponseStubs,
singleFetchAction,
singleFetchLoaders,
SingleFetchRedirectSymbol,
} from "./single-fetch";
import { resourceRouteJsonWarning } from "./deprecations";
import { ResponseStubOperationsSymbol } from "./routeModules";

export type RequestHandler = (
request: Request,
Expand Down Expand Up @@ -570,12 +572,22 @@ async function handleResourceRequest(
handleError: (err: unknown) => void
) {
try {
let responseStubs = build.future.unstable_singleFetch
? getResponseStubs()
: {};
// Note we keep the routeId here to align with the Remix handling of
// resource routes which doesn't take ?index into account and just takes
// the leaf match
let response = await staticHandler.queryRoute(request, {
routeId,
requestContext: loadContext,
...(build.future.unstable_singleFetch
? {
unstable_dataStrategy: getSingleFetchResourceRouteDataStrategy({
responseStubs,
}),
}
: null),
});

if (typeof response === "object") {
Expand All @@ -586,14 +598,28 @@ async function handleResourceRequest(
);
}

if (build.future.unstable_singleFetch && !isResponse(response)) {
console.warn(
resourceRouteJsonWarning(
request.method === "GET" ? "loader" : "action",
routeId
)
);
response = json(response);
if (build.future.unstable_singleFetch) {
let stub = responseStubs[routeId];
if (isResponse(response)) {
// Merge directly onto the response if one was returned
let ops = stub[ResponseStubOperationsSymbol];
for (let [op, ...args] of ops) {
// @ts-expect-error
response.headers[op](...args);
}
} else {
console.warn(
resourceRouteJsonWarning(
request.method === "GET" ? "loader" : "action",
routeId
)
);
// Otherwise just create a response using the ResponseStub fields
response = json(response, {
status: stub.status || 200,
headers: stub.headers,
});
}
}

// callRouteLoader/callRouteAction always return responses (w/o single fetch).
Expand Down
31 changes: 29 additions & 2 deletions packages/remix-server-runtime/single-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
StaticHandler,
unstable_DataStrategyFunctionArgs as DataStrategyFunctionArgs,
unstable_DataStrategyFunction as DataStrategyFunction,
StaticHandlerContext,
} from "@remix-run/router";
import {
Expand Down Expand Up @@ -49,7 +50,7 @@ export function getSingleFetchDataStrategy(
isActionDataRequest,
loadRouteIds,
}: { isActionDataRequest?: boolean; loadRouteIds?: string[] } = {}
) {
): DataStrategyFunction {
return async ({ request, matches }: DataStrategyFunctionArgs) => {
// Don't call loaders on action data requests
if (isActionDataRequest && request.method === "GET") {
Expand Down Expand Up @@ -102,6 +103,32 @@ export function getSingleFetchDataStrategy(
};
}

export function getSingleFetchResourceRouteDataStrategy({
responseStubs,
}: {
responseStubs: ReturnType<typeof getResponseStubs>;
}): DataStrategyFunction {
return async ({ matches }: DataStrategyFunctionArgs) => {
let results = await Promise.all(
matches.map(async (match) => {
let responseStub = match.shouldLoad
? responseStubs[match.route.id]
: null;
let result = await match.resolve(async (handler) => {
// Cast `ResponseStubImpl -> ResponseStub` to hide the symbol in userland
let ctx: DataStrategyCtx = {
response: responseStub as ResponseStub,
};
let data = await handler(ctx);
return { type: "data", result: data };
});
return result;
})
);
return results;
};
}

export async function singleFetchAction(
serverMode: ServerMode,
staticHandler: StaticHandler,
Expand All @@ -128,7 +155,7 @@ export async function singleFetchAction(
}),
});

// Unlike `handleDataRequest`, when singleFetch is enabled, queryRoute does
// Unlike `handleDataRequest`, when singleFetch is enabled, query does
// let non-Response return values through
if (isResponse(result)) {
return {
Expand Down

0 comments on commit cc1aed7

Please sign in to comment.