Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twelve-seas-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Remove Content-Length Header from Single Fetch Responses to Prevent Errors
31 changes: 31 additions & 0 deletions integration/redirects-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ 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 (
<Form method="post">
<button type="submit">Submit</button>
</Form>
);
}
`,

"app/routes/loader.external.ts": js`
import { redirect } from "react-router";
export const loader = () => {
Expand Down Expand Up @@ -166,6 +182,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,
}) => {
Expand Down
73 changes: 73 additions & 0 deletions integration/single-fetch-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,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 (
<>
<h1 id="heading">Data</h1>
<p id="message">{data.message}</p>
<p id="date">{data.date.toISOString()}</p>
{actionData ? <p id="action-data">{actionData.key}</p> : 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,
}) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/lib/server-runtime/single-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down