From a25cc3d6343692c41b6df3a2b38d515532e99fa8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:33:44 +0000 Subject: [PATCH 01/12] fix: support stable subresource integrity config --- src/index.ts | 2 ++ src/modify-browser-manifest.ts | 4 +++- src/react-router-config.ts | 13 ++++++++++++- tests/react-router-config.test.ts | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 651343a..c73b071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -229,6 +229,7 @@ export const pluginReactRouter = ( serverBuildFile, serverModuleFormat, serverBundles, + subResourceIntegrity, buildEnd, } = resolvedConfig; @@ -1255,6 +1256,7 @@ export const pluginReactRouter = ( routeChunkOptions, { future, + subResourceIntegrity, onManifest: (manifest, sri) => { const baseServerManifest = { ...manifest, diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 50dff2e..f43c144 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -24,6 +24,7 @@ export function createModifyBrowserManifestPlugin( routeChunkOptions?: Parameters[5], options?: { future?: { unstable_subResourceIntegrity?: boolean }; + subResourceIntegrity?: boolean; onManifest?: ( manifest: Awaited>, sri: Record | undefined @@ -103,7 +104,8 @@ export function createModifyBrowserManifestPlugin( let sri: Record | undefined; if ( routeChunkOptions?.isBuild && - options?.future?.unstable_subResourceIntegrity + (options?.subResourceIntegrity ?? + options?.future?.unstable_subResourceIntegrity) ) { const assets = typeof compilation.getAssets === 'function' diff --git a/src/react-router-config.ts b/src/react-router-config.ts index 76ad529..3798f8e 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -13,8 +13,12 @@ export type BuildEndHook = { }): void | Promise; }['bivarianceHack']; -export type Config = Omit & { +export type Config = Omit< + ReactRouterConfig, + 'buildEnd' | 'subResourceIntegrity' +> & { buildEnd?: BuildEndHook; + subResourceIntegrity?: boolean; }; type FutureConfig = { @@ -49,6 +53,7 @@ export type ResolvedReactRouterConfig = Readonly<{ serverBuildFile: NonNullable; serverBundles?: Config['serverBundles']; serverModuleFormat: NonNullable; + subResourceIntegrity: boolean; ssr: NonNullable; allowedActionOrigins: string[] | false; unstable_routeConfig: RouteConfigEntry[]; @@ -60,6 +65,7 @@ const DEFAULT_CONFIG = { buildDirectory: 'build', serverBuildFile: 'index.js', serverModuleFormat: 'esm', + subResourceIntegrity: false, ssr: true, future: { unstable_optimizeDeps: false, @@ -151,11 +157,16 @@ export const resolveReactRouterConfig = async ( ...DEFAULT_CONFIG.future, ...(userAndPresetConfigs.future ?? {}), }; + const subResourceIntegrity = + userAndPresetConfigs.subResourceIntegrity ?? + userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? + DEFAULT_CONFIG.subResourceIntegrity; let resolved: ResolvedReactRouterConfig = { ...DEFAULT_CONFIG, ...userAndPresetConfigs, future: resolvedFuture, + subResourceIntegrity, allowedActionOrigins: userAndPresetConfigs.allowedActionOrigins ?? DEFAULT_CONFIG.allowedActionOrigins, diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index ca403a8..0d5cf11 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -30,4 +30,18 @@ describe('resolveReactRouterConfig', () => { }); expect(buildEndCalls).toBe(2); }); + + it('resolves stable subresource integrity from top-level config', async () => { + const defaultResult = await resolveReactRouterConfig({}); + const enabledResult = await resolveReactRouterConfig({ + subResourceIntegrity: true, + } as any); + const futureResult = await resolveReactRouterConfig({ + future: { unstable_subResourceIntegrity: true }, + }); + + expect(defaultResult.resolved.subResourceIntegrity).toBe(false); + expect(enabledResult.resolved.subResourceIntegrity).toBe(true); + expect(futureResult.resolved.subResourceIntegrity).toBe(true); + }); }); From 300c12c0dcff32e1bf0819b569c0ca4564125fed Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:40:16 +0000 Subject: [PATCH 02/12] fix: preserve SRI precedence across presets --- src/react-router-config.ts | 32 +++++++++++++++++++++----- tests/react-router-config.test.ts | 37 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/react-router-config.ts b/src/react-router-config.ts index 3798f8e..32fb043 100644 --- a/src/react-router-config.ts +++ b/src/react-router-config.ts @@ -121,6 +121,25 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => { return configs.reduce(reducer, {}); }; +const normalizeSubResourceIntegrity = (config: Config): Config => { + const subResourceIntegrity = + config.subResourceIntegrity ?? + config.future?.unstable_subResourceIntegrity; + + if (subResourceIntegrity === undefined) { + return config; + } + + return { + ...config, + subResourceIntegrity, + future: { + ...(config.future ?? {}), + unstable_subResourceIntegrity: subResourceIntegrity, + }, + }; +}; + export const resolveReactRouterConfig = async ( reactRouterUserConfig: Config ): Promise<{ @@ -149,18 +168,19 @@ export const resolveReactRouterConfig = async ( ); const userAndPresetConfigs = mergeReactRouterConfig( - ...(presets.filter(Boolean) as Config[]), - reactRouterUserConfig + ...(presets.filter(Boolean) as Config[]).map(normalizeSubResourceIntegrity), + normalizeSubResourceIntegrity(reactRouterUserConfig) ); - const resolvedFuture: FutureConfig = { - ...DEFAULT_CONFIG.future, - ...(userAndPresetConfigs.future ?? {}), - }; const subResourceIntegrity = userAndPresetConfigs.subResourceIntegrity ?? userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? DEFAULT_CONFIG.subResourceIntegrity; + const resolvedFuture: FutureConfig = { + ...DEFAULT_CONFIG.future, + ...(userAndPresetConfigs.future ?? {}), + unstable_subResourceIntegrity: subResourceIntegrity, + }; let resolved: ResolvedReactRouterConfig = { ...DEFAULT_CONFIG, diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index 0d5cf11..c87110f 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -43,5 +43,42 @@ describe('resolveReactRouterConfig', () => { expect(defaultResult.resolved.subResourceIntegrity).toBe(false); expect(enabledResult.resolved.subResourceIntegrity).toBe(true); expect(futureResult.resolved.subResourceIntegrity).toBe(true); + expect(futureResult.resolved.future.unstable_subResourceIntegrity).toBe( + true + ); + }); + + it('lets user SRI config override preset aliases', async () => { + const disabledByFuture = await resolveReactRouterConfig({ + presets: [ + { + name: 'sri-preset', + reactRouterConfig: async () => ({ + subResourceIntegrity: true, + }), + }, + ], + future: { unstable_subResourceIntegrity: false }, + } as any); + const disabledByTopLevel = await resolveReactRouterConfig({ + presets: [ + { + name: 'sri-preset', + reactRouterConfig: async () => ({ + future: { unstable_subResourceIntegrity: true }, + }), + }, + ], + subResourceIntegrity: false, + } as any); + + expect(disabledByFuture.resolved.subResourceIntegrity).toBe(false); + expect( + disabledByFuture.resolved.future.unstable_subResourceIntegrity + ).toBe(false); + expect(disabledByTopLevel.resolved.subResourceIntegrity).toBe(false); + expect( + disabledByTopLevel.resolved.future.unstable_subResourceIntegrity + ).toBe(false); }); }); From 343f47114ae8cb31e9856f10b43af6b20b92ed6e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:59:55 +0000 Subject: [PATCH 03/12] test: narrow SRI config casts --- tests/react-router-config.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index c87110f..c9d5386 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { resolveReactRouterConfig } from '../src/react-router-config'; +import type { Config } from '../src/react-router-config'; describe('resolveReactRouterConfig', () => { it('merges presets and combines buildEnd hooks', async () => { @@ -35,7 +36,7 @@ describe('resolveReactRouterConfig', () => { const defaultResult = await resolveReactRouterConfig({}); const enabledResult = await resolveReactRouterConfig({ subResourceIntegrity: true, - } as any); + }); const futureResult = await resolveReactRouterConfig({ future: { unstable_subResourceIntegrity: true }, }); @@ -59,7 +60,7 @@ describe('resolveReactRouterConfig', () => { }, ], future: { unstable_subResourceIntegrity: false }, - } as any); + } as Config); const disabledByTopLevel = await resolveReactRouterConfig({ presets: [ { @@ -70,7 +71,7 @@ describe('resolveReactRouterConfig', () => { }, ], subResourceIntegrity: false, - } as any); + } as Config); expect(disabledByFuture.resolved.subResourceIntegrity).toBe(false); expect( From 04347ff1cc49165bc271143d0b43ad68021be300 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 20:30:37 +0000 Subject: [PATCH 04/12] chore: add SRI config changeset --- .changeset/stable-subresource-integrity.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/stable-subresource-integrity.md diff --git a/.changeset/stable-subresource-integrity.md b/.changeset/stable-subresource-integrity.md new file mode 100644 index 0000000..f36a7fb --- /dev/null +++ b/.changeset/stable-subresource-integrity.md @@ -0,0 +1,7 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Support React Router's stable `subResourceIntegrity` config and keep it in sync +with `future.unstable_subResourceIntegrity` when merging presets and user +configuration. From 2bac7be7cd1e9164f6d8876baee72a90d3b74f95 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:30:49 +0000 Subject: [PATCH 05/12] fix: use official sri asset metadata --- src/index.ts | 93 +++++++++++++++++++++++++-- src/modify-browser-manifest.ts | 91 ++++++++++++++++++-------- tests/index.test.ts | 51 ++++++++++++++- tests/modify-browser-manifest.test.ts | 70 ++++++++++++++++++++ tests/setup.ts | 5 +- 5 files changed, 278 insertions(+), 32 deletions(-) create mode 100644 tests/modify-browser-manifest.test.ts diff --git a/src/index.ts b/src/index.ts index c73b071..e2d723d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,10 @@ import { getReactRouterManifestForDev, configRoutesToRouteManifest, } from './manifest.js'; -import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; +import { + collectSubresourceIntegrity, + createModifyBrowserManifestPlugin, +} from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { getExportNames, @@ -408,6 +411,27 @@ export const pluginReactRouter = ( let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; + const updateLatestServerManifestSri = ( + sri: Record | undefined + ) => { + if (!latestServerManifest) { + return; + } + + latestServerManifest = { + ...latestServerManifest, + sri, + }; + + for (const [bundleId, manifest] of Object.entries( + latestServerManifestsByBundleId + )) { + latestServerManifestsByBundleId[bundleId] = { + ...manifest, + sri, + }; + } + }; const routeByFilePath = new Map( Object.values(routes).map(route => [ @@ -468,9 +492,46 @@ export const pluginReactRouter = ( const assetsBuildDirectory = relative(process.cwd(), outputClientPath); let clientStats: Rspack.StatsCompilation | undefined; + let clientSri: Record | undefined; + let resolveClientStatsReady: + | ((stats: Rspack.StatsCompilation | undefined) => void) + | undefined; + let clientStatsReadyResolved = false; + const clientStatsReady = new Promise( + resolve => { + resolveClientStatsReady = resolve; + } + ); + const resolveClientStatsOnce = ( + stats: Rspack.StatsCompilation | undefined + ) => { + if (clientStatsReadyResolved) { + return; + } + clientStatsReadyResolved = true; + resolveClientStatsReady?.(stats); + }; + const toClientManifestStats = ( + stats: Rspack.Stats | undefined + ): Rspack.StatsCompilation | undefined => { + return stats?.toJson({ + all: false, + assets: true, + }); + }; + api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { - clientStats = stats?.toJson(); + clientStats = toClientManifestStats(stats); + if (isBuild && subResourceIntegrity) { + clientSri = collectSubresourceIntegrity( + clientStats, + undefined, + assetPrefix + ); + updateLatestServerManifestSri(clientSri); + } + resolveClientStatsOnce(clientStats); } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -1129,6 +1190,15 @@ export const pluginReactRouter = ( }, environments: { web: { + ...(subResourceIntegrity + ? { + security: { + sri: { + enable: true, + }, + }, + } + : {}), source: { entry: { // no query needed when federation is disabled @@ -1325,8 +1395,10 @@ export const pluginReactRouter = ( /virtual\/react-router\/server-manifest(?:-([^?]+))?/ ); const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); + const manifestStats = + isBuild && subResourceIntegrity ? await clientStatsReady : clientStats; - const manifest = + const manifestBase = (isBuild && latestServerManifest ? bundleId && latestServerManifestsByBundleId[bundleId] ? latestServerManifestsByBundleId[bundleId] @@ -1335,11 +1407,24 @@ export const pluginReactRouter = ( (await getReactRouterManifestForDev( routes, pluginOptions, - clientStats, + manifestStats ?? clientStats, appDirectory, assetPrefix, routeChunkOptions )); + const manifest = + isBuild && subResourceIntegrity + ? { + ...manifestBase, + sri: + clientSri ?? + collectSubresourceIntegrity( + manifestStats, + undefined, + assetPrefix + ), + } + : manifestBase; return { code: `export default ${jsesc(manifest, { es6: true })};`, }; diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index f43c144..8e151e8 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import type { Route, PluginOptions } from './types.js'; import { rspack } from '@rsbuild/core'; import type { Rspack } from '@rsbuild/core'; @@ -9,6 +8,70 @@ import { import { combineURLs } from './plugin-utils.js'; import jsesc from 'jsesc'; +type StatsAssetWithIntegrity = { + name?: string; + integrity?: unknown; +}; + +type StatsWithIntegrity = { + assets?: StatsAssetWithIntegrity[]; +}; + +type CompilationAssetWithIntegrity = { + name: string; + info?: { + integrity?: unknown; + }; +}; + +const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; + +const toManifestAssetUrl = (assetPrefix: string, assetName: string) => { + if ( + ABSOLUTE_URL_RE.test(assetName) || + assetName.startsWith('//') || + assetName.startsWith('/') + ) { + return assetName; + } + return combineURLs(assetPrefix, assetName); +}; + +const addIntegrity = ( + sri: Record, + assetPrefix: string, + assetName: unknown, + integrity: unknown +) => { + if (typeof assetName !== 'string' || typeof integrity !== 'string') { + return; + } + sri[toManifestAssetUrl(assetPrefix, assetName)] = integrity; +}; + +export const collectSubresourceIntegrity = ( + stats: StatsWithIntegrity | undefined, + compilation: + | Pick + | { getAssets?: () => CompilationAssetWithIntegrity[] } + | undefined, + assetPrefix = '/' +): Record | undefined => { + const sri: Record = {}; + + for (const asset of stats?.assets ?? []) { + addIntegrity(sri, assetPrefix, asset.name, asset.integrity); + } + + if (typeof compilation?.getAssets === 'function') { + for (const asset of compilation.getAssets()) { + addIntegrity(sri, assetPrefix, asset.name, asset.info?.integrity); + } + } + + return Object.keys(sri).length > 0 ? sri : undefined; +}; + /** * Creates a Webpack/Rspack plugin that modifies the browser manifest * @param routes - The routes configuration @@ -101,31 +164,7 @@ export function createModifyBrowserManifestPlugin( ); } - let sri: Record | undefined; - if ( - routeChunkOptions?.isBuild && - (options?.subResourceIntegrity ?? - options?.future?.unstable_subResourceIntegrity) - ) { - const assets = - typeof compilation.getAssets === 'function' - ? compilation.getAssets() - : Object.entries(compilation.assets).map(([name, asset]) => ({ - name, - source: asset, - })); - sri = {}; - for (const asset of assets) { - if (!asset.name.endsWith('.js')) { - continue; - } - const source = asset.source.source().toString(); - const hash = createHash('sha384').update(source).digest('base64'); - sri[combineURLs(assetPrefix, asset.name)] = `sha384-${hash}`; - } - } - - options?.onManifest?.(manifest, sri); + options?.onManifest?.(manifest, undefined); callback(); } ); diff --git a/tests/index.test.ts b/tests/index.test.ts index 47c3e4b..5d9099b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,11 @@ import { createStubRsbuild } from '@scripts/test-helper'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { pluginReactRouter } from '../src'; +type ReactRouterTestGlobal = typeof globalThis & { + __reactRouterTestConfig?: unknown; +}; + describe('pluginReactRouter', () => { it('should configure basic plugin options', async () => { const rsbuild = await createStubRsbuild({ @@ -46,6 +50,25 @@ describe('pluginReactRouter', () => { expect(webConfig.output.module).toBe(true); }); + it('should enable Rsbuild SRI for the web environment when configured', async () => { + (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig = { + subResourceIntegrity: true, + }; + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + try { + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.environments?.web?.security?.sri?.enable).toBe(true); + expect(config.environments?.node?.security?.sri).toBeUndefined(); + } finally { + delete (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig; + } + }); + it('should configure node environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -59,6 +82,32 @@ describe('pluginReactRouter', () => { expect(nodeConfig.experiments.outputModule).toBe(true); }); + it('should serialize only asset stats after web compilation', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as any); + + const onAfterEnvironmentCompile = rsbuild.onAfterEnvironmentCompile as any; + const handler = onAfterEnvironmentCompile.mock.calls[0][0]; + const toJson = rstest.fn().mockReturnValue({ + assets: [], + assetsByChunkName: {}, + }); + + handler({ + environment: { name: 'web' }, + stats: { toJson }, + }); + + expect(toJson).toHaveBeenCalledTimes(1); + expect(toJson).toHaveBeenCalledWith({ + all: false, + assets: true, + }); + }); + it('should use async-node target for federation builds', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: { diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts new file mode 100644 index 0000000..311dc16 --- /dev/null +++ b/tests/modify-browser-manifest.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from '@rstest/core'; +import { collectSubresourceIntegrity } from '../src/modify-browser-manifest'; + +describe('collectSubresourceIntegrity', () => { + it('uses official integrity metadata from stats and compilation assets', () => { + const sri = collectSubresourceIntegrity( + { + assets: [ + { + name: 'static/js/entry.client.js', + integrity: 'sha384-entry', + }, + { + name: 'static/css/entry.client.css', + integrity: 'sha384-css', + }, + { + name: 'static/js/no-integrity.js', + }, + ], + }, + { + getAssets: () => [ + { + name: 'static/js/route.js', + info: { + integrity: 'sha384-route', + }, + }, + { + name: '/static/js/already-prefixed.js', + info: { + integrity: 'sha384-prefixed', + }, + }, + ], + }, + '/assets/' + ); + + expect(sri).toEqual({ + '/assets/static/js/entry.client.js': 'sha384-entry', + '/assets/static/css/entry.client.css': 'sha384-css', + '/assets/static/js/route.js': 'sha384-route', + '/static/js/already-prefixed.js': 'sha384-prefixed', + }); + }); + + it('returns undefined when Rspack does not provide integrity metadata', () => { + const sri = collectSubresourceIntegrity( + { + assets: [ + { + name: 'static/js/entry.client.js', + }, + ], + }, + { + getAssets: () => [ + { + name: 'static/js/route.js', + info: {}, + }, + ], + } + ); + + expect(sri).toBeUndefined(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a860a14..84ecd01 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -18,7 +18,10 @@ rstest.mock('jiti', () => ({ }, ]); } - return Promise.resolve({}); + return Promise.resolve( + (globalThis as { __reactRouterTestConfig?: unknown }) + .__reactRouterTestConfig ?? {} + ); }), }), })); From 8d4da870f68b04dcea119b22c26a81e7d05ba00e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:37:56 +0000 Subject: [PATCH 06/12] fix: mark browser manifests as sri aware --- src/manifest.ts | 2 +- src/modify-browser-manifest.ts | 12 +++- tests/modify-browser-manifest.test.ts | 100 +++++++++++++++++++++++++- tests/setup.ts | 13 +++- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index 961757a..322e959 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -127,7 +127,7 @@ export async function getReactRouterManifestForDev( imports: string[]; css: string[]; }; - sri?: Record; + sri?: Record | true; routes: Record; }> { const result: Record = {}; diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 8e151e8..3a02028 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -108,6 +108,12 @@ export function createModifyBrowserManifestPlugin( assetPrefix, routeChunkOptions ); + const manifestForBrowser = + routeChunkOptions?.isBuild && + (options?.subResourceIntegrity ?? + options?.future?.unstable_subResourceIntegrity) + ? { ...manifest, sri: true as const } + : manifest; const virtualManifestPath = 'static/js/virtual/react-router/browser-manifest.js'; @@ -117,7 +123,7 @@ export function createModifyBrowserManifestPlugin( .toString(); const newSource = originalSource.replace( /["'`]PLACEHOLDER["'`]/, - jsesc(manifest, { es6: true }) + jsesc(manifestForBrowser, { es6: true }) ); compilation.assets[virtualManifestPath] = { source: () => newSource, @@ -156,7 +162,7 @@ export function createModifyBrowserManifestPlugin( entryModulePath: entryJsAssets[0], }); const manifestSource = `window.__reactRouterManifest=${jsesc( - manifest, + manifestForBrowser, { es6: true } )};`; compilation.assets[manifestPath] = new rspack.sources.RawSource( @@ -164,7 +170,7 @@ export function createModifyBrowserManifestPlugin( ); } - options?.onManifest?.(manifest, undefined); + options?.onManifest?.(manifestForBrowser, undefined); callback(); } ); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 311dc16..30e8996 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -1,5 +1,14 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { collectSubresourceIntegrity } from '../src/modify-browser-manifest'; +import { + collectSubresourceIntegrity, + createModifyBrowserManifestPlugin, +} from '../src/modify-browser-manifest'; + +const BROWSER_MANIFEST_PATH = + 'static/js/virtual/react-router/browser-manifest.js'; describe('collectSubresourceIntegrity', () => { it('uses official integrity metadata from stats and compilation assets', () => { @@ -67,4 +76,93 @@ describe('collectSubresourceIntegrity', () => { expect(sri).toBeUndefined(); }); + + it('emits the browser manifest with the React Router SRI sentinel', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-browser-manifest-')); + const appDir = join(root, 'app'); + mkdirSync(appDir, { recursive: true }); + writeFileSync( + join(appDir, 'root.tsx'), + 'export default function Root() { return null; }' + ); + + try { + let emit: + | (( + compilation: any, + callback: (error?: Error) => void + ) => Promise) + | undefined; + const plugin = createModifyBrowserManifestPlugin( + { + root: { + id: 'root', + file: 'root.tsx', + path: '', + }, + }, + {}, + appDir, + '/', + { + splitRouteModules: false, + rootRouteFile: 'root.tsx', + isBuild: true, + }, + { + subResourceIntegrity: true, + onManifest: manifest => { + expect(manifest.sri).toBe(true); + }, + } + ); + + plugin.apply({ + hooks: { + emit: { + tapAsync: (_name: string, handler: typeof emit) => { + emit = handler; + }, + }, + }, + } as any); + + const compilation = { + assets: { + [BROWSER_MANIFEST_PATH]: { + source: () => 'window.__reactRouterManifest="PLACEHOLDER";', + }, + }, + getStats: () => ({ + toJson: () => ({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + root: ['static/js/root.js'], + }, + }), + }), + }; + + await new Promise((resolve, reject) => { + emit?.(compilation, error => { + if (error) { + reject(error); + return; + } + resolve(); + }).catch(reject); + }); + + expect(compilation.assets[BROWSER_MANIFEST_PATH].source()).toContain( + "'sri':true" + ); + + const buildManifestAsset = Object.entries(compilation.assets).find( + ([name]) => /^static\/js\/manifest-[a-f0-9]+\.js$/.test(name) + ); + expect(buildManifestAsset?.[1].source()).toContain("'sri':true"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 84ecd01..46cdf6e 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,13 @@ import * as fs from 'node:fs'; -import { rstest } from '@rstest/core'; +import { afterEach, rstest } from '@rstest/core'; + +type ReactRouterTestGlobal = typeof globalThis & { + __reactRouterTestConfig?: unknown; +}; + +afterEach(() => { + delete (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig; +}); // Mock the file system rstest.mock('node:fs', { spy: true }); @@ -19,8 +27,7 @@ rstest.mock('jiti', () => ({ ]); } return Promise.resolve( - (globalThis as { __reactRouterTestConfig?: unknown }) - .__reactRouterTestConfig ?? {} + (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig ?? {} ); }), }), From c2e229eab31ab439b11e7f0d02353c411ec72adb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:44:09 +0000 Subject: [PATCH 07/12] fix: preserve browser manifest sri callback --- src/modify-browser-manifest.ts | 9 +++++++-- tests/modify-browser-manifest.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 3a02028..0f1a646 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -90,7 +90,7 @@ export function createModifyBrowserManifestPlugin( subResourceIntegrity?: boolean; onManifest?: ( manifest: Awaited>, - sri: Record | undefined + sri: Record | true | undefined ) => void; } ) { @@ -114,6 +114,11 @@ export function createModifyBrowserManifestPlugin( options?.future?.unstable_subResourceIntegrity) ? { ...manifest, sri: true as const } : manifest; + const sri = + manifestForBrowser.sri === true + ? collectSubresourceIntegrity(stats, compilation, assetPrefix) ?? + true + : undefined; const virtualManifestPath = 'static/js/virtual/react-router/browser-manifest.js'; @@ -170,7 +175,7 @@ export function createModifyBrowserManifestPlugin( ); } - options?.onManifest?.(manifestForBrowser, undefined); + options?.onManifest?.(manifestForBrowser, sri); callback(); } ); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 30e8996..2b7fb20 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -93,6 +93,7 @@ describe('collectSubresourceIntegrity', () => { callback: (error?: Error) => void ) => Promise) | undefined; + let callbackSri: Record | true | undefined; const plugin = createModifyBrowserManifestPlugin( { root: { @@ -111,8 +112,9 @@ describe('collectSubresourceIntegrity', () => { }, { subResourceIntegrity: true, - onManifest: manifest => { + onManifest: (manifest, sri) => { expect(manifest.sri).toBe(true); + callbackSri = sri; }, } ); @@ -156,6 +158,7 @@ describe('collectSubresourceIntegrity', () => { expect(compilation.assets[BROWSER_MANIFEST_PATH].source()).toContain( "'sri':true" ); + expect(callbackSri).toBe(true); const buildManifestAsset = Object.entries(compilation.assets).find( ([name]) => /^static\/js\/manifest-[a-f0-9]+\.js$/.test(name) From d487910757b7db09adb73cec8b9bf3cb2fea776c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:46:29 +0000 Subject: [PATCH 08/12] docs: document sri opt in --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65a0a18..2f623ce 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,12 @@ export default { */ serverBundles: async ({ branch }) => branch[0]?.id ?? "main", + /** + * Enable Subresource Integrity for browser assets. + * @default false + */ + subResourceIntegrity: true, + /** * Hook called after the build completes. */ @@ -283,10 +289,17 @@ If no configuration is provided, the following defaults will be used: ssr: true, buildDirectory: 'build', appDirectory: 'app', - basename: '/' + basename: '/', + subResourceIntegrity: false } ``` +Subresource Integrity is disabled by default. Enable it with +`subResourceIntegrity: true` in `react-router.config.*` when the deployed app +should emit integrity metadata for browser scripts. The legacy +`future.unstable_subResourceIntegrity` flag is still accepted and is normalized +to the stable option. + ### Route Configuration Routes can be defined in `app/routes.ts` using the helper functions from `@react-router/dev/routes`: From ec405f0ab7563f32646ebdc993df78478ac8c6f4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:53:21 +0000 Subject: [PATCH 09/12] refactor: simplify sri manifest wiring --- src/index.ts | 13 ++++++------- src/modify-browser-manifest.ts | 8 +++++--- tests/index.test.ts | 14 +++++--------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index e2d723d..dd72fbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -493,9 +493,9 @@ export const pluginReactRouter = ( let clientStats: Rspack.StatsCompilation | undefined; let clientSri: Record | undefined; - let resolveClientStatsReady: - | ((stats: Rspack.StatsCompilation | undefined) => void) - | undefined; + let resolveClientStatsReady: ( + stats: Rspack.StatsCompilation | undefined + ) => void = () => {}; let clientStatsReadyResolved = false; const clientStatsReady = new Promise( resolve => { @@ -509,16 +509,15 @@ export const pluginReactRouter = ( return; } clientStatsReadyResolved = true; - resolveClientStatsReady?.(stats); + resolveClientStatsReady(stats); }; const toClientManifestStats = ( stats: Rspack.Stats | undefined - ): Rspack.StatsCompilation | undefined => { - return stats?.toJson({ + ): Rspack.StatsCompilation | undefined => + stats?.toJson({ all: false, assets: true, }); - }; api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 0f1a646..d0407b1 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -108,14 +108,16 @@ export function createModifyBrowserManifestPlugin( assetPrefix, routeChunkOptions ); - const manifestForBrowser = + const shouldUseSri = routeChunkOptions?.isBuild && (options?.subResourceIntegrity ?? - options?.future?.unstable_subResourceIntegrity) + options?.future?.unstable_subResourceIntegrity); + const manifestForBrowser = + shouldUseSri ? { ...manifest, sri: true as const } : manifest; const sri = - manifestForBrowser.sri === true + shouldUseSri ? collectSubresourceIntegrity(stats, compilation, assetPrefix) ?? true : undefined; diff --git a/tests/index.test.ts b/tests/index.test.ts index 5d9099b..f02c3e4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -58,15 +58,11 @@ describe('pluginReactRouter', () => { rsbuildConfig: {}, }); - try { - rsbuild.addPlugins([pluginReactRouter()]); - const config = await rsbuild.unwrapConfig(); - - expect(config.environments?.web?.security?.sri?.enable).toBe(true); - expect(config.environments?.node?.security?.sri).toBeUndefined(); - } finally { - delete (globalThis as ReactRouterTestGlobal).__reactRouterTestConfig; - } + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.environments?.web?.security?.sri?.enable).toBe(true); + expect(config.environments?.node?.security?.sri).toBeUndefined(); }); it('should configure node environment correctly', async () => { From 7d5f0ccd536edc99f748f2de1af78764ea2a2e02 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:58:09 +0000 Subject: [PATCH 10/12] test: deslop sri test fixtures --- tests/index.test.ts | 22 +++++++++++-- tests/modify-browser-manifest.test.ts | 45 ++++++++++++++++++++------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/tests/index.test.ts b/tests/index.test.ts index f02c3e4..18033b2 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -5,6 +5,23 @@ import { pluginReactRouter } from '../src'; type ReactRouterTestGlobal = typeof globalThis & { __reactRouterTestConfig?: unknown; }; +type PluginSetupApi = Parameters< + NonNullable['setup']> +>[0]; +type EnvironmentCompileHandler = (args: { + environment: { name: string }; + stats: { + toJson: () => { + assets: never[]; + assetsByChunkName: Record; + }; + }; +}) => void; +type MockEnvironmentCompileHook = { + mock: { + calls: Array<[EnvironmentCompileHandler]>; + }; +}; describe('pluginReactRouter', () => { it('should configure basic plugin options', async () => { @@ -83,9 +100,10 @@ describe('pluginReactRouter', () => { rsbuildConfig: {}, }); const plugin = pluginReactRouter(); - await plugin.setup(rsbuild as any); + await plugin.setup(rsbuild as PluginSetupApi); - const onAfterEnvironmentCompile = rsbuild.onAfterEnvironmentCompile as any; + const onAfterEnvironmentCompile = + rsbuild.onAfterEnvironmentCompile as MockEnvironmentCompileHook; const handler = onAfterEnvironmentCompile.mock.calls[0][0]; const toJson = rstest.fn().mockReturnValue({ assets: [], diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 2b7fb20..339e1f9 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -10,6 +10,29 @@ import { const BROWSER_MANIFEST_PATH = 'static/js/virtual/react-router/browser-manifest.js'; +type ManifestAsset = { + source: () => string; +}; +type TestCompilation = { + assets: Record; + getStats: () => { + toJson: () => { + assetsByChunkName: Record; + }; + }; +}; +type EmitHandler = ( + compilation: TestCompilation, + callback: (error?: Error) => void +) => Promise; +type TestCompiler = { + hooks: { + emit: { + tapAsync: (name: string, handler: EmitHandler) => void; + }; + }; +}; + describe('collectSubresourceIntegrity', () => { it('uses official integrity metadata from stats and compilation assets', () => { const sri = collectSubresourceIntegrity( @@ -87,12 +110,7 @@ describe('collectSubresourceIntegrity', () => { ); try { - let emit: - | (( - compilation: any, - callback: (error?: Error) => void - ) => Promise) - | undefined; + let emit: EmitHandler | undefined; let callbackSri: Record | true | undefined; const plugin = createModifyBrowserManifestPlugin( { @@ -119,17 +137,18 @@ describe('collectSubresourceIntegrity', () => { } ); - plugin.apply({ + const compiler: TestCompiler = { hooks: { emit: { - tapAsync: (_name: string, handler: typeof emit) => { + tapAsync: (_name, handler) => { emit = handler; }, }, }, - } as any); + }; + plugin.apply(compiler as Parameters[0]); - const compilation = { + const compilation: TestCompilation = { assets: { [BROWSER_MANIFEST_PATH]: { source: () => 'window.__reactRouterManifest="PLACEHOLDER";', @@ -145,8 +164,12 @@ describe('collectSubresourceIntegrity', () => { }), }; + if (!emit) { + throw new Error('Expected manifest plugin to register an emit hook.'); + } + await new Promise((resolve, reject) => { - emit?.(compilation, error => { + emit(compilation, error => { if (error) { reject(error); return; From 0a5699086244b81ad92607d32bba9311fdb94a91 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:29:12 +0000 Subject: [PATCH 11/12] fix: remove duplicate sri option --- src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3026cea..ce38aba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1324,8 +1324,6 @@ export const pluginReactRouter = ( assetPrefix, routeChunkOptions, { - subResourceIntegrity: - resolvedConfigWithRoutes.subResourceIntegrity, future, subResourceIntegrity, onManifest: (manifest, sri) => { From 84b4a5e2b6f370573d2d4c27f37cadd87eae15ae Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:14:19 +0000 Subject: [PATCH 12/12] chore: simplify sri manifest handling --- src/modify-browser-manifest.ts | 28 ++++++++++---- tests/modify-browser-manifest.test.ts | 54 ++++++++++++++++++--------- tests/react-router-config.test.ts | 14 +++++-- 3 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index c0cee76..4d6bb24 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -24,6 +24,12 @@ type CompilationAssetWithIntegrity = { }; }; +type CompilationWithIntegrityAssets = + | { + getAssets?: () => readonly CompilationAssetWithIntegrity[]; + } + | Pick; + const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; const toManifestAssetUrl = (assetPrefix: string, assetName: string) => { @@ -51,10 +57,7 @@ const addIntegrity = ( export const collectSubresourceIntegrity = ( stats: StatsWithIntegrity | undefined, - compilation: - | Pick - | { getAssets?: () => CompilationAssetWithIntegrity[] } - | undefined, + compilation: CompilationWithIntegrityAssets | undefined, assetPrefix = '/' ): Record | undefined => { const sri: Record = {}; @@ -64,7 +67,9 @@ export const collectSubresourceIntegrity = ( } if (typeof compilation?.getAssets === 'function') { - for (const asset of compilation.getAssets()) { + const assets = + compilation.getAssets() as readonly CompilationAssetWithIntegrity[]; + for (const asset of assets) { addIntegrity(sri, assetPrefix, asset.name, asset.info?.integrity); } } @@ -72,6 +77,12 @@ export const collectSubresourceIntegrity = ( return Object.keys(sri).length > 0 ? sri : undefined; }; +const getManifestStats = (compilation: Rspack.Compilation) => + compilation.getStats().toJson({ + all: false, + assets: true, + }); + /** * Creates a Webpack/Rspack plugin that modifies the browser manifest * @param routes - The routes configuration @@ -99,7 +110,7 @@ export function createModifyBrowserManifestPlugin( compiler.hooks.emit.tapAsync( 'ModifyBrowserManifest', async (compilation: Rspack.Compilation, callback) => { - const stats = compilation.getStats().toJson(); + const stats = getManifestStats(compilation); const manifest = await getReactRouterManifestForDev( routes, pluginOptions, @@ -108,10 +119,11 @@ export function createModifyBrowserManifestPlugin( assetPrefix, routeChunkOptions ); - const shouldUseSri = + const shouldUseSri = Boolean( routeChunkOptions?.isBuild && (options?.subResourceIntegrity ?? - options?.future?.unstable_subResourceIntegrity); + options?.future?.unstable_subResourceIntegrity) + ); const manifestForBrowser = shouldUseSri ? { ...manifest, sri: true as const } : manifest; diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts index 339e1f9..25b07bb 100644 --- a/tests/modify-browser-manifest.test.ts +++ b/tests/modify-browser-manifest.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; import { collectSubresourceIntegrity, createModifyBrowserManifestPlugin, @@ -13,12 +13,18 @@ const BROWSER_MANIFEST_PATH = type ManifestAsset = { source: () => string; }; +type StatsJsonOptions = { + all: false; + assets: true; +}; +type TestStats = { + assets?: Array<{ name?: string; integrity?: unknown }>; + assetsByChunkName: Record; +}; type TestCompilation = { assets: Record; getStats: () => { - toJson: () => { - assetsByChunkName: Record; - }; + toJson: (options: StatsJsonOptions) => TestStats; }; }; type EmitHandler = ( @@ -33,6 +39,20 @@ type TestCompiler = { }; }; +const runEmit = ( + emit: EmitHandler, + compilation: TestCompilation +): Promise => + new Promise((resolve, reject) => { + emit(compilation, error => { + if (error) { + reject(error); + return; + } + resolve(); + }).catch(reject); + }); + describe('collectSubresourceIntegrity', () => { it('uses official integrity metadata from stats and compilation assets', () => { const sri = collectSubresourceIntegrity( @@ -112,6 +132,12 @@ describe('collectSubresourceIntegrity', () => { try { let emit: EmitHandler | undefined; let callbackSri: Record | true | undefined; + const toJson = rstest.fn((_options: StatsJsonOptions) => ({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + root: ['static/js/root.js'], + }, + })); const plugin = createModifyBrowserManifestPlugin( { root: { @@ -155,12 +181,7 @@ describe('collectSubresourceIntegrity', () => { }, }, getStats: () => ({ - toJson: () => ({ - assetsByChunkName: { - 'entry.client': ['static/js/entry.client.js'], - root: ['static/js/root.js'], - }, - }), + toJson, }), }; @@ -168,14 +189,11 @@ describe('collectSubresourceIntegrity', () => { throw new Error('Expected manifest plugin to register an emit hook.'); } - await new Promise((resolve, reject) => { - emit(compilation, error => { - if (error) { - reject(error); - return; - } - resolve(); - }).catch(reject); + await runEmit(emit, compilation); + + expect(toJson).toHaveBeenCalledWith({ + all: false, + assets: true, }); expect(compilation.assets[BROWSER_MANIFEST_PATH].source()).toContain( diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts index b460d3a..ba34506 100644 --- a/tests/react-router-config.test.ts +++ b/tests/react-router-config.test.ts @@ -70,7 +70,7 @@ describe('resolveReactRouterConfig', () => { }); it('lets user SRI config override preset aliases', async () => { - const disabledByFuture = await resolveReactRouterConfig({ + const disablesPresetSriWithFuture = { presets: [ { name: 'sri-preset', @@ -80,8 +80,8 @@ describe('resolveReactRouterConfig', () => { }, ], future: { unstable_subResourceIntegrity: false }, - } as Config); - const disabledByTopLevel = await resolveReactRouterConfig({ + } satisfies Config; + const disablesPresetSriWithTopLevel = { presets: [ { name: 'sri-preset', @@ -91,8 +91,14 @@ describe('resolveReactRouterConfig', () => { }, ], subResourceIntegrity: false, - } as Config); + } satisfies Config; + const disabledByFuture = await resolveReactRouterConfig( + disablesPresetSriWithFuture + ); + const disabledByTopLevel = await resolveReactRouterConfig( + disablesPresetSriWithTopLevel + ); expect(disabledByFuture.resolved.subResourceIntegrity).toBe(false); expect( disabledByFuture.resolved.future.unstable_subResourceIntegrity