Skip to content

ditto-agent/react-router-prerender-suspense-flash

Repository files navigation

Reproduction: Suspense fallback flash on prerendered routes in React Router

Minimal reproduction of a bug where prerendered routes ship the Suspense fallback in the initial shell with the actual content emitted as a hidden <template> + inline $RC() swap script. When the static HTML is served at runtime, the browser paints the fallback first, then runs the swap script — producing a visible "fallback flash" on initial paint.

The Suspense boundary's children render fully synchronously (nothing actually suspends). The flash is purely an artifact of React's streaming-SSR progressiveChunkSize outlining heuristic being applied to a non-streaming consumer (the prerender pipeline buffers the response to disk via response.text()).

Reproduce

bun install
bunx react-router build

Open build/client/index.html. Search for SUSPENSE FALLBACK:

grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 2  (one in the shell as the fallback, one in the bundled JS)

The body opens with React's deferred-Suspense markup — the fallback in the shell, the actual route content in a hidden <div id="S:0">, and an inline <script>$RC("B:0","S:0")</script> that swaps them at runtime:

<body>
  <!--$?--><template id="B:0"></template>
  <div style="...">⚠ SUSPENSE FALLBACK</div>
  <!--/$-->
  ...
  <div hidden id="S:0">
    <main><h1>HOME PAGE</h1>...actual content...</main>
  </div>
  <script>$RC("B:0","S:0")</script>
</body>

To see the flash visually, serve the file:

bunx react-router-serve ./build/server/index.js
# or any static server pointed at build/client/

…then open the page in a browser. On a fast machine the flash is brief but visible (LCP comes after the swap).

Why this happens

React's renderToPipeableStream "outlines" a <Suspense> boundary when the rendered HTML is about to exceed progressiveChunkSize (default 12,800 bytes). Outlining writes the fallback into the shell and emits the actual content later as <template> + inline $RC() swap. The point of the optimization is to flush the shell early so the browser can start parsing / painting while the rest of the response streams in.

For prerendered routes there is no streaming. prerenderRoute calls the user's handleRequest and does await response.text(), capturing the entire stream into a string written to disk. The early-flush "win" buys nothing — the consumer is buffering the whole thing anyway. But the outlining decision is made during the render and bakes the shell-with-fallback + swap-template structure into the static file. Serving it later means the browser still paints the fallback first.

The 12.8 KB threshold is easy to trip in real apps:

  • The <head> includes <link rel="modulepreload"> for every route module on the path; v8_splitRouteModules: 'enforce' makes that worse (every route export is its own chunk).
  • A top-level <Suspense> is a common composition pattern (e.g. with TanStack Query's <HydrationBoundary> and any descendant useSuspenseQuery).
  • The bug is content-sensitive in surprising ways — adding or removing a few bytes inside the boundary can flip whether outlining occurs, since it tips byte totals across the threshold.

Workaround

react-router's prerender constructs its synthetic Request with no user-agent header. You can detect that in app/entry.server.tsx and disable outlining only for prerender:

return new Promise((resolve, reject) => {
  let shellRendered = false;
  let userAgent = request.headers.get("user-agent");
  let isPrerender = !userAgent;

  let readyOption: keyof RenderToPipeableStreamOptions =
    (userAgent && isbot(userAgent)) || routerContext.isSpaMode
      ? "onAllReady"
      : "onShellReady";

  // ...

  const { pipe, abort } = renderToPipeableStream(
    <ServerRouter context={routerContext} url={request.url} />,
    {
      progressiveChunkSize: isPrerender ? Infinity : undefined,
      [readyOption]() {
        /* ... */
      },
      // ...
    },
  );
});

Runtime SSR keeps its streaming behavior; prerender renders content inline, no flash.

To verify the workaround:

bunx react-router reveal entry.server
# apply the diff above, then:
bunx react-router build
grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 1  (only in the bundled JS — no longer in the rendered HTML shell)

Suggested framework-level fix

react-router could pass a header on its synthetic prerender requests (e.g. X-React-Router-Prerender: 1), and the default entry.server.tsx template could check for it and pass progressiveChunkSize: Infinity when present. Or expose a routerContext.isPrerender flag, similar to the existing routerContext.isSpaMode.

Environment

  • react-router: 7.15.0
  • react-dom: 19.2.x
  • Node.js: 24.14.1
  • Bun: 1.3.x
  • macOS

The bug also reproduces on react-router 7.13.0.

About

Minimal reproduction of Suspense fallback flash on prerendered routes in React Router

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors