Skip to content

Commit

Permalink
Resource Route Single Fetch Updates (#9349)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 committed May 1, 2024
1 parent 75321d3 commit 09e81a8
Show file tree
Hide file tree
Showing 13 changed files with 365 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/rich-spoons-draw.md
@@ -0,0 +1,5 @@
---
"@remix-run/server-runtime": patch
---

Automatically wrap resource route naked object returns in `json()` for back-compat in v2 (and log deprecation warning)
5 changes: 5 additions & 0 deletions .changeset/slow-peaches-matter.md
@@ -0,0 +1,5 @@
---
"@remix-run/server-runtime": patch
---

Pass `response` stub to resource route handlerså when single fetch is enabled
70 changes: 70 additions & 0 deletions docs/guides/single-fetch.md
Expand Up @@ -270,6 +270,74 @@ And then when `c` calls `serverLoader`, it'll make it's own call for just the `c
GET /a/b/c.data?_routes=routes/c
```

### Resource Routes

Because of the new streaming format used by Single Fetch, raw JavaScript objects returned from `loader` and `action` functions are no longer automatically converted to `Response` instances via the `json()` utility. Instead, in normal navigational data loads they're combined with the other loader data and streamed down in a `turbo-stream` response. Resource routes are unique because they're intended to be hit individually -- and not always via Remix client side code. They can also be accessed via any other HTTP client (`fetch`, `cURL`, etc.).

With Single Fetch enabled, raw Javascript objects returned from resource routes will be handled as follows:

When accessing from a Remix API such as `useFetcher`, raw Javascript objects will be returned as turbo-stream responses, just like normal loaders and actions (this is because `useFetcher` will append the `.data` suffix to the request).

When accessing from an external tool such as `fetch` or `cURL`, we will continue this automatic conversion to `json()` or backwards-compatibility in v2:

- When we detect a raw object for an external request in v2, we will will log a deprecation warning and wrap the value in `json()` for easier opt-into the Single Fetch feature
- At your convenience, you can add the `json()` call to your resource route handlers to stop returning raw objects when you want a JSON response for external consumption
- Once you've addressed all of the deprecation warnings in your application's resource routes, you will be better prepared for the eventual Remix v3 upgrade

```tsx filename=app/routes/resource.tsx bad
export function loader() {
return {
message: "My externally-accessed resource route",
};
}
```

```tsx filename=app/routes/resource.tsx good
import { json } from "@remix-run/react";

export function loader() {
return json({
message: "My externally-accessed resource route",
});
}
```

#### 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 @@ -284,3 +352,5 @@ GET /a/b/c.data?_routes=routes/c
[starttransition]: https://react.dev/reference/react/startTransition
[headers]: ../route/headers
[mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
[resource-routes]: ../guides/resource-routes
[responsestub]: #headers
2 changes: 1 addition & 1 deletion integration/package.json
Expand Up @@ -14,7 +14,7 @@
"@remix-run/dev": "workspace:*",
"@remix-run/express": "workspace:*",
"@remix-run/node": "workspace:*",
"@remix-run/router": "1.16.0",
"@remix-run/router": "1.16.1-pre.0",
"@remix-run/server-runtime": "workspace:*",
"@types/express": "^4.17.9",
"@vanilla-extract/css": "^1.10.0",
Expand Down
167 changes: 167 additions & 0 deletions integration/single-fetch-test.ts
Expand Up @@ -1500,6 +1500,173 @@ test.describe("single-fetch", () => {
expect(await app.getHtml("#target")).toContain("Target");
});

test("wraps resource route naked object returns in json with a deprecation warning", async () => {
let oldConsoleWarn = console.warn;
let warnLogs: unknown[] = [];
console.warn = (...args) => warnLogs.push(...args);

let fixture = await createFixture(
{
config: {
future: {
unstable_singleFetch: true,
},
},
files: {
...files,
"app/routes/resource.tsx": js`
export function loader() {
return { message: "RESOURCE" };
}
`,
},
},
ServerMode.Development
);
let res = await fixture.requestResource("/resource");
expect(await res.json()).toEqual({
message: "RESOURCE",
});

expect(warnLogs).toEqual([
"⚠️ REMIX FUTURE CHANGE: Resource routes will no longer be able to return " +
"raw JavaScript objects in v3 when Single Fetch becomes the default. You " +
"can prepare for this change at your convenience by wrapping the data " +
"returned from your `loader` function in the `routes/resource` route with " +
"`json()`. For instructions on making this change see " +
"https://remix.run/docs/en/v2.9.2/guides/single-fetch#resource-routes",
]);
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 }) {
// When raw json is returned, the stub status/headers will just be used directly
response.status = 201;
response.headers.set('X-Stub', 'yes')
return { message: "RESOURCE" };
}
`,
},
},
ServerMode.Development
);
let res = await fixture.requestResource("/resource");
expect(res.status).toBe(201);
expect(res.headers.get("X-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 }) {
// This will be ignored in favor of the returned Response status
response.status = 200;
response.headers.set('X-Stub', 'yes')
// This will overwrite the returned Response header
response.headers.set('X-Set', '2')
// This will append to the returned Response header
response.headers.append('X-Append', '2')
return json({ message: "RESOURCE" }, {
// This one takes precedence
status: 201,
headers: {
'X-Response': 'yes',
'X-Set': '1',
'X-Append': '1',
},
});
}
`,
},
},
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-Stub")).toBe("yes");
expect(res.headers.get("X-Set")).toBe("2");
expect(res.headers.get("X-Append")).toBe("1, 2");
expect(await res.json()).toEqual({
message: "RESOURCE",
});
});

test("allows fetcher to hit resource route and return via turbo stream", async ({
page,
}) => {
let fixture = await createFixture({
config: {
future: {
unstable_singleFetch: true,
},
},
files: {
...files,
"app/routes/_index.tsx": js`
import { useFetcher } from "@remix-run/react";
export default function Component() {
let fetcher = useFetcher();
return (
<div>
<button id="load" onClick={() => fetcher.load('/resource')}>
Load
</button>
{fetcher.data ? <pre id="fetcher-data">{fetcher.data.message} {fetcher.data.date.toISOString()}</pre> : null}
</div>
);
}
`,
"app/routes/resource.tsx": js`
export function loader() {
// Fetcher calls to resource routes will append ".data" and we'll go through
// the turbo-stream flow. If a user were to curl this endpoint they'd go
// through "handleResourceRoute" and it would be returned as "json()"
return {
message: "RESOURCE",
date: new Date("${ISO_DATE}"),
};
}
`,
},
});
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/");
await app.clickElement("#load");
await page.waitForSelector("#fetcher-data");
expect(await app.getHtml("#fetcher-data")).toContain(
"RESOURCE 2024-03-12T12:00:00.000Z"
);
});

test.describe("client loaders", () => {
test("when no routes have client loaders", async ({ page }) => {
let fixture = await createFixture(
Expand Down
2 changes: 1 addition & 1 deletion packages/remix-dev/package.json
Expand Up @@ -32,7 +32,7 @@
"@mdx-js/mdx": "^2.3.0",
"@npmcli/package-json": "^4.0.1",
"@remix-run/node": "workspace:*",
"@remix-run/router": "1.16.0",
"@remix-run/router": "1.16.1-pre.0",
"@remix-run/server-runtime": "workspace:*",
"@types/mdx": "^2.0.5",
"@vanilla-extract/integration": "^6.2.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/remix-react/package.json
Expand Up @@ -19,10 +19,10 @@
"tsc": "tsc"
},
"dependencies": {
"@remix-run/router": "1.16.0",
"@remix-run/router": "1.16.1-pre.0",
"@remix-run/server-runtime": "workspace:*",
"react-router": "6.23.0",
"react-router-dom": "6.23.0",
"react-router": "6.23.1-pre.0",
"react-router-dom": "6.23.1-pre.0",
"turbo-stream": "^2.0.0"
},
"devDependencies": {
Expand Down
13 changes: 13 additions & 0 deletions packages/remix-server-runtime/deprecations.ts
@@ -0,0 +1,13 @@
export function resourceRouteJsonWarning(
type: "loader" | "action",
routeId: string
) {
return (
"⚠️ REMIX FUTURE CHANGE: Resource routes will no longer be able to " +
"return raw JavaScript objects in v3 when Single Fetch becomes the default. " +
"You can prepare for this change at your convenience by wrapping the data " +
`returned from your \`${type}\` function in the \`${routeId}\` route with ` +
"`json()`. For instructions on making this change see " +
"https://remix.run/docs/en/v2.9.2/guides/single-fetch#resource-routes"
);
}
2 changes: 1 addition & 1 deletion packages/remix-server-runtime/package.json
Expand Up @@ -19,7 +19,7 @@
"tsc": "tsc"
},
"dependencies": {
"@remix-run/router": "1.16.0",
"@remix-run/router": "1.16.1-pre.0",
"@types/cookie": "^0.6.0",
"@web3-storage/multipart-parser": "^1.0.0",
"cookie": "^0.6.0",
Expand Down

0 comments on commit 09e81a8

Please sign in to comment.