feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model#709
feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model#709james-elicx wants to merge 2 commits intomainfrom
Conversation
…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
commit: |
|
|
/bigbonk review |
There was a problem hiding this comment.
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()treatscacheState: "stale"uniformly regardless of source (time-based vs tag-based). No ISR changes needed. - All
revalidateTagcallers accounted for —revalidatePathandupdateTagcorrectly do hard invalidation (no durations), only the publicrevalidateTag()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.
|
Review posted on PR #709. Here's a summary of the findings: Overall: The implementation is architecturally sound. The Key issues raised:
Positives: Clean |
- 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
|
/bigbonk review |
There was a problem hiding this comment.
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 > timestampvinext:
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
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.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.- 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.
- 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. - 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.
- 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) { |
There was a problem hiding this comment.
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.jsrevalidateTag("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) { |
There was a problem hiding this comment.
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.
|
Review posted on PR #709. Here's a summary of what I found: Overall: The implementation is well-structured — the Key finding — divergence from Next.js's The main issue I identified (with inline comments on both
For Confirmed from ask-bonk's review:
|
Summary
Implements full parity with Next.js 16's
revalidateTagAPI, including:TagManifestEntryshape fromnext/src/server/lib/incremental-cache/tags-manifest.external.tsrevalidateTag()is called without a secondprofileargumentexpire > 0is provided: entries are served stale (triggering background regen) until the expire window closes, then become a hard missexpire: 0: nextget()is an immediate cache missKVCacheHandlernow writes{ stale?, expired? }JSON objects instead of plain timestamps; backward-compatible with old plain-timestamp strings viaparseKVTagEntry()Key implementation details
MemoryCacheHandler(packages/vinext/src/shims/cache.ts):tagManifest: Map<string, TagManifestEntry>replacestagRevalidatedAt: Map<string, number>get()checksexpired >= lastModified && expired <= now(hard miss) thenstale >= lastModified(SWR)>=(not>) for both comparisons to handle same-millisecondset()+revalidateTag()callsKVCacheHandler(packages/vinext/src/cloudflare/kv-cache-handler.ts):KVTagEntry/CachedTagEntryinterfacesparseKVTagEntry()helper: parses new JSON format and falls back to legacy plain-timestamp (maps to{ expired: ts })checkTagInvalidation()helper: returns"expired" | "stale" | "fresh"— same>=fix appliedTests added
revalidateTagreturnscacheState: "stale"(SWR, not null)expire: 0causes hard miss{ expired }/{ stale, expired }formatReference
revalidateTagsource:packages/next/src/server/web/spec-extension/revalidate.tsTagManifestEntry+areTagsExpired/areTagsStale:packages/next/src/server/lib/incremental-cache/tags-manifest.external.ts