Skip to content

feat(cache): render-callable cacheTag() — PPR shell/document tags ride the existing instrument (#648) - #678

Merged
ivogt merged 2 commits into
mainfrom
cache/render-callable-cachetag
Jul 4, 2026
Merged

ivogt merged 2 commits into
mainfrom
cache/render-callable-cachetag

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #648.

Problem statement

A PPR shell (and the document cache) is invalidated by the tags of whatever rendered into it — but the only producers of those tags were the cache() DSL and the "use cache" runtime. A server component that renders into a shell with no cached data anywhere in its tree had NO way to make revalidateTag() evict that shell: cacheTag() threw outside a "use cache" function. Consumers could declare static ppr.tags on the route, but nothing dynamic could be recorded from the content itself.

Settled API (#648 discussion): no new ppr API. No ppr.key(ctx), no tags fn, no ppr() combinator — the shell key stays URL-only. PPR is execution-preserving (everything still runs underneath; only document bytes are shortcut), unlike the execution-preventing cache()/"use cache" — the layers compose, and PPR's invalidation stays derivative. The one change: cacheTag() becomes render-callable, so tags ride the existing instrument end-to-end.

Before vs. after

// a server component that renders INTO the /ppr-blog shell
function CampaignBanner() {
  cacheTag("campaign:spring");
  return <aside>Spring sale</aside>;
}
Before After
cacheTag() in a server component (no "use cache" in tree) throws cacheTag() must be called inside a "use cache" function. records onto the request's document artifact (_requestTags)
revalidateTag("campaign:spring") after the shell captured no effect (tag never stored) drops the shell → next request recaptures
cacheTag() inside "use cache" tags the cache entry unchanged (scope wins)
cacheTag() inside a cache() DSL segment throws records at the DOCUMENT level, not the segment (only the "use cache" runtime enters the tag scope) — documented semantic
cacheTag() under a renderable loading() hole, during capture n/a (threw) cannot happen — masked loaders never execute during capture, so holes cannot tag the shell (baked ⇒ evicts, hole ⇒ fresh, by construction)
cacheTag() with neither scope nor request throws still throws (message updated: ... or during a request render.)
Document cache entry tags cache()/"use cache" propagated tags only render-recorded tags included (same collection point, document-cache.ts)

End-to-end consumer example

// urls.tsx — PPR declaration unchanged, static tags still work
path("/blog", BlogPage, { name: "blog", ppr: { ttl: 300, tags: ["blog"] } });

// blog-layout.tsx — dynamic, content-derived tag with zero cache() in the tree
export async function BlogLayout({ children }) {
  const campaign = getActiveCampaignSync();
  cacheTag(`campaign:${campaign.id}`);   // bakes into the shell entry's tags
  return <div className={campaign.theme}>{children}</div>;
}

// admin action — existing invalidation instrument, now reaches shells
export async function endCampaign(id: string) {
  "use server";
  await db.campaigns.end(id);
  revalidateTag(`campaign:${id}`);       // drops every shell that baked this campaign
}

Code-change summary

One production file: src/cache/cache-tag.ts (+74/−22 incl. JSDoc). cacheTag() gains a fallback branch — "use cache" scope wins (byte-identical behavior); else an active request context records via the existing recordRequestTags; else the (reworded) throw. The collection layers needed zero changes: shell capture (shell-capture.ts — untouched), document cache, and prerender contexts already read _requestTags. Everything else is tests, fixtures, and docs (13 files, +723/−22 total).

Test plan

  • Unit, red-before-green (11 of 24 tag tests fail without the fallback): extended cache-tag.test.ts + new dedicated matrix suite cache-tag-render.test.ts — normalization parity across both forms (trim/empty/dedupe/accumulate), scope↔request interleaving with no cross-leak, the cache()-segment document-level semantic pinned explicitly, capture-derived vs foreground context isolation, updateTag eviction round-trips for shell AND document entries, build-shaped prerender context, verbatim throw-message contract.
  • Capture integration (shell-capture.test.ts): render-recorded tag unioned into the stored ShellCacheEntry with static ppr.tags (deduped); hole/bake invariant pinned via the bake-lane contrast (executed bake-lane tag collected, never-executed hole tag absent); store-level eviction round-trip.
  • Document cache (document-cache.test.ts): render-recorded tag lands on the document entry.
  • Userland (testing/__tests__/cache-tag-render-callable.test.ts): through the public runInRequestContext/createTestRequestContext primitives — no primitive extension needed; production createRequestContext already seeds _requestTags (request-context.ts:967).
  • e2e, dev + production (tests/cloudflare-basic/e2e/ppr-tag-eviction.test.ts): prime /ppr-blog → shell HIT → revalidateTag via a server action → MISS/recapture → HIT again; zero hydration errors on tagged-shell HITs. Hermetic via a unique per-run probe token (a fixed tag collided with shells persisted in miniflare KV across runs — caught with --repeat-each). Existing 19-test ppr-shell suite unaffected (the fixture tag in BlogLayout is probe-gated behind a search param).
  • Gates: typecheck 15/15, test:unit:all clean, lint 0/0, format clean, check:e2e-bucketing OK, check:docs-api 58 identifiers verified, zero .gen.ts churn (no routes added).

Docs

use-cache-api-design.md (both forms + document-level semantics), design/ppr-shell-resume.md (derivative-invalidation paragraph + the composition doctrine + the known edge below), internal/feature-map.md + feature-file-map.md, skills/migrate-nextjs/SKILL.md (stale "use cache"-only restriction removed).

Notes

  • Known edge, filed as PPR capture: tags recorded after an await in async shell content miss the shell tag snapshot #676: a tag recorded only AFTER an await in an async shell component lands after the shell's synchronous tag snapshot and is not collected (pre-existing window, shared with async cache() reads at capture; the document cache is immune — it buffers the body before collecting). Fix direction (render-complete barrier for the tag snapshot) documented in the design doc; deliberately out of this PR's scope — an attempted inline fix was A/B-disproved for the sync path and reverted, keeping shell-capture.ts at zero diff.
  • Rejected in the API discussion, recorded on PPR shell tags via render-callable cacheTag() (API settled: no variant keys — the URL is the key) #648: ppr.key(ctx) variant discriminators, a tags fn form, the ppr() group combinator, router-level defaults. Same-host non-URL variants stay unsupported by design; the capture cookie guard remains the safety net.
  • Segment-level runtime tags for cache() DSL segments (nearest-artifact semantics) are a possible future refinement; the document-level fallback is the documented contract until then.

ivogt added 2 commits July 5, 2026 00:31
…st document artifact (#648)

With no "use cache" tag scope active but a request context present, cacheTag()
now records onto the request's _requestTags (the document artifact) instead of
throwing. The PPR shell capture and the document cache already collect that set,
so a plain server component can call cacheTag("campaign:spring") — with zero
cache()/"use cache" in its tree — and revalidateTag("campaign:spring") drops the
shell/document it rendered into. This is PPR's DERIVATIVE invalidation: PPR is
execution-PRESERVING, so its tags ride the existing instrument rather than a
first-class ppr key/tag API. Inside a cache() DSL segment cacheTag() records at
the document level (only the "use cache" runtime enters the tag scope). No new
ppr API; no change to shell capture/serve.

Coverage: cache-tag unit suite (scope-wins / request-recording / no-context
throw), a dedicated cache-tag-render suite (normalization parity across both
forms, scope/request interleaving, cache()-segment document-level semantic,
capture/foreground isolation, build-context, updateTag eviction round-trips for
shell + document families), shell-capture capture-integration (render-recorded
tag unions into the entry, hole-by-construction, dedupe, store eviction
round-trip), a document-cache render-recorded-tag case, and a userland test
through the public @rangojs/router/testing primitives (_requestTags is already
seeded by production createRequestContext, so no primitive change was needed).
…648)

cloudflare-basic: BlogLayout (a baked SERVER component in the /ppr-blog shell —
all PprShell* components are client) calls the render-callable cacheTag(),
probe-gated with the tag derived from a UNIQUE per-run probe token, so each test
owns a hermetically fresh shell key + tag (a fixed probe collides with persisted
miniflare KV across runs and flakes the first-GET-is-MISS assertion). New
ppr-tag-eviction.test.ts (dev + production, serial like cache-tag.test.ts) primes
the shell to a HIT, invalidates the component's tag via the existing
/test/invalidate-tag/:tag route, asserts the shell recaptures as a MISS then HITs
again, an unrelated tag leaves a sibling shell untouched (isolation), and the HIT
hydrates with zero hydration errors. No routes added (reuses /ppr-blog and the
invalidate-tag fixture) — *.gen.ts clean.

Docs: use-cache-api-design.md (render-callable form + document-level semantic),
ppr-shell-resume.md (derivative invalidation, composition doctrine, baked/hole
invariant, synchronous-collection timing + async-after-await known edge),
feature-map.md + feature-file-map.md cacheTag rows, migrate-nextjs skill.
@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 00dafe8

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.

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

1 participant