You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
First-class per-render shell opt-out — ctx.dynamic() / noShell() for handler branches that must never bake #726
Found via the #725 guard rollout in a real storefront
A ppr'd catch-all dispatcher has branches whose renders must NEVER become shells: e.g. "SCAPI says SFRA owns this URL" — the handler returns a 200 chrome render carrying a control-flow response header the worker converts into a proxy. Pre-guard, these renders were silently CAPTURED (200 + full chrome = eligible), so a warm HIT served the empty chrome page without the proxy signal — broken page instead of proxied content. The #725 guard now makes the header write throw (loud, correct), but the underlying need remains: a branch-level way to say "this render is per-request; do not shell it".
Today's implicit opt-outs are accidents: reading identity (cookies()) trips the capture's identity guard; throwing trips the error path. Consumers need the explicit primitive:
ctx.dynamic() (or noShell()): callable in a handler branch; during a capture render → capture REFUSES deterministically (no store write, once-per-key debug note, backoff per the existing refusal machinery); during a foreground render → no-op. Composes with the header guard: a branch that opts out is declaring itself live — whether its header writes should then be permitted (within that render) or still forced to middleware is a design decision to settle here (leaning: still middleware — the write would ride MISSes only by definition, but with dynamic() declared that IS deterministic: the route never HITs... except per-URL: the catch-all's OTHER branches still shell. Key nuance: opt-out is per SHELL KEY (URL), not per route — a catch-all's PDP URLs shell while its proxy URLs never do).
Acceptance: e2e — a ppr route with one dynamic() branch: that URL never HITs (x-rango-shell stays MISS, no capture warning spam — refusal is declared, not an error), other URLs of the same route shell normally; dev+prod, both apps. Docs: ppr skill + ppr-shell-resume.md.
Refs: #725, #713 doctrine, the storefront dispatcher SFRA-proxy lane (2026-07-07 session).
ctx.dynamic() is not merely a shell opt-out — it is the principled front door to the cached-scope header guard (#713/#725). The two are one mechanism seen from opposite sides.
The primitive
ctx.dynamic() — a METHOD on HandlerContext and MiddlewareContext. Declares: "this render is live; do not bake it into a shell." Per shell key (URL), document-level — any one segment calling it opts the whole URL out.
Effective layers (run during the capture render, before the putShell barrier)
path, layout, parallel, intercept handlers + middleware. Callable anytime before the barrier — late in the handler, after your async/SCAPI work, is the point (decide dynamically, then declare). The bake decision happens after all handlers complete, so a late call is still observed.
Loaders do NOT get it — compile-time, not a runtime throw
Loaders run with ambient access, not a handler ctx, so ctx.dynamic() is simply absent from their surface — TS (isolatedDeclarations) rejects it at author time; no masked-render surprise. Root cause: loaders are masked at capture (bodies never execute during the render that bakes), so a loader's dynamic() is unreachable at the moment it would matter. This is the SAME fact as "loaders can't set headers": the loader is downstream of every decision — post-commit for headers, post-barrier for shelling. One pre/post-commit line, drawn once. (Historical confirmation: "decide after the loader" is the dropped guard() primitive shape — rejected for reopening RSC side-effect rollback.)
The header unification — the load-bearing insight
The header guard forbids handler header writes on ppr routes because they'd be non-deterministic (ride MISSes, vanish on HITs). But a dynamic() branch never HITs — so its header write becomes deterministic again (applied on every request, because every request is a live render). ctx.dynamic() therefore clears the cached-header latch for the rest of that render, RE-PERMITTING handler header writes. Ordering contract: call dynamic() before the header write (the write before it already threw).
Exhaustive case table (the model this completes):
Write site
Pre-commit?
Same every request?
Verdict
Middleware
yes
yes
legal
Handler, shelled (ppr) route
frozen into shell, gone on HIT
no
guard throws
Handler, dynamic() route
yes
yes
re-permitted
Loader behind loading()
no (settles mid-stream)
—
forbidden
Loader in plain cache()
buffered as a unit
yes
existing contract
dynamic() is the toggle that moves a route from row 2 to row 3. There is no third option: shell (headers via middleware) XOR dynamic (headers via handler). It makes that fork a declaration instead of an accident.
Mechanism (verify during build)
Thin public name over the EXISTING capture-refusal path (handlerLiveHoles / the refused lane) + a latch state change. Key refinement over today's cookies()-trick: a dynamic() key should skip scheduling the capture entirely (clean — no schedule, no refuse, NO backoff), not schedule-then-refuse-then-backoff. Per-URL: the SFRA URLs consistently call it → never scheduled; the same route's PDP URLs never call it → shell normally. Middleware likely uses prevent-schedule (runs on the serve path, stops the background capture); handlers use refuse-at-barrier. Same outcome — confirm against how capture scheduling reads request context.
hec SFRA before/after (the motivating case)
Before (today's hack — force-live by side-effect + smuggle header through middleware context-var):
functionmarkSfraProxy(ctx,reason){cookies();// side-effect trips the identity guard -> capture refusesctx.set(SfraProxySignal,reason);// handler can't write the header; stash for post-next middleware}// + dispatcherHeaderMiddleware reads the var post-next() and writes x-rango-sfra-proxy
After (dynamic() — one causal chain, no middleware relay):
ctx.dynamic();// declare live -> refuses capture AND clears the header latchctx.headers.set("x-rango-sfra-proxy",reason);returnnull;
Deferred (not v1)
Ambient standalone dynamic() import for call sites inside nested async server components (no ctx threaded) — would rely on a runtime scope-throw like cookies(). Add only if a real nested-component need appears (same defer posture as the RR7-style headers() use-item).
Prior art
Next.js connection() / unstable_noStore force a route dynamic — same primitive, different surface.
Refs: #713/#725 (header guard), #695 (shell fast path / handlerLiveHoles), the hec SFRA migration (.150 session).
Activity
ivogt commentedon Jul 7, 2026
Converged design (2026-07-07 design session) — supersedes the issue body's shell-opt-out-only framing
ctx.dynamic()is not merely a shell opt-out — it is the principled front door to the cached-scope header guard (#713/#725). The two are one mechanism seen from opposite sides.The primitive
ctx.dynamic()— a METHOD onHandlerContextandMiddlewareContext. Declares: "this render is live; do not bake it into a shell." Per shell key (URL), document-level — any one segment calling it opts the whole URL out.Effective layers (run during the capture render, before the putShell barrier)
path,layout,parallel,intercepthandlers + middleware. Callable anytime before the barrier — late in the handler, after your async/SCAPI work, is the point (decide dynamically, then declare). The bake decision happens after all handlers complete, so a late call is still observed.Loaders do NOT get it — compile-time, not a runtime throw
Loaders run with ambient access, not a handler
ctx, soctx.dynamic()is simply absent from their surface — TS (isolatedDeclarations) rejects it at author time; no masked-render surprise. Root cause: loaders are masked at capture (bodies never execute during the render that bakes), so a loader'sdynamic()is unreachable at the moment it would matter. This is the SAME fact as "loaders can't set headers": the loader is downstream of every decision — post-commit for headers, post-barrier for shelling. One pre/post-commit line, drawn once. (Historical confirmation: "decide after the loader" is the droppedguard()primitive shape — rejected for reopening RSC side-effect rollback.)The header unification — the load-bearing insight
The header guard forbids handler header writes on ppr routes because they'd be non-deterministic (ride MISSes, vanish on HITs). But a
dynamic()branch never HITs — so its header write becomes deterministic again (applied on every request, because every request is a live render).ctx.dynamic()therefore clears the cached-header latch for the rest of that render, RE-PERMITTING handler header writes. Ordering contract: calldynamic()before the header write (the write before it already threw).Exhaustive case table (the model this completes):
dynamic()routeloading()cache()dynamic()is the toggle that moves a route from row 2 to row 3. There is no third option: shell (headers via middleware) XOR dynamic (headers via handler). It makes that fork a declaration instead of an accident.Mechanism (verify during build)
Thin public name over the EXISTING capture-refusal path (
handlerLiveHoles/ the refused lane) + a latch state change. Key refinement over today'scookies()-trick: adynamic()key should skip scheduling the capture entirely (clean — no schedule, no refuse, NO backoff), not schedule-then-refuse-then-backoff. Per-URL: the SFRA URLs consistently call it → never scheduled; the same route's PDP URLs never call it → shell normally. Middleware likely uses prevent-schedule (runs on the serve path, stops the background capture); handlers use refuse-at-barrier. Same outcome — confirm against how capture scheduling reads request context.hec SFRA before/after (the motivating case)
Before (today's hack — force-live by side-effect + smuggle header through middleware context-var):
After (
dynamic()— one causal chain, no middleware relay):Deferred (not v1)
Ambient standalone
dynamic()import for call sites inside nested async server components (noctxthreaded) — would rely on a runtime scope-throw likecookies(). Add only if a real nested-component need appears (same defer posture as the RR7-styleheaders()use-item).Prior art
Next.js
connection()/unstable_noStoreforce a route dynamic — same primitive, different surface.Refs: #713/#725 (header guard), #695 (shell fast path / handlerLiveHoles), the hec SFRA migration (.150 session).
feat(router): ctx.dynamic() re-permits handler header writes — closes…
ivogt commentedon Aug 30, 2026
Shipped as
ctx.dynamic()(nonoShell()alias).HandlerContext/MiddlewareContext.dynamic()insrc/server/request-context.ts— sets_dynamicand clears the PPR header latch (ctx.dynamic() should re-permit handler header writes (clear the cached-header latch) — the header-re-permit half of #726 #735).src/rsc/rsc-rendering.ts(middleware call bypasses the HIT commit; handler call on a MISS suppresses follow-up capture).tests/cloudflare-basicppr-header-guard,e2e/prerender-ppr.test.ts(build + runtime opt-out). Userland:renderHandler/runMiddlewareexposeresult.dynamic.skills/ppr/SKILL.md("Opting out per request with ctx.dynamic()").