Skip to content

feat(router): cached-scope header guard — only middleware writes headers on ppr routes (#713) - #725

Merged
ivogt merged 1 commit into
mainfrom
feat/cached-header-guard
Jul 7, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/cached-header-guard

Conversation

@ivogt

@ivogt ivogt commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

fix(ppr): the pure header guard — handler/loader ctx.headers writes throw in cached scopes; middleware is THE header lane (#713)

Problem statement

A handler on a ppr route could call ctx.headers.set() / cookies().set() and see it work — on the MISS. On every shell HIT the handler is replayed from the cached shell and never runs, so the header silently vanished: two visitors to the same URL got different response headers depending on cache state, with no error anywhere (field evidence: a storefront session bug caused by exactly this MISS/HIT cookie divergence). Loader writes were worse-by-physics: shell-first streaming flushes the response headers at TTFB before any loader settles, so loader header writes are dead letters on HITs. The equivalent hazard inside a cache() boundary already threw; the ppr surface did not.

Doctrine (final spec on #713, post six-framework survey): ppr is a document-scoped cache() — in any cached scenario, ONLY MIDDLEWARE writes headers. No replay machinery, no derived headers, no exclusion taxonomy.

Before vs. after

Before (silent divergence):

path("/products/:id", (ctx) => {
  ctx.headers.set("x-page-type", "pdp");   // works on MISS, vanishes on HIT
  cookies().set("session", mint());        // MISS-only Set-Cookie — the storefront bug
  return <ProductPage />;
}, { name: "product", ppr: { ttl: 600 } });

After (deterministic first-render error, dev AND prod):

Error: ctx.headers.set() cannot be called from a handler on a ppr route
(route "product") — the document shell is cached and replayed. On a cache hit
the handler is skipped, so this write would silently vanish. Set response
headers in route middleware instead — middleware runs on every request,
including cache hits.

The fix (the live lane):

middleware(async (ctx, next) => {
  ctx.headers.set("x-page-type", "pdp");   // present on MISS and HIT, byte-equal
  cookies().set("session", mint());        // live on every request
  return next();
}, () => [
  path("/products/:id", ProductPage, { name: "product", ppr: { ttl: 600 } }, () => [
    loader(PriceLoader),
    loading(<PriceSkeleton />),
  ]),
]);

End-to-end consumer example (the storefront basket)

// actions/basket.ts
"use server";
import { cookies } from "@rangojs/router";
export async function addToBasket(): Promise<number> {
  const jar = cookies();
  const next = Number(jar.get("basket_count")?.value ?? "0") + 1;
  jar.set("basket_count", String(next), { path: "/" });  // actions stay live — they run before the render
  return next;
}

// urls.tsx — PDP with a cached shell, personalization on the middleware + hole lanes
middleware(async (ctx, next) => {
  ctx.headers.set("x-basket-count", cookies().get("basket_count")?.value ?? "0");
  return next();
}, () => [
  path("/products/:id", ProductPage, { name: "product", ppr: { ttl: 600, swr: 120 } }, () => [
    loader(BasketAwarePriceLoader),      // live hole — fresh per request, even on HITs
    loading(<PriceSkeleton />),
  ]),
]);

Add-to-basket POST → the next GET serves the cached shell (x-rango-shell: HIT) and the middleware still reads the rotated cookie and emits x-basket-count: 1 — session continuity through the cache, pinned by e2e in the cloudflare app.

Semantics table

Writer plain route inside cache() ppr route (any render)
Middleware (pre/post next) allowed allowed (unchanged) allowed — pinned MISS + HIT
Handler ctx.headers.* allowed throws (existed; message unified) throws (new)
Handler header/setCookie/setStatus (reqCtx lane) allowed throws (existed; message unified) throws (new)
Loader writes (cookies().set, reqCtx lane) allowed allowed (unchanged — deliberate) throws (new)
Server action cookies().set allowed allowed allowed (runs pre-render)
Serve-owned headers (x-rango-shell, content-type, state cookie) — unchanged unchanged

The one deliberate deviation from the literal spec text ("handler AND loader writes throw in ppr and cache() scopes"): loader writes inside a plain cache() boundary stay allowed. They are a pinned, working consumer contract — cache-scope-guard.test.ts "loader calling cookies().set() inside cache() should be allowed" (dev + production) and vite-rsc-demo's shop cart (getOrCreateCartId from CartLoader/ProductCartLoader inside the shop cache(); shop-cache-scope.test.ts asserts it must not throw). The dead-letter physics does not apply there: those loaders re-run on every request including cache hits and their writes merge into each response — no MISS/HIT divergence exists to guard against. On ppr the physics does apply (headers flush with the shell prelude before loaders settle), so ppr loader writes throw. Both suites re-run green.

Code-change summary

One guard, one latch field, six latch sites, ONE guarded mutation surface (a choke point, not enumerated wrappers) — no new files in src/ outside tests.

  • src/server/context.ts — the whole mechanism:
    • CachedHeaderScope type + cachedHeaderScope field on the RangoContext ALS store (runWithStore deliberately never copies it — the middleware exemption depends on the latch dying with the funnel scope, now annotated at the copy list);
    • latchCachedHeaderScope(scope) (first latch wins — a nested cache() never downgrades a ppr scope);
    • latchPprHeaderScopeForEntries(entries, routeKey) — checks the LEAF route entry (entries[entries.length - 1], the manifestEntry) with the shared isPprEntry predicate: guard and serve (resolvePprConfig(manifestEntry)) now share predicate AND input, so they cannot drift (a ppr declaration on a non-leaf ancestor neither shell-serves nor latches). Still guards on declaration, so a missing shell store or gating nonce doesn't turn the error off;
    • assertCachedHeaderWriteAllowed(surface) — the unified check + one message family naming surface, layer (handler/loader via the loader ALS scopes), route, and the fix. kind: "cache" + loader returns early (the pinned exemption).
  • src/server/request-context.ts — the choke point: the stub Response's Headers are only reachable through a guarding proxy shadowed onto the instance (Object.defineProperty(res, "headers", ...) — the stub stays a REAL Response, no facade), whose set/append/delete consult the guard first. This covers ctx.header/setCookie/deleteCookie (their per-method asserts are deleted), cookies().set(), ctx.setTheme (a real hole in the previous revision — its Set-Cookie append was unguarded), the handler ctx.headers proxy, and raw ctx.res.headers.set(...) (the bypass documented in the previous revision's notes — now closed). setStatus/onResponse are not Headers mutations and stay enumerated. Internal serve writers (_rotateStateCookie, _setKeepCacheDirective) write to the raw Headers — invalidateClientCache()/keepClientCache() are documented callable from loaders and during capture.
  • src/router/segment-resolution/fresh.ts — ppr latch at the top of resolveAllSegments (document-scoped: the whole chain, root layout included, bakes into the shell — unlike the positional cache() latch) and resolveLoadersOnly (loaders on shell-HIT tails / cached serves); the existing in-loop cache() latch also sets cachedHeaderScope so the message can name the route.
  • src/router/segment-resolution/revalidation.ts — same two latches for the partial/action funnels (resolveAllSegmentsWithRevalidation, resolveLoadersOnlyWithRevalidation) — "on EVERY render" includes client navigations and action re-renders.
  • src/router/intercept-resolution.ts — defense-in-depth latches in resolveInterceptEntry (after intercept middleware — middleware stays the live lane) and resolveInterceptLoadersOnly, keyed off the intercepted TARGET route's manifest entry. Reachability: ppr and intercepts do not compose on the shell path (shells are captured/served only for document requests; withInterceptResolution returns early on isFullMatch), but a partial nav CAN render an intercept over a ppr-declared target route in its own store scope where the main funnel's latch does not apply — these latches close that seam by declaration.
  • src/router/handler-context.ts — the ctx.headers proxy's mutating methods call the unified assert (kept for the precise ctx.headers.set() message and the no-request-context fallback); the "use cache" exec stamp check is unchanged. Writes flow through to the request context's guarded stub headers underneath.

Why this seam: the funnels are where handler/loader execution begins and they run strictly INSIDE the middleware chain's next(), so middleware pre-next writes precede the latch and post-next writes execute outside the resolution ALS scope — middleware can never trip the guard, by construction rather than by exclusion list. The fast-path HIT replays handlers without running them, so the guard's job on HITs is the loader funnels + the deterministic failure already thrown on the very first MISS render.

Timing note (same shape as the existing cache() guard): the latch is per-ALS-resolution-scope and timing-based, not layer-tagged — a post-next() middleware write through the reqCtx lane cannot trip it because the resolution scope has exited by then.

Test plan

  • Red→green: with the 5 src/ files reverted to main, the new App-A guard e2es fail 4/4 — the route renders happily, x-phg-handler: should-never-apply reaches the response, and phg-loader=should-never-apply appears in Set-Cookie (the exact silent hazard); with the guard they pass. Output captured in the session.
  • Unit (src/rsc/__tests__/cached-header-guard.test.ts, 19 tests): the seam (both kinds x both layers, first-latch-wins, no-latch), the latch predicate (ppr: true/{ttl} latch; false/undefined/layout don't; leaf-input parity — ppr on a non-leaf ancestor does not latch), the guarded surfaces (handler proxy set/append/delete + reqCtx header/setCookie/deleteCookie/setStatus; setTheme under ppr throws / outside cached scopes works — red→green: both new tests fail with the choke point reverted, 2 failed | 17 passed; raw ctx.res.headers mutations throw, reads stay open; the serve-path drain stays exempt after the latched scope exits), class+message-family equivalence (ppr error ≡ cache() error, one FAMILY_RE), the cache()-loader exemption, and middleware merge on the HIT serve path (createResponseWithMergedHeaders: serve-owned headers win, middleware header merges, Set-Cookie appends, stub drained).
  • E2e App A (packages/rangojs-router/e2e/ppr-header-guard.test.ts, dev 6 + production 6): handler write → error boundary on first AND second GET (deterministic, no poisoned shell — x-rango-shell never HIT); loader write → error with the loader wording; writes never reach the response; middleware header + cookie on MISS and HIT (static value byte-equal, per-request value advancing across HITs = live, not replayed); NEW /mw-post-next: route middleware writing header + cookies().set() AFTER await next() on a ppr route lands on MISS and HIT — pins the middleware exemption's mechanism (the latch dies with the funnel scope). The dev-only browser EQUIVALENCE test was deleted — it re-asserted the unit-level EQUIVALENCE pin verbatim.
  • E2e App B (tests/cloudflare-basic/e2e/ppr-header-guard.test.ts, dev 5 + production 5, workerd/KV): same guard + middleware-live pins, plus basket continuity: action POST rotates the cookie → GET is a shell HIT and middleware emits x-cf-basket-count: 1, then 2 after a second action.
  • Batteries: rangojs-router shell-cache shell-secure shell-cache-action shell-capture-timeout prerender-ppr — dev 48 passed; production 47 passed + 1 flaky (shell-capture-timeout ppr capture budget must be configurable — deferred shell material (async Meta / top-level handle pushes) beyond 5s makes a route uncapturable #715 negative, ECONNRESET on a raw GET against the isolated preview server, passed on retry; the test exercises capture-budget refusal, no header writes anywhere near it). cloudflare-basic ppr-shell ppr-tag-eviction ppr-capture-timeout — dev 32, production 32. semantic-matrix (segment-resolution touched): dev 21, production 24. cache-scope-guard dev 16 + production 14 (message change compatible — the family keeps the "cache() boundary" fragment). vite-rsc-demo shop-cache-scope dev 3 + production 3 (the pinned loader-cookie contract).
  • Gate: pnpm run typecheck (16/16), pnpm run test:unit:all (all packages, 0 failures), pnpm run lint (0 warnings), pnpm run format (clean), pnpm check:e2e-bucketing (OK).
  • *.gen.ts regenerated and included for both apps that gained routes (test-app, cloudflare-basic).

Consumer migration note

Handler ctx.headers.set / cookies().set / setStatus on a cached route (a ppr route or inside cache()) is a runtime error after this — deterministic on the first render, dev and prod. Move the write into route middleware (e.g. the storefront PDP's Cache-Control / x-page-type lines), or delete it: an s-maxage set from a holed ppr document was already edge-freezing per-request material on MISSes. Note ppr.ttl/swr governs the worker-internal shell entry, never HTTP cacheability — ppr responses are dynamic by design. Repo sweep found ZERO in-repo migrations needed: no fixture/example sets handler headers on a ppr or cache() route (the only such routes are the guard-test fixtures themselves); the nonce route (middleware-set ctx.set(nonce, ...) on a ppr route) is untouched and green as the positive control.

Notes

  • /simplify protocol (4 agents) ran on the diff; survivors applied, re-gated green: extracted isPprEntry(entry) as the ONE ppr opt-in predicate shared by resolvePprConfig and the latch (kills the hand-synced mirror); latchCachedHeaderScope takes scalars (no discarded allocation on the already-latched path); assertCachedHeaderWriteAllowed builds the surface string lazily on the throw path only (the pre-diff code allocated nothing on the success path — restored); extracted private isInsideAnyLoaderScope() (one home for the loader-exemption predicate, also used by isInsideCacheScope); renamed the request-context wrapper to assertResponseWriteAllowed (old name said cache-scope-only; it now also guards ppr) — it additionally guards ctx.onResponse(), which was already on the same guarded lane; dropped a dead data-testid in the cloudflare fixture. Skips: per-file warmToHit/HTML_HEADERS copies are the documented house convention (8+ suites); the funnel-top latch seam was verified as the right depth (full-render and loader-only paths never share a post-middleware node); the request-context wrapper was kept (5 call sites) rather than inlined.
  • ctx.res is closed: the stub Response's headers property is shadowed with the guarding proxy on the instance itself, so the raw getter-then-mutate escape (ctx.res.headers.set(...)) hits the same guard as every other surface. The stub remains a real Response (no facade proxy), so platform hand-offs are unaffected.
  • The guard fires on declaration (ppr in the path options), not on cache-store availability: a ppr route gated to axis 1 by a missing shell family or an active nonce still throws, keeping dev(memory store) → prod(KV) behavior identical.
  • Docs: one-sentence doctrine + the six-framework survey summary added to packages/rangojs-router/docs/design/ppr-shell-resume.md ("The header doctrine"); constraint-table row updated; docs/internal/feature-map.md gained the doctrine bullet; skills/ppr/SKILL.md "hard edges" now leads with the header rule.
  • Someday-option (not this PR, per spec): an RR7-style declarative per-route headers() use-item on the live lane, if a request-dependent need materializes.

Simplify review (external pass)

A second, external consolidation pass over the PR diff. Applied findings (re-gated green: full typecheck, test:unit:all, lint, format, and the dev+production e2e batteries below):

  1. ctx.setTheme was a real hole — fixed at the choke point, not with a sixth wrapper. setTheme appended its theme cookie to the stub headers with only the "use cache" taint check, so a ppr-route handler calling it wrote a cookie that silently vanished on HITs — the exact divergence this PR forbids. Instead of enumerating another wrapper, the stub Response's Headers mutation surface itself is now guarded: a proxy whose set/append/delete consult assertCachedHeaderWriteAllowed("response headers", method) is shadowed onto the stub instance (Object.defineProperty — the stub stays a real Response). This automatically covers ctx.header/setCookie/deleteCookie (their per-method asserts were deleted), cookies().set(), setTheme, the handler ctx.headers proxy, and the raw ctx.res.headers escape (bypass note above rewritten to "closed"). setStatus/onResponse stay enumerated (not Headers writes); internal serve writers (_rotateStateCookie, _setKeepCacheDirective) use the raw Headers (documented loader/capture-callable). Every legitimate stub mutation site was audited for exemption: the serve/commit drain (rsc/helpers.ts:130/:306) and post-next() middleware writes run outside latched funnel scopes (guard no-ops), intercept middleware runs before the intercept latch, and the theme/nonce middleware fixtures write from middleware (never latched). Red→green: with the choke point reverted, the two new unit tests fail (2 failed | 17 passed); with it, 19/19.
  2. Intercept funnels latch (defense in depth). Reachability verdict: ppr+intercepts do NOT compose on the shell path — shells are captured/served only for document requests and withInterceptResolution returns early on isFullMatch, so intercept content can never bake into a shell. But a partial nav CAN render an intercept over a ppr-declared target route, in its own Store.run scope where the main funnel's latch does not apply — resolveInterceptEntry (after intercept middleware) and resolveInterceptLoadersOnly now latch off the target route's manifest entry, closing that seam by declaration.
  3. The middleware exemption's invariant is pinned. (a) runWithStore's field-copy list now carries the annotation that cachedHeaderScope is deliberately NOT copied — the exemption depends on the latch dying with the funnel scope. (b) New e2e (/ppr-header-guard/mw-post-next, dev + production): route middleware doing await next(); cookies().set(...); ctx.header(...) on a ppr route — both writes land on MISS and HIT.
  4. Guard and serve share predicate AND input. latchPprHeaderScopeForEntries no longer scans the whole entry chain per request; it checks the LEAF route entry (entries[entries.length - 1] — verified equal to the snapshot's manifestEntry, the exact input resolvePprConfig gets on the serve path). Kills the latent drift where a ppr declaration on a non-leaf ancestor latched the guard but never shell-served. Unit test pins the parity.
  5. Doctrine re-narration collapsed. The cachedHeaderScope field JSDoc and the guard's "Layer rules" block in server/context.ts are now RULE + one-line pointer to docs/design/ppr-shell-resume.md "The header doctrine" (which gained a "Mechanics" subsection as the single home). Runtime error strings unchanged in shape; the reqCtx-lane surface string is now response headers.<method>() (same family — tests pin the family, not surfaces).
  6. Duplicate e2e deleted. The dev-only browser EQUIVALENCE test in packages/rangojs-router/e2e/ppr-header-guard.test.ts was a pure re-assertion of the unit EQUIVALENCE test (cached-header-guard.test.ts) — removed.

Skipped (with rationale):

  • isPprValue() extraction for the two non-EntryData .ppr checks — different layers and shapes; forcing one predicate across them couples unrelated types.
  • warmToHit/routeEntry/FAMILY_RE test-helper duplication — pre-existing per-file convention (~7 copies predate this PR).
  • Moving isPprEntry to a leaf module — cohesion polish with import-cycle risk; not worth the churn.
  • The cloudflare-basic app did not get a mw-post-next twin — the mechanism under test (ALS store scope lifetime) is runtime-agnostic and the rangojs app pins it in both modes; cloudflare keeps its middleware-live + basket-continuity pins.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e2448eb

…ers on ppr routes; handlers throw in ppr and cache() scopes (closes #713)

Doctrine (six-framework survey, recorded on #713): ppr = document-scoped
cache(), and no framework in the ecosystem replays page-code headers
from a document cache entry — Next app pages expose no mutable API
(cookies().set() throws), SvelteKit/Astro silently drop (self-admitted
warts), RR7 errors at build when no live lane exists. Replay is
ecosystem-normal only for raw-Response endpoint surfaces, where
set-cookie is an open wound (Nitro session-fixation issue #3468). Our
prior behavior was the SvelteKit/Astro shape: handler headers rode
MISSes and silently vanished on HITs — the divergence class behind a
production session bug. This PR makes it deterministic and loud.

One mechanism: a cachedHeaderScope latch on the request context ALS,
set by the four segment-resolution funnels when the entry chain
declares ppr (document-scoped, via the isPprEntry() predicate now
shared with resolvePprConfig — guard and serve cannot drift) and by the
positional cache() latch. Guarded surfaces: the ctx.headers proxy and
the reqCtx header/setCookie/deleteCookie/setStatus/onResponse wrappers;
one message family naming route + layer + the fix (route middleware).
The funnels run inside the middleware chain's next(), so middleware
writes structurally cannot trip the guard.

Physics-shaped, not blanket: loader writes inside PLAIN cache() stay
ALLOWED — a pinned consumer contract (cache-scope-guard e2e; demo shop
cart loaders) and the commit order permits them. Under ppr the
stored-prelude flush commits headers at TTFB before any loader settles
(dead letters on every HIT), so ppr loader writes throw. Handlers throw
in both cached scopes on every render — deterministic first-MISS
failure, dev and prod.

Middleware pinned as THE header lane: custom header + Set-Cookie
asserted byte-equal on MISS and HIT in both apps, plus an action
POST -> GET(HIT) session-cookie continuity test (the storefront basket
shape). No replay machinery, no derived headers, no exclusion taxonomy
(rejected design recorded on the issue).

Red->green: guard e2es fail 4/4 on main (handler header + loader cookie
reach the response), pass with the guard. Migration sweep: zero in-repo
call sites needed changes; the nonce-via-middleware fixture is the
positive control. Batteries: guard suites 6+5 / 5+5 (both apps), shell
battery 48 dev / 47 prod, semantic-matrix 21+24, cache-scope-guard
16+14, cloudflare ppr 32+32, demo shop 3+3, 14 new unit; gate green.
/simplify applied.

External simplify pass (applied on review): the guard now sits at ONE
choke point — a guarding Headers proxy shadowed onto the stub Response
instance — instead of enumerated per-method wrappers. This closed two
real holes: ctx.setTheme (its Set-Cookie append was unguarded — a
ppr-route handler could write a theme cookie that vanished on HITs) and
the raw ctx.res.headers escape; setStatus/onResponse stay enumerated,
internal serve writers exempt via the raw Headers. The ppr latch now
checks the LEAF route entry only (same predicate AND input as
resolvePprConfig's serve path); the intercept funnels gained
defense-in-depth latches (ppr+intercepts don't compose on the shell
path — full matches skip intercepts). New pins: setTheme red->green
unit pair, raw-res-escape unit, leaf-parity unit, serve-drain-exempt
unit (19 unit total), and a /mw-post-next e2e (dev+prod) asserting
post-next() middleware header + cookie land on MISS and HIT (the latch
dies with the funnel scope; runWithStore never copies it — now
annotated). Deleted the browser EQUIVALENCE e2e (verbatim re-assert of
the unit pin). Re-gated: batteries 69+1f dev / 67+1f prod (flake =
shell-cache bake-lane locator race, retried green, passes 5x standalone
on base), semantic-matrix 21+24, cloudflare 37+37, demo shop 3+3,
guard suite 6+6.
@ivogt
ivogt force-pushed the feat/cached-header-guard branch from 0a8ab7a to e2448eb Compare July 7, 2026 09:57
@ivogt
ivogt merged commit 646ea02 into main Jul 7, 2026
34 checks passed
@ivogt
ivogt deleted the feat/cached-header-guard branch July 7, 2026 11:39
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.
ivogt added a commit that referenced this pull request Jul 8, 2026
… the header doctrine (#735) (#736)

The cached-scope header guard (#725) forbids handler header/cookie writes
on ppr routes because they ride MISSes and vanish on HITs — non-
deterministic. But ctx.dynamic() (#734) opts the render off the shell
axis: it never HITs, every request is live, so its header writes ARE
deterministic. The guard's reason to forbid them is gone.

ctx.dynamic() now clears the cached-header latch (server/context.ts
clearPprHeaderScope, called from request-context.ts dynamic() after
_dynamic is set). Ordering: a write BEFORE dynamic() still throws (latch
live); writes AFTER pass. This completes the #726 design — #734 shipped
the shell-opt-out half, this is the header-re-permit half.

Composition hardening (surfaced by the altitude simplify pass, closed not
shipped): a ppr route nested UNDER a cache() boundary runs the handler
with insideCacheScope true; a cache() HIT still skips it, so a cleared
latch would drop the write on that HIT. clearPprHeaderScope therefore
UNMASKS to kind:"cache" when insideCacheScope is active instead of
clearing — ppr writes re-permitted, cache()-scope writes stay guarded. A
plain cache() boundary entered after dynamic() likewise re-latches
"cache". Middleware dynamic() is a no-op (runs outside the funnel scope,
already exempt); loaders unchanged (no dynamic() on their surface).

Consumer payoff — the SFRA-proxy lane collapses to one causal chain:
  ctx.dynamic();                                  // off the shell axis
  ctx.headers.set("x-rango-sfra-proxy", reason); // now legal, always live
retiring the context-var + post-next-middleware relay the workaround
needed.

Tests: unit clearPprHeaderScope (clear-ppr, write-before-throws, not-
cache, cache-after-clear re-latch, nested-under-cache unmask, no-op) +
guarded-surfaces dynamic() re-permit; userland renderHandler dogfood
(the markSfraProxy collapse); e2e both apps dev+prod (a dynamic()+header
route: header on every request, never x-rango-shell HIT). Doctrine table
gains the re-permitted row (ppr-shell-resume.md + ppr skill). Batteries:
router unit 5195, e2e 44/44 dev+prod, cloudflare 35/35; simplify applied.
Closes #735.
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