Repository navigation
perf(ssr): head-executing module scripts — preinit client-reference chunks, bootstrapModules entry - #694
Merged
Conversation
ivogt
force-pushed
the
perf/head-script-preinit
branch
from
July 5, 2026 20:32
6b828dd to
76dc7d4
Compare
commit: |
…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
force-pushed
the
perf/head-script-preinit
branch
from
July 5, 2026 21:18
76dc7d4 to
126b442
Compare
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.
Merged
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Client-reference chunks now ship as executing
<script type="module" async>tags hoisted into<head>, and the browser entry ships as FizzbootstrapModules— the strategy Next.js uses (viaReactDOM.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'spreloadModulecalls) plus one inline<script>import("/assets/index-….js")</script>at end of shell.modulepreloadfetches 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:
After:
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
preloadModulefor the same URL in place whenpreinitModulefollows 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):
To see the default:
For a CSP app the request nonce lands on every executing tag automatically:
Semantics
"preinit")rango({ headScripts: "preload" })<link rel="modulepreload">hint in head<script type="module" async>in head (preinit upgrade removes the hint)<script>import("…")</script>at end of shellmodulepreload fetchpriority=lowhint + executing<script type="module" id="_R_" async>at end of shelljs: []in dev)import("<url>"))bootstrapScriptContentCode changes
packages/rangojs-router/src/ssr/preinit-client-references.ts(new) — the whole mechanism:plugin-rsc's
onClientReferencefires synchronously inside the Fizz render, at the same instant as its ownpreloadModulecalls, 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/ssrfree of plugin-rsc imports, consistent with every other injected dependency.packages/rangojs-router/src/ssr/index.tsx—resolveBootstrapOptionsconverts content matching exactlyimport("<url>")(the shape plugin-rsc generates in both dev and build) tobootstrapModules: [url]; anything else falls back tobootstrapScriptContentunchanged. Applied inrenderHTMLand the PPR capture prerender;renderHTML/resumeShellHTMLwrap their renders inrunWithPreinitNonce.packages/rangojs-router/src/deps/ssr.ts— re-exportsetOnClientReferencefrom@vitejs/plugin-rsc/ssr.packages/rangojs-router/src/vite/plugins/virtual-entries.ts—VIRTUAL_ENTRY_SSRbecomesgetVirtualEntrySSR(headScripts):"preinit"installs the hook at module scope (covering live SSR, shell capture, and resume) and threadsheadScriptsinto 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— theheadScripts?: "preinit" | "preload"plugin option (HeadScriptsOption, exported), resolved inrango()and passed tocreateVirtualEntriesPluginon both the cloudflare and node/vercel paths. Custom SSR entries opt in per handler viaSSRDependencies.headScripts.Docs:
feature-map.md(./ssrexport row,./internal/deps/ssrrow),feature-file-map.md(SSR row) updated in the same PR.Test plan
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 —preloadModulethen preinit leaves norel="modulepreload"in the output, (c) the ALS nonce channel end to end throughcreateSSRHandler.src/vite/__tests__/virtual-entry-ssr.test.ts, 4 tests): the generated entry installs the hook only in"preinit"mode, threadsheadScriptsinto all three handler factories, and BOTH generated variants parse as valid modules (parseAstAsync).e2e/shell-cache.test.ts, dev + production via the shared spec): the frozen prelude carries the executing module scripts (bootstrapid="_R_"included) exactly once each, and the resumed tail re-emits none — pins the capture→resume dedupe through the serialized postponed state.rango({ headScripts: "preload" })build serves 6 modulepreload hints, zero head executing scripts, and the inlineimport()bootstrap carryingid="_R_"— the exact previous output.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 theid="_R_"bootstrap; dev pins the bootstrap pair; both modes hydrate cleanly.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.tests/react-experimental/e2e/gtm.test.ts, extended): every executing module script in the document carries the exact nonce thecontent-security-policyheader names, dev + production (8 tests green).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).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.
hasOwnPropertycheck inside React'smoduleScriptResourcesdedupe; it rides plugin-rsc's pre-existing per-accesspreloadDepsproxy 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.headScriptsflip, 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.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):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.preinitModuleforwards no fetchPriority (React API limitation —preinitdoes,preinitModuledoes not). Execution overlap is bought with hint priority — the same trade Next.js makes.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.setOnClientReferenceis 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:
headScripts === "preinit", not!== "preload"): a custom SSR entry withheadScriptsundefined keeps its inlineimport()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, sorango()app behavior is unchanged.runWithPreinitNonceskips the ALS frame when no nonce is set — non-CSP requests pay nothing.parseAstAsync), closing the gap where a"preload"-only splice error would ship green (no in-repo app compiles that variant).HeadScriptsOptionis 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.@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
preloadModuleruns first in the same synchronous block, then our preinit clears the queued hint (ReactFizzConfigDOMpreinitModuleScript,renderState.preloads.moduleScripts). If plugin-rsc ever reorders those calls the result degrades to hint + script (harmless duplication), not breakage.js: []per reference in dev), matching today's no-hints dev behavior; the bootstrapModules conversion does apply in dev.strict-dynamic— today's behavior, never a wrong nonce.resume()/pipeToblock; the eager-handover semantics are untouched.headScripts: "preload"(the flag is unit-pinned and was flip-verified manually on mini; a dedicated fixture app felt disproportionate), and upstreaming apreload | preinitmode to plugin-rsc'spreloadDeps(would replace the hook; filed as a follow-up idea).