Skip to content

ssr: false + performance.buildCache: warm build renders index.html against the previous build's asset URLs (stale virtual/react-router/server-manifest), so the hydration script 404s #136

Description

@RobHannay

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions