Skip to content

First-class per-render shell opt-out — ctx.dynamic() / noShell() for handler branches that must never bake #726

Closed

Description

@ivogt

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).
  • Next.js analog: connection()/unstable_noStore forcing dynamic.

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).

Activity

  1. ivogt commented on Jul 7, 2026

    @ivogt
    CollaboratorAuthor

    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 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):

    function markSfraProxy(ctx, reason) {
      cookies();                          // side-effect trips the identity guard -> capture refuses
      ctx.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 latch
    ctx.headers.set("x-rango-sfra-proxy", reason);
    return null;

    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).

  2. ivogt commented on Aug 30, 2026

    @ivogt
    CollaboratorAuthor

    Shipped as ctx.dynamic() (no noShell() alias).

    • API: HandlerContext / MiddlewareContext.dynamic() in src/server/request-context.ts — sets _dynamic and 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).
    • Serve/capture honor it in src/rsc/rsc-rendering.ts (middleware call bypasses the HIT commit; handler call on a MISS suppresses follow-up capture).
    • e2e: tests/cloudflare-basic ppr-header-guard, e2e/prerender-ppr.test.ts (build + runtime opt-out). Userland: renderHandler / runMiddleware expose result.dynamic.
    • Docs: skills/ppr/SKILL.md ("Opting out per request with ctx.dynamic()").
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