Repository navigation
Description
Context
#734 shipped ctx.dynamic() as a PPR shell opt-out (_dynamic marker; capture never scheduled — rsc-rendering.ts:137/:533). That's the first half of the #726 design. The second half — the header-re-permit — was not built, and it's the part that unifies dynamic() with the cached-scope header guard (#713/#725).
The gap
The header guard forbids handler header writes on ppr routes because they'd be non-deterministic (ride MISSes, vanish on HITs). But a ctx.dynamic() render never HITs — every request is a live render — so its header write IS deterministic (applied on every request). The guard's reason to forbid it evaporates. Yet today ctx.dynamic() only sets _dynamic; it does NOT clear cachedHeaderScope, so ctx.headers.set() in that same handler still throws.
Consequence (observed in the hec storefront .151 migration): the SFRA-proxy branch calls ctx.dynamic() to opt off the shell, but STILL cannot write its x-rango-sfra-proxy control-flow header from the handler — it must relay through a request-scoped context var read by a post-next() middleware. With the re-permit, the whole dance collapses:
// today (.151): dynamic() + middleware relay
function markSfraProxy(ctx, reason) {
ctx.dynamic();
ctx.set(SfraProxySignal, reason); // handler can't write the header; middleware does, post-next
}
// with the re-permit: one causal chain
ctx.dynamic(); // declare live -> refuses capture AND clears the header latch
ctx.headers.set("x-rango-sfra-proxy", reason);
return null;Fix
ctx.dynamic() clears the cached-header latch (cachedHeaderScope) for the remainder of the render, re-permitting handler header/cookie writes. Ordering contract: call dynamic() before the write (a write before it already threw — keep that). Loaders unaffected (still no dynamic(), still post-commit → still forbidden).
The exhaustive case table this completes (from #726):
| Write site | Pre-commit? | Same every request? | Verdict |
|---|---|---|---|
| Middleware | yes | yes | legal |
| Handler, shelled (ppr) route | frozen, gone on HIT | no | guard throws |
Handler, dynamic() route |
yes | yes | re-permitted (this issue) |
Loader behind loading() |
no | — | forbidden |
Loader in plain cache() |
buffered | yes | existing contract |
Tests
- Unit: a ppr-route handler that calls
ctx.dynamic()thenctx.headers.set()succeeds (no throw); withoutdynamic()it throws (the feat(router): cached-scope header guard — only middleware writes headers on ppr routes (#713) #725 pin, unchanged); write-before-dynamic()still throws. - e2e (both apps, dev+prod): a
dynamic()route sets a handler header → present on EVERY request (the route never HITs, so no MISS/HIT split to assert — it's always live); pair with the shell-opt-out assertion (neverx-rango-shell: HIT). - Consumer: hec's
markSfraProxycollapses to the two-liner (retires the SfraProxySignal middleware relay).
Refs: #726 (full dynamic() design), #734 (shell-opt-out half, shipped), #713/#725 (header guard), the hec .151 SFRA migration.
Activity
feat(router): ctx.dynamic() re-permits handler header writes — closes…