Repository navigation
perf(ppr): extend fragment passthrough to partial navigation replay - #795
Merged
Merged
Conversation
Partial replay HITs re-decoded stored segments into React elements and re-serialized them per request; the #700 splice was document-tail only. Arm _shellFragmentPayload around the partial match (mutate-restore on the shared reqCtx; hydrated-client GET lanes only) and expand envelopes at the client's navigation/prefetch decode chokepoints so cached prefetch entries only ever resolve expanded. deriveShellCaptureContext resets the flag as an own property so a capture render can never inherit envelopes.
commit: |
Gate partial-replay fragment envelopes behind an explicit browser capability header and include the capability and recovery variants in the RSC Vary contract. Legacy clients and context-less probes continue through the server-side segment decoder. When a fragment decoder fails, retry the navigation once with a recovery marker and without passthrough. The recovery bypasses the corrupt fragment response-cache read, forces CacheScope to decode and evict the bad segment, replaces the capable response slot with valid fallback bytes, and disables passthrough for the current document so its HTTP cache cannot replay the stale variant. Track which runtime shell key supplied a corrupt seeded snapshot and recapture at that exact key. Repairs remain navigation-only, allowing partial replay to use them immediately while document requests reject the partial-derived shell and restore a document-safe capture. Add unit coverage for fresh, warm, and inflight recovery, response-cache isolation and replacement, server recovery precedence, and seeded-snapshot healing. Add deterministic warm and held-inflight prefetch e2e coverage in the router test app and cloudflare-basic in both development and production, and update the PPR/cache documentation.
ivogt
force-pushed
the
feat/partial-replay-fragment-passthrough
branch
from
July 16, 2026 10:02
9d4eade to
93cb1ca
Compare
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.
Problem
Since #706, a PPR document HIT splices the stored per-segment Flight strings verbatim into the hydration payload (−55% HIT server CPU measured on workerd). A partial navigation to the same route — the most frequent PPR serve in a browsing session — still paid the full round trip on every request:
deserializeSegmentsdecoded every stored segment into React elements and the outgoing Flight render re-serialized them, byte-for-byte identical to what was already stored. The consumer-observable cost is server CPU and TTFB on every client-side navigation to apprroute, with the splice machinery already in the codebase but gated to document tails.Before vs after
Nothing changes in what a consumer writes. The observable delta is on the wire of a partial replay HIT:
Before — every stored segment decoded and re-serialized per navigation:
After — a capable client declares itself, and stored fragment strings ride verbatim; only loaders/handles/metadata are freshly serialized:
A client that does NOT send the capability header (legacy bundle, curl, monitors) gets the pre-existing wire shape — the server decodes stored segments into elements exactly as before.
The client expands envelopes at its decode chokepoints before anything renders, so pages, hydration, and
useRouter()behavior are identical. Replay status headers, freshness semantics, and loader liveness are unchanged.End-to-end example
First document GET captures the shell; every subsequent client-side navigation to
/products/42replays the captured segment record withx-rango-ppr-replay: HIT— and now streams the stored Flight strings instead of re-encoding the tree, whileproductLoaderstill executes fresh per navigation.Semantics table
Arming requires BOTH a hydrated-client GET navigation (past the method/dynamic/nonce/store/navigation-context gates) AND the
X-Rango-Fragment-Passthrough: 1capability header:BYPASS; reason=prerender-store,Prerender()+ppr)cache()tier hit during an armed matchX-Rango-Fragment-Recovery: 1)server-action-bridgenever sees envelopesctx.dynamic(), nonce'd requestspprroutes, plainPrerender()partialsCorruption recovery (one-shot, self-healing at the source key): if a stored fragment fails the client's Flight decoder, the navigation retries exactly once with the recovery header and
cache: "no-store"(so the retry cannot be served the same bytes by an HTTP cache). The server then takesCacheScope's decode-and-evict path — the corrupt record is validated server-side, evicted, and a heal capture is scheduled at the corrupt snapshot's own store key (a corrupt document snapshot is overwritten at the document key; repairing only the navigation sibling would leave the preferred entry shadowing the fix). The current browser session also disables passthrough (disableFragmentPassthrough()) so subsequent navigations avoid stale HTTP-cache replay of the fragment variant. RSC partial responses nowVaryon both headers, and the document cache isolates the fragment wire variant under a:fragmentskey suffix so capable and legacy/context-less slots never cross-serve.Code changes
src/rsc/rsc-rendering.ts— all threematchPartialcall sites inmatchPartialWithPprReplayroute through a newmatchPartialForReplayhelper; after the navigation-context gate it armsreqCtx._shellFragmentPayloadwith mutate-restore (try/finally, thrown-Response redirect exit included). Mutate-restore on the shared reqCtx, not a derived context: the match pipeline writes ambient state that must land on reqCtx (_pprReplayPostMatchReason, location state,_treeHasStreamingread by the render-barrier closure) — a derived context strands those writes and misreports replay statuses / schedules spurious heal captures.src/browser/navigation-client.ts— sends the capability header, expands envelopes at the single payload await all three sources flow through (fresh fetch, warm prefetch, adopted inflight), and owns the one-shot decode recovery (distinctSegmentFragmentDecodeError, never recursive, aborts respected); depsPickwidened to includecreateFromReadableStream(runtime object already carried it).src/browser/rsc-router.tsx— prefetch decoder wrapped so expansion happens before the payload enters the prefetch cache (cached entries only ever resolve expanded); initBrowserApp's pre-existing expansion migrated to the shared helper.src/segment-fragments.ts— newexpandPayloadFragments(payload, decode)(per /simplify consensus): one helper for all three browser sites,hasSegmentFragmentspre-scan keeps envelope-free payloads (every non-replay response) to one synchronous field walk; exports the capability/recovery header names andSegmentFragmentDecodeError.src/cache/cache-scope.ts+src/cache/document-cache.ts— corrupt seeded-record decode reports through the replay marker (onCorrupt→ heal capture at the source key); document-cache partial slots carry the:fragmentsvariant suffix and recovery requests bypass the document cache to reach the server decoder.src/rsc/shell-capture.ts—deriveShellCaptureContextresets_shellFragmentPayloadas an own property: an inherited flag would makeserializeSegmentsbake a double-encoded fragment into a store record. Defense-in-depth; no current arming site reaches a capture render.src/server/request-context.ts— flag JSDoc updated for the two arming sites.docs/design/shell-fast-path.md(splice section + out-of-v1 bullet),feature-file-map.md,feature-map.md,skills/ppr/SKILL.md, plus an upstream-gatedonPostponefollow-up note inppr-shell-resume.md(vite-plugin-react PR fix(router): the segment walker meters, spans and warns the same on a full match and a navigation (rango-t187) #1285; revisit on the next plugin-rsc bump past 0.5.27).Test plan
Red-before-green: with the server arming in place and the client expansion stashed, the existing partial-nav e2e fails with exactly the predicted crash — captured output:
With the expansion restored, the same test passes in dev and production.
rsc-rendering-shell-ppr.test.ts): arms on the SHARED context during the match and restores after (pins the mutate-restore contract); restores on a thrown-Response redirect exit; stays unarmed on method/dynamic/nonce/no-navigation-context/undeclared-ppr lanes (it.each); pre-existingactive === baseContextidentity test unchanged.shell-capture-barrier-isolation.test.ts): capture context gets an own_shellFragmentPayload: falseeven when the foreground armed it.navigation-client.test.ts): envelope-bearing payload is expanded in place throughcreateFromReadableStream(decoder receives the stored string); envelope-free payload never invokes the decoder.shell-cache.test.ts): newpartial replay HIT carries verbatim fragment envelopes; a context-less probe carries none(raw body + negative); envelope assert welded into the real page-click nav test (expectNoPageError/expectNoReload, exec-matrix counters still pin loaders-live/handlers-frozen). 58 dev / 56 production passed.ppr-shell.test.ts): same pair on workerd/KV. 44 dev / 42 production passed.prerender-ppr.test.ts): prerender-store partial (BYPASS; reason=prerender-store) body carries envelopes — pins theyieldFromStorelane.9d4eade4): unit + e2e coverage for the capability gate (legacy/no-header requests get decoded elements), the one-shot decode recovery (corrupt fragment → recovery fetch → evict + heal capture at the source key; never recursive), document-cache:fragmentsvariant isolation, and warm + deterministically-held in-flight prefetch navigations — both apps, dev and production.pnpm run typecheck,pnpm run test:unit:all(all packages),pnpm run lint,pnpm run format— green; no.gen.tsdrift.Notes
X-Rango-Fragment-Passthrough, so the server never emits envelopes to it, regardless of version stamps. Independently: server↔store skew is covered byShellCacheEntry.buildVersiongating (a new server never replays an old build's entries), and full client↔server version skew by the pre-match_rsc_vreload classification (stamp injected unconditionally by the vite plugin).claimRevalidation: falseby design) — unchanged; the flag's lifetime therefore only needs to span the match window.cache-lookup.tsyieldFromStore) is pre-existing and unchanged; its new partial-lane reachability is pinned by e2e rather than a new unit harness./simplifyprotocol (4 agents) ran on the diff; consensus fix applied (sharedexpandPayloadFragments, bare decoder reference, comment dedup). Skipped: a WeakSet stamp to avoid one redundant sync field-scan on prefetch-sourced payloads (over-engineering); migratingssr-root.tsx's pre-existing expansion site (outside this diff).pprPrerender()partials (same mechanics would apply); intercept-variant navigation replay (never arms; demand-gated);onPostponecapture-gate spike (upstream-gated, design note added).