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..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 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"),