Repository navigation
feat(router): loader container bake — one promise doctrine for handlers, handles, and loaders - #663
Merged
Merged
Conversation
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
force-pushed
the
test/ppr-layout-loader-e2e
branch
from
July 4, 2026 13:12
644191e to
8be1a39
Compare
…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
force-pushed
the
feat/loader-container-bake
branch
from
July 4, 2026 13:15
3274a52 to
efb52d0
Compare
Closed
4 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…, dev prerender memoization (#662); map loader-snapshot.ts
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
The most common production PPR shape — an app-wide layout registering loaders (session context, basket, wishlist) with a
pprpage beneath it — dead-ended: every shell capture refused and the route stayedx-rango-shell: MISSforever, 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:
loading()present is byte-for-byte today's behavior and stays the guaranteed live lane. The storefront homepage shape — layout loaders + bareppr: trueroute + zeroloading()anywhere — now HITs.End-to-end example
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
loading()loading(), under capture<Suspense>cookies()/headers()in a bake-lane loaderloading(false)/loading(x, { ssr: false })under SSRCode 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:resolveLoaderDatais the lane-policy funnel — mask (live lane) / execute+record (capture bake) / execute+overlay (HIT seed), one ALS read,bakeSegmentKeythreaded fromfresh.tsper entry (entryLoadingMasksLoadersin loader-mask.ts mirrors segment-system'sisRenderableLoading).rsc/shell-capture.ts: capture gateholdUntilextended over bake-lane containers (same mechanism as top-level handle pushes); drain elides + Flight-serializes containers into the snapshot's newloaderfamily; deterministic"refused"outcome (no retry, backs off) via onerefuseOnGuardTripchoke point;warnNullCaptureOncerewritten (old text gave route-level advice that could not fix layout loaders).cache/shell-snapshot.ts+cache/types.ts:ShellSnapshotFamilygains"loader";buildShellLoaderSeeddecodes records in parallel next toSeededShellStore;rsc/rsc-rendering.ts#serveShellHitseeds_shellLoaderSeedon the tail context.server/cookie-store.ts: the capture guard stamps_shellCaptureGuardTrippedbefore throwing — load-bearing because the throw is swallowed bywrapLoaderPromise(error UI) or rejects the prerender promise; both capture exits check the flag.ssr/index.tsx: pre-attached no-op catch on theprerender()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)./pprskill 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.mdflipped to IMPLEMENTED with deviations noted.Test plan
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)./shell-cache/guard(bake-lane loader readingcookies()) stays MISS with the per-user value intact on axis 1, dev + production.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.*.gen.tsregenerated./simplifypass applied (4-agent review): sharedisThenablereuse, single guard-refusal choke point, flattened gate hold, one-ALS-read hot path, parallel seed decode inshell-snapshot.ts,splitPreludee2e helper.Notes