From e0c37bf20c9286181c0855bfd7d33ff717bb7612 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 21:17:21 -0700 Subject: [PATCH 1/2] 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/2] 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(); + } + }); +}