cloudflare / cloudflare/vinext

Cache: discard only 'use cache' entries that predate a tag revalidation (timestamp-compare, not tag membership)

Open
#2,819 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.