Plugin version: 0.7.0 and 0.7.1 · @rsbuild/core: 2.2.1 · @rspack/core: 2.2.1 · @rsbuild/plugin-react: 2.1.0 · react-router / @react-router/dev / @react-router/node: 8.3.1 · react: 19.2.8 · Node: v24.11.0 · pnpm: 12.3.4 · OS: macOS 26.6.2 (Darwin 25.6.0)
Problem
With ssr: false and Rsbuild's persistent cache enabled (performance.buildCache: true), a warm build whose root route chunk hash changed emits an index.html whose <link rel="modulepreload">s and hydration <script type="module"> import the previous build's /static/js/manifest-<hash>.js and /static/js/root.<hash>.js. Those files are not in the new output, so the module script 404s and the page never hydrates. The HTML body is rendered from the new root code (an edited label shows up), and the client-side manifest-<hash>.js on disk already points at the new root chunk; only the URLs baked into the HTML are stale. With performance.buildCache: false everything lines up.
Mechanism, as far as I traced it: SPA mode renders index.html by executing the node (server) build. The server build reads its asset URLs from virtual/react-router/server-manifest, whose virtual source is the constant export default {}; (classic mode plan); the real content is injected by
api.transform({ test: /virtual\/react-router\/(browser|server)-manifest/ }, async (args) => {
// web: 'window.__reactRouterManifest = "PLACEHOLDER";'
// node: `export default ${jsesc(getLatestServerManifest() ?? ..., { es6: true })};`
});
(src/build-output-transforms.ts, lines 192 to 230 on main), where getLatestServerManifest() returns a value captured from the web compilation's processAssets stage report (onManifest in src/classic-mode.ts). So the node module's output depends on state outside the module (the web build's emitted asset names), while everything Rspack hashes for that module (virtual file content, resource, loader options) is identical between builds. Under buildCache the node compilation keeps the previous build's transformed server-manifest module even when the web compilation emitted new hashes. I verified the effect end to end (below: the server bundle itself carries the old URLs), not Rspack's cache internals.
Reproduction
Clone-and-run repo: https://github.com/RobHannay/rsbuild-plugin-react-router-spa-repros
git clone https://github.com/RobHannay/rsbuild-plugin-react-router-spa-repros
cd rsbuild-plugin-react-router-spa-repros
pnpm install
pnpm repro:stale-manifest
Three-route app, react-router.config.ts with ssr: false, rsbuild.config.ts with pluginReactRouter(), pluginReact() and performance.buildCache: true, no other plugins and no module-scope handles (so builds exit on their own). The script does a cold build with an empty node_modules/.cache, changes one string in src/root.tsx so the root chunk's content hash must change, does a warm build, and checks that every /static URL referenced by build/client/index.html exists in the warm output. BUILD_CACHE=false pnpm repro:stale-manifest is the control and passes. Versions are pinned in the repo's package.json (plugin 0.7.0, @rsbuild/core 2.2.1, @rspack/core 2.2.1 via override, react-router 8.3.1, Node 24.11.0, pnpm 12.3.4); 0.7.1 behaves the same.
Expected
build/client/index.html references the assets emitted by the same build, for a warm build exactly as for a cold one, so that any build with performance.buildCache enabled is deployable.
Actual, buildCache: true (plugin 0.7.0):
cold: exit=0 root chunk: root.0586d57356.js
warm: exit=0 root chunk: root.6b98314a19.js
URLs referenced by out-warm/index.html:
OK /static/js/entry.client.4c9a2a2abe.js
OK /static/js/lib-react.c6b050aed0.js
OK /static/js/lib-router.0e8b7d4b46.js
MISSING /static/js/manifest-97275d68.js
MISSING /static/js/root.0586d57356.js
OK /static/js/runtime.a4cfe9b8d3.js
Control, buildCache: false, same steps: every URL exists (OK /static/js/manifest-f9ce3d39.js, OK /static/js/root.6b98314a19.js, nothing MISSING).
Evidence
out-warm/index.html (buildCache on, excerpt): the body carries the new label, the head and hydration script carry the cold build's names.
<link rel="modulepreload" href="/static/js/manifest-97275d68.js"/> ... <link rel="modulepreload" href="/static/js/root.0586d57356.js"/></head>
<body><nav><a href="/" data-discover="true">Home (edited)</a> | ...
<script type="module" async="">import "/static/js/manifest-97275d68.js";
import * as route0 from "/static/js/root.0586d57356.js";
window.__reactRouterRouteModules = {"root":route0};
import("/static/js/entry.client.4c9a2a2abe.js");</script>
Files actually in out-warm/static/js/: entry.client.4c9a2a2abe.js lib-react.c6b050aed0.js lib-router.0e8b7d4b46.js manifest-f9ce3d39.js root.6b98314a19.js runtime.a4cfe9b8d3.js routes/ virtual/. The warm manifest-f9ce3d39.js on disk names root.6b98314a19.js, i.e. the browser manifest emitted from the web stats is correct; only the server-side copy used for the HTML is old.
The stale copy is in the server build. Repeating the same cold / edit / warm sequence with ssr: true (so build/server is kept and can be inspected) and grepping the server bundle for the asset names it embeds:
buildCache: true
cold: client root on disk root.0586d57356.js | server bundle names root.0586d57356.js, manifest-97275d68.js
warm: client root on disk root.6b98314a19.js | server bundle names root.0586d57356.js, manifest-97275d68.js <- stale
buildCache: false
cold: client root on disk root.0586d57356.js | server bundle names root.0586d57356.js, manifest-97275d68.js
warm: client root on disk root.6b98314a19.js | server bundle names root.6b98314a19.js, manifest-f9ce3d39.js
Same results with rsbuild-plugin-react-router@0.7.1 (published 2026-09-11): warm index.html imports manifest-d33384e7.js / root.b49d5ce2cb.js while the output contains manifest-6bd16114.js / root.ec2afb67fb.js, and the ssr: true server bundle keeps the cold names.
A warm rebuild of unchanged sources in this small project is byte-identical to the cold build (all 14 client files, index.html included), so here the stale HTML only appears after a change that moves the root or manifest hash. In the real app the hashes moved without any source change (next section), which is how we hit it.
Ruled out
- Not the hang from the other report: this project has no module-scope handles, both builds exit 0 in 1 to 2 s.
- Not a stale
build/ directory: rm -rf build before every build; only node_modules/.cache is carried over.
- Not the browser manifest:
manifest-<hash>.js and virtual/react-router/browser-manifest.<hash>.js in the warm output are correct.
- Not plugin version specific: 0.7.0 and 0.7.1.
buildCache: false is a complete workaround (at the cost of the cache).
Where this was found (real-app observation, not reproduced minimally)
Trialling the plugin on a production Rsbuild 2.2 SPA (about 107 routes, pluginReact({ reactCompiler: true }), @rsbuild/plugin-svgr 2.0.5, hidden-source-map, performance.buildCache: { buildDependencies: [pnpm-lock.yaml, package.json] }). There, a cold build (empty node_modules/.cache/rspack) and the immediately following warm build of identical sources produced different content hashes for 30 of 107 route chunks plus static/js/root.<hash>.js, static/js/manifest-<hash>.js and virtual/react-router/browser-manifest.<hash>.js, while 0 of 207 shared numbered chunks changed (md5 listings of 506 files per build; a diff of one changed route chunk showed only minifier identifier renames). build/client/index.html was byte-identical across four consecutive builds (md5 1089f910...), so the warm output's index.html imported /static/js/manifest-56a093e3.js and /static/js/root.03112d8bec.js while the output contained manifest-d07d1970.js and root.f461735c7f.js: a deploy whose module script 404s and never hydrates.
That same-source hash instability did not reproduce in the minimal project (cold vs warm byte-identical with 3 and 30 generated routes, a 48-export shared module, 90 to 210 SVGs through @rsbuild/plugin-svgr, reactCompiler, hidden-source-map, and buildDependencies), so it may be an Rspack-side cache property and is noted here as context only. The plugin-side half is what this report demonstrates: whenever the web build's root / manifest hashes change between a cold and a warm cached build, for whatever reason, the SPA-mode index.html is rendered from the previous manifest.
Possible fix directions
Make the server-manifest module's identity depend on its content, so the persistent cache cannot serve a previous build's copy: for example include a hash of the manifest (or the web compilation hash) in the virtual module's source or resource query, emit the manifest as a real file that the node compilation depends on, or mark that module as uncacheable / rebuild it explicitly in the node compilation whenever latestServerManifest changes.
Plugin version: 0.7.0 and 0.7.1 · @rsbuild/core: 2.2.1 · @rspack/core: 2.2.1 · @rsbuild/plugin-react: 2.1.0 · react-router / @react-router/dev / @react-router/node: 8.3.1 · react: 19.2.8 · Node: v24.11.0 · pnpm: 12.3.4 · OS: macOS 26.6.2 (Darwin 25.6.0)
Problem
With
ssr: falseand Rsbuild's persistent cache enabled (performance.buildCache: true), a warm build whose root route chunk hash changed emits anindex.htmlwhose<link rel="modulepreload">s and hydration<script type="module">import the previous build's/static/js/manifest-<hash>.jsand/static/js/root.<hash>.js. Those files are not in the new output, so the module script 404s and the page never hydrates. The HTML body is rendered from the new root code (an edited label shows up), and the client-sidemanifest-<hash>.json disk already points at the new root chunk; only the URLs baked into the HTML are stale. Withperformance.buildCache: falseeverything lines up.Mechanism, as far as I traced it: SPA mode renders
index.htmlby executing the node (server) build. The server build reads its asset URLs fromvirtual/react-router/server-manifest, whose virtual source is the constantexport default {};(classic mode plan); the real content is injected by(
src/build-output-transforms.ts, lines 192 to 230 on main), wheregetLatestServerManifest()returns a value captured from the web compilation'sprocessAssetsstagereport(onManifestinsrc/classic-mode.ts). So the node module's output depends on state outside the module (the web build's emitted asset names), while everything Rspack hashes for that module (virtual file content, resource, loader options) is identical between builds. UnderbuildCachethe node compilation keeps the previous build's transformed server-manifest module even when the web compilation emitted new hashes. I verified the effect end to end (below: the server bundle itself carries the old URLs), not Rspack's cache internals.Reproduction
Clone-and-run repo: https://github.com/RobHannay/rsbuild-plugin-react-router-spa-repros
git clone https://github.com/RobHannay/rsbuild-plugin-react-router-spa-repros cd rsbuild-plugin-react-router-spa-repros pnpm install pnpm repro:stale-manifestThree-route app,
react-router.config.tswithssr: false,rsbuild.config.tswithpluginReactRouter(),pluginReact()andperformance.buildCache: true, no other plugins and no module-scope handles (so builds exit on their own). The script does a cold build with an emptynode_modules/.cache, changes one string insrc/root.tsxso the root chunk's content hash must change, does a warm build, and checks that every/staticURL referenced bybuild/client/index.htmlexists in the warm output.BUILD_CACHE=false pnpm repro:stale-manifestis the control and passes. Versions are pinned in the repo'spackage.json(plugin 0.7.0, @rsbuild/core 2.2.1, @rspack/core 2.2.1 via override, react-router 8.3.1, Node 24.11.0, pnpm 12.3.4); 0.7.1 behaves the same.Expected
build/client/index.htmlreferences the assets emitted by the same build, for a warm build exactly as for a cold one, so that any build withperformance.buildCacheenabled is deployable.Actual,
buildCache: true(plugin 0.7.0):Control,
buildCache: false, same steps: every URL exists (OK /static/js/manifest-f9ce3d39.js,OK /static/js/root.6b98314a19.js, nothingMISSING).Evidence
out-warm/index.html(buildCache on, excerpt): the body carries the new label, the head and hydration script carry the cold build's names.Files actually in
out-warm/static/js/:entry.client.4c9a2a2abe.js lib-react.c6b050aed0.js lib-router.0e8b7d4b46.js manifest-f9ce3d39.js root.6b98314a19.js runtime.a4cfe9b8d3.js routes/ virtual/. The warmmanifest-f9ce3d39.json disk namesroot.6b98314a19.js, i.e. the browser manifest emitted from the web stats is correct; only the server-side copy used for the HTML is old.The stale copy is in the server build. Repeating the same cold / edit / warm sequence with
ssr: true(sobuild/serveris kept and can be inspected) and grepping the server bundle for the asset names it embeds:Same results with
rsbuild-plugin-react-router@0.7.1(published 2026-09-11): warmindex.htmlimportsmanifest-d33384e7.js/root.b49d5ce2cb.jswhile the output containsmanifest-6bd16114.js/root.ec2afb67fb.js, and thessr: trueserver bundle keeps the cold names.A warm rebuild of unchanged sources in this small project is byte-identical to the cold build (all 14 client files,
index.htmlincluded), so here the stale HTML only appears after a change that moves the root or manifest hash. In the real app the hashes moved without any source change (next section), which is how we hit it.Ruled out
build/directory:rm -rf buildbefore every build; onlynode_modules/.cacheis carried over.manifest-<hash>.jsandvirtual/react-router/browser-manifest.<hash>.jsin the warm output are correct.buildCache: falseis a complete workaround (at the cost of the cache).Where this was found (real-app observation, not reproduced minimally)
Trialling the plugin on a production Rsbuild 2.2 SPA (about 107 routes,
pluginReact({ reactCompiler: true }),@rsbuild/plugin-svgr2.0.5,hidden-source-map,performance.buildCache: { buildDependencies: [pnpm-lock.yaml, package.json] }). There, a cold build (emptynode_modules/.cache/rspack) and the immediately following warm build of identical sources produced different content hashes for 30 of 107 route chunks plusstatic/js/root.<hash>.js,static/js/manifest-<hash>.jsandvirtual/react-router/browser-manifest.<hash>.js, while 0 of 207 shared numbered chunks changed (md5 listings of 506 files per build; a diff of one changed route chunk showed only minifier identifier renames).build/client/index.htmlwas byte-identical across four consecutive builds (md51089f910...), so the warm output'sindex.htmlimported/static/js/manifest-56a093e3.jsand/static/js/root.03112d8bec.jswhile the output containedmanifest-d07d1970.jsandroot.f461735c7f.js: a deploy whose module script 404s and never hydrates.That same-source hash instability did not reproduce in the minimal project (cold vs warm byte-identical with 3 and 30 generated routes, a 48-export shared module, 90 to 210 SVGs through
@rsbuild/plugin-svgr,reactCompiler,hidden-source-map, andbuildDependencies), so it may be an Rspack-side cache property and is noted here as context only. The plugin-side half is what this report demonstrates: whenever the web build's root / manifest hashes change between a cold and a warm cached build, for whatever reason, the SPA-modeindex.htmlis rendered from the previous manifest.Possible fix directions
Make the server-manifest module's identity depend on its content, so the persistent cache cannot serve a previous build's copy: for example include a hash of the manifest (or the web compilation hash) in the virtual module's source or resource query, emit the manifest as a real file that the node compilation depends on, or mark that module as uncacheable / rebuild it explicitly in the node compilation whenever
latestServerManifestchanges.