Repository navigation
perf(ppr): fragment passthrough — HIT payloads emit stored snapshot fragments verbatim (#700) - #706
Merged
Merged
Conversation
…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.
commit: |
This was referenced Jul 6, 2026
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.
8 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.
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
renderToReadableStreamre-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
pprsee a single extra field scan per initial payload.Before vs. after (the wire,
/shell-cache/outlinedHIT)Before — the tail re-serializes the baked tree per request:
After — the stored fragment rides as one verbatim string copy; live rows stay live:
End-to-end consumer example
First request: MISS, background capture stores prelude + postponed + snapshot (unchanged). Every later request: prelude bytes flush at ~0ms; middleware and
CartCountLoaderrun live;HomePage/StoreChromeare 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
yieldFromStore)cache()hit inside the tail still splicesserveShellHitschedules the healing recapture (same class as a mismatched postponed blob)Alternatives weighed (design inputs from vinext / Waku / Next.js)
metadata.handlesAsyncGenerator, 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.T-row copy), live rows stay genuinely live, and each fragment decodes in its own row space — the in-treeserializeResult/deserializeResultprecedent.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.thenreturns 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)— buildsResolvedSegments carrying the stored strings verbatim; decodes only loader-data fields (as before); preserves theloading: "null"sentinel server-side.src/server/request-context.ts: internal_shellFragmentPayloadmarker (+PublicRequestContextomit).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 sharedreqCtx, so a scheduled capture cannot inherit it (an envelope reachingserializeSegmentswould store a double-encoded fragment).src/cache/cache-scope.ts(lookupRoute) +src/router/match-middleware/cache-lookup.ts(yieldFromStore): under the marker, build segments viafragmentSegmentsinstead ofdeserializeSegments— covering the implicit doc record, route-scopedcache()hits, and the prerender store.src/ssr/ssr-root.tsx: the payload promise chainsexpandSegmentFragmentsthrough the SSRcreateFromReadableStream;onPayloadSettledfires 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/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).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 rawT-row text chunks (\d+:T[0-9a-f]+,present in the tail), and__rangoFragmentenvelopes appear on HITs only, never in the prelude, never on MISS.Test plan
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—fragmentSegmentscarries stored strings verbatim; loading sentinel; metadata/loader-data handling; expansion parity:fragmentSegments+expandSegmentFragments≡deserializeSegments;lookupRouteemits envelopes only under the tail marker.src/rsc/__tests__/rsc-rendering-shell-ppr.test.ts—_shellFragmentPayloadarmed on the HIT tail context (snapshot-seeded AND no-snapshot branches) and never on the shared reqCtx (capture-leak guard).test:unit:rsc, via the public@rangojs/router/testing/flightsurface):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.shell-cache,shell-secure,shell-cache-action,prerender-ppr):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.fragment-envelope HIT hydrates with zero errors and the slot hole stays live(slot-hole route) —guardHydrationErrors+ live badge through an expanded-fragment shell.HIT payload carries verbatim fragment envelopes (prerender-store splice)(prerender-ppr) — producer-B composition lane.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.ppr-shell, incl. drift tests,ppr-tag-eviction):HIT payload carries verbatim fragment envelopes; the page hydrates cleanly on workerd.ppr-tag-eviction's hydration test: the bareblog-sidebarlocator is now scoped to the layout — see the streaming-race note below.pnpm run typecheck(repo),pnpm run lint,pnpm run format,pnpm check:e2e-bucketing,pnpm check:docs-api— clean.*.gen.tschurn.Notes
Trows. 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.$RCrace widens. The baked payload is now a byte copy, so__FLIGHT_DATAfinishes further ahead of the fizz resume. When a hole's Flight data lands client-side before its$RCscript 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:$RCpresent, 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-evictionsidebartoBeVisible) is now scoped to the layout; 10/10 after./shell-cache/outlinedhas 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.