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

Open
opened 2026-05-06 13:10:06 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/709
Author: @james-elicx
Created: 3/29/2026
Status: 🔄 Open

Base: mainHead: opencode/calm-meadow


📝 Commits (5)

  • 0c1c468 feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model
  • f204a9b refactor(cache): address bonk review comments
  • cc43b17 fix(cache): match Next.js updateTags branch logic for durations without expire
  • 1a32999 fix(cache): reuse now variable in MemoryCacheHandler.get for time-based expiry check
  • 26f740a chore: merge main and fix interface→type lint violations from #716

📊 Changes

5 files changed (+629 additions, -69 deletions)

View changed files

📝 packages/vinext/src/cloudflare/kv-cache-handler.ts (+169 -20)
📝 packages/vinext/src/shims/cache.ts (+148 -16)
📝 packages/vinext/src/shims/next-shims.d.ts (+18 -3)
📝 tests/kv-cache-handler.test.ts (+157 -29)
📝 tests/shims.test.ts (+137 -1)

📄 Description

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

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/709 **Author:** [@james-elicx](https://github.com/james-elicx) **Created:** 3/29/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `opencode/calm-meadow` --- ### 📝 Commits (5) - [`0c1c468`](https://github.com/cloudflare/vinext/commit/0c1c46891e6ddf89e3d722f25c61334a3ebd89d0) feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model - [`f204a9b`](https://github.com/cloudflare/vinext/commit/f204a9be9e5389d89444c2ecd23615149ecd8e43) refactor(cache): address bonk review comments - [`cc43b17`](https://github.com/cloudflare/vinext/commit/cc43b1748f47683d149771e307abef17043404fa) fix(cache): match Next.js updateTags branch logic for durations without expire - [`1a32999`](https://github.com/cloudflare/vinext/commit/1a329995f3c352ff1b0d13bfa28bcf09bc0de643) fix(cache): reuse now variable in MemoryCacheHandler.get for time-based expiry check - [`26f740a`](https://github.com/cloudflare/vinext/commit/26f740a9712295e92a278a37caa41435ebf3a59b) chore: merge main and fix interface→type lint violations from #716 ### 📊 Changes **5 files changed** (+629 additions, -69 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/cloudflare/kv-cache-handler.ts` (+169 -20) 📝 `packages/vinext/src/shims/cache.ts` (+148 -16) 📝 `packages/vinext/src/shims/next-shims.d.ts` (+18 -3) 📝 `tests/kv-cache-handler.test.ts` (+157 -29) 📝 `tests/shims.test.ts` (+137 -1) </details> ### 📄 Description ## 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 format** — `KVCacheHandler` 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` --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#788
No description provided.