From ad2983e1655880f8a4e1f88179d6b164ab757f4d Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 15 Jul 2026 13:37:53 -0700 Subject: [PATCH 1/2] feat(rsc): support subResourceIntegrity --- docs/how-to/react-server-components.md | 1 - integration/sri-test.ts | 78 ++++++++++++++++++- .../unstable.rsc-subresource-integrity.md | 1 + .../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, 126 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 6283213d99..0c410e1844 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..668a3dd7c4 --- /dev/null +++ b/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md @@ -0,0 +1 @@ +Support the `subResourceIntegrity` config option in RSC Framework Mode 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 58223e68de..a9556ef55d 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 8a7705bb81..b4463bc861 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"; @@ -161,8 +162,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`; } @@ -512,6 +511,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) { @@ -781,6 +819,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { const virtual = { routeConfig: create("unstable_rsc/routes"), + subResourceIntegrity: create("unstable_rsc/subresource-integrity"), routeDiscovery: create("unstable_rsc/route-discovery"), injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), basename: create("unstable_rsc/basename"), From ec44df3bef186e2b512e21e8acc271592fdaf544 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 15 Jul 2026 13:40:18 -0700 Subject: [PATCH 2/2] add migration guide to changefile --- .../unstable.rsc-subresource-integrity.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md b/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md index 668a3dd7c4..271c6e15d8 100644 --- a/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md +++ b/packages/react-router-dev/.changes/unstable.rsc-subresource-integrity.md @@ -1 +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, +}); +```