Skip to content

Commit b9861fd

Browse files
authored
only prefix prefetch cache entries if they vary based on Next-URL (vercel#61235)
### What Prefetches to pages within a shared layout would frequently cache miss despite having the data available. This causes the "instant navigation" behavior (with the 30s/5min TTL) to not be effective on these pages. ### Why In vercel#59861, `nextUrl` was added as a prefetch cache key prefix to ensure multiple interception routes that correspond to the same URL wouldn't clash in the prefetch cache. However this causes a problem in the case where you're navigating between sub-pages. To illustrate the issue, consider the case where you load `/foo`. This will populate the prefetch cache with an entry of `{foo: <PrefetchCacheNode}`. Navigating to `/foo/bar`, with a link that prefetches back to `/foo`, will now result in a new cache node: `{foo: <PrefetchCacheNode>, /foo/bar%/foo: <PrefetchCacheNode>}` (where `Next-URL` is `/foo/bar`). Now we have a cache entry for the full data, as well as a cache entry for a partial prefetch up to the nearest loading boundary. Now when we navigate back to `/foo`, the router will see that it's missing data, and need to lazy-fetch the data triggering the loading boundary. This was especially noticeable in the case where you have a route group with it's own loading.js file because it creates a level of hierarchy in the React tree, and suspending on the data fetch would result in the group's loading boundary to be triggered. In the non-route group scenario, there's still a bug here but it would stall on the data fetch rather than triggering a boundary. ### How In vercel#61794 we conditionally send `Next-URL` as part of the `Vary` header if we detect it could be intercepted. We use this information when creating the prefetch entry to prefix it, in case it corresponds with an intercepted route. Closes NEXT-2193
1 parent dc88609 commit b9861fd

File tree

16 files changed

+345
-152
lines changed

16 files changed

+345
-152
lines changed

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ import { hexHash } from '../../../shared/lib/hash'
3333
export type FetchServerResponseResult = [
3434
flightData: FlightData,
3535
canonicalUrlOverride: URL | undefined,
36-
postponed?: boolean
36+
postponed?: boolean,
37+
intercepted?: boolean
3738
]
3839

3940
function doMpaNavigation(url: string): FetchServerResponseResult {
40-
return [urlToUrlWithoutFlightMarker(url).toString(), undefined]
41+
return [urlToUrlWithoutFlightMarker(url).toString(), undefined, false, false]
4142
}
4243

4344
/**
@@ -112,6 +113,7 @@ export async function fetchServerResponse(
112113

113114
const contentType = res.headers.get('content-type') || ''
114115
const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER)
116+
const interception = !!res.headers.get('vary')?.includes(NEXT_URL)
115117
let isFlightResponse = contentType === RSC_CONTENT_TYPE_HEADER
116118

117119
if (process.env.NODE_ENV === 'production') {
@@ -145,7 +147,7 @@ export async function fetchServerResponse(
145147
return doMpaNavigation(res.url)
146148
}
147149

148-
return [flightData, canonicalUrl, postponed]
150+
return [flightData, canonicalUrl, postponed, interception]
149151
} catch (err) {
150152
console.error(
151153
`Failed to fetch RSC payload for ${url}. Falling back to browser navigation.`,
@@ -154,6 +156,6 @@ export async function fetchServerResponse(
154156
// If fetch fails handle it like a mpa navigation
155157
// TODO-APP: Add a test for the case where a CORS request fails, e.g. external url redirect coming from the response.
156158
// See https://github.com/vercel/next.js/issues/43605#issuecomment-1451617521 for a reproduction.
157-
return [url.toString(), undefined]
159+
return [url.toString(), undefined, false, false]
158160
}
159161
}

packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { createHrefFromUrl } from './create-href-from-url'
2+
import { fetchServerResponse } from './fetch-server-response'
13
import {
24
PrefetchCacheEntryStatus,
3-
type AppRouterState,
45
type PrefetchCacheEntry,
6+
PrefetchKind,
7+
type ReadonlyReducerState,
58
} from './router-reducer-types'
6-
import { addPathPrefix } from '../../../shared/lib/router/utils/add-path-prefix'
7-
import { pathHasPrefix } from '../../../shared/lib/router/utils/path-has-prefix'
8-
import { createHrefFromUrl } from './create-href-from-url'
9+
import { prefetchQueue } from './reducers/prefetch-reducer'
910

1011
/**
1112
* Creates a cache key for the router prefetch cache
@@ -14,27 +15,177 @@ import { createHrefFromUrl } from './create-href-from-url'
1415
* @param nextUrl - an internal URL, primarily used for handling rewrites. Defaults to '/'.
1516
* @return The generated prefetch cache key.
1617
*/
17-
export function createPrefetchCacheKey(url: URL, nextUrl: string | null) {
18+
function createPrefetchCacheKey(url: URL, nextUrl?: string | null) {
1819
const pathnameFromUrl = createHrefFromUrl(
1920
url,
2021
// Ensures the hash is not part of the cache key as it does not impact the server fetch
2122
false
2223
)
2324

24-
// delimit the prefix so we don't conflict with other pages
25-
const nextUrlPrefix = `${nextUrl}%`
26-
27-
// Route interception depends on `nextUrl` values which aren't a 1:1 mapping to a URL
28-
// The cache key that we store needs to use `nextUrl` to properly distinguish cache entries
29-
if (nextUrl && !pathHasPrefix(pathnameFromUrl, nextUrl)) {
30-
return addPathPrefix(pathnameFromUrl, nextUrlPrefix)
25+
// nextUrl is used as a cache key delimiter since entries can vary based on the Next-URL header
26+
if (nextUrl) {
27+
return `${nextUrl}%${pathnameFromUrl}`
3128
}
3229

3330
return pathnameFromUrl
3431
}
3532

33+
/**
34+
* Returns a prefetch cache entry if one exists. Otherwise creates a new one and enqueues a fetch request
35+
* to retrieve the prefetch data from the server.
36+
*/
37+
export function getOrCreatePrefetchCacheEntry({
38+
url,
39+
nextUrl,
40+
tree,
41+
buildId,
42+
prefetchCache,
43+
kind,
44+
}: Pick<
45+
ReadonlyReducerState,
46+
'nextUrl' | 'prefetchCache' | 'tree' | 'buildId'
47+
> & {
48+
url: URL
49+
kind?: PrefetchKind
50+
}): PrefetchCacheEntry {
51+
let existingCacheEntry: PrefetchCacheEntry | undefined = undefined
52+
// We first check if there's a more specific interception route prefetch entry
53+
// This is because when we detect a prefetch that corresponds with an interception route, we prefix it with nextUrl (see `createPrefetchCacheKey`)
54+
// to avoid conflicts with other pages that may have the same URL but render different things depending on the `Next-URL` header.
55+
const interceptionCacheKey = createPrefetchCacheKey(url, nextUrl)
56+
const interceptionData = prefetchCache.get(interceptionCacheKey)
57+
58+
if (interceptionData) {
59+
existingCacheEntry = interceptionData
60+
} else {
61+
// If we dont find a more specific interception route prefetch entry, we check for a regular prefetch entry
62+
const prefetchCacheKey = createPrefetchCacheKey(url)
63+
const prefetchData = prefetchCache.get(prefetchCacheKey)
64+
if (prefetchData) {
65+
existingCacheEntry = prefetchData
66+
}
67+
}
68+
69+
if (existingCacheEntry) {
70+
// when `kind` is provided, an explicit prefetch was requested.
71+
// if the requested prefetch is "full" and the current cache entry wasn't, we want to re-prefetch with the new intent
72+
if (
73+
kind &&
74+
existingCacheEntry.kind !== PrefetchKind.FULL &&
75+
kind === PrefetchKind.FULL
76+
) {
77+
return createLazyPrefetchEntry({
78+
tree,
79+
url,
80+
buildId,
81+
nextUrl,
82+
prefetchCache,
83+
kind,
84+
})
85+
}
86+
87+
// Grab the latest status of the cache entry and update it
88+
existingCacheEntry.status = getPrefetchEntryCacheStatus(existingCacheEntry)
89+
90+
// If the existing cache entry was marked as temporary, it means it was lazily created when attempting to get an entry,
91+
// where we didn't have the prefetch intent. Now that we have the intent (in `kind`), we want to update the entry to the more accurate kind.
92+
if (kind && existingCacheEntry.kind === PrefetchKind.TEMPORARY) {
93+
existingCacheEntry.kind = kind
94+
}
95+
96+
// We've determined that the existing entry we found is still valid, so we return it.
97+
return existingCacheEntry
98+
}
99+
100+
// If we didn't return an entry, create a new one.
101+
return createLazyPrefetchEntry({
102+
tree,
103+
url,
104+
buildId,
105+
nextUrl,
106+
prefetchCache,
107+
kind:
108+
kind ||
109+
// in dev, there's never gonna be a prefetch entry so we want to prefetch here
110+
(process.env.NODE_ENV === 'development'
111+
? PrefetchKind.AUTO
112+
: PrefetchKind.TEMPORARY),
113+
})
114+
}
115+
116+
function prefixExistingPrefetchCacheEntry({
117+
url,
118+
nextUrl,
119+
prefetchCache,
120+
}: Pick<ReadonlyReducerState, 'nextUrl' | 'prefetchCache'> & {
121+
url: URL
122+
}) {
123+
const existingCacheKey = createPrefetchCacheKey(url)
124+
const existingCacheEntry = prefetchCache.get(existingCacheKey)
125+
if (!existingCacheEntry) {
126+
// no-op -- there wasn't an entry to move
127+
return
128+
}
129+
130+
const newCacheKey = createPrefetchCacheKey(url, nextUrl)
131+
prefetchCache.set(newCacheKey, existingCacheEntry)
132+
prefetchCache.delete(existingCacheKey)
133+
}
134+
135+
/**
136+
* Creates a prefetch entry for data that has not been resolved. This will add the prefetch request to a promise queue.
137+
*/
138+
function createLazyPrefetchEntry({
139+
url,
140+
kind,
141+
tree,
142+
nextUrl,
143+
buildId,
144+
prefetchCache,
145+
}: Pick<
146+
ReadonlyReducerState,
147+
'nextUrl' | 'tree' | 'buildId' | 'prefetchCache'
148+
> & {
149+
url: URL
150+
kind: PrefetchKind
151+
}): PrefetchCacheEntry {
152+
const prefetchCacheKey = createPrefetchCacheKey(url)
153+
154+
// initiates the fetch request for the prefetch and attaches a listener
155+
// to the promise to update the prefetch cache entry when the promise resolves (if necessary)
156+
const data = prefetchQueue.enqueue(() =>
157+
fetchServerResponse(url, tree, nextUrl, buildId, kind).then(
158+
(prefetchResponse) => {
159+
// TODO: `fetchServerResponse` should be more tighly coupled to these prefetch cache operations
160+
// to avoid drift between this cache key prefixing logic
161+
// (which is currently directly influenced by the server response)
162+
const [, , , intercepted] = prefetchResponse
163+
if (intercepted) {
164+
prefixExistingPrefetchCacheEntry({ url, nextUrl, prefetchCache })
165+
}
166+
167+
return prefetchResponse
168+
}
169+
)
170+
)
171+
172+
const prefetchEntry = {
173+
treeAtTimeOfPrefetch: tree,
174+
data,
175+
kind,
176+
prefetchTime: Date.now(),
177+
lastUsedTime: null,
178+
key: prefetchCacheKey,
179+
status: PrefetchCacheEntryStatus.fresh,
180+
}
181+
182+
prefetchCache.set(prefetchCacheKey, prefetchEntry)
183+
184+
return prefetchEntry
185+
}
186+
36187
export function prunePrefetchCache(
37-
prefetchCache: AppRouterState['prefetchCache']
188+
prefetchCache: ReadonlyReducerState['prefetchCache']
38189
) {
39190
for (const [href, prefetchCacheEntry] of prefetchCache) {
40191
if (
@@ -49,7 +200,7 @@ export function prunePrefetchCache(
49200
const FIVE_MINUTES = 5 * 60 * 1000
50201
const THIRTY_SECONDS = 30 * 1000
51202

52-
export function getPrefetchEntryCacheStatus({
203+
function getPrefetchEntryCacheStatus({
53204
kind,
54205
prefetchTime,
55206
lastUsedTime,

packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,11 @@ describe('navigateReducer', () => {
256256
"prefetchCache": Map {
257257
"/linking/about" => {
258258
"data": Promise {},
259+
"key": "/linking/about",
259260
"kind": "temporary",
260261
"lastUsedTime": 1690329600000,
261262
"prefetchTime": 1690329600000,
263+
"status": "fresh",
262264
"treeAtTimeOfPrefetch": [
263265
"",
264266
{
@@ -450,9 +452,11 @@ describe('navigateReducer', () => {
450452
"prefetchCache": Map {
451453
"/linking/about" => {
452454
"data": Promise {},
455+
"key": "/linking/about",
453456
"kind": "temporary",
454457
"lastUsedTime": 1690329600000,
455458
"prefetchTime": 1690329600000,
459+
"status": "fresh",
456460
"treeAtTimeOfPrefetch": [
457461
"",
458462
{
@@ -887,9 +891,11 @@ describe('navigateReducer', () => {
887891
"prefetchCache": Map {
888892
"/linking" => {
889893
"data": Promise {},
894+
"key": "/linking",
890895
"kind": "temporary",
891896
"lastUsedTime": 1690329600000,
892897
"prefetchTime": 1690329600000,
898+
"status": "fresh",
893899
"treeAtTimeOfPrefetch": [
894900
"",
895901
{
@@ -1113,9 +1119,11 @@ describe('navigateReducer', () => {
11131119
"prefetchCache": Map {
11141120
"/linking/about" => {
11151121
"data": Promise {},
1122+
"key": "/linking/about",
11161123
"kind": "auto",
11171124
"lastUsedTime": 1690329600000,
11181125
"prefetchTime": 1690329600000,
1126+
"status": "fresh",
11191127
"treeAtTimeOfPrefetch": [
11201128
"",
11211129
{
@@ -1367,9 +1375,11 @@ describe('navigateReducer', () => {
13671375
"prefetchCache": Map {
13681376
"/parallel-tab-bar/demographics" => {
13691377
"data": Promise {},
1378+
"key": "/parallel-tab-bar/demographics",
13701379
"kind": "temporary",
13711380
"lastUsedTime": 1690329600000,
13721381
"prefetchTime": 1690329600000,
1382+
"status": "fresh",
13731383
"treeAtTimeOfPrefetch": [
13741384
"",
13751385
{
@@ -1710,9 +1720,11 @@ describe('navigateReducer', () => {
17101720
"prefetchCache": Map {
17111721
"/linking/about" => {
17121722
"data": Promise {},
1723+
"key": "/linking/about",
17131724
"kind": "temporary",
17141725
"lastUsedTime": 1690329600000,
17151726
"prefetchTime": 1690329600000,
1727+
"status": "fresh",
17161728
"treeAtTimeOfPrefetch": [
17171729
"",
17181730
{

0 commit comments

Comments
 (0)