Skip to content

fix(cache): preserve prerendered page cache tags - #709

Merged
james-elicx merged 7 commits into
mainfrom
opencode/calm-meadow
Jul 22, 2026
Merged

fix(cache): preserve prerendered page cache tags#709
james-elicx merged 7 commits into
mainfrom
opencode/calm-meadow

Conversation

@james-elicx

@james-elicx james-elicx commented Mar 29, 2026

Copy link
Copy Markdown
Member

Summary

  • propagate user cache tags collected during App Router prerender into vinext-prerender.json
  • seed those tags into both the Node page cache and Cloudflare KV artifacts
  • preserve tag invalidation for prerendered pages so updateTag / revalidateTag from server actions refreshes the page

Root cause

Prerendered page artifacts were seeded without the user cache tags collected during render. Later tag invalidation therefore had no tag-to-page association to expire. The fix carries Next-compatible x-next-cache-tags metadata through prerendering and both seed paths.

Validation

  • focused unit/cache tests: 158/158
  • prerender integration tests: 92/92
  • exact Next.js v16.2.6 app-static.test.ts: owned updateTag and revalidateTag(..., "max") rows pass
  • independent review: no findings
  • Big Bonk: LGTM

The remaining useSearchParams app-static failure belongs to #2243. Cache Components / use cache coverage remains intentionally deferred.

…ed model

- Add TagRevalidationDurations interface and update CacheHandler interface
- Rewrite MemoryCacheHandler to use stale/expired TagManifestEntry model
- Rewrite KVCacheHandler with KVTagEntry JSON format (backward-compat with legacy plain-timestamp)
- Add deprecation warning to public revalidateTag() when called without profile
- SWR semantics when profile with expire>0: mark stale immediately, hard-expire after window
- Hard invalidation when no profile or expire=0: set expired=now, next get() is a miss
- Fix >= comparisons for same-millisecond set()+revalidateTag() correctness
- Add tests for deprecation warning, SWR stale return, expire=0 hard miss, JSON KV format
@pkg-pr-new

pkg-pr-new Bot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@709
npm i https://pkg.pr.new/create-vinext-app@709
npm i https://pkg.pr.new/@vinext/types@709
npm i https://pkg.pr.new/vinext@709

commit: eca828a

@github-actions

github-actions Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model

Thorough review of the two-phase stale/expired revalidateTag implementation. The overall architecture is sound — the TagManifestEntry shape matches Next.js exactly, the layering between MemoryCacheHandler, KVCacheHandler, and the ISR layer is clean, and the backward-compatible parseKVTagEntry() fallback for legacy plain-timestamp KV entries is well-designed.

Issues

1. >= vs > divergence from Next.js (deliberate but should be documented more prominently)

The code comments explain the rationale for >= (handle same-millisecond set()+revalidateTag()), and I confirmed that Next.js uses strict > in areTagsExpired and areTagsStale (expiredAt > timestamp, staleAt > timestamp). The vinext choice is defensively correct — it prevents a race where invalidation in the same ms as a write would be silently ignored. However, this is a behavioral divergence from Next.js that could cause subtle differences in edge cases (e.g., a set() immediately followed by revalidateTag() in the same ms will be invalidated in vinext but not in Next.js).

Per AGENTS.md: "If Next.js and vinext should behave differently (defense-in-depth, Cloudflare-specific requirements), that is OK, but it must be a deliberate, documented decision, not an accidental divergence." The inline comments explain the choice, which is good, but consider adding a brief note in the TagManifestEntry doc comment or the checkTagInvalidation JSDoc that this is an intentional divergence from Next.js's strict >.

2. revalidatePath doesn't pass durations — this is correct, but now it triggers the deprecation warning

revalidatePath calls _getActiveHandler().revalidateTag(tag) at line 507. This is correct — Next.js's revalidatePath always does hard invalidation without a profile. However, the new deprecation warning in the revalidateTag() public API function fires for any call without a profile argument. Since revalidatePath calls the handler directly (not the public revalidateTag function), this is fine — no warning is emitted. Good.

But updateTag() at line 529 also calls _getActiveHandler().revalidateTag(tag) directly, which also avoids the deprecation warning. This is also correct — updateTag is the recommended replacement for the deprecated single-arg revalidateTag. Just confirming the design is intentional. ✓

3. Missing TagRevalidationDurations export from next-shims.d.ts

The next-shims.d.ts file (which provides ambient module declarations for "next/cache") still uses the inline { expire?: number } type for CacheHandler.revalidateTag() (lines 432, 481) instead of the newly-created TagRevalidationDurations interface. While structurally equivalent, this means consumers who implement CacheHandler from the ambient types won't see the TagRevalidationDurations type name or its JSDoc. Consider exporting TagRevalidationDurations from the ambient declarations module too, and using it in those signatures for consistency.

4. MemoryCacheHandler.revalidateTag spreads existing — potential for stale stale field

In cache.ts line 367:

this.tagManifest.set(tag, {
  ...existing,
  stale: now,
  expired: now + durations.expire * 1000,
});

And line 376:

this.tagManifest.set(tag, {
  ...existing,
  stale: undefined,
  expired: now,
});

The spread of existing is mostly harmless since the explicit properties override it, but consider whether this is needed at all. What scenario would require preserving fields from the previous TagManifestEntry? Both branches set all fields explicitly (stale and expired), so ...existing is effectively dead code here. If a future TagManifestEntry gains new fields, spreading existing could accidentally preserve stale values from a previous invalidation cycle. I'd suggest dropping the spread entirely — just pass the literal object.

5. Test: deprecation warning test may be fragile

In shims.test.ts line 1466:

expect(warnSpy.mock.calls[0][0]).toMatch(/deprecated|second argument|max/i);

This regex matches if any of those words appear. The actual warning message contains all three, so it passes, but this could match unrelated warnings. Consider tightening to check for the specific message or at least require two of the three terms. Minor nit.

6. The expired <= now check in MemoryCacheHandler.get() is always true at hard-invalidation time

In cache.ts line 274:

if (typeof expired === "number" && expired >= entry.lastModified && expired <= now) {

For the no-profile case, expired is set to Date.now() in revalidateTag(). When get() runs next, expired <= now will always be true (unless the clock goes backward, which is vanishingly rare). For the SWR case, expired = now + expire * 1000, so expired <= now will only be true after the expire window elapses. This is correct behavior, but the expired <= now check is doing meaningful work only in the SWR case. The comment could be clearer about this — it currently says "the SWR window has itself elapsed" but could note that for hard-invalidation this condition is trivially satisfied.

Positives

  • Clean extraction of checkTagInvalidation() in KV handler — shared logic between the local-cache and KV-fetch paths, eliminates duplication.
  • parseKVTagEntry() backward compat — handles both JSON { stale?, expired? } and legacy plain-timestamp strings gracefully. The fallback to { expired: ts } for old format correctly treats legacy entries as hard-invalidated.
  • Correct priority ordering — hard-expiry is checked before stale for both handlers. The KV handler does a full hard-expiry sweep across all tags before checking stale, preventing a scenario where an earlier tag's stale masks a later tag's hard-expiry.
  • ISR layer agnosticism — confirmed that isrGet() treats cacheState: "stale" uniformly regardless of source (time-based vs tag-based). No ISR changes needed.
  • All revalidateTag callers accounted forrevalidatePath and updateTag correctly do hard invalidation (no durations), only the public revalidateTag() resolves and passes durations.
  • Good test coverage — deprecation warning, SWR stale, expire=0 hard miss, no-profile hard miss, KV JSON format, KV SWR lifecycle, legacy backward compat all tested.

@ask-bonk

ask-bonk Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #709. Here's a summary of the findings:

Overall: The implementation is architecturally sound. The TagManifestEntry shape matches Next.js exactly, the two-phase model (stale → expired) is correctly implemented in both MemoryCacheHandler and KVCacheHandler, and backward compatibility with legacy plain-timestamp KV entries is handled gracefully.

Key issues raised:

  1. >= vs > divergence — vinext uses >= while Next.js uses strict > for stale/expired comparisons. The code documents this as intentional (handles same-millisecond race conditions), but per AGENTS.md guidelines, this should be more prominently documented as a deliberate divergence.

  2. next-shims.d.ts not updated — The ambient type declarations still use inline { expire?: number } instead of the new TagRevalidationDurations interface. Consumers implementing CacheHandler from ambient types won't see the named type or its JSDoc.

  3. Unnecessary ...existing spread — In MemoryCacheHandler.revalidateTag, both branches set all TagManifestEntry fields explicitly, making the ...existing spread dead code that could accidentally preserve stale values if the interface grows.

  4. Minor test fragility — The deprecation warning regex test is overly broad.

Positives: Clean checkTagInvalidation() extraction, correct priority ordering (hard-expiry before stale), ISR layer requires zero changes, comprehensive test coverage, and all caller sites are correctly handled.

github run

- Document >= vs > as deliberate divergence from Next.js with rationale
  (same-millisecond set+revalidateTag must invalidate; strict > would allow
  stale serves when both events share a timestamp)
- Export TagRevalidationDurations interface from next-shims.d.ts and use it
  in CacheHandler, MemoryCacheHandler, and revalidateTag signatures
- Remove dead ...existing spread in MemoryCacheHandler.revalidateTag — both
  branches fully overwrite the TagManifestEntry, the spread could accidentally
  preserve stale fields if the interface grows
- Tighten deprecation warning test regex to match the exact emitted message
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model

Good implementation overall. The architecture cleanly mirrors the Next.js TagManifestEntry shape, the two-phase model is correct, backward compat is handled, and the test coverage is solid. A few issues follow, one of which is a correctness divergence from Next.js.

Divergence: MemoryCacheHandler.revalidateTag when durations is provided without expire

In Next.js's default.ts handler (createDefaultCacheHandler), the updateTags method with durations always sets stale = now, regardless of whether expire is defined:

// Next.js packages/next/src/server/lib/cache-handlers/default.ts:187-198
if (durations) {
  const updates: TagManifestEntry = { ...existingEntry }
  updates.stale = now
  if (durations.expire !== undefined) {
    updates.expired = now + durations.expire * 1000
  }
  tagsManifest.set(tag, updates)
}

But vinext's MemoryCacheHandler.revalidateTag only enters the SWR branch when durations.expire > 0:

if (durations && durations.expire !== undefined && durations.expire > 0) {
  // SWR branch — sets stale + expired
} else {
  // Hard invalidation — only sets expired
}

This means if someone calls handler.revalidateTag("tag", {}) (durations present but no expire field), Next.js would set stale = now (making the entry stale), while vinext would hard-expire it. Same for { expire: undefined }.

The fix is straightforward — match Next.js's logic: if durations is truthy, always set stale = now. The expired field only gets set when expire is defined on the durations object. The expire: 0 case also differs: Next.js treats { expire: 0 } as setting expired = now + 0 = now AND stale = now, while vinext routes it to the hard-invalidation branch. The functional outcome is the same (entry is both stale and hard-expired, so the hard-expiry check fires immediately), but the stored manifest entry differs.

The same divergence exists in KVCacheHandler.revalidateTag.

This may be intentional (the current behavior is arguably more correct for the API surface vinext actually exposes), but per AGENTS.md, behavioral divergences from Next.js should be deliberate and documented. Worth confirming whether revalidateTag("tag", {}) is a realistic call path or purely theoretical.

areTagsExpired comparison logic check

The >= vs > divergence is well-documented in comments. I verified against the Next.js source:

Next.js (tags-manifest.external.ts):

const isImmediatelyExpired = expiredAt <= now && expiredAt > timestamp

vinext:

if (typeof expired === "number" && expired >= entry.lastModified && expired <= now)

These are logically equivalent with the >= being the deliberate divergence (handling same-millisecond writes). The documentation in both cache.ts and kv-cache-handler.ts clearly explains the rationale. This is fine.

Positive observations

  1. checkTagInvalidation() in KV handler — clean extraction, correct priority (hard-expiry before stale sweep across ALL tags), eliminates duplication between local-cache and KV-fetch paths.
  2. parseKVTagEntry() backward compat — graceful handling of both JSON { stale?, expired? } and legacy plain-timestamp strings. The fallback mapping to { expired: ts } for old format correctly treats legacy entries as hard-invalidated.
  3. Two-loop tag-fetch pattern in KV handler — populating local cache for ALL fetched tags before checking invalidation avoids wasted KV reads on early returns. Well designed.
  4. ISR layer requires zero changes — the cacheState: "stale" abstraction cleanly slots into the existing ISR SWR logic regardless of whether staleness comes from time-based or tag-based invalidation.
  5. Comprehensive test coverage — deprecation warning, SWR stale, expire=0 hard miss, no-profile hard miss, KV JSON format, KV SWR lifecycle, legacy backward compat all tested.
  6. Deprecation warning test — despite ask-bonk's flag, the actual test uses a specific substring match (/"revalidateTag" without the second argument is now deprecated/), not the loose regex described. The test is fine.

Summary

The main item worth discussing is the durations handling divergence: when durations is truthy but expire is undefined or zero, the branching logic differs from Next.js. Whether this matters depends on whether that call path is realistic. Everything else is well-implemented with good documentation of deliberate divergences.

Comment thread packages/vinext/src/shims/cache.ts Outdated

for (const tag of tagList) {
this.tagRevalidatedAt.set(tag, now);
if (durations && durations.expire !== undefined && durations.expire > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Divergence from Next.js: In Next.js default.ts:187-198, when durations is truthy, stale = now is always set regardless of expire. The expired field is only conditionally set when durations.expire !== undefined. Here, the condition durations.expire !== undefined && durations.expire > 0 means:

  • revalidateTag("tag", {}) → hard invalidation in vinext, stale-only in Next.js
  • revalidateTag("tag", { expire: 0 }) → hard invalidation in vinext, stale+expired in Next.js

The functional outcome for expire: 0 is the same (hard miss), but the stored manifest differs. For {} (no expire), the behavior genuinely diverges. Worth a documenting comment if intentional.

// - Profile with expire (SWR): { stale: now, expired: now + expire * 1000 }
// Entries are served stale until `expired` is reached, then become a hard miss.
let tagEntry: KVTagEntry;
if (durations && durations.expire !== undefined && durations.expire > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same divergence as MemoryCacheHandler: Next.js's updateTags always sets stale = now when durations is truthy, then conditionally sets expired only when durations.expire !== undefined. The expire > 0 guard here routes { expire: 0 } and {} to the hard-invalidation branch instead.

@ask-bonk

ask-bonk Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #709. Here's a summary of what I found:

Overall: The implementation is well-structured — the TagManifestEntry shape matches Next.js, the two-phase stale/expired model is correctly implemented in both handlers, backward compat for legacy KV entries is clean, and test coverage is comprehensive.

Key finding — divergence from Next.js's updateTags branching:

The main issue I identified (with inline comments on both cache.ts:370 and kv-cache-handler.ts:410) is a behavioral divergence in how durations is handled:

  • Next.js: When durations is truthy (even {}), it always sets stale = now, and only conditionally sets expired when durations.expire !== undefined.
  • vinext: The condition durations.expire !== undefined && durations.expire > 0 routes revalidateTag("tag", {}) and revalidateTag("tag", { expire: 0 }) to the hard-invalidation branch instead of the SWR branch.

For expire: 0 the functional outcome is the same (hard miss), but for {} the behavior genuinely diverges. Whether this matters depends on whether that call path is realistic, but per AGENTS.md it should be documented as a deliberate choice if intentional.

Confirmed from ask-bonk's review:

  • The >= vs > divergence is well-documented and defensively correct
  • The ...existing spread issue was already resolved in the final code
  • The deprecation warning test is fine (uses specific substring, not loose regex)
  • ISR layer agnosticism is confirmed — no changes needed there

github run

…ut expire

When revalidateTag is called with a truthy durations object, stale is now
always written to the tag manifest regardless of whether expire is set.
The expired field is only set when durations.expire !== undefined.

This fixes two divergences from Next.js default.ts updateTags:
- revalidateTag('tag', {}) → { stale: now } (stale-only SWR), was { expired: now }
- revalidateTag('tag', { expire: 0 }) → { stale: now, expired: now }, was { expired: now }

Applies the same fix to both MemoryCacheHandler and KVCacheHandler.
Adds tests covering the new stale-only ({}) and expire=0 shapes.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model

All tests pass (822/822). The implementation is correct and well-structured.

Verification of prior review findings

ask-bonk's durations branching divergence (the main issue) — fixed in cc43b17. The final commit correctly matches Next.js's updateTags logic: when durations is truthy, stale = now is always set, and expired is only conditionally set when durations.expire !== undefined. Both MemoryCacheHandler and KVCacheHandler are consistent. The edge cases are:

  • revalidateTag("tag", {}){ stale: now } — SWR with no hard expiry ✓
  • revalidateTag("tag", { expire: N }){ stale: now, expired: now + N*1000 }
  • revalidateTag("tag", { expire: 0 }){ stale: now, expired: now } — immediate hard miss ✓
  • revalidateTag("tag") (no durations) → { expired: now } — hard invalidation ✓

New tests cover all four cases in both handlers.

>= vs > divergence — acceptable and well-documented. The inline comments in both cache.ts:264-270 and kv-cache-handler.ts:583-590 clearly explain the rationale. This is a defense-in-depth divergence that prevents same-millisecond stale-serve bugs. No action needed.

next-shims.d.ts — addressed. The ambient "next/cache" module declaration now exports TagRevalidationDurations as a named interface (line 432) and uses it in the CacheHandler.revalidateTag signature (line 444) and the revalidateTag function signature (line 501). Consumers implementing CacheHandler from ambient types will see the named type.

Code quality observations

  1. checkTagInvalidation() extraction in KV handler — clean shared logic between local-cache and KV-fetch paths. Correct priority ordering: hard-expiry swept across ALL tags before stale is checked.

  2. Two-loop tag-fetch pattern (kv-cache-handler.ts:263-277) — populates local cache for ALL fetched tags before checking invalidation, avoiding wasted KV reads on early returns. Well designed.

  3. parseKVTagEntry() backward compat — handles JSON { stale?, expired? } and legacy plain-timestamp strings gracefully. The fallback mapping to { expired: ts } for old format correctly treats legacy entries as hard-invalidated. Test at line 572 covers this.

  4. revalidatePath and updateTag correctly bypass deprecation warning — both call _getActiveHandler().revalidateTag(tag) directly (not the public revalidateTag function), so no spurious warnings.

  5. ISR layer requires zero changescacheState: "stale" from tag-based SWR slots cleanly into existing ISR SWR logic.

One minor observation (non-blocking)

In MemoryCacheHandler.get() at line 306, there's a second Date.now() call for time-based expiry that could reuse the now variable from line 271. This is cosmetic — the two timestamps will differ by microseconds at most — but reusing now would be slightly more consistent. Not worth a fixup commit.

LGTM. Clean implementation with comprehensive test coverage.

@ask-bonk

ask-bonk Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #709 with approval. Summary:

  • All 822 tests pass across both test files
  • The durations branching divergence flagged by ask-bonk's second review was fixed in cc43b17 — both handlers now match Next.js's updateTags logic exactly (stale always set when durations is truthy, expired only set when expire is explicitly provided)
  • The >= vs > divergence is well-documented and defensively correct
  • next-shims.d.ts exports TagRevalidationDurations as a named interface
  • parseKVTagEntry() backward compat with legacy plain-timestamp KV entries works correctly
  • The checkTagInvalidation() extraction is clean with correct priority ordering
  • ISR layer needs zero changes — cacheState: "stale" from tag-based SWR slots into existing ISR logic

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Worker E note for deploy-suite run 28478866791 / job 84413308650: I think this PR covers the tag-invalidation/updateTag side of the cache/revalidation failures, including the test/e2e/app-dir/app-static/app-static.test.ts server-action updateTag/revalidateTag stale-data assertion and the related test/e2e/app-dir/resume-data-cache/resume-data-cache.test.ts fresh-data-after-updateTag assertion. The PR implements the Next.js 16 two-phase stale/expired tag model and hard invalidation paths, so I am skipping those exact tag-invalidation items and continuing with the remaining uncovered cache consistency/static-data failures.

@james-elicx

Copy link
Copy Markdown
Member Author

Run 28478866791 / job 84413308650 mapping for the remaining app-static tag row:

  • Suite: test/e2e/app-dir/app-static/app-static.test.ts
  • Row: app-dir static/dynamic handling > updateTag/revalidateTag > should successfully update tag when called from server action
  • Artifact: test-results-8/e2e/app-dir/app-static/app-static.test.ts.results.json
  • Upstream assertion: test/e2e/app-dir/app-static/app-static.test.ts:4998-5007; after clicking the server-action update button and refreshing, newData should differ from initialData.
  • Observed failure: expect(received).not.toEqual(expected) at line 5007; vinext returned the same { timestamp, random } object after updateTag, so the tag hard-expiration path did not force a fresh cache read.

This PR is the exact existing fix candidate for that row: it implements the Next.js 16 stale/expired tag manifest model and, most importantly for this assertion, maps no-profile revalidateTag / updateTag to immediate hard expiration so the next tagged get() is a miss instead of returning the stale cached value. That scope matches the failure better than #2472, which explicitly left this row after fixing the App static ISR lifecycle rows.

Validation done for this mapping: inspected the original deploy-suite report artifact and the failing assertion text, and checked this PR diff touches the relevant Memory/KV cache handler tag invalidation paths plus focused cache tests.

Validation still needed before counting the row closed: refresh/rebase this PR onto current main and run the targeted wrapper against test/e2e/app-dir/app-static/app-static.test.ts (or at least the updateTag/revalidateTag row) with the current deploy-suite harness. Suggested command shape:

REPO="$(pwd)" \
NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" \
NEXT_TEST_CONCURRENCY=1 \
./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/app-static/app-static.test.ts

@james-elicx

james-elicx commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Run 29871622126 / job 88775791401 still fails test/e2e/app-dir/app-static/app-static.test.ts at updateTag/revalidateTag should successfully update tag when called from server action: cached data remains unchanged. I think this PR owns that invalidation-semantics failure. I am keeping it mapped here; current-main revival must prove the exact targeted Next.js v16.2.6 assertion.

# Conflicts:
#	packages/cloudflare/src/cache/kv-data-adapter.runtime.ts
#	packages/vinext/src/shims/cache.ts
#	packages/vinext/src/shims/next-shims.d.ts
#	tests/shims.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

Refreshed this PR onto current main and pushed head eca828a90cf57c1d8f408b7a424a9d433b193f2d.

The remaining app-static failure was not in the current tag invalidation primitive: prerendered App Router pages lost the user cache tags collected by unstable_cache before vinext-prerender.json was written. Startup then seeded the full-page HTML/RSC entries with path tags only, so updateTag("test-update-tag") could invalidate nested data without reliably invalidating the prerendered page artifact. This head now carries collected tags through the same x-next-cache-tags static-generation side channel Next.js uses, persists them in the prerender manifest, and attaches them to both Node memory-cache and Cloudflare KV prerender seeds.

Exact Next.js v16.2.6 proof at this head:

CI=true REPO="$(pwd)" \
NEXTJS_DIR="/private/tmp/nextjs-metadata-streaming-ref" \
./scripts/run-targeted-nextjs-e2e.sh \
test/e2e/app-dir/app-static/app-static.test.ts

Result:

  • PASS: updateTag/revalidateTag > should successfully update tag when called from server action (1.303s)
  • PASS: updateTag/revalidateTag > revalidateTag work with max profile in server actions
  • PASS: all other in-scope rows in this suite
  • Remaining suite failure: useSearchParams > server response > should bailout to client rendering - with suspense boundary, which is the separately deferred/unowned row from run 29871622126 and is outside this PR.

Local regression proof:

  • tests/app-page-render.test.ts, tests/seed-cache.test.ts, tests/prerender-kv-populate.test.ts, tests/kv-cache-handler.test.ts: 158/158 passed
  • tests/prerender.test.ts: 92/92 passed (real App Router build/prerender fixture verifies the unstable_cache tag reaches the manifest)
  • scoped vp check: clean

The stale auto-merged tests for the old KV JSON tag-manifest architecture were removed during the main refresh; the net PR diff now contains only the prerender tag propagation and its regression coverage.

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared eca828a against base 3d30a3c using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 130.6 KB 130.6 KB ⚫ +0.0%
Client entry size (gzip) vinext 118.1 KB 118.1 KB ⚫ +0.0%
Dev server cold start vinext 2.80 s 2.79 s ⚫ -0.4%
Production build time vinext 3.11 s 3.11 s ⚫ +0.0%
RSC entry closure size (gzip) vinext 101.8 KB 101.9 KB ⚫ +0.1%
Server bundle size (gzip) vinext 169.1 KB 169.2 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(cache): preserve prerendered page cache tags

Note: the PR title/description describe the older KV JSON tag-manifest work, but the current diff (after the refresh onto main, head eca828a) is entirely different — it propagates user cache tags collected during App Router prerender through to the seeded page cache artifact. Reviewed against the actual diff.

Summary

The change is well-scoped and correct. It closes a real gap: prerendered App Router pages previously lost the user tags collected by unstable_cache/cacheTag before vinext-prerender.json was written, so revalidateTag("some-user-tag") could invalidate nested data-cache entries but never the prerendered page artifact itself. The fix carries collected tags through the same x-next-cache-tags static-generation side channel Next.js uses, persists them in the prerender manifest, and attaches them to both Node memory-cache and Cloudflare KV prerender seeds.

I verified the full flow end-to-end and it holds together:

  • getPageTags()buildAppPageTags(cleanPathname, getCollectedFetchTags(), routeSegments) (app-page-dispatch.ts:1097) returns ASCII-encoded tags (both implicit _N_T_/… path tags and user tags).
  • Header emit is correctly gated on options.isPrerender === true, and isPrerender is process.env.VINEXT_PRERENDER === "1" (app-page-dispatch.ts:632) — build-time only. So x-next-cache-tags is never sent to real clients at runtime; it's purely the build-time capture channel, matching Next.js.
  • readPrerenderCacheTagsHeader (prerender.ts:1731) reads/dedups → manifest tags (prerender-manifest.ts) → seedMemoryCacheFromPrerender and buildPrerenderKVPairs both pass route.tags ?? [] into buildAppPageCacheTags, which dedups against the implicit tags it regenerates (app-page-cache.ts:147-151), so the redundant implicit tags in the manifest are harmless.

Correctness checks

Header name matches Next.js. x-next-cache-tags matches NEXT_CACHE_TAGS_HEADER in packages/types/next/upstream/dist/lib/constants.d.ts:20. Good.

Tag timing during prerender. The x-next-cache-tags header is set at response-build time, before the returned safeHtmlStream body is drained. This would be a bug if fetch/unstable_cache tags accumulated only during stream consumption. It's safe here because the prerender path awaits htmlRender.metadataReady (app-page-render.ts:1011-1013) and settleCapturedRscRenderForCacheMetadata(htmlRender.capturedRscData) (shouldReadRequestCacheLifeForPrerender is isPrerender === true, line 758) before building the response, so getCollectedFetchTags() is fully populated. The real App Router build+prerender fixture test (tests/prerender.test.ts "records collected App Router cache tags") passes and confirms unstable-data reaches the manifest, which is the authoritative proof.

Comma delimiting. Tags are joined/split on , with no escaping of commas within a tag. This matches Next.js (it uses the same comma-delimited x-next-cache-tags header), so it's consistent, not a divergence — worth being aware of but not a blocker.

One parity observation (non-blocking, likely out of scope)

packages/cloudflare/src/tpr.ts:935 still uses buildAppPageCacheTags(routePath, []) with the exact "Path-derived implicit tags … #1486" comment that the other two seed sites just moved past. TPR seeds from runtime responses (isPrerender false), so x-next-cache-tags isn't present there and user tags genuinely aren't available on that path — so this isn't a regression from this PR. But it does mean TPR-seeded entries still can't be invalidated by user tags, so the #1486 gap this PR closes for build prerender remains open for TPR. Worth a follow-up issue or a note that the divergence is intentional.

Tests

Good coverage across the stack:

  • tests/app-page-render.test.ts — asserts the x-next-cache-tags header is emitted with both implicit and user tags on the prerender path.
  • tests/prerender.test.ts — real build/prerender fixture proves unstable_cache tags reach the route result and vinext-prerender.json.
  • tests/prerender-kv-populate.test.ts — manifest tags flow into KV metadata.
  • tests/seed-cache.test.ts — full loop: manifest tags → seeded entry → revalidateTag("test-update-tag") invalidates both html and rsc keys.

All 89 targeted unit tests + the prerender fixture test pass locally.

Verdict

LGTM. Correct, minimal, well-tested fix with proper dev/build parity. The only follow-up worth tracking is the TPR (tpr.ts:935) tag gap, which is out of scope for this PR.

@ask-bonk

ask-bonk Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #709.

Summary

The PR title/body describe the old KV JSON tag-manifest work, but after the refresh onto main (head eca828a), the actual diff is a different, focused fix: propagating user cache tags collected during App Router prerender into the seeded page cache artifact. I reviewed the real diff.

Verdict: LGTM. The fix is correct, minimal, and well-tested.

What it does: Closes a real gap — prerendered pages lost the user tags from unstable_cache/cacheTag, so revalidateTag("user-tag") could invalidate nested data but never the prerendered page HTML/RSC. The fix carries tags through Next.js's x-next-cache-tags static-generation side channel → prerender manifest → both Node memory-cache and Cloudflare KV seeds.

Correctness verified:

  • Header name x-next-cache-tags matches Next.js's constant.
  • Header emit is gated on isPrerender === true (VINEXT_PRERENDER=1, build-time only) — never leaks to runtime clients.
  • Tag timing is safe: the prerender path awaits metadataReady + settleCapturedRscRenderForCacheMetadata before building the response, so getCollectedFetchTags() is fully populated. The real build fixture test proves unstable-data reaches the manifest.
  • Redundant implicit tags in the manifest are deduped by buildAppPageCacheTags.

One non-blocking observation: packages/cloudflare/src/tpr.ts:935 still uses buildAppPageCacheTags(routePath, []) — the same pattern the other two seed sites moved past. It's not a regression (TPR seeds runtime responses where x-next-cache-tags isn't present), but the #1486 gap remains open for the TPR path. Worth a follow-up.

All 89 targeted unit tests plus the real prerender fixture test pass locally.

github run

@james-elicx james-elicx changed the title feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model fix(cache): preserve prerendered page cache tags Jul 22, 2026
@james-elicx
james-elicx marked this pull request as ready for review July 22, 2026 03:16
@james-elicx
james-elicx merged commit 95661c2 into main Jul 22, 2026
66 checks passed
@james-elicx
james-elicx deleted the opencode/calm-meadow branch July 22, 2026 19:33
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.

Update revalidateTag shim to support new two-argument signature (tag, profile)

1 participant