From 3d83ad4ac2d19768bf7b9f80962b76c52c1f777a Mon Sep 17 00:00:00 2001 From: dfedoryshchev <64079946+dfedoryshchev@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:32:51 +0100 Subject: [PATCH 1/9] docs: fix useLinkClickHandler defaultShouldRevalidate default description (#15322) --- packages/react-router/lib/dom/lib.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 47e66196e6..10254e1198 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -2205,7 +2205,8 @@ function useDataRouterState(hookName: DataRouterStateHook) { * for this navigation. To apply specific styles during the transition, see * {@link useViewTransitionState}. Defaults to `false`. * @param options.defaultShouldRevalidate Specify the default revalidation - * behavior for the navigation. Defaults to `true`. + * behavior for the navigation. When not specified, loaders revalidate + * according to the router's standard revalidation behavior. * @param options.mask Masked location to display in the browser instead * of the router location. Defaults to `undefined`. * @param options.useTransitions Wraps the navigation in From 6c8d945db0ed22cf5797ce45356d6123e51e7e4e Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 16 Jul 2026 13:33:51 +0000 Subject: [PATCH 2/9] chore: generate markdown docs from jsdocs --- docs/api/hooks/useLinkClickHandler.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api/hooks/useLinkClickHandler.md b/docs/api/hooks/useLinkClickHandler.md index a484f7682a..4b0e014bd0 100644 --- a/docs/api/hooks/useLinkClickHandler.md +++ b/docs/api/hooks/useLinkClickHandler.md @@ -89,7 +89,8 @@ Enables a [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/Vie ### options.defaultShouldRevalidate -Specify the default revalidation behavior for the navigation. Defaults to `true`. +Specify the default revalidation behavior for the navigation. When not specified, loaders revalidate +according to the router's standard revalidation behavior. ### options.mask From 88ded3859287e619c1dad202c6e794fff26b58f8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 16 Jul 2026 10:26:12 -0400 Subject: [PATCH 3/9] Fix change file --- .../react-router-dev/.changes/minor.development-conditions.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-router-dev/.changes/minor.development-conditions.md b/packages/react-router-dev/.changes/minor.development-conditions.md index 635e6ecf71..423b22f725 100644 --- a/packages/react-router-dev/.changes/minor.development-conditions.md +++ b/packages/react-router-dev/.changes/minor.development-conditions.md @@ -1,2 +1 @@ -- Restart `react-router dev` with `--conditions=development` when not enabled -- +Restart `react-router dev` with `--conditions=development` when not already configured From 95c7c8beb40f815ec79b49c888fe4f1b86055ade Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 16 Jul 2026 10:31:28 -0400 Subject: [PATCH 4/9] Validate change files in PR checks Assisted-By: devx/06090486-2b20-4fb8-99f0-cdf7db642619 --- scripts/pr.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/pr.ts b/scripts/pr.ts index c23cd79352..2a505670f7 100644 --- a/scripts/pr.ts +++ b/scripts/pr.ts @@ -32,6 +32,7 @@ import * as fs from "node:fs"; import * as util from "node:util"; +import { parseAllChangeFiles } from "./changes/changes.ts"; import { addPrLabels, closePr, @@ -96,6 +97,15 @@ pnpm run changes:add > Not every PR needs a change file — you can skip this step if the change is internal-only > (tests, tooling, docs)`; +const CHANGE_FILE_INVALID_COMMENT = `${CHANGE_FILE_MARKER} +### ❌ Invalid Change Files + +One or more change files are invalid. Run the following command locally for details: + +\`\`\`sh +pnpm changes:validate +\`\`\``; + const CLOSE_FEATURE_PR_COMMENT = `\ To align with our new [Open Governance](https://remix.run/blog/rr-governance) model, we are now asking that all new features go through the [Proposal/RFC process](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and that we don't open PRs until a proposal has been accepted and advanced to Stage 1. @@ -274,6 +284,22 @@ async function changeFileCheck(ctx: CheckContext): Promise { let body = CHANGE_FILE_MISSING_COMMENT; if (summaries.length > 0) { + let { valid } = parseAllChangeFiles(); + if (!valid) { + console.log("changeFileCheck: invalid change files found"); + return { + actions: [ + { + type: "upsert-sticky-comment", + marker: CHANGE_FILE_MARKER, + body: CHANGE_FILE_INVALID_COMMENT, + }, + ], + failureMessage: + "Change file validation failed - please run `pnpm changes:validate` locally for details", + }; + } + body = [ CHANGE_FILE_FOUND_COMMENT, "| Type | Change |", From 6286f904855afc245513a47ba5e4e4ffa3d63a15 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 16 Jul 2026 10:14:11 -0700 Subject: [PATCH 5/9] feat(rsc): reload stale clients after new deployments (#15318) --- integration/rsc-client-version-test.ts | 276 ++++++++++++++++++ .../.changes/unstable.rsc-client-version.md | 1 + .../config/default-rsc-entries/entry.rsc.tsx | 3 + packages/react-router-dev/rsc-types.d.ts | 5 + packages/react-router-dev/vite/rsc/plugin.ts | 58 ++++ .../.changes/unstable.rsc-client-version.md | 14 + .../react-router/__tests__/rsc/server-test.ts | 25 ++ .../react-router/lib/dom/ssr/fog-of-war.ts | 121 ++++---- packages/react-router/lib/rsc/browser.tsx | 94 +++++- packages/react-router/lib/rsc/server.rsc.ts | 24 ++ .../rsc-vite-framework/app/entry.rsc.tsx | 3 + 11 files changed, 554 insertions(+), 70 deletions(-) create mode 100644 integration/rsc-client-version-test.ts create mode 100644 packages/react-router-dev/.changes/unstable.rsc-client-version.md create mode 100644 packages/react-router/.changes/unstable.rsc-client-version.md diff --git a/integration/rsc-client-version-test.ts b/integration/rsc-client-version-test.ts new file mode 100644 index 0000000000..55b871183e --- /dev/null +++ b/integration/rsc-client-version-test.ts @@ -0,0 +1,276 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, type Page } from "@playwright/test"; + +import { + reactRouterConfig, + test, + type Files, + type TemplateName, +} from "./helpers/vite.js"; + +const js = String.raw; +const templateName = "rsc-vite-framework" as const satisfies TemplateName; + +function getFiles() { + return { + "app/root.tsx": js` + import { Link, Outlet } from "react-router"; + + export default function Root() { + return ( + + + + Go to Other + + + + + ); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Home

; + } + `, + "app/routes/other.tsx": js` + import { useLocation } from "react-router"; + + export default function Other() { + let location = useLocation(); + return ( +

+ {location.pathname + location.search + location.hash} +

+ ); + } + `, + }; +} + +function trackDocumentRequests(page: Page) { + let requests: string[] = []; + page.on("request", (request) => { + if (request.resourceType() === "document") { + requests.push(request.url()); + } + }); + return requests; +} + +async function interceptWithStaleClientVersion(page: Page) { + let manifestRequests: string[] = []; + await page.route(/\.manifest(?:\?|$)/, async (route) => { + let url = new URL(route.request().url()); + manifestRequests.push(url.href); + url.searchParams.set("version", "stale"); + await route.continue({ url: url.href }); + }); + return manifestRequests; +} + +test.describe("RSC client versions", () => { + test("derives the client version from the Vite RSC assets manifest", async ({ + page, + request, + vitePreview, + }) => { + let files: Files = async () => getFiles(); + let { cwd, port } = await vitePreview(files, templateName); + let baseUrl = `http://localhost:${port}`; + let documentRequests = trackDocumentRequests(page); + let manifestRequests: string[] = []; + page.on("request", (request) => { + if (/\.manifest(?:\?|$)/.test(request.url())) { + manifestRequests.push(request.url()); + } + }); + + await page.goto(`${baseUrl}/`); + await expect.poll(() => manifestRequests.length).toBeGreaterThan(0); + expect(manifestRequests[0]).toMatch( + /\/other\.manifest\?version=[a-f0-9]{8}$/, + ); + + let clientVersion = new URL(manifestRequests[0]).searchParams.get( + "version", + ); + let { default: assetsManifest } = await import( + pathToFileURL( + path.join(cwd, "build/server/__vite_rsc_assets_manifest.js"), + ).href + ); + let { clientVersion: manifestClientVersion, ...versionedManifestValues } = + assetsManifest; + expect(manifestClientVersion).toBe(clientVersion); + expect(clientVersion).toBe( + await getClientVersion(JSON.stringify(versionedManifestValues)), + ); + + let currentResponse = await request.get( + `${baseUrl}/other.manifest?version=${clientVersion}`, + ); + expect(currentResponse.status()).toBe(200); + + await page.getByRole("link", { name: "Go to Other" }).click(); + await expect(page.locator("[data-location]")).toHaveText( + "/other?token=abc123&ref=campaign#section1", + ); + expect(documentRequests).toHaveLength(1); + }); + + test("defers an eager discovery mismatch until navigation, then reloads the destination", async ({ + page, + vitePreview, + }) => { + let files: Files = async () => getFiles(); + let { port } = await vitePreview(files, templateName); + let baseUrl = `http://localhost:${port}`; + let documentRequests = trackDocumentRequests(page); + let manifestRequests = await interceptWithStaleClientVersion(page); + let eagerMismatch = page.waitForResponse( + (response) => + response.url().includes(".manifest") && response.status() === 204, + ); + + await page.goto(`${baseUrl}/`); + await eagerMismatch; + await expect.poll(() => manifestRequests.length).toBeGreaterThan(0); + + // An eager discovery mismatch should not disrupt the current document. + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + expect(documentRequests).toHaveLength(1); + + await page.getByRole("link", { name: "Go to Other" }).click(); + + await expect(page.locator("[data-location]")).toHaveText( + "/other?token=abc123&ref=campaign#section1", + ); + expect(page.url()).toBe( + `${baseUrl}/other?token=abc123&ref=campaign#section1`, + ); + expect(documentRequests).toHaveLength(2); + expect(documentRequests[1]).toBe( + `${baseUrl}/other?token=abc123&ref=campaign`, + ); + }); + + test("reloads when a prerendered RSC payload has a new client version", async ({ + page, + vitePreview, + }) => { + let files: Files = async () => ({ + ...getFiles(), + "react-router.config.ts": reactRouterConfig({ + ssr: false, + prerender: ["/", "/other"], + }), + "app/root.tsx": js` + import { Link, Outlet } from "react-router"; + + export default function Root() { + return ( + + + Go to Other + + + + ); + } + `, + }); + let { cwd, port } = await vitePreview(files, templateName); + let baseUrl = `http://localhost:${port}`; + let documentRequests = trackDocumentRequests(page); + + let { default: assetsManifest } = await import( + pathToFileURL( + path.join(cwd, "build/server/__vite_rsc_assets_manifest.js"), + ).href + ); + let clientVersion = assetsManifest.clientVersion as string; + let newVersion = clientVersion === "deadbeef" ? "feedface" : "deadbeef"; + + let replacedVersion = false; + await page.route(/\/other\.rsc(?:\?|$)/, async (route) => { + if (replacedVersion) { + await route.continue(); + return; + } + + replacedVersion = true; + let response = await route.fetch(); + let source = await response.text(); + expect(source).toContain(clientVersion); + await route.fulfill({ + response, + body: source.replaceAll(clientVersion, newVersion), + }); + }); + + await page.goto(`${baseUrl}/`); + let rscResponse = page.waitForResponse( + (response) => + response.url().includes("/other.rsc") && response.status() === 200, + ); + + await page.getByRole("link", { name: "Go to Other" }).click(); + await rscResponse; + + await expect(page.locator("[data-location]")).toHaveText("/other"); + expect(documentRequests).toHaveLength(2); + expect(documentRequests[1]).toBe(`${baseUrl}/other`); + }); + + test("does not reload repeatedly for the same stale client version", async ({ + page, + vitePreview, + }) => { + let files: Files = async () => getFiles(); + let { port } = await vitePreview(files, templateName); + let baseUrl = `http://localhost:${port}`; + let documentRequests = trackDocumentRequests(page); + let manifestRequests = await interceptWithStaleClientVersion(page); + let consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + let eagerMismatch = page.waitForResponse( + (response) => + response.url().includes(".manifest") && response.status() === 204, + ); + + await page.goto(`${baseUrl}/`); + await eagerMismatch; + await expect.poll(() => manifestRequests.length).toBeGreaterThan(0); + + let clientVersion = new URL(manifestRequests[0]).searchParams.get( + "version", + ); + await page.evaluate((version) => { + sessionStorage.setItem("react-router-manifest-version", version!); + }, clientVersion); + + await page.getByRole("link", { name: "Go to Other" }).click(); + await expect.poll(() => manifestRequests.length).toBeGreaterThan(1); + await expect + .poll(() => consoleErrors) + .toContain("Unable to discover routes due to manifest version mismatch."); + + expect(documentRequests).toHaveLength(1); + }); +}); + +async function getClientVersion(source: string) { + let digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(source), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 8); +} diff --git a/packages/react-router-dev/.changes/unstable.rsc-client-version.md b/packages/react-router-dev/.changes/unstable.rsc-client-version.md new file mode 100644 index 0000000000..001cc296b0 --- /dev/null +++ b/packages/react-router-dev/.changes/unstable.rsc-client-version.md @@ -0,0 +1 @@ +Add `unstable_rsc/client-version` client build version virtual module diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx index b06cca5505..aead00e7f2 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.rsc.tsx @@ -15,6 +15,7 @@ import { import routes from "virtual:react-router/unstable_rsc/routes"; import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery"; import basename from "virtual:react-router/unstable_rsc/basename"; +import clientVersion from "virtual:react-router/unstable_rsc/client-version"; import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config"; export { unstable_reactRouterServeConfig }; @@ -31,6 +32,8 @@ export function fetchServer( decodeFormState, decodeReply, loadServerAction, + // Detect stale clients after a new deployment. + clientVersion, // The incoming request. request, requestContext, diff --git a/packages/react-router-dev/rsc-types.d.ts b/packages/react-router-dev/rsc-types.d.ts index 58223e68de..22a00eb86d 100644 --- a/packages/react-router-dev/rsc-types.d.ts +++ b/packages/react-router-dev/rsc-types.d.ts @@ -35,4 +35,9 @@ declare module "virtual:react-router/unstable_rsc/route-discovery" { export default routeDiscovery; } +declare module "virtual:react-router/unstable_rsc/client-version" { + const clientVersion: string; + export default clientVersion; +} + declare module "virtual:react-router/unstable_rsc/inject-hmr-runtime" {} diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 8a7705bb81..5ee9329c31 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -134,6 +134,17 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { ).code; } + async function getClientVersion(source: string) { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(source), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 8); + } + return [ { name: "react-router/rsc", @@ -572,6 +583,52 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { } }, }, + { + name: "react-router/rsc/virtual-client-version", + resolveId(id) { + if (id === virtual.clientVersion.id) { + return virtual.clientVersion.resolvedId; + } + }, + load(id) { + if (id === virtual.clientVersion.resolvedId) { + return viteCommand === "build" + ? ` +import assetsManifest from "virtual:vite-rsc/assets-manifest"; +export default assetsManifest.clientVersion; +` + : `export default "development";`; + } + }, + generateBundle: { + order: "post", + async handler() { + if (this.environment.name !== "client") return; + + const viteRscPlugin = resolvedViteConfig.plugins.find( + (plugin) => plugin.name === "rsc:minimal", + ) as + | (Vite.Plugin & { + api?: { + manager?: { + buildAssetsManifest?: Record; + }; + }; + }) + | undefined; + const assetsManifest = + viteRscPlugin?.api?.manager?.buildAssetsManifest; + invariant(assetsManifest, "Vite RSC assets manifest not found"); + + // Add the version before the Vite RSC plugin serializes this manifest + // into the RSC and SSR builds. This lets the virtual module expose a + // build-time value without rewriting files after Vite writes them. + assetsManifest.clientVersion = await getClientVersion( + JSON.stringify(assetsManifest), + ); + }, + }, + }, { name: "react-router/rsc/hmr/inject-runtime", enforce: "pre", @@ -782,6 +839,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { const virtual = { routeConfig: create("unstable_rsc/routes"), routeDiscovery: create("unstable_rsc/route-discovery"), + clientVersion: create("unstable_rsc/client-version"), injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), basename: create("unstable_rsc/basename"), reactRouterServeConfig: create("unstable_rsc/react-router-serve-config"), diff --git a/packages/react-router/.changes/unstable.rsc-client-version.md b/packages/react-router/.changes/unstable.rsc-client-version.md new file mode 100644 index 0000000000..596756b0a7 --- /dev/null +++ b/packages/react-router/.changes/unstable.rsc-client-version.md @@ -0,0 +1,14 @@ +Detect stale RSC clients during lazy route discovery and reload the destination document + +### Migration + +Apps using the default RSC Framework entry do not need to make any changes. Apps with a custom `entry.rsc.tsx` should import the generated client version and pass it to `unstable_matchRSCServerRequest`: + +```tsx +import clientVersion from "virtual:react-router/unstable_rsc/client-version"; + +return unstable_matchRSCServerRequest({ + // ... + clientVersion, +}); +``` diff --git a/packages/react-router/__tests__/rsc/server-test.ts b/packages/react-router/__tests__/rsc/server-test.ts index fdf4d2ab32..887fed4ac4 100644 --- a/packages/react-router/__tests__/rsc/server-test.ts +++ b/packages/react-router/__tests__/rsc/server-test.ts @@ -18,15 +18,40 @@ describe("RSC server", () => { expect(response.status).toBe(400); expect(match).toBeUndefined(); }); + + test("signals a reload when the client version does not match", async () => { + let { response, match } = await matchManifestRequest( + new Request("https://remix.run/path.manifest?version=old"), + [], + "new", + ); + + expect(response.status).toBe(204); + expect(response.headers.get("X-Remix-Reload-Document")).toBe("true"); + expect(match).toBeUndefined(); + }); + + test("returns patches when the client version matches", async () => { + let { response, match } = await matchManifestRequest( + new Request("https://remix.run/path.manifest?version=current"), + [], + "current", + ); + + expect(response.status).toBe(200); + expect(match?.payload.type).toBe("manifest"); + }); }); }); async function matchManifestRequest( request: Request, routes: RSCRouteConfigEntry[], + clientVersion?: string, ) { let match: RSCMatch | undefined; let response = await matchRSCServerRequest({ + clientVersion, createTemporaryReferenceSet: () => ({}), request, routes, diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index b4857d8d9c..78016c81f6 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -241,6 +241,64 @@ export function getManifestPath( const MANIFEST_VERSION_STORAGE_KEY = "react-router-manifest-version"; +export async function handleClientVersionMismatch( + needsReload: boolean, + version: string, + errorReloadPath: string | null, +): Promise { + if (!needsReload) { + // Reset loop-detection on a successful response + try { + sessionStorage.removeItem(MANIFEST_VERSION_STORAGE_KEY); + } catch { + // Session storage unavailable + } + return false; + } + + if (!errorReloadPath) { + // No-op during eager route discovery so we will trigger a hard reload + // of the destination during the next navigation instead of reloading + // while the user is sitting on the current page. Slightly more + // disruptive on fetcher calls because we reload the current page, but + // it's better than the `React.useContext` error that occurs without + // this detection. + console.warn( + "Detected a manifest version mismatch during eager route discovery. " + + "The next navigation/fetch to an undiscovered route will result in " + + "a new document navigation to sync up with the latest manifest.", + ); + return true; + } + + try { + // This will hard reload the destination path on navigations, or the + // current path on fetcher calls + if (sessionStorage.getItem(MANIFEST_VERSION_STORAGE_KEY) === version) { + // We've already tried fixing for this version, don't try again to + // avoid loops - just let this navigation/fetch 404 + console.error( + "Unable to discover routes due to manifest version mismatch.", + ); + return true; + } + + sessionStorage.setItem(MANIFEST_VERSION_STORAGE_KEY, version); + } catch { + // Session storage unavailable + } + + window.location.href = errorReloadPath; + console.warn("Detected manifest version mismatch, reloading..."); + + // Stall here and let the browser reload and avoid triggering a flash of + // an ErrorBoundary if we threw (same thing we do in `loadRouteModule()`) + await new Promise(() => { + // check out of this hook cause the DJs never gonna re[s]olve this + }); + return true; +} + export async function fetchAndApplyManifestPatches( paths: string[], errorReloadPath: string | null, @@ -282,63 +340,18 @@ export async function fetchAndApplyManifestPatches( if (!res.ok) { throw new Error(`${res.status} ${res.statusText}`); - } else if ( - res.status === 204 && - res.headers.has("X-Remix-Reload-Document") - ) { - if (!errorReloadPath) { - // No-op during eager route discovery so we will trigger a hard reload - // of the destination during the next navigation instead of reloading - // while the user is sitting on the current page. Slightly more - // disruptive on fetcher calls because we reload the current page, but - // it's better than the `React.useContext` error that occurs without - // this detection. - console.warn( - "Detected a manifest version mismatch during eager route discovery. " + - "The next navigation/fetch to an undiscovered route will result in " + - "a new document navigation to sync up with the latest manifest.", - ); - return; - } - - try { - // This will hard reload the destination path on navigations, or the - // current path on fetcher calls - if ( - sessionStorage.getItem(MANIFEST_VERSION_STORAGE_KEY) === - manifest.version - ) { - // We've already tried fixing for this version, don' try again to - // avoid loops - just let this navigation/fetch 404 - console.error( - "Unable to discover routes due to manifest version mismatch.", - ); - return; - } - - sessionStorage.setItem(MANIFEST_VERSION_STORAGE_KEY, manifest.version); - } catch { - // Session storage unavailable - } - - window.location.href = errorReloadPath; - console.warn("Detected manifest version mismatch, reloading..."); - - // Stall here and let the browser reload and avoid triggering a flash of - // an ErrorBoundary if we threw (same thing we do in `loadRouteModule()`) - await new Promise(() => { - // check out of this hook cause the DJs never gonna re[s]olve this - }); - } else if (res.status >= 400) { - throw new Error(await res.text()); } - // Reset loop-detection on a successful response - try { - sessionStorage.removeItem(MANIFEST_VERSION_STORAGE_KEY); - } catch { - // Session storage unavailable + if ( + await handleClientVersionMismatch( + res.status === 204 && res.headers.has("X-Remix-Reload-Document"), + manifest.version, + errorReloadPath, + ) + ) { + return; } + serverPatches = (await res.json()) as AssetsManifest["routes"]; } catch (e) { if (signal?.aborted) return; diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 3ee49224d2..b1f9a75ea3 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -5,7 +5,7 @@ import { RouterProvider } from "../components"; import { RSCRouterContext } from "../context"; import { FrameworkContext, setIsHydrated } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; -import { createBrowserHistory, invariant } from "../router/history"; +import { createBrowserHistory, createPath, invariant } from "../router/history"; import type { Router as DataRouter, RouterInit } from "../router/router"; import { createRouter, @@ -44,7 +44,11 @@ import { import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries"; import type { RouteModules } from "../dom/ssr/routeModules"; import { populateRSCRouteModules } from "./route-modules"; -import { URL_LIMIT, getPathsWithAncestors } from "../dom/ssr/fog-of-war"; +import { + URL_LIMIT, + getPathsWithAncestors, + handleClientVersionMismatch, +} from "../dom/ssr/fog-of-war"; const defaultManifestPath = "/__manifest"; @@ -259,6 +263,8 @@ function createRouterFromPayload({ if (payload.type !== "render") throw new Error("Invalid payload type"); + let { clientVersion } = payload; + globalVar.__reactRouterRouteModules = globalVar.__reactRouterRouteModules ?? {}; populateRSCRouteModules(globalVar.__reactRouterRouteModules, payload.matches); @@ -303,7 +309,7 @@ function createRouterFromPayload({ basename: payload.basename, isSpaMode: false, }), - async patchRoutesOnNavigation({ path, signal }) { + async patchRoutesOnNavigation({ path, signal, fetcherKey }) { if (payload.routeDiscovery.mode === "initial") { if (!applyPatchesPromise) { applyPatchesPromise = (async () => { @@ -331,10 +337,17 @@ function createRouterFromPayload({ if (discoveredPaths.has(path)) { return; } + let { state } = globalVar.__reactRouterDataRouter; await fetchAndApplyManifestPatches( [path], createFromReadableStream, fetchImplementation, + clientVersion, + // If we're patching for a fetcher call, reload the current location. + // Otherwise prefer any ongoing navigation location. + fetcherKey + ? window.location.href + : createPath(state.navigation.location || state.location), signal, ); }, @@ -344,6 +357,7 @@ function createRouterFromPayload({ true, createFromReadableStream, fetchImplementation, + clientVersion, ), }); @@ -444,6 +458,7 @@ export function getRSCSingleFetchDataStrategy( ssr: boolean, createFromReadableStream: BrowserCreateFromReadableStreamFunction, fetchImplementation: (request: Request) => Promise, + clientVersion?: string, ): DataStrategyFunction { // TODO: Clean this up with a shared type type RSCDataRouteMatch = DataRouteMatch & { @@ -470,7 +485,12 @@ export function getRSCSingleFetchDataStrategy( }; }, // pass map into fetchAndDecode so it can add payloads - getFetchAndDecodeViaRSC(createFromReadableStream, fetchImplementation), + getFetchAndDecodeViaRSC( + getRouter, + createFromReadableStream, + fetchImplementation, + clientVersion, + ), ssr, // If the route has a component but we don't have an element, we need to hit // the server loader flow regardless of whether the client loader calls @@ -524,8 +544,10 @@ export function getRSCSingleFetchDataStrategy( } function getFetchAndDecodeViaRSC( + getRouter: () => DataRouter, createFromReadableStream: BrowserCreateFromReadableStreamFunction, fetchImplementation: (request: Request) => Promise, + clientVersion?: string, ): FetchAndDecodeFunction { return async (args: DataStrategyFunctionArgs, targetRoutes?: string[]) => { let { request, context } = args; @@ -576,6 +598,20 @@ function getFetchAndDecodeViaRSC( throw new Error("Unexpected payload type"); } + if ( + clientVersion !== undefined && + (await handleClientVersionMismatch( + payload.clientVersion !== clientVersion, + clientVersion, + createPath( + getRouter().state.navigation.location || getRouter().state.location, + ), + )) + ) { + // The page is about to be reloaded so we can keep this promise pending. + return new Promise(() => {}); + } + // Track routes rendered per-single-fetch call so we can gather them up // and patch them in together at the end. context.get(renderedRoutesContext).push(...payload.matches); @@ -681,7 +717,7 @@ export function RSCHydratedRouter({ }: RSCHydratedRouterProps) { if (payload.type !== "render") throw new Error("Invalid payload type"); - let { routeDiscovery } = payload; + let { routeDiscovery, clientVersion } = payload; let { router, routeModules } = React.useMemo( () => @@ -789,6 +825,8 @@ export function RSCHydratedRouter({ paths, createFromReadableStream, fetchImplementation, + clientVersion, + null, ); } catch (e) { console.error("Failed to fetch manifest patches", e); @@ -808,7 +846,12 @@ export function RSCHydratedRouter({ attributes: true, attributeFilter: ["data-discover", "href", "action"], }); - }, [routeDiscovery, createFromReadableStream, fetchImplementation]); + }, [ + routeDiscovery, + createFromReadableStream, + fetchImplementation, + clientVersion, + ]); const frameworkContext: FrameworkContextObject = { future: {}, @@ -983,22 +1026,30 @@ const nextPaths = new Set(); const discoveredPathsMaxSize = 1000; const discoveredPaths = new Set(); -function getManifestUrl(paths: string[]): URL | null { +function getManifestUrl( + paths: string[], + clientVersion: string | undefined, +): URL | null { if (paths.length === 0) { return null; } + let url: URL; if (paths.length === 1) { - return new URL(`${paths[0]}.manifest`, window.location.origin); + url = new URL(`${paths[0]}.manifest`, window.location.origin); + } else { + const globalVar = window as WindowWithRouterGlobals; + let basename = (globalVar.__reactRouterDataRouter.basename ?? "").replace( + /^\/|\/$/g, + "", + ); + url = new URL(`${basename}/.manifest`, window.location.origin); + url.searchParams.set("paths", paths.sort().join(",")); } - const globalVar = window as WindowWithRouterGlobals; - let basename = (globalVar.__reactRouterDataRouter.basename ?? "").replace( - /^\/|\/$/g, - "", - ); - let url = new URL(`${basename}/.manifest`, window.location.origin); - url.searchParams.set("paths", paths.sort().join(",")); + if (clientVersion !== undefined) { + url.searchParams.set("version", clientVersion); + } return url; } @@ -1007,11 +1058,13 @@ async function fetchAndApplyManifestPatches( paths: string[], createFromReadableStream: BrowserCreateFromReadableStreamFunction, fetchImplementation: (request: Request) => Promise, + clientVersion: string | undefined, + errorReloadPath: string | null, signal?: AbortSignal, ) { paths = getPathsWithAncestors(paths); - let url = getManifestUrl(paths); + let url = getManifestUrl(paths, clientVersion); if (url == null) { return; } @@ -1025,6 +1078,15 @@ async function fetchAndApplyManifestPatches( } let response = await fetchImplementation(new Request(url, { signal })); + if ( + clientVersion !== undefined && + response.status === 204 && + response.headers.has("X-Remix-Reload-Document") + ) { + await handleClientVersionMismatch(true, clientVersion, errorReloadPath); + return; + } + if (!response.body || response.status < 200 || response.status >= 300) { throw new Error("Unable to fetch new route matches from the server"); } diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index a7db3796e1..4897ff2052 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -243,6 +243,7 @@ export type RSCRenderPayload = { type: "render"; actionData: Record | null; basename: string | undefined; + clientVersion?: string; errors: Record | null; loaderData: Record; location: Location; @@ -374,6 +375,8 @@ export type RouteDiscovery = * encoding the {@link unstable_RSCPayload}. * @param opts.loadServerAction Your `react-server-dom-xyz/server`'s * `loadServerAction` function, used to load a server action by ID. + * @param opts.clientVersion A version derived from the client build output used + * to detect stale clients during lazy route discovery. * @param opts.onError An optional error handler that will be called with any * errors that occur during the request processing. * @param opts.request The [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) @@ -397,6 +400,7 @@ export async function matchRSCServerRequest({ loadServerAction, decodeAction, decodeFormState, + clientVersion, onError, request, routes, @@ -410,6 +414,7 @@ export async function matchRSCServerRequest({ decodeFormState?: DecodeFormStateFunction; requestContext?: RouterContextProvider; loadServerAction?: LoadServerActionFunction; + clientVersion?: string; onError?: (error: unknown) => void; request: Request; routes: RSCRouteConfigEntry[]; @@ -464,6 +469,7 @@ export async function matchRSCServerRequest({ generateResponse, temporaryReferences, routeDiscovery, + clientVersion, ); return response; } @@ -511,6 +517,7 @@ export async function matchRSCServerRequest({ temporaryReferences, allowedActionOrigins, routeDiscovery, + clientVersion, ); // The front end uses this to know whether a 4xx/5xx status came from app code // or never reached the origin server @@ -531,6 +538,7 @@ async function generateManifestResponse( ) => Response, temporaryReferences: unknown, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ) { let url = new URL(request.url); if (url.toString().length > URL_LIMIT) { @@ -540,6 +548,18 @@ async function generateManifestResponse( }); } + if ( + clientVersion !== undefined && + clientVersion !== url.searchParams.get("version") + ) { + return new Response(null, { + status: 204, + headers: { + "X-Remix-Reload-Document": "true", + }, + }); + } + if (routeDiscovery?.mode === "initial") { let payload: RSCManifestPayload = { type: "manifest", @@ -814,6 +834,7 @@ async function generateRenderResponse( temporaryReferences: unknown, allowedActionOrigins: string[] | undefined, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ): Promise { // If this is a RR submission, we just want the `actionData` but don't want // to call any loaders or render any components back in the response - that @@ -952,6 +973,7 @@ async function generateRenderResponse( skipRevalidation, ctx.redirect?.headers, routeDiscovery, + clientVersion, ); }, }), @@ -1050,6 +1072,7 @@ async function generateStaticContextResponse( skipRevalidation: boolean, sideEffectRedirectHeaders: Headers | undefined, routeDiscovery: RouteDiscovery | undefined, + clientVersion: string | undefined, ): Promise { statusCode = staticContext.statusCode ?? statusCode; @@ -1099,6 +1122,7 @@ async function generateStaticContextResponse( const baseRenderPayload: Omit = { type: "render", basename: staticContext.basename, + clientVersion, routeDiscovery: routeDiscovery ?? { mode: "lazy" }, actionData: staticContext.actionData, errors: staticContext.errors, diff --git a/playground/rsc-vite-framework/app/entry.rsc.tsx b/playground/rsc-vite-framework/app/entry.rsc.tsx index 40d71d4de1..c6612493b4 100644 --- a/playground/rsc-vite-framework/app/entry.rsc.tsx +++ b/playground/rsc-vite-framework/app/entry.rsc.tsx @@ -15,6 +15,7 @@ import { import routes from "virtual:react-router/unstable_rsc/routes"; import routeDiscovery from "virtual:react-router/unstable_rsc/route-discovery"; import basename from "virtual:react-router/unstable_rsc/basename"; +import clientVersion from "virtual:react-router/unstable_rsc/client-version"; import unstable_reactRouterServeConfig from "virtual:react-router/unstable_rsc/react-router-serve-config"; export { unstable_reactRouterServeConfig }; @@ -33,6 +34,8 @@ export function fetchServer( decodeFormState, decodeReply, loadServerAction, + // Detect stale clients after a new deployment. + clientVersion, // The incoming request. request, requestContext, From c26e431c608c68580841e7c156ed986919c60630 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 16 Jul 2026 10:14:44 -0700 Subject: [PATCH 6/9] feat(rsc): support CSP nonces in document rendering (#15320) --- docs/how-to/react-server-components.md | 53 +++++ docs/how-to/security.md | 9 +- integration/rsc-nonce-test.ts | 196 ++++++++++++++++++ .../.changes/unstable.rsc-nonce.md | 32 +++ .../__tests__/rsc/html-stream-test.ts | 83 ++++++++ .../lib/rsc/html-stream/server.ts | 24 ++- packages/react-router/lib/rsc/server.rsc.ts | 1 - packages/react-router/lib/rsc/server.ssr.tsx | 40 +++- 8 files changed, 424 insertions(+), 14 deletions(-) create mode 100644 integration/rsc-nonce-test.ts create mode 100644 packages/react-router/.changes/unstable.rsc-nonce.md diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index 6283213d99..2bda28376c 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -868,7 +868,60 @@ createFromReadableStream(getRSCStream()).then( ); ``` +## Content Security Policy nonces + +A [Content Security Policy][csp] can use a per-response nonce to allow the inline scripts required for RSC hydration without allowing arbitrary inline scripts. The nonce is an HTML concern, so configure it in `entry.ssr.tsx`; it does not need to be passed to `matchRSCServerRequest` or included in the RSC payload. + +In RSC Framework Mode, first run `react-router reveal entry.ssr` to create a custom SSR entry. In RSC Data Mode, update your existing SSR entry. Generate a fresh nonce for each document response, then pass it to `routeRSCServerRequest`, the `RSCStaticRouter`, and your CSP response header: + +```tsx filename=app/entry.ssr.tsx +export async function generateHTML( + request: Request, + serverResponse: Response, +): Promise { + const nonce = crypto.randomUUID(); + + const response = await routeRSCServerRequest({ + request, + serverResponse, + createFromReadableStream, + nonce, + async renderHTML(getPayload, options) { + const payload = getPayload(); + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent( + "index", + ); + + return renderHTMLToReadableStream( + , + { + ...options, + bootstrapScriptContent, + formState: await payload.formState, + signal: request.signal, + }, + ); + }, + }); + + response.headers.set( + "Content-Security-Policy", + `script-src 'self' 'nonce-${nonce}'`, + ); + return response; +} +``` + +The `nonce` option on `routeRSCServerRequest` applies the nonce to the inline scripts that transfer the RSC payload into the HTML document. Spreading its `renderHTML` options into `renderHTMLToReadableStream` applies the same nonce to scripts generated by React. Passing it to `RSCStaticRouter` makes it the default for nonce-aware components such as `` and ``. + +The default RSC Framework entry does not generate a nonce. Only generate one when your application also sends a matching CSP header. For statically prerendered pages, prefer CSP hashes or external scripts instead of a per-response nonce. + [picking-a-mode]: ../start/modes +[csp]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP [react-server-components-doc]: https://react.dev/reference/rsc/server-components [react-server-functions-doc]: https://react.dev/reference/rsc/server-functions [use-client-docs]: https://react.dev/reference/rsc/use-client diff --git a/docs/how-to/security.md b/docs/how-to/security.md index d3ec9491d1..6e9415789f 100644 --- a/docs/how-to/security.md +++ b/docs/how-to/security.md @@ -4,7 +4,7 @@ title: Security # Security -[MODES: framework] +[MODES: framework, data]

@@ -13,6 +13,8 @@ This is by no means a comprehensive guide, but React Router provides features to ## `Content-Security-Policy` +### Framework Mode without RSC + If you are implementing a [Content-Security-Policy (CSP)][csp] in your application, specifically one using the `unsafe-inline` directive, you will need to specify a [`nonce`][nonce] attribute on the inline `', + ); + }); + it("does not crash when the readable side is cancelled while a flush is pending", async () => { let rsc = createRSCStream({ keepOpen: true }); let transform = injectRSCPayload(rsc.stream); @@ -169,6 +191,67 @@ describe("injectRSCPayload", () => { }); describe("routeRSCServerRequest", () => { + it("passes a nonce to the HTML renderer and RSC payload scripts", async () => { + let renderNonce: string | undefined; + let response = await routeRSCServerRequest({ + request: new Request("https://remix.run/"), + serverResponse: new Response(createRSCStream().stream), + nonce: "test-nonce", + createFromReadableStream: async (body) => { + await readStream(body); + return { type: "render" } as never; + }, + async renderHTML(getPayload, options) { + await getPayload(); + renderNonce = options.nonce; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("hi")); + controller.close(); + }, + }); + }, + }); + + expect(renderNonce).toBe("test-nonce"); + await expect(readStream(response.body!)).resolves.toContain( + '`, ), ); } +function escapeAttribute(value: string) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + // Escape closing script tags and HTML comments in JS content. // https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements // Avoid replacing ; - nonce?: string; formState?: ReactFormState; }; diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 9551fff246..8dae5b56ad 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -46,12 +46,17 @@ export type SSRCreateFromReadableStreamFunction = ( * request, * serverResponse, * createFromReadableStream, - * async renderHTML(getPayload) { + * nonce, + * async renderHTML(getPayload, options) { * const payload = getPayload(); * * return await renderHTMLToReadableStream( - * , + * , * { + * ...options, * bootstrapScriptContent, * formState: await payload.formState, * } @@ -69,6 +74,8 @@ export type SSRCreateFromReadableStreamFunction = ( * @param opts.serverResponse A Response or partial response generated by the [RSC](https://react.dev/reference/rsc/server-components) handler containing a serialized {@link unstable_RSCPayload}. * @param opts.hydrate Whether to hydrate the server response with the RSC payload. * Defaults to `true`. + * @param opts.nonce An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) + * for inline scripts generated while rendering the HTML document. * @param opts.renderHTML A function that renders the {@link unstable_RSCPayload} to * HTML, usually using a {@link unstable_RSCStaticRouter | ``}. * @param opts.request The request to route. @@ -82,6 +89,7 @@ export async function routeRSCServerRequest({ createFromReadableStream, renderHTML, hydrate = true, + nonce, }: { request: Request; serverResponse: Response; @@ -89,11 +97,13 @@ export async function routeRSCServerRequest({ renderHTML: ( getPayload: () => DecodedPayload, options: { + nonce?: string; onError(error: unknown): string | undefined; onHeaders(headers: Headers): void; }, ) => ReadableStream | Promise>; hydrate?: boolean; + nonce?: string; }): Promise { const url = new URL(request.url); const isDataRequest = isReactServerRequest(url); @@ -213,6 +223,7 @@ export async function routeRSCServerRequest({ let statusText = serverResponse.statusText; let html = await renderHTML(getPayload, { + nonce, onError(error: unknown) { if ( typeof error === "object" && @@ -287,7 +298,7 @@ export async function routeRSCServerRequest({ } const body = html - .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(injectRSCPayload(serverResponseB.body, { nonce })) .pipeThrough(redirectTransform); return new Response(body, { status, @@ -356,6 +367,7 @@ export async function routeRSCServerRequest({ }) as unknown as DecodedPayload; }, { + nonce, onError(error: unknown) { if ( typeof error === "object" && @@ -432,7 +444,7 @@ export async function routeRSCServerRequest({ } const body = html - .pipeThrough(injectRSCPayload(serverResponseB.body)) + .pipeThrough(injectRSCPayload(serverResponseB.body, { nonce })) .pipeThrough(retryRedirectTransform); return new Response(body, { status, @@ -462,6 +474,12 @@ export interface RSCStaticRouterProps { * through from {@link unstable_routeRSCServerRequest}'s `renderHTML`. */ getPayload: () => DecodedPayload; + /** + * An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) + * used as the default for nonce-aware components such as `` and + * ``. + */ + nonce?: string; } /** @@ -480,12 +498,17 @@ export interface RSCStaticRouterProps { * request, * serverResponse, * createFromReadableStream, - * async renderHTML(getPayload) { + * nonce, + * async renderHTML(getPayload, options) { * const payload = getPayload(); * * return await renderHTMLToReadableStream( - * , + * , * { + * ...options, * bootstrapScriptContent, * formState: await payload.formState, * } @@ -499,9 +522,10 @@ export interface RSCStaticRouterProps { * @mode data * @param props Props * @param {unstable_RSCStaticRouterProps.getPayload} props.getPayload n/a + * @param {unstable_RSCStaticRouterProps.nonce} props.nonce n/a * @returns A React component that renders the {@link unstable_RSCPayload} as HTML. */ -export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { +export function RSCStaticRouter({ getPayload, nonce }: RSCStaticRouterProps) { const decoded = getPayload(); const payload = React.use(decoded); @@ -615,6 +639,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { payload.routeDiscovery.manifestPath || defaultManifestPath, }, routeModules: createRSCRouteModules(payload), + nonce, }; return ( @@ -625,7 +650,6 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { context={context} router={router} hydrate={false} - nonce={payload.nonce} /> From ce449895a37a571e9126a04fbce7284dfca48c07 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 16 Jul 2026 10:15:04 -0700 Subject: [PATCH 7/9] feat(rsc): support subResourceIntegrity (#15321) --- docs/how-to/react-server-components.md | 1 - integration/sri-test.ts | 78 ++++++++++++++++++- .../unstable.rsc-subresource-integrity.md | 19 +++++ .../config/default-rsc-entries/entry.ssr.tsx | 4 + packages/react-router-dev/rsc-types.d.ts | 5 ++ packages/react-router-dev/vite/rsc/plugin.ts | 43 +++++++++- 6 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index 2bda28376c..0fee0f9e4e 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -385,7 +385,6 @@ The following options from `react-router.config.ts` are not currently supported - `presets` - `serverBundles` - `splitRouteModules` -- `subResourceIntegrity` ## RSC Data Mode diff --git a/integration/sri-test.ts b/integration/sri-test.ts index 3ccd26b153..ec2e00cd98 100644 --- a/integration/sri-test.ts +++ b/integration/sri-test.ts @@ -1,4 +1,7 @@ -import { test, expect } from "@playwright/test"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; import { createAppFixture, @@ -6,10 +9,15 @@ import { js, } from "./helpers/create-fixture.js"; import type { AppFixture, Fixture } from "./helpers/create-fixture.js"; -import { reactRouterConfig } from "./helpers/vite.js"; +import { + type Files, + reactRouterConfig, + test, + viteConfig, +} from "./helpers/vite.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -test.describe("CSub-Resource Integrity", () => { +test.describe("Sub-Resource Integrity", () => { test.use({ javaScriptEnabled: false }); let fixture: Fixture; @@ -85,3 +93,67 @@ test.describe("CSub-Resource Integrity", () => { } }); }); + +test.describe("Sub-Resource Integrity in RSC Framework Mode (production)", () => { + test.use({ javaScriptEnabled: false }); + + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + templateName: "rsc-vite-framework", + files: { + "react-router.config.ts": reactRouterConfig({ + subResourceIntegrity: true, + }), + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test("includes an importmap with integrity hashes", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + let json = await page.locator('script[type="importmap"]').innerText(); + let importMap = JSON.parse(json); + expect(Object.keys(importMap.integrity).length).toBeGreaterThan(0); + for (let [url, value] of Object.entries(importMap.integrity)) { + expect(value).toMatch(/^sha384-/); + + let asset = await readFile( + path.join(fixture.projectDir, "build/client", url), + ); + let expected = `sha384-${createHash("sha384") + .update(asset) + .digest("base64")}`; + expect(value).toBe(expected); + } + }); +}); + +test.describe("Sub-Resource Integrity in RSC Framework Mode (development)", () => { + test("accepts the config without emitting production integrity hashes", async ({ + page, + dev, + }) => { + let files: Files = async ({ port }) => ({ + "react-router.config.ts": reactRouterConfig({ + subResourceIntegrity: true, + }), + "vite.config.ts": await viteConfig.basic({ + port, + templateName: "rsc-vite-framework", + }), + }); + + let { port } = await dev(files, "rsc-vite-framework"); + + await page.goto(`http://localhost:${port}/`); + await expect(page.getByRole("heading")).toHaveText( + "Welcome to React Router", + ); + await expect(page.locator('script[type="importmap"]')).toHaveCount(0); + expect(page.errors).toEqual([]); + }); +}); diff --git a/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md b/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md new file mode 100644 index 0000000000..271c6e15d8 --- /dev/null +++ b/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md @@ -0,0 +1,19 @@ +Support the `subResourceIntegrity` config option in RSC Framework Mode + +### Migration guide + +No changes are required when using the default RSC SSR entry. If you maintain a custom `app/entry.ssr.tsx`, import the new virtual module and pass its hashes to React's `importMap` render option: + +```diff ++import subResourceIntegrity from "virtual:react-router/unstable_rsc/subresource-integrity"; + +return renderToReadableStream(, { + ...options, + bootstrapScriptContent, + formState, ++ importMap: subResourceIntegrity ++ ? { integrity: subResourceIntegrity } ++ : undefined, + signal: request.signal, +}); +``` diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx index c30c5acac9..18fa49a75e 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.ssr.tsx @@ -4,6 +4,7 @@ import { unstable_routeRSCServerRequest as routeRSCServerRequest, unstable_RSCStaticRouter as RSCStaticRouter, } from "react-router"; +import subResourceIntegrity from "virtual:react-router/unstable_rsc/subresource-integrity"; export async function generateHTML( request: Request, @@ -31,6 +32,9 @@ export async function generateHTML( ...options, bootstrapScriptContent, formState, + importMap: subResourceIntegrity + ? { integrity: subResourceIntegrity } + : undefined, signal: request.signal, }, ); diff --git a/packages/react-router-dev/rsc-types.d.ts b/packages/react-router-dev/rsc-types.d.ts index 22a00eb86d..ce12e1bedf 100644 --- a/packages/react-router-dev/rsc-types.d.ts +++ b/packages/react-router-dev/rsc-types.d.ts @@ -15,6 +15,11 @@ declare module "virtual:react-router/unstable_rsc/ssr" { export default ssr; } +declare module "virtual:react-router/unstable_rsc/subresource-integrity" { + const subResourceIntegrity: Record | undefined; + export default subResourceIntegrity; +} + declare module "virtual:react-router/unstable_rsc/react-router-serve-config" { const unstable_reactRouterServeConfig: { publicPath: string; diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 5ee9329c31..3fb60495eb 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -1,4 +1,5 @@ import type * as Vite from "vite"; +import { createHash } from "node:crypto"; import { init as initEsModuleLexer } from "es-module-lexer"; import * as Path from "pathe"; import colors from "picocolors"; @@ -172,8 +173,6 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { if (userConfig.buildEnd) errors.push("buildEnd"); if (userConfig.presets?.length) errors.push("presets"); if (userConfig.serverBundles) errors.push("serverBundles"); - if (userConfig.subResourceIntegrity) - errors.push("subResourceIntegrity"); if (errors.length) { return `RSC Framework Mode does not currently support the following React Router config:\n${errors.map((x) => ` - ${x}`).join("\n")}\n`; } @@ -523,6 +522,45 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { } : null, + (() => { + let sri: Record | undefined; + + return { + name: "react-router/rsc/subresource-integrity", + sharedDuringBuild: true, + resolveId(id) { + if (id === virtual.subResourceIntegrity.id) { + return virtual.subResourceIntegrity.resolvedId; + } + }, + load(id) { + if (id === virtual.subResourceIntegrity.resolvedId) { + return `export default ${JSON.stringify(sri)};`; + } + }, + writeBundle(_options, bundle) { + if ( + this.environment.name !== "client" || + !config.subResourceIntegrity + ) { + return; + } + + sri = {}; + for (let output of Object.values(bundle)) { + if (!output.fileName.endsWith(".js")) { + continue; + } + + let contents = + output.type === "chunk" ? output.code : output.source; + let hash = createHash("sha384").update(contents).digest("base64"); + sri[`${resolvedViteConfig.base}${output.fileName}`] = + `sha384-${hash}`; + } + }, + } satisfies Vite.Plugin; + })(), { name: "react-router/rsc/virtual-route-config", resolveId(id) { @@ -838,6 +876,7 @@ export default assetsManifest.clientVersion; const virtual = { routeConfig: create("unstable_rsc/routes"), + subResourceIntegrity: create("unstable_rsc/subresource-integrity"), routeDiscovery: create("unstable_rsc/route-discovery"), clientVersion: create("unstable_rsc/client-version"), injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), From 971a499c3df410b98882a6fb8de0a0e76d1e21ba Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 16 Jul 2026 17:16:06 +0000 Subject: [PATCH 8/9] chore: generate markdown docs from jsdocs --- docs/api/rsc/RSCStaticRouter.md | 17 ++++++++++++++--- docs/api/rsc/matchRSCServerRequest.md | 6 ++++++ docs/api/rsc/routeRSCServerRequest.md | 16 ++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/api/rsc/RSCStaticRouter.md b/docs/api/rsc/RSCStaticRouter.md index e402d1d507..65930906d6 100644 --- a/docs/api/rsc/RSCStaticRouter.md +++ b/docs/api/rsc/RSCStaticRouter.md @@ -45,12 +45,17 @@ routeRSCServerRequest({ request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + nonce, + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( - , + , { + ...options, bootstrapScriptContent, formState: await payload.formState, } @@ -62,7 +67,7 @@ routeRSCServerRequest({ ## Signature ```tsx -function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) +function RSCStaticRouter({ getPayload, nonce }: RSCStaticRouterProps) ``` ## Props @@ -72,3 +77,9 @@ function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) A function that starts decoding of the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react-router.unstable_RSCPayload.html). Usually passed through from [`unstable_routeRSCServerRequest`](../rsc/routeRSCServerRequest)'s `renderHTML`. +### nonce + +An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) +used as the default for nonce-aware components such as `` and +``. + diff --git a/docs/api/rsc/matchRSCServerRequest.md b/docs/api/rsc/matchRSCServerRequest.md index f140225600..c2cdebc504 100644 --- a/docs/api/rsc/matchRSCServerRequest.md +++ b/docs/api/rsc/matchRSCServerRequest.md @@ -79,6 +79,7 @@ async function matchRSCServerRequest({ loadServerAction, decodeAction, decodeFormState, + clientVersion, onError, request, routes, @@ -92,6 +93,7 @@ async function matchRSCServerRequest({ decodeFormState?: DecodeFormStateFunction; requestContext?: RouterContextProvider; loadServerAction?: LoadServerActionFunction; + clientVersion?: string; onError?: (error: unknown) => void; request: Request; routes: RSCRouteConfigEntry[]; @@ -147,6 +149,10 @@ encoding the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react- Your `react-server-dom-xyz/server`'s `loadServerAction` function, used to load a server action by ID. +### opts.clientVersion + +A version derived from the client build output used to detect stale clients during lazy route discovery. + ### opts.onError An optional error handler that will be called with any errors that occur during the request processing. diff --git a/docs/api/rsc/routeRSCServerRequest.md b/docs/api/rsc/routeRSCServerRequest.md index 3f716940e6..5307f33d47 100644 --- a/docs/api/rsc/routeRSCServerRequest.md +++ b/docs/api/rsc/routeRSCServerRequest.md @@ -47,12 +47,17 @@ routeRSCServerRequest({ request, serverResponse, createFromReadableStream, - async renderHTML(getPayload) { + nonce, + async renderHTML(getPayload, options) { const payload = getPayload(); return await renderHTMLToReadableStream( - , + , { + ...options, bootstrapScriptContent, formState: await payload.formState, } @@ -70,6 +75,7 @@ async function routeRSCServerRequest({ createFromReadableStream, renderHTML, hydrate = true, + nonce, }: { request: Request; serverResponse: Response; @@ -77,11 +83,13 @@ async function routeRSCServerRequest({ renderHTML: ( getPayload: () => DecodedPayload, options: { + nonce?: string; onError(error: unknown): string | undefined; onHeaders(headers: Headers): void; }, ) => ReadableStream | Promise>; hydrate?: boolean; + nonce?: string; }): Promise ``` @@ -99,6 +107,10 @@ A Response or partial response generated by the [RSC](https://react.dev/referenc Whether to hydrate the server response with the RSC payload. Defaults to `true`. +### opts.nonce + +An optional [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) for inline scripts generated while rendering the HTML document. + ### opts.renderHTML A function that renders the [`unstable_RSCPayload`](https://api.reactrouter.com/v8/types/react-router.unstable_RSCPayload.html) to HTML, usually using a [``](../rsc/RSCStaticRouter). From 8186207e4773958a3e124a76de5565256b248134 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Thu, 16 Jul 2026 10:23:27 -0700 Subject: [PATCH 9/9] fix(rsc): preserve component metadata for client loader revalidation (#15323) --- integration/rsc/rsc-test.ts | 72 +++++++++++++++++++ .../patch.rsc-client-loader-revalidation.md | 1 + packages/react-router/lib/rsc/browser.tsx | 38 ++++------ 3 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 packages/react-router/.changes/patch.rsc-client-loader-revalidation.md diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index ed074b4737..912f7b5a5c 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -485,6 +485,11 @@ implementations.forEach((implementation) => { path: "client-route-component-props", lazy: () => import("./routes/client-route-component-props/home"), }, + { + id: "client-loader-revalidation", + path: "client-loader-revalidation", + lazy: () => import("./routes/client-loader-revalidation/home"), + }, { id: "client-error-boundary-props-client-loader", path: "client-error-boundary-props-client-loader", @@ -587,6 +592,17 @@ implementations.forEach((implementation) => { export default function RootRoute() { return ; } + + export { shouldRevalidate } from "./root.client"; + `, + "src/routes/root.client.tsx": js` + "use client"; + + export function shouldRevalidate({ currentUrl, defaultShouldRevalidate }) { + return currentUrl.pathname === "/client-loader-revalidation" + ? false + : defaultShouldRevalidate; + } `, "src/config/request-context.ts": js` @@ -1213,6 +1229,36 @@ implementations.forEach((implementation) => { } `, + "src/routes/client-loader-revalidation/home.tsx": js` + export { default, clientLoader } from "./home.client"; + `, + "src/routes/client-loader-revalidation/home.client.tsx": js` + "use client"; + + import { useRevalidator } from "react-router"; + + let count = 0; + + export async function clientLoader() { + return { count: ++count }; + } + + export default function HomeRoute({ loaderData }) { + const revalidator = useRevalidator(); + return ( +
+

{loaderData.count}

+ +
+ ); + } + `, + "src/routes/client-error-boundary-props-client-loader/home.tsx": js` export { default, clientLoader, ErrorBoundary } from "./home.client"; `, @@ -2328,6 +2374,32 @@ implementations.forEach((implementation) => { validateRSCHtml(await page.content()); }); + test("Allows client loaders to opt out of server revalidation once their component is rendered", async ({ + page, + }) => { + await page.goto( + `http://localhost:${port}/client-loader-revalidation`, + ); + await expect(page.locator("[data-client-loader-count]")).toHaveText( + "1", + ); + + const rscRequests: string[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.endsWith(".rsc")) { + rscRequests.push(url.href); + } + }); + + await page.click("[data-revalidate]"); + await expect(page.locator("[data-client-loader-count]")).toHaveText( + "2", + ); + + expect(rscRequests).toEqual([]); + }); + test("Passes props to client ErrorBoundary when error is thrown in client loader", async ({ page, }) => { diff --git a/packages/react-router/.changes/patch.rsc-client-loader-revalidation.md b/packages/react-router/.changes/patch.rsc-client-loader-revalidation.md new file mode 100644 index 0000000000..f3df2a93aa --- /dev/null +++ b/packages/react-router/.changes/patch.rsc-client-loader-revalidation.md @@ -0,0 +1 @@ +Preserve RSC route component metadata so routes with a `clientLoader` can skip unnecessary server requests once their components have rendered while still fetching missing server-rendered elements. diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index b1f9a75ea3..0daef296f5 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -453,6 +453,19 @@ function createRouterFromPayload({ const renderedRoutesContext = createContext(); +type DataRouteObjectWithManifestInfo = DataRouteObject & { + children?: DataRouteObjectWithManifestInfo[]; + hasLoader: boolean; + hasClientLoader: boolean; + hasComponent: boolean; + hasAction: boolean; + hasClientAction: boolean; +}; + +type RSCDataRouteMatch = DataRouteMatch & { + route: DataRouteObjectWithManifestInfo; +}; + export function getRSCSingleFetchDataStrategy( getRouter: () => DataRouter, ssr: boolean, @@ -460,17 +473,6 @@ export function getRSCSingleFetchDataStrategy( fetchImplementation: (request: Request) => Promise, clientVersion?: string, ): DataStrategyFunction { - // TODO: Clean this up with a shared type - type RSCDataRouteMatch = DataRouteMatch & { - route: DataRouteObject & { - hasLoader: boolean; - hasClientLoader: boolean; - hasComponent: boolean; - hasAction: boolean; - hasClientAction: boolean; - }; - }; - // create map let dataStrategy = getSingleFetchDataStrategyImpl( getRouter, @@ -479,9 +481,6 @@ export function getRSCSingleFetchDataStrategy( return { hasLoader: M.route.hasLoader, hasClientLoader: M.route.hasClientLoader, - hasComponent: M.route.hasComponent, - hasAction: M.route.hasAction, - hasClientAction: M.route.hasClientAction, }; }, // pass map into fetchAndDecode so it can add payloads @@ -497,7 +496,7 @@ export function getRSCSingleFetchDataStrategy( // `serverLoader` or not, otherwise we'll have nothing to render. (match) => { let M = match as RSCDataRouteMatch; - return M.route.hasComponent && !M.route.element; + return !M.route.hasComponent || M.route.element != null; }, ); return async (args) => @@ -892,14 +891,6 @@ export function RSCHydratedRouter({ ); } -type DataRouteObjectWithManifestInfo = DataRouteObject & { - children?: DataRouteObjectWithManifestInfo[]; - hasLoader: boolean; - hasClientLoader: boolean; - hasAction: boolean; - hasClientAction: boolean; -}; - function createRouteFromServerManifest( match: RSCRouteManifest, payload?: RSCRenderPayload, @@ -982,6 +973,7 @@ function createRouteFromServerManifest( // have a `loader` we may need to get the `element` implementation hasLoader: true, hasClientLoader: match.clientLoader != null, + hasComponent: match.hasComponent, hasAction: match.hasAction, hasClientAction: match.clientAction != null, };