Skip to content

[Bug]: dev server build embedded in a custom server/index bundle keeps a stale, content-addressed browser manifest (0.8.x) #159

Open

Description

@jaknas

Plugin version: 0.8.2 (works on 0.7.2) · @rsbuild/core: 2.2.12 · @rspack/core: 2.2.8 · react-router: 7.18.4

Problem

With pluginReactRouter({ customServer: true }) and a server/index.ts entry that imports virtual/react-router/server-build itself (the layout examples/epic-stack uses), the dev SSR HTML references a browser manifest the dev server does not serve:

<link rel="modulepreload" href="/static/js/manifest-2492e836.js"/>
GET /static/js/manifest-2492e836.js -> 200 text/html   (falls through to the SSR handler)
GET /static/js/manifest-6dd56d97.js -> 200 text/javascript

The page never hydrates.

The server build embedded in the static/js/app bundle carries a manifest that was generated before the web compilation finished. Its route css arrays are empty, so its sha256 version differs from the published browser manifest:

static/js/app (server/index.ts)      version 2492e836  routes.root.css: []
loadReactRouterServerBuild(devServer) version 6dd56d97  routes.root.css: ["/static/css/root.css.__react_router_css_<hash>.css"]

The embedded copy is never regenerated. It stays at 2492e836 after source edits that rebuild both environments, and after the content of node_modules/.cache/react-router/<hash>/server-manifest.json changes (it holds the correct 6dd56d97 snapshot). loadReactRouterServerBuild(devServer) returns the correct pair the whole time.

0.7.2 used a fixed dev manifest URL (static/js/virtual/react-router/browser-manifest.js?v=...), so the stale embedded copy still loaded. Since #138 (0.8.0), dev manifest filenames are content-addressed, so the mismatch breaks the page.

Reproduction

// server/index.ts (bundled as static/js/app)
import { createRequestHandler } from '@react-router/express';
export const handler = createRequestHandler({
  build: () => import('virtual/react-router/server-build'),
});

// server/start.js
const devServer = await rsbuild.createDevServer();
app.use(devServer.middlewares);
app.all('/{*splat}', async (req, res, next) => {
  const mod = await devServer.environments.node.loadBundle('static/js/app');
  await mod.handler(req, res, next);
});

Start the dev server with an empty node_modules/.cache/react-router, request a page, and compare the manifest-*.js URL in the HTML with the one the web environment emits.

Workaround

Keep the routes and server setup from the server/index bundle, and take assets from the plugin's paired build:

const mod = await devServer.environments.node.loadBundle('static/js/app');
const paired = await loadReactRouterServerBuild(devServer);
// build = { ...embeddedServerBuild, assets: paired.assets }

Using the paired build wholesale isn't an option for us. static/js/react-router-server-build is a separate bundle with its own module instances, so singletons initialized by server/index.ts (a logger, in our case) are uninitialized in the route modules it evaluates.

Question

Which setup is supported for a server/index entry in dev?

  1. server/index may import virtual/react-router/server-build (as examples/epic-stack does), and the embedded manifest should stay paired with the published browser manifest. That makes this a bug.
  2. server/index must take its build from loadReactRouterServerBuild(devServer). In that case, is there a way for the paired build to share module instances with the server/index bundle, or should the docs say that server/index must not read its own embedded server build?

Activity

  1. dajiaohuang commented on Oct 22, 2026

    @dajiaohuang

    The current documented custom-server development path is to use the plugin's paired build provider rather than load the server bundle directly. The Custom Server Setup section sets build = () => loadReactRouterServerBuild(currentDevServer) and passes that provider to createRequestHandler. It says this returns the last-good server build paired with the selected web compilation, and explicitly warns that calling devServer.environments.node.loadBundle() directly bypasses that guarantee.

    For module instances needed by React Router createContext, the sharing section shows re-exporting the context from app/entry.server.tsx and reading it from (await build()).entry.module. That documents the context case; it does not claim that arbitrary logger singletons initialized only in server/index.ts are shared automatically.

    Does using the documented build provider resolve the stale-manifest case in your setup? If it still reproduces, a minimal example using that provider would help clarify whether further integration guidance is needed.

  2. dajiaohuang commented on Oct 27, 2026

    @dajiaohuang

    Correction to my earlier update: PR #163 does not use the paired build wholesale. It injects loadReactRouterServerBuild(devServer) from the unbundled launcher, keeps the embedded build as the handler base, and overlays only the paired build's assets. This preserves the route modules/module instances while updating the browser manifest, matching the asset-merge approach in your report. The README now documents this option and the module-graph boundary.

    This addresses the repository's Epic Stack example and guidance; plugin runtime behavior for arbitrary custom servers is unchanged. Please let me know whether this asset-merge path resolves your setup. If it does not, a minimal repro or result would help.

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