Skip to content

feat(router): PPR opt-in via the ppr path option, integral serving, render-defined holes - #645

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-cache-opt-in
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-cache-opt-in

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 internal x-rango-shell-resumed marker 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:

// BEFORE — mount + middleware-held policy
router.use("/products", createShellCacheMiddleware({ ttlSeconds: 600, swrSeconds: 120 }));
export const patterns = urls(({ path, layout, loader, loading }) => [
  layout(ProductShell, () => [
    path("/products/:id", PricePage, { name: "product" }, () => [
      loader(LivePriceLoader),
      loading(<PriceSkeleton />),
    ]),
  ]),
]);

// AFTER — the `ppr` path option IS the opt-in AND the policy; nothing to mount
export const patterns = urls(({ path, layout, loader, loading }) => [
  layout(ProductShell, () => [
    path(
      "/products/:id",
      PricePage,
      { name: "product", ppr: { ttl: 600, swr: 120 } }, // or ppr: true (ttl 300)
      () => [loader(LivePriceLoader), loading(<PriceSkeleton />)],
    ),
  ]),
]);

PartialPrerenderProps { ttl?; swr?; tags? } is exported from the root. createShellCacheMiddleware/ShellCacheOptions are removed (pre-release rule: remove, never deprecate). The shell store is the app-level createRouter({ cache }) store; a ppr route on a store without the getShell/putShell family stays on axis 1 with a once-per-key warning. A route without ppr is 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 executeRender wraps — after the global router.use() chain AND route DSL middleware(). 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 fizz resume run 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):

  • STRUCTURAL: the segment subtree under loading() — loaders masked, the boundary postpones, the fallback bakes as route structure.
  • PHYSICS: any promise nested in handed-over data still pending at capture, under the consumer's own <Suspense> — handler props, handle containers (push({ x: promise })), loader-carried. Real I/O cannot win the task-quantized quiet window.
  • SHELL: awaited handler data, top-level 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 why live() is removed entirely.

allReady bypasses PPR: ssr.resolveStreaming returning "allReady" (the SEO/bot story) serves one complete axis-1 document.

End-to-end example

Auth-guarded PPR page with middleware-derived shell content:

// global guard — ANY middleware layer guards PPR for free (commit point is after all of them)
router.use("/account/*", async (ctx, next) => {
  const user = await verify(cookies().get("session")?.value);
  if (!user) return new Response("unauthorized", { status: 401 }); // zero shell bytes, even warmed
  ctx.set("orgName", user.orgName); // photographed into the shell by the capture (scope fidelity)
  return next();
});

export const patterns = urls(({ path, layout, loader, loading }) => [
  layout((ctx) => <AccountShell org={ctx.get("orgName")} />, () => [
    path("/account/usage", UsagePage, { name: "usage", ppr: { ttl: 300, swr: 60 } }, () => [
      loader(LiveUsageLoader),          // the hole — fresh every request
      loading(<UsageSkeleton />),
    ]),
  ]),
]);

Semantics table

Case Behavior
route declares ppr shell served/captured; ttl/swr/tags from the option (ttl default 300)
no ppr option pure axis 1 — no store read, no capture, no logs
middleware 401/redirect (global OR route DSL), warmed route rejection returned, zero shell bytes (commit point is after the whole chain)
loader behind loading() structural hole — masked at capture, fresh every serve
pending promise in handed-over data under consumer Suspense physics hole — fallback in prelude, value resumed
handler promise that resolves / awaited handler data shell — baked into the prelude
top-level push(promise) shell — awaited pre-SSR; capture gate holds for the await
nested push({ x: promise }) hole — streams into the consumer's Suspense
loader route without loading() eternal MISS + once-per-key warning (unchanged)
ppr route, store without shell family axis 1 + once-per-key warning
allReady / nonce / non-GET / RSC-partial-action axis 1 (unchanged)
background capture mixed-chain render, middleware NOT re-run (run counter exact), inherits post-middleware ctx

Code-change summary

  • urls/pattern-types.ts + urls/path-helper.ts + server/context.ts — PartialPrerenderProps, the ppr PathOption, stored on the route EntryData; exported from both root indexes.
  • rsc/shell-serve.ts (new) — resolvePprConfig (default ttl 300), host-scoped buildShellKey, 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 and maybeScheduleShellCapture policy layer are gone.
  • cache/shell-cache.ts deleted; exports removed from cache/index.ts. _shellResume/_shellCapture request-context flags removed (ShellCaptureDescriptor is passed by value); _shellCaptureRun stays.
  • rsc/shell-capture.ts — replay-only bail removed (mixed-chain capture); gateFlightForCapture gains holdUntil (held for getData().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.
  • Kept from the prior arc: shell store family, SSR capture/resume strategies, task-hop quiesce (POST_QUIESCE_TASK_HOPS 16 — 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 (shallow isThenable) and passes containers through verbatim. The one gap was the capture gate freezing before a real-latency top-level pushed promise resolved; holdUntil closes it (holes emit no bytes, so holding only admits shell rows; bounded by the 5s guard).

Test plan

  • Unit: integrated serve path against a REAL memory store (MISS policy/default-ttl/props flow-through, HIT prelude-first composition, stale/SWR recapture, reactVersion gate, host-scoped keys, no-ppr = zero store reads + zero logs, ppr:false, nonce/allReady bypasses, missing-store-family warn-once, non-200 no-schedule); capture gate holdUntil; 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).
  • E2e dev + production, both apps: test-app 18/18 dev + 18/18 prod (MISS→HIT, HIT streaming+TTFB, hydration-zero-errors, loader-carried 3-layer, physics hole, handles pair, no-loading() negative, security: 401 zero-shell-bytes for global AND route-DSL middleware, scope fidelity, middleware-run counter, action correctness incl. updateTag drop+recapture, PE POST); cloudflare 10/10 dev + 10/10 prod (incl. physics hole; DSL-attachment tests removed with the feature).
  • Semantic matrix: dev 20/20 incl. rewritten [PPR1] (commit-after-all-middleware + capture-never-reruns-chain + scope fidelity) and kept [PPR2] (serve-time guarding); production PPR rows pass. execution-model.md updated in the same PR.
  • pnpm build-router, repo-root typecheck (15/15), lint, format, check:e2e-bucketing, check:docs-api.

Notes / call-outs

  • Removed public APIs: createShellCacheMiddleware, ShellCacheOptions, live() — removed, not deprecated (pre-release rule). Migration: replace mounts with the ppr path option; replace live() with a loader behind loading().
  • HIT status commits at the flush: a failing hole cannot become a 500/redirect after the first shell byte — error UI renders inline (the standard streamed-SSR property). A near-unreachable redirecting match on a HIT degrades to a client-side location.replace.
  • Ring-3 writes on HIT serves: the composed HIT response drains onResponse callbacks 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.
  • Subtree inheritance of ppr (declaring it on a layout) is deliberately out of scope — noted follow-up.
  • Design doc grew a dead-ideas ledger covering the full arc: middleware-as-public-surface, cache({ ppr }) coupling (segment-granular primitive for a document property), replay-only capture (deleted the promise-hole contract), the live() story, response-header opt-in, standalone shellCache(), automatic no-key capture, full-pipeline capture dispatch.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: c7f4f17

…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
ivogt force-pushed the feat/ppr-cache-opt-in branch from d12a55a to c7f4f17 Compare July 3, 2026 17:53
@ivogt ivogt changed the title feat(router): PPR opt-in via cache({ ppr: true }) + replay-only capture feat(router): PPR opt-in via the ppr path option, integral serving, render-defined holes Jul 3, 2026
@ivogt
ivogt merged commit 18ea565 into main Jul 3, 2026
30 checks passed
@ivogt
ivogt deleted the feat/ppr-cache-opt-in branch July 3, 2026 21:02
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)
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