Skip to content

perf(ppr): eager Flight injection on resume — hydration no longer waits for the slowest live loader; FE/server boot debug logs; directoryClientChunks export - #691

Merged
ivogt merged 4 commits into
mainfrom
experiment/fe-boot-debug
Jul 5, 2026

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Problem statement

On a PPR shell HIT, hydration could not start until the slowest live loader finished — even though the payload the client needed was ready within milliseconds. A consumer's storefront homepage painted its cached shell at ~70ms, but hydrateRoot waited 1.5–3.5s: rsc-html-stream's injectRSCPayload only starts forwarding Flight chunks after the first HTML chunk flows through it, and the resumed fizz render emits nothing until the first hole's loaders (per-request SFCC basket/wishlist calls) resolve. The entire hydration payload — root row included — sat parked in a buffer behind data it did not depend on. Separately, none of this was observable: FE debug logs under INTERNAL_RANGO_DEBUG only started at the first soft navigation, so the initial-document boot (flight decode → handle stream → tree build → hydrateRoot) was a black box.

Before vs. after

Before (consumer app, PPR homepage, warm shell HIT):

  70ms  FCP (shell paints)
1731ms  payload root reaches the client        ← parked behind basket/wishlist latency
3900ms  hydrateRoot (cold runs)

After (same page, same loaders):

  70ms  FCP
 +14ms into the tail: first flight script on the wire
 431ms  loader rows land (genuine API latency, no longer plumbing)
 520ms  hydration commit (production preview, warm HIT)

And the boot is now narrated end to end under INTERNAL_RANGO_DEBUG=1:

[Browser][boot] flight decode: awaiting initial payload from document stream @ 196ms
[Browser][boot] initial payload decoded @ 983ms {version, routerId, segments, matched}
[Browser][boot] handles: push #1 … stream complete {pushes: 1}
[Browser][segments] segment M0L0L0 {type: layout, loaders: [...], hasLoading: false}
[Browser][segments] segment M0L0L0: layout loaders awaited (blocking) {ms: 1}
[Browser][boot] initBrowserApp complete -- handing off to hydrateRoot @ 985ms
[Browser][boot] hydration commit (root effect flushed) @ 1080ms
[Server][ppr] shell HIT: prelude enqueued (2675771b) +0ms
[Server][ppr] flight render: first chunk +4ms
[Server][ppr] eager-inject: first flight script +14ms

End-to-end consumer example

Measured on a real consumer storefront (yarn workspaces, @cloudflare/vite-plugin, PPR homepage with three no-loading() chrome loaders backed by SFCC APIs), production build served via vite preview:

Metric Before After
First flight script on the wire ~1.5s (parked) +14ms into the tail
Client payload decode complete 787ms–3.5s 219–545ms
Hydration commit 1.3s–4.4s ~520ms (warm), ~1.08s (dev)

The structural win over the raw numbers: hydration start no longer depends on loader latency at all — a slow basket API can no longer delay interactivity of the shell.

Semantics

Case Before After
PPR resume (shell HIT tail) Flight forwarding starts after the first fizz chunk (gated on the slowest hole loader) Flight pumps eagerly from start(); fizz batches interleave atomically
PPR DATA variant (no fizz) needed a synthetic empty HTML chunk to avoid deadlock eager pump; the synthetic stream is kept but no longer load-bearing for startup
Normal document render (no PPR) stock injectRSCPayload unchanged — deliberately. The first bytes must be the doctype; an eager script would precede it
Flight chunk ordering within the stream unchanged unchanged (only when chunks are written into the HTML stream changes)
INTERNAL_RANGO_DEBUG unset / production build logs absent unchanged — every new log sits behind the baked flag and is minifier-DCE'd
clientChunks custom function replaces the built-in strategy wholesale can now delegate: directoryClientChunks is exported

Code changes

  • src/ssr/inject-rsc-eager.ts (new) — injectRSCPayloadEager: pumps Flight in start(), before any HTML exists. Ordering safety via a single output task queue: same-tick fizz chunks flush as one atomic batch (never split by a script), each Flight script is its own task; trailer stripped from passing HTML and re-appended once after both streams complete. Safe on resume because the stored prelude — a complete document through </body></html> — is already on the wire, so every tail byte is foster-parented.
  • src/ssr/index.tsx — createShellResumeHandler pipes through the eager injector for both the resume and DATA variants. createSSRHandler (normal path) is untouched.
  • src/rsc/rsc-rendering.ts — flag-gated [Server][ppr] stream timing taps (prelude enqueue, flight first chunk, first tail chunk) in serveShellHit, so this class of stall is attributable from the terminal.
  • src/browser/rsc-router.tsx, src/browser/logging.ts — bootLog() steps through initBrowserApp (flight decode, handle-stream consumption with per-push logs, bridge wiring, initial tree) plus a once-only hydration-commit signal from Rango's root effect.
  • src/segment-system.tsx, src/segment-loader-promise.ts — [Browser][segments] steps: per-segment tree build, blocking vs streaming loader awaits with timings, per-loader settle taps. All gated on the baked flag AND typeof window, so SSR/RSC renders stay silent and prod bundles drop the code entirely.
  • src/vite/index.ts — export directoryClientChunks so a custom clientChunks function can overlay the built-in route grouping instead of silently replacing it.
  • docs/internal/feature-file-map.md, docs/internal/feature-map.md — synced.

Why the injector lives in the router instead of patching rsc-html-stream: the lazy start is correct for the normal document path (upstream's only path); eagerness is only valid where a prelude already closed the document — that knowledge is rango's.

Test plan

  • Unit: src/ssr/__tests__/inject-rsc-eager.test.ts — 7 tests. The headline regression test ("emits flight scripts while the HTML side is still silent") drives the injector with a stalled HTML source and asserts the first output chunk is a flight script — the stock injector fails this by construction. Also pinned: atomic same-tick HTML batches (split-tag reassembly), single trailing </body></html>, </script escaping, nonce on every script tag, chunkless-source (DATA variant) no-deadlock, base64 fallback for invalid-unicode chunks.
  • E2e: shell-cache + shell-secure — 58 passed, dev + production (the entire PPR serve/resume surface over the new injector).
  • E2e: internal-debug-fe — 4 passed, dev + production (boot logs additive; existing flag-delivery contract intact).
  • semantic-matrix — 45 passed (incl. PPR3 consumption-lane row).
  • pnpm run typecheck, pnpm run test:unit:all (0 failures), pnpm run lint, pnpm run format, pnpm check:docs-api (167 identifiers verified).
  • Real-app verification: consumer storefront preview measured before/after (table above); server logs show first flight script +14ms vs first tail HTML at +226ms.

Notes

  • The cold path (shell MISS / expired TTL) still serves through the stock injector and still couples payload delivery to the document render; fixing that requires head-flush work on the normal path and is deliberately out of scope here.
  • The boot/segment logs are intended to stay: they are the instrumentation that located this bug and two others (a hydration-gate stall and a PPR bake-lane staleness issue tracked separately), and they cost nothing when the flag is off.
  • directoryClientChunks called without a ClientChunkContext leaves the app-fallback split inactive (discovery wires that only for the built-in default) — documented on the export.

ivogt added 4 commits July 5, 2026 19:57
…segment tree build, loader awaits, hydration commit

The initial document path was silent under INTERNAL_RANGO_DEBUG: FE logs
only started at the first soft navigation, so a boot stall (an await
holding initBrowserApp, and with it hydrateRoot) was invisible.

Adds [Browser][boot] steps through initBrowserApp (flight decode, handle
stream consumption with per-push logs, bridge wiring, initial tree) plus a
hydration-commit signal from Rango's root effect, and [Browser][segments]
steps through renderSegments (per-segment build, blocking vs streaming
loader awaits with timings, per-loader settle taps). All gated on the baked
flag + typeof window, so prod bundles DCE them and SSR/RSC stay silent.
…ion payload behind the slowest live loader

On a shell HIT the stock rsc-html-stream injectRSCPayload starts forwarding
Flight chunks only after the first fizz chunk, and the resumed fizz emits
nothing until the first hole's loaders resolve. The Flight root row (ready
~30ms into the tail render) therefore rode the response ~1.5s late and the
client could not call hydrateRoot until then — hydration start was gated on
the slowest live loader instead of on the payload being ready.

injectRSCPayloadEager pumps Flight immediately in start(); ordering safety
via a single output task queue (same-tick fizz chunks flush as one atomic
batch, scripts only land between batches; trailer stripped/re-appended
once). Resume + DATA variants only — the normal document path keeps the
stock injector (scripts must not precede the doctype there).

Also adds INTERNAL_RANGO_DEBUG-gated [Server][ppr] stream-timing logs
(prelude enqueue, first flight chunk, first flight script, first resumed
HTML batch, first tail chunk) so hydration-start latency is attributable
to real server work vs stream plumbing.

7 unit tests (ordering, trailer, escaping, nonce, DATA variant, base64
fallback); shell-cache + shell-secure e2e: 58 passed (dev + production).
…ions can overlay the built-in strategy

A custom clientChunks function replaces the built-in route/marker grouping
entirely; without access to the built-in, overriding a handful of modules
silently degrades the rest of the app to plugin-rsc's default serverChunk
grouping. Exporting the strategy lets consumers delegate:

  clientChunks: (meta) =>
    meta.normalizedId.includes("/hec/builder/")
      ? "builder-island"
      : directoryClientChunks(meta)

Called without a ClientChunkContext the app-fallback split is inactive
(discovery wires that only for the built-in default).
@ivogt
ivogt force-pushed the experiment/fe-boot-debug branch from 139f512 to 2df372b Compare July 5, 2026 17:01
@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@691

commit: 2df372b

@ivogt
ivogt merged commit 9725be9 into main Jul 5, 2026
65 of 66 checks passed
@ivogt
ivogt deleted the experiment/fe-boot-debug branch July 5, 2026 17:23
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