Skip to content

feat(ppr): shell fast path — HIT tails replay the handler layer; only loaders are dynamic - #695

Merged
ivogt merged 1 commit into
mainfrom
feat/shell-fast-path
Jul 5, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/shell-fast-path

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

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

// Consumer code — IDENTICAL before and after. No new API.
layout(Chrome, () => [
  path("/", HomePage, { ppr: { ttl: 300, swr: 120 } }, () => [
    loader(ProductsLoader),          // live on every HIT (promise-shaped paths stream)
    loading(<Skeleton />),
  ]),
]),
BEFORE (HIT tail):  match → run Chrome → run HomePage → run loaders → Flight-render executed tree
AFTER  (HIT tail):  match → cache-hit: REPLAY Chrome + HomePage from the shell entry
                    → run loaders fresh → Flight-render (per-request metadata rebuilt as always)

The mechanism is the existing cache() hit lane, not a new renderer: the capture treats the whole matched route as an implicit doc-level cache() boundary and records the segment entry INTO the shell entry's snapshot; an eligible HIT's tail match hits that record through the already-shipped SeededShellStore, so withCacheLookup replays segments and resolveLoadersOnly runs loaders — the exact path every cache()-wrapped route already serves in production.

End-to-end consumer example

// urls.tsx — a PPR homepage with a personalized carousel
export const patterns = urls(({ path, layout, loader, loading, middleware }) => [
  middleware(authGate, () => [                       // live on every HIT
    layout(StoreChrome, () => [                      // replayed byte-identically
      path("/", HomePage, { ppr: { ttl: 300, swr: 120 } }, () => [
        loader(CarouselLoader),                      // re-runs on every HIT
        loading(<CarouselSkeleton />),
      ]),
    ]),
  ]),
]);

// CarouselLoader: the nested-promise shape IS the liveness declaration
export const CarouselLoader = createLoader(async () => ({
  categoryId: "featured",
  products: fetchPersonalized(),   // Promise → streams fresh into the shell hole per visitor
}));

On a HIT: prelude flushes immediately, StoreChrome/HomePage never execute (their trees replay from the entry), CarouselLoader runs fresh, and products streams into the baked skeleton.

Semantics table

Layer Today's HIT (full tail) Fast-path HIT
Middleware live live (unchanged)
Handlers (path/layout/parallel) re-executed; parity via snapshot pinning REPLAYED byte-identically, never executed
DSL loaders re-run; plain bake-lane paths pinned by seed overlay re-run; same overlay, same pinning
Promise-shaped loader paths live live (identical funnel)
Handler-pushed handles (settled) re-pushed by handler re-run replayed from the entry
Handler-pushed handles (nested promise) live via handler re-run entry ineligible → full tail
Handler-invoked ctx.use(loader) (#672 consumption lane) re-consumed fresh entry ineligible → full tail
Loader-pushed handles re-pushed by loader re-run re-pushed by loader re-run (identical)
Per-request metadata (initialTheme/locationState/…) rebuilt per request rebuilt per request (buildFullPayload, unchanged)
Routes with their own cache() / cache(false) their semantics their semantics (route-derived scope always wins)
MISS / actions / PE / partial navigations full pipeline full pipeline (unchanged)
updateTag/revalidateTag evicts shell entry evicts shell entry + its doc record together (record rides inside the entry)
SWR recapture re-runs handlers in background re-runs handlers in background (the deliberate exception)

Eligibility (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):

  1. 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 _shellImplicitCache marker. A route-derived scope — including cache(false) — always wins.
export function resolveShellImplicitCacheScope(scope: CacheScope | null): CacheScope | null {
  if (scope) return scope;
  const marker = getRequestContext()?._shellImplicitCache;
  if (!marker) return null;
  return new CacheScope({ ttl: marker.ttl, swr: marker.swr, store: marker.store }, null);
}
  1. rsc/shell-capture.ts — capture side: the derived context arms the marker with a SnapshotOnlySegmentStore (cache/shell-snapshot.ts) so the implicit cacheRoute write 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-walk MaskReport on maskNestedContainerThenables, folds handler-layer liveness into handlerLiveHoles, and collects DSL-loader push values into _shellCaptureLoaderHandleValues — threaded explicitly into captureHandles(…, 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 other getDataForSegment consumer provably sees every push. The capture context also gets its OWN _explicitTaggedStores so capture-ephemeral stores never pin the handler-lifetime tag registry (spurious updateTag partial-store warning + retained snapshot memory — found in review).

  2. 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 through lookupRoute's existing evict-and-miss).

  3. 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

  • Execution-matrix e2e (red-proven): 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-matrix on workerd/KV), dev AND (production).
  • Unit: src/cache/__tests__/shell-fast-path.test.ts — implicit-scope substitution (marker vs route scope vs cache(false)), SnapshotOnlySegmentStore record-only writes/read-through, MaskReport single-walk liveness signal (10 tests). Existing match-api-classified-reuse mock extended for the new export.
  • Existing nets as regression coverage: shell-cache + shell-secure + shell-cache-action 32/32 in dev AND production (the handle-liveness fixtures pin the ineligible→full-tail degrade; drift/settled/slot fixtures now exercise the fast lane); semantic matrix 21 dev + 24 production (PPR rows incl. consumption lane); full cloudflare-basic suite 287/287 (incl. ppr-tag-eviction).
  • Gate: pnpm run typecheck, pnpm run test:unit:all (0 failures), pnpm run lint, pnpm run format; *.gen.ts committed for both apps (new fixture routes).
  • Multi-agent simplify + adversarial review passes applied (state-leak lens: 1 confirmed finding, fixed — the _explicitTaggedStores isolation; semantics lens: zero confirmed regressions; payload segment ordering on the fast path equals what cache() hits already produce in production).

Notes

  • Design record: 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.
  • v1 is document-HIT-tail only: prefetch/partial renders keep the full path (the PDP-prefetch starvation lever is a named follow-up), as does Prerender-as-producer-B of the same entry format.
  • Handler-minted prop promises self-degrade: pending at capture, they time out the segment serialization, the doc record never lands, and the route silently keeps the full tail.
  • The SWR recapture re-running handlers in a background capture is the deliberate freshness exception, pinned in the fixture comments.

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

pkg-pr-new Bot commented Jul 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e617300

@ivogt
ivogt merged commit 34dd2c7 into main Jul 5, 2026
34 checks passed
@ivogt
ivogt deleted the feat/shell-fast-path branch July 5, 2026 21:18
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.
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.
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