diff --git a/contributors.yml b/contributors.yml index f7a29ecb06..add15b112e 100644 --- a/contributors.yml +++ b/contributors.yml @@ -293,6 +293,7 @@ - rtmann - rtzll - rubeonline +- rururux - ryanflorence - ryanhiebert - saengmotmi diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index f3eefd7963..12d6440a85 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -56,6 +56,24 @@ test.describe("redirects", () => { } `, + "app/routes/absolute.content-length.tsx": js` + import { redirect, Form } from "react-router"; + + export async function action({ request }) { + return redirect(new URL(request.url).origin + "/absolute/landing", { + headers: { 'Content-Length': '0' } + }); + }; + + export default function Component() { + return ( +
+ ); + } + `, + "app/routes/loader.external.ts": js` import { redirect } from "react-router"; export const loader = () => { @@ -157,6 +175,21 @@ test.describe("redirects", () => { expect(await app.getHtml("#increment")).toMatch("Count:1"); }); + test("redirects to absolute URLs in the app with a SPA navigation and Content-Length header", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/absolute/content-length`, true); + await app.clickElement("#increment"); + expect(await app.getHtml("#increment")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickSubmitButton("/absolute/content-length") + ); + await page.waitForSelector(`h1:has-text("Landing")`); + // No hard reload + expect(await app.getHtml("#increment")).toMatch("Count:1"); + }); + test("supports hard redirects within the app via reloadDocument", async ({ page, }) => { diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index dc38fe2764..368d21b9bd 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -1859,6 +1859,79 @@ test.describe("single-fetch", () => { ); }); + test("Strips Content-Length header from loader/action responses", async () => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/data-with-response.tsx": js` + import { useActionData, useLoaderData, data } from "react-router"; + + export function headers ({ actionHeaders, loaderHeaders, errorHeaders }) { + if ([...actionHeaders].length > 0) { + return actionHeaders; + } else { + return loaderHeaders; + } + } + + export async function action({ request }) { + let formData = await request.formData(); + return data({ + key: formData.get('key'), + }, { headers: { 'Content-Length': '0' }}); + } + + export function loader({ request }) { + return data({ + message: "DATA", + }, { headers: { 'Content-Length': '0' }}); + } + + export default function DataWithResponse() { + let data = useLoaderData(); + let actionData = useActionData(); + return ( + <> +{data.message}
+{data.date.toISOString()}
+ {actionData ?{actionData.key}
: null} + > + ) + } + `, + }, + }); + + let res = await fixture.requestSingleFetchData("/data-with-response.data"); + expect(res.headers.get("Content-Length")).toEqual(null); + expect(res.data).toStrictEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/data-with-response": { + data: { + message: "DATA", + }, + }, + }); + + let postBody = new URLSearchParams(); + postBody.set("key", "value"); + res = await fixture.requestSingleFetchData("/data-with-response.data", { + method: "post", + body: postBody, + }); + expect(res.headers.get("Content-Length")).toEqual(null); + expect(res.data).toEqual({ + data: { + key: "value", + }, + }); + }); + test("Action requests do not use _routes and do not call loaders on the server", async ({ page, }) => { diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index a1613092ee..d841966699 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -280,6 +280,12 @@ function generateSingleFetchResponse( // - https://developers.cloudflare.com/speed/optimization/content/brotli/content-compression/ resultHeaders.set("Content-Type", "text/x-script"); + // 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 + resultHeaders.delete("Content-Length"); + return new Response( encodeViaTurboStream( result,