Skip to content

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
ivogt merged 2 commits into
mainfrom
fix/bake-lane-nested-liveness
Jul 5, 2026

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

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 curl of the homepage received the capturing session's basketId, 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):

export const BasketLoader = createLoader(async (ctx) => {
  return { data: fetchBasket(ctx) }; // nested promise = "this is per-request"
});
Before After
Nested promise still pending at capture quiet hole, fresh per HIT hole, fresh per HIT (unchanged)
Nested promise settled during capture (the normal case) value PINNED into snapshot/prelude; every HIT serves the capture-time value; fresh run discarded masked at capture → holes; every HIT streams the fresh value
Promise.resolve(x) in a container (the session-trap branch) baked holes, fresh per HIT
Outer (non-promise) container material pinned (prelude parity) pinned (unchanged)
Top-level handle push(promise) awaited + baked (documented) unchanged — but the container it resolves to gets nested masking
Pre-mask stored snapshots (SETTLED markers) rehydrated as Promise.resolve(pinned) unchanged (legacy decode kept until TTL turnover)

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 (no basketId/customerId/line items anywhere in prelude or payload), while the capturing session's badge shows its real, current basket (3 / $107.95, matching /cart exactly) 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-capture cannot import through loader-mask without a cycle. createMaskedLoaderPromise moved here; loader-mask/loader-snapshot re-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).
  • Skill (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/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.

Test plan

  • Red-before-green, loaders: the /shell-cache/settled + /ppr-shell/settled e2e 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.
  • Red-before-green, handles: the handles-pair e2e gained a fast-nested item (nested promise ALREADY RESOLVED at push). Proven failing with the store wrap stashed, passing with it, dev + production.
  • Units: +4 in 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.
  • Suites: shell-cache + shell-secure + shell-cache-action + semantic-matrix — 107 passed (PPR3 consumption-lane row intact); cloudflare ppr-shell — 38 passed.
  • pnpm run typecheck, pnpm run test:unit:all (0 failures), pnpm run lint, pnpm run format.
  • Real-app verification as above (leak protocol on the storefront, before/after).

Notes

  • The privacy dimension: what gets baked comes from whichever request triggers the capture — cookies included. With this fix, promise-shaped per-user data can no longer reach the shell; per-user data returned as PLAIN container material still bakes (deterministically, documented as the remaining trap). Stripping credentials from the capture context entirely is a possible defense-in-depth follow-up.
  • Existing shells stored before this fix still carry pinned values and keep serving them until TTL/buildVersion turnover — deploying a new build (buildVersion gate, fix(ppr): shell serve-gate hardening — buildVersion gate, payload integrity check, tail-failure recapture (#684 SSR-03/04) #687) or waiting out the TTL heals them.
  • The old /settled twins asserted the pinning as correct behavior — this PR is also the test-suite correction for that: the suite now pins liveness-by-declaration.

ivogt added 2 commits July 5, 2026 20:23
…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.
@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@692

commit: 7c15d4a

@ivogt
ivogt merged commit ea9f40f into main Jul 5, 2026
34 checks passed
@ivogt
ivogt deleted the fix/bake-lane-nested-liveness branch July 5, 2026 17:47
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.
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.
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