From e0c37bf20c9286181c0855bfd7d33ff717bb7612 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 21:17:21 -0700 Subject: [PATCH 1/6] fix: retain content-addressed development CSS assets --- .changeset/css-preload-restoration.md | 5 + src/dev-css-assets.ts | 39 +++++++ src/dev-generation.ts | 45 +++++++- src/dev-hmr.ts | 40 ++++--- src/dev-runtime-controller.ts | 30 ++++- src/manifest-assets.ts | 5 +- src/modify-browser-manifest.ts | 2 + tests/dev-css-assets.test.ts | 85 ++++++++++++++ tests/dev-generation-css.test.ts | 99 ++++++++++++++++ tests/dev-hmr.test.ts | 49 +++++++- tests/dev-runtime-controller.test.ts | 26 ++++- tests/development-manifest-hashes.test.ts | 5 +- .../integration/css-preload-test.ts | 107 ++++++++++++++++++ 13 files changed, 504 insertions(+), 33 deletions(-) create mode 100644 .changeset/css-preload-restoration.md create mode 100644 src/dev-css-assets.ts create mode 100644 tests/dev-css-assets.test.ts create mode 100644 tests/react-router-framework/integration/css-preload-test.ts diff --git a/.changeset/css-preload-restoration.md b/.changeset/css-preload-restoration.md new file mode 100644 index 00000000..ce80a5f8 --- /dev/null +++ b/.changeset/css-preload-restoration.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Serve development manifest stylesheets through retained content-addressed assets so preloaded CSS cannot revive stale bytes after an exact source restoration. Publish committed CSS manifests through the HMR-idle queue and replay the last committed manifest on reconnect. diff --git a/src/dev-css-assets.ts b/src/dev-css-assets.ts new file mode 100644 index 00000000..508da107 --- /dev/null +++ b/src/dev-css-assets.ts @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto'; +import type { Rspack } from '@rsbuild/core'; +import { + getManifestAssetType, + type ReactRouterManifestStats, +} from './manifest-assets.js'; + +export const stripDevCssVersion = (url: string): string => + url.replace(/\.__react_router_css_[a-f0-9]{64}\.css(?=[?#]|$)/, ''); + +export const versionDevCssAssets = ( + compilation: Pick, + stats: ReactRouterManifestStats +): void => { + const names = new Set([ + ...Object.values(stats.assetsByChunkName ?? {}).flat(), + ...Object.values(stats.entrypointFilesByName ?? {}).flat(), + ]); + const urls: Record = {}; + for (const name of names) { + if (getManifestAssetType(name, stats.assetTypesByName) !== 'css') continue; + const asset = compilation.getAsset(name); + if (!asset) throw new Error(`[react-router] Missing CSS asset ${name}`); + // Asset metadata can predate processAssets transforms; version final bytes. + const version = createHash('sha256') + .update(asset.source.buffer()) + .digest('hex'); + const bareName = name.replace(/[?#].*$/, ''); + const alias = `${bareName}.__react_router_css_${version}.css`; + // A distinct path keeps Rspack HMR from removing React-owned links. + // The same directory preserves relative CSS URLs. Development output + // retention keeps old manifest URLs serving their original bytes. + if (!compilation.getAsset(alias)) { + compilation.emitAsset(alias, asset.source, asset.info); + } + urls[name] = alias + name.slice(bareName.length); + } + stats.cssUrlsByName = urls; +}; diff --git a/src/dev-generation.ts b/src/dev-generation.ts index c4338a8a..1f0107bc 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -1,3 +1,4 @@ +import { stripDevCssVersion } from './dev-css-assets.js'; import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; import * as EffectDeferred from 'effect/Deferred'; import * as Effect from 'effect/Effect'; @@ -63,6 +64,7 @@ export type ReactRouterDevRuntime = { ) => Promise<'committed' | 'ignored' | 'retry-node'>; /** Node identity actually retained by the last successful generation. */ getCommittedNodeIdentity: () => DevCompilationIdentity | undefined; + getCommittedManifest: () => ReactRouterDevManifestSet[string] | undefined; failAttempt: (error: Error) => void; load: (entryName?: string) => Promise; close: (error?: Error) => void; @@ -84,11 +86,11 @@ const collectManifestCssAssetOwnership = ( ): Set => { const ownership = new Set(); for (const asset of manifest.entry?.css ?? []) { - ownership.add(`entry\0${asset}`); + ownership.add(`entry\0${stripDevCssVersion(asset)}`); } for (const [routeId, route] of Object.entries(manifest.routes ?? {})) { for (const asset of route.css ?? []) { - ownership.add(`route\0${routeId}\0${asset}`); + ownership.add(`route\0${routeId}\0${stripDevCssVersion(asset)}`); } } return ownership; @@ -118,6 +120,23 @@ const hasRemovedCssAssetOwnership = ( return false; }; +const hasCssManifestChanges = ( + previous: ReactRouterDevManifestSet, + next: ReactRouterDevManifestSet +): boolean => { + const css = (manifests: ReactRouterDevManifestSet) => + Object.entries(manifests) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, manifest]) => [ + name, + manifest.entry?.css ?? [], + Object.entries(manifest.routes ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, route]) => [id, route.css ?? []]), + ]); + return JSON.stringify(css(previous)) !== JSON.stringify(css(next)); +}; + const hasAddedCssAssetOwnership = ( previous: ReactRouterDevManifestSet, next: ReactRouterDevManifestSet @@ -520,8 +539,15 @@ export const createReactRouterDevRuntime = ({ previous.web.manifestsByEntryName, manifestsByEntryName ); + const cssManifestChanged = + !!previous && + webChanged && + hasCssManifestChanges( + previous.web.manifestsByEntryName, + manifestsByEntryName + ); const cssOnlyWebManifestChange = - (cssAssetsRemoved || cssAssetsAdded) && + cssManifestChanged && hasOnlyCssAssetOwnershipChanges( previous.web.manifestsByEntryName, manifestsByEntryName @@ -587,6 +613,8 @@ export const createReactRouterDevRuntime = ({ if (!committed) { return 'ignored'; } + const ownershipReloaded = + cssAssetsRemoved || (cssAssetsAdded && reloadAfterCssRemoval); if (cssAssetsRemoved) { reloadAfterCssRemoval = !cssAssetsAdded; notifyCssAssetOwnershipChanged('removed'); @@ -596,7 +624,10 @@ export const createReactRouterDevRuntime = ({ } reloadAfterCssRemoval = false; } - if (routeManifestMetadataChanged) { + if ( + routeManifestMetadataChanged || + (cssManifestChanged && !ownershipReloaded) + ) { notifyRouteManifestChanged( web.manifestsByEntryName[buildPlan.defaultEntryName] ); @@ -608,6 +639,12 @@ export const createReactRouterDevRuntime = ({ } }, + getCommittedManifest() { + return state.kind === 'ready' + ? state.committed.web.manifestsByEntryName[buildPlan.defaultEntryName] + : undefined; + }, + getCommittedNodeIdentity() { return state.kind === 'ready' ? state.committed.nodeIdentity : undefined; }, diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts index 422dfc85..bc019221 100644 --- a/src/dev-hmr.ts +++ b/src/dev-hmr.ts @@ -102,6 +102,7 @@ const RefreshRuntime = : __refreshRuntimeModule.default; const pendingRouteUpdates = new Map(); +let pendingManifestUpdate; let flushTimeout; let pendingRevalidation = false; let flushing = false; @@ -332,30 +333,16 @@ function performReactRefresh() { } } -function applyManifestUpdate(nextRoutes) { - const router = window.__reactRouterDataRouter; - const routeModules = window.__reactRouterRouteModules; - const manifest = window.__reactRouterManifest; - const context = window.__reactRouterContext; - if ( - !router || - !routeModules || - !manifest || - !context || - !nextRoutes || - typeof router.createRoutesForHMR !== 'function' || - typeof router._internalSetRoutes !== 'function' - ) { - return; - } +function applyManifestUpdate(update, router, routeModules, manifest, context) { + if (typeof router.createRoutesForHMR !== 'function' || typeof router._internalSetRoutes !== 'function') return; const routes = router.createRoutesForHMR( - new Set(Object.keys(nextRoutes)), - nextRoutes, + new Set(Object.keys(update.routes)), + update.routes, routeModules, context.ssr, context.isSpaMode ); - manifest.routes = nextRoutes; + Object.assign(manifest, update); router._internalSetRoutes(routes); patchCurrentRouteMatches(router, routes); } @@ -374,8 +361,19 @@ async function flush() { scheduleFlush(); return; } + // Reconnect also replays the initial SSR manifest. Avoid route-state changes + // during hydration when the client already has exactly that committed version. + if (pendingManifestUpdate?.version && pendingManifestUpdate.version === manifest.version) { + pendingManifestUpdate = undefined; + if (!pendingRevalidation && pendingRouteUpdates.size === 0) return; + } flushing = true; try { + if (pendingManifestUpdate) { + const update = pendingManifestUpdate; + pendingManifestUpdate = undefined; + applyManifestUpdate(update, router, routeModules, manifest, context); + } let shouldRevalidate = pendingRevalidation; pendingRevalidation = false; const { nextManifest, hmrRoutes, shouldRefreshRouteState } = @@ -403,14 +401,14 @@ async function flush() { performReactRefresh(); } finally { flushing = false; - if (pendingRevalidation || pendingRouteUpdates.size > 0) scheduleFlush(); + if (pendingManifestUpdate || pendingRevalidation || pendingRouteUpdates.size > 0) scheduleFlush(); } } if (typeof window !== 'undefined' && import.meta.webpackHot) { import.meta.webpackHot.on( ${JSON.stringify(DEV_MANIFEST_UPDATE_EVENT)}, - applyManifestUpdate + update => { pendingManifestUpdate = update; scheduleFlush(); } ); import.meta.webpackHot.on( ${JSON.stringify(DEV_HDR_UPDATE_EVENT)}, diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index f2bca276..7831be49 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -64,6 +64,16 @@ type CreateControllerOptions = { const isCssSourceFile = (file: string): boolean => /\.css(?:\.[cm]?[jt]s)?$/.test(file); +const manifestPayload = (manifest: ReactRouterDevManifestSet[string]) => ({ + event: DEV_MANIFEST_UPDATE_EVENT, + data: { + entry: manifest.entry, + routes: manifest.routes, + version: manifest.version, + url: manifest.url, + }, +}); + export const createReactRouterDevRuntimeController = ({ api, isBuild, @@ -92,6 +102,7 @@ export const createReactRouterDevRuntimeController = ({ binding.server.sockWrite('full-reload', { path: '*' }); }; + const manifestSubscriptions = new WeakMap void>(); const hdrChannels = new WeakMap< RuntimeBinding, ReturnType @@ -102,6 +113,8 @@ export const createReactRouterDevRuntimeController = ({ : clientPatchesRouteMetadata === true; const closeBinding = (binding: RuntimeBinding, error?: Error): void => { + manifestSubscriptions.get(binding)?.(); + manifestSubscriptions.delete(binding); hdrChannels.get(binding)?.close(); hdrChannels.delete(binding); const pair = binding.compilers; @@ -291,10 +304,7 @@ export const createReactRouterDevRuntimeController = ({ return; } if (isHmrEnabled()) { - server.sockWrite('custom', { - event: DEV_MANIFEST_UPDATE_EVENT, - data: manifest.routes, - }); + server.sockWrite('custom', manifestPayload(manifest)); } else { server.sockWrite('full-reload', { path: '*' }); } @@ -302,6 +312,18 @@ export const createReactRouterDevRuntimeController = ({ onWarning: message => api.logger.warn(message), }); const binding = sessions.createBinding(server, runtime); + manifestSubscriptions.set( + binding, + server.environments.web.hot.onConnect(client => { + const manifest = runtime.getCommittedManifest(); + if ( + sessions.getActiveBinding() === binding && + isHmrEnabled() && + manifest + ) + client.send('custom', manifestPayload(manifest)); + }) + ); hdrChannels.set( binding, createDevHdrChannel({ diff --git a/src/manifest-assets.ts b/src/manifest-assets.ts index 941f53a5..f289ee10 100644 --- a/src/manifest-assets.ts +++ b/src/manifest-assets.ts @@ -1,6 +1,7 @@ import { DEFAULT_JS_DIST_PATH } from './constants.js'; export type ReactRouterManifestStats = { + cssUrlsByName?: Record; assetsByChunkName?: Record; entrypointFilesByName?: Record; assetTypesByName?: Record; @@ -220,7 +221,9 @@ export const createChunkAssetResolver = ( const result = { js: [...new Set(jsAssets)], - css: [...cssAssets], + css: [...cssAssets].map( + asset => clientStats?.cssUrlsByName?.[asset] ?? asset + ), }; chunkAssetsByName.set(chunkName, result); return result; diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 5a7dbaa9..846439e8 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -1,3 +1,4 @@ +import { versionDevCssAssets } from './dev-css-assets.js'; import { PLUGIN_NAME } from './constants.js'; import { getManifestAssetType, stripAssetQuery } from './manifest-assets.js'; import { createHash } from 'node:crypto'; @@ -200,6 +201,7 @@ export function registerModifyBrowserManifestAssets( compilation, manifestChunkNames ); + if (!isBuild && stats) versionDevCssAssets(compilation, stats); const { manifest, moduleExportsByRouteId } = await generateReactRouterManifestForDev( routes, diff --git a/tests/dev-css-assets.test.ts b/tests/dev-css-assets.test.ts new file mode 100644 index 00000000..6695565c --- /dev/null +++ b/tests/dev-css-assets.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@rstest/core'; +import { rspack, type Rspack } from '@rsbuild/core'; +import { stripDevCssVersion, versionDevCssAssets } from '../src/dev-css-assets'; +import { + createChunkAssetResolver, + type ReactRouterManifestStats, +} from '../src/manifest-assets'; + +describe('immutable development CSS assets', () => { + it('retains original bytes across edits and exact restoration', () => { + const retained = new Map(); + const emit = (value: string) => { + const source = new rspack.sources.RawSource(value); + const stats: ReactRouterManifestStats = { + assetsByChunkName: { + 'entry.client': ['main.js', 'styles/main.css'], + route: ['route.js', 'styles/main.css'], + }, + }; + versionDevCssAssets( + { + getAsset: name => + name === 'styles/main.css' + ? { name, source, info: { contenthash: 'stale-metadata' } } + : undefined, + emitAsset: (name, asset) => { + retained.set(name, asset); + }, + }, + stats + ); + const resolver = createChunkAssetResolver(stats, false); + expect(resolver('entry.client').css).toEqual(resolver('route').css); + return resolver('route').css[0]; + }; + const original = emit('original'); + const edited = emit('edited'); + expect(edited).not.toBe(original); + expect(retained.get(original)?.source()).toBe('original'); + expect(retained.get(edited)?.source()).toBe('edited'); + expect(emit('original')).toBe(original); + expect(stripDevCssVersion(original)).toBe('styles/main.css'); + }); + + it('normalizes only reserved aliases, preserving query parameters and fragments', () => { + const hash = 'a'.repeat(64); + expect( + stripDevCssVersion( + `/base/a.css.__react_router_css_${hash}.css?theme=dark%20blue#part` + ) + ).toBe('/base/a.css?theme=dark%20blue#part'); + expect(stripDevCssVersion('/a.css?version=1')).toBe('/a.css?version=1'); + expect(stripDevCssVersion('/a.css.__react_router_css_invalid.css')).toBe( + '/a.css.__react_router_css_invalid.css' + ); + }); + + it('supports custom CSS filenames and preserves relative asset directories', () => { + const stats: ReactRouterManifestStats = { + assetsByChunkName: { route: ['route.js', 'styles/custom-output'] }, + assetTypesByName: { 'styles/custom-output': 'extract-css' }, + }; + const source = new rspack.sources.RawSource( + 'body { background: url(./image.png); }' + ); + const emitted = new Map(); + versionDevCssAssets( + { + getAsset: name => + name === 'styles/custom-output' + ? { name, source, info: {} } + : undefined, + emitAsset: (name, asset) => { + emitted.set(name, asset); + }, + }, + stats + ); + const url = createChunkAssetResolver(stats, false)('route').css[0]; + expect(url).toMatch( + /^styles\/custom-output\.__react_router_css_[a-f0-9]{64}\.css$/ + ); + expect(emitted.get(url)?.source()).toBe(source.source()); + }); +}); diff --git a/tests/dev-generation-css.test.ts b/tests/dev-generation-css.test.ts index da3850fb..dc02aa72 100644 --- a/tests/dev-generation-css.test.ts +++ b/tests/dev-generation-css.test.ts @@ -90,4 +90,103 @@ describe('React Router development runtime CSS ownership', () => { assets: { version: 'without-entry-css' }, }); }); + it('commits CSS version changes in web-only builds without reloading or reevaluating loaders', async () => { + const changed = rstest.fn(); + const ownership = rstest.fn(); + let build = createBuild('loader-v1'); + const { runtime, loadBundle, warnings } = createDevRuntimeHarness( + () => build, + { onRouteManifestChanged: changed, onCssAssetOwnershipChanged: ownership } + ); + let node = createCompilation('node', { files: ['/app/style.css'] }); + for (const [i, token] of ['a', 'b', 'a'].entries()) { + const web = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, web, token, { + entry: [`/entry.css.__react_router_css_${token.repeat(64)}.css`], + routes: { + 'routes/about': [ + `/route.css.__react_router_css_${token.repeat(64)}.css?theme=x`, + ], + }, + }); + expect( + await runtime.finishAttempt( + createGraphStats(web, node), + { + web: { known: true, files: new Set(['/app/style.css']) }, + node: { known: false, files: new Set() }, + }, + graphIdentity(web, node) + ) + ).toBe('committed'); + expect((await runtime.load()).marker).toBe('loader-v1'); + expect(changed).toHaveBeenCalledTimes(i); + } + expect(loadBundle).toHaveBeenCalledTimes(1); + expect(ownership).not.toHaveBeenCalled(); + expect(warnings).toEqual([]); + const web = createCompilation('web'); + node = createCompilation('node'); + build = createBuild('loader-v2'); + runtime.beginAttempt(); + captureWeb(runtime, web, 'c', { + entry: [`/entry.css.__react_router_css_${'c'.repeat(64)}.css`], + routes: { + 'routes/about': [ + `/route.css.__react_router_css_${'c'.repeat(64)}.css?theme=x`, + ], + }, + }); + expect( + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ) + ).toBe('committed'); + expect((await runtime.load()).marker).toBe('loader-v2'); + expect(loadBundle).toHaveBeenCalledTimes(2); + expect(changed).toHaveBeenCalledTimes(3); + expect(ownership).not.toHaveBeenCalled(); + }); + + it('does not publish failed CSS/loader generations or replay them to reconnecting clients', async () => { + const changed = rstest.fn(); + let fail = false; + const { runtime } = createDevRuntimeHarness( + () => { + if (fail) throw new Error('loader evaluation failed'); + return createBuild('good-loader'); + }, + { onRouteManifestChanged: changed } + ); + for (const token of ['a', 'b']) { + const web = createCompilation('web'); + const node = createCompilation('node'); + runtime.beginAttempt(); + captureWeb(runtime, web, token, { + entry: [`/entry.css.__react_router_css_${token.repeat(64)}.css`], + routes: { + 'routes/about': [ + `/route.css.__react_router_css_${token.repeat(64)}.css`, + ], + }, + }); + fail = token === 'b'; + expect( + await runtime.finishAttempt( + createGraphStats(web, node), + noKnownChanges, + graphIdentity(web, node) + ) + ).toBe(fail ? 'ignored' : 'committed'); + } + expect(changed).not.toHaveBeenCalled(); + expect(runtime.getCommittedManifest()?.version).toBe('a'); + expect(runtime.getCommittedManifest()?.entry.css).toEqual([ + `/entry.css.__react_router_css_${'a'.repeat(64)}.css`, + ]); + expect((await runtime.load()).marker).toBe('good-loader'); + }); }); diff --git a/tests/dev-hmr.test.ts b/tests/dev-hmr.test.ts index ae0af435..65552823 100644 --- a/tests/dev-hmr.test.ts +++ b/tests/dev-hmr.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, rstest } from '@rstest/core'; import { + DEV_MANIFEST_UPDATE_EVENT, generateDevHmrRuntimeModule, resolveReactRefreshRuntimePath, } from '../src/dev-hmr'; @@ -13,7 +14,11 @@ const createRuntime = () => { const listeners = new Map void>(); let status = 'idle'; const revalidate = rstest.fn(async () => {}); - const router = { revalidate }; + const router = { + revalidate, + createRoutesForHMR: rstest.fn(() => []), + _internalSetRoutes: rstest.fn(), + }; const window = { __reactRouterDataRouter: router as typeof router | undefined, __reactRouterRouteModules: {}, @@ -52,6 +57,8 @@ const createRuntime = () => { window.__reactRouterManifest.routes.component = {}; updateRoute('component', {}, () => ({ default: () => null })); }, + receiveManifest: (manifest: object) => + listeners.get(DEV_MANIFEST_UPDATE_EVENT)!(manifest), receive: (revision: number, sessionId = 'first') => listeners.get(DEV_HDR_UPDATE_EVENT)!({ sessionId, revision }), setStatus: (next: string) => { @@ -153,3 +160,43 @@ describe('resolveReactRefreshRuntimePath', () => { } }); }); + +describe('committed CSS manifests', () => { + it('retains early entry and route updates, coalesces, and waits for HMR idle without revalidating loaders', async () => { + const runtime = createRuntime(); + runtime.window.__reactRouterDataRouter = undefined; + runtime.receiveManifest({ + entry: { css: ['/entry.css?v=1'] }, + routes: { home: { css: ['/route.css?v=1'] } }, + }); + await runtime.flush(); + runtime.window.__reactRouterDataRouter = runtime.router; + runtime.setStatus('apply'); + runtime.receiveManifest({ + entry: { css: ['/entry.css?v=2'] }, + routes: { home: { css: ['/route.css?v=2'] } }, + }); + await runtime.flush(); + expect(runtime.router._internalSetRoutes).not.toHaveBeenCalled(); + runtime.setStatus('idle'); + await runtime.flush(); + expect(runtime.window.__reactRouterManifest).toMatchObject({ + entry: { css: ['/entry.css?v=2'] }, + routes: { home: { css: ['/route.css?v=2'] } }, + }); + expect(runtime.router._internalSetRoutes).toHaveBeenCalledTimes(1); + expect(runtime.revalidate).not.toHaveBeenCalled(); + }); + it('does not rebuild routes for an identical SSR manifest replay during hydration', async () => { + const runtime = createRuntime(); + Object.assign(runtime.window.__reactRouterManifest, { version: 'initial' }); + runtime.receiveManifest({ + version: 'initial', + entry: { css: [] }, + routes: {}, + }); + await runtime.flush(); + expect(runtime.router._internalSetRoutes).not.toHaveBeenCalled(); + expect(runtime.revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index 5efaaa8c..230d5724 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -800,9 +800,33 @@ describe('React Router development runtime controller', () => { expect(server.sockWrite).toHaveBeenCalledWith('custom', { event: 'react-router:manifest-update', data: expect.objectContaining({ - 'routes/about': expect.objectContaining({ hasClientLoader: true }), + routes: expect.objectContaining({ + 'routes/about': expect.objectContaining({ hasClientLoader: true }), + }), }), }); + const client = { send: rstest.fn() }; + for (const [listener] of (server.environments.web.hot.onConnect as any).mock + .calls) + listener(client); + expect(client.send).toHaveBeenCalledWith( + 'custom', + expect.objectContaining({ + event: 'react-router:manifest-update', + data: expect.objectContaining({ + version: 'web-next', + routes: expect.objectContaining({ + 'routes/about': expect.objectContaining({ hasClientLoader: true }), + }), + }), + }) + ); + await callbacks.close(); + client.send.mockClear(); + for (const [listener] of (server.environments.web.hot.onConnect as any).mock + .calls) + listener(client); + expect(client.send).not.toHaveBeenCalled(); }); it('publishes a safe node-only compile after the aggregate pre-hook', async () => { diff --git a/tests/development-manifest-hashes.test.ts b/tests/development-manifest-hashes.test.ts index 3d17ba19..fd8302ac 100644 --- a/tests/development-manifest-hashes.test.ts +++ b/tests/development-manifest-hashes.test.ts @@ -347,7 +347,10 @@ describe('development manifests with content hashes', () => { const [finalized] = result.publications; expect(finalized.compilation).toBe(result.compilation); expect(finalized.manifest).toEqual(result.emitted.manifest); - expect(finalized.manifestStats).toEqual(result.manifestStats); + expect(finalized.manifestStats).toEqual({ + ...result.manifestStats, + cssUrlsByName: {}, + }); expect(finalized.moduleExportsByRouteId.page).toEqual([ 'clientLoader', 'loader', diff --git a/tests/react-router-framework/integration/css-preload-test.ts b/tests/react-router-framework/integration/css-preload-test.ts new file mode 100644 index 00000000..e0f5a27e --- /dev/null +++ b/tests/react-router-framework/integration/css-preload-test.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; +import getPort from 'get-port'; +import { + createProject, + createEditor, + dev, + rsbuildConfig, +} from './helpers/rsbuild.js'; + +test('CSS preloads cannot revive edited bytes after exact restoration', async ({ + page, +}) => { + const port = await getPort(); + const original = '.sentinel { color: rgb(255, 0, 0); outline: none; }'; + const edited = '.sentinel { color: rgb(0, 0, 255); outline: 2px dashed; }'; + const cwd = await createProject({ + 'rsbuild.config.ts': await rsbuildConfig.basic({ port }), + 'app/style.css': original, + 'app/routes/_index.tsx': ` + import '../style.css'; + import { useEffect, useState } from 'react'; + export default function Index() { + const [ready, setReady] = useState(false); + useEffect(() => setReady(true), []); + return <>

CSS

; + } + `, + }); + const stop = await dev({ cwd, port }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + const cssUrl = () => + page.evaluate( + () => + (window as any).__reactRouterManifest.routes['routes/_index'] + .css[0] as string + ); + try { + await page.goto(`http://localhost:${port}/`); + await expect(page.locator('h1')).toHaveAttribute('data-ready', 'true'); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await page.getByLabel('Draft').fill('preserved draft'); + await page.evaluate(() => { + (window as any).__cssDocument = document; + }); + const firstUrl = await cssUrl(); + const firstBytes = await page.evaluate( + url => fetch(url).then(r => r.text()), + firstUrl + ); + const edit = createEditor(cwd); + await edit('app/style.css', () => edited); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(0, 0, 255)'); + await expect.poll(cssUrl).not.toBe(firstUrl); + const editedUrl = await cssUrl(); + expect( + await page.evaluate(url => fetch(url).then(r => r.text()), firstUrl) + ).toBe(firstBytes); + await page.evaluate( + url => + new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'style'; + link.href = url; + link.onload = () => { + link.remove(); + resolve(); + }; + link.onerror = () => { + link.remove(); + reject(new Error('CSS preload failed')); + }; + document.head.append(link); + }), + editedUrl + ); + await edit('app/style.css', () => original); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect.poll(cssUrl).toBe(firstUrl); + await page.evaluate( + url => + new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.onload = () => resolve(); + link.onerror = () => reject(new Error('CSS remount failed')); + document.head.append(link); + }), + await cssUrl() + ); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(page.locator('h1')).toHaveCSS('outline-style', 'none'); + await expect(page.getByLabel('Draft')).toHaveValue('preserved draft'); + expect( + await page.evaluate(() => document === (window as any).__cssDocument) + ).toBe(true); + expect(errors).toEqual([]); + } finally { + if (errors.length) console.error(errors); + stop(); + } +}); From 71e5e3a10e568421d150ad2cb9234040a5f92f6c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 23:20:01 -0700 Subject: [PATCH 2/6] fix: keep extracted CSS HMR under Router ownership --- .changeset/css-preload-restoration.md | 2 + src/dev-css-assets.ts | 23 ++- src/index.ts | 3 + .../integration/css-preload-test.ts | 189 +++++++++--------- 4 files changed, 125 insertions(+), 92 deletions(-) diff --git a/.changeset/css-preload-restoration.md b/.changeset/css-preload-restoration.md index ce80a5f8..fcc2d51d 100644 --- a/.changeset/css-preload-restoration.md +++ b/.changeset/css-preload-restoration.md @@ -3,3 +3,5 @@ --- Serve development manifest stylesheets through retained content-addressed assets so preloaded CSS cannot revive stale bytes after an exact source restoration. Publish committed CSS manifests through the HMR-idle queue and replay the last committed manifest on reconnect. + +Keep extracted stylesheet updates under Router ownership in development so the extract loader's fallback cannot remove React-owned links during Vanilla Extract HMR. diff --git a/src/dev-css-assets.ts b/src/dev-css-assets.ts index 508da107..e4e4989b 100644 --- a/src/dev-css-assets.ts +++ b/src/dev-css-assets.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { Rspack } from '@rsbuild/core'; +import { rspack, type Rspack } from '@rsbuild/core'; import { getManifestAssetType, type ReactRouterManifestStats, @@ -8,6 +8,27 @@ import { export const stripDevCssVersion = (url: string): string => url.replace(/\.__react_router_css_[a-f0-9]{64}\.css(?=[?#]|$)/, ''); +export const devCssOwnershipPlugin: Rspack.RspackPluginInstance = { + apply(compiler) { + compiler.hooks.compilation.tap('ReactRouterCssOwnership', compilation => { + rspack.NormalModule.getCompilationHooks(compilation).loader.tap( + 'ReactRouterCssOwnership', + context => { + if ( + context.loaders.some( + ({ path }) => path === rspack.CssExtractRspackPlugin.loader + ) + ) { + // Router updates extracted styles through committed manifests. The + // loader's fallback scans all links, including React-owned nodes. + context.hot = false; + } + } + ); + }); + }, +}; + export const versionDevCssAssets = ( compilation: Pick, stats: ReactRouterManifestStats diff --git a/src/index.ts b/src/index.ts index c722cb8b..31ed493f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { type ReactRouterManifestSnapshot, } from './manifest-snapshot.js'; import { createReactRouterManifestState } from './manifest-state.js'; +import { devCssOwnershipPlugin } from './dev-css-assets.js'; import { registerNodeOnlyManifestValidation } from './node-only-manifest.js'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; @@ -616,6 +617,8 @@ export const pluginReactRouter = ( rspack: rspackConfig => { devHmrEnabled = isRspackSwcReactRefreshEnabled(rspackConfig); if (devHmrEnabled) { + rspackConfig.plugins ??= []; + rspackConfig.plugins.push(devCssOwnershipPlugin); const entries = rspackConfig.entry; if ( entries && diff --git a/tests/react-router-framework/integration/css-preload-test.ts b/tests/react-router-framework/integration/css-preload-test.ts index e0f5a27e..5dda7cf0 100644 --- a/tests/react-router-framework/integration/css-preload-test.ts +++ b/tests/react-router-framework/integration/css-preload-test.ts @@ -7,101 +7,108 @@ import { rsbuildConfig, } from './helpers/rsbuild.js'; -test('CSS preloads cannot revive edited bytes after exact restoration', async ({ - page, -}) => { - const port = await getPort(); - const original = '.sentinel { color: rgb(255, 0, 0); outline: none; }'; - const edited = '.sentinel { color: rgb(0, 0, 255); outline: 2px dashed; }'; - const cwd = await createProject({ - 'rsbuild.config.ts': await rsbuildConfig.basic({ port }), - 'app/style.css': original, - 'app/routes/_index.tsx': ` - import '../style.css'; +for (const vanillaExtract of [false, true]) { + test(`CSS preloads cannot revive edited bytes after exact restoration (vanillaExtract=${vanillaExtract})`, async ({ + page, + }) => { + const port = await getPort(); + const original = vanillaExtract + ? `import { style } from '@vanilla-extract/css'; export const sentinel = style({ color: 'rgb(255, 0, 0)', outline: 'none' });` + : '.sentinel { color: rgb(255, 0, 0); outline: none; }'; + const edited = original + .replace('rgb(255, 0, 0)', 'rgb(0, 0, 255)') + .replace('none', '2px dashed'); + const styleFile = vanillaExtract ? 'app/style.css.ts' : 'app/style.css'; + const cwd = await createProject({ + 'rsbuild.config.ts': await rsbuildConfig.basic({ port, vanillaExtract }), + [styleFile]: original, + 'app/routes/_index.tsx': ` + ${vanillaExtract ? "import { sentinel } from '../style.css';" : "import '../style.css';"} import { useEffect, useState } from 'react'; export default function Index() { const [ready, setReady] = useState(false); useEffect(() => setReady(true), []); - return <>

CSS

; + return <>

CSS

; } `, - }); - const stop = await dev({ cwd, port }); - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - page.on('console', message => { - if (message.type() === 'error') errors.push(message.text()); - }); - const cssUrl = () => - page.evaluate( - () => - (window as any).__reactRouterManifest.routes['routes/_index'] - .css[0] as string - ); - try { - await page.goto(`http://localhost:${port}/`); - await expect(page.locator('h1')).toHaveAttribute('data-ready', 'true'); - await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); - await page.getByLabel('Draft').fill('preserved draft'); - await page.evaluate(() => { - (window as any).__cssDocument = document; }); - const firstUrl = await cssUrl(); - const firstBytes = await page.evaluate( - url => fetch(url).then(r => r.text()), - firstUrl - ); - const edit = createEditor(cwd); - await edit('app/style.css', () => edited); - await expect(page.locator('h1')).toHaveCSS('color', 'rgb(0, 0, 255)'); - await expect.poll(cssUrl).not.toBe(firstUrl); - const editedUrl = await cssUrl(); - expect( - await page.evaluate(url => fetch(url).then(r => r.text()), firstUrl) - ).toBe(firstBytes); - await page.evaluate( - url => - new Promise((resolve, reject) => { - const link = document.createElement('link'); - link.rel = 'preload'; - link.as = 'style'; - link.href = url; - link.onload = () => { - link.remove(); - resolve(); - }; - link.onerror = () => { - link.remove(); - reject(new Error('CSS preload failed')); - }; - document.head.append(link); - }), - editedUrl - ); - await edit('app/style.css', () => original); - await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); - await expect.poll(cssUrl).toBe(firstUrl); - await page.evaluate( - url => - new Promise((resolve, reject) => { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = url; - link.onload = () => resolve(); - link.onerror = () => reject(new Error('CSS remount failed')); - document.head.append(link); - }), - await cssUrl() - ); - await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); - await expect(page.locator('h1')).toHaveCSS('outline-style', 'none'); - await expect(page.getByLabel('Draft')).toHaveValue('preserved draft'); - expect( - await page.evaluate(() => document === (window as any).__cssDocument) - ).toBe(true); - expect(errors).toEqual([]); - } finally { - if (errors.length) console.error(errors); - stop(); - } -}); + const stop = await dev({ cwd, port }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + const cssUrl = () => + page.evaluate( + () => + (window as any).__reactRouterManifest.routes['routes/_index'] + .css[0] as string + ); + try { + await page.goto(`http://localhost:${port}/`); + await expect(page.locator('h1')).toHaveAttribute('data-ready', 'true'); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await page.getByLabel('Draft').fill('preserved draft'); + await page.evaluate(() => { + (window as any).__cssDocument = document; + }); + const firstUrl = await cssUrl(); + const firstBytes = await page.evaluate( + url => fetch(url).then(r => r.text()), + firstUrl + ); + const edit = createEditor(cwd); + await edit(styleFile, () => edited); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(0, 0, 255)'); + await expect.poll(cssUrl).not.toBe(firstUrl); + const editedUrl = await cssUrl(); + expect( + await page.evaluate(url => fetch(url).then(r => r.text()), firstUrl) + ).toBe(firstBytes); + await page.evaluate( + url => + new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'style'; + link.href = url; + link.onload = () => { + link.remove(); + resolve(); + }; + link.onerror = () => { + link.remove(); + reject(new Error('CSS preload failed')); + }; + document.head.append(link); + }), + editedUrl + ); + await edit(styleFile, () => original); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect.poll(cssUrl).toBe(firstUrl); + await page.evaluate( + url => + new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.onload = () => resolve(); + link.onerror = () => reject(new Error('CSS remount failed')); + document.head.append(link); + }), + await cssUrl() + ); + await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(page.locator('h1')).toHaveCSS('outline-style', 'none'); + await expect(page.getByLabel('Draft')).toHaveValue('preserved draft'); + expect( + await page.evaluate(() => document === (window as any).__cssDocument) + ).toBe(true); + expect(errors).toEqual([]); + } finally { + if (errors.length) console.error(errors); + stop(); + } + }); +} From fbcf2a0a84030449ca37705e3e6c9c31759a18d8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:16:03 +0000 Subject: [PATCH 3/6] fix: preserve compiler pairing for CSS manifest updates --- .changeset/css-preload-restoration.md | 2 + README.md | 5 ++ src/dev-generation.ts | 79 --------------------------- src/types.ts | 3 +- tests/dev-generation-css.test.ts | 38 ++++++++++++- tests/dev-generation.test.ts | 21 +++++-- 6 files changed, 60 insertions(+), 88 deletions(-) diff --git a/.changeset/css-preload-restoration.md b/.changeset/css-preload-restoration.md index fcc2d51d..6f3db28c 100644 --- a/.changeset/css-preload-restoration.md +++ b/.changeset/css-preload-restoration.md @@ -5,3 +5,5 @@ Serve development manifest stylesheets through retained content-addressed assets so preloaded CSS cannot revive stale bytes after an exact source restoration. Publish committed CSS manifests through the HMR-idle queue and replay the last committed manifest on reconnect. Keep extracted stylesheet updates under Router ownership in development so the extract loader's fallback cannot remove React-owned links during Vanilla Extract HMR. + +Keep the last-good server/browser generation when a CSS update also contains shared JavaScript changes or has unknown invalidation provenance. diff --git a/README.md b/README.md index 85d3c564..49df75ba 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ pluginReactRouter({ | `onRouteTopologyChange` | `undefined` | Notification for programmatic/custom dev servers. Recreate the Rsbuild server when route files are added, removed, or moved. The callback is not awaited. | | `federation` | `false` | Enables the plugin's experimental Module Federation integration. | +Lazy compilation applies to the browser build. Server dynamic imports still +compile eagerly; `lazyCompilation` does not enable on-demand SSR compilation. +Supporting suspended SSR imports across rebuilds requires support for retaining +the server runtime and coordinating lazy activations, tracked in [#155](https://github.com/rstackjs/rsbuild-plugin-react-router/issues/155). + When `federation` is enabled, configure the Module Federation plugin with `experiments.asyncStartup: true` on every compiler (the plugin enforces it) and keep shared dependencies non-eager. The dev server resolves async server build diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 1f0107bc..02122918 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -142,78 +142,6 @@ const hasAddedCssAssetOwnership = ( next: ReactRouterDevManifestSet ): boolean => hasRemovedCssAssetOwnership(next, previous); -const collectManifestCssAssets = ( - manifest: ReactRouterDevManifestSet[string] -): Set => { - const assets = new Set(manifest.entry?.css ?? []); - for (const route of Object.values(manifest.routes ?? {})) { - for (const asset of route.css ?? []) { - assets.add(asset); - } - } - return assets; -}; - -const normalizeManifestForCssOwnershipCheck = ( - manifest: ReactRouterDevManifestSet[string] -) => { - const cssAssets = collectManifestCssAssets(manifest); - const nonCssImports = (imports: string[] = []) => - imports.filter(importPath => !cssAssets.has(importPath)); - - return { - entry: { - imports: nonCssImports(manifest.entry?.imports), - module: manifest.entry?.module, - }, - routes: Object.fromEntries( - Object.entries(manifest.routes ?? {}) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([routeId, route]) => [ - routeId, - { - caseSensitive: route.caseSensitive, - clientActionModule: route.clientActionModule, - clientLoaderModule: route.clientLoaderModule, - clientMiddlewareModule: route.clientMiddlewareModule, - errorBoundary: route.hasErrorBoundary, - hasAction: route.hasAction, - hasClientAction: route.hasClientAction, - hasClientLoader: route.hasClientLoader, - hasClientMiddleware: route.hasClientMiddleware, - hasDefaultExport: route.hasDefaultExport, - hasLoader: route.hasLoader, - hydrateFallbackModule: route.hydrateFallbackModule, - id: route.id, - imports: nonCssImports(route.imports), - index: route.index, - module: route.module, - parentId: route.parentId, - path: route.path, - }, - ]) - ), - }; -}; - -const hasOnlyCssAssetOwnershipChanges = ( - previous: ReactRouterDevManifestSet, - next: ReactRouterDevManifestSet -): boolean => { - const previousEntryNames = Object.keys(previous).sort(); - const nextEntryNames = Object.keys(next).sort(); - if (previousEntryNames.join('\0') !== nextEntryNames.join('\0')) { - return false; - } - return previousEntryNames.every(entryName => { - const previousManifest = normalizeManifestForCssOwnershipCheck( - previous[entryName] - ); - const nextManifest = normalizeManifestForCssOwnershipCheck(next[entryName]); - return JSON.stringify(previousManifest) === JSON.stringify(nextManifest); - }); -}; - type DevRouteManifestEntry = NonNullable< ReactRouterDevManifestSet[string]['routes'] >[string]; @@ -546,12 +474,6 @@ export const createReactRouterDevRuntime = ({ previous.web.manifestsByEntryName, manifestsByEntryName ); - const cssOnlyWebManifestChange = - cssManifestChanged && - hasOnlyCssAssetOwnershipChanges( - previous.web.manifestsByEntryName, - manifestsByEntryName - ); const routeManifestMetadataChanged = !!previous && webChanged && @@ -578,7 +500,6 @@ export const createReactRouterDevRuntime = ({ if ( previous && webChanged !== nodeChanged && - !cssOnlyWebManifestChange && discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes) ) { return 'ignored'; diff --git a/src/types.ts b/src/types.ts index 6aabaf86..cf1da055 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,7 +55,8 @@ export type PluginOptions = { }; /** - * Rsbuild dev-only lazy compilation behavior. + * Rsbuild dev-only browser lazy compilation behavior. + * Server dynamic imports still compile eagerly. * * React Router's browser manifest remains eager so initial dev requests can * discover browser assets without lazy proxy delays. diff --git a/tests/dev-generation-css.test.ts b/tests/dev-generation-css.test.ts index dc02aa72..a3735ab8 100644 --- a/tests/dev-generation-css.test.ts +++ b/tests/dev-generation-css.test.ts @@ -90,7 +90,7 @@ describe('React Router development runtime CSS ownership', () => { assets: { version: 'without-entry-css' }, }); }); - it('commits CSS version changes in web-only builds without reloading or reevaluating loaders', async () => { + it('commits browser-only CSS changes without reloading or reevaluating loaders', async () => { const changed = rstest.fn(); const ownership = rstest.fn(); let build = createBuild('loader-v1'); @@ -98,7 +98,7 @@ describe('React Router development runtime CSS ownership', () => { () => build, { onRouteManifestChanged: changed, onCssAssetOwnershipChanged: ownership } ); - let node = createCompilation('node', { files: ['/app/style.css'] }); + let node = createCompilation('node', { files: ['/app/server.ts'] }); for (const [i, token] of ['a', 'b', 'a'].entries()) { const web = createCompilation('web'); runtime.beginAttempt(); @@ -151,6 +151,40 @@ describe('React Router development runtime CSS ownership', () => { expect(ownership).not.toHaveBeenCalled(); }); + for (const files of [ + undefined, + [], + ['/app/style.css', '/app/shared.ts'], + ['/app/style.css.ts'], + ['/app/style.module.css'], + ]) { + it(`rejects unpaired CSS version changes with unsafe source changes: ${files}`, async () => { + const { runtime } = createDevRuntimeHarness(() => + createBuild('old-loader') + ); + const node = createCompilation('node', { + files: ['/app/shared.ts', '/app/style.css.ts', '/app/style.module.css'], + }); + for (const [index, token] of ['a', 'b'].entries()) { + const web = createCompilation('web'); + runtime.beginAttempt(); + captureWeb(runtime, web, token, { + entry: [`/entry.css.__react_router_css_${token.repeat(64)}.css`], + }); + const result = await runtime.finishAttempt( + createGraphStats(web, node), + { + web: { known: files !== undefined, files: new Set(files) }, + node: { known: false, files: new Set() }, + }, + graphIdentity(web, node) + ); + expect(result).toBe(index === 0 ? 'committed' : 'ignored'); + } + expect((await runtime.load()).assets.version).toBe('a'); + }); + } + it('does not publish failed CSS/loader generations or replay them to reconnecting clients', async () => { const changed = rstest.fn(); let fail = false; diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index 60f3ee4b..f975b164 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -60,7 +60,7 @@ describe('React Router development runtime', () => { }); }); - it('publishes css-only removals when the route file overlaps node dependencies', async () => { + it('waits for the Node build when CSS removal also edits a shared route', async () => { const routePath = '/app/routes/about.tsx'; const onCssAssetOwnershipChanged = rstest.fn(); const { runtime, warnings } = createHarness(() => createBuild('build'), { @@ -93,8 +93,20 @@ describe('React Router development runtime', () => { graphIdentity(removedCssWeb, node) ); + expect(onCssAssetOwnershipChanged).not.toHaveBeenCalled(); + expect(warnings).toHaveLength(1); + await expect(runtime.load()).resolves.toMatchObject({ + assets: { version: 'with-css' }, + }); + + const nextNode = createCompilation('node', { files: [routePath] }); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(removedCssWeb, nextNode), + noKnownChanges, + graphIdentity(removedCssWeb, nextNode) + ); expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); - expect(warnings).toEqual([]); await expect(runtime.load()).resolves.toMatchObject({ assets: { version: 'without-css' }, }); @@ -480,9 +492,7 @@ describe('React Router development runtime', () => { }); it('resolves all initial waiters from one committed generation', async () => { - const { loadBundle, runtime } = createHarness(() => - createBuild('shared') - ); + const { loadBundle, runtime } = createHarness(() => createBuild('shared')); const web = createCompilation('web'); const node = createCompilation('node'); @@ -1165,5 +1175,4 @@ describe('React Router development runtime', () => { ); expect(errors).toEqual([]); }); - }); From 25d65a9dd3b113b15c227f58ef392ae65de98c5f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:53:42 +0000 Subject: [PATCH 4/6] test: assert paired loader and CSS responses --- tests/dev-generation-css.test.ts | 125 ++++++++++++++++++++++++++----- tests/dev-generation.test.ts | 52 ------------- 2 files changed, 107 insertions(+), 70 deletions(-) diff --git a/tests/dev-generation-css.test.ts b/tests/dev-generation-css.test.ts index a3735ab8..8495ab35 100644 --- a/tests/dev-generation-css.test.ts +++ b/tests/dev-generation-css.test.ts @@ -1,7 +1,10 @@ +import { createRequestHandler, type ServerBuild } from 'react-router'; import { describe, expect, it, rstest } from '@rstest/core'; import { captureWeb, createDevRuntimeHarness } from './dev-generation-fixtures'; import { createBuild, + createDevManifest, + createRouteManifest, createCompilation, createGraphStats, graphIdentity, @@ -151,27 +154,94 @@ describe('React Router development runtime CSS ownership', () => { expect(ownership).not.toHaveBeenCalled(); }); - for (const files of [ - undefined, - [], - ['/app/style.css', '/app/shared.ts'], - ['/app/style.css.ts'], - ['/app/style.module.css'], + // A real Router server entry exposes the loader result and its paired CSS in + // the response, so generation regressions can assert the request contract. + const createDocumentBuild = (message: string, stylesheets: string[] = []) => + ({ + ...createBuild(message), + assets: { + ...createDevManifest(message), + routes: { + 'routes/about': createRouteManifest('routes/about', stylesheets, { + hasLoader: true, + path: '/', + }), + }, + }, + routes: { + 'routes/about': { + id: 'routes/about', + path: '/', + module: { default: () => null, loader: () => message }, + }, + }, + entry: { + module: { + default: async (_request, status, _headers, context) => + Response.json( + { + message: + context.staticHandlerContext.loaderData['routes/about'], + stylesheets: context.manifest.routes['routes/about'].css, + }, + { status } + ), + }, + }, + }) satisfies ServerBuild; + + const originalCss = + '/about.css.__react_router_css_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.css'; + const editedCss = + '/about.css.__react_router_css_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.css'; + for (const { name, files, stylesheets } of [ + { + name: 'unknown invalidation', + files: undefined, + stylesheets: [editedCss], + }, + { name: 'empty invalidation', files: [], stylesheets: [editedCss] }, + { + name: 'CSS and shared JavaScript', + files: ['/app/style.css', '/app/shared.ts'], + stylesheets: [editedCss], + }, + { + name: 'CSS-in-JS', + files: ['/app/style.css.ts'], + stylesheets: [editedCss], + }, + { + name: 'CSS Module exports', + files: ['/app/style.module.css'], + stylesheets: [editedCss], + }, + { + name: 'shared route removes CSS', + files: ['/app/routes/about.tsx'], + stylesheets: [], + }, ]) { - it(`rejects unpaired CSS version changes with unsafe source changes: ${files}`, async () => { - const { runtime } = createDevRuntimeHarness(() => - createBuild('old-loader') - ); + it(`serves matching loader data and CSS while waiting for Node: ${name}`, async () => { + let build = createDocumentBuild('original loader'); + const { runtime } = createDevRuntimeHarness(() => build); + const request = createRequestHandler(() => runtime.load(), 'development'); const node = createCompilation('node', { - files: ['/app/shared.ts', '/app/style.css.ts', '/app/style.module.css'], + files: [ + '/app/shared.ts', + '/app/style.css.ts', + '/app/style.module.css', + '/app/routes/about.tsx', + ], }); - for (const [index, token] of ['a', 'b'].entries()) { - const web = createCompilation('web'); + let web = createCompilation('web'); + for (const css of [[originalCss], stylesheets]) { + web = createCompilation('web'); runtime.beginAttempt(); - captureWeb(runtime, web, token, { - entry: [`/entry.css.__react_router_css_${token.repeat(64)}.css`], + runtime.captureWeb(web, { + 'static/js/app': createDocumentBuild(css.join(), css).assets, }); - const result = await runtime.finishAttempt( + await runtime.finishAttempt( createGraphStats(web, node), { web: { known: files !== undefined, files: new Set(files) }, @@ -179,9 +249,28 @@ describe('React Router development runtime CSS ownership', () => { }, graphIdentity(web, node) ); - expect(result).toBe(index === 0 ? 'committed' : 'ignored'); } - expect((await runtime.load()).assets.version).toBe('a'); + const waiting = await request(new Request('http://localhost/')); + expect(waiting.status).toBe(200); + expect(await waiting.json()).toEqual({ + message: 'original loader', + stylesheets: [originalCss], + }); + + build = createDocumentBuild('edited loader'); + const nextNode = createCompilation('node'); + runtime.beginAttempt(); + await runtime.finishAttempt( + createGraphStats(web, nextNode), + noKnownChanges, + graphIdentity(web, nextNode) + ); + const updated = await request(new Request('http://localhost/')); + expect(updated.status).toBe(200); + expect(await updated.json()).toEqual({ + message: 'edited loader', + stylesheets, + }); }); } diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts index f975b164..6c2e5c5e 100644 --- a/tests/dev-generation.test.ts +++ b/tests/dev-generation.test.ts @@ -60,58 +60,6 @@ describe('React Router development runtime', () => { }); }); - it('waits for the Node build when CSS removal also edits a shared route', async () => { - const routePath = '/app/routes/about.tsx'; - const onCssAssetOwnershipChanged = rstest.fn(); - const { runtime, warnings } = createHarness(() => createBuild('build'), { - onCssAssetOwnershipChanged, - }); - const firstWeb = createCompilation('web'); - const node = createCompilation('node', { files: [routePath] }); - - runtime.beginAttempt(); - captureWeb(runtime, firstWeb, 'with-css', { - routes: { 'routes/about': ['/assets/about.css'] }, - }); - await runtime.finishAttempt( - createGraphStats(firstWeb, node), - noKnownChanges, - graphIdentity(firstWeb, node) - ); - - const removedCssWeb = createCompilation('web'); - runtime.beginAttempt(); - captureWeb(runtime, removedCssWeb, 'without-css', { - routes: { 'routes/about': [] }, - }); - await runtime.finishAttempt( - createGraphStats(removedCssWeb, node), - { - web: { known: true, files: new Set([routePath]) }, - node: { known: false, files: new Set() }, - }, - graphIdentity(removedCssWeb, node) - ); - - expect(onCssAssetOwnershipChanged).not.toHaveBeenCalled(); - expect(warnings).toHaveLength(1); - await expect(runtime.load()).resolves.toMatchObject({ - assets: { version: 'with-css' }, - }); - - const nextNode = createCompilation('node', { files: [routePath] }); - runtime.beginAttempt(); - await runtime.finishAttempt( - createGraphStats(removedCssWeb, nextNode), - noKnownChanges, - graphIdentity(removedCssWeb, nextNode) - ); - expect(onCssAssetOwnershipChanged).toHaveBeenCalledOnce(); - await expect(runtime.load()).resolves.toMatchObject({ - assets: { version: 'without-css' }, - }); - }); - it('evaluates changed node output during css ownership removals', async () => { const routePath = '/app/routes/about.tsx'; const onCssAssetOwnershipChanged = rstest.fn(); From 1b7536b1bfd534c75c7afb159922cd41def19337 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:59:17 +0000 Subject: [PATCH 5/6] docs: track upstream CSS ownership workaround removal --- src/dev-css-assets.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dev-css-assets.ts b/src/dev-css-assets.ts index e4e4989b..4ae0721c 100644 --- a/src/dev-css-assets.ts +++ b/src/dev-css-assets.ts @@ -8,6 +8,11 @@ import { export const stripDevCssVersion = (url: string): string => url.replace(/\.__react_router_css_[a-f0-9]{64}\.css(?=[?#]|$)/, ''); +// TODO: Once supported Rspack versions include the ownership fix below, remove +// this plugin and its registration in index.ts. Verify CSS edit/restore/remount +// preserves form state and async chunk CSS still hot-updates. Keep immutable +// CSS aliases: the upstream fix does not address stale document preloads. +// https://github.com/web-infra-dev/rspack/pull/15879 (fixes issue #15845) export const devCssOwnershipPlugin: Rspack.RspackPluginInstance = { apply(compiler) { compiler.hooks.compilation.tap('ReactRouterCssOwnership', compilation => { @@ -48,7 +53,7 @@ export const versionDevCssAssets = ( .digest('hex'); const bareName = name.replace(/[?#].*$/, ''); const alias = `${bareName}.__react_router_css_${version}.css`; - // A distinct path keeps Rspack HMR from removing React-owned links. + // A distinct path identifies the exact CSS bytes for document preloads. // The same directory preserves relative CSS URLs. Development output // retention keeps old manifest URLs serving their original bytes. if (!compilation.getAsset(alias)) { From d91d6c8a376acd0f1eb56806a6416c2dde1e1970 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 28 Sep 2026 19:22:03 -0700 Subject: [PATCH 6/6] fix: replay manifest to clients connected before initial build --- src/dev-runtime-controller.ts | 12 +++++++++++- tests/dev-runtime-controller.test.ts | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 7831be49..37d2e4ab 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -314,7 +314,17 @@ export const createReactRouterDevRuntimeController = ({ const binding = sessions.createBinding(server, runtime); manifestSubscriptions.set( binding, - server.environments.web.hot.onConnect(client => { + server.environments.web.hot.onConnect(async client => { + if (sessions.getActiveBinding() !== binding || !isHmrEnabled()) { + return; + } + if (!runtime.getCommittedManifest()) { + try { + await runtime.load(); + } catch { + return; + } + } const manifest = runtime.getCommittedManifest(); if ( sessions.getActiveBinding() === binding && diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index 230d5724..4914757c 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -791,7 +791,20 @@ describe('React Router development runtime controller', () => { }); }; + const earlyClient = { send: rstest.fn() }; + const connections = (server.environments.web.hot.onConnect as any).mock.calls.map( + ([listener]: any) => listener(earlyClient) + ); + expect(earlyClient.send).not.toHaveBeenCalled(); await finishCompile('web-base', { hasClientLoader: false }); + await Promise.all(connections); + expect(earlyClient.send).toHaveBeenCalledWith( + 'custom', + expect.objectContaining({ + event: 'react-router:manifest-update', + data: expect.objectContaining({ version: 'web-base' }), + }) + ); await finishCompile('web-next', { hasClientLoader: true, clientLoaderModule: '/routes/about.clientLoader.js',