Skip to content

perf(ppr): fragment passthrough — HIT payloads emit stored snapshot fragments verbatim (#700) - #706

Merged
ivogt merged 1 commit into
mainfrom
feat/shell-fragment-passthrough
Jul 6, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/shell-fragment-passthrough

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

PPR fast path: emit stored snapshot fragments verbatim into the HIT payload (closes #700)

Problem

On a PPR shell HIT, the fast path (#695) already skips handler re-execution — but it still re-SERIALIZES the whole page per request. The tail deserialized the stored segment records into element trees and renderToReadableStream re-encoded the entire payload into __FLIGHT_DATA, so a consumer with a 3.77MB storefront homepage paid ~1.09MB of Flight generation per request (245 chunks) whose bytes are CONSTANT for the lifetime of the entry — at the measured ~5MB/s workerd stream-generation throughput, roughly 200-300ms of per-request isolate CPU that scales with page weight, not dynamism, and competes with concurrent renders (the PDP-prefetch starvation class). Waku has the same limitation: they killed re-execution but re-encode their stored slots into every document response. The stored snapshot already contains per-segment RSC-encoded fragment strings — the serialization work was already done once, at capture time, off the request path.

Fix (consumer-invisible; zero API change)

A HIT tail now emits those stored fragment strings VERBATIM into the outgoing payload. Replayed segments travel as fragment envelopes on their ReactNode fields; the outer Flight render copies each string into a raw T-row text chunk instead of re-encoding an element tree. Fresh serialization covers only what is genuinely live: loader rows, handle records, per-request metadata rows (pathname/params/theme/etc.). Both payload consumers — the SSR resume pass and browser hydration — expand each envelope through their own Flight deserializer before anything reads the segments, the same per-record decode the segment codec has always used (each fragment is its own row space, so there is no Flight row-id collision or shared-row dedupe hazard). The browser cannot tell: hydration sees the same trees the old deserialize→re-serialize round trip produced — now with one FEWER Flight round trip between capture and client.

Nothing changes for a consumer. No option, no new API, no entry-format change (producer A and producer B entries are consumed identically). Routes that don't use ppr see a single extra field scan per initial payload.

Before vs. after (the wire, /shell-cache/outlined HIT)

Before — the tail re-serializes the baked tree per request:

<script>self.__FLIGHT_DATA.push("...2:[\"$\",\"a\",\"row-0\",{...}]...  // 10,000 re-encoded element rows, every request
...(1.09MB of entry-constant bytes, re-generated each time)...")</script>

After — the stored fragment rides as one verbatim string copy; live rows stay live:

<script>self.__FLIGHT_DATA.push("...7:T1f4a2,<stored fragment bytes, copied>...   // {\"__rangoFragment\":1,\"f\":\"$7\"}
...loader rows / handles / metadata serialized fresh per request...")</script>

End-to-end consumer example

// routes.tsx — nothing new to write; any existing ppr route benefits.
export const router = createRouter((r) => [
  layout(StoreChrome, () => [
    parallel({
      "@cartBadge": {
        handler: () => <CartBadge />,
        use: () => [loader(CartCountLoader), loading(<BadgePending />)],
      },
    }),
    path("/", HomePage, { ppr: { ttl: 300, swr: 120 } }),
  ]),
]);

First request: MISS, background capture stores prelude + postponed + snapshot (unchanged). Every later request: prelude bytes flush at ~0ms; middleware and CartCountLoader run live; HomePage/StoreChrome are neither executed NOR re-serialized — their stored Flight fragments are string-copied into the payload. Hydration and the resume tree are identical to before.

Semantics table

Case Before After
Shell HIT, fast path armed (snapshot doc record) segments deserialized + whole payload re-serialized per request stored fragments copied verbatim; consumers expand
Shell HIT, Prerender+ppr (producer B) prerender-store segments deserialized + re-serialized per request same splice via the prerender-store lane (yieldFromStore)
Shell HIT, handler-live holes (fast path declined) handlers re-run, fresh serialization unchanged (fresh elements); a route-scoped cache() hit inside the tail still splices
Shell HIT, entry without snapshot full tail unchanged full tail (no record to splice; flag armed but nothing replayed)
Loader rows / handles / per-request metadata serialized fresh serialized fresh (unchanged)
MISS / axis 1 / actions / PE / partial navigations / prefetch fresh serialization unchanged — no envelopes ever appear
Capture render (producers A and B) serializes real elements into records unchanged — the flag lives on the derived tail context only and cannot reach a capture
Corrupt stored record failed server-side decode ⇒ evict ⇒ MISS (full tail) fails at the consumer decode ⇒ tail errors ⇒ serveShellHit schedules the healing recapture (same class as a mismatched postponed blob)
Cold dev capture whose doc-record write misses the snapshot settle window fast path never arms for that entry (pre-existing #695 behavior) unchanged; such HITs also carry no fragments

Alternatives weighed (design inputs from vinext / Waku / Next.js)

  • vinext-style whole-payload byte splice (store the baked payload bytes; serve verbatim; append fresh rows): unsound here. Rango's HIT payload interleaves live loader rows, the metadata.handles AsyncGenerator, and per-request metadata in the SAME Flight document, and the capture's payload stream is deliberately frozen mid-document (masked-loader rows never emit) — reusing its bytes means raw Flight row-id surgery, the exact hazard PPR fast path: emit stored snapshot fragments verbatim into the HIT payload (stop re-serializing baked segments per request) #700 rules out. vinext can serve whole documents verbatim because its prerendered pages are fully static; Rango's are not.
  • Per-segment fragments expanded by consumers (this PR): stored bytes reach the wire without re-encoding (raw T-row copy), live rows stay genuinely live, and each fragment decodes in its own row space — the in-tree serializeResult/deserializeResult precedent.
  • Next.js-style navigation-surface byte-reuse (serve stored flight to client-nav requests only): the honorable fallback if in-document splicing had proven unsound. Not needed — the document splice holds (battery below).

Code-change summary

  • src/segment-fragments.ts (new, shared client/server, dependency-free): the { __rangoFragment: 1, f } envelope, isSegmentFragment, expandSegmentFragments(segments, decode) (in-place, parallel, Promise.resolve() adoption because Flight clients return thenable Chunks whose .then returns undefined), hasSegmentFragments. Envelopes ride ONLY ReactNode fields (component/layout/loading) — a plain object is never a valid ReactNode, so no collision with consumer data; loader data fields are never enveloped.
  • src/cache/segment-codec.ts: fragmentSegments(data) — builds ResolvedSegments carrying the stored strings verbatim; decodes only loader-data fields (as before); preserves the loading: "null" sentinel server-side.
  • src/server/request-context.ts: internal _shellFragmentPayload marker (+ PublicRequestContext omit).
  • src/rsc/rsc-rendering.ts (serveShellHit): every HIT tail runs under a derived context with the marker — on the snapshot-seeded branch AND the no-snapshot branch (producer B entries often have no snapshot; their splice happens on the prerender-store lane). Never set on the shared reqCtx, so a scheduled capture cannot inherit it (an envelope reaching serializeSegments would store a double-encoded fragment).
  • src/cache/cache-scope.ts (lookupRoute) + src/router/match-middleware/cache-lookup.ts (yieldFromStore): under the marker, build segments via fragmentSegments instead of deserializeSegments — covering the implicit doc record, route-scoped cache() hits, and the prerender store.
  • src/ssr/ssr-root.tsx: the payload promise chains expandSegmentFragments through the SSR createFromReadableStream; onPayloadSettled fires after expansion so the capture's fizz-readiness gate includes fragment module loads.
  • src/browser/rsc-router.tsx (initBrowserApp): expands fragments right after the initial payload decode, before the store seed / renderSegments / history cache see the segments.
  • Docs: docs/design/shell-fast-path.md ("The fragment splice, as built"), packages/rangojs-router/docs/design/ppr-shell-resume.md (out-of-scope bullet now records PPR fast path: emit stored snapshot fragments verbatim into the HIT payload (stop re-serializing baked segments per request) #700 as shipped), docs/internal/feature-map.md, docs/internal/feature-file-map.md.

Why these locations: the splice must happen where the stored strings are already in hand (the two decode sites), and expansion must happen once per consumer environment at the single point each consumes the payload — everything between (match pipeline, buildFullPayload, gateTransitions, the render barrier) treats segments as opaque and needed no changes.

Measured win

Subject: /shell-cache/outlined (the 2MB-baked-section fixture from #703), test-app production build, Node preview server, M-series laptop, steady-state HITs. Method: process CPU delta (ps -o cputime) across 40 sequential HITs; latency via a streaming reader (n=15, median).

Metric (per HIT) main this PR delta
Server CPU 98.8ms 44.8ms -54ms (-55%)
TTFB (prelude on the wire) 79.2ms 34.9ms -44ms — the tail's 2MB record decode no longer blocks the event loop ahead of the prelude flush
Wall first→last byte 90.6ms 132.6ms tail bytes ride behind the live badge loader either way; total request time ~equal (177.7ms → 172.0ms wall/request over the CPU run)
Response bytes 4,851,527 4,791,439 -60KB (fragment encoding is slightly more compact than the re-serialized rows)

The eliminated CPU is exactly the two per-request Flight passes over entry-constant data (RSC-side deserializeSegments + whole-payload re-serialization); what remains is the SSR resume, the live loaders, and a string copy. On workerd — the issue's measured environment, ~5MB/s Flight generation — the eliminated re-serialization alone was ~200-300ms/request on the 1.09MB-payload storefront homepage; the Node numbers above are the same work at Node speed. Wire-shape verification: the fragments ride the outer stream as raw T-row text chunks (\d+:T[0-9a-f]+, present in the tail), and __rangoFragment envelopes appear on HITs only, never in the prelude, never on MISS.

Test plan

  • Unit (pnpm run test:unit:all, all packages green):
    • src/__tests__/segment-fragments.test.ts — envelope detection (no false positives), in-place expansion across fields, loading null/undefined preservation, zero decoder calls on envelope-free payloads, decode-failure rejection, loaderData never expanded.
    • src/cache/__tests__/segment-codec-fragments.test.ts — fragmentSegments carries stored strings verbatim; loading sentinel; metadata/loader-data handling; expansion parity: fragmentSegments + expandSegmentFragments ≡ deserializeSegments; lookupRoute emits envelopes only under the tail marker.
    • src/rsc/__tests__/rsc-rendering-shell-ppr.test.ts — _shellFragmentPayload armed on the HIT tail context (snapshot-seeded AND no-snapshot branches) and never on the shared reqCtx (capture-leak guard).
    • Real-Flight round trip (test:unit:rsc, via the public @rangojs/router/testing/flight surface): src/testing/__tests__/segment-fragments.rsc-test.tsx — a stored fragment expands through the real vendored Flight client to the same tree as a direct decode: client island preserved as an I-row, typed props (Date/Map) intact, text content byte-identical; layout/loading fragments decode in independent row spaces.
  • E2e, dev + production, test-app (shell-cache, shell-secure, shell-cache-action, prerender-ppr):
    • NEW HIT hydration payload carries verbatim fragment envelopes; MISS carries none (outlined route, 2MB section) — envelope only in the resumed tail, never the prelude; baked rows still delivered for hydration; MISS carries no envelopes.
    • NEW fragment-envelope HIT hydrates with zero errors and the slot hole stays live (slot-hole route) — guardHydrationErrors + live badge through an expanded-fragment shell.
    • NEW HIT payload carries verbatim fragment envelopes (prerender-store splice) (prerender-ppr) — producer-B composition lane.
    • Existing exec-matrix (fast-path HIT executes ONLY middleware and loaders), outlined-bake guarantee, drift/theme/hydration suites, shell-secure, shell-cache-action: green — the execution guarantees and hydration-cleanliness hold unchanged over fragment payloads.
  • E2e, dev + production, cloudflare-basic (ppr-shell, incl. drift tests, ppr-tag-eviction):
    • NEW HIT payload carries verbatim fragment envelopes; the page hydrates cleanly on workerd.
    • ppr-tag-eviction's hydration test: the bare blog-sidebar locator is now scoped to the layout — see the streaming-race note below.
  • Full battery counts: test-app production 43 passed (+1 retry-passed local pnpm-wrapper infra flake), dev 44 passed; semantic-matrix dev 21 / production 24 passed; cloudflare-basic dev 30 passed + tag-eviction production 4 passed, ppr-shell production 25 passed; tag-eviction dev hydration test 10/10 after the locator scoping.
  • pnpm run typecheck (repo), pnpm run lint, pnpm run format, pnpm check:e2e-bucketing, pnpm check:docs-api — clean.
  • No route changes — no *.gen.ts churn.

Notes

  • Fragment strings ride as raw T rows. react-server-dom emits large strings as length-prefixed text chunks — a byte copy, no JSON escaping of the fragment body — so "stored bytes reach the wire without re-encoding" holds literally, not just asymptotically.
  • Producer B needed the prerender-store lane, not the entry. Build-time entries share the entry format unchanged; but a Prerender+ppr tail serves segments from the prerender store (the lookup runs before the cache scope), so the splice is applied there too — one consumer, both producers.
  • temporaryReferences caveat (pre-existing class). A baked segment containing a value only serializable via temporary references was already broken under replay (the store round trip cannot revive a temp ref); the failure point moves from a server-side error row to a consumer-side decode error, and the failed-tail recapture path covers it. No supported shape regresses.
  • One timing shift this PR does cause (dev-workerd-visible, functionally benign): the dehydrated-boundary vs $RC race widens. The baked payload is now a byte copy, so __FLIGHT_DATA finishes further ahead of the fizz resume. When a hole's Flight data lands client-side before its $RC script executes, React client-renders the dehydrated Suspense boundary and fizz's hidden segment container (<div hidden id="S:n">) stays orphaned in the DOM — a standard React streaming artifact (raw responses verified complete and correct: $RC present, content once). Visible tree, hydration, and interactivity are identical; zero hydration errors. Observed only on the cloudflare dev worker's first-HITs (2/14 runs vs 0/14 on main); production clean everywhere (25/25 + 4/4). The one existing assertion that strict-mode-collided with the hidden node (ppr-tag-eviction sidebar toBeVisible) is now scoped to the layout; 10/10 after.
  • Two pre-existing dev-only behaviors surfaced while testing (both reproduced on main, unrelated to this diff): (1) /shell-cache/outlined has a dev hydration mismatch — its module-level render counter bakes one value into the prelude and one more into the doc record; (2) /shell-cache's capture write-chain exceeds the 1s snapshot write-settle window in dev even warm, so its dev entries store snapshot-less and those dev HITs never arm the feat(ppr): shell fast path — HIT tails replay the handler layer; only loaders are dynamic #695 fast path (production is unaffected). The new tests route around both (slot-hole arms deterministically; a sacrificial warm-up absorbs cold-graph captures) — worth follow-up issues.
  • No feature flag. The battery proves parity; per pre-release API hygiene no transitional option was added. The self-healing posture for a corrupt record shifts from pre-commit MISS to post-commit recapture (documented in the semantics table) — same class the shell already handles for a mismatched postponed blob.

…ragments verbatim, not re-serialized (closes #700)

On a shell HIT the tail re-serialized the ENTIRE segment tree to Flight
per request — entry-constant bytes measured at ~1MB/request on a real
storefront (~200-300ms of workerd CPU). The snapshot already stores
per-segment Flight strings; the serve path just ignored them.

serveShellHit now runs the HIT tail under a derived context flagged
_shellFragmentPayload: both segment sources (CacheScope.lookupRoute and
the prerender-store lane) build segments whose ReactNode fields carry the
STORED per-segment Flight strings as { __rangoFragment, f } envelopes
(src/segment-fragments.ts); the outer Flight render emits each as a raw
T-row text chunk — a byte copy, verified on the wire. Loader rows,
handles, and per-request metadata still serialize fresh; loader-data
fields are never enveloped; capture renders can never see envelopes (the
flag lives on the serve tail's derived context only). SSR resume and
browser hydration expand envelopes through their own Flight deserializers
— each fragment its own row space, so no row-id/dedupe hazard. No public
API change, no entry-format change.

Alternatives weighed (source-verified research on Next/Waku/vinext):
vinext-style whole-payload byte splice rejected — Rango's HIT payload
interleaves live loader rows in one Flight document, so byte reuse means
row-id surgery; Waku's deserialize-then-re-encode limitation is exactly
what this avoids (stored strings reach the wire as strings).

Measured (production preview, /shell-cache/outlined 2MB baked section):
server CPU 98.8ms -> 44.8ms per HIT (-55%), TTFB 79 -> 35ms, response
60KB smaller.

Tests: segment-fragments unit (9), codec fragment-expansion parity (6),
serve-arming (2), real-Flight round-trips via testing/flight (island
I-row + Date/Map fidelity); shell suites 44 dev / 43 production;
semantic-matrix 21+24; prerender-ppr (producer B lane) green; cloudflare
ppr battery 30 dev / 29 production. One disclosed timing shift: the
earlier payload can client-fill a dehydrated boundary before $RC runs,
orphaning fizz's hidden segment container (functionally invisible; one
locator scoped in ppr-tag-eviction). Docs updated: shell-fast-path.md
(fragment splice as built), ppr-shell-resume.md, feature maps.
@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e7323d4

@ivogt
ivogt merged commit 99f052b into main Jul 6, 2026
34 checks passed
@ivogt
ivogt deleted the feat/shell-fragment-passthrough branch July 6, 2026 18:11
ivogt added a commit that referenced this pull request Jul 8, 2026
…settled-fast hidden-artifact scoping (#718) (#727)

* fix(ppr): dev shell endpoint signals boot-race NOT-READY; read-through re-polls to keep first-request HIT (#719)

Root cause: the dev `/__rsc_shell` endpoint boots its capture realm lazily on
the first hit — temp server (Cloudflare preset), registry import, and a Vite
dependency re-optimization the first shell-capture import can trigger. All are
transient. The read-through fired once and mapped any non-2xx to a hard
first-request MISS, so a Prerender+ppr route's FIRST request lost the race and
served axis-1 (x-rango-shell: MISS) while a later poll HIT — the build-time
first-request-HIT contract held only after a warm-up. Fast machines lose the
race deterministically; slow CI arrives after the boot infra settled, so it
read "consistent locally, 0-flake on CI". warmToHit in the tests masked it, but
the literal first-request-HIT contract was broken.

Fix: the endpoint tags the transient not-ready branches with
`x-rango-shell-dev: NOT-READY` (HTTP 503) — re-optimization catches, missing
runners, and an empty registry. The read-through re-polls ONLY that signal, a
bounded await of readiness (10s deadline, 150ms interval). Every genuine
outcome — a served entry, a real negative (404: not prerenderable / capture
refused), or a network fault — resolves on the first attempt, so a non-baked or
already-warm route pays zero extra latency. The endpoint half lives in the Vite
plugin, so `pnpm build-router` is required for dist/vite to reflect src.

Closes #719.

* test(ppr): scope settled-fast assertions under the settled container to skip a hidden fizz artifact (#718)

The `*-settled-fast` duplicate is a benign, hidden fizz artifact, not a bug.
When the nested-promise Flight payload fills the dehydrated boundary before the
trailing `$RC("B:0","S:0")` completeSegment script runs — a streaming window
that #706's byte-copy fragment splice widens (the baked payload now finishes
well ahead of the resume) — React client-renders the boundary in place and
`$RC` leaves fizz's hidden segment container (`<div hidden id="S:0">`) orphaned
as a direct child of `<body>`. It carries the `hidden` attribute: invisible,
zero layout, out of the a11y tree — but it holds a second `-settled-fast` node,
so a bare testid locator strict-mode-collides with it.

Fix: scope the `-label` / `-fast` assertions under the visible settled
container (`data-testid="shell-settled"` / `ppr-settled`) so the strict-mode
locator selects only the in-place copy; the orphan lives outside it. Confirmed
once-on-wire; reproduced 6/6 by widening the $RC delay, 1/1 after the scope fix.

Closes #718.

* fix(ppr): narrow dev readiness NOT-READY to reoptimization only — terminal temp-server/registry failures fail fast, not 10s-retry; + cold/injection dev coverage. Addresses review P2/P3 on #719.
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.

PPR fast path: emit stored snapshot fragments verbatim into the HIT payload (stop re-serializing baked segments per request)

1 participant