Skip to content

feat(router): loader container bake — one promise doctrine for handlers, handles, and loaders - #663

Merged
ivogt merged 5 commits into
mainfrom
feat/loader-container-bake
Jul 4, 2026
Merged

ivogt merged 5 commits into
mainfrom
feat/loader-container-bake

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #661 (folded in): this branch carries the trap e2e pins + the design doc as its first four commits, so the diff shows the whole story — the pins were authored against the OLD contract (MISS-forever, proven green pre-implementation on those commits), then flipped red→green by the implementation commit.

Problem

The most common production PPR shape — an app-wide layout registering loaders (session context, basket, wishlist) with a ppr page beneath it — dead-ended: every shell capture refused and the route stayed x-rango-shell: MISS forever, with a warning whose advice ("add loading() to the loader route") could not help layout-registered loaders. The only escapes were structural refactors (parallel-slot widgets, layout splits). #661 pinned that dead end; this PR removes it.

Root cause at the doctrine level: loaders were the one data lane that violated the container rule. Handlers (awaited data bakes, handed-over pending promises hole) and handles (top-level push bakes, nested-in-container holes) already followed "a promise nested inside your data is never baked; the container settles" — loaders were container-level all-or-nothing.

What a consumer writes differently

Nothing, unless they want the new lane — the return shape is the entire API:

// Before: this shape could never produce a shell (eternal MISS).
// After: the container bakes, the nested promise stays a live hole.
export const StorefrontContextLoader = createLoader(async (ctx) => {
  const config = await loadSiteConfig(ctx.params.locale); // bakes (pinned per shell)
  return {
    config,                   // shell material
    basket: fetchBasket(ctx), // hole — consumer <Suspense>s it, fresh per request
  };
});

loading() present is byte-for-byte today's behavior and stays the guaranteed live lane. The storefront homepage shape — layout loaders + bare ppr: true route + zero loading() anywhere — now HITs.

End-to-end example

layout(StoreLayout, () => [           // registers the three loaders, no loading()
  loader(StorefrontContextLoader),    // returns { config, basket: promise }
  loader(WishlistLoader),
  loader(BasketLoader),
  path("/", HomePage, { name: "home", ppr: true }),
]),

First GET: MISS + background capture (the capture gate holds for the loaders' real latency). Later GETs: HIT — chrome and the loader containers stream from the frozen prelude instantly, nested promises resolve fresh per request, and the hydration payload matches the prelude byte-for-byte (snapshot overlay), so zero hydration errors.

Semantics table

Case Before After
Loader behind loading() live lane: masked at capture, fresh every serve unchanged
Loader without loading(), under capture masked → boundary-less await pins → capture refuses (eternal MISS) executes; settled container bakes; nested pending promises hole at the consumer's <Suspense>
Same, on a HIT n/a (never HIT) loader runs fresh; recorded container overlaid (pinned per shell); nested promises live
Same, on axis 1 / client nav / actions container settled at tree-build, nested streams unchanged
cookies()/headers() in a bake-lane loader n/a (never ran at capture) capture REFUSED deterministically, once-per-key warning naming the read; axis 1 unaffected
Bake-lane loader rejects during capture n/a capture REFUSED — error UI never bakes
Bake-lane loader slower than the 5s capture guard n/a no-shell (warned); axis 1 unaffected
loading(false) / loading(x, { ssr: false }) under SSR awaited branch → refusal bake lane (mask decision = renderable loading only)

Code changes

  • router/segment-resolution/loader-snapshot.ts (new): promise-elide (settled nested promise inlines — it baked by physics; pending → hole marker; rejected → poisons the record) + HIT overlay (recorded paths pinned, markers take the fresh promises, fresh-only keys pass). Unit-tested.
  • router/segment-resolution/loader-cache.ts: resolveLoaderData is the lane-policy funnel — mask (live lane) / execute+record (capture bake) / execute+overlay (HIT seed), one ALS read, bakeSegmentKey threaded from fresh.ts per entry (entryLoadingMasksLoaders in loader-mask.ts mirrors segment-system's isRenderableLoading).
  • rsc/shell-capture.ts: capture gate holdUntil extended over bake-lane containers (same mechanism as top-level handle pushes); drain elides + Flight-serializes containers into the snapshot's new loader family; deterministic "refused" outcome (no retry, backs off) via one refuseOnGuardTrip choke point; warnNullCaptureOnce rewritten (old text gave route-level advice that could not fix layout loaders).
  • cache/shell-snapshot.ts + cache/types.ts: ShellSnapshotFamily gains "loader"; buildShellLoaderSeed decodes records in parallel next to SeededShellStore; rsc/rsc-rendering.ts#serveShellHit seeds _shellLoaderSeed on the tail context.
  • server/cookie-store.ts: the capture guard stamps _shellCaptureGuardTripped before throwing — load-bearing because the throw is swallowed by wrapLoaderPromise (error UI) or rejects the prerender promise; both capture exits check the flag.
  • ssr/index.tsx: pre-attached no-op catch on the prerender() promise — an early bake-lane rejection otherwise sat handler-less until the post-quiesce await and killed the worker as an unhandled rejection (found live via an async-hooks probe).
  • server/request-context.ts: _shellCaptureLoaderRecords, _shellLoaderSeed, _shellCaptureGuardTripped (flat, matching _shellCaptureRun).
  • Docs in the same PR: /ppr skill rewritten around the two lanes (doctrine table, bake-lane section, layout-with-loaders playbook now leads with return-shaping, pitfalls), migrate-nextjs (the "loader MUST have loading()" requirement deleted), execution-model.md, ppr-shell-resume.md (loader family), loader-container-bake.md flipped to IMPLEMENTED with deviations noted.

Test plan

  • Red before green: the test(ppr): e2e-pin the layout-loader trap + slot escape; design doc for loader container bake #661 trap tests (layout-loader with child loading(), bare storefront shape, route-level no-hole) were run against this change asserting MISS-forever and failed (routes flip to HIT), then rewritten to pin the new contract — in the test-app suite AND the cloudflare-basic twin on workerd with the real KV-backed store (loader-family snapshot round-trips the KV envelope).
  • The decisive parity pin: across two HITs the baked outer container seq is frozen (snapshot overlay) while the nested inner seq advances (live hole) — both suites, dev + production.
  • Identity wall e2e: /shell-cache/guard (bake-lane loader reading cookies()) stays MISS with the per-user value intact on axis 1, dev + production.
  • Unit: loader-snapshot.test.ts (elide/overlay semantics incl. shape drift), shell-capture.test.ts +3 (guard refusal, rejected-container refusal, pending-container omission), full suite 4830.
  • Suites: shell-cache 44/44, cloudflare ppr-shell 36/36 (dev + production each), semantic matrix 64/64 (segment-resolution touched), typecheck / lint / format / bucketing, *.gen.ts regenerated.
  • /simplify pass applied (4-agent review): shared isThenable reuse, single guard-refusal choke point, flattened gate hold, one-ALS-read hot path, parallel seed decode in shell-snapshot.ts, splitPrelude e2e helper.

Notes

ivogt added 4 commits July 4, 2026 15:26
Reproduces the storefront dead-end end to end: a layout that registers a
loader with no loading() on the LAYOUT pins every shell capture, and a
loading() on the ppr child route does NOT unpin it — the tree-build await
lives at the entry that registers the loaders. Previously only the
route-level variant (/shell-cache/no-hole) was pinned.

New fixtures (test-app shell-cache):
- /shell-cache/layout-loader — ShellTrapChromeLayout registers
  ShellChromeLoader (unconsumed; registration alone pins), child route has
  its own loader + loading() + ppr:true. Test: 6 spaced GETs stay
  x-rango-shell: MISS while axis 1 stays healthy.
- /shell-cache/slot-hole — the escape from skills/ppr's
  layout-with-loaders playbook: the same chrome data owned by a @badge
  parallel slot with its own loader + loading(); neither the layout nor
  the route carries loading(). Test: warms to HIT; prelude carries chrome,
  static page content, and the frozen badge fallback (never a badge
  value); the badge value streams in the resumed tail and its seq
  advances across HITs (stays live).

Both tests run in the dev AND (production) describes via the existing
runShellCacheSpec wiring. Full shell-cache suite: 38 passed in both modes.
router.named-routes.gen.ts regenerated for the two new named routes.
…rs, handles, and loaders

Design for closing the last doctrine asymmetry: a loader on a no-loading()
entry executes during shell capture, its settled container bakes, and
nested promises hole at the consumer's Suspense — the return shape is the
declaration ({static: 'baked', dynamic: promise}), no new API. loading()
stays the guaranteed live lane, byte-for-byte unchanged.

Grounded in the existing mechanisms: axis 1 already implements
container-settles/nested-streams for loading()-less loaders (e2e-pinned);
capture unmasking + the identity guard losing its loader exemption during
capture; HIT parity via a third capture-data-snapshot family with a
fresh-run + recorded-container overlay. Ships default-on: the only shape
whose behavior changes is today's eternal-MISS structural refusal.

Includes rollout note for the shell-capture.ts:244 warning fix (route-level
framing misled a real migration) and the red->green test plan built on the
layout-loader trap e2e added in the previous commit.
… under a loader layout

/shell-cache/layout-loader-bare: a ppr route with no loader, no loading(),
no use list at all, under the loader-registering trap layout. The route is
fully shell-eligible on its own; the layout's boundary-less loader alone
keeps every capture refusing (MISS across 6 spaced GETs, axis 1 healthy),
in dev and production. Completes the trap coverage: with-child-loading()
and bare variants both pinned.
…scape

Same three pins as the test-app suite, on the real KV-backed CFCacheStore
under workerd (dev worker + built preview worker): layout-registered
loader with no loading() on the layout stays MISS with a loading()-bearing
child route; the bare storefront-homepage shape (ppr route with no use
list under the loader layout) stays MISS; the @badge parallel-slot escape
flips to HIT with the fallback frozen in the prelude and the badge value
live (seq advances) across HITs.

Full cloudflare-basic ppr-shell suite: 34 passed in dev and production.
router.named-routes.gen.ts regenerated for the three new named routes.
@ivogt
ivogt force-pushed the test/ppr-layout-loader-e2e branch from 644191e to 8be1a39 Compare July 4, 2026 13:12
…rs, handles, and loaders

A loader on an entry with no renderable loading() now follows the same
container rule as handlers and handles under PPR shell capture: it
EXECUTES during capture, its settled container bakes into the shell, and
every promise still nested in the container postpones at the consumer's
own Suspense — {static: 'baked', dynamic: promise}, the return shape as
the entire API. loading() present keeps today's guaranteed live lane,
byte-for-byte. This turns the layout-loader eternal-MISS refusal (the
storefront homepage shape, e2e-pinned by the parent commits) into a
working capture.

Mechanism:
- Lane decision per entry (entryLoadingMasksLoaders, mirroring
  isRenderableLoading); policy applied at the single loader funnel
  (resolveLoaderData) with the loader segment id threaded as the bake
  key. One ALS read on the per-loader hot path.
- Capture gate holds for bake-lane containers (holdUntil extended over
  the recorded container promises — same mechanism as top-level handle
  pushes, bounded by the 5s guard).
- HIT parity via a new snapshot loader family: settled containers are
  promise-elided (pending nested promise -> hole marker) and
  Flight-serialized at drain (loader-snapshot.ts); on a HIT
  buildShellLoaderSeed decodes them in parallel and resolveLoaderData
  overlays recorded paths onto the fresh run — prelude and payload agree
  byte-for-byte while nested promises stay live.
- Identity fail-closed: cookies()/headers() in a bake-lane loader stamps
  _shellCaptureGuardTripped before throwing (the throw is swallowed by
  wrapLoaderPromise or rejects the prerender); the capture refuses
  deterministically (no retry, once-per-key warning naming the read). A
  rejecting bake-lane loader refuses the same way — error UI never bakes.
- captureShellHTML pre-attaches a no-op catch to the prerender promise:
  an early bake-lane rejection otherwise sat handler-less until the
  post-quiesce await and killed the worker as an unhandled rejection.
- warnNullCaptureOnce rewritten: the old text blamed "a loader route
  without a route-level loading()" and told users to add loading() to the
  route — advice that could not help layout-registered loaders and no
  longer describes the contract.

Tests: the parent commits' trap tests (layout-loader, bare storefront
shape, route-level no-hole) were captured failing against this change
(red), then rewritten to pin the new contract (green) in BOTH the
test-app suite and the cloudflare-basic twin (real KV envelope
round-trip), dev + production. New pins: container baked+pinned across
HITs while the nested promise stays live (seq assertions), identity-guard
refusal e2e (/shell-cache/guard), refusal/omission unit tests
(shell-capture), elide/overlay unit tests (loader-snapshot). Semantic
matrix green; docs updated in the same change (ppr skill bake-lane
rewrite, migrate-nextjs, execution-model, both design docs).
@ivogt
ivogt force-pushed the feat/loader-container-bake branch from 3274a52 to efb52d0 Compare July 4, 2026 13:15
@ivogt
ivogt changed the base branch from test/ppr-layout-loader-e2e to main July 4, 2026 13:18
@ivogt
ivogt merged commit 7043134 into main Jul 4, 2026
28 checks passed
@ivogt
ivogt deleted the feat/loader-container-bake branch July 4, 2026 13:30
ivogt added a commit that referenced this pull request Jul 4, 2026
…, dev prerender memoization (#662); map loader-snapshot.ts
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