Skip to content

perf(ssr): head-executing module scripts — preinit client-reference chunks, bootstrapModules entry - #694

Merged
ivogt merged 1 commit into
mainfrom
perf/head-script-preinit
Jul 5, 2026
Merged

ivogt merged 1 commit into
mainfrom
perf/head-script-preinit

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Client-reference chunks now ship as executing <script type="module" async> tags hoisted into <head>, and the browser entry ships as Fizz bootstrapModules — the strategy Next.js uses (via ReactDOM.preinit + bootstrapScripts), module-flavored for Vite. Zero consumer code changes; the delta is entirely in the served HTML. Flippable: rango({ headScripts: "preinit" | "preload" }), default "preinit" — "preload" reproduces the previous output byte for byte, so the two strategies can be A/B-measured on any app by flipping one plugin option.

Problem

A Rango document previously shipped its JavaScript as <link rel="modulepreload"> hints (one per client-reference chunk, emitted by plugin-rsc's preloadModule calls) plus one inline <script>import("/assets/index-….js")</script> at end of shell. modulepreload fetches and compiles but never executes: every chunk sat parsed-but-inert until the entry's hydration import walked the module graph — after the whole document had streamed. The inline bootstrap also hid the entry URL from the preload scanner (it is only discoverable by executing the script), so the entry fetch could not start until the parser reached the end of the shell.

Under PPR this mattered most: the stored prelude carried only hints, so a CDN-fast shell still executed zero JavaScript until the end-of-shell inline script ran.

Before vs. after (served HTML, any app)

Before:

<head>
  <link rel="modulepreload" href="/assets/client-….js" crossorigin="">   <!-- hint only -->
  <link rel="modulepreload" href="/assets/router-….js" crossorigin="">
  ...
</head>
<body>
  ...shell...
  <script>import("/assets/index-….js")</script>   <!-- URL invisible to the preload scanner -->

After:

<head>
  <link rel="modulepreload" fetchPriority="low" href="/assets/index-….js"/>  <!-- entry hint -->
  <script src="/assets/client-….js" type="module" async crossorigin=""></script>  <!-- executing -->
  <script src="/assets/router-….js" type="module" async crossorigin=""></script>
  ...
</head>
<body>
  ...shell...
  <script type="module" src="/assets/index-….js" id="_R_" async></script>   <!-- entry, end of shell -->

Chunk execution now overlaps body streaming instead of queuing behind the hydration import walk, and the entry fetch starts from the first flushed bytes. Under PPR all of it lives in the stored prelude. There are no duplicate tags: React's Fizz upgrades a queued preloadModule for the same URL in place when preinitModule follows it (clears the hint chunks, adopts its credentials).

End-to-end consumer example

Nothing to adopt — any existing app picks this up on upgrade. To opt out (or A/B):

// vite.config.ts
export default defineConfig({
  plugins: [rango({ preset: "node", headScripts: "preload" })], // previous behavior
});

To see the default:

pnpm build && pnpm preview --port 4173
curl -s -H "Accept: text/html" http://localhost:4173/ | grep -E 'modulepreload|type="module"'

For a CSP app the request nonce lands on every executing tag automatically:

// router.tsx — unchanged, the existing nonce provider is enough
const router = createRouter({ /* ... */ });
router.use(cspMiddleware); // sets script-src 'self' 'nonce-…' 'strict-dynamic'
<script src="/assets/router-….js" type="module" async crossorigin="" nonce="BBhi3XZf…"></script>
<script type="module" src="/assets/index-….js" nonce="BBhi3XZf…" id="_R_" async></script>

Semantics

Case Before After (default "preinit")
rango({ headScripts: "preload" }) n/a identical to the Before column everywhere below
Client-reference chunk (build) <link rel="modulepreload"> hint in head executing <script type="module" async> in head (preinit upgrade removes the hint)
Browser entry (build + dev) inline <script>import("…")</script> at end of shell head modulepreload fetchpriority=low hint + executing <script type="module" id="_R_" async> at end of shell
Client-reference chunk (dev) no hints (plugin-rsc reports js: [] in dev) unchanged — no head preinits in dev
Custom bootstrap content (not exactly import("<url>")) inline bootstrapScriptContent unchanged — regex-gated fallback keeps the inline form
CSP nonce n/a on hints request nonce stamped on every executing tag (ALS channel; Fizz stamps the bootstrap pair itself)
PPR capture hints in stored prelude executing tags + bootstrap pair in stored prelude
PPR resume n/a no re-emission: preinit dedupe markers and cleared bootstrap fields serialize inside the postponed state; refs first discovered during resume preinit into the resumed stream
Chunks first referenced after the shell (late Suspense content) hint emitted mid-stream executing script emitted mid-stream (Fizz hoistables)

Code changes

  • packages/rangojs-router/src/ssr/preinit-client-references.ts (new) — the whole mechanism:

    export function installClientReferencePreinit(setOnClientReference: SetOnClientReference): void {
      setOnClientReference(({ deps }) => {
        const nonce = preinitNonceStorage.getStore();
        for (const href of deps.js) {
          preinitModule(href, { as: "script", crossOrigin: "", nonce });
        }
      });
    }

    plugin-rsc's onClientReference fires synchronously inside the Fizz render, at the same instant as its own preloadModule calls, so Float attributes the resource to the correct request. The nonce is per request while the hook is per isolate; runWithPreinitNonce (AsyncLocalStorage) is the only channel that survives from the render call into the client-reference proxy access — a module variable would race across interleaved concurrent Fizz renders. This location (SSR package + virtual-entry wiring) keeps @rangojs/router/ssr free of plugin-rsc imports, consistent with every other injected dependency.

  • packages/rangojs-router/src/ssr/index.tsx — resolveBootstrapOptions converts content matching exactly import("<url>") (the shape plugin-rsc generates in both dev and build) to bootstrapModules: [url]; anything else falls back to bootstrapScriptContent unchanged. Applied in renderHTML and the PPR capture prerender; renderHTML/resumeShellHTML wrap their renders in runWithPreinitNonce.

  • packages/rangojs-router/src/deps/ssr.ts — re-export setOnClientReference from @vitejs/plugin-rsc/ssr.

  • packages/rangojs-router/src/vite/plugins/virtual-entries.ts — VIRTUAL_ENTRY_SSR becomes getVirtualEntrySSR(headScripts): "preinit" installs the hook at module scope (covering live SSR, shell capture, and resume) and threads headScripts into all three handler factories; "preload" omits the hook entirely.

  • packages/rangojs-router/src/vite/plugin-types.ts, rango.ts, utils/shared-utils.ts, vite/index.ts — the headScripts?: "preinit" | "preload" plugin option (HeadScriptsOption, exported), resolved in rango() and passed to createVirtualEntriesPlugin on both the cloudflare and node/vercel paths. Custom SSR entries opt in per handler via SSRDependencies.headScripts.

  • Docs: feature-map.md (./ssr export row, ./internal/deps/ssr row), feature-file-map.md (SSR row) updated in the same PR.

Test plan

  • Unit (src/ssr/__tests__/preinit-client-references.test.tsx, 10 tests): bootstrap conversion for prod/dev/quoting variants and both fallback shapes; "preload" AND undefined (custom entry) keep the inline bootstrap verbatim; real-Fizz renders pinning (a) the executing module script for a client-reference chunk, (b) the preload-upgrade contract — preloadModule then preinit leaves no rel="modulepreload" in the output, (c) the ALS nonce channel end to end through createSSRHandler.
  • Unit (src/vite/__tests__/virtual-entry-ssr.test.ts, 4 tests): the generated entry installs the hook only in "preinit" mode, threads headScripts into all three handler factories, and BOTH generated variants parse as valid modules (parseAstAsync).
  • PPR composite HIT (e2e/shell-cache.test.ts, dev + production via the shared spec): the frozen prelude carries the executing module scripts (bootstrap id="_R_" included) exactly once each, and the resumed tail re-emits none — pins the capture→resume dedupe through the serialized postponed state.
  • Flip verification on e2e/mini: rango({ headScripts: "preload" }) build serves 6 modulepreload hints, zero head executing scripts, and the inline import() bootstrap carrying id="_R_" — the exact previous output.
  • E2e dev + production, node preset (packages/rangojs-router/e2e/head-script-preinit.test.ts, 4 tests): production head has executing async module scripts and the only surviving modulepreload is the entry hint paired with the id="_R_" bootstrap; dev pins the bootstrap pair; both modes hydrate cleanly.
  • E2e dev + production, workerd (tests/cloudflare-basic/e2e/head-script-preinit.test.ts, 4 tests): same contract on the Cloudflare preset — also pins that the ALS import works in the workerd SSR environment.
  • CSP (tests/react-experimental/e2e/gtm.test.ts, extended): every executing module script in the document carries the exact nonce the content-security-policy header names, dev + production (8 tests green).
  • PPR regression: shell-cache, shell-cache-action, shell-secure — 61 passed (one pre-existing-looking dev bake-lane timing test was flaky and passed on retry; not verified against the base commit).
  • mini e2e 34 production + 33 dev; pnpm run typecheck (16/16), pnpm run test:unit:all (all packages), pnpm run lint, pnpm run format, pnpm check:e2e-bucketing, pnpm check:docs-api.

Performance verification

Measured, not assumed — the explicit goal was zero hidden degradation.

  • Server hot path: no measurable per-request overhead. On the deduped path (all accesses after a chunk's first emit) the preinit hook collapses to ~2 small object allocations + a hasOwnProperty check inside React's moduleScriptResources dedupe; it rides plugin-rsc's pre-existing per-access preloadDeps proxy loop at identical frequency. The ALS wrap runs once per render and is skipped entirely when no nonce is set; the bootstrap regex runs once per render; entry generation is build-time only.
  • Lighthouse A/B (headScripts flip, same app, 3 runs each, mobile simulated throttling): mini — identical (90 score, ~2857 ms FCP/LCP, TBT 0 both modes). cloudflare-basic (workerd preview) — preinit 89/89/89 score with FCP 2863–2871 ms vs preload 89/89/88 with FCP 3025–3079 ms (~160 ms better FCP on preinit); LCP/TTI ~3016 vs ~3025–3079; TBT 0 both.
  • Hydration-start probe (Fast-3G + 4x CPU via CDP, 5 runs, median ms to data-hydrated): preinit 2106 vs preload 2107 — identical within noise.

Known trades, deliberate and documented in preinit-client-references.ts (all measured neutral-to-positive above):

  • The entry's modulepreload hint is fetchpriority=low (Fizz hardcodes it for bootstrap hints — the identical shape Next.js ships), replacing plugin-rsc's previous default-priority entry preload, which the bootstrapModules dedupe marker suppresses.
  • Executing async module scripts fetch at Chromium's async-script priority, below a bare hint; preinitModule forwards no fetchPriority (React API limitation — preinit does, preinitModule does not). Execution overlap is bought with hint priority — the same trade Next.js makes.
  • Chunk preinit is build-only (plugin-rsc reports js: [] per reference in dev), so a client module whose module scope assumes body-parsed DOM could break in production only; headScripts: "preload" is the escape hatch.
  • plugin-rsc's setOnClientReference is single-slot last-write-wins; another registrant in the SSR environment would silently replace the hook (upstream composition API is a follow-up ask).

Review findings applied

A multi-angle review pass (simplification/reuse/efficiency/altitude + verified correctness finders) produced these fixes on top of the initial implementation:

  • Bootstrap conversion is now explicit-opt-in (headScripts === "preinit", not !== "preload"): a custom SSR entry with headScripts undefined keeps its inline import() bootstrap byte-for-byte on upgrade — previously it would have silently converted without ever installing the preinit hook, breaking CSPs that allowlist the inline bootstrap by script hash. The generated entry always passes an explicit value, so rango() app behavior is unchanged.
  • runWithPreinitNonce skips the ALS frame when no nonce is set — non-CSP requests pay nothing.
  • Both generated entry variants are parse-tested (parseAstAsync), closing the gap where a "preload"-only splice error would ship green (no in-repo app compiles that variant).
  • HeadScriptsOption is the single union source (type-only imports in the ssr handler and entry generator), so a future third strategy can't silently drift between the plugin and the handlers.
  • E2e parsing helpers centralized in @shared/e2e (the package that exists precisely to stop cross-suite assertion drift); dead public re-exports trimmed; internal docs corrected to match the final export surface.

Notes

  • The preinit upgrade relies on ordering: plugin-rsc's preloadModule runs first in the same synchronous block, then our preinit clears the queued hint (ReactFizzConfigDOM preinitModuleScript, renderState.preloads.moduleScripts). If plugin-rsc ever reorders those calls the result degrades to hint + script (harmless duplication), not breakage.
  • Dev intentionally has no head chunk scripts (plugin-rsc resolves js: [] per reference in dev), matching today's no-hints dev behavior; the bootstrapModules conversion does apply in dev.
  • If a lost ALS context ever drops the nonce, the affected script is CSP-blocked and execution falls back to the hydration import walk under strict-dynamic — today's behavior, never a wrong nonce.
  • Rebased over perf(ppr): eager tail handover — first tail byte no longer waits for the resume shell; server-side segment timings + absolute tail/match taps #693 (eager tail handover): the resume-path nonce wrap now lives inside the detached resume()/pipeTo block; the eager-handover semantics are untouched.
  • Deliberately not done: an e2e app permanently configured with headScripts: "preload" (the flag is unit-pinned and was flip-verified manually on mini; a dedicated fixture app felt disproportionate), and upstreaming a preload | preinit mode to plugin-rsc's preloadDeps (would replace the hook; filed as a follow-up idea).

@ivogt
ivogt force-pushed the perf/head-script-preinit branch from 6b828dd to 76dc7d4 Compare July 5, 2026 20:32
@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@694

commit: 126b442

…hunks, bootstrapModules entry

Client-reference chunks now ship as executing <script type="module" async>
tags hoisted into <head> instead of bare modulepreload hints, and the browser
entry ships as Fizz bootstrapModules (head modulepreload fetchpriority=low
hint + executing end-of-shell id=_R_ module script) instead of an opaque
inline import(). Chunk execution overlaps body streaming instead of waiting
for the hydration import walk; under PPR everything lands in the stored
prelude so execution starts on the first flushed bytes.

Flippable: rango({ headScripts: "preinit" | "preload" }), default
"preinit"; "preload" reproduces the previous hint-only output byte for
byte (verified on e2e/mini). Threaded through getVirtualEntrySSR into
SSRDependencies.headScripts for custom SSR entries.

Mechanism: installClientReferencePreinit hooks plugin-rsc's
setOnClientReference (fires at the same instant as its preloadModule calls)
and calls ReactDOM.preinitModule per JS dep; Fizz's preinitModuleScript
upgrades the queued preload in place (clears the hint, adopts creds), so no
duplicate tags. Per-request CSP nonce reaches the isolate-global hook via
AsyncLocalStorage (runWithPreinitNonce). Capture->resume dedupe is free:
moduleScriptResources null-markers serialize inside the postponed state.
resolveBootstrapOptions converts only exact import("<url>") content;
anything else falls back to bootstrapScriptContent unchanged.
@ivogt
ivogt force-pushed the perf/head-script-preinit branch from 76dc7d4 to 126b442 Compare July 5, 2026 21:18
@ivogt
ivogt merged commit 95b87f5 into main Jul 5, 2026
61 of 63 checks passed
@ivogt
ivogt deleted the perf/head-script-preinit branch July 5, 2026 21:28
ivogt added a commit that referenced this pull request Jul 5, 2026
…he transient DOM (#696)

The direct-load streaming-error test asserted the loading fallback was
visible in the live DOM before the error boundary replaced it. That window
is hydration-speed-dependent, and #694's head-executing module scripts
shrink it by design: page.goto resolves at load (the error already
streamed), and on a warm runner hydration swaps in the error boundary
before the first locator poll — failed 4/4 on a fast local preview, flaked
on CI shard rangojs-router-e2e (production, 1, 2), while slower runners
pass. The contract worth pinning is deterministic on the stream: the shell
ships the fallback markup, React's errored-boundary marker (<!--$!-->)
arrives later in the same body, and the browser lands on the error
boundary. The SPA-navigation sibling keeps its DOM assertion — its window
is the full 500ms handler delay on the client transition, not a hydration
race. Red-before-green: old form 4/4 red locally, new form 4/4 green
production + green dev; full error-boundary file 18/18.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant