Skip to content

Commit f8da0c8

Browse files
committed
Fix: Server refresh() should not purge client cache
The server form of refresh() is used to re-render all the dynamic data on the current page. Unlike updateTag/revalidateTag, it does not affect any cached data, so the client cache should not evict any of its entries.
1 parent 6e25064 commit f8da0c8

File tree

11 files changed

+192
-53
lines changed

11 files changed

+192
-53
lines changed

packages/next/src/client/components/app-router-headers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ export const NEXT_IS_PRERENDER_HEADER = 'x-nextjs-prerender' as const
3434
export const NEXT_ACTION_NOT_FOUND_HEADER = 'x-nextjs-action-not-found' as const
3535
export const NEXT_REQUEST_ID_HEADER = 'x-nextjs-request-id' as const
3636
export const NEXT_HTML_REQUEST_ID_HEADER = 'x-nextjs-html-request-id' as const
37+
38+
// TODO: Should this include nextjs in the name, like the others?
39+
export const NEXT_ACTION_REVALIDATED_HEADER = 'x-action-revalidated' as const

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 28 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ import {
5454
navigate as navigateUsingSegmentCache,
5555
} from '../../segment-cache/navigation'
5656
import type { NormalizedSearch } from '../../segment-cache/cache-key'
57+
import {
58+
ActionDidNotRevalidate,
59+
ActionDidRevalidateDynamicOnly,
60+
ActionDidRevalidateStaticAndDynamic,
61+
type ActionRevalidationKind,
62+
} from '../../../../shared/lib/action-revalidation-kind'
5763

5864
const createFromFetch =
5965
createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch']
@@ -77,16 +83,12 @@ if (
7783
type FetchServerActionResult = {
7884
redirectLocation: URL | undefined
7985
redirectType: RedirectType | undefined
86+
revalidationKind: ActionRevalidationKind
8087
actionResult: ActionResult | undefined
8188
actionFlightData: NormalizedFlightData[] | string | undefined
8289
actionFlightDataRenderedSearch: NormalizedSearch | undefined
8390
actionFlightDataCouldBeIntercepted: boolean | undefined
8491
isPrerender: boolean
85-
revalidatedParts: {
86-
tag: boolean
87-
cookie: boolean
88-
paths: string[]
89-
}
9092
}
9193

9294
async function fetchServerAction(
@@ -160,19 +162,20 @@ async function fetchServerAction(
160162
}
161163

162164
const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER)
163-
let revalidatedParts: FetchServerActionResult['revalidatedParts']
165+
166+
let revalidationKind: ActionRevalidationKind = ActionDidNotRevalidate
164167
try {
165-
const revalidatedHeader = JSON.parse(
166-
res.headers.get('x-action-revalidated') || '[[],0,0]'
167-
)
168-
revalidatedParts = {
169-
paths: revalidatedHeader[0] || [],
170-
tag: !!revalidatedHeader[1],
171-
cookie: !!revalidatedHeader[2],
168+
const revalidationHeader = res.headers.get('x-action-revalidated')
169+
if (revalidationHeader) {
170+
const parsedKind = JSON.parse(revalidationHeader)
171+
if (
172+
parsedKind === ActionDidRevalidateStaticAndDynamic ||
173+
parsedKind === ActionDidRevalidateDynamicOnly
174+
) {
175+
revalidationKind = parsedKind
176+
}
172177
}
173-
} catch (e) {
174-
revalidatedParts = NO_REVALIDATED_PARTS
175-
}
178+
} catch {}
176179

177180
const redirectLocation = location
178181
? assignLocation(
@@ -239,17 +242,11 @@ async function fetchServerAction(
239242
actionFlightDataCouldBeIntercepted,
240243
redirectLocation,
241244
redirectType,
242-
revalidatedParts,
245+
revalidationKind,
243246
isPrerender,
244247
}
245248
}
246249

247-
const NO_REVALIDATED_PARTS = {
248-
paths: [],
249-
tag: false,
250-
cookie: false,
251-
}
252-
253250
/*
254251
* This reducer is responsible for calling the server action and processing any side-effects from the server action.
255252
* It does not mutate the state by itself but rather delegates to other reducers to do the actual mutation.
@@ -280,20 +277,15 @@ export function serverActionReducer(
280277

281278
return fetchServerAction(state, nextUrl, action).then(
282279
async ({
280+
revalidationKind,
283281
actionResult,
284282
actionFlightData: flightData,
285283
actionFlightDataRenderedSearch: flightDataRenderedSearch,
286284
actionFlightDataCouldBeIntercepted: flightDataCouldBeIntercepted,
287285
redirectLocation,
288286
redirectType,
289-
revalidatedParts,
290287
}) => {
291-
const actionDidRevalidateCache =
292-
revalidatedParts.paths.length > 0 ||
293-
revalidatedParts.tag ||
294-
revalidatedParts.cookie
295-
296-
if (actionDidRevalidateCache) {
288+
if (revalidationKind !== ActionDidNotRevalidate) {
297289
// Store whether this action triggered any revalidation
298290
// The action queue will use this information to potentially
299291
// trigger a refresh action if the action was discarded
@@ -302,7 +294,9 @@ export function serverActionReducer(
302294

303295
// If there was a revalidation, evict the entire prefetch cache.
304296
// TODO: Evict only segments with matching tags and/or paths.
305-
revalidateEntireCache(state.nextUrl, state.tree)
297+
if (revalidationKind === ActionDidRevalidateStaticAndDynamic) {
298+
revalidateEntireCache(state.nextUrl, state.tree)
299+
}
306300
}
307301

308302
if (redirectLocation !== undefined) {
@@ -341,7 +335,7 @@ export function serverActionReducer(
341335
// Did the action trigger a redirect?
342336
redirectLocation === undefined &&
343337
// Did the action revalidate any data?
344-
!actionDidRevalidateCache &&
338+
revalidationKind === ActionDidNotRevalidate &&
345339
// Did the server render new data?
346340
flightData === undefined
347341
) {
@@ -382,7 +376,8 @@ export function serverActionReducer(
382376

383377
// If the action triggered a revalidation of the cache, we should also
384378
// refresh all the dynamic data.
385-
const shouldRefreshDynamicData = actionDidRevalidateCache
379+
const shouldRefreshDynamicData =
380+
revalidationKind !== ActionDidNotRevalidate
386381

387382
// The server may have sent back new data. If so, we will perform a
388383
// "seeded" navigation that uses the data from the response.

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
NEXT_ROUTER_PREFETCH_HEADER,
1515
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
1616
NEXT_URL,
17+
NEXT_ACTION_REVALIDATED_HEADER,
1718
} from '../../client/components/app-router-headers'
1819
import {
1920
getAccessFallbackHTTPStatus,
@@ -63,6 +64,10 @@ import { executeRevalidates } from '../revalidation-utils'
6364
import { getRequestMeta } from '../request-meta'
6465
import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param'
6566
import { getServerModuleMap } from './manifests-singleton'
67+
import {
68+
ActionDidNotRevalidate,
69+
ActionDidRevalidateStaticAndDynamic,
70+
} from '../../shared/lib/action-revalidation-kind'
6671

6772
function formDataFromSearchQueryString(query: string) {
6873
const searchParams = new URLSearchParams(query)
@@ -141,9 +146,10 @@ function addRevalidationHeader(
141146
// client router cache as they may be stale. And if a path was revalidated, the
142147
// client needs to invalidate all subtrees below that path.
143148

144-
// To keep the header size small, we use a tuple of
145-
// [[revalidatedPaths], isTagRevalidated ? 1 : 0, isCookieRevalidated ? 1 : 0]
146-
// instead of a JSON object.
149+
// TODO: Currently we don't send the specific tags or paths to the client,
150+
// we just send a flag indicating that all the static data on the client
151+
// should be invalidated. In the future, this will likely be a Bloom filter
152+
// or bitmask of some kind.
147153

148154
// TODO-APP: Currently the prefetch cache doesn't have subtree information,
149155
// so we need to invalidate the entire cache if a path was revalidated.
@@ -157,10 +163,22 @@ function addRevalidationHeader(
157163
? 1
158164
: 0
159165

160-
res.setHeader(
161-
'x-action-revalidated',
162-
JSON.stringify([[], isTagRevalidated, isCookieRevalidated])
163-
)
166+
// First check if a tag, cookie, or path was revalidated.
167+
if (isTagRevalidated || isCookieRevalidated) {
168+
res.setHeader(
169+
NEXT_ACTION_REVALIDATED_HEADER,
170+
JSON.stringify(ActionDidRevalidateStaticAndDynamic)
171+
)
172+
} else if (
173+
// Check for refresh() actions. This will invalidate only the dynamic data.
174+
workStore.pathWasRevalidated !== undefined &&
175+
workStore.pathWasRevalidated !== ActionDidNotRevalidate
176+
) {
177+
res.setHeader(
178+
NEXT_ACTION_REVALIDATED_HEADER,
179+
JSON.stringify(workStore.pathWasRevalidated)
180+
)
181+
}
164182
}
165183

166184
/**
@@ -1137,7 +1155,9 @@ export async function handleAction({
11371155
// If the page was not revalidated, or if the action was forwarded
11381156
// from another worker, we can skip rendering the page.
11391157
skipPageRendering:
1140-
!workStore.pathWasRevalidated || actionWasForwarded,
1158+
workStore.pathWasRevalidated === undefined ||
1159+
workStore.pathWasRevalidated === ActionDidNotRevalidate ||
1160+
actionWasForwarded,
11411161
temporaryReferences,
11421162
}),
11431163
}
@@ -1170,7 +1190,9 @@ async function executeActionAndPrepareForRender<
11701190

11711191
// If the page was not revalidated, or if the action was forwarded from
11721192
// another worker, we can skip rendering the page.
1173-
skipPageRendering ||= !workStore.pathWasRevalidated
1193+
skipPageRendering ||=
1194+
workStore.pathWasRevalidated === undefined ||
1195+
workStore.pathWasRevalidated === ActionDidNotRevalidate
11741196

11751197
return { actionResult, skipPageRendering }
11761198
} finally {

packages/next/src/server/app-render/work-async-storage.external.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { CacheLife } from '../use-cache/cache-life'
1010
import { workAsyncStorageInstance } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
1111
import type { LazyResult } from '../lib/lazy-result'
1212
import type { DigestedError } from './create-error-handler'
13+
import type { ActionRevalidationKind } from '../../shared/lib/action-revalidation-kind'
1314

1415
export interface WorkStore {
1516
readonly isStaticGeneration: boolean
@@ -61,7 +62,7 @@ export interface WorkStore {
6162
invalidDynamicUsageError?: Error
6263

6364
nextFetchId?: number
64-
pathWasRevalidated?: boolean
65+
pathWasRevalidated?: ActionRevalidationKind
6566

6667
/**
6768
* Tags that were revalidated during the current request. They need to be sent

packages/next/src/server/web/spec-extension/adapters/request-cookies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ResponseCookies } from '../cookies'
44
import { ReflectAdapter } from './reflect'
55
import { workAsyncStorage } from '../../../app-render/work-async-storage.external'
66
import type { RequestStore } from '../../../app-render/work-unit-async-storage.external'
7+
import { ActionDidRevalidateStaticAndDynamic } from '../../../../shared/lib/action-revalidation-kind'
78

89
/**
910
* @internal
@@ -116,7 +117,7 @@ export class MutableRequestCookiesAdapter {
116117
// TODO-APP: change method of getting workStore
117118
const workStore = workAsyncStorage.getStore()
118119
if (workStore) {
119-
workStore.pathWasRevalidated = true
120+
workStore.pathWasRevalidated = ActionDidRevalidateStaticAndDynamic
120121
}
121122

122123
const allCookies = responseCookies.getAll()

packages/next/src/server/web/spec-extension/revalidate.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { workAsyncStorage } from '../../app-render/work-async-storage.external'
1111
import { workUnitAsyncStorage } from '../../app-render/work-unit-async-storage.external'
1212
import { DynamicServerError } from '../../../client/components/hooks-server-context'
1313
import { InvariantError } from '../../../shared/lib/invariant-error'
14+
import {
15+
ActionDidRevalidateDynamicOnly,
16+
ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate,
17+
} from '../../../shared/lib/action-revalidation-kind'
1418

1519
type CacheLifeConfig = {
1620
expire?: number
@@ -73,8 +77,9 @@ export function refresh() {
7377
}
7478

7579
if (workStore) {
76-
// TODO: break this to it's own field
77-
workStore.pathWasRevalidated = true
80+
// The Server Action version of refresh() only revalidates the dynamic data
81+
// on the client. It doesn't affect cached data.
82+
workStore.pathWasRevalidated = ActionDidRevalidateDynamicOnly
7883
}
7984
}
8085

@@ -226,6 +231,6 @@ function revalidate(
226231

227232
if (!profile || cacheLife?.expire === 0) {
228233
// TODO: only revalidate if the path matches
229-
store.pathWasRevalidated = true
234+
store.pathWasRevalidated = ActionDidRevalidate
230235
}
231236
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type ActionRevalidationKind = 0 | 1 | 2
2+
3+
export const ActionDidNotRevalidate = 0
4+
export const ActionDidRevalidateStaticAndDynamic = 1
5+
export const ActionDidRevalidateDynamicOnly = 2

test/e2e/app-dir/segment-cache/refresh/app/dashboard/client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function ClientRefreshButton() {
1111
router.refresh()
1212
}}
1313
>
14-
Refresh
14+
Client refresh
1515
</button>
1616
)
1717
}

test/e2e/app-dir/segment-cache/refresh/app/dashboard/layout.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import { LinkAccordion } from '../../components/link-accordion'
22
import { ClientRefreshButton } from './client'
3+
import { refresh } from 'next/cache'
4+
5+
function ServerRefreshButton() {
6+
return (
7+
<form
8+
action={async () => {
9+
'use server'
10+
refresh()
11+
}}
12+
>
13+
<button id="server-refresh-button">Server refresh</button>
14+
</form>
15+
)
16+
}
317

418
export default function DashboardLayout({
519
navbar,
@@ -14,6 +28,7 @@ export default function DashboardLayout({
1428
{navbar}
1529
<div>
1630
<ClientRefreshButton />
31+
<ServerRefreshButton />
1732
</div>
1833
<ul>
1934
<li>
@@ -22,6 +37,9 @@ export default function DashboardLayout({
2237
<li>
2338
<LinkAccordion href="/dashboard/analytics">Analytics</LinkAccordion>
2439
</li>
40+
<li>
41+
<LinkAccordion href="/docs">Docs</LinkAccordion>
42+
</li>
2543
</ul>
2644
</div>
2745
<div>{main}</div>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function DocsPage() {
2+
return <div id="docs-page">Static docs page</div>
3+
}

0 commit comments

Comments
 (0)