Skip to content

Commit fa56f2c

Browse files
feedthejimclaude
andauthored
fix: revalidateTag with profile should not trigger client cache invalidation (#88069)
## Summary Fixes a bug where calling `revalidateTag(tag, profile)` incorrectly triggers client-side cache invalidation, causing read-your-own-writes behavior that violates stale-while-revalidate semantics. ### The Problem When `revalidateTag('tag', 'max')` is called in a server action: 1. The tag is correctly marked for stale-while-revalidate 2. BUT the `x-action-revalidated` header is incorrectly set to `1` 3. This triggers client-side cache invalidation via `revalidateEntireCache()` 4. The client navigates and may display stale data from background revalidation This caused the confusing behavior where: - Click 1: Nothing happens (correct) - Click 2: Nothing happens (correct) - Click 3: Data changes to a stale value from click 1 (incorrect!) ### The Fix In `addRevalidationHeader`, change the `isTagRevalidated` calculation to only count tags **without** a profile (from `updateTag`). Tags with a profile (from `revalidateTag`) should follow stale-while-revalidate semantics and not trigger immediate client-side cache invalidation. ```typescript // Before: const isTagRevalidated = workStore.pendingRevalidatedTags?.length ? 1 : 0 // After: const isTagRevalidated = workStore.pendingRevalidatedTags?.some( (item) => item.profile === undefined ) ? 1 : 0 ``` ### Expected Behavior After Fix | API | Profile | Header Set | Client Behavior | |-----|---------|------------|-----------------| | `updateTag(tag)` | `undefined` | ✅ Yes | Immediate refresh (read-your-own-writes) | | `revalidateTag(tag, 'max')` | `'max'` | ❌ No | Keep stale data (SWR) | | `revalidateTag(tag, { expire: 0 })` | `{ expire: 0 }` | ✅ Yes* | Immediate refresh | *For immediate expiration via `pathWasRevalidated` which is already handled correctly. ## Test Plan - [x] Added new test page at `test/e2e/app-dir/use-cache/app/(partially-static)/revalidate-tag-no-refresh/page.tsx` - [x] Added test case that verifies 3 clicks of `revalidateTag` with profile don't change the displayed value - [x] Verified existing `updateTag` test (`should update after revalidateTag correctly`) still passes - [x] Verified both tests pass with `pnpm test-start` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 8aa9feb commit fa56f2c

File tree

3 files changed

+78
-1
lines changed

3 files changed

+78
-1
lines changed

packages/next/src/server/app-render/action-handler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,14 @@ function addRevalidationHeader(
160160
// TODO-APP: Currently paths are treated as tags, so the second element of the tuple
161161
// is always empty.
162162

163-
const isTagRevalidated = workStore.pendingRevalidatedTags?.length ? 1 : 0
163+
// Only count tags without a profile (updateTag) as requiring client cache invalidation
164+
// Tags with a profile (revalidateTag) use stale-while-revalidate and shouldn't
165+
// trigger immediate client-side cache invalidation
166+
const isTagRevalidated = workStore.pendingRevalidatedTags?.some(
167+
(item) => item.profile === undefined
168+
)
169+
? 1
170+
: 0
164171
const isCookieRevalidated = getModifiedCookieValues(
165172
requestStore.mutableCookies
166173
).length
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { revalidateTag, cacheTag, cacheLife } from 'next/cache'
2+
3+
async function getCachedRandomNumber() {
4+
'use cache'
5+
cacheTag('revalidate-tag-test')
6+
cacheLife('max')
7+
8+
// This should change on each cache refresh
9+
return Math.random().toString()
10+
}
11+
12+
export default async function Page() {
13+
const randomNumber = await getCachedRandomNumber()
14+
15+
return (
16+
<div>
17+
<p id="random">{randomNumber}</p>
18+
<form>
19+
<button
20+
id="revalidate-tag-with-profile"
21+
formAction={async () => {
22+
'use server'
23+
// This should NOT cause immediate client refresh
24+
// The client should continue showing stale data
25+
// Fresh data should only appear on next navigation/refresh
26+
revalidateTag('revalidate-tag-test', 'max')
27+
}}
28+
>
29+
Revalidate Tag (background)
30+
</button>
31+
</form>
32+
</div>
33+
)
34+
}

test/e2e/app-dir/use-cache/use-cache.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ describe('use-cache', () => {
495495
'/react-cache',
496496
'/referential-equality',
497497
'/revalidate-and-redirect/redirect',
498+
'/revalidate-tag-no-refresh',
498499
'/rsc-payload',
499500
'/static-class-method',
500501
withCacheComponents && '/unhandled-promise-regression',
@@ -679,6 +680,41 @@ describe('use-cache', () => {
679680
'cache-handler set fetch cache https://next-data-api-endpoint.vercel.app/api/random?no-store'
680681
)
681682
})
683+
684+
// Test for revalidateTag with profile (stale-while-revalidate)
685+
// This should NOT cause immediate client refresh - only updateTag should do that
686+
it('should NOT update immediately after revalidateTag with profile (stale-while-revalidate)', async () => {
687+
const browser = await next.browser('/revalidate-tag-no-refresh')
688+
const initial = await browser.elementByCss('#random').text()
689+
690+
console.log('[Test] Initial value:', initial)
691+
692+
// Click 1: revalidateTag with profile - should NOT cause immediate refresh
693+
await browser.elementByCss('#revalidate-tag-with-profile').click()
694+
// Wait for the action to complete
695+
await new Promise((r) => setTimeout(r, 1000))
696+
const afterClick1 = await browser.elementByCss('#random').text()
697+
console.log('[Test] After click 1:', afterClick1)
698+
expect(afterClick1).toBe(initial) // No change - stale-while-revalidate
699+
700+
// Click 2: Same as click 1 - should still show stale data
701+
await browser.elementByCss('#revalidate-tag-with-profile').click()
702+
await new Promise((r) => setTimeout(r, 1000))
703+
const afterClick2 = await browser.elementByCss('#random').text()
704+
console.log('[Test] After click 2:', afterClick2)
705+
expect(afterClick2).toBe(initial) // Still no change
706+
707+
// Click 3: Same as before - should still show stale data (not data from click 1)
708+
await browser.elementByCss('#revalidate-tag-with-profile').click()
709+
await new Promise((r) => setTimeout(r, 1000))
710+
const afterClick3 = await browser.elementByCss('#random').text()
711+
console.log('[Test] After click 3:', afterClick3)
712+
expect(afterClick3).toBe(initial) // Still no change - no read-your-own-writes
713+
714+
// The key assertion: after 3 clicks, the value should still be the same
715+
// This proves revalidateTag with profile does NOT cause read-your-own-writes
716+
// (Unlike the bug where click 3 would show a different stale value)
717+
})
682718
}
683719

684720
it('should override fetch with cookies/auth in use cache properly', async () => {

0 commit comments

Comments
 (0)