Skip to content

PPR shell tags via render-callable cacheTag() (API settled: no variant keys — the URL is the key) #648

Closed

Description

@ivogt

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)

  1. PartialPrerenderProps (src/urls/pattern-types.ts): add key?: (ctx) => string; widen tags to string[] | ((ctx) => string[]).
  2. resolvePprConfig / buildShellKey (src/rsc/shell-serve.ts): carry the fns; mix the encoded, length-capped discriminator into the key ahead of the :shell suffix.
  3. Serve path (src/rsc/rsc-rendering.ts): evaluate key(ctx) once at the commit point before getShell; thread the resolved key through ShellCaptureDescriptor so capture and serve agree by construction.
  4. Capture (src/rsc/shell-capture.ts): evaluate the tags fn on the derived context at putShell; union with collected tags (no change to the auto-collection).
  5. ppr() combinator in the urls DSL: scope-level PartialPrerenderProps applied to descendant PAGE routes, nearest-wins resolution. Doc-note: unlike cache() 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).
  6. Dev cardinality guard: per-route distinct-key counter, warn once past ~50.
  7. Docs: design-doc section, /ppr skill (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 revalidateTag evicts 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

  1. ivogt commented on Jul 4, 2026

    @ivogt
    CollaboratorAuthor

    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, buildShellKey as shipped; internal validity gates — reactVersion resume gate, router version — stay internal). The variant key fn, the tags fn form, and the ppr() group combinator are all REJECTED. PPR remains a per-route ppr: { 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 _requestTags instead of throwing (cache-tag.ts:56-60 today). The collection layers already exist:

    • Shell capture auto-collects _requestTags into the entry → cacheTag("campaign:spring") in a server component that renders into the shell means revalidateTag("campaign:spring") drops the shell — with zero cache()/"use cache" in the tree. This is the gap the issue now exists to close.
    • The shell-expiry invariant holds by construction: baked ⇒ evicts (bake-lane loaders execute during capture; their reads/tags record), hole ⇒ fresh (masked loaders never execute, so nothing under a renderable loading() boundary can tag the shell). No filtering logic.
    • The document cache reads the same set (document-cache.ts), so the invariant extends uniformly to every document-level artifact; prerender build contexts already seed their own _requestTags.
    • Inside "use cache" nothing changes (scope wins; entry tags already propagate on read). Inside a cache() DSL segment, cacheTag() records at the DOCUMENT level, not the segment (only the "use cache" runtime enters the tag scope) — documented semantic. Static ppr.tags unchanged.

    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 by revalidateTag, hole non-eviction), before merge.

  2. changed the title [-]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)[/+] on Jul 4, 2026
  3. changed the title [-]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)[/+] on Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions