Skip to content

Prerendered routes ship Suspense fallback in shell with swap script, causing fallback flash on initial paint #15025

@jbojcic1

Description

@jbojcic1

Reproduction

Minimal repro: https://github.com/ditto-agent/react-router-prerender-suspense-flash

git clone https://github.com/ditto-agent/react-router-prerender-suspense-flash
cd react-router-prerender-suspense-flash
bun install
bunx react-router build
grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 2  (one in the shell as the deferred fallback, one in the bundled JS)

Or to reproduce in your own app:

  1. Configure ssr: true in react-router.config.ts and add a route to prerender.
  2. Enable v8_splitRouteModules: 'enforce' (any reasonably-sized app with many routes will reproduce; this flag just makes the head large enough to trip the threshold reliably).
  3. In root.tsx, wrap <Outlet /> in <Suspense fallback={<LoadingScreen />}> — a common pattern for protecting against descendant useSuspenseQuery calls. The fallback should be visibly distinct (e.g. a centered logo).
  4. npm run build. Open build/client/<prerendered-route>/index.html.

System Info

System:
    OS: macOS 26.3.1
    CPU: (12) arm64 Apple M2 Pro
    Memory: 376.53 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 24.14.1 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/node
    Yarn: 1.22.22 - /opt/homebrew/bin/yarn
    npm: 11.11.0 - /Users/ditto/.local/state/fnm_multishells/99520_1778257878168/bin/npm
    bun: 1.3.11 - /nix/store/cgzjp11cgzg8n349rqmbqz46izm2kl2k-bun-1.3.11/bin/bun
  Browsers:
    Chrome: 147.0.7727.139
    Safari: 26.3.1
  npmPackages:
    @react-router/dev: 7.13.0 => 7.13.0
    @react-router/express: 7.13.0 => 7.13.0
    @react-router/fs-routes: 7.13.0 => 7.13.0
    @react-router/node: 7.13.0 => 7.13.0
    react-router: 7.13.0 => 7.13.0
    vite: 7.3.1 => 7.3.1

Used Package Manager

npm

Expected Behavior

The <body> of the prerendered file contains the route's rendered content directly. When the file is served at runtime, the user sees the actual page on first paint.

Actual Behavior

The <body> opens with React's deferred-Suspense markup — the fallback is rendered in the shell, and the actual route content sits below in a hidden <div> paired with an inline $RC() swap script:

<body class="overflow-hidden">
  <!--$?--><template id="B:0"></template>
  <div class="fixed inset-0 ..."><img alt="Loading Logo" .../></div>
  <!--/$-->
  ...
  <div hidden id="S:0"><div>... actual route content ...</div></div>
  <script>$RC("B:0","S:0")</script>
</body>

When the browser loads this static HTML, it paints the LoadingScreen first; the inline $RC() script then swaps in the real content. The result is a visible fallback flash on initial paint, even though nothing inside the Suspense boundary
actually suspended during the SSR render.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions