Skip to content

fix(ppr): consumption-lane rule — handler ctx.use(loader) bakes during shell capture - #674

Merged
ivogt merged 1 commit into
mainfrom
fix/ppr-slot-loader-mask-nonindex
Jul 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/ppr-slot-loader-mask-nonindex

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #672.

Problem statement

A slot handler awaiting ctx.use(SlotLoader) during PPR shell capture executed the loader and tripped the cookies() identity guard, refusing the capture forever (x-rango-shell: MISS permanently) with a warning blaming a bake-lane loader that did not exist. Affects EVERY route (the issue's working / was a stale-shell artifact — reproduced at full router.match fidelity).

Resolution: the consumption-lane rule (maintainer decision)

Instead of masking machinery, PPR capture now follows the cache()/"use cache" precedent, locked in as doctrine: for any shared-artifact capture, server-side handler consumption via ctx.use(loader) yields a BAKED copy with cookies()/headers() reads permitted — the same documented footgun cache() carries ("Cache purity & tainted objects"); client-side useLoader is the live lane. DSL segment lanes untouched: a loading() registration still masks its segment, so in the parallel-slot shape the slot remains a LIVE hole (the segment mask pins the boundary; the handler's baked copy is discarded with the postponed subtree) — the baked copy materializes only where the value renders as unshielded shell material.

Mechanism (net ~40 lines of runtime change)

The loader-body ALS scope (which now carries loaderId for guard attribution) gains handlerInvoked, set when the invoking ctx.use ran outside DSL loader scope; the shell-capture identity guard in cookie-store exempts handler-invoked loader bodies. No priming, no release machinery, no new request-context fields. refuseOnGuardTrip now names the actual tripping loader id.

Semantics (during capture)

Case Before After
Handler ctx.use(loader), any lane executed; cookies() → capture refused forever executes; identity reads permitted; value bakes (rule)
DSL live-lane segment (loading()) masked hole unchanged — masked, live per serve
DSL bake-lane segment loader reading cookies() guard refuses unchanged (refusal now names the loader)
Non-capture requests — untouched

Locked in

Semantic-matrix row [PPR3] (dev+prod) + docs/internal/execution-model.md; e2e pins for BOTH artifact types (shell-cache slot-use cases, new cache() handler-consumed baked-copy cases — all dev+prod); design docs (ppr-shell-resume.md with the masking-approach history as scar tissue, loader-container-bake.md); skills (/rango invariant, /ppr, /parallel, /caching, /use-cache, /loader).

Test plan

  • Guard exemption proven red (2 failed with exemption disabled) → 4/4 green
  • Full router suite 4937 + RSC 66; semantic matrix 21 dev / 24 prod (new [PPR3] green)
  • shell-cache e2e 22/22 both projects; cache.test.ts baked-copy cases dev+prod
  • cloudflare-basic ppr-shell 19/19 both projects (slot settles, ring-3 writes serialize — no hang)
  • typecheck 15/15, lint, skills/doc-links/docs-api/bucketing guards, gen files regenerated

Accepted trade (stated plainly)

The capture request's identity value can freeze into the shared shell where rendered as shell material — deliberate, e2e-pinned (a user-A HIT still sees the anon value), same class as cache()'s existing allowance; escape hatch is client-side useLoader. Prior masking approach (priming + settled-release) was reviewed, built, and rejected as over-complex for this edge; history preserved in ppr-shell-resume.md.

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 1315b06

@ivogt
ivogt force-pushed the fix/ppr-slot-loader-mask-nonindex branch from 8d91a34 to 12b6ce6 Compare July 4, 2026 19:01
@ivogt

ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Amended with a fix for the cloudflare-basic ppr-shell regression CI caught: loaders carrying their own cache() config are now EXEMPT from capture priming — a cache-keyed value is shared by definition, so masking buys no identity safety, and masking it starved the capture's ring-3 cacheRoute write into an infinite Flight-serialization hang (empty snapshot -> generation drift -> React #418 on HIT). Also hardened: cacheRoute skips its capture-time write when a masked handler's segment is in the set, so no shape can hang the worker. New unit case pins the exemption (red without the fix); cloudflare-basic FULL e2e green post-fix (production 340, dev 279), router shell-cache 22/22 both projects, full unit suite 4938. The CI dev :145 failure did not reproduce locally (0/4) and is tracked as a suspected order-dependent flake in the ppr-shell describe (no isolatedServer + shared ring-3 keys).

…g shell capture, identity reads permitted (#672)

A slot handler awaiting ctx.use(SlotLoader) during PPR shell capture executed
the loader and tripped the cookies() identity guard, refusing the capture
forever (x-rango-shell: MISS permanently) with a warning blaming a bake-lane
loader that did not exist. This affected EVERY route (the issue's working "/"
was a stale-shell artifact).

Resolution: align PPR capture with the cache()/"use cache" precedent instead
of adding masking machinery. The CONSUMPTION-LANE RULE, now doctrine: for any
shared-artifact capture, server-side handler consumption via ctx.use(loader)
yields a BAKED copy with cookies()/headers() reads permitted — the same
documented footgun cache() already carries ("Cache purity & tainted objects");
client-side useLoader is the live lane. DSL segment lanes are untouched: a
live-lane (loading()) registration still masks its segment — so for the
parallel-slot shape the slot stays a LIVE hole (the segment mask pins the
boundary; the handler's baked copy is discarded with the postponed subtree),
and the baked copy materializes only where the value renders as unshielded
shell material.

Mechanism: the loader-body ALS scope (which already carries loaderId for
guard attribution) gains handlerInvoked, set when the invoking ctx.use ran
outside DSL loader scope; the shell-capture identity guard in cookie-store
exempts handler-invoked loader bodies. Nothing else: no priming, no release
machinery, no new request-context fields. refuseOnGuardTrip now names the
actual tripping loader; the misleading bake-lane attribution is gone.

Locked in as doctrine: semantic-matrix row [PPR3] (dev+prod) +
docs/internal/execution-model.md; e2e pins for BOTH artifact types
(shell-cache slot-use cases both modes; new cache() handler-consumed baked-
copy cases both modes); design docs (ppr-shell-resume.md, incl. the masking
history as scar tissue; loader-container-bake.md); skills (/rango invariant,
/ppr, /parallel, /caching, /use-cache, /loader).

Tests: guard exemption proven red (2 failed with the exemption disabled);
full router suite 4937 passed; shell-cache e2e 22/22 both projects; semantic
matrix 21 dev / 24 production; cloudflare-basic ppr-shell 19/19 both
projects (confirms the slot settles and ring-3 writes serialize — no hang);
typecheck 15/15; lint; skills/doc-links/docs-api guards green; gen files
regenerated.
@ivogt
ivogt force-pushed the fix/ppr-slot-loader-mask-nonindex branch from a9551a2 to 1315b06 Compare July 4, 2026 21:30
@ivogt ivogt changed the title fix(ppr): mask live-lane loaders for handler-side ctx.use during shell capture fix(ppr): consumption-lane rule — handler ctx.use(loader) bakes during shell capture Jul 4, 2026
@ivogt
ivogt merged commit 09d5d7c into main Jul 4, 2026
30 checks passed
@ivogt
ivogt deleted the fix/ppr-slot-loader-mask-nonindex branch July 4, 2026 21:50
ivogt added a commit that referenced this pull request Jul 7, 2026
…ites (closes #725 gap)

The cached-scope header guard (#725) exempted every loader body under a
cache() boundary via isInsideAnyLoaderScope(). That let a handler-invoked
loader — await ctx.use(Loader) from a handler, never registered with
loader() — write a cookie/header on the MISS. On a HIT the handler is
skipped and only DSL-registered loaders re-run, so that loader never
executes and its write silently vanishes — the exact MISS/HIT divergence
#725 was built to eliminate.

Narrow the exemption to isInsideLoaderScope() (DSL scope only): registered
loaders re-run on every HIT and merge into every response, so their writes
stay allowed; a handler-invoked loader now throws deterministically like a
handler write. The DSL scope ALS survives nested ctx.use bodies, so a
handler-invoked loader nested under a DSL loader stays exempt (its parent
re-runs it every HIT). The broad predicate is deferred to the throw path for
the error label so the PPR handler-invoked-loader message stays "loader",
and the throw reason keys on scope.kind so the cache() case explains the
handler-skip cause. Re-ground the neighbouring read-purity guard comment,
which this change makes contradictory: reads stay on the broad predicate
under the consumption-lane baked-copy tradeoff (#672/#674), only writes
narrow to DSL scope.

Unit (cached-header-guard.test.ts): DSL-allowed pin, handler-invoked-throws
pin (red before this change, green after), nested-under-DSL-allowed pin.
E2e (cache-scope-guard, dev + production): /handler-loader-cookie-blocked
asserts the error boundary renders and the cookie never reaches the wire;
the DSL /loader-cookie-allowed control stays green in both modes.
ivogt added a commit that referenced this pull request Jul 8, 2026
…ites (#725 gap) + drop removed __debug_manifest docs (#730)

* fix(router): handler-invoked loaders under cache() throw on header writes (closes #725 gap)

The cached-scope header guard (#725) exempted every loader body under a
cache() boundary via isInsideAnyLoaderScope(). That let a handler-invoked
loader — await ctx.use(Loader) from a handler, never registered with
loader() — write a cookie/header on the MISS. On a HIT the handler is
skipped and only DSL-registered loaders re-run, so that loader never
executes and its write silently vanishes — the exact MISS/HIT divergence
#725 was built to eliminate.

Narrow the exemption to isInsideLoaderScope() (DSL scope only): registered
loaders re-run on every HIT and merge into every response, so their writes
stay allowed; a handler-invoked loader now throws deterministically like a
handler write. The DSL scope ALS survives nested ctx.use bodies, so a
handler-invoked loader nested under a DSL loader stays exempt (its parent
re-runs it every HIT). The broad predicate is deferred to the throw path for
the error label so the PPR handler-invoked-loader message stays "loader",
and the throw reason keys on scope.kind so the cache() case explains the
handler-skip cause. Re-ground the neighbouring read-purity guard comment,
which this change makes contradictory: reads stay on the broad predicate
under the consumption-lane baked-copy tradeoff (#672/#674), only writes
narrow to DSL scope.

Unit (cached-header-guard.test.ts): DSL-allowed pin, handler-invoked-throws
pin (red before this change, green after), nested-under-DSL-allowed pin.
E2e (cache-scope-guard, dev + production): /handler-loader-cookie-blocked
asserts the error boundary renders and the cookie never reaches the wire;
the DSL /loader-cookie-allowed control stays green in both modes.

* docs: drop the removed __debug_manifest browser endpoint from the skill and guide

#728 deleted the ?__debug_manifest HTTP endpoint and its routeAncestry
data, but the debug-manifest skill and docs guide still told users to open
http://localhost:PORT/?__debug_manifest and documented the deleted
{ routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries }
shape. Remove both browser-endpoint sections; both files already carry the
correct router.debugManifest() programmatic path ({ routes, layouts,
totalRoutes, totalLayouts }), now the primary documented route.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant