Skip to content

Commit 07d9a07

Browse files
committed
Add onInvalidate option to router.prefetch
This commit adds an `onInvalidate` callback to `router.prefetch()` so custom `<Link>` implementations can re-prefetch data when it becomes stale. The callback is invoked when the data associated with the prefetch may have been invalidated (e.g. by `revalidatePath` or `revalidateTag`). This is not a live subscription and should not be treated as one. It's a one-time callback per prefetch request that acts as a signal: "If you care about the freshness of this data, now would be a good time to re-prefetch." The supported use case is for advanced clients who opt out of rendering the built-in `<Link>` component (e.g. to customize visibility tracking or polling behavior) but still want to retain proper cache integration. When the callback is fired, the component can trigger a new call to `router.prefetch()` with the same parameters, including a new `onInvalidate` callback to continue the cycle. (For reference, `<Link>` handles this automatically. This API exists to give custom implementations access to the same underlying behavior.) Note that the callback *may* be invoked even if the prefetched data is still cached. This is intentional—prefetching in the app router is a pull-based mechanism, not a push-based one. Rather than subscribing to the lifecycle of specific cache entries, the app occasionally polls the prefetch layer to check for missing or stale data. Calling `router.prefetch()` does not necessarily result in a network request. If the data is already cached, the call is a no-op. This makes polling a practical way to check cache freshness over time without incurring unnecessary requests.
1 parent d0dbd3a commit 07d9a07

File tree

10 files changed

+264
-31
lines changed

10 files changed

+264
-31
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,8 @@ export const publicAppRouterInstance: AppRouterInstance = {
321321
href,
322322
actionQueue.state.nextUrl,
323323
actionQueue.state.tree,
324-
options?.kind === PrefetchKind.FULL
324+
options?.kind === PrefetchKind.FULL,
325+
options?.onInvalidate ?? null
325326
)
326327
}
327328
: (href: string, options?: PrefetchOptions) => {

packages/next/src/client/components/links.ts

+7-23
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AppRouterInstance } from '../../shared/lib/app-router-context.shar
33
import { getCurrentAppRouterState } from './app-router-instance'
44
import { createPrefetchURL } from './app-router'
55
import { PrefetchKind } from './router-reducer/router-reducer-types'
6-
import { getCurrentCacheVersion } from './segment-cache'
6+
import { isPrefetchTaskDirty } from './segment-cache'
77
import { createCacheKey } from './segment-cache'
88
import {
99
type PrefetchTask,
@@ -28,13 +28,9 @@ type LinkOrFormInstanceShared = {
2828
wasHoveredOrTouched: boolean
2929

3030
// The most recently initiated prefetch task. It may or may not have
31-
// already completed. The same prefetch task object can be reused across
31+
// already completed. The same prefetch task object can be reused across
3232
// multiple prefetches of the same link.
3333
prefetchTask: PrefetchTask | null
34-
35-
// The cache version at the time the task was initiated. This is used to
36-
// determine if the cache was invalidated since the task was initiated.
37-
cacheVersion: number
3834
}
3935

4036
export type FormInstance = LinkOrFormInstanceShared & {
@@ -158,7 +154,6 @@ export function mountLinkInstance(
158154
isVisible: false,
159155
wasHoveredOrTouched: false,
160156
prefetchTask: null,
161-
cacheVersion: -1,
162157
prefetchHref: prefetchURL.href,
163158
setOptimisticLinkStatus,
164159
}
@@ -176,7 +171,6 @@ export function mountLinkInstance(
176171
isVisible: false,
177172
wasHoveredOrTouched: false,
178173
prefetchTask: null,
179-
cacheVersion: -1,
180174
prefetchHref: null,
181175
setOptimisticLinkStatus,
182176
}
@@ -203,7 +197,6 @@ export function mountFormInstance(
203197
isVisible: false,
204198
wasHoveredOrTouched: false,
205199
prefetchTask: null,
206-
cacheVersion: -1,
207200
prefetchHref: prefetchURL.href,
208201
setOptimisticLinkStatus: null,
209202
}
@@ -322,7 +315,8 @@ function rescheduleLinkPrefetch(instance: PrefetchableInstance) {
322315
cacheKey,
323316
treeAtTimeOfPrefetch,
324317
instance.kind === PrefetchKind.FULL,
325-
priority
318+
priority,
319+
null
326320
)
327321
} else {
328322
// We already have an old task object that we can reschedule. This is
@@ -334,10 +328,6 @@ function rescheduleLinkPrefetch(instance: PrefetchableInstance) {
334328
priority
335329
)
336330
}
337-
338-
// Keep track of the cache version at the time the prefetch was requested.
339-
// This is used to check if the prefetch is stale.
340-
instance.cacheVersion = getCurrentCacheVersion()
341331
}
342332
}
343333

@@ -352,15 +342,9 @@ export function pingVisibleLinks(
352342
// This is called when the Next-Url or the base tree changes, since those
353343
// may affect the result of a prefetch task. It's also called after a
354344
// cache invalidation.
355-
const currentCacheVersion = getCurrentCacheVersion()
356345
for (const instance of prefetchableAndVisible) {
357346
const task = instance.prefetchTask
358-
if (
359-
task !== null &&
360-
instance.cacheVersion === currentCacheVersion &&
361-
task.key.nextUrl === nextUrl &&
362-
task.treeAtTimeOfPrefetch === tree
363-
) {
347+
if (task !== null && !isPrefetchTaskDirty(task, nextUrl, tree)) {
364348
// The cache has not been invalidated, and none of the inputs have
365349
// changed. Bail out.
366350
continue
@@ -378,9 +362,9 @@ export function pingVisibleLinks(
378362
cacheKey,
379363
tree,
380364
instance.kind === PrefetchKind.FULL,
381-
priority
365+
priority,
366+
null
382367
)
383-
instance.cacheVersion = getCurrentCacheVersion()
384368
}
385369
}
386370

packages/next/src/client/components/segment-cache-impl/cache.ts

+70
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '../router-reducer/fetch-server-response'
2929
import {
3030
pingPrefetchTask,
31+
isPrefetchTaskDirty,
3132
type PrefetchTask,
3233
type PrefetchSubtaskResult,
3334
} from './scheduler'
@@ -245,6 +246,14 @@ let segmentCacheLru = createLRU<SegmentCacheEntry>(
245246
onSegmentLRUEviction
246247
)
247248

249+
// All invalidation listeners for the whole cache are tracked in single set.
250+
// Since we don't yet support tag or path-based invalidation, there's no point
251+
// tracking them any more granularly than this. Once we add granular
252+
// invalidation, that may change, though generally the model is to just notify
253+
// the listeners and allow the caller to poll the prefetch cache with a new
254+
// prefetch task if desired.
255+
let invalidationListeners: Set<PrefetchTask> | null = null
256+
248257
// Incrementing counter used to track cache invalidations.
249258
let currentCacheVersion = 0
250259

@@ -276,6 +285,65 @@ export function revalidateEntireCache(
276285

277286
// Prefetch all the currently visible links again, to re-fill the cache.
278287
pingVisibleLinks(nextUrl, tree)
288+
289+
// Similarly, notify all invalidation listeners (i.e. those passed to
290+
// `router.prefetch(onInvalidate)`), so they can trigger a new prefetch
291+
// if needed.
292+
pingInvalidationListeners(nextUrl, tree)
293+
}
294+
295+
function attachInvalidationListener(task: PrefetchTask): void {
296+
// This function is called whenever a prefetch task reads a cache entry. If
297+
// the task has an onInvalidate function associated with it — i.e. the one
298+
// optionally passed to router.prefetch(onInvalidate) — then we attach that
299+
// listener to the every cache entry that the task reads. Then, if an entry
300+
// is invalidated, we call the function.
301+
if (task.onInvalidate !== null) {
302+
if (invalidationListeners === null) {
303+
invalidationListeners = new Set([task])
304+
} else {
305+
invalidationListeners.add(task)
306+
}
307+
}
308+
}
309+
310+
function notifyInvalidationListener(task: PrefetchTask): void {
311+
const onInvalidate = task.onInvalidate
312+
if (onInvalidate !== null) {
313+
// Clear the callback from the task object to guarantee it's not called more
314+
// than once.
315+
task.onInvalidate = null
316+
317+
// This is a user-space function, so we must wrap in try/catch.
318+
try {
319+
onInvalidate()
320+
} catch (error) {
321+
if (typeof reportError === 'function') {
322+
reportError(error)
323+
} else {
324+
console.error(error)
325+
}
326+
}
327+
}
328+
}
329+
330+
export function pingInvalidationListeners(
331+
nextUrl: string | null,
332+
tree: FlightRouterState
333+
): void {
334+
// The rough equivalent of pingVisibleLinks, but for onInvalidate callbacks.
335+
// This is called when the Next-Url or the base tree changes, since those
336+
// may affect the result of a prefetch task. It's also called after a
337+
// cache invalidation.
338+
if (invalidationListeners !== null) {
339+
const tasks = invalidationListeners
340+
invalidationListeners = null
341+
for (const task of tasks) {
342+
if (isPrefetchTaskDirty(task, nextUrl, tree)) {
343+
notifyInvalidationListener(task)
344+
}
345+
}
346+
}
279347
}
280348

281349
export function readExactRouteCacheEntry(
@@ -445,6 +513,8 @@ export function readOrCreateRouteCacheEntry(
445513
now: number,
446514
task: PrefetchTask
447515
): RouteCacheEntry {
516+
attachInvalidationListener(task)
517+
448518
const key = task.key
449519
const existingEntry = readRouteCacheEntry(now, key)
450520
if (existingEntry !== null) {

packages/next/src/client/components/segment-cache-impl/prefetch.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,27 @@ import { PrefetchPriority } from '../segment-cache'
99
* @param href - The URL to prefetch. Typically this will come from a <Link>,
1010
* or router.prefetch. It must be validated before we attempt to prefetch it.
1111
* @param nextUrl - A special header used by the server for interception routes.
12-
* Roughly corresponds to the current URL.
12+
* Roughly corresponds to the current URL.
1313
* @param treeAtTimeOfPrefetch - The FlightRouterState at the time the prefetch
1414
* was requested. This is only used when PPR is disabled.
1515
* @param includeDynamicData - Whether to prefetch dynamic data, in addition to
1616
* static data. This is used by <Link prefetch={true}>.
17+
* @param onInvalidate - A callback that will be called when the prefetch cache
18+
* When called, it signals to the listener that the data associated with the
19+
* prefetch may have been invalidated from the cache. This is not a live
20+
* subscription — it's called at most once per `prefetch` call. The only
21+
* supported use case is to trigger a new prefetch inside the listener, if
22+
* desired. It also may be called even in cases where the associated data is
23+
* still cached. Prefetching is a poll-based (pull) operation, not an event-
24+
* based (push) one. Rather than subscribe to specific cache entries, you
25+
* occasionally poll the prefetch cache to check if anything is missing.
1726
*/
1827
export function prefetch(
1928
href: string,
2029
nextUrl: string | null,
2130
treeAtTimeOfPrefetch: FlightRouterState,
22-
includeDynamicData: boolean
31+
includeDynamicData: boolean,
32+
onInvalidate: null | (() => void)
2333
) {
2434
const url = createPrefetchURL(href)
2535
if (url === null) {
@@ -31,6 +41,7 @@ export function prefetch(
3141
cacheKey,
3242
treeAtTimeOfPrefetch,
3343
includeDynamicData,
34-
PrefetchPriority.Default
44+
PrefetchPriority.Default,
45+
onInvalidate
3546
)
3647
}

packages/next/src/client/components/segment-cache-impl/scheduler.ts

+36-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
getSegmentKeypathForTask,
2727
} from './cache'
2828
import type { RouteCacheKey } from './cache-key'
29-
import { PrefetchPriority } from '../segment-cache'
29+
import { getCurrentCacheVersion, PrefetchPriority } from '../segment-cache'
3030

3131
const scheduleMicrotask =
3232
typeof queueMicrotask === 'function'
@@ -50,6 +50,12 @@ export type PrefetchTask = {
5050
*/
5151
treeAtTimeOfPrefetch: FlightRouterState
5252

53+
/**
54+
* The cache version at the time the task was initiated. This is used to
55+
* determine if the cache was invalidated since the task was initiated.
56+
*/
57+
cacheVersion: number
58+
5359
/**
5460
* Whether to prefetch dynamic data, in addition to static data. This is
5561
* used by <Link prefetch={true}>.
@@ -105,6 +111,11 @@ export type PrefetchTask = {
105111
*/
106112
isCanceled: boolean
107113

114+
/**
115+
* The callback passed to `router.prefetch`, if given.
116+
*/
117+
onInvalidate: null | (() => void)
118+
108119
/**
109120
* The index of the task in the heap's backing array. Used to efficiently
110121
* change the priority of a task by re-sifting it, which requires knowing
@@ -182,18 +193,21 @@ export function schedulePrefetchTask(
182193
key: RouteCacheKey,
183194
treeAtTimeOfPrefetch: FlightRouterState,
184195
includeDynamicData: boolean,
185-
priority: PrefetchPriority
196+
priority: PrefetchPriority,
197+
onInvalidate: null | (() => void)
186198
): PrefetchTask {
187199
// Spawn a new prefetch task
188200
const task: PrefetchTask = {
189201
key,
190202
treeAtTimeOfPrefetch,
203+
cacheVersion: getCurrentCacheVersion(),
191204
priority,
192205
phase: PrefetchPhase.RouteTree,
193206
hasBackgroundWork: false,
194207
includeDynamicData,
195208
sortId: sortIdCounter++,
196209
isCanceled: false,
210+
onInvalidate,
197211
_heapIndex: -1,
198212
}
199213
heapPush(taskHeap, task)
@@ -254,6 +268,24 @@ export function reschedulePrefetchTask(
254268
ensureWorkIsScheduled()
255269
}
256270

271+
export function isPrefetchTaskDirty(
272+
task: PrefetchTask,
273+
nextUrl: string | null,
274+
tree: FlightRouterState
275+
): boolean {
276+
// This is used to quickly bail out of a prefetch task if the result is
277+
// guaranteed to not have changed since the task was initiated. This is
278+
// strictly an optimization — theoretically, if it always returned true, no
279+
// behavior should change because a full prefetch task will effectively
280+
// perform the same checks.
281+
const currentCacheVersion = getCurrentCacheVersion()
282+
return (
283+
task.cacheVersion !== currentCacheVersion ||
284+
task.treeAtTimeOfPrefetch !== tree ||
285+
task.key.nextUrl !== nextUrl
286+
)
287+
}
288+
257289
function ensureWorkIsScheduled() {
258290
if (didScheduleMicrotask || !hasNetworkBandwidth()) {
259291
// Either we already scheduled a task to process the queue, or there are
@@ -344,6 +376,8 @@ function processQueueInMicrotask() {
344376
// Process the task queue until we run out of network bandwidth.
345377
let task = heapPeek(taskHeap)
346378
while (task !== null && hasNetworkBandwidth()) {
379+
task.cacheVersion = getCurrentCacheVersion()
380+
347381
const route = readOrCreateRouteCacheEntry(now, task)
348382
const exitStatus = pingRootRouteTree(now, task, route)
349383

packages/next/src/client/components/segment-cache.ts

+9
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export const reschedulePrefetchTask: typeof import('./segment-cache-impl/schedul
8585
}
8686
: notEnabled
8787

88+
export const isPrefetchTaskDirty: typeof import('./segment-cache-impl/scheduler').isPrefetchTaskDirty =
89+
process.env.__NEXT_CLIENT_SEGMENT_CACHE
90+
? function (...args) {
91+
return require('./segment-cache-impl/scheduler').isPrefetchTaskDirty(
92+
...args
93+
)
94+
}
95+
: notEnabled
96+
8897
export const createCacheKey: typeof import('./segment-cache-impl/cache-key').createCacheKey =
8998
process.env.__NEXT_CLIENT_SEGMENT_CACHE
9099
? function (...args) {

packages/next/src/shared/lib/app-router-context.shared-runtime.ts

+1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export interface NavigateOptions {
122122

123123
export interface PrefetchOptions {
124124
kind: PrefetchKind
125+
onInvalidate?: () => void
125126
}
126127

127128
export interface AppRouterInstance {

test/e2e/app-dir/segment-cache/revalidation/app/page.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { revalidatePath, revalidateTag } from 'next/cache'
2-
import { LinkAccordion, FormAccordion } from '../components/link-accordion'
2+
import {
3+
LinkAccordion,
4+
FormAccordion,
5+
ManualPrefetchLinkAccordion,
6+
} from '../components/link-accordion'
37
import Link from 'next/link'
48

59
export default async function Page() {
@@ -36,6 +40,12 @@ export default async function Page() {
3640
Form pointing to target page with prefetching enabled
3741
</FormAccordion>
3842
</li>
43+
<li>
44+
<ManualPrefetchLinkAccordion href="/greeting">
45+
Manual link (router.prefetch) to target page with prefetching
46+
enabled
47+
</ManualPrefetchLinkAccordion>
48+
</li>
3949
<li>
4050
<Link prefetch={false} href="/greeting">
4151
Link to target with prefetching disabled

0 commit comments

Comments
 (0)