Skip to content

Commit 7f4a518

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 7f4a518

File tree

9 files changed

+210
-9
lines changed

9 files changed

+210
-9
lines changed

Diff for: 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) => {

Diff for: packages/next/src/client/components/links.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ function rescheduleLinkPrefetch(instance: PrefetchableInstance) {
322322
cacheKey,
323323
treeAtTimeOfPrefetch,
324324
instance.kind === PrefetchKind.FULL,
325-
priority
325+
priority,
326+
null
326327
)
327328
} else {
328329
// We already have an old task object that we can reschedule. This is
@@ -378,7 +379,8 @@ export function pingVisibleLinks(
378379
cacheKey,
379380
tree,
380381
instance.kind === PrefetchKind.FULL,
381-
priority
382+
priority,
383+
null
382384
)
383385
instance.cacheVersion = getCurrentCacheVersion()
384386
}

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

+56
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ let segmentCacheLru = createLRU<SegmentCacheEntry>(
245245
onSegmentLRUEviction
246246
)
247247

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

@@ -276,6 +284,52 @@ export function revalidateEntireCache(
276284

277285
// Prefetch all the currently visible links again, to re-fill the cache.
278286
pingVisibleLinks(nextUrl, tree)
287+
288+
// Similarly, notify all invalidation listeners (i.e. those passed to
289+
// `router.prefetch(onInvalidate)`), so they can trigger a new prefetch
290+
// if needed.
291+
if (invalidationListeners !== null) {
292+
const tasks = invalidationListeners
293+
invalidationListeners = null
294+
for (const task of tasks) {
295+
notifyInvalidationListener(task)
296+
}
297+
}
298+
}
299+
300+
function attachInvalidationListener(task: PrefetchTask): void {
301+
// This function is called whenever a prefetch task reads a cache entry. If
302+
// the task has an onInvalidate function associated with it — i.e. the one
303+
// optionally passed to router.prefetch(onInvalidate) — then we attach that
304+
// listener to the every cache entry that the task reads. Then, if an entry
305+
// is invalidated, we call the function.
306+
if (task.onInvalidate !== null) {
307+
if (invalidationListeners === null) {
308+
invalidationListeners = new Set([task])
309+
} else {
310+
invalidationListeners.add(task)
311+
}
312+
}
313+
}
314+
315+
function notifyInvalidationListener(task: PrefetchTask): void {
316+
const onInvalidate = task.onInvalidate
317+
if (onInvalidate !== null) {
318+
// Clear the callback from the task object to guarantee it's not called more
319+
// than once.
320+
task.onInvalidate = null
321+
322+
// This is a user-space function, so we must wrap in try/catch.
323+
try {
324+
onInvalidate()
325+
} catch (error) {
326+
if (typeof reportError === 'function') {
327+
reportError(error)
328+
} else {
329+
console.error(error)
330+
}
331+
}
332+
}
279333
}
280334

281335
export function readExactRouteCacheEntry(
@@ -445,6 +499,8 @@ export function readOrCreateRouteCacheEntry(
445499
now: number,
446500
task: PrefetchTask
447501
): RouteCacheEntry {
502+
attachInvalidationListener(task)
503+
448504
const key = task.key
449505
const existingEntry = readRouteCacheEntry(now, key)
450506
if (existingEntry !== null) {

Diff for: 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
}

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ export type PrefetchTask = {
105105
*/
106106
isCanceled: boolean
107107

108+
/**
109+
* The callback passed to `router.prefetch`, if given.
110+
*/
111+
onInvalidate: null | (() => void)
112+
108113
/**
109114
* The index of the task in the heap's backing array. Used to efficiently
110115
* change the priority of a task by re-sifting it, which requires knowing
@@ -182,7 +187,8 @@ export function schedulePrefetchTask(
182187
key: RouteCacheKey,
183188
treeAtTimeOfPrefetch: FlightRouterState,
184189
includeDynamicData: boolean,
185-
priority: PrefetchPriority
190+
priority: PrefetchPriority,
191+
onInvalidate: null | (() => void)
186192
): PrefetchTask {
187193
// Spawn a new prefetch task
188194
const task: PrefetchTask = {
@@ -194,6 +200,7 @@ export function schedulePrefetchTask(
194200
includeDynamicData,
195201
sortId: sortIdCounter++,
196202
isCanceled: false,
203+
onInvalidate,
197204
_heapIndex: -1,
198205
}
199206
heapPush(taskHeap, task)

Diff for: 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 {

Diff for: 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

Diff for: test/e2e/app-dir/segment-cache/revalidation/components/link-accordion.tsx

+63-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import Link from 'next/link'
44
import Form from 'next/form'
5-
import { useState } from 'react'
5+
import { useEffect, useState } from 'react'
6+
import { useRouter } from 'next/navigation'
67

78
export function LinkAccordion({
89
href,
@@ -61,3 +62,64 @@ export function FormAccordion({
6162
</>
6263
)
6364
}
65+
66+
export function ManualPrefetchLinkAccordion({
67+
href,
68+
children,
69+
prefetch,
70+
}: {
71+
href: string
72+
children: React.ReactNode
73+
prefetch?: boolean
74+
}) {
75+
const [isVisible, setIsVisible] = useState(false)
76+
return (
77+
<>
78+
<input
79+
type="checkbox"
80+
checked={isVisible}
81+
onChange={() => setIsVisible(!isVisible)}
82+
data-manual-prefetch-link-accordion={href}
83+
/>
84+
{isVisible ? (
85+
<ManualPrefetchLink href={href} prefetch={prefetch}>
86+
{children}
87+
</ManualPrefetchLink>
88+
) : (
89+
<>{children} (form is hidden)</>
90+
)}
91+
</>
92+
)
93+
}
94+
95+
function ManualPrefetchLink({
96+
href,
97+
children,
98+
prefetch,
99+
}: {
100+
href: string
101+
children: React.ReactNode
102+
prefetch?: boolean
103+
}) {
104+
const router = useRouter()
105+
useEffect(() => {
106+
if (prefetch !== false) {
107+
// For as long as the link is mounted, poll the prefetch cache whenever
108+
// it's invalidated to ensure the data is fresh.
109+
let didUnmount = false
110+
const pollPrefetch = () => {
111+
if (!didUnmount) {
112+
// @ts-expect-error: onInvalidate is not yet part of public types
113+
router.prefetch(href, {
114+
onInvalidate: pollPrefetch,
115+
})
116+
}
117+
}
118+
pollPrefetch()
119+
return () => {
120+
didUnmount = true
121+
}
122+
}
123+
}, [href, prefetch, router])
124+
return <a href={href}>{children}</a>
125+
}

Diff for: test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,57 @@ describe('segment cache (revalidation)', () => {
150150
}, 'no-requests')
151151
})
152152

153+
it('call router.prefetch(..., {onInvalidate}) after cache is revalidated', async () => {
154+
// This is the similar to the previous tests, but uses a custom Link
155+
// implementation that calls router.prefetch manually. It demonstrates it's
156+
// possible to simulate the revalidating behavior of Link using the manual
157+
// prefetch API.
158+
let act: ReturnType<typeof createRouterAct>
159+
const browser = await next.browser('/', {
160+
beforePageLoad(page: Playwright.Page) {
161+
act = createRouterAct(page)
162+
},
163+
})
164+
165+
const linkVisibilityToggle = await browser.elementByCss(
166+
'input[data-manual-prefetch-link-accordion="/greeting"]'
167+
)
168+
169+
// Reveal the link that points to the target page to trigger a prefetch
170+
await act(
171+
async () => {
172+
await linkVisibilityToggle.click()
173+
},
174+
{
175+
includes: 'random-greeting',
176+
}
177+
)
178+
179+
// Perform an action that calls revalidatePath. This should cause the
180+
// corresponding entry to be evicted from the client cache, and a new
181+
// prefetch to be requested.
182+
await act(
183+
async () => {
184+
const revalidateByPath = await browser.elementById('revalidate-by-path')
185+
await revalidateByPath.click()
186+
},
187+
{
188+
includes: 'random-greeting [1]',
189+
}
190+
)
191+
TestLog.assert(['REQUEST: random-greeting'])
192+
193+
// Navigate to the target page.
194+
await act(async () => {
195+
const link = await browser.elementByCss('a[href="/greeting"]')
196+
await link.click()
197+
// Navigation should finish immedately because the page is
198+
// fully prefetched.
199+
const greeting = await browser.elementById('greeting')
200+
expect(await greeting.innerHTML()).toBe('random-greeting [1]')
201+
}, 'no-requests')
202+
})
203+
153204
it('evict client cache when Server Action calls revalidateTag', async () => {
154205
let act: ReturnType<typeof createRouterAct>
155206
const browser = await next.browser('/', {

0 commit comments

Comments
 (0)