Repository navigation
feat(ppr): shell fast path — HIT tails replay the handler layer; only loaders are dynamic - #695
Merged
Merged
Conversation
…gh the cache() hit lane Only loaders are dynamic: an eligible shell HIT's tail match hits an implicit doc-level segment record (recorded into the shell entry at capture, SnapshotOnlySegmentStore) and replays path/layout/parallel handlers byte-identically; middleware and DSL loaders run live. Eligibility (handlerLiveHoles): handler-scoped nested-thenable handle pushes, pushes pending at the putShell barrier, or handler-invoked loaders (#672 consumption lane) decline the fast path and keep the full tail. Route-derived cache()/cache(false) scopes always win. Capture context isolates _explicitTaggedStores so ephemeral stores never pin the handler-lifetime tag registry. Red-proven execution-matrix e2e in test-app + cloudflare-basic (dev + production): middleware+loader counters advance per HIT, handler counters frozen.
commit: |
5 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 5, 2026
…ts capture starves sibling captures on GH runners With the composition test already CI-dev-skipped, the warmup's module-warm GET was the last CI trigger of its background capture, whose per-request /__rsc_prerender app-graph re-import grinds the runner long enough to blow SIBLING captures' 5s quiet windows — the rotating eternal-MISS victim seen across four CI runs (warmup on one shard, the composition probe, then /ppr-shell?probe=stream once the rest were quiet; #695/#696 green before the route existed; local runs unaffected). Locally the route stays warmed and every test runs. Lift with the dev-endpoint capture hardening follow-up.
This was referenced Jul 6, 2026
Merged
ivogt
added a commit
that referenced
this pull request
Jul 6, 2026
…dered routes (#699) (#701) A Prerender route that also declares the ppr path option now gets its complete ShellCacheEntry (HTML prelude + postponed state + tag union) produced at vite build and served from the very first request after deploy — x-rango-shell: HIT with zero runtime capture. One entry format, two producers, one consumer: the build's shell prerender phase (buildApp post) reuses the discovery temp server and runs producer A's capture core (deriveShellCaptureContext + captureAndStoreShell) over the just-collected prerender payloads; entries stage as __ps-*.js assets behind a lazy __shell-manifest.js read through on a shell-store MISS. ppr.ttl is staleness-only (runtime recapture is the SWR upgrade path), the buildVersion gate retires entries per deploy, and updateTag evicts the immutable baked entries via tag-marker comparison (new optional store method isTagsInvalidatedSince on memory/CF/Vercel — no tombstones). Dev parity via the /__rsc_shell on-demand endpoint (memoized per HMR generation). Also fixes handlerLiveHoles being dropped by the KV/Vercel shell envelopes (pre-existing #695 bug, red-before-green). Closes #699.
This was referenced Jul 6, 2026
Merged
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 statement
A PPR shell HIT commits its prelude headers at ~25ms and then re-runs the entire handler layer to produce a hydration payload the capture already produced once:
ctx.router.match()re-executes segment resolution and every path/layout/parallel handler, and the payload is Flight-rendered by re-executing all those components. On a measured storefront (workerd,"use cache"'d handler data) that re-run is 26–36ms of pure waste per HIT; on an app whose handlers do real work it is that work's full cost. Worse, the re-run is a standing parity hazard — the whole capture-data-snapshot apparatus exists to pin cache reads so the re-rendered payload hopefully matches the frozen prelude.The consumer contract, in one sentence
Only loaders are dynamic. Middleware guards the request (live), loaders load the data (live), handlers render the page (cached). No API change — the primitives already mean this; the fast path makes the runtime agree.
Before vs. after
The mechanism is the existing
cache()hit lane, not a new renderer: the capture treats the whole matched route as an implicit doc-levelcache()boundary and records the segment entry INTO the shell entry's snapshot; an eligible HIT's tail match hits that record through the already-shippedSeededShellStore, sowithCacheLookupreplays segments andresolveLoadersOnlyruns loaders — the exact path everycache()-wrapped route already serves in production.End-to-end consumer example
On a HIT: prelude flushes immediately,
StoreChrome/HomePagenever execute (their trees replay from the entry),CarouselLoaderruns fresh, andproductsstreams into the baked skeleton.Semantics table
ctx.use(loader)(#672 consumption lane)buildFullPayload, unchanged)cache()/cache(false)updateTag/revalidateTagEligibility (
ShellCacheEntry.handlerLiveHoles) is decided at capture from three signals: a handle pushed OUTSIDE a DSL loader scope carrying a nested thenable, a handler push still pending at the putShell barrier, or a handler-invoked loader executing during capture. Any of them declines the FAST PATH, never the capture — those routes keep today's behavior exactly.Code-change summary
Four pieces, all riding existing machinery (
packages/rangojs-router/src):cache/cache-scope.ts—resolveShellImplicitCacheScope, applied at the single full-match construction site (router/match-api.ts): substitutes an enabled doc-level scope when the route derived NO cache scope and the request context carries the_shellImplicitCachemarker. A route-derived scope — includingcache(false)— always wins.rsc/shell-capture.ts— capture side: the derived context arms the marker with aSnapshotOnlySegmentStore(cache/shell-snapshot.ts) so the implicitcacheRoutewrite lands in the snapshot only, never the real store (a passthrough write would make the NEXT capture's lookup replay the previous generation — broken SWR recapture freshness). The PR fix(ppr): nested-promise shape IS the liveness declaration — mask nested thenables at capture (loaders + handles); stops per-user data baking into the shared shell #692 push-mask funnel now also attributes each push (isInsideLoaderScope()read synchronously at push time) via a single-walkMaskReportonmaskNestedContainerThenables, folds handler-layer liveness intohandlerLiveHoles, and collects DSL-loader push values into_shellCaptureLoaderHandleValues— threaded explicitly intocaptureHandles(…, exclude)so loader-scoped values stay out of cache-write handle records (they re-push fresh per HIT; their masks would stall the Flight handle encode) while every othergetDataForSegmentconsumer provably sees every push. The capture context also gets its OWN_explicitTaggedStoresso capture-ephemeral stores never pin the handler-lifetime tag registry (spuriousupdateTagpartial-store warning + retained snapshot memory — found in review).rsc/rsc-rendering.ts— serve side:serveShellHit's seeded tail context arms the marker when!entry.handlerLiveHoles. Missing/corrupt record, handler-live entry, or route-derived scope all degrade to the full tail automatically (the corrupt case self-heals throughlookupRoute's existing evict-and-miss).router/loader-resolution.ts— a handler-invoked loader executing during a capture marks handler-layer liveness (its PPR: live-lane parallel-slot loader consumed via ctx.use() in the slot handler executes (and trips the identity guard) during capture on non-index routes #672 consumption-lane value would freeze under replay).Location rationale: the serve path stays literally
ctx.router.match()— no parallel fast-path renderer; the fast path is scope substitution + an eligibility flag, so every downstream behavior (intercept handling, revalidation gating, metrics, the loader seed overlay) is the production-tested cache-hit lane.Test plan
fast-path HIT executes ONLY middleware and loaders — path/layout/parallel handlers are replayed, not run— per-layer module counters streamed through the live loader, asserted across two consecutive HITs. Without the fix it fails exactly at the frozen-handler assertion (expected 4, received 5); with it, middleware+loader advance and path/layout/parallel stay frozen. Both apps (test-app/shell-cache/exec-matrix, cloudflare-basic/ppr-shell/exec-matrixon workerd/KV), dev AND(production).src/cache/__tests__/shell-fast-path.test.ts— implicit-scope substitution (marker vs route scope vscache(false)),SnapshotOnlySegmentStorerecord-only writes/read-through,MaskReportsingle-walk liveness signal (10 tests). Existingmatch-api-classified-reusemock extended for the new export.ppr-tag-eviction).pnpm run typecheck,pnpm run test:unit:all(0 failures),pnpm run lint,pnpm run format;*.gen.tscommitted for both apps (new fixture routes)._explicitTaggedStoresisolation; semantics lens: zero confirmed regressions; payload segment ordering on the fast path equals whatcache()hits already produce in production).Notes
docs/design/shell-fast-path.md(in this PR) — measurements that motivated it, why whole-payload Flight splicing was rejected, the parity argument, and what stays out of v1.