diff --git a/.changeset/new-hornets-run.md b/.changeset/new-hornets-run.md new file mode 100644 index 0000000000..91b73c60da --- /dev/null +++ b/.changeset/new-hornets-run.md @@ -0,0 +1,9 @@ +--- +"react-router": patch +--- + +Update Single Fetch to also handle the 204 redirects used in `?_data` requests in Remix v2 + +- This allows applications to return a redirect on `.data` requests from outside the scope of React Router (i.e., an `express`/`hono` middleware) +- ⚠️ Please note that doing so relies on implementation details that are subject to change without a SemVer major release +- 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 diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 1330c291e1..305235592f 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -140,6 +140,7 @@ export const EXPRESS_SERVER = (args: { port: number; base?: string; loadContext?: Record; + customLogic?: string; }) => String.raw` import { createRequestHandler } from "@react-router/express"; @@ -166,6 +167,8 @@ export const EXPRESS_SERVER = (args: { } app.use(express.static("build/client", { maxAge: "1h" })); + ${args?.customLogic || ""} + app.all( "*", createRequestHandler({ diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index 358922905d..07216f6c2a 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -10,7 +10,14 @@ import { js, } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -import { reactRouterConfig } from "./helpers/vite.js"; +import { + EXPRESS_SERVER, + createProject, + customDev, + reactRouterConfig, + viteConfig, +} from "./helpers/vite.js"; +import getPort from "get-port"; const ISO_DATE = "2024-03-12T12:00:00.000Z"; @@ -1538,6 +1545,63 @@ test.describe("single-fetch", () => { expect(await app.getHtml("#target")).toContain("Target"); }); + test("processes redirects returned outside of react router", async ({ + page, + }) => { + let port = await getPort(); + let cwd = await createProject({ + "vite.config.js": await viteConfig.basic({ port }), + "server.mjs": EXPRESS_SERVER({ + port, + customLogic: js` + app.use(async (req, res, next) => { + if (req.url === "/page.data") { + res.status(204); + res.append('X-Remix-Status', '302'); + res.append('X-Remix-Redirect', '/target'); + res.end(); + } else { + next(); + } + }); + `, + }), + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Component() { + return Go to /page + } + `, + "app/routes/page.tsx": js` + export function loader() { + return null + } + export default function Component() { + return

Should not see me

+ } + `, + "app/routes/target.tsx": js` + export default function Component() { + return

Target

+ } + `, + }); + let stop = await customDev({ cwd, port }); + + try { + await page.goto(`http://localhost:${port}/`, { + waitUntil: "networkidle", + }); + let link = page.locator('a[href="/page"]'); + await expect(link).toHaveText("Go to /page"); + await link.click(); + await page.waitForSelector("#target"); + await expect(page.locator("#target")).toHaveText("Target"); + } finally { + stop(); + } + }); + test("processes thrown loader errors", async ({ page }) => { let fixture = await createFixture({ files: { diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index d626c987b8..278903eb8c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -56,6 +56,13 @@ interface StreamTransferProps { nonce?: string; } +// We can't use a 3xx status or else the `fetch()` would follow the redirect. +// We need to communicate the redirect back as data so we can act on it in the +// client side router. We use a 202 to avoid any automatic caching we might +// get from a 200 since a "temporary" redirect should not be cached. This lets +// the user control cache behavior via Cache-Control +export const SINGLE_FETCH_REDIRECT_STATUS = 202; + // Some status codes are not permitted to have bodies, so we want to just // treat those as "no data" instead of throwing an exception: // https://datatracker.ietf.org/doc/html/rfc9110#name-informational-1xx @@ -535,6 +542,22 @@ async function fetchAndDecodeViaTurboStream( throw new ErrorResponseImpl(404, "Not Found", true); } + // Handle non-RR redirects (i.e., from express middleware) + if (res.status === 204 && res.headers.has("X-Remix-Redirect")) { + return { + status: SINGLE_FETCH_REDIRECT_STATUS, + data: { + redirect: { + redirect: res.headers.get("X-Remix-Redirect")!, + status: Number(res.headers.get("X-Remix-Status") || "302"), + revalidate: res.headers.get("X-Remix-Revalidate") === "true", + reload: res.headers.get("X-Remix-Reload-Document") === "true", + replace: res.headers.get("X-Remix-Replace") === "true", + }, + }, + }; + } + if (NO_BODY_STATUS_CODES.has(res.status)) { let routes: { [key: string]: SingleFetchResult } = {}; // We get back just a single result for action requests - normalize that diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 5cba00bcd4..1cc006caa2 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -29,7 +29,6 @@ import { getSingleFetchRedirect, singleFetchAction, singleFetchLoaders, - SINGLE_FETCH_REDIRECT_STATUS, SERVER_NO_BODY_STATUS_CODES, } from "./single-fetch"; import { getDocumentHeaders } from "./headers"; @@ -38,7 +37,10 @@ import type { SingleFetchResult, SingleFetchResults, } from "../dom/ssr/single-fetch"; -import { SingleFetchRedirectSymbol } from "../dom/ssr/single-fetch"; +import { + SINGLE_FETCH_REDIRECT_STATUS, + SingleFetchRedirectSymbol, +} from "../dom/ssr/single-fetch"; import type { MiddlewareEnabled } from "../types/future"; export type RequestHandler = ( diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 21c2d84538..a7c21eb1b8 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -20,6 +20,7 @@ import type { } from "../dom/ssr/single-fetch"; import { NO_BODY_STATUS_CODES, + SINGLE_FETCH_REDIRECT_STATUS, SingleFetchRedirectSymbol, } from "../dom/ssr/single-fetch"; import type { AppLoadContext } from "./data"; @@ -28,13 +29,6 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; -// We can't use a 3xx status or else the `fetch()` would follow the redirect. -// We need to communicate the redirect back as data so we can act on it in the -// client side router. We use a 202 to avoid any automatic caching we might -// get from a 200 since a "temporary" redirect should not be cached. This lets -// the user control cache behavior via Cache-Control -export const SINGLE_FETCH_REDIRECT_STATUS = 202; - // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data // https://datatracker.ietf.org/doc/html/rfc9110#name-304-not-modified