Skip to content

feat(router): ctx.dynamic() re-permits handler header writes — completes the header doctrine (#735) - #736

Merged
ivogt merged 1 commit into
mainfrom
feat/dynamic-header-repermit
Jul 8, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/dynamic-header-repermit

Conversation

@ivogt

@ivogt ivogt commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

ctx.dynamic() re-permits handler header/cookie writes (closes #735)

Problem statement

ctx.dynamic() shipped in #734 as a PPR shell opt-out — it sets _dynamic, and the router skips both the shell HIT-commit and the MISS capture for that request (rsc-rendering.ts:137, :533). But that was only the first half of the #726 design. The second half — the header re-permit — was never built.

The cached-header write guard (#713/#725) forbids handler header writes on a ppr route because a shelled handler runs on the MISS but is replayed on HITs, so any header it writes would silently differ between MISS and HIT (a real storefront session bug came from exactly that divergence). A ctx.dynamic() render, however, never HITs — every request re-runs the handler live — so its header write IS deterministic and the guard's reason to forbid it evaporates. Yet today ctx.dynamic() only sets _dynamic; it does not clear the latch, so a ctx.headers.set() in the same handler still throws.

Observed in the hec storefront .151 migration: the SFRA-proxy branch calls ctx.dynamic() to opt off the shell but STILL can't write its x-rango-sfra-proxy control-flow header from the handler — it has to relay through a request-scoped context var read by a post-next() middleware.

Before vs. after (the consumer story)

Before — dynamic() + a middleware relay just to write one header:

// handler: declares live, but cannot write its own header
function markSfraProxy(ctx, reason) {
  ctx.dynamic();
  ctx.set(SfraProxySignal, reason);   // handler can't write the header...
}
// ...a separate post-next() middleware reads SfraProxySignal and writes the header

After — one causal chain in the handler:

ctx.dynamic();                            // declare live -> refuses capture AND clears the header latch
ctx.headers.set("x-rango-sfra-proxy", reason);
return null;

The SfraProxySignal context var and its relay middleware are retired.

End-to-end consumer example

// A catalog route that proxies to SFRA for some requests. dynamic() opts this
// request off the shell (always live) and re-permits the control-flow header.
path("/catalog/:slug", (ctx) => {
  if (needsSfraProxy(ctx)) {
    ctx.dynamic();                                  // BEFORE the write
    ctx.headers.set("x-rango-sfra-proxy", "catalog");
    return null;
  }
  return <Catalog slug={ctx.params.slug} />;
}, { name: "catalog", ppr: true });

The x-rango-sfra-proxy header rides EVERY response for the proxied branch, and that route never serves x-rango-shell: HIT (dynamic opts off the shell axis — always MISS/live).

Semantics table (write-site matrix, #726/#735)

Write site Pre-commit? Same every request? Verdict
Middleware yes yes legal (unchanged)
Handler, shelled (ppr) route frozen, gone on HIT no guard throws (unchanged, #725)
Handler, ctx.dynamic() route yes (always live) yes re-permitted (this PR)
Loader behind loading() no (post-commit) — forbidden (unchanged)
Loader in plain cache() (DSL) buffered, re-runs yes existing exemption (unchanged)
Handler write inside a cache() entered AFTER dynamic() skipped on HIT no still throws (cache re-latches)
Handler on a ppr route NESTED under a cache() boundary skipped on cache HIT no still throws (unmasked to cache)
dynamic() from middleware — — no-op for the latch (already exempt)

Ordering is a contract: call dynamic() BEFORE the write — a write before it hits the still-live latch and throws.

Code-change summary

Two-line runtime change plus a helper:

  • packages/rangojs-router/src/server/context.ts — new exported helper clearPprHeaderScope(): void right before assertCachedHeaderWriteAllowed. It reads the RangoContext ALS store and, ONLY for kind === "ppr", clears store.cachedHeaderScope. A cache() latch is a separate axis whose handler writes stay non-deterministic (skipped on a HIT), so kind === "cache" is left latched. This mirrors the sibling latch helpers (latchCachedHeaderScope:847, latchPprHeaderScopeForEntries:894) — same store, same field — so the guard and the opt-out can't drift.
  • packages/rangojs-router/src/server/request-context.ts — dynamic() calls clearPprHeaderScope() after ctx._dynamic = true. Chosen here (not in the guard) because dynamic() is the single seam that already declares the request live; the latch field is the one thing the guard reads, so clearing it in place keeps one source of truth and one predicate. Harmless no-op when there is no funnel store (middleware, outside the funnel Store.run scope) or no ppr latch (non-ppr routes).

dynamic() drops only the SHELL axis, never the CACHE axis. Composition verified (not assumed):

  • A cache() entered after dynamic() re-latches "cache" via latchCachedHeaderScope's !store.cachedHeaderScope guard (the field is undefined again after the clear), so nested cache() writes stay guarded.
  • A ppr route NESTED under a cache() boundary latches "ppr" at the funnel top (first-wins, fresh.ts:659), masking the positional cache latch, but the handler still runs with insideCacheScope true (fresh.ts:681). A cache() HIT skips that handler, so the write is still non-deterministic — clearPprHeaderScope UNMASKS to "cache" there (keeps the accurate cache() wording) instead of clearing. Only a pure-ppr funnel actually clears. This closed a hole a review pass surfaced (clearing "ppr" unconditionally would have opened it).
  • Loaders unchanged (no dynamic() on their surface); dynamic() from middleware is a latch no-op, so the middleware exemption is untouched.

Test plan (red → green)

  • Unit — src/rsc/__tests__/cached-header-guard.test.ts: new clearPprHeaderScope describe pins clear-ppr-then-write passes / write-before-clear throws (the feat(router): cached-scope header guard — only middleware writes headers on ppr routes (#713) #725 pin) / does NOT clear cache / cache-after-clear re-latches and re-guards / no-op with no scope / no-op with no store; plus a guarded-surfaces case driving the real reqCtx.dynamic() seam (before → throws, after → header + Set-Cookie land on the stub). Red before the fix on the write-after-dynamic() assertion; green with it. 27 tests in the file pass.
  • Userland (@rangojs/router/testing) — src/testing/__tests__/render-handler.rsc-test.tsx: renderHandler runs the collapsed markSfraProxy snippet (ctx.dynamic() + ctx.headers.set()); asserts result.dynamic === true and the header on result.headers.
  • e2e — both apps, dev + production: a /ppr-header-guard/dynamic ppr: true route whose handler calls ctx.dynamic() then sets a response header. Asserts the page renders (no guard throw), the header rides all 3 requests, and the route never serves x-rango-shell: HIT. Router (packages/rangojs-router/e2e/ppr-header-guard.test.ts) dev 44 / production 44 green; cloudflare-basic (tests/cloudflare-basic/e2e/ppr-header-guard.test.ts) dev + production green (.gen.ts regenerated in both apps).
  • Gate: pnpm run typecheck (16/16), pnpm run test:unit:all (all packages green), pnpm run lint (0/0), pnpm run format (clean).

Notes

  • dynamic() clears the ppr latch in place rather than adding a parallel "dynamic override" flag consulted by the guard — the latch field is already the single thing the guard reads, and dynamic() is the single live-declaration seam, so there is one source of truth.
  • Simplify pass (4 agents: reuse / simplification / efficiency / altitude): reuse and efficiency clean; simplification merged two duplicate no-op unit cases into one and dropped an assert already owned by the seam test; altitude verdict "appropriate" but flagged the nested-under-cache masking edge, which this PR closes (unmask-to-cache above) rather than shipping as a documented limitation.
  • The one flaky e2e in the cloudflare run (basket continuity, unrelated line 133) is pre-existing — a documented resume-window DOM-coexistence strict-mode selector issue in a route this PR does not touch; it passed on retry. This PR's new route uses a disjoint testid.
  • Docs updated in the same PR: docs/design/ppr-shell-resume.md (header doctrine + the write-site table) and skills/ppr/SKILL.md (the dynamic() re-permit note + the hard-edge exception).

… the header doctrine (#735)

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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 0d7ea01

@ivogt
ivogt merged commit 8c324b5 into main Jul 8, 2026
34 checks passed
@ivogt
ivogt deleted the feat/dynamic-header-repermit branch July 8, 2026 20:11
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.

ctx.dynamic() should re-permit handler header writes (clear the cached-header latch) — the header-re-permit half of #726

1 participant