diff --git a/.changeset/v2-headers.md b/.changeset/v2-headers.md new file mode 100644 index 00000000000..31d7f5f4a67 --- /dev/null +++ b/.changeset/v2-headers.md @@ -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. diff --git a/docs/pages/api-development-strategy.md b/docs/pages/api-development-strategy.md index ff30a4830a6..152dd1c1900 100644 --- a/docs/pages/api-development-strategy.md +++ b/docs/pages/api-development-strategy.md @@ -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 | diff --git a/docs/pages/v2.md b/docs/pages/v2.md index 8307a2c43ef..4435df72256 100644 --- a/docs/pages/v2.md +++ b/docs/pages/v2.md @@ -124,6 +124,14 @@ routes For more background on this change, see the [original "flat routes" proposal][flat-routes]. +## Route `headers` + +In Remix v2, the behavior for route `headers` functions is changing slightly. You can opt-into this new behavior ahead of time via the `future.v2_headers` flag in `remix.config.js`. + +In v1, Remix would only use the result of the leaf "rendered" route `headers` function. It was your responsibility to add a `headers` function to every potential leaf and merge in `parentHeaders` accordingly. This can get tedious quickly and is also easy to forget to add a `headers` function when you add a new route, even if you want it to just share the same headers from it's parent. + +In v2, Remix will use the deepest `headers` function that it finds in the rendered routes. This more easily allows you to share headers across routes from a common ancestor. Then as needed you can add `headers` functions to deeper routes if they require specific behavior. + ## Route `meta` In Remix v2, the signature for route `meta` functions and how Remix handles meta tags under the hood have changed. diff --git a/docs/route/headers.md b/docs/route/headers.md index b214b6fb03b..42a8972e4be 100644 --- a/docs/route/headers.md +++ b/docs/route/headers.md @@ -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", @@ -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 @@ -53,7 +58,11 @@ If we are looking at `/users/123/profile` then three routes are rendering: ``` -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. + + +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. + 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. @@ -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 a user is looking at `/users/123/profile` and `profile.tsx` does not export a `headers` function, then Remix will use the return value of `$userId.tsx`'s `headers` function. If that file doesn't export one, then it will use the result of the one in `users.tsx`, and so on. + [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 diff --git a/integration/headers-test.ts b/integration/headers-test.ts index 7521cd6a4a8..0bf306be55b 100644 --- a/integration/headers-test.ts +++ b/integration/headers-test.ts @@ -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"; @@ -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()) : []), - ]); - } - export function loader({ request }) { if (new URL(request.url).searchParams.get('throw') === "child") { throw new Response(null, { @@ -138,24 +133,18 @@ test.describe("headers export", () => { headers: { 'X-Child-Loader': 'error' }, }) } - return new Response(null, { - headers: { 'X-Child-Loader': 'success' }, - }) + return null } 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' }, - }) + return null } export default function Component() { return
} @@ -256,7 +245,6 @@ test.describe("headers export", () => { expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( JSON.stringify([ ["content-type", "text/html"], - ["x-child-loader", "success"], ["x-parent-loader", "success"], ]) ); @@ -284,8 +272,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"], ]) ); @@ -365,3 +351,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 ( + + + + + + + + + + + ); + } + `, + + "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
} + `, + + "app/routes/parent.child.jsx": js` + export async function action({ request }) { + return null; + } + + export default function Component() { return
} + `, + }, + }); + }); + + 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"]]) + ); + }); +}); diff --git a/packages/remix-dev/__tests__/create-test.ts b/packages/remix-dev/__tests__/create-test.ts index 3891972938f..15d6d7fcc59 100644 --- a/packages/remix-dev/__tests__/create-test.ts +++ b/packages/remix-dev/__tests__/create-test.ts @@ -12,6 +12,7 @@ import { errorBoundaryWarning, flatRoutesWarning, formMethodWarning, + headersWarning, metaWarning, serverModuleFormatWarning, } from "../config"; @@ -360,6 +361,8 @@ describe("the create command", () => { "\n" + metaWarning + "\n" + + headersWarning + + "\n" + serverModuleFormatWarning + "\n" + flatRoutesWarning + diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index e1f795537c7..3267b8717ef 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -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), @@ -55,6 +56,7 @@ describe("readConfig", () => { "unstable_postcss": Any, "unstable_tailwind": Any, "v2_errorBoundary": Any, + "v2_headers": Any, "v2_meta": Any, "v2_normalizeFormMethod": Any, "v2_routeConvention": Any, diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index a795c10cc75..ed90d95a1f9 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -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; @@ -442,6 +443,10 @@ export async function readConfig( warnOnce(metaWarning, "v2_meta"); } + if (!appConfig.future?.v2_headers) { + warnOnce(headersWarning, "v2_headers"); + } + let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes( appConfig.serverBuildTarget ?? "" ); @@ -738,6 +743,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, @@ -928,3 +934,9 @@ export const metaWarning = "You can prepare for this change at your convenience with the `v2_meta` future flag. " + "For instructions on making this change see " + "https://remix.run/docs/en/v1.15.0/pages/v2#meta"; + +export const headersWarning = + "⚠️ REMIX FUTURE CHANGE: The route `headers` export behavior is changing in v2. " + + "You can prepare for this change at your convenience with the `v2_headers` future flag. " + + "For instructions on making this change see " + + "https://remix.run/docs/en/v1.17.0/pages/v2#route-headers"; diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index fde03eca196..d2a2ab2f269 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -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; diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 1191e01ccfc..ea09f729b7c 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -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; diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts index e13e65aee7c..98ac4853353 100644 --- a/packages/remix-server-runtime/headers.ts +++ b/packages/remix-server-runtime/headers.ts @@ -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; + } + let headers = new Headers( routeModule.headers ? typeof routeModule.headers === "function" diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index 1ef4b85ada0..b7f0f464791 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -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,