Skip to content

perf(ppr): extend fragment passthrough to partial navigation replay - #795

Merged
ivogt merged 2 commits into
mainfrom
feat/partial-replay-fragment-passthrough
Jul 16, 2026
Merged

ivogt merged 2 commits into
mainfrom
feat/partial-replay-fragment-passthrough

Conversation

@ivogt

@ivogt ivogt commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

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: deserializeSegments decoded 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 a ppr route, 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:

GET /products/42?_rsc_partial=true ...
x-rango-ppr-replay: HIT; freshness=fresh
<Flight body: freshly re-serialized element rows for the replayed segments>

After — a capable client declares itself, and stored fragment strings ride verbatim; only loaders/handles/metadata are freshly serialized:

GET /products/42?_rsc_partial=true ...
X-Rango-Fragment-Passthrough: 1
x-rango-ppr-replay: HIT; freshness=fresh
vary: accept, X-Rango-State, X-RSC-Router-Client-Path, X-Rango-Fragment-Passthrough, X-Rango-Fragment-Recovery
<Flight body: {"__rangoFragment":1,"f":"<stored rows>"} envelopes for replayed segments>

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

// urls.tsx — a PPR route with a live loader (pre-existing API, unchanged)
path(
  "/products/:id",
  ProductPage,
  { name: "product", ppr: { ttl: 300, swr: 120 } },
  () => [
    loader(ProductLoader), // stays live on every serve — never enveloped
    loading(<ProductSkeleton />), // the hole boundary
  ],
),

// Any client navigation:
<Link to="/products/42">Product 42</Link>

First document GET captures the shell; every subsequent client-side navigation to /products/42 replays the captured segment record with x-rango-ppr-replay: HIT — and now streams the stored Flight strings instead of re-encoding the tree, while productLoader still 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: 1 capability header:

Partial-nav lane Envelopes emitted? Rationale
Seeded doc-record replay HIT (capable client) yes the main win
Prerender-store serve (BYPASS; reason=prerender-store, Prerender()+ppr) yes same branch #706 already uses for documents
Explicit cache() tier hit during an armed match yes same parity property, same client expansion
Capable client, snapshot-miss / no-entry fresh render no cached segments to envelope fresh elements serialize as before
Client WITHOUT the capability header (legacy bundle, custom consumer) no server decodes stored segments as before — structurally skew-proof
One-shot recovery request (X-Rango-Fragment-Recovery: 1) no forces the server decode path so corrupt records are validated and evicted
Non-GET (actions) no method gate precedes arming; server-action-bridge never sees envelopes
ctx.dynamic(), nonce'd requests no bypass before arming
No navigation context (curl probes, monitors) no a consumer that cannot expand must get real elements
Non-ppr routes, plain Prerender() partials no (unchanged) wrapper never runs; follow-up candidate
Document HITs / SSR resume / initial hydration unchanged pre-existing #706 behavior

Corruption 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 takes CacheScope'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 now Vary on both headers, and the document cache isolates the fragment wire variant under a :fragments key suffix so capable and legacy/context-less slots never cross-serve.

Code changes

  • src/rsc/rsc-rendering.ts — all three matchPartial call sites in matchPartialWithPprReplay route through a new matchPartialForReplay helper; after the navigation-context gate it arms reqCtx._shellFragmentPayload with 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, _treeHasStreaming read 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 (distinct SegmentFragmentDecodeError, never recursive, aborts respected); deps Pick widened to include createFromReadableStream (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 — new expandPayloadFragments(payload, decode) (per /simplify consensus): one helper for all three browser sites, hasSegmentFragments pre-scan keeps envelope-free payloads (every non-replay response) to one synchronous field walk; exports the capability/recovery header names and SegmentFragmentDecodeError.
  • 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 :fragments variant suffix and recovery requests bypass the document cache to reach the server decoder.
  • src/rsc/shell-capture.ts — deriveShellCaptureContext resets _shellFragmentPayload as an own property: an inherited flag would make serializeSegments bake 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: 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-gated onPostpone follow-up note in ppr-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:

Error: Objects are not valid as a React child (found: object with keys {__rangoFragment, f}).
1 failed
  [dev] › e2e/shell-cache.test.ts › shell-cache (dev) › partial navigation replays the PPR segment shell while middleware and loaders stay live

With the expansion restored, the same test passes in dev and production.

  • Unit (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-existing active === baseContext identity test unchanged.
  • Unit (shell-capture-barrier-isolation.test.ts): capture context gets an own _shellFragmentPayload: false even when the foreground armed it.
  • Unit (navigation-client.test.ts): envelope-bearing payload is expanded in place through createFromReadableStream (decoder receives the stored string); envelope-free payload never invokes the decoder.
  • E2e dev + production, test-app (shell-cache.test.ts): new partial 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.
  • E2e dev + production, cloudflare-basic (ppr-shell.test.ts): same pair on workerd/KV. 44 dev / 42 production passed.
  • E2e (prerender-ppr.test.ts): prerender-store partial (BYPASS; reason=prerender-store) body carries envelopes — pins the yieldFromStore lane.
  • Review commit (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 :fragments variant 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.ts drift.

Notes

  • Version skew — now structurally impossible for envelopes, plus the two existing gates. The capability header makes old-client skew a non-issue by construction: a legacy bundle never sends X-Rango-Fragment-Passthrough, so the server never emits envelopes to it, regardless of version stamps. Independently: server↔store skew is covered by ShellCacheEntry.buildVersion gating (a new server never replays an old build's entries), and full client↔server version skew by the pre-match _rsc_v reload classification (stamp injected unconditionally by the vite plugin).
  • Stale replay HITs never recapture (claimRevalidation: false by design) — unchanged; the flag's lifetime therefore only needs to span the match window.
  • The prerender-store decode branch (cache-lookup.ts yieldFromStore) is pre-existing and unchanged; its new partial-lane reachability is pinned by e2e rather than a new unit harness.
  • /simplify protocol (4 agents) ran on the diff; consensus fix applied (shared expandPayloadFragments, bare decoder reference, comment dedup). Skipped: a WeakSet stamp to avoid one redundant sync field-scan on prefetch-sourced payloads (over-engineering); migrating ssr-root.tsx's pre-existing expansion site (outside this diff).
  • Follow-ups deliberately out of scope: non-ppr Prerender() partials (same mechanics would apply); intercept-variant navigation replay (never arms; demand-gated); onPostpone capture-gate spike (upstream-gated, design note added).

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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 16, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 93cb1ca

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
ivogt force-pushed the feat/partial-replay-fragment-passthrough branch from 9d4eade to 93cb1ca Compare July 16, 2026 10:02
@ivogt
ivogt merged commit 4cba403 into main Jul 16, 2026
36 checks passed
@ivogt
ivogt deleted the feat/partial-replay-fragment-passthrough branch July 16, 2026 10:13
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