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
PPR shells are runtime-captured today: the first visitor per URL (per variant, once #648 lands) always pays a full axis-1 MISS, and cold starts after TTL expiry re-pay it. The repo doctrine is "prerender = build-time cache" (docs/prerender-api-design.md) — PPR should compose with it.
Direction
Run the existing capture pipeline at build time for routes in the prerender set ∩ ppr-declared routes, and putShell into the store so the very first request is a HIT. The capture data snapshot (#647) already guarantees a shell is self-consistent (prelude + snapshot + payload agree), which is what makes build-time capture safe: the built shell carries its own pinned data.
Composition principle: same capture machinery (task-quantized quiesce, hole doctrine, retry), different trigger. Node-tier determinism applies (build runs on Node, serve may be workerd — reactVersion gate already guards build/runtime coupling).
Open questions (design first — see docs/design/ppr-shell-resume.md follow-up section)
Param enumeration for dynamic routes (/products/:id) — explicit list, crawl, or handler-provided enumerate fn.
Host + variant keying at build (no live request: key(ctx) needs a synthetic context or per-variant enumeration).
Seeding transport: build writes directly to the production store (KV binding at build) vs emitting a seed artifact applied at deploy.
failOnUncapturable: whether a ppr route that refuses capture (no holes) fails the build or degrades with a report.
Plan
Design-doc section first (build-time capture / prerender-with-ppr) resolving the open questions.
prerender() config surface gaining ppr awareness.
Build hook that drives scheduleShellCapture's attempt machinery against enumerated URLs.
E2e: build an app with prerender+ppr, first request asserts x-rango-shell HIT, dev + production per the repo mandate.
Ppr(handlerThatRuns) handler wrapper (a Prerender-style sibling). Right instinct — no handler eviction, since the HIT tail / hole loaders / expiry recapture still need the handler at runtime — but it creates a second PPR declaration syntax for behavior that is indistinguishable at serve time. Build-time vs runtime capture is eagerness of the same cache write ("pre-rendering IS caching, just at build time"), not a different route semantic.
Extending Static() to carry shell-ness (layout(Static(...))). Category error: Static is segment-granular, the shell is a document property — the same granularity mismatch that killed cache({ ppr }). Static/Prerender instead COMPOSE with shell capture: they make shell content build-deterministic; the snapshot pins the rest.
Chosen direction: extend the ppr prop with enumeration — the one genuinely build-specific input:
One declaration site (route line reads "PPR'd, and seeded at build"); build hook lives beside Prerender's closeBundle walk; existing capture machinery runs against a synthetic request; putShell + snapshot into the runtime store; handler and serve path untouched.
Enumeration API refinement (supersedes the ppr.build field from the previous comment):
Use the DSL's reverse-form named-route language for the build manifest instead of per-route enumerate fns — a build() manifest of typed path(name, params) entries:
Typed end-to-end against GeneratedRouteMap (wrong/missing params = TS error; route rename breaks entries at typecheck), vs stringly { id } objects.
One visible build surface instead of fns scattered across route options.
Cross-route enumeration from one data source in one expression.
Generalizes: the same manifest vocabulary can later drive classic Prerender (B segments) too — one build-instance language for both artifacts.
Division of labor: the route's ppr prop declares BEHAVIOR; the build() manifest picks INSTANCES. A manifest entry naming a route without ppr is a build error (until/unless it grows a plain-Prerender meaning).
Rejected sibling reading: build.path(pattern, Handler, ...) as a marker variant of the route-defining path() — splits the declaration across combinator + prop (same critique that killed Ppr(handler)), and the path() overload surface is fragile (#642 scar).
Still open (unchanged): host + variant keying without a live request; build-store transport. Async manifest entries should be supported (data-driven enumeration needs awaits — same precedent as Prerender's getParams).
Enumeration direction superseded again, final form: the build() reverse-form manifest from the previous comment is dropped in favor of the build.* DSL namespace — see the full proposal in the new issue. Enumeration colocates on build.path(..., { params }) (today's getParams moved into the options bag), and a route that is both build.path'd and ppr'd gets its shells captured at build for the enumerated instances — the intersection principle expressed entirely in the tree. Eviction rule: ppr routes retain their handler (tail/holes/recapture need it); plain build.path keeps Prerender's eviction.
Activity
ivogt commentedon Jul 3, 2026
API design discussion outcome (2026-07-04):
Considered and rejected:
Ppr(handlerThatRuns)handler wrapper (aPrerender-style sibling). Right instinct — no handler eviction, since the HIT tail / hole loaders / expiry recapture still need the handler at runtime — but it creates a second PPR declaration syntax for behavior that is indistinguishable at serve time. Build-time vs runtime capture is eagerness of the same cache write ("pre-rendering IS caching, just at build time"), not a different route semantic.Static()to carry shell-ness (layout(Static(...))). Category error:Staticis segment-granular, the shell is a document property — the same granularity mismatch that killedcache({ ppr }).Static/Prerenderinstead COMPOSE with shell capture: they make shell content build-deterministic; the snapshot pins the rest.Chosen direction: extend the
pprprop with enumeration — the one genuinely build-specific input:One declaration site (route line reads "PPR'd, and seeded at build"); build hook lives beside Prerender's closeBundle walk; existing capture machinery runs against a synthetic request;
putShell+ snapshot into the runtime store; handler and serve path untouched.Open questions sharpened by this shape:
key(ctx)/tags(ctx)are request-derived — build has no request. Options: configured build host + enumerated variants, or hybrid (build warms the default variant, runtime capture fills the rest).ivogt commentedon Jul 3, 2026
Enumeration API refinement (supersedes the
ppr.buildfield from the previous comment):Use the DSL's reverse-form named-route language for the build manifest instead of per-route enumerate fns — a
build()manifest of typedpath(name, params)entries:Why this beats
ppr: { build: getParams }:{ id }objects.Division of labor: the route's
pprprop declares BEHAVIOR; thebuild()manifest picks INSTANCES. A manifest entry naming a route withoutppris a build error (until/unless it grows a plain-Prerender meaning).Rejected sibling reading:
build.path(pattern, Handler, ...)as a marker variant of the route-defining path() — splits the declaration across combinator + prop (same critique that killedPpr(handler)), and the path() overload surface is fragile (#642 scar).Still open (unchanged): host + variant keying without a live request; build-store transport. Async manifest entries should be supported (data-driven enumeration needs awaits — same precedent as Prerender's getParams).
ivogt commentedon Jul 3, 2026
Enumeration direction superseded again, final form: the
build()reverse-form manifest from the previous comment is dropped in favor of thebuild.*DSL namespace — see the full proposal in the new issue. Enumeration colocates onbuild.path(..., { params })(today's getParams moved into the options bag), and a route that is bothbuild.path'd andppr'd gets its shells captured at build for the enumerated instances — the intersection principle expressed entirely in the tree. Eviction rule: ppr routes retain their handler (tail/holes/recapture need it); plain build.path keeps Prerender's eviction.