Skip to content

feat(router): ctx.dynamic() PPR shell opt-out + ctx.build; build-shell capture replays middleware - #734

Merged
ivogt merged 3 commits into
mainfrom
fix/prerender-shop-shell-hit
Jul 8, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/prerender-shop-shell-hit

Conversation

@ivogt

@ivogt ivogt commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

A Prerender() route that also declares ppr gets its shell baked at vite build (producer B) and served x-rango-shell: HIT from the first request after deploy. But that build capture ran no middleware — so a route whose middleware seeds a variable the shell renders, or that must decide per-request whether the URL is even shell-eligible, could not be captured faithfully. And at runtime there was no way for a request to opt off the shared-shell axis: once a shell existed, every visitor got it, even when middleware meant to force per-request rendering.

Concretely: a consumer writes middleware(authGate, () => [ path("/pp/:slug", Article, { ppr }) ]). If authGate needs some slugs rendered fresh per request, they had no lever — the shell was served regardless. And build-time capture couldn't be told "skip baking this URL".

Solution — ctx.dynamic() + ctx.build

ctx.dynamic() opts the current request off the PPR shell axis. ctx.build marks the build-time capture pass. Both are available on the request, middleware, and handler contexts.

Before vs. after

// BEFORE — no opt-out; a warmed shell is served to everyone.
export const gate: Middleware = async (ctx, next) => next();

// AFTER — force fresh rendering for signed-in users, shell for anonymous.
export const gate: Middleware = async (ctx, next) => {
  if (ctx.get(User)) ctx.dynamic(); // runs BEFORE the shell HIT commit -> axis 1
  return next();
};

End-to-end consumer example

// urls.tsx
const gate: Middleware = async (ctx, next) => {
  // Build-time shell capture replays middleware with ctx.build === true.
  // Skip baking a shell for URLs we never want frozen; they keep runtime capture.
  if (ctx.build && ctx.params.slug === "live") ctx.dynamic();
  return next();
};

export const patterns = urls(({ path, middleware }) => [
  middleware(gate, () => [
    path("/article/:slug", Article, { name: "article", ppr: { ttl: 300 } }),
  ]),
]);

// Article.test.ts — now unit-testable through the public testing primitives
import { runMiddleware } from "@rangojs/router/testing";

const r = await runMiddleware(gate, { request: "/article/live", build: true });
expect(r.ctx.build).toBe(true);
expect(r.dynamic).toBe(true); // the opt-out is observable

Semantics

Caller / phase ctx.dynamic() effect
Runtime middleware (before commit) Forces axis 1 — shell lookup/HIT/MISS-capture all skipped, even when a valid shell exists
Runtime handler (after commit) Only suppresses scheduling the follow-up capture on a MISS; a committed HIT is unaffected
Build-shell middleware (ctx.build) URL is skipped (outcome dynamic); it keeps runtime capture, no shell baked
Prerender-collect / static render Inert no-op (no live shell decision to influence)
B-segment serving Unchanged — a Prerender() route's build-baked segments still replay; dynamic() gates the shell axis only

ctx.build: true only during producer-B shell capture (and prerender/static contexts). ctx.waitUntil() is inert when ctx.build is true, so replayed middleware fires no background work.

Code-change summary

  • src/prerender/build-shell-capture.ts — build capture now previewMatches, then replays global (matchMiddleware) + route (buildRouteMiddlewareEntries) middleware through executeMiddleware before the capture. A {outcome, response} envelope adapts response-based middleware (which may short-circuit with a real Response) to the capture's outcome union; new "dynamic" outcome. The two vite/discovery callers already treat non-stored as "keep runtime capture".
  • src/server/request-context.ts — build, dynamic(), _dynamic (@internal) on RequestContext; waitUntil() early-returns when build.
  • src/router/{middleware-types,middleware}.ts, src/types/handler-context.ts, src/router/handler-context.ts, src/router/prerender-match.ts — build/dynamic() wired onto every context (real delegation on live/handler contexts, inert stub on prerender/static).
  • src/rsc/rsc-rendering.ts — !reqCtx._dynamic guards the shell serve gate and the MISS capture-schedule.
  • src/testing/{run-middleware,render-handler,internal/context}.ts — build option + dynamic result field on the public primitives.

Test plan

  • typecheck (router + test-app)
  • test:unit:all — router unit (5185) + RSC (69) + all consumer dogfood suites, exit 0
  • lint (oxlint --deny-warnings, 0/0) · format (oxfmt) · check:docs-api · check:e2e-bucketing
  • Unit — build-shell-capture.test.ts: route-mismatch, build-load-failure passthrough, middleware dynamic() short-circuit, middleware→stored, in-render dynamic() discard. request-scope.test.ts: build marker + inert waitUntil + dynamic() delegation on all three contexts. rsc-rendering-shell-ppr.test.ts: dynamic() during axis-1 MISS suppresses capture; before-commit bypass of a warm shell; HIT-tail dynamic() stays HIT.
  • Userland — run-middleware.test.ts (build gating + dynamic result + inert waitUntil) and render-handler.rsc-test.tsx (HandlerContext.build/dynamic) through the public @rangojs/router/testing primitives.
  • e2e (dev + production) — prerender-ppr.test.ts: build ctx.dynamic() skips the baked shell (MISS then runtime HIT); runtime ctx.dynamic() keeps the route off the shell axis. Both run in the (dev) and (production) describes via runPrerenderPprSpec.

Notes

  • dynamic() deliberately scopes to the PPR shell only; a Prerender() route's B-segments still replay at runtime (documented on the JSDoc, execution-model.md, and feature-map.md).
  • Behavior shift for consumers: global middleware now runs at build-shell-capture time. Side-effectful global middleware should guard with if (!ctx.build) (or rely on the inert waitUntil); documented in skills/middleware/SKILL.md.
  • Deferred (low, contracts already unit-covered): an e2e that warms a shell then bypasses it via a query-gated dynamic() (the pre-commit warm-shell bypass is pinned at unit level in rsc-rendering-shell-ppr.test.ts).

Review method: 6-angle adversarial review (correctness, PPR semantics, type/API hygiene, test coverage, simplification, docs) + dedicated simplify pass; 13 findings addressed, 1 refuted.

…ld-shell capture replays middleware

Adds a request-level PPR opt-out (ctx.dynamic()) and a ctx.build marker across
the request/middleware/handler contexts, and makes build-time PPR shell capture
(producer B) replay global + route middleware before capturing.

Why: a Prerender+ppr route's build shell capture previously ran NO middleware, so
a route whose middleware seeds vars or must force per-request rendering could not
be captured faithfully, and there was no way for a live request to opt off the
shared-shell axis. Now:

- ctx.dynamic() opts a request off the PPR SHELL axis. From middleware it runs
  before the shell HIT commit (forces axis 1 even when a valid shell exists);
  from a handler it runs after the commit (suppresses the follow-up capture on a
  MISS). It gates the shell only -- a Prerender() route's build-baked B-segments
  still replay at runtime.
- ctx.build is true during build-shell capture; ctx.waitUntil() is inert then, so
  replayed middleware fires no background work.
- Build capture replays middleware under a synthetic build context; middleware can
  seed vars or call ctx.dynamic() to skip baking a URL (outcome "dynamic", the URL
  keeps runtime capture).

Testing primitives extended: runMiddleware/renderHandler take a `build` option and
return a `dynamic` field, so the feature is unit-testable through the public
@rangojs/router/testing surface.
@pkg-pr-new

pkg-pr-new Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@734

commit: b390b1e

@ivogt
ivogt marked this pull request as ready for review July 8, 2026 16:50
ivogt added 2 commits July 8, 2026 19:50
…y (recapture upgrade), refreshes only cache()-baked data; ctx.dynamic() is shell-only [skip ci]
@ivogt
ivogt merged commit f010f6a into main Jul 8, 2026
@ivogt
ivogt deleted the fix/prerender-shop-shell-hit branch July 8, 2026 17:25
ivogt added a commit that referenced this pull request Jul 8, 2026
… the header doctrine (#735) (#736)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant