Repository navigation
feat(router): ctx.dynamic() re-permits handler header writes — completes the header doctrine (#735) - #736
Merged
Conversation
… 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.
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pprroute 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). Actx.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 todayctx.dynamic()only sets_dynamic; it does not clear the latch, so actx.headers.set()in the same handler still throws.Observed in the hec storefront
.151migration: the SFRA-proxy branch callsctx.dynamic()to opt off the shell but STILL can't write itsx-rango-sfra-proxycontrol-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:After — one causal chain in the handler:
The
SfraProxySignalcontext var and its relay middleware are retired.End-to-end consumer example
The
x-rango-sfra-proxyheader rides EVERY response for the proxied branch, and that route never servesx-rango-shell: HIT(dynamic opts off the shell axis — always MISS/live).Semantics table (write-site matrix, #726/#735)
ppr) routectx.dynamic()routeloading()cache()(DSL)cache()entered AFTERdynamic()cache()boundarycache)dynamic()from middlewareOrdering 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 helperclearPprHeaderScope(): voidright beforeassertCachedHeaderWriteAllowed. It reads the RangoContext ALS store and, ONLY forkind === "ppr", clearsstore.cachedHeaderScope. Acache()latch is a separate axis whose handler writes stay non-deterministic (skipped on a HIT), sokind === "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()callsclearPprHeaderScope()afterctx._dynamic = true. Chosen here (not in the guard) becausedynamic()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 funnelStore.runscope) or no ppr latch (non-ppr routes).dynamic()drops only the SHELL axis, never the CACHE axis. Composition verified (not assumed):cache()entered afterdynamic()re-latches"cache"vialatchCachedHeaderScope's!store.cachedHeaderScopeguard (the field is undefined again after the clear), so nestedcache()writes stay guarded.cache()boundary latches"ppr"at the funnel top (first-wins,fresh.ts:659), masking the positional cache latch, but the handler still runs withinsideCacheScopetrue (fresh.ts:681). Acache()HIT skips that handler, so the write is still non-deterministic —clearPprHeaderScopeUNMASKS 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).dynamic()on their surface);dynamic()from middleware is a latch no-op, so the middleware exemption is untouched.Test plan (red → green)
src/rsc/__tests__/cached-header-guard.test.ts: newclearPprHeaderScopedescribe 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 clearcache/ 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 realreqCtx.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.@rangojs/router/testing) —src/testing/__tests__/render-handler.rsc-test.tsx:renderHandlerruns the collapsedmarkSfraProxysnippet (ctx.dynamic()+ctx.headers.set()); assertsresult.dynamic === trueand the header onresult.headers./ppr-header-guard/dynamicppr: trueroute whose handler callsctx.dynamic()then sets a response header. Asserts the page renders (no guard throw), the header rides all 3 requests, and the route never servesx-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.tsregenerated in both apps).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 thepprlatch 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, anddynamic()is the single live-declaration seam, so there is one source of truth.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/design/ppr-shell-resume.md(header doctrine + the write-site table) andskills/ppr/SKILL.md(thedynamic()re-permit note + the hard-edge exception).