Repository: vercel/next.js
Pull request: fix: revalidateTag with profile should not trigger client cache invalidation

## 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)

Changed files:
filename: packages/next/src/server/app-render/action-handler.ts
patch: @@ -160,7 +160,14 @@ function addRevalidationHeader(
   // TODO-APP: Currently paths are treated as tags, so the second element of the tuple
   // is always empty.
 
-  const isTagRevalidated = workStore.pendingRevalidatedTags?.length ? 1 : 0
+  // Only count tags without a profile (updateTag) as requiring client cache invalidation
+  // Tags with a profile (revalidateTag) use stale-while-revalidate and shouldn't
+  // trigger immediate client-side cache invalidation
+  const isTagRevalidated = workStore.pendingRevalidatedTags?.some(
+    (item) => item.profile === undefined
+  )
+    ? 1
+    : 0
   const isCookieRevalidated = getModifiedCookieValues(
     requestStore.mutableCookies
   ).length

filename: test/e2e/app-dir/use-cache/app/(partially-static)/revalidate-tag-no-refresh/page.tsx
patch: @@ -0,0 +1,34 @@
+import { revalidateTag, cacheTag, cacheLife } from 'next/cache'
+
+async function getCachedRandomNumber() {
+  'use cache'
+  cacheTag('revalidate-tag-test')
+  cacheLife('max')
+
+  // This should change on each cache refresh
+  return Math.random().toString()
+}
+
+export default async function Page() {
+  const randomNumber = await getCachedRandomNumber()
+
+  return (
+    <div>
+      <p id="random">{randomNumber}</p>
+      <form>
+        <button
+          id="revalidate-tag-with-profile"
+          formAction={async () => {
+            'use server'
+            // This should NOT cause immediate client refresh
+            // The client should continue showing stale data
+            // Fresh data should only appear on next navigation/refresh
+            revalidateTag('revalidate-tag-test', 'max')
+          }}
+        >
+          Revalidate Tag (background)
+        </button>
+      </form>
+    </div>
+  )
+}

filename: test/e2e/app-dir/use-cache/use-cache.test.ts
patch: @@ -495,6 +495,7 @@ describe('use-cache', () => {
           '/react-cache',
           '/referential-equality',
           '/revalidate-and-redirect/redirect',
+          '/revalidate-tag-no-refresh',
           '/rsc-payload',
           '/static-class-method',
           withCacheComponents && '/unhandled-promise-regression',
@@ -679,6 +680,41 @@ describe('use-cache', () => {
         'cache-handler set fetch cache https://next-data-api-endpoint.vercel.app/api/random?no-store'
       )
     })
+
+    // Test for revalidateTag with profile (stale-while-revalidate)
+    // This should NOT cause immediate client refresh - only updateTag should do that
+    it('should NOT update immediately after revalidateTag with profile (stale-while-revalidate)', async () => {
+      const browser = await next.browser('/revalidate-tag-no-refresh')
+      const initial = await browser.elementByCss('#random').text()
+
+      console.log('[Test] Initial value:', initial)
+
+      // Click 1: revalidateTag with profile - should NOT cause immediate refresh
+      await browser.elementByCss('#revalidate-tag-with-profile').click()
+      // Wait for the action to complete
+      await new Promise((r) => setTimeout(r, 1000))
+      const afterClick1 = await browser.elementByCss('#random').text()
+      console.log('[Test] After click 1:', afterClick1)
+      expect(afterClick1).toBe(initial) // No change - stale-while-revalidate
+
+      // Click 2: Same as click 1 - should still show stale data
+      await browser.elementByCss('#revalidate-tag-with-profile').click()
+      await new Promise((r) => setTimeout(r, 1000))
+      const afterClick2 = await browser.elementByCss('#random').text()
+      console.log('[Test] After click 2:', afterClick2)
+      expect(afterClick2).toBe(initial) // Still no change
+
+      // Click 3: Same as before - should still show stale data (not data from click 1)
+      await browser.elementByCss('#revalidate-tag-with-profile').click()
+      await new Promise((r) => setTimeout(r, 1000))
+      const afterClick3 = await browser.elementByCss('#random').text()
+      console.log('[Test] After click 3:', afterClick3)
+      expect(afterClick3).toBe(initial) // Still no change - no read-your-own-writes
+
+      // The key assertion: after 3 clicks, the value should still be the same
+      // This proves revalidateTag with profile does NOT cause read-your-own-writes
+      // (Unlike the bug where click 3 would show a different stale value)
+    })
   }
 
   it('should override fetch with cookies/auth in use cache properly', async () => {