Skip to content

Commit 8e4963f

Browse files
authored
Restore handling of 204 "soft" redirects on data requests (#13364)
1 parent ed77157 commit 8e4963f

File tree

6 files changed

+105
-10
lines changed

6 files changed

+105
-10
lines changed

.changeset/new-hornets-run.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Update Single Fetch to also handle the 204 redirects used in `?_data` requests in Remix v2
6+
7+
- This allows applications to return a redirect on `.data` requests from outside the scope of React Router (i.e., an `express`/`hono` middleware)
8+
- ⚠️ Please note that doing so relies on implementation details that are subject to change without a SemVer major release
9+
- This is primarily done to ease upgrading to Single Fetch for existing Remix v2 applications, but the recommended way to handle this is redirecting from a route middleware

integration/helpers/vite.ts

+3
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export const EXPRESS_SERVER = (args: {
140140
port: number;
141141
base?: string;
142142
loadContext?: Record<string, unknown>;
143+
customLogic?: string;
143144
}) =>
144145
String.raw`
145146
import { createRequestHandler } from "@react-router/express";
@@ -166,6 +167,8 @@ export const EXPRESS_SERVER = (args: {
166167
}
167168
app.use(express.static("build/client", { maxAge: "1h" }));
168169
170+
${args?.customLogic || ""}
171+
169172
app.all(
170173
"*",
171174
createRequestHandler({

integration/single-fetch-test.ts

+65-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ import {
1010
js,
1111
} from "./helpers/create-fixture.js";
1212
import { PlaywrightFixture } from "./helpers/playwright-fixture.js";
13-
import { reactRouterConfig } from "./helpers/vite.js";
13+
import {
14+
EXPRESS_SERVER,
15+
createProject,
16+
customDev,
17+
reactRouterConfig,
18+
viteConfig,
19+
} from "./helpers/vite.js";
20+
import getPort from "get-port";
1421

1522
const ISO_DATE = "2024-03-12T12:00:00.000Z";
1623

@@ -1538,6 +1545,63 @@ test.describe("single-fetch", () => {
15381545
expect(await app.getHtml("#target")).toContain("Target");
15391546
});
15401547

1548+
test("processes redirects returned outside of react router", async ({
1549+
page,
1550+
}) => {
1551+
let port = await getPort();
1552+
let cwd = await createProject({
1553+
"vite.config.js": await viteConfig.basic({ port }),
1554+
"server.mjs": EXPRESS_SERVER({
1555+
port,
1556+
customLogic: js`
1557+
app.use(async (req, res, next) => {
1558+
if (req.url === "/page.data") {
1559+
res.status(204);
1560+
res.append('X-Remix-Status', '302');
1561+
res.append('X-Remix-Redirect', '/target');
1562+
res.end();
1563+
} else {
1564+
next();
1565+
}
1566+
});
1567+
`,
1568+
}),
1569+
"app/routes/_index.tsx": js`
1570+
import { Link } from "react-router";
1571+
export default function Component() {
1572+
return <Link to="/page">Go to /page</Link>
1573+
}
1574+
`,
1575+
"app/routes/page.tsx": js`
1576+
export function loader() {
1577+
return null
1578+
}
1579+
export default function Component() {
1580+
return <p>Should not see me</p>
1581+
}
1582+
`,
1583+
"app/routes/target.tsx": js`
1584+
export default function Component() {
1585+
return <h1 id="target">Target</h1>
1586+
}
1587+
`,
1588+
});
1589+
let stop = await customDev({ cwd, port });
1590+
1591+
try {
1592+
await page.goto(`http://localhost:${port}/`, {
1593+
waitUntil: "networkidle",
1594+
});
1595+
let link = page.locator('a[href="/page"]');
1596+
await expect(link).toHaveText("Go to /page");
1597+
await link.click();
1598+
await page.waitForSelector("#target");
1599+
await expect(page.locator("#target")).toHaveText("Target");
1600+
} finally {
1601+
stop();
1602+
}
1603+
});
1604+
15411605
test("processes thrown loader errors", async ({ page }) => {
15421606
let fixture = await createFixture({
15431607
files: {

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

+23
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ interface StreamTransferProps {
5656
nonce?: string;
5757
}
5858

59+
// We can't use a 3xx status or else the `fetch()` would follow the redirect.
60+
// We need to communicate the redirect back as data so we can act on it in the
61+
// client side router. We use a 202 to avoid any automatic caching we might
62+
// get from a 200 since a "temporary" redirect should not be cached. This lets
63+
// the user control cache behavior via Cache-Control
64+
export const SINGLE_FETCH_REDIRECT_STATUS = 202;
65+
5966
// Some status codes are not permitted to have bodies, so we want to just
6067
// treat those as "no data" instead of throwing an exception:
6168
// https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx
@@ -535,6 +542,22 @@ async function fetchAndDecodeViaTurboStream(
535542
throw new ErrorResponseImpl(404, "Not Found", true);
536543
}
537544

545+
// Handle non-RR redirects (i.e., from express middleware)
546+
if (res.status === 204 && res.headers.has("X-Remix-Redirect")) {
547+
return {
548+
status: SINGLE_FETCH_REDIRECT_STATUS,
549+
data: {
550+
redirect: {
551+
redirect: res.headers.get("X-Remix-Redirect")!,
552+
status: Number(res.headers.get("X-Remix-Status") || "302"),
553+
revalidate: res.headers.get("X-Remix-Revalidate") === "true",
554+
reload: res.headers.get("X-Remix-Reload-Document") === "true",
555+
replace: res.headers.get("X-Remix-Replace") === "true",
556+
},
557+
},
558+
};
559+
}
560+
538561
if (NO_BODY_STATUS_CODES.has(res.status)) {
539562
let routes: { [key: string]: SingleFetchResult } = {};
540563
// We get back just a single result for action requests - normalize that

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import {
2929
getSingleFetchRedirect,
3030
singleFetchAction,
3131
singleFetchLoaders,
32-
SINGLE_FETCH_REDIRECT_STATUS,
3332
SERVER_NO_BODY_STATUS_CODES,
3433
} from "./single-fetch";
3534
import { getDocumentHeaders } from "./headers";
@@ -38,7 +37,10 @@ import type {
3837
SingleFetchResult,
3938
SingleFetchResults,
4039
} from "../dom/ssr/single-fetch";
41-
import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch";
40+
import {
41+
SINGLE_FETCH_REDIRECT_STATUS,
42+
SingleFetchRedirectSymbol,
43+
} from "../dom/ssr/single-fetch";
4244
import type { MiddlewareEnabled } from "../types/future";
4345

4446
export type RequestHandler = (

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

+1-7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
} from "../dom/ssr/single-fetch";
2121
import {
2222
NO_BODY_STATUS_CODES,
23+
SINGLE_FETCH_REDIRECT_STATUS,
2324
SingleFetchRedirectSymbol,
2425
} from "../dom/ssr/single-fetch";
2526
import type { AppLoadContext } from "./data";
@@ -28,13 +29,6 @@ import { ServerMode } from "./mode";
2829
import { getDocumentHeaders } from "./headers";
2930
import type { ServerBuild } from "./build";
3031

31-
// We can't use a 3xx status or else the `fetch()` would follow the redirect.
32-
// We need to communicate the redirect back as data so we can act on it in the
33-
// client side router. We use a 202 to avoid any automatic caching we might
34-
// get from a 200 since a "temporary" redirect should not be cached. This lets
35-
// the user control cache behavior via Cache-Control
36-
export const SINGLE_FETCH_REDIRECT_STATUS = 202;
37-
3832
// Add 304 for server side - that is not included in the client side logic
3933
// because the browser should fill those responses with the cached data
4034
// https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified

0 commit comments

Comments
 (0)