Skip to content

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

Draft
james-elicx wants to merge 2 commits intomainfrom
opencode/calm-meadow
Draft

feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model#709
james-elicx wants to merge 2 commits intomainfrom
opencode/calm-meadow

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

Summary

Implements full parity with Next.js 16's revalidateTag API, including:

  • Two-phase stale/expired model — mirrors the TagManifestEntry shape from next/src/server/lib/incremental-cache/tags-manifest.external.ts
  • Deprecation warning when revalidateTag() is called without a second profile argument
  • SWR semantics when a profile with expire > 0 is provided: entries are served stale (triggering background regen) until the expire window closes, then become a hard miss
  • Hard invalidation when no profile or expire: 0: next get() is an immediate cache miss
  • KV JSON formatKVCacheHandler now writes { stale?, expired? } JSON objects instead of plain timestamps; backward-compatible with old plain-timestamp strings via parseKVTagEntry()

Key implementation details

MemoryCacheHandler (packages/vinext/src/shims/cache.ts):

  • tagManifest: Map<string, TagManifestEntry> replaces tagRevalidatedAt: Map<string, number>
  • get() checks expired >= lastModified && expired <= now (hard miss) then stale >= lastModified (SWR)
  • >= (not >) for both comparisons to handle same-millisecond set() + revalidateTag() calls

KVCacheHandler (packages/vinext/src/cloudflare/kv-cache-handler.ts):

  • New KVTagEntry / CachedTagEntry interfaces
  • parseKVTagEntry() helper: parses new JSON format and falls back to legacy plain-timestamp (maps to { expired: ts })
  • checkTagInvalidation() helper: returns "expired" | "stale" | "fresh" — same >= fix applied

Tests added

  • Deprecation warning fires without profile
  • Profile-based revalidateTag returns cacheState: "stale" (SWR, not null)
  • expire: 0 causes hard miss
  • No-profile call causes hard miss
  • KV writes JSON { expired } / { stale, expired } format
  • KV SWR: stale entry returned, then hard miss after expire window
  • Legacy plain-timestamp KV entries still invalidate correctly

Reference

  • Next.js revalidateTag source: packages/next/src/server/web/spec-extension/revalidate.ts
  • TagManifestEntry + areTagsExpired/areTagsStale: packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts

…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
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 29, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@709

commit: f204a9b

@github-actions
Copy link
Copy Markdown

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

@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

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
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 29, 2026

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
Collaborator Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

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.


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
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 29, 2026

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

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