Repository navigation
feat(router): ctx.dynamic() PPR shell opt-out + ctx.build; build-shell capture replays middleware - #734
Merged
Conversation
…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.
commit: |
ivogt
marked this pull request as ready for review
July 8, 2026 16:50
…y (recapture upgrade), refreshes only cache()-baked data; ctx.dynamic() is shell-only [skip ci]
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.
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.
Problem
A
Prerender()route that also declarespprgets its shell baked atvite build(producer B) and servedx-rango-shell: HITfrom 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 }) ]). IfauthGateneeds 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.buildctx.dynamic()opts the current request off the PPR shell axis.ctx.buildmarks the build-time capture pass. Both are available on the request, middleware, and handler contexts.Before vs. after
End-to-end consumer example
Semantics
ctx.dynamic()effectctx.build)dynamic); it keeps runtime capture, no shell bakedPrerender()route's build-baked segments still replay;dynamic()gates the shell axis onlyctx.build:trueonly during producer-B shell capture (and prerender/static contexts).ctx.waitUntil()is inert whenctx.buildis true, so replayed middleware fires no background work.Code-change summary
src/prerender/build-shell-capture.ts— build capture nowpreviewMatches, then replays global (matchMiddleware) + route (buildRouteMiddlewareEntries) middleware throughexecuteMiddlewarebefore the capture. A{outcome, response}envelope adapts response-based middleware (which may short-circuit with a realResponse) to the capture's outcome union; new"dynamic"outcome. The twovite/discoverycallers already treat non-storedas "keep runtime capture".src/server/request-context.ts—build,dynamic(),_dynamic(@internal) onRequestContext;waitUntil()early-returns whenbuild.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._dynamicguards the shell serve gate and the MISS capture-schedule.src/testing/{run-middleware,render-handler,internal/context}.ts—buildoption +dynamicresult field on the public primitives.Test plan
typecheck(router + test-app)test:unit:all— router unit (5185) + RSC (69) + all consumer dogfood suites, exit 0lint(oxlint--deny-warnings, 0/0) ·format(oxfmt) ·check:docs-api·check:e2e-bucketingbuild-shell-capture.test.ts: route-mismatch, build-load-failure passthrough, middlewaredynamic()short-circuit, middleware→stored, in-renderdynamic()discard.request-scope.test.ts: build marker + inertwaitUntil+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-taildynamic()stays HIT.run-middleware.test.ts(buildgating +dynamicresult + inertwaitUntil) andrender-handler.rsc-test.tsx(HandlerContext.build/dynamic) through the public@rangojs/router/testingprimitives.prerender-ppr.test.ts: buildctx.dynamic()skips the baked shell (MISS then runtime HIT); runtimectx.dynamic()keeps the route off the shell axis. Both run in the(dev)and(production)describes viarunPrerenderPprSpec.Notes
dynamic()deliberately scopes to the PPR shell only; aPrerender()route's B-segments still replay at runtime (documented on the JSDoc,execution-model.md, andfeature-map.md).if (!ctx.build)(or rely on the inertwaitUntil); documented inskills/middleware/SKILL.md.dynamic()(the pre-commit warm-shell bypass is pinned at unit level inrsc-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.