From 0c1c46891e6ddf89e3d722f25c61334a3ebd89d0 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 10:22:38 +0100 Subject: [PATCH 1/3] feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired 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 --- .../vinext/src/cloudflare/kv-cache-handler.ts | 177 ++++++++++++++++-- packages/vinext/src/shims/cache.ts | 153 +++++++++++++-- tests/kv-cache-handler.test.ts | 119 +++++++++--- tests/shims.test.ts | 104 +++++++++- 4 files changed, 488 insertions(+), 65 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index f5cd31210..050ca9940 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -37,6 +37,7 @@ import type { CachedRouteValue, CachedImageValue, IncrementalCacheValue, + TagRevalidationDurations, } from "../shims/cache.js"; import { getRequestExecutionContext, type ExecutionContextLike } from "../shims/request-context.js"; @@ -88,6 +89,35 @@ interface KVCacheEntry { revalidateAt: number | null; } +/** + * Shape stored in KV under `__tag:` keys. + * + * When a tag is invalidated without a profile (hard invalidation), only + * `expired` is set to `Date.now()`. Entries with `lastModified < expired` are + * a hard miss on the next `get()`. + * + * When a tag is invalidated WITH a cacheLife profile, `stale` is also set so + * the cache handler can serve stale entries (SWR) until `expired` is reached. + * + * Backward compat: the old format stored a plain timestamp string instead of + * JSON. Reads that produce a non-JSON value are treated as hard invalidation + * with `expired = parsedTimestamp`. + */ +interface KVTagEntry { + /** Absolute ms timestamp at which the tag was marked stale (SWR start). */ + stale?: number; + /** Absolute ms timestamp after which entries with this tag are a hard miss. */ + expired?: number; +} + +/** + * Local in-memory representation of a fetched tag entry. + * Extends KVTagEntry with the time we fetched it (for TTL bookkeeping). + */ +interface CachedTagEntry extends KVTagEntry { + fetchedAt: number; +} + /** Key prefix for tag invalidation timestamps. */ const TAG_PREFIX = "__tag:"; @@ -136,8 +166,8 @@ export class KVCacheHandler implements CacheHandler { private ctx: ExecutionContextLike | undefined; private ttlSeconds: number; - /** Local in-memory cache for tag invalidation timestamps. Avoids redundant KV reads. */ - private _tagCache = new Map(); + /** Local in-memory cache for tag invalidation entries. Avoids redundant KV reads. */ + private _tagCache = new Map(); /** TTL (ms) for local tag cache entries. After this, re-fetch from KV. */ private _tagCacheTtl: number; @@ -192,7 +222,7 @@ export class KVCacheHandler implements CacheHandler { } } - // Check tag-based invalidation. + // Check tag-based invalidation using the two-phase stale/expired model. // Uses a local in-memory cache to avoid redundant KV reads for recently-seen tags. if (entry.tags.length > 0) { const now = Date.now(); @@ -203,11 +233,13 @@ export class KVCacheHandler implements CacheHandler { for (const tag of entry.tags) { const cached = this._tagCache.get(tag); if (cached && now - cached.fetchedAt < this._tagCacheTtl) { - // Local cache hit — check invalidation inline - if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { + // Local cache hit — apply invalidation check + const result = checkTagInvalidation(cached, entry.lastModified, now); + if (result === "expired") { this._deleteInBackground(kvKey); return null; } + // "stale" is handled after all tags are checked (below) } else { // Expired or absent — evict stale entry and re-fetch from KV if (cached) this._tagCache.delete(tag); @@ -229,22 +261,40 @@ export class KVCacheHandler implements CacheHandler { // earlier tag would cause an early return — so subsequent get() calls // for entries sharing those tags don't redundantly re-fetch from KV. for (let i = 0; i < uncachedTags.length; i++) { - const tagTime = tagResults[i]; - const tagTimestamp = tagTime ? Number(tagTime) : 0; - this._tagCache.set(uncachedTags[i], { timestamp: tagTimestamp, fetchedAt: now }); + const tagRaw = tagResults[i]; + const tagEntry = parseKVTagEntry(tagRaw); + this._tagCache.set(uncachedTags[i], { ...tagEntry, fetchedAt: now }); } - // Then check for invalidation using the now-cached timestamps + // Then check for invalidation using the now-cached entries for (const tag of uncachedTags) { const cached = this._tagCache.get(tag)!; - if (cached.timestamp !== 0) { - if (Number.isNaN(cached.timestamp) || cached.timestamp >= entry.lastModified) { - this._deleteInBackground(kvKey); - return null; - } + const result = checkTagInvalidation(cached, entry.lastModified, now); + if (result === "expired") { + this._deleteInBackground(kvKey); + return null; } } } + + // After all hard-expiry checks passed, check for stale-by-tag (SWR). + // We do this in a second sweep so a later tag's hard-expiry doesn't get + // masked by an earlier tag's stale return. + let isTagStale = false; + for (const tag of entry.tags) { + const cached = this._tagCache.get(tag); + if (cached && checkTagInvalidation(cached, entry.lastModified, now) === "stale") { + isTagStale = true; + break; + } + } + if (isTagStale) { + return { + lastModified: entry.lastModified, + value: restoredValue, + cacheState: "stale", + }; + } } // Check time-based expiry — return stale with cacheState @@ -341,23 +391,46 @@ export class KVCacheHandler implements CacheHandler { }); } - async revalidateTag(tags: string | string[], _durations?: { expire?: number }): Promise { + async revalidateTag( + tags: string | string[], + durations?: TagRevalidationDurations, + ): Promise { const tagList = Array.isArray(tags) ? tags : [tags]; const now = Date.now(); const validTags = tagList.filter((t) => validateTag(t) !== null); - // Store invalidation timestamp for each tag - // Use a long TTL (30 days) so recent invalidations are always found + + // Build the KVTagEntry payload based on whether a profile was provided. + // + // - No profile (hard invalidation): { expired: now } + // Entries with lastModified <= expired (and expired <= now at get time) are a hard miss. + // + // - 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) { + tagEntry = { + stale: now, + expired: now + durations.expire * 1000, + }; + } else { + tagEntry = { expired: now }; + } + + const tagJson = JSON.stringify(tagEntry); + + // Store invalidation entry for each tag. + // Use a long TTL (30 days) so recent invalidations are always found. await Promise.all( validTags.map((tag) => - this.kv.put(this.prefix + TAG_PREFIX + tag, String(now), { + this.kv.put(this.prefix + TAG_PREFIX + tag, tagJson, { expirationTtl: 30 * 24 * 3600, }), ), ); // Update local tag cache immediately so invalidations are reflected - // without waiting for the TTL to expire + // without waiting for the cache TTL to expire. for (const tag of validTags) { - this._tagCache.set(tag, { timestamp: now, fetchedAt: now }); + this._tagCache.set(tag, { ...tagEntry, fetchedAt: now }); } } @@ -466,6 +539,70 @@ export class KVCacheHandler implements CacheHandler { const VALID_KINDS = new Set(["FETCH", "APP_PAGE", "PAGES", "APP_ROUTE", "REDIRECT", "IMAGE"]); +/** + * Parse a raw KV tag value into a `KVTagEntry`. + * + * New format: JSON string `{ stale?: number, expired?: number }`. + * Old format (backward compat): plain timestamp string e.g. `"1234567890123"`. + * Missing/null: returns `{}` (no invalidation). + */ +function parseKVTagEntry(raw: string | null): KVTagEntry { + if (!raw) return {}; + // Try JSON first + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const entry: KVTagEntry = {}; + if (typeof parsed.stale === "number") entry.stale = parsed.stale; + if (typeof parsed.expired === "number") entry.expired = parsed.expired; + return entry; + } + } catch { + // Not JSON — fall through to legacy plain-timestamp handling + } + // Legacy format: plain numeric timestamp string (hard invalidation) + const ts = Number(raw); + if (!Number.isNaN(ts) && ts > 0) { + return { expired: ts }; + } + return {}; +} + +/** + * Check whether a cache entry (identified by `lastModified`) is invalidated + * by the given tag entry, relative to `now`. + * + * Returns: + * - `"expired"`: hard miss — entry must not be served. + * - `"stale"`: SWR — entry may be served stale while background regen runs. + * - `"fresh"`: no invalidation — entry is still valid. + */ +function checkTagInvalidation( + tagEntry: KVTagEntry, + lastModified: number, + now: number, +): "expired" | "stale" | "fresh" { + const { stale, expired } = tagEntry; + + // Hard expiry check: the tag's expired timestamp was set AT OR AFTER the + // entry was last written, AND the expiry time has now been reached. + // Use >= (not >) so same-millisecond set()+revalidateTag() still invalidates. + if (typeof expired === "number" && expired >= lastModified && expired <= now) { + return "expired"; + } + + // Stale-by-tag (SWR): the tag's stale timestamp was set AT OR AFTER the + // entry was last written. Serve the entry stale — caller will trigger + // background regen. Use >= to handle same-millisecond set+revalidateTag. + // Note: we only reach here when the expire window hasn't closed yet (or no + // expire is set, which means the profile-based SWR has no hard expiry). + if (typeof stale === "number" && stale >= lastModified) { + return "stale"; + } + + return "fresh"; +} + /** * Validate that a parsed JSON value has the expected KVCacheEntry shape. * Returns the validated entry or null if the shape is invalid. diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 3084559cc..98086dc3c 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -125,6 +125,21 @@ export interface CacheHandlerContext { [key: string]: unknown; } +/** + * Durations passed to CacheHandler.revalidateTag when a profile is provided. + * + * When `expire` is undefined, the handler should mark tags as immediately + * hard-expired (no SWR window). When `expire` is a positive number, the + * handler should serve stale cache entries until `expire` seconds have elapsed, + * then force a synchronous fresh render. + * + * Matches the Next.js 16 CacheHandler revalidateTag signature. + */ +export interface TagRevalidationDurations { + /** Seconds until the tagged entries are truly expired (hard miss). */ + expire?: number; +} + export interface CacheHandler { get(key: string, ctx?: Record): Promise; @@ -134,7 +149,19 @@ export interface CacheHandler { ctx?: Record, ): Promise; - revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise; + /** + * Invalidate cached entries associated with the given tag(s). + * + * When `durations` is provided (because the caller passed a cacheLife profile), + * the handler SHOULD implement stale-while-revalidate: mark entries as stale + * immediately (so the next request triggers a background revalidation) but + * continue serving them until `durations.expire` seconds have elapsed (hard miss). + * + * When `durations` is undefined (no profile / `updateTag` call), the handler + * SHOULD mark entries as immediately hard-expired — the next request gets a + * synchronous fresh render. + */ + revalidateTag(tags: string | string[], durations?: TagRevalidationDurations): Promise; resetRequestCache?(): void; } @@ -159,7 +186,10 @@ export class NoOpCacheHandler implements CacheHandler { // intentionally empty } - async revalidateTag(_tags: string | string[], _durations?: { expire?: number }): Promise { + async revalidateTag( + _tags: string | string[], + _durations?: TagRevalidationDurations, + ): Promise { // intentionally empty } } @@ -176,6 +206,25 @@ interface MemoryEntry { revalidateAt: number | null; } +/** + * Per-tag invalidation state stored by MemoryCacheHandler. + * + * Mirrors the Next.js FileSystemCache TagManifestEntry shape: + * - `stale`: absolute ms timestamp after which entries are served stale (SWR) + * - `expired`: absolute ms timestamp after which entries are hard-expired (miss) + * + * When only `expired` is set (no profile / hard invalidation), cache entries + * whose `lastModified < expired` are a hard miss on the next get(). + * + * When `stale` is also set (profile with expire window), entries whose + * `lastModified < stale` are returned with `cacheState: "stale"` until + * `expired` is reached, at which point they become a hard miss. + */ +interface TagManifestEntry { + stale?: number; + expired?: number; +} + /** * Shape of the optional `ctx` argument passed to `CacheHandler.set()`. * Covers both the older `{ revalidate: number }` shape and the newer @@ -191,21 +240,57 @@ interface SetCtx { export class MemoryCacheHandler implements CacheHandler { private store = new Map(); - private tagRevalidatedAt = new Map(); + private tagManifest = new Map(); async get(key: string, _ctx?: Record): Promise { const entry = this.store.get(key); if (!entry) return null; - // Check tag-based invalidation first — if tag was invalidated, treat as hard miss. - // Note: the stale entry is deleted here as a side effect of the read, not on write. - // This keeps memory bounded without a separate eviction pass. + // Check tag-based invalidation using the Next.js stale/expired two-phase model. + // + // For each tag on the entry: + // - If `expired` is set and `expired >= entry.lastModified` and `expired <= now`: + // hard miss (the tag was invalidated without a profile, or the SWR window + // has itself elapsed). Delete the entry and return null. The >= handles + // same-millisecond set()+revalidateTag() calls. + // - If `stale` is set and `stale >= entry.lastModified`: + // serve stale (profile-based SWR). The entry is still usable; caller will + // trigger background revalidation. The >= handles same-millisecond calls. + // + // Note: the stale check intentionally comes AFTER the expired check, so that + // an entry that has both stale and expired set (profile-based revalidation) + // is correctly evicted once the expire window has passed. + const now = Date.now(); + let isTagStale = false; + for (const tag of entry.tags) { - const revalidatedAt = this.tagRevalidatedAt.get(tag); - if (revalidatedAt && revalidatedAt >= entry.lastModified) { + const manifest = this.tagManifest.get(tag); + if (!manifest) continue; + + const { stale, expired } = manifest; + + // Hard expiry check: expired was set AND the invalidation happened at or + // after the entry was last written (>= handles same-millisecond set+revalidate). + if (typeof expired === "number" && expired >= entry.lastModified && expired <= now) { this.store.delete(key); return null; } + + // Stale check (SWR window): stale timestamp was set at or after the entry + // was last written — the tag was revalidated with a profile. Serve stale + // until the expire window closes. Use >= to handle same-millisecond calls. + if (typeof stale === "number" && stale >= entry.lastModified) { + isTagStale = true; + // Don't break — we still need to check remaining tags for hard expiry. + } + } + + if (isTagStale) { + return { + lastModified: entry.lastModified, + value: entry.value, + cacheState: "stale", + }; } // Check time-based expiry — return stale entry with cacheState="stale" @@ -266,11 +351,34 @@ export class MemoryCacheHandler implements CacheHandler { }); } - async revalidateTag(tags: string | string[], _durations?: { expire?: number }): Promise { + async revalidateTag( + tags: string | string[], + durations?: TagRevalidationDurations, + ): Promise { const tagList = Array.isArray(tags) ? tags : [tags]; const now = Date.now(); + for (const tag of tagList) { - this.tagRevalidatedAt.set(tag, now); + const existing = this.tagManifest.get(tag) ?? {}; + + if (durations && durations.expire !== undefined && durations.expire > 0) { + // Profile-based SWR: mark stale immediately (triggers background regen) + // and set an absolute expire time (after which it's a hard miss). + this.tagManifest.set(tag, { + ...existing, + stale: now, + expired: now + durations.expire * 1000, + }); + } else { + // No profile (or expire=0): immediate hard expiration. + // Set expired=now so the next get() on any entry with this tag is a hard miss. + // The >= check in get() ensures same-millisecond set()+revalidateTag() is invalidated. + this.tagManifest.set(tag, { + ...existing, + stale: undefined, + expired: now, + }); + } } } @@ -338,19 +446,34 @@ export function getCacheHandler(): CacheHandler { * Works with both `fetch(..., { next: { tags: ['myTag'] } })` and * `unstable_cache(fn, keys, { tags: ['myTag'] })`. * - * Next.js 16 updated signature: accepts a cacheLife profile as second argument - * for stale-while-revalidate (SWR) behavior. The single-argument form is - * deprecated but still supported for backward compatibility. + * Next.js 16 updated signature: the second `profile` argument is now **required**. + * Omitting it causes a TypeScript build error in Next.js 16 and triggers a + * deprecation warning at runtime. Use `'max'` as the default recommended value + * for stale-while-revalidate semantics: + * + * revalidateTag('my-tag', 'max') + * + * To hard-expire a tag without SWR (e.g. from a Server Action), use `updateTag()` + * instead, which has no profile argument and always hard-expires immediately. * * @param tag - Cache tag to revalidate - * @param profile - cacheLife profile name (e.g. 'max', 'hours') or inline { expire: number } + * @param profile - cacheLife profile name (e.g. 'max', 'hours') or inline { expire: number }. + * Required in Next.js 16. Omitting it emits a deprecation warning and falls back + * to immediate hard expiration (same as updateTag). */ export async function revalidateTag( tag: string, profile?: string | { expire?: number }, ): Promise { + if (!profile) { + console.warn( + '"revalidateTag" without the second argument is now deprecated, add second argument of "max" ' + + 'or use "updateTag". See more info here: https://nextjs.org/docs/messages/revalidate-tag-single-arg', + ); + } + // Resolve the profile to durations for the handler - let durations: { expire?: number } | undefined; + let durations: TagRevalidationDurations | undefined; if (typeof profile === "string") { const resolved = cacheLifeProfiles[profile]; if (resolved) { diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 2f7ff30f3..8efd53d3d 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -418,14 +418,92 @@ describe("KVCacheHandler", () => { }); describe("tag invalidation", () => { - it("revalidateTag persists slash-based path invalidation markers", async () => { + it("revalidateTag persists slash-based path invalidation markers as JSON", async () => { await handler.revalidateTag(["/revalidate-tag-test", "_N_T_/revalidate-tag-test"]); - expect(store.get("__tag:/revalidate-tag-test")).toMatch(/^\d+$/); - expect(store.get("__tag:_N_T_/revalidate-tag-test")).toMatch(/^\d+$/); + // New format: JSON object with { expired: } for hard invalidation + const raw1 = store.get("__tag:/revalidate-tag-test"); + const raw2 = store.get("__tag:_N_T_/revalidate-tag-test"); + expect(raw1).not.toBeNull(); + expect(raw2).not.toBeNull(); + const parsed1 = JSON.parse(raw1!); + const parsed2 = JSON.parse(raw2!); + expect(typeof parsed1.expired).toBe("number"); + expect(typeof parsed2.expired).toBe("number"); + // No stale field for hard invalidation (no profile) + expect(parsed1.stale).toBeUndefined(); + expect(parsed2.stale).toBeUndefined(); }); - it("slash-based path tags invalidate persisted APP_PAGE entries", async () => { + it("revalidateTag with profile persists stale+expired fields (SWR)", async () => { + // Ported from Next.js FileSystemCache behaviour: profile-based revalidation + // sets both stale (immediate) and expired (stale + expire window). + const beforeMs = Date.now(); + await handler.revalidateTag("swr-tag", { expire: 3600 }); + const afterMs = Date.now(); + + const raw = store.get("__tag:swr-tag"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(typeof parsed.stale).toBe("number"); + expect(typeof parsed.expired).toBe("number"); + // stale should be approximately now + expect(parsed.stale).toBeGreaterThanOrEqual(beforeMs); + expect(parsed.stale).toBeLessThanOrEqual(afterMs + 10); + // expired should be stale + 3600 seconds + expect(parsed.expired).toBeCloseTo(parsed.stale + 3600 * 1000, -2); + }); + + it("revalidateTag with profile returns stale entry (SWR), not null", async () => { + // Ported from Next.js FileSystemCache: profile-based revalidateTag marks entries + // as stale for SWR rather than hard-deleting them. + const entryTime = Date.now() - 1000; // written 1s ago + + store.set( + "cache:swr-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

stale

", pageData: {}, status: 200 }, + tags: ["swr-profile-tag"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + + // Invalidate with a 1-hour SWR window + await handler.revalidateTag("swr-profile-tag", { expire: 3600 }); + + // Entry should be returned as stale (not null) + const result = await handler.get("swr-page"); + expect(result).not.toBeNull(); + expect(result!.cacheState).toBe("stale"); + expect(result!.value).not.toBeNull(); + }); + + it("revalidateTag with expired SWR window causes hard miss", async () => { + // When the expire window has already elapsed, entry must be a hard miss. + const entryTime = 1000; // very old entry + const staleAt = 2000; // stale marked after entry + const expiredAt = 3000; // expire window already passed (Date.now() >> 3000) + + store.set( + "cache:expired-swr-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

expired

", pageData: {}, status: 200 }, + tags: ["expired-swr-tag"], + lastModified: entryTime, + revalidateAt: null, + }), + ); + // Manually set a tag entry where both stale and expired are in the past + store.set("__tag:expired-swr-tag", JSON.stringify({ stale: staleAt, expired: expiredAt })); + + const result = await handler.get("expired-swr-page"); + expect(result).toBeNull(); + expect(kv.delete).toHaveBeenCalledWith("cache:expired-swr-page"); + }); + + it("slash-based path tags invalidate persisted APP_PAGE entries (legacy plain-timestamp format)", async () => { + // Backward compat: old plain-timestamp format (String(ms)) still causes hard miss. const entryTime = 1000; const invalidatedTime = 2000; @@ -769,10 +847,13 @@ describe("KVCacheHandler", () => { expect(kv.get).toHaveBeenCalledWith("__tag:t3"); }); - it("NaN tag timestamp in local cache treated as invalidation", async () => { + it("unparseable tag value in KV is ignored (not treated as invalidation)", async () => { + // With the new JSON tag format, a completely unrecognizable value (neither valid + // JSON nor a legacy numeric timestamp) is treated as "no invalidation" rather + // than causing a hard miss. This is the safe default — don't evict on corrupt data. const entryTime = 1000; - // Put a non-numeric tag value in KV + // Put a non-numeric, non-JSON tag value in KV store.set("__tag:bad-tag", "not-a-number"); store.set( @@ -785,29 +866,9 @@ describe("KVCacheHandler", () => { }), ); - // First get() — fetches from KV, gets NaN, caches it, returns null - const result1 = await handler.get("nan-page"); - expect(result1).toBeNull(); - - kv.get.mockClear(); - - // Re-store the entry (it was deleted by the first get) - store.set( - "cache:nan-page", - JSON.stringify({ - value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, - tags: ["bad-tag"], - lastModified: entryTime, - revalidateAt: null, - }), - ); - - // Second get() — NaN is in local cache, should still treat as invalidation - const result2 = await handler.get("nan-page"); - expect(result2).toBeNull(); - - // kv.get: 1 for entry, 0 for tag (NaN was cached locally) - expect(kv.get).toHaveBeenCalledTimes(1); + // get() should NOT be invalidated — corrupted tag value is ignored + const result = await handler.get("nan-page"); + expect(result).not.toBeNull(); }); it("resetRequestCache() forces tags to be re-fetched from KV", async () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 11cad4425..c07a3ce7f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1445,12 +1445,114 @@ describe("next/cache shim", () => { await revalidateTag("my-tag", "hours"); await revalidateTag("my-tag", { expire: 3600 }); - // Should still work without profile (deprecated single-arg form) + // Should still work without profile (deprecated single-arg form — emits a warning) await revalidateTag("my-tag"); setCacheHandler(new MemoryCacheHandler()); }); + it("revalidateTag emits deprecation warning when called without profile", async () => { + // Ported from Next.js: packages/next/src/server/web/spec-extension/revalidate.ts + // The single-argument form is deprecated in Next.js 16 and emits a console.warn. + const { revalidateTag, setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + + setCacheHandler(new MemoryCacheHandler()); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await revalidateTag("some-tag"); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toMatch(/deprecated|second argument|max/i); + + warnSpy.mockRestore(); + setCacheHandler(new MemoryCacheHandler()); + }); + + it("revalidateTag with profile returns stale entry (SWR) instead of hard miss", async () => { + // Ported from Next.js behaviour: when a profile is provided, revalidateTag marks + // entries as stale (for SWR background revalidation) rather than hard-deleting them. + // The entry should be returned with cacheState="stale" until expire elapses. + const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); + + const handler = new MemoryCacheHandler(); + + await handler.set( + "swr-entry", + { + kind: "FETCH", + data: { headers: {}, body: '"stale-value"', url: "test" }, + tags: ["my-swr-tag"], + revalidate: 3600, + }, + { tags: ["my-swr-tag"] }, + ); + + // Before invalidation: entry is fresh + let result = await handler.get("swr-entry"); + expect(result).not.toBeNull(); + expect(result!.cacheState).toBeUndefined(); + + // Revalidate with a profile that has a long expire window (SWR behaviour) + await handler.revalidateTag("my-swr-tag", { expire: 3600 }); + + // After revalidation: entry should be returned as stale (not null) for SWR + result = await handler.get("swr-entry"); + expect(result).not.toBeNull(); + expect(result!.cacheState).toBe("stale"); + expect(result!.value).not.toBeNull(); + }); + + it("revalidateTag with expire=0 causes hard miss (no SWR window)", async () => { + // expire=0 means the SWR window has already closed — treated as hard expiration. + const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); + + const handler = new MemoryCacheHandler(); + + await handler.set( + "expired-swr-entry", + { + kind: "FETCH", + data: { headers: {}, body: '"will-expire"', url: "test" }, + tags: ["expire-tag"], + revalidate: 3600, + }, + { tags: ["expire-tag"] }, + ); + + // Revalidate with expire=0 — equivalent to immediate hard expiration + await handler.revalidateTag("expire-tag", { expire: 0 }); + + // expire=0 should behave like no-profile (hard miss) + const result = await handler.get("expired-swr-entry"); + expect(result).toBeNull(); + }); + + it("revalidateTag without profile is a hard miss (no SWR)", async () => { + // Ported from Next.js: no-profile revalidateTag is immediate hard expiration. + // The entry must not be returned at all (not even as stale). + const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); + + const handler = new MemoryCacheHandler(); + + await handler.set( + "hard-miss-entry", + { + kind: "FETCH", + data: { headers: {}, body: '"cached"', url: "test" }, + tags: ["hard-tag"], + revalidate: 3600, + }, + { tags: ["hard-tag"] }, + ); + + // Without profile — hard invalidation + await handler.revalidateTag("hard-tag"); + + const result = await handler.get("hard-miss-entry"); + expect(result).toBeNull(); + }); + it("exports updateTag function (Next.js 16)", async () => { const mod = await import("../packages/vinext/src/shims/cache.js"); expect(typeof mod.updateTag).toBe("function"); From f204a9be9e5389d89444c2ecd23615149ecd8e43 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 10:34:30 +0100 Subject: [PATCH 2/3] refactor(cache): address bonk review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../vinext/src/cloudflare/kv-cache-handler.ts | 8 +++++++ packages/vinext/src/shims/cache.ts | 19 ++++++++++------- packages/vinext/src/shims/next-shims.d.ts | 21 ++++++++++++++++--- tests/shims.test.ts | 4 +++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 050ca9940..517521d99 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -576,6 +576,14 @@ function parseKVTagEntry(raw: string | null): KVTagEntry { * - `"expired"`: hard miss — entry must not be served. * - `"stale"`: SWR — entry may be served stale while background regen runs. * - `"fresh"`: no invalidation — entry is still valid. + * + * DELIBERATE DIVERGENCE FROM NEXT.JS: Next.js uses strict `>` for both + * comparisons (see tags-manifest.external.ts: areTagsExpired/areTagsStale). + * We use `>=` to handle same-millisecond set()+revalidateTag() calls. With + * strict `>`, an entry written at T and invalidated at T would not be + * considered expired — a subtle stale-serve bug. The `>=` form is strictly + * safer: it is impossible for a cache entry to be newer than its own + * invalidation event, so no valid fresh entry is ever incorrectly evicted. */ function checkTagInvalidation( tagEntry: KVTagEntry, diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 98086dc3c..704313243 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -251,15 +251,23 @@ export class MemoryCacheHandler implements CacheHandler { // For each tag on the entry: // - If `expired` is set and `expired >= entry.lastModified` and `expired <= now`: // hard miss (the tag was invalidated without a profile, or the SWR window - // has itself elapsed). Delete the entry and return null. The >= handles - // same-millisecond set()+revalidateTag() calls. + // has itself elapsed). Delete the entry and return null. // - If `stale` is set and `stale >= entry.lastModified`: // serve stale (profile-based SWR). The entry is still usable; caller will - // trigger background revalidation. The >= handles same-millisecond calls. + // trigger background revalidation. // // Note: the stale check intentionally comes AFTER the expired check, so that // an entry that has both stale and expired set (profile-based revalidation) // is correctly evicted once the expire window has passed. + // + // DELIBERATE DIVERGENCE FROM NEXT.JS: Next.js uses strict `>` for both + // comparisons (see tags-manifest.external.ts: areTagsExpired/areTagsStale). + // We use `>=` to handle same-millisecond set()+revalidateTag() calls, which + // are common in tests and can occur in production under fast execution. + // With strict `>`, an entry written at time T and invalidated at time T would + // not be considered expired — a subtle stale-serve bug. The `>=` form is + // strictly safer: it is impossible for a cache entry to be newer than its + // own invalidation event, so no valid fresh entry is ever incorrectly evicted. const now = Date.now(); let isTagStale = false; @@ -359,13 +367,10 @@ export class MemoryCacheHandler implements CacheHandler { const now = Date.now(); for (const tag of tagList) { - const existing = this.tagManifest.get(tag) ?? {}; - if (durations && durations.expire !== undefined && durations.expire > 0) { // Profile-based SWR: mark stale immediately (triggers background regen) // and set an absolute expire time (after which it's a hard miss). this.tagManifest.set(tag, { - ...existing, stale: now, expired: now + durations.expire * 1000, }); @@ -374,8 +379,6 @@ export class MemoryCacheHandler implements CacheHandler { // Set expired=now so the next get() on any entry with this tag is a hard miss. // The >= check in get() ensures same-millisecond set()+revalidateTag() is invalidated. this.tagManifest.set(tag, { - ...existing, - stale: undefined, expired: now, }); } diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index d5c7cdf36..8348869da 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -422,6 +422,18 @@ declare module "next/font/local" { } declare module "next/cache" { + /** + * Controls stale-while-revalidate behaviour when calling `revalidateTag` + * with a profile. Mirrors the Next.js 16 `TagRevalidationDurations` shape. + * + * - `expire`: seconds until tagged entries become a hard miss. When omitted + * (or 0), revalidation is immediate (no SWR window). + */ + export interface TagRevalidationDurations { + /** Seconds until tagged entries are hard-expired (forced cache miss). */ + expire?: number; + } + export interface CacheHandler { get(key: string, ctx?: Record): Promise; set( @@ -429,7 +441,7 @@ declare module "next/cache" { data: IncrementalCacheValue | null, ctx?: Record, ): Promise; - revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise; + revalidateTag(tags: string | string[], durations?: TagRevalidationDurations): Promise; resetRequestCache?(): void; } @@ -478,13 +490,16 @@ declare module "next/cache" { data: IncrementalCacheValue | null, ctx?: Record, ): Promise; - revalidateTag(tags: string | string[], durations?: { expire?: number }): Promise; + revalidateTag(tags: string | string[], durations?: TagRevalidationDurations): Promise; resetRequestCache(): void; } export function setCacheHandler(handler: CacheHandler): void; export function getCacheHandler(): CacheHandler; - export function revalidateTag(tag: string, profile?: string | { expire?: number }): Promise; + export function revalidateTag( + tag: string, + profile?: string | TagRevalidationDurations, + ): Promise; export function revalidatePath(path: string, type?: "page" | "layout"): Promise; export function updateTag(tag: string): Promise; export function refresh(): void; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index c07a3ce7f..8b6403790 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1463,7 +1463,9 @@ describe("next/cache shim", () => { await revalidateTag("some-tag"); expect(warnSpy).toHaveBeenCalled(); - expect(warnSpy.mock.calls[0][0]).toMatch(/deprecated|second argument|max/i); + expect(warnSpy.mock.calls[0][0]).toMatch( + /"revalidateTag" without the second argument is now deprecated/, + ); warnSpy.mockRestore(); setCacheHandler(new MemoryCacheHandler()); From cc43b1748f47683d149771e307abef17043404fa Mon Sep 17 00:00:00 2001 From: James Date: Sun, 29 Mar 2026 19:56:49 +0100 Subject: [PATCH 3/3] fix(cache): match Next.js updateTags branch logic for durations without expire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../vinext/src/cloudflare/kv-cache-handler.ts | 18 +++-- packages/vinext/src/shims/cache.ts | 22 +++--- tests/kv-cache-handler.test.ts | 67 +++++++++++++++++++ tests/shims.test.ts | 36 +++++++++- 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/cloudflare/kv-cache-handler.ts b/packages/vinext/src/cloudflare/kv-cache-handler.ts index 517521d99..70af44164 100644 --- a/packages/vinext/src/cloudflare/kv-cache-handler.ts +++ b/packages/vinext/src/cloudflare/kv-cache-handler.ts @@ -401,17 +401,21 @@ export class KVCacheHandler implements CacheHandler { // Build the KVTagEntry payload based on whether a profile was provided. // + // Matches Next.js default.ts updateTags exactly: + // // - No profile (hard invalidation): { expired: now } // Entries with lastModified <= expired (and expired <= now at get time) are a hard miss. // - // - Profile with expire (SWR): { stale: now, expired: now + expire * 1000 } - // Entries are served stale until `expired` is reached, then become a hard miss. + // - Profile (any durations object): stale is ALWAYS set. + // - durations = {} → { stale: now } (SWR, no hard expiry) + // - durations = { expire: N } → { stale: now, expired: now + N*1000 } + // - durations = { expire: 0 } → { stale: now, expired: now } (immediate hard miss) let tagEntry: KVTagEntry; - if (durations && durations.expire !== undefined && durations.expire > 0) { - tagEntry = { - stale: now, - expired: now + durations.expire * 1000, - }; + if (durations) { + tagEntry = { stale: now }; + if (durations.expire !== undefined) { + tagEntry.expired = now + durations.expire * 1000; + } } else { tagEntry = { expired: now }; } diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 704313243..13a245755 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -367,15 +367,21 @@ export class MemoryCacheHandler implements CacheHandler { const now = Date.now(); for (const tag of tagList) { - if (durations && durations.expire !== undefined && durations.expire > 0) { - // Profile-based SWR: mark stale immediately (triggers background regen) - // and set an absolute expire time (after which it's a hard miss). - this.tagManifest.set(tag, { - stale: now, - expired: now + durations.expire * 1000, - }); + if (durations) { + // Profile-based invalidation: always mark stale immediately (triggers SWR). + // Matches Next.js default.ts updateTags: stale is ALWAYS set when durations + // is truthy, and expired is only set when expire is explicitly provided. + // + // durations = {} → { stale: now } (SWR, no hard expiry) + // durations = { expire: N }→ { stale: now, expired: now + N*1000 } + // durations = { expire: 0 }→ { stale: now, expired: now } (immediate hard miss) + const entry: TagManifestEntry = { stale: now }; + if (durations.expire !== undefined) { + entry.expired = now + durations.expire * 1000; + } + this.tagManifest.set(tag, entry); } else { - // No profile (or expire=0): immediate hard expiration. + // No profile (updateTag / revalidateTag without second arg): immediate hard expiration. // Set expired=now so the next get() on any entry with this tag is a hard miss. // The >= check in get() ensures same-millisecond set()+revalidateTag() is invalidated. this.tagManifest.set(tag, { diff --git a/tests/kv-cache-handler.test.ts b/tests/kv-cache-handler.test.ts index 8efd53d3d..fc83dd8d5 100644 --- a/tests/kv-cache-handler.test.ts +++ b/tests/kv-cache-handler.test.ts @@ -502,6 +502,73 @@ describe("KVCacheHandler", () => { expect(kv.delete).toHaveBeenCalledWith("cache:expired-swr-page"); }); + it("revalidateTag with expire=0 persists { stale, expired } and causes hard miss", async () => { + // expire=0 stores { stale: now, expired: now } (matching Next.js updateTags). + // Since expired <= now, get() treats it as a hard miss. + const beforeMs = Date.now(); + await handler.revalidateTag("expire-zero-tag", { expire: 0 }); + const afterMs = Date.now(); + + const raw = store.get("__tag:expire-zero-tag"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + // Both stale and expired must be set (stale is always set when durations is truthy) + expect(typeof parsed.stale).toBe("number"); + expect(typeof parsed.expired).toBe("number"); + expect(parsed.stale).toBeGreaterThanOrEqual(beforeMs); + expect(parsed.stale).toBeLessThanOrEqual(afterMs + 10); + // expired = now + 0*1000 = now, so expired === stale + expect(parsed.expired).toBeGreaterThanOrEqual(beforeMs); + expect(parsed.expired).toBeLessThanOrEqual(afterMs + 10); + + // Verify get() returns null (hard miss because expired <= now) + store.set( + "cache:expire-zero-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

hi

", pageData: {}, status: 200 }, + tags: ["expire-zero-tag"], + lastModified: beforeMs - 1000, // written before the revalidation + revalidateAt: null, + }), + ); + const result = await handler.get("expire-zero-page"); + expect(result).toBeNull(); + }); + + it("revalidateTag with empty durations ({}) persists { stale } only and returns stale entry", async () => { + // Ported from Next.js default.ts updateTags: when durations is truthy but has no + // `expire` field, only `stale` is written to the manifest. The entry is served stale + // (SWR with no hard expiry) — it is never hard-expired by this call alone. + // Ref: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/default.ts + const beforeMs = Date.now(); + await handler.revalidateTag("stale-only-tag", {}); + const afterMs = Date.now(); + + const raw = store.get("__tag:stale-only-tag"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + // Only stale should be set — no expired field + expect(typeof parsed.stale).toBe("number"); + expect(parsed.expired).toBeUndefined(); + expect(parsed.stale).toBeGreaterThanOrEqual(beforeMs); + expect(parsed.stale).toBeLessThanOrEqual(afterMs + 10); + + // Verify get() returns the entry as stale (not null) + store.set( + "cache:stale-only-page", + JSON.stringify({ + value: { kind: "PAGES", html: "

stale

", pageData: {}, status: 200 }, + tags: ["stale-only-tag"], + lastModified: beforeMs - 1000, // written before the revalidation + revalidateAt: null, + }), + ); + const result = await handler.get("stale-only-page"); + expect(result).not.toBeNull(); + expect(result!.cacheState).toBe("stale"); + expect(result!.value).not.toBeNull(); + }); + it("slash-based path tags invalidate persisted APP_PAGE entries (legacy plain-timestamp format)", async () => { // Backward compat: old plain-timestamp format (String(ms)) still causes hard miss. const entryTime = 1000; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 8b6403790..33fd0945c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1506,7 +1506,9 @@ describe("next/cache shim", () => { }); it("revalidateTag with expire=0 causes hard miss (no SWR window)", async () => { - // expire=0 means the SWR window has already closed — treated as hard expiration. + // expire=0 stores { stale: now, expired: now } — since expired <= now the entry + // is a hard miss on the next get(). Functionally identical to no-profile, but + // the stored manifest shape includes stale (matching Next.js updateTags behaviour). const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); const handler = new MemoryCacheHandler(); @@ -1522,7 +1524,7 @@ describe("next/cache shim", () => { { tags: ["expire-tag"] }, ); - // Revalidate with expire=0 — equivalent to immediate hard expiration + // Revalidate with expire=0 — expired = now, so hard miss on get() await handler.revalidateTag("expire-tag", { expire: 0 }); // expire=0 should behave like no-profile (hard miss) @@ -1530,6 +1532,36 @@ describe("next/cache shim", () => { expect(result).toBeNull(); }); + it("revalidateTag with empty durations ({}) returns stale entry (SWR, no expire window)", async () => { + // Ported from Next.js default.ts updateTags: when durations is truthy but has no + // `expire` field, only `stale` is set in the manifest. The entry is served stale + // (SWR with no hard expiry window) — it is NEVER hard-expired by this call alone. + // Ref: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/cache-handlers/default.ts + const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); + + const handler = new MemoryCacheHandler(); + + await handler.set( + "stale-only-entry", + { + kind: "FETCH", + data: { headers: {}, body: '"stale-value"', url: "test" }, + tags: ["stale-only-tag"], + revalidate: 3600, + }, + { tags: ["stale-only-tag"] }, + ); + + // Empty durations object — profile provided but no expire field + await handler.revalidateTag("stale-only-tag", {}); + + // Entry should be returned as stale (not null) because no hard expiry is set + const result = await handler.get("stale-only-entry"); + expect(result).not.toBeNull(); + expect(result!.cacheState).toBe("stale"); + expect(result!.value).not.toBeNull(); + }); + it("revalidateTag without profile is a hard miss (no SWR)", async () => { // Ported from Next.js: no-profile revalidateTag is immediate hard expiration. // The entry must not be returned at all (not even as stale).