Skip to content

Commit f4b6ef9

Browse files
authored
fix(react-router): remove Content-Length header from Single Fetch responses (#13902)
1 parent 6c0c0ba commit f4b6ef9

File tree

4 files changed

+115
-0
lines changed

4 files changed

+115
-0
lines changed

.changeset/twelve-seas-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Remove `Content-Length` header from Single Fetch responses

integration/redirects-test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ test.describe("redirects", () => {
6565
}
6666
`,
6767

68+
"app/routes/absolute.content-length.tsx": js`
69+
import { redirect, Form } from "react-router";
70+
export async function action({ request }) {
71+
return redirect(new URL(request.url).origin + "/absolute/landing", {
72+
headers: { 'Content-Length': '0' }
73+
});
74+
};
75+
export default function Component() {
76+
return (
77+
<Form method="post">
78+
<button type="submit">Submit</button>
79+
</Form>
80+
);
81+
}
82+
`,
83+
6884
"app/routes/loader.external.ts": js`
6985
import { redirect } from "react-router";
7086
export const loader = () => {
@@ -166,6 +182,21 @@ test.describe("redirects", () => {
166182
expect(await app.getHtml("#increment")).toMatch("Count:1");
167183
});
168184

185+
test("redirects to absolute URLs in the app with a SPA navigation and Content-Length header", async ({
186+
page,
187+
}) => {
188+
let app = new PlaywrightFixture(appFixture, page);
189+
await app.goto(`/absolute/content-length`, true);
190+
await app.clickElement("#increment");
191+
expect(await app.getHtml("#increment")).toMatch("Count:1");
192+
await app.waitForNetworkAfter(() =>
193+
app.clickSubmitButton("/absolute/content-length")
194+
);
195+
await page.waitForSelector(`h1:has-text("Landing")`);
196+
// No hard reload
197+
expect(await app.getHtml("#increment")).toMatch("Count:1");
198+
});
199+
169200
test("supports hard redirects within the app via reloadDocument", async ({
170201
page,
171202
}) => {

integration/single-fetch-test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,79 @@ test.describe("single-fetch", () => {
18961896
);
18971897
});
18981898

1899+
test("Strips Content-Length header from loader/action responses", async () => {
1900+
let fixture = await createFixture({
1901+
files: {
1902+
...files,
1903+
"app/routes/data-with-response.tsx": js`
1904+
import { useActionData, useLoaderData, data } from "react-router";
1905+
1906+
export function headers ({ actionHeaders, loaderHeaders, errorHeaders }) {
1907+
if ([...actionHeaders].length > 0) {
1908+
return actionHeaders;
1909+
} else {
1910+
return loaderHeaders;
1911+
}
1912+
}
1913+
1914+
export async function action({ request }) {
1915+
let formData = await request.formData();
1916+
return data({
1917+
key: formData.get('key'),
1918+
}, { headers: { 'Content-Length': '0' }});
1919+
}
1920+
1921+
export function loader({ request }) {
1922+
return data({
1923+
message: "DATA",
1924+
}, { headers: { 'Content-Length': '0' }});
1925+
}
1926+
1927+
export default function DataWithResponse() {
1928+
let data = useLoaderData();
1929+
let actionData = useActionData();
1930+
return (
1931+
<>
1932+
<h1 id="heading">Data</h1>
1933+
<p id="message">{data.message}</p>
1934+
<p id="date">{data.date.toISOString()}</p>
1935+
{actionData ? <p id="action-data">{actionData.key}</p> : null}
1936+
</>
1937+
)
1938+
}
1939+
`,
1940+
},
1941+
});
1942+
1943+
let res = await fixture.requestSingleFetchData("/data-with-response.data");
1944+
expect(res.headers.get("Content-Length")).toEqual(null);
1945+
expect(res.data).toStrictEqual({
1946+
root: {
1947+
data: {
1948+
message: "ROOT",
1949+
},
1950+
},
1951+
"routes/data-with-response": {
1952+
data: {
1953+
message: "DATA",
1954+
},
1955+
},
1956+
});
1957+
1958+
let postBody = new URLSearchParams();
1959+
postBody.set("key", "value");
1960+
res = await fixture.requestSingleFetchData("/data-with-response.data", {
1961+
method: "post",
1962+
body: postBody,
1963+
});
1964+
expect(res.headers.get("Content-Length")).toEqual(null);
1965+
expect(res.data).toEqual({
1966+
data: {
1967+
key: "value",
1968+
},
1969+
});
1970+
});
1971+
18991972
test("Action requests do not use _routes and do not call loaders on the server", async ({
19001973
page,
19011974
}) => {

packages/react-router/lib/server-runtime/single-fetch.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,12 @@ function generateSingleFetchResponse(
280280
// - https://developers.cloudflare.com/speed/optimization/content/brotli/content-compression/
281281
resultHeaders.set("Content-Type", "text/x-script");
282282

283+
// Remove Content-Length because node:http will truncate the response body
284+
// to match the Content-Length header, which can result in incomplete data
285+
// if the actual encoded body is longer.
286+
// https://nodejs.org/api/http.html#class-httpclientrequest
287+
resultHeaders.delete("Content-Length");
288+
283289
return new Response(
284290
encodeViaTurboStream(
285291
result,

0 commit comments

Comments
 (0)