Repository navigation
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
Merged
Conversation
…the liveness declaration A bake-lane loader's nested promise that settled before the capture's quiet window closed was pinned into the shell snapshot (SETTLED marker); every HIT then served the capture-time value to every visitor and discarded the fresh run. The window waits for the slowest shared material on the page plus the bake-lane holdUntil, so any real data source (a 5ms SQL read, a 200ms basket API) lost the race — 'nesting = liveness' was liveness-by-physics, and in practice liveness-never. Found live: a storefront basket, carrying the capturing session's basketId/customer identifiers, frozen into the shared shell and served to anonymous requests. The capture now deep-copies each bake-lane container with every nested thenable replaced by a never-resolving mask (maskNestedContainerThenables): the consuming boundary postpones as a hole regardless of settle timing, elide records a HOLE marker, and every HIT streams the fresh value. The raw container is untouched, so handler-side consumption (consumption-lane rule, semantic-matrix PPR3) keeps real values. SETTLED markers remain legacy-decode-only for pre-mask snapshots. Red-before-green: the flipped /shell-cache/settled + /ppr-shell/settled twins (outer pins, nested stays FRESH per request, prelude bakes the consumer's fallback) fail without the mask in both dev and production, pass with it. +4 unit tests (mask semantics, no input mutation, leaf preservation, cycles). Suites: shell-cache/shell-secure/shell-cache-action 57 passed (+1 unrelated flaky), cloudflare ppr-shell 38 passed, semantic-matrix 45 passed.
…e — same shape rule as loaders
The handles analog of the loader fix in the parent commit: a pushed handle
container's nested promise (ctx.use(H)({ x: promise })) that settled before
the capture's quiet window baked its rendered value into the shared shell —
same settle race, same per-request-data-in-shared-shell consequence.
Single funnel: the capture attempt's fresh handle store gets a push wrap
(shell-capture.ts) that masks nested thenables in every pushed container, so
all three push wrappers (setupLoaderAccess, createUseFunction, prerender)
inherit the policy and the foreground store is untouched. A TOP-LEVEL promise
push keeps its documented bake contract (awaited pre-SSR, gate held for it);
the container it RESOLVES to gets the same nested masking.
maskNestedContainerThenables moved to the leaf module mask-nested.ts
(request-context/shell-capture cannot import through loader-mask without a
cycle); loader-mask/loader-snapshot re-export.
Red-before-green: the handles-pair e2e gained a fast-nested item (nested
promise ALREADY RESOLVED at push) asserting the prelude bakes the consumer's
fallback and the value streams in the resume — fails without the wrap in dev
AND production, passes with it.
Out of scope, documented: handler PROP promises (promise passed into JSX by a
path/layout/parallel handler) remain physics-only holes — they are minted
inside a server component's render, invisible to any rango funnel until React
renders them. Intercept routes never capture. Per-request data belongs in
loaders or handles, where liveness is now guaranteed by shape.
Suites: shell-cache/shell-secure/shell-cache-action/semantic-matrix 107
passed; cloudflare ppr-shell 38 passed; segment-resolution units 32 passed.
commit: |
Merged
5 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 28, 2026
…ane, loading() optional when fully baked The flag's document promise is "this loader's data, handle pushes, and status are in the HTML before first flush"; under ppr that silently degraded — the awaitBeforeFlush await was skipped at capture and the loader masked like any live-lane loader, so its data arrived in the resume stream, never the prelude. The capture lane decision is now per LOADER: - stream:"navigation" — the BAKE lane. Executes at capture; the SETTLED return is shell material; nested thenables stay masked (shape is the liveness declaration — the #692 cross-session scar) and postpone as holes; the masked container registers on _shellCaptureLoaderRecords, holding the capture gate (bounded by ppr.captureTimeout) and pinning into the shell snapshot so HIT tails replay the baked value and hydration stays byte-agreed with the frozen prelude. The dormant post-#813 bake machinery (gate hold, snapshot drain, seed overlay, pin-first) re-engages unchanged; only the trigger moved from "entry lacks loading()" to the explicit per-loader flag. - Every other loader — LIVE unconditionally, exactly as #813 left it: masked, postponed at loading() or an inline Suspense, boundary-less reads refuse the capture. loading() is therefore OPTIONAL when every loader is flagged: nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existing awaitedLoaderIndices projection — no group-specific code. Red-proven: the flipped await-before-flush unit (flagged loader executes + registers its record at capture; plain sibling never runs) and both e2e pins fail without the capture branch (baked value absent from the prelude; the fully-baked route degrades to eternal MISS). New pins, dev+prod: "stream:navigation loader bakes into the shell; nested promise and plain sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route needs no loading()", and the group-ppr probe extended with a baked group loader frozen in the group shell. Sweeps green: shell-cache + shell-cache-action + prerender-ppr + semantic-matrix, 88 dev / 90 prod. Docs: loader-container-bake.md addendum (trigger moved), feature-map PPR rows, client-urls.md, stream option docs both DSL surfaces, fixture doctrine header.
Merged
8 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 28, 2026
…ng for group routes (#817) * feat(client-urls): ppr as a projected group path option — shell caching for group routes clientUrls() routes can now declare `ppr: true | PartialPrerenderProps`. The option was an explicit projection rejection with no structural reason (unlike parallel/boundaries) — the config is pure JSON and the runtime shell machinery is route-generic, so this lifts the rejection and threads the option through the one seam that exists: DSL validation (validateClientPpr, fails in the authoring module), projection serialization (serializePpr — frozen JSON copy, re-validated independently), and materializedPathOptions, where the materialized server path() stamps it on the manifest entry so resolvePprConfig classifies the group route exactly like a hand-written ppr page. The interplay that motivated this lands whole: the group's static markup freezes into the shell INCLUDING its useSearchParams read — search is part of shell identity (the key embeds the sorted search and the capture render seeds that same string), so the frozen value matches the URL the shell serves and HIT hydration is clean. loading() subtrees stay the live holes; the server-route rules (loader routes need loading() or capture refuses, actions/PE/nonce take the live axis) apply unchanged. Red-proven at both levels: the manifest round-trip unit (entry.ppr undefined without the materialization spread) and the e2e (x-rango-shell header absent without the projection). New shell-cache probe "clientUrls group route with ppr" pins MISS -> HIT, frozen group shell with its search value in the first flushed bytes, per-request loader liveness under the frozen shell, and zero-hydration-error browser HIT — dev AND production. Full shell-cache + client-urls sweeps green both modes (64 dev / 65 prod). Docs: client-urls.md supported surface + rejection list, feature-map, client-urls skill. * feat(ppr): stream:"navigation" loaders BAKE at capture — per-loader lane, loading() optional when fully baked The flag's document promise is "this loader's data, handle pushes, and status are in the HTML before first flush"; under ppr that silently degraded — the awaitBeforeFlush await was skipped at capture and the loader masked like any live-lane loader, so its data arrived in the resume stream, never the prelude. The capture lane decision is now per LOADER: - stream:"navigation" — the BAKE lane. Executes at capture; the SETTLED return is shell material; nested thenables stay masked (shape is the liveness declaration — the #692 cross-session scar) and postpone as holes; the masked container registers on _shellCaptureLoaderRecords, holding the capture gate (bounded by ppr.captureTimeout) and pinning into the shell snapshot so HIT tails replay the baked value and hydration stays byte-agreed with the frozen prelude. The dormant post-#813 bake machinery (gate hold, snapshot drain, seed overlay, pin-first) re-engages unchanged; only the trigger moved from "entry lacks loading()" to the explicit per-loader flag. - Every other loader — LIVE unconditionally, exactly as #813 left it: masked, postponed at loading() or an inline Suspense, boundary-less reads refuse the capture. loading() is therefore OPTIONAL when every loader is flagged: nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existing awaitedLoaderIndices projection — no group-specific code. Red-proven: the flipped await-before-flush unit (flagged loader executes + registers its record at capture; plain sibling never runs) and both e2e pins fail without the capture branch (baked value absent from the prelude; the fully-baked route degrades to eternal MISS). New pins, dev+prod: "stream:navigation loader bakes into the shell; nested promise and plain sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route needs no loading()", and the group-ppr probe extended with a baked group loader frozen in the group shell. Sweeps green: shell-cache + shell-cache-action + prerender-ppr + semantic-matrix, 88 dev / 90 prod. Docs: loader-container-bake.md addendum (trigger moved), feature-map PPR rows, client-urls.md, stream option docs both DSL surfaces, fixture doctrine header.
5 tasks done
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
On a PPR page, per-request data returned as a nested promise — the documented "nesting = liveness" shape — could bake into the shared shell and be served to every visitor. Found live on a consumer storefront: an anonymous
curlof the homepage received the capturing session'sbasketId,customerId, and cart line items in its payload, and the cart badge showed the capture-time count (someone else's basket) to everyone, on every HIT, until the shell's TTL. The same applied to handle containers (ctx.use(H)({x: promise})).The root cause is that liveness was decided by a race, not by the shape: a nested promise stayed live only if it was still pending when the capture's quiet window closed. The window waits for the slowest shared material on the page (plus the bake-lane
holdUntil), so any real data source — a 5ms SQL read, a 200ms basket API — settles first, gets pinned into the snapshot (loaders) or rendered into the prelude (handles), and the HIT overlay then serves the pinned value while discarding the fresh run. "Nesting = liveness" was, in practice, "nesting = frozen, nondeterministically per capture."Before vs. after
The loader shape the skill teaches (and the storefront uses):
Promise.resolve(x)in a container (the session-trap branch)push(promise)Promise.resolve(pinned)End-to-end consumer verification
On the storefront that surfaced the bug (yarn +
@cloudflare/vite-plugin, PPR homepage, three no-loading()chrome loaders over SFCC APIs): shell cache wiped, capture triggered by a session holding a real basket, then measured — an anonymous request now receives its own empty basket (nobasketId/customerId/line items anywhere in prelude or payload), while the capturing session's badge shows its real, current basket (3 / $107.95, matching/cartexactly) live on every HIT. Before the fix the identical protocol delivered the capturing session's basket identifiers and items to the anonymous request — reproduced byte-identically on a pre-fix stock build to rule out unrelated changes.Code changes
src/router/segment-resolution/mask-nested.ts(new, leaf) —maskNestedContainerThenables: deep-copies a container with every nested thenable replaced by a never-resolving mask. Input never mutated (handler-side consumption — the consumption-lane rule, semantic-matrix PPR3 — keeps real values); cycles preserved; only plain objects/arrays traversed. Leaf on purpose:request-context/shell-capturecannot import throughloader-maskwithout a cycle.createMaskedLoaderPromisemoved here;loader-mask/loader-snapshotre-export.src/router/segment-resolution/loader-cache.ts— the capture bake branch masks the container the capture render and record consume. Elide then sees pending nested promises and records HOLE markers naturally.src/rsc/shell-capture.ts— the capture attempt's fresh handle store gets a push wrap applying the same mask, the single funnel all three handle-push wrappers inherit; the foreground store is untouched.src/router/segment-resolution/loader-snapshot.ts— SETTLED markers documented as legacy-decode-only (new captures cannot produce them; pre-mask snapshots stay servable until TTL).skills/ppr/SKILL.md) + design doc (docs/design/loader-container-bake.md) rewritten: the physics caveat no longer applies to bake-lane nested promises or handle containers; the session-trap example's fast-resolve branch is no longer a trap; the remaining trap (per-user data as PLAIN container material) called out.Deliberately out of scope, documented in both docs: handler PROP promises (a promise passed into JSX by a
path/layout/parallelhandler) remain physics-only holes — they are minted inside a server component's render, invisible to any rango funnel until React renders them. Intercept routes never capture. Per-request data belongs in loaders or handles, where liveness is now guaranteed by shape.Test plan
/shell-cache/settled+/ppr-shell/settlede2e twins flipped from pinning the old contract ("HIT pins the value") to the new one ("holes anyway — outer pins, nested stays FRESH per request", prelude bakes the consumer's fallback). Both fail without the mask and pass with it, in dev AND production, on the node test-app and the workerd/KV cloudflare twin.loader-snapshot.test.ts(mask replaces settled AND pending thenables with elide-visible holes; no input mutation; non-plain values are leaves; cycles preserved) — 32 total in segment-resolution.pnpm run typecheck,pnpm run test:unit:all(0 failures),pnpm run lint,pnpm run format.Notes
/settledtwins asserted the pinning as correct behavior — this PR is also the test-suite correction for that: the suite now pins liveness-by-declaration.