Repository navigation
Description
Problem (consumer-observable)
The shell key is host + pathname + sorted search only (buildShellKey, src/rsc/shell-serve.ts), and PartialPrerenderProps has no key option. Any variant axis that does not live in the URL — locale cookie, Accept-Language, currency, tenant-on-one-host, A/B bucket — shares one shell entry per URL. The capture bakes whichever variant the capturing request had and every other variant is served it: with the capture data snapshot (#647), consistently rendered but wrong-variant content; the only current mitigation is the capture cookie guard (fail-safe refusal), which does not cover header-driven variants.
Tags are half-covered today: ppr.tags is a static string[], and the capture auto-collects the shell content's non-loader request tags into the entry (so revalidateTag("product:123") already drops shells that rendered that product). What is missing is the dynamic form — per-variant/per-tenant operational tags — and without variant keys there is nothing to evict per-variant anyway. The two gaps are one feature.
Proposed API
path("/products/:id", ProductPage, {
name: "product",
ppr: {
ttl: 300,
key: (ctx) => ctx.locale, // variant discriminator -> separate shell per variant
tags: (ctx) => [`locale:${ctx.locale}`], // fn form, evaluated at capture/putShell time
},
});Companion (same-PR candidate): the ppr() group combinator — explicit group opt-in inside the urls() pattern tree, same idiom as cache()/middleware():
ppr({ ttl: 300, key: (ctx) => ctx.locale }, () => [
path("/", Home, { name: "home" }),
path("/live", Live, { name: "live", ppr: false }), // opt-out wins
]);Resolution: nearest declaration wins — route prop > enclosing ppr() scope. No router-level default tier (explicitly rejected: a default hides the risk of unintentionally caching documents; PPR stays a visible declaration).
Semantics
| Case | Behavior |
|---|---|
key returns "fr" / "en" |
Two independent shell entries + snapshots per URL; each HIT is parity-correct for its variant |
key at capture vs serve |
Same request state (the capture derives its context from the triggering request), so capture stores under the exact key the serve looked up |
tags fn |
Evaluated on the capture's derived context at putShell; UNIONED with the auto-collected content tags (existing behavior preserved) |
revalidateTag("locale:fr") |
Drops only the fr-variant shells |
Route without ppr inside ppr() scope |
Inherits the scope policy |
ppr: false inside a scope |
Opts out; route stays axis 1 |
High-cardinality key (user id) |
Anti-pattern: per-user shells, no sharing. Dev-mode distinct-keys-per-route counter warns past a threshold |
Implementation plan (high level)
PartialPrerenderProps(src/urls/pattern-types.ts): addkey?: (ctx) => string; widentagstostring[] | ((ctx) => string[]).resolvePprConfig/buildShellKey(src/rsc/shell-serve.ts): carry the fns; mix the encoded, length-capped discriminator into the key ahead of the:shellsuffix.- Serve path (
src/rsc/rsc-rendering.ts): evaluatekey(ctx)once at the commit point beforegetShell; thread the resolved key throughShellCaptureDescriptorso capture and serve agree by construction. - Capture (
src/rsc/shell-capture.ts): evaluate thetagsfn on the derived context atputShell; union with collected tags (no change to the auto-collection). ppr()combinator in the urls DSL: scope-levelPartialPrerenderPropsapplied to descendant PAGE routes, nearest-wins resolution. Doc-note: unlikecache()it is NOT segment-granular — it marks the page routes within (PPR is a document-level property;cache({ ppr })died on exactly this granularity mismatch).- Dev cardinality guard: per-route distinct-key counter, warn once past ~50.
- Docs: design-doc section,
/pprskill (variants + cardinality footgun), feature-map/feature-file-map.
Test plan
- Unit: key resolution + encoding, tags fn evaluated on the derived capture context, combinator nearest-wins resolution (inherit / override /
ppr: false), cardinality warning. - E2e, dev + production, both apps (test-app + cloudflare-basic): locale-cookie fixture — two cookies produce two shells, each HIT parity-correct with zero hydration errors; per-variant
revalidateTagevicts only its variant; a combinator-grouped fixture with an opted-out route.
Notes / out of scope
- Per-variant snapshots come free (the snapshot rides inside each shell entry).
- Host is already in the key — multi-tenant-by-host is covered today; this issue is same-host variants.
- Router-level default-on PPR is out of scope and rejected (unintentional-caching risk).
- Related follow-ups tracked separately: CF shell-family L1 tier, build-time capture (prerender integration), capture-backoff CI-flake hardening.
Activity
ivogt commentedon Jul 4, 2026
API settled (2026-07-04). Supersedes the proposal above — the scope is much smaller:
Why the original proposal is rejected — the composition doctrine. PPR is not like
cache()/"use cache": those are execution-preventing (a hit means the wrapped work does not run), PPR is execution-preserving — it is purely a performance shortcut for document bytes, and everything still runs underneath (middleware, holes, the worker handles every request). That is why the two layers compose and cannot substitute for each other — and why PPR's invalidation is DERIVATIVE: the shell is invalidated by the tags of whatever rendered into it, rather than PPR growing first-class key/tag APIs of its own.NO new ppr API. The shell key stays URL-only (host + pathname + sorted search,
buildShellKeyas shipped; internal validity gates — reactVersion resume gate, router version — stay internal). The variantkeyfn, thetagsfn form, and theppr()group combinator are all REJECTED. PPR remains a per-routeppr: { ttl, swr, tags }declaration exactly as it is today. Same-host non-URL variants stay unsupported by design — if it must vary, put it in the URL or let it run underneath (hole/middleware); the capture cookie guard remains the safety net.The one change — tags ride the existing instrument end-to-end:
cacheTag()becomes render-callable. With no"use cache"scope active but a request context present, it records onto the request's_requestTagsinstead of throwing (cache-tag.ts:56-60today). The collection layers already exist:_requestTagsinto the entry →cacheTag("campaign:spring")in a server component that renders into the shell meansrevalidateTag("campaign:spring")drops the shell — with zerocache()/"use cache"in the tree. This is the gap the issue now exists to close.loading()boundary can tag the shell). No filtering logic.document-cache.ts), so the invariant extends uniformly to every document-level artifact; prerender build contexts already seed their own_requestTags."use cache"nothing changes (scope wins; entry tags already propagate on read). Inside acache()DSL segment,cacheTag()records at the DOCUMENT level, not the segment (only the"use cache"runtime enters the tag scope) — documented semantic. Staticppr.tagsunchanged.Stability bar (caching-integration-critical): unit coverage for the
cacheTag()fallback semantics (scope wins / request-recording / no-context throw), capture-integration coverage (a render-recorded tag evicts the shell; a tag under a hole does not), document-cache tag coverage, userland coverage through the public testing primitives, and dev + production e2e in cloudflare-basic (component-tagged shell evicted byrevalidateTag, hole non-eviction), before merge.[-]PPR variants: ppr.key(ctx) discriminator, dynamic tags fn, and the ppr() group combinator[/-][+]PPR variants: ppr.key(ctx) discriminator + render-callable cacheTag() (API settled)[/+][-]PPR variants: ppr.key(ctx) discriminator + render-callable cacheTag() (API settled)[/-][+]PPR shell tags via render-callable cacheTag() (API settled: no variant keys — the URL is the key)[/+]feat(cache): render-callable cacheTag() — PPR shell/document tags rid…
fix(caching/ppr): audit remediation — redirect guard, host key, base6…