cloudflare / cloudflare/vinext
Cache: discard only 'use cache' entries that predate a tag revalidation (timestamp-compare, not tag membership)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`5da1c1a`](https://github.com/vercel/next.js/commit/5da1c1ae03d2ee39c27a2d6d8807c573c46b37f9)
**PR:** [#96726](https://github.com/vercel/next.js/pull/96726)
## What changed
Fixes over-eager cache discarding after a tag revalidation. Calling `updateTag()` in a server action made **every** later read of a cache carrying that tag regenerate for the remainder of the request — including reads of an entry that was generated *after* the invalidation and therefore already reflected it. Two sequential reads of the same `'use cache'` function during the re-render produced two different values within a single render, and each repeated the work.
The old `isRecentlyRevalidatedTag` only asked whether a tag appeared in `pendingRevalidatedTags`, with no notion of *when* the revalidation happened. That array lives for the whole `WorkStore`, which spans a server action and the render that follows it, so once a tag was in it, every entry carrying that tag looked stale regardless of when it was produced.
### Mechanism (from the diff)
- Each pending revalidated tag now records a `revalidatedAt` timestamp taken from the same clock as `CacheEntry.timestamp` (`performance.timeOrigin + performance.now()`).
- `revalidate()` in `web/spec-extension/revalidate.ts` stamps `revalidatedAt` when pushing a tag, and **moves it forward** when the same tag is revalidated again (the latest revalidation decides staleness).
- `isRecentlyRevalidatedTag(tag, workStore)` is renamed to `isRevalidatedAfter(tag, entryTimestamp, workStore)` and now reports an entry as stale **only when the revalidation is newer than the entry**:
```ts
if (
pendingRevalidatedTags?.some(
(item) => item.tag === tag && item.revalidatedAt > entryTimestamp
)
) {
return true
}
```
- `CacheEntry.timestamp` is captured *before* a fill begins, so a fill straddling a revalidation is still discarded (conservative for a body that may have read pre-invalidation data).
- `previouslyRevalidatedTags` (forwarded from an earlier request by a redirecting server action) carry no timestamp of their own, so the work store now records `requestStartTime` and treats them as revalidated at that instant. Entries predating the request are discarded; entries generated during it survive:
```ts
if (entryTimestamp <= requestStartTime && previouslyRevalidatedTags.includes(tag)) {
return true
}
```
- The hang-detection probe worker and the dev validation worker take `requestStartTime` from the request they serve (not the clock when they start), so they don't date every outer-request entry as older than their own start.
- Applied in both `shouldDiscardCacheEntry` and the Resume Data Cache (RDC) discard check in `use-cache-wrapper.ts`.
New `WorkStore` fields: `pendingRevalidatedTags[].revalidatedAt: number` and `readonly requestStartTime: number`.
## Impact on vinext
vinext reimplements `'use cache'` / tag revalidation and ISR. Its tag-invalidation logic must not discard cache entries that were generated *after* the invalidation in the same request. Otherwise a `updateTag()` (or `revalidateTag()`) in a server action followed by re-reads during the re-render will regenerate every tagged cache repeatedly and can yield inconsistent values within one render.
What to check/do:
1. **Stamp each tag revalidation with a timestamp** on the same clock as cache-entry timestamps. Re-revalidating a tag should move the timestamp forward.
2. **Compare entry timestamp vs revalidation timestamp**, not mere tag membership. Discard an entry only when the tag's `revalidatedAt` is newer than the entry's `timestamp`.
3. **Capture the entry timestamp before the fill begins** so a fill that straddles a revalidation is still discarded (conservative).
4. **Handle tags forwarded from a prior request** (redirecting server actions): they have no timestamp, so treat them as revalidated at request start. Record a `requestStartTime` and only discard entries created at/before it.
5. **Apply consistently** to both the direct cache-handler read path and any resume-data-cache read path.
6. **Port the regression test.** Next.js added an `action-dedupe` fixture: read a tagged cache twice inside a server action, revalidate the tag, then read it twice again during the re-render, asserting each pair shares a value and the two pairs differ.
## Related
- #2812 — Cache Components: `'use cache'` called after prerender aborts must error
- #2002 — Cache Components: exclude short-stale `'use cache'` entries from prerenders
- #1936 — Dev cache handler: serve stale `'use cache'` entries until `expire`
- #1937 — Persist `'use cache: private'` entries in dev
Contributor guide
Research direction
Use Next.js's web/spec-extension/revalidate.ts and use-cache-wrapper.ts as references, then locate vinext's tag invalidation, direct cache-handler, and resume-data-cache paths. Run or port the action-dedupe fixture: verify reads before and after revalidation each reuse one value, while the two pairs differ; also cover request-start timestamps and fills that cross revalidation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, typescript
- Domain
- backend, performance, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100