Skip to content

Commit 04b1a3b

Browse files
authored
Properly handle empty body response codes in single fetch requests (#12760)
1 parent ca45788 commit 04b1a3b

File tree

4 files changed

+105
-7
lines changed

4 files changed

+105
-7
lines changed

.changeset/fifty-eagles-love.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+
Properly handle status codes that cannot have a body in single fetch responses (204, etc.)

integration/single-fetch-test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,69 @@ test.describe("single-fetch", () => {
17001700
]);
17011701
});
17021702

1703+
test("does not try to encode a turbo-stream body into 204 responses", async ({
1704+
page,
1705+
}) => {
1706+
let fixture = await createFixture({
1707+
files: {
1708+
...files,
1709+
"app/routes/_index.tsx": js`
1710+
import { data, Form, useActionData, useNavigation } from "react-router";
1711+
1712+
export async function action({ request }) {
1713+
await new Promise(r => setTimeout(r, 500));
1714+
return data(null, { status: 204 });
1715+
};
1716+
1717+
export default function Index() {
1718+
const navigation = useNavigation();
1719+
const actionData = useActionData();
1720+
return (
1721+
<Form method="post">
1722+
{navigation.state === "idle" ? <p data-idle>idle</p> : <p data-active>active</p>}
1723+
<button data-submit type="submit">{actionData ?? 'no content!'}</button>
1724+
</Form>
1725+
);
1726+
}
1727+
`,
1728+
},
1729+
});
1730+
let appFixture = await createAppFixture(fixture);
1731+
1732+
let app = new PlaywrightFixture(appFixture, page);
1733+
1734+
let requests: [string, number, string][] = [];
1735+
page.on("request", async (req) => {
1736+
if (req.url().includes(".data")) {
1737+
let url = new URL(req.url());
1738+
requests.push([
1739+
req.method(),
1740+
(await req.response())!.status(),
1741+
url.pathname + url.search,
1742+
]);
1743+
}
1744+
});
1745+
1746+
// Document requests
1747+
let documentRes = await fixture.requestDocument("/?index", {
1748+
method: "post",
1749+
});
1750+
expect(documentRes.status).toBe(204);
1751+
expect(await documentRes.text()).toBe("");
1752+
1753+
// Data requests
1754+
await app.goto("/");
1755+
(await page.$("[data-submit]"))?.click();
1756+
await page.waitForSelector("[data-active]");
1757+
await page.waitForSelector("[data-idle]");
1758+
1759+
expect(await page.innerText("[data-submit]")).toEqual("no content!");
1760+
expect(requests).toEqual([
1761+
["POST", 204, "/_root.data?index"],
1762+
["GET", 200, "/_root.data"],
1763+
]);
1764+
});
1765+
17031766
test("does not try to encode a turbo-stream body into 304 responses", async () => {
17041767
let fixture = await createFixture({
17051768
files: {

packages/react-router/lib/dom/ssr/single-fetch.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,10 @@ export function singleFetchUrl(reqUrl: URL | string) {
414414
return url;
415415
}
416416

417-
async function fetchAndDecode(url: URL, init: RequestInit) {
417+
async function fetchAndDecode(
418+
url: URL,
419+
init: RequestInit
420+
): Promise<{ status: number; data: unknown }> {
418421
let res = await fetch(url, init);
419422

420423
// If this 404'd without hitting the running server (most likely in a
@@ -423,6 +426,22 @@ async function fetchAndDecode(url: URL, init: RequestInit) {
423426
throw new ErrorResponseImpl(404, "Not Found", true);
424427
}
425428

429+
// some status codes are not permitted to have bodies, so we want to just
430+
// treat those as "no data" instead of throwing an exception.
431+
// 304 is not included here because the browser should fill those responses
432+
// with the cached body content.
433+
const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]);
434+
if (NO_BODY_STATUS_CODES.has(res.status)) {
435+
if (!init.method || init.method === "GET") {
436+
// SingleFetchResults can just have no routeId keys which will result
437+
// in no data for all routes
438+
return { status: res.status, data: {} };
439+
} else {
440+
// SingleFetchResult is for a singular route and can specify no data
441+
return { status: res.status, data: { data: undefined } };
442+
}
443+
}
444+
426445
invariant(res.body, "No response body to decode");
427446

428447
try {

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ export type CreateRequestHandlerFunction = (
4242
mode?: string
4343
) => RequestHandler;
4444

45+
// Do not include a response body if the status code is one of these,
46+
// otherwise `undici` will throw an error when constructing the Response:
47+
// https://github.com/nodejs/undici/blob/bd98a6303e45d5e0d44192a93731b1defdb415f3/lib/web/fetch/response.js#L522-L528
48+
//
49+
// Specs:
50+
// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx
51+
// https://datatracker.ietf.org/doc/html/rfc9110#name-204-no-content
52+
// https://datatracker.ietf.org/doc/html/rfc9110#name-205-reset-content
53+
// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified
54+
const NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205, 304]);
55+
4556
function derive(build: ServerBuild, mode?: string) {
4657
let routes = createRoutes(build.routes);
4758
let dataRoutes = createStaticHandlerDataRoutes(build.routes, build.future);
@@ -297,9 +308,9 @@ async function handleSingleFetchRequest(
297308
let resultHeaders = new Headers(headers);
298309
resultHeaders.set("X-Remix-Response", "yes");
299310

300-
// 304 responses should not have a body
301-
if (status === 304) {
302-
return new Response(null, { status: 304, headers: resultHeaders });
311+
// Skip response body for unsupported status codes
312+
if (NO_BODY_STATUS_CODES.has(status)) {
313+
return new Response(null, { status, headers: resultHeaders });
303314
}
304315

305316
// We use a less-descriptive `text/x-script` here instead of something like
@@ -347,9 +358,9 @@ async function handleDocumentRequest(
347358

348359
let headers = getDocumentHeaders(build, context);
349360

350-
// 304 responses should not have a body or a content-type
351-
if (context.statusCode === 304) {
352-
return new Response(null, { status: 304, headers });
361+
// Skip response body for unsupported status codes
362+
if (NO_BODY_STATUS_CODES.has(context.statusCode)) {
363+
return new Response(null, { status: context.statusCode, headers });
353364
}
354365

355366
// Sanitize errors outside of development environments

0 commit comments

Comments
 (0)