Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server-runtime): v2_headers future flag for inheriting ancestor headers #6431

Merged
merged 10 commits into from
May 24, 2023
5 changes: 5 additions & 0 deletions .changeset/v2-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/server-runtime": minor
---

Added a new `future.v2_headers` future flag to opt into automatic inheriting of ancestor route `headers` functions so you do not need to export a `headers` function from every possible leaf route if you don't wish to.
1 change: 1 addition & 0 deletions docs/pages/api-development-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Here's the current future flags in Remix v1 today:
| ------------------------ | --------------------------------------------------------------------- |
| `unstable_dev` | Enable the new development server (including HMR/HDR support) |
| `v2_errorBoundary` | Combine `ErrorBoundary`/`CatchBoundary` into a single `ErrorBoundary` |
| `v2_headers` | Leverage ancestor `headers` if children do not export `headers` |
| `v2_meta` | Enable the new API for your `meta` functions |
| `v2_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method |
| `v2_routeConvention` | Enable the flat routes style of file-based routing |
Expand Down
33 changes: 31 additions & 2 deletions docs/route/headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const headers: HeadersFunction = ({
actionHeaders,
loaderHeaders,
parentHeaders,
errorHeaders,
}) => ({
"X-Stretchy-Pants": "its for fun",
"Cache-Control": "max-age=300, s-maxage=3600",
Expand All @@ -33,7 +34,11 @@ export const headers: HeadersFunction = ({

Note: `actionHeaders` & `loaderHeaders` are an instance of the [Web Fetch API][headers] `Headers` class.

Because Remix has nested routes, there's a battle of the headers to be won when nested routes match. In this case, the deepest route wins. Consider these files in the routes directory:
If an action or a loader threw a `Response` and we're rendering a boundary, any headers from the thrown `Response` will be available in `errorHeaders`. This allows you to access headers from a child loader that threw in a parent error boundary.

## Nested Routes

Because Remix has nested routes, there's a battle of the headers to be won when nested routes match. The default behavior is that Remix only leverages the resulting headers from the leaf rendered route. Consider these files in the routes directory:

```
├── users.tsx
Expand All @@ -53,7 +58,11 @@ If we are looking at `/users/123/profile` then three routes are rendering:
</Users>
```

If all three define `headers`, the deepest module wins, in this case `profile.tsx`.
If all three define `headers`, the deepest module wins, in this case `profile.tsx`. However, if your `profile.tsx` loader threw and bubbled to a boundary in `userId.tsx` - then `userId.tsx`'s `headers` function would be used as it is the leaf rendered route.

<docs-info>
We realize that it can be tedious and error-prone to have to define `headers` on every possible leaf route so we're changing the current behavior in v2 behind the [`future.v2_headers`][v2_headers] flag.
</docs-info>

We don't want surprise headers in your responses, so it's your job to merge them if you'd like. Remix passes in the `parentHeaders` to your `headers` function. So `users.tsx` headers get passed to `$userId.tsx`, and then `$userId.tsx` headers are passed to `profile.tsx` headers.

Expand Down Expand Up @@ -118,5 +127,25 @@ export default function handleRequest(

Just keep in mind that doing this will apply to _all_ document requests, but does not apply to `data` requests (for client-side transitions for example). For those, use [`handleDataRequest`][handledatarequest].

## v2 Behavior

Since it can be tedious and error-prone to define a `header` function in every single possible leaf route, we're changing the behavior slightly in v2 and you can opt-into the new behavior via the `future.v2_headers` [Future Flag][future-flags] in `remix.config.js`.

When enabling this flag, Remix will now use the deepest `headers` function it finds in the renderable matches (up to and including the boundary route if an error is present). You'll still need to handle merging together headers as shown above for any `headers` functions above this route.

This means that, re-using the example above:

```
├── users.tsx
└── users
├── $userId.tsx
└── $userId
└── profile.tsx
```

If we are looking at `/users/123/profile` and `profile.tsx` does not export a `headers` function, then we'll use the return value of `usersId.tsx`'s `headers` function. If that file doesn't export one, then w'ell use the result of the one in `users.tsx`, and so on.
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved

[headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
[handledatarequest]: ../file-conventions/entry.server
[v2_headers]: #v2-behavior
[future-flags]: ../pages/api-development-strategy
113 changes: 98 additions & 15 deletions integration/headers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ test.describe("headers export", () => {

test.beforeAll(async () => {
appFixture = await createFixture({
future: { v2_routeConvention: true, v2_errorBoundary: true },
future: {
v2_routeConvention: true,
v2_errorBoundary: true,
v2_headers: true,
},
files: {
"app/root.jsx": js`
import { json } from "@remix-run/node";
Expand Down Expand Up @@ -122,15 +126,6 @@ test.describe("headers export", () => {
`,

"app/routes/parent.child.jsx": js`
export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
return new Headers([
...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
]);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This existing test section is now testing the v2 behavior so removed headers from the child. See below for specific tests that continue to assert the v1 behavior.


export function loader({ request }) {
if (new URL(request.url).searchParams.get('throw') === "child") {
throw new Response(null, {
Expand All @@ -146,13 +141,11 @@ test.describe("headers export", () => {
export async function action({ request }) {
let fd = await request.formData();
if (fd.get('throw') === "child") {
console.log('throwing from child action')
throw new Response(null, {
status: 400,
headers: { 'X-Child-Action': 'error' },
})
}
console.log('returning from child action')
return new Response(null, {
headers: { 'X-Child-Action': 'success' },
})
Expand All @@ -171,6 +164,18 @@ test.describe("headers export", () => {

export default function Component() { return <div/> }
`,

"app/routes/parent.child-no-headers.jsx": js`
export function loader({ request }) {
return null
}

export async function action({ request }) {
return null
}

export default function Component() { return <div/> }
`,
},
});
});
Expand Down Expand Up @@ -256,7 +261,6 @@ test.describe("headers export", () => {
expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
JSON.stringify([
["content-type", "text/html"],
["x-child-loader", "success"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No more headers from children in these tests since we're using the parent headers function

["x-parent-loader", "success"],
])
);
Expand Down Expand Up @@ -284,8 +288,6 @@ test.describe("headers export", () => {
expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
JSON.stringify([
["content-type", "text/html"],
["x-child-action", "success"],
["x-child-loader", "success"],
["x-parent-loader", "success"],
])
);
Expand Down Expand Up @@ -365,3 +367,84 @@ test.describe("headers export", () => {
);
});
});

test.describe("v1 behavior (future.v2_headers=false)", () => {
let appFixture: Fixture;

test.beforeAll(async () => {
appFixture = await createFixture({
future: {
v2_routeConvention: true,
v2_errorBoundary: true,
v2_headers: false,
},
files: {
"app/root.jsx": js`
import { json } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts } from "@remix-run/react";

export const loader = () => json({});

export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
`,

"app/routes/parent.jsx": js`
export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) {
return new Headers([
...(parentHeaders ? Array.from(parentHeaders.entries()) : []),
...(actionHeaders ? Array.from(actionHeaders.entries()) : []),
...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []),
...(errorHeaders ? Array.from(errorHeaders.entries()) : []),
]);
}

export function loader({ request }) {
return new Response(null, {
headers: { 'X-Parent-Loader': 'success' },
})
}

export default function Component() { return <div/> }
`,

"app/routes/parent.child.jsx": js`
export async function action({ request }) {
return null;
}

export default function Component() { return <div/> }
`,
},
});
});

test("returns no headers when the leaf route doesn't export a header function (GET)", async () => {
let response = await appFixture.requestDocument("/parent/child");
expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
JSON.stringify([["content-type", "text/html"]])
);
});

test("returns no headers when the leaf route doesn't export a header function (POST)", async () => {
let response = await appFixture.postDocument(
"/parent/child",
new URLSearchParams()
);
expect(JSON.stringify(Array.from(response.headers.entries()))).toBe(
JSON.stringify([["content-type", "text/html"]])
);
});
});
2 changes: 2 additions & 0 deletions packages/remix-dev/__tests__/readConfig-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("readConfig", () => {
unstable_postcss: expect.any(Boolean),
unstable_tailwind: expect.any(Boolean),
v2_errorBoundary: expect.any(Boolean),
v2_headers: expect.any(Boolean),
v2_meta: expect.any(Boolean),
v2_normalizeFormMethod: expect.any(Boolean),
v2_routeConvention: expect.any(Boolean),
Expand All @@ -55,6 +56,7 @@ describe("readConfig", () => {
"unstable_postcss": Any<Boolean>,
"unstable_tailwind": Any<Boolean>,
"v2_errorBoundary": Any<Boolean>,
"v2_headers": Any<Boolean>,
"v2_meta": Any<Boolean>,
"v2_normalizeFormMethod": Any<Boolean>,
"v2_routeConvention": Any<Boolean>,
Expand Down
2 changes: 2 additions & 0 deletions packages/remix-dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface FutureConfig {
/** @deprecated Use the `tailwind` config option instead */
unstable_tailwind: boolean;
v2_errorBoundary: boolean;
v2_headers: boolean;
v2_meta: boolean;
v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
Expand Down Expand Up @@ -738,6 +739,7 @@ export async function readConfig(
unstable_postcss: appConfig.future?.unstable_postcss === true,
unstable_tailwind: appConfig.future?.unstable_tailwind === true,
v2_errorBoundary: appConfig.future?.v2_errorBoundary === true,
v2_headers: appConfig.future?.v2_headers === true,
v2_meta: appConfig.future?.v2_meta === true,
v2_normalizeFormMethod: appConfig.future?.v2_normalizeFormMethod === true,
v2_routeConvention: appConfig.future?.v2_routeConvention === true,
Expand Down
1 change: 1 addition & 0 deletions packages/remix-react/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface FutureConfig {
/** @deprecated Use the `tailwind` config option instead */
unstable_tailwind: boolean;
v2_errorBoundary: boolean;
v2_headers: boolean;
v2_meta: boolean;
v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/remix-server-runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface FutureConfig {
/** @deprecated Use the `tailwind` config option instead */
unstable_tailwind: boolean;
v2_errorBoundary: boolean;
v2_headers: boolean;
v2_meta: boolean;
v2_normalizeFormMethod: boolean;
v2_routeConvention: boolean;
Expand Down
15 changes: 14 additions & 1 deletion packages/remix-server-runtime/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export function getDocumentHeadersRR(
let routeModule = build.routes[id].module;
let loaderHeaders = context.loaderHeaders[id] || new Headers();
let actionHeaders = context.actionHeaders[id] || new Headers();

// When the future flag is enabled, use the parent headers for any route
// that doesn't have a `headers` export
if (routeModule.headers == null && build.future.v2_headers) {
let headers = parentHeaders;
prependCookies(actionHeaders, headers);
prependCookies(loaderHeaders, headers);
return headers;
}
Comment on lines +43 to +48
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to prependCookies for parenHeaders since that's what we're returning


let headers = new Headers(
routeModule.headers
? typeof routeModule.headers === "function"
Expand Down Expand Up @@ -66,7 +76,10 @@ export function getDocumentHeadersRR(
function prependCookies(parentHeaders: Headers, childHeaders: Headers): void {
let parentSetCookieString = parentHeaders.get("Set-Cookie");

if (parentSetCookieString) {
if (
parentSetCookieString &&
parentSetCookieString !== childHeaders.get("Set-Cookie")
) {
let cookies = splitCookiesString(parentSetCookieString);
cookies.forEach((cookie) => {
childHeaders.append("Set-Cookie", cookie);
Expand Down
1 change: 1 addition & 0 deletions packages/remix-testing/create-remix-stub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function createRemixStub(
unstable_postcss: false,
unstable_tailwind: false,
v2_errorBoundary: false,
v2_headers: false,
v2_meta: false,
v2_normalizeFormMethod: false,
v2_routeConvention: false,
Expand Down