Skip to content

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

Closed

Description

@ivogt

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() then ctx.headers.set() succeeds (no throw); without dynamic() 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 (never x-rango-shell: HIT).
  • Consumer: hec's markSfraProxy collapses 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions