diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index cc60961874..9c71b4b2c5 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -150,6 +150,42 @@ test.describe("redirects", () => { return

D

} `, + + "app/routes/headers.tsx": js` + import * as React from 'react'; + import { Link, Form, redirect, useLocation } from 'react-router'; + + export function action() { + return redirect('/headers?action-redirect', { + headers: { 'X-Test': 'Foo' } + }) + } + + export function loader({ request }) { + let url = new URL(request.url); + if (url.searchParams.has('redirect')) { + return redirect('/headers?loader-redirect', { + headers: { 'X-Test': 'Foo' } + }) + } + return null + } + + export default function Component() { + let location = useLocation() + return ( + <> + Redirect +
+ +
+

+ Search Params: {location.search} +

+ + ); + } + `, }, }); @@ -224,6 +260,49 @@ test.describe("redirects", () => { await page.goBack(); await page.waitForSelector("#a"); // [/a] }); + + test("maintains custom headers on redirects", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + let hasGetHeader = false; + let hasPostHeader = false; + page.on("request", async (request) => { + let extension = /^rsc/.test(templateName) ? "rsc" : "data"; + if ( + request.method() === "GET" && + request.url().endsWith(`headers.${extension}?redirect=`) + ) { + const headers = (await request.response())?.headers() || {}; + hasGetHeader = headers["x-test"] === "Foo"; + } + if ( + request.method() === "POST" && + request.url().endsWith(`headers.${extension}`) + ) { + const headers = (await request.response())?.headers() || {}; + hasPostHeader = headers["x-test"] === "Foo"; + } + }); + + await app.goto("/headers", true); + await app.clickElement("#loader-redirect"); + await expect(page.locator("#search-params")).toHaveText( + "Search Params: ?loader-redirect" + ); + expect(hasGetHeader).toBe(true); + expect(hasPostHeader).toBe(false); + + hasGetHeader = false; + hasPostHeader = false; + + await app.goto("/headers", true); + await app.clickElement("#action-redirect"); + await expect(page.locator("#search-params")).toHaveText( + "Search Params: ?action-redirect" + ); + expect(hasGetHeader).toBe(false); + expect(hasPostHeader).toBe(true); + }); }); } }); diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 21362593f5..b06267b228 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -649,13 +649,24 @@ function generateRedirectResponse( status: response.status, actionResult, }; + + // Preserve non-internal headers on the user-created redirect + let headers = new Headers(response.headers); + headers.delete("Location"); + headers.delete("X-Remix-Reload-Document"); + headers.delete("X-Remix-Replace"); + // Remove Content-Length because node:http will truncate the response body + // to match the Content-Length header, which can result in incomplete data + // if the actual encoded body is longer. + // https://nodejs.org/api/http.html#class-httpclientrequest + headers.delete("Content-Length"); + headers.set("Content-Type", "text/x-component"); + headers.set("Vary", "Content-Type"); + return generateResponse( { statusCode: SINGLE_FETCH_REDIRECT_STATUS, - headers: new Headers({ - "Content-Type": "text/x-component", - Vary: "Content-Type", - }), + headers, payload, }, { temporaryReferences } @@ -708,6 +719,12 @@ async function generateStaticContextResponse( (match) => (match as RouteMatch).route.headers ); + // Remove Content-Length because node:http will truncate the response body + // to match the Content-Length header, which can result in incomplete data + // if the actual encoded body is longer. + // https://nodejs.org/api/http.html#class-httpclientrequest + headers.delete("Content-Length"); + const baseRenderPayload: Omit = { type: "render", actionData: staticContext.actionData,