Repository navigation
feat(router): PPR opt-in via the ppr path option, integral serving, render-defined holes - #645
Merged
Merged
Conversation
commit: |
…ender-defined holes
Rework PPR shell caching (Axis 2) to its final design:
Opt-in: `path(pattern, Handler, { name, ppr: true | PartialPrerenderProps })`.
PPR is a DOCUMENT-level property of the page route; `PartialPrerenderProps
{ ttl?, swr?, tags? }` (default ttl 300) is exported from the root. A route
without the option is pure axis 1 — no store read, no capture, no logs.
Integral serving: createShellCacheMiddleware and ShellCacheOptions are REMOVED
from the public surface (pre-release rule: remove, never deprecate). The serve
path lives at the top of the render pass executeRender wraps — the COMMIT POINT
is after the WHOLE middleware chain (global router.use() AND route DSL
middleware()), so any rejection/401 wins before a single shell byte, on MISS
and warmed HIT alike. On a HIT the composed response commits eagerly: the
stored prelude flushes first while match/Flight/resume run behind it inside
the response stream. The x-rango-shell-resumed marker handshake is gone (one
layer decides AND composes); x-rango-shell: HIT|MISS stays. New
rsc/shell-serve.ts owns config/key/store plumbing (resolvePprConfig,
host-scoped buildShellKey, reactVersion gate, once-per-key missing-store-family
warning). Store = the app-level createRouter({ cache }) store.
Mixed-chain capture, render-defined holes (the hole doctrine):
(a) STRUCTURAL — the segment subtree under loading(): loaders masked, the
boundary postpones, the fallback bakes as route structure;
(b) PHYSICS — any promise nested in handed-over data still pending at
capture under the consumer's own Suspense (handler props, handle
containers, loader-carried): real I/O cannot win the task-quantized
quiet window;
(c) SHELL — awaited handler data, TOP-LEVEL push(promise) (awaited pre-SSR,
baked), resolved promises, replayed cache()'d segments.
The replay-only bail seam in cache-lookup.ts is removed: capture renders like
a normal request wrt the segment cache (cached segments replay, uncached
handlers execute fresh — the cookies()/headers() capture guard is load-bearing
again). Middleware never re-runs during capture; the derived context inherits
the request's post-middleware state (scope fidelity by inheritance).
Handles contract ("nesting = liveness"): verified against shipped semantics —
resolvedHandleStream's shallow resolution already bakes top-level pushed
promises and passes containers through. New: gateFlightForCapture holdUntil
keeps the capture gate from freezing before the top-level handle await
completes (a real-latency pushed promise would otherwise lose the byte-quiet
race and root-suspend the prerender); holes never emit bytes, so the hold only
ever admits shell rows.
live() is removed entirely (src/server/live.ts, exports, tests, fixtures,
docs): the loader lane subsumes its guarantee — loader(() => Promise.resolve(x))
+ loading() is a hole for already-resolved data. allReady
(ssr.resolveStreaming) bypasses PPR: bots/SEO get one complete axis-1 document.
Fixtures/tests: both apps + mini rewired from middleware mounts to ppr props;
DSL-attachment fixtures/tests removed (attachment modes no longer exist); the
replay-only sentinel is replaced by the PHYSICS-hole and HANDLES-pair e2e; new
shell-secure e2e (401-zero-shell-bytes for global AND route-DSL middleware,
scope fidelity, middleware-run counter); semantic matrix [PPR1] rewritten to
commit-after-all-middleware + capture-never-reruns-the-chain, [PPR2] kept.
Docs: /ppr skill rewritten (no-middleware setup, hole doctrine table,
execution matrix, allReady note), design doc final model + grown dead-ideas
ledger (middleware-as-public-surface, cache({ppr}) coupling, replay-only,
live() arc), execution-model, feature maps.
ivogt
force-pushed
the
feat/ppr-cache-opt-in
branch
from
July 3, 2026 17:53
d12a55a to
c7f4f17
Compare
3 tasks done
Merged
5 of 6 tasks
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…discovery, doc guards (#660) * docs(router): make shipped AGENTS.md consumer-facing — fix positioning, drop repo-internal rules and dead links * docs(router): remove phantom live() from feature-map — superseded by render-defined holes in #645 * docs(skills): add "Use when" trigger phrasing to skill descriptions 37 of 43 skill frontmatter descriptions were bare topic labels that only match users who already speak the framework's vocabulary. Add "Use when" clauses with plain-language symptom/goal phrasing (e.g. "data is stale after changes", "how do I read the route param in a component") so an LLM agent can match a skill against how a user actually asks the question, not just its name. The cache cluster (caching/use-cache/document-cache/ cache-guide) each now name their distinguishing scope in the topic sentence. Body content, name/argument-hint fields, and the already- triggered skills (migrate-nextjs, migrate-react-router, bundle-analysis, comparison, css, react-compiler) are untouched. * chore(tools): add check-skills guard for skill frontmatter and cross-references Walks packages/rangojs-router/skills/*/ and enforces: SKILL.md exists, frontmatter name matches the directory and description is non-empty, backtick /name cross-references resolve to a skill directory (with a verified URL_EXAMPLES allowlist for route examples, subpath entries, and storage-key suffixes), and relative markdown links resolve on disk. * chore(tools): widen check-doc-links to ../../skills/ link targets The internal maps link to skills (e.g. feature-map.md -> ../../skills/view-transitions/SKILL.md) but the guard only matched ../../src/ targets, leaving those links unchecked. * chore(tools): wire check-skills into scripts, pre-commit, and CI lint Adds the check:skills script, lefthook pre-commit entries for the skills guard and check-docs-api (previously CI-only), and a CI lint-job step. * docs(skills): point-of-use "Not this skill if" boundaries for the confusable clusters * feat(skills): generated machine-readable catalog.json + README discovery section Adds tools/generate-skills-catalog.mjs, generating packages/rangojs-router/skills/catalog.json (name/description/argument-hint/ path for all 43 shipped skills) from SKILL.md frontmatter. --check mode verifies the catalog is current and cross-checks bidirectional completeness against skills/rango/SKILL.md's ## Skills tables. * docs(router): hub links for caching design + checklists, reviews banner, file shallow-navigation RFC under design/ * docs(router): add skills discovery section to README Consumers had no pointer to the 43 shipped agent skills or how to wire them into their harness. Adds a "Using the skills with your coding agent" section covering Claude Code (.claude/skills/) and the shared .agents/skills/ convention used by Cursor, Codex CLI, and Gemini CLI. * feat(skills): generated machine-readable catalog.json + README discovery section Wires the skills-catalog --check into the pre-commit hook (glob on packages/rangojs-router/skills/**) and the CI lint job, mirroring the existing check:doc-links / check:docs-api entries. * docs(router): tag execution-model guarantees with semantic-matrix row ids * docs(skills): split hooks into index + per-hook companions * docs(skills): split typesafety into index + topic companions * docs(skills): split migrate-react-router into index + step companions * chore: trigger CI (PR opened from draft)
7 tasks done
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
PPR shell caching (axis 2) was opt-in only at a middleware mount —
router.use(createShellCacheMiddleware({ ttlSeconds, ... })). Three consumer-visible problems: (1) no per-route control — any GET document under the mount became a shell candidate, and the freshness policy lived on the middleware, far from the route it governed; (2) a two-layer design — the middleware decided WHERE while the render layer composed, coupled through an internalx-rango-shell-resumedmarker handshake; (3) the promise-hole story was undefined: whether a handler-created pending promise became a hole or shell material had no stated contract a consumer could rely on.What a consumer writes differently
Opt-in is now a path option on the page route, and there is no middleware at all — serving is integral to the router:
PartialPrerenderProps { ttl?; swr?; tags? }is exported from the root.createShellCacheMiddleware/ShellCacheOptionsare removed (pre-release rule: remove, never deprecate). The shell store is the app-levelcreateRouter({ cache })store; a ppr route on a store without thegetShell/putShellfamily stays on axis 1 with a once-per-key warning. A route withoutppris pure axis 1 — zero cost, zero logs.What a consumer sees differently
Commit after ALL middleware. The serve path sits at the top of the render pass that
executeRenderwraps — after the globalrouter.use()chain AND route DSLmiddleware(). Any 401/redirect wins before a single shell byte, on MISS and on a warmed HIT. On a HIT the composed response commits eagerly: the stored prelude flushes first while segment resolution / the fresh Flight render / the fizzresumerun behind it inside the response stream.Render-defined holes (the hole doctrine). The background capture is mixed-chain —
cache()'d segments replay, uncached handlers execute fresh, middleware never re-runs (the derived context inherits the request's post-middleware state):loading()— loaders masked, the boundary postpones, the fallback bakes as route structure.<Suspense>— handler props, handle containers (push({ x: promise })), loader-carried. Real I/O cannot win the task-quantized quiet window.push(promise)(awaited pre-SSR, baked — the capture gate now HOLDS for that await), resolved promises, replayed cached segments.Unified rule: a promise nested inside your data is never baked; the container settles. Want a hole for already-resolved data?
loader(() => Promise.resolve(x))+loading()— which is whylive()is removed entirely.allReady bypasses PPR:
ssr.resolveStreamingreturning"allReady"(the SEO/bot story) serves one complete axis-1 document.End-to-end example
Auth-guarded PPR page with middleware-derived shell content:
Semantics table
pprpproptionloading()push(promise)push({ x: promise })loading()allReady/ nonce / non-GET / RSC-partial-actionCode-change summary
urls/pattern-types.ts+urls/path-helper.ts+server/context.ts—PartialPrerenderProps, thepprPathOption, stored on the route EntryData; exported from both root indexes.rsc/shell-serve.ts(new) —resolvePprConfig(default ttl 300), host-scopedbuildShellKey, reactVersion gate,hasShellFamily+ once-per-key warning.rsc/rsc-rendering.ts— the commit point: ppr config read off the classified route snapshot (available pre-match, enabling the eager HIT flush),serveShellHit(prelude-first composed stream; tail kicked off synchronously inside the ALS frame), MISS tagging + capture scheduling. The resume-marker branch andmaybeScheduleShellCapturepolicy layer are gone.cache/shell-cache.tsdeleted; exports removed fromcache/index.ts._shellResume/_shellCapturerequest-context flags removed (ShellCaptureDescriptoris passed by value);_shellCaptureRunstays.rsc/shell-capture.ts— replay-only bail removed (mixed-chain capture);gateFlightForCapturegainsholdUntil(held forgetData().then(resolveDeferredHandleValues)) so top-level pushed handle promises bake deterministically; retry/backoff/stampede/quiesce machinery unchanged.router/match-middleware/cache-lookup.ts— reverted to main (bail seam removed).src/server/live.ts+ tests + all fixture usage removed.POST_QUIESCE_TASK_HOPS16 — the replay Flight emits in one tick, fizz needs post-quiesce turns), retry + exponential backoff, host-scoped keys, reactVersion gate, SWR recapture, tags auto-collection.Handle-semantics verification finding: the required contract already matched shipped code —
resolvedHandleStream(handles/deferred-resolution.ts) awaits only top-level thenables (shallowisThenable) and passes containers through verbatim. The one gap was the capture gate freezing before a real-latency top-level pushed promise resolved;holdUntilcloses it (holes emit no bytes, so holding only admits shell rows; bounded by the 5s guard).Test plan
ppr:false, nonce/allReady bypasses, missing-store-family warn-once, non-200 no-schedule); capture gateholdUntil; shell store family; capture retry/backoff suite (bail test removed).pnpm run test:unit:all(root): router 296 files / 4774+ tests, mini, cloudflare, vite-rsc-demo — all green (+ RSC suites).[PPR1](commit-after-all-middleware + capture-never-reruns-chain + scope fidelity) and kept[PPR2](serve-time guarding); production PPR rows pass.execution-model.mdupdated in the same PR.pnpm build-router, repo-root typecheck (15/15), lint, format,check:e2e-bucketing,check:docs-api.Notes / call-outs
createShellCacheMiddleware,ShellCacheOptions,live()— removed, not deprecated (pre-release rule). Migration: replace mounts with thepprpath option; replacelive()with a loader behindloading().location.replace.onResponsecallbacks at commit (before match), so a HIT serve does not write the segment cache. Correctness is unaffected (the foreground MISS/axis-1 path writes it); noted as a perf nuance.ppr(declaring it on a layout) is deliberately out of scope — noted follow-up.cache({ ppr })coupling (segment-granular primitive for a document property), replay-only capture (deleted the promise-hole contract), thelive()story, response-header opt-in, standaloneshellCache(), automatic no-key capture, full-pipeline capture dispatch.