Repository navigation
feat(cache): render-callable cacheTag() — PPR shell/document tags ride the existing instrument (#648) - #678
Merged
Merged
Conversation
…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.
commit: |
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.
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 makerevalidateTag()evict that shell:cacheTag()threw outside a"use cache"function. Consumers could declare staticppr.tagson the route, but nothing dynamic could be recorded from the content itself.Settled API (#648 discussion): no new ppr API. No
ppr.key(ctx), notagsfn, noppr()combinator — the shell key stays URL-only. PPR is execution-preserving (everything still runs underneath; only document bytes are shortcut), unlike the execution-preventingcache()/"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
cacheTag()in a server component (no"use cache"in tree)cacheTag() must be called inside a "use cache" function._requestTags)revalidateTag("campaign:spring")after the shell capturedcacheTag()inside"use cache"cacheTag()inside acache()DSL segment"use cache"runtime enters the tag scope) — documented semanticcacheTag()under a renderableloading()hole, during capturecacheTag()with neither scope nor request... or during a request render.)cache()/"use cache"propagated tags onlydocument-cache.ts)End-to-end consumer example
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 existingrecordRequestTags; 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
cache-tag.test.ts+ new dedicated matrix suitecache-tag-render.test.ts— normalization parity across both forms (trim/empty/dedupe/accumulate), scope↔request interleaving with no cross-leak, thecache()-segment document-level semantic pinned explicitly, capture-derived vs foreground context isolation,updateTageviction round-trips for shell AND document entries, build-shaped prerender context, verbatim throw-message contract.shell-capture.test.ts): render-recorded tag unioned into the storedShellCacheEntrywith staticppr.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.test.ts): render-recorded tag lands on the document entry.testing/__tests__/cache-tag-render-callable.test.ts): through the publicrunInRequestContext/createTestRequestContextprimitives — no primitive extension needed; productioncreateRequestContextalready seeds_requestTags(request-context.ts:967).tests/cloudflare-basic/e2e/ppr-tag-eviction.test.ts): prime/ppr-blog→ shell HIT →revalidateTagvia 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 inBlogLayoutis probe-gated behind a search param).test:unit:allclean, lint 0/0, format clean,check:e2e-bucketingOK,check:docs-api58 identifiers verified, zero.gen.tschurn (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
awaitin an async shell component lands after the shell's synchronous tag snapshot and is not collected (pre-existing window, shared with asynccache()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, keepingshell-capture.tsat zero diff.ppr.key(ctx)variant discriminators, atagsfn form, theppr()group combinator, router-level defaults. Same-host non-URL variants stay unsupported by design; the capture cookie guard remains the safety net.cache()DSL segments (nearest-artifact semantics) are a possible future refinement; the document-level fallback is the documented contract until then.