Skip to content

Commit c8aefc1

Browse files
committed
[Segment Cache] Interception routes
Implements prefetching support for interception routes using the Segment Cache. The overall flow is the same as the previous prefetch cache implementation. If a page varies based on the Next-URL — in other words, if it might possibly be intercepted — we include the Next-URL as part of the cache key. However, since most pages do not vary on the Next-URL, and this is known at build time, for most pages we can omit the Next-URL from the cache key for all but the first request. We do this by checking the Vary header of the first response, and if the Next-URL is not included, we re-key the cache entry to remove the Next-URL. All subsequent requests for the same page will match this entry regardless of the Next-URL. --- One difference from the previous prefetch cache implementation: when an entry varies by Next-URL, rather than concatentating the Next-URL to the href to create a combined cache key, we store the entries in a tiered map structure whose keys are tuples of the href and Next-URL. Then we compare each key part separately. This might end up being overkill but it's nice because we don't have to worry about escaping the values, nor do we have to store an encoded cache key separately from its individual parts. We will likely use the same approach for storing segment cache entries, which vary on both the segment path and (in some cases; not yet implemented) the search params.
1 parent b47a3f0 commit c8aefc1

11 files changed

Lines changed: 347 additions & 51 deletions

File tree

packages/next/src/client/components/app-router.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ function Router({
280280
? // Unlike the old implementation, the Segment Cache doesn't store its
281281
// data in the router reducer state; it writes into a global mutable
282282
// cache. So we don't need to dispatch an action.
283-
prefetchWithSegmentCache
283+
(href) => prefetchWithSegmentCache(href, actionQueue.state.nextUrl)
284284
: (href, options) => {
285285
// Use the old prefetch implementation.
286286
const url = createPrefetchURL(href)
@@ -329,7 +329,7 @@ function Router({
329329
}
330330

331331
return routerInstance
332-
}, [dispatch, navigate])
332+
}, [actionQueue, dispatch, navigate])
333333

334334
useEffect(() => {
335335
// Exists for debugging purposes. Don't use in application code.

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@
22
type Opaque<K, T> = T & { __brand: K }
33

44
// Only functions in this module should be allowed to create CacheKeys.
5-
export type RouteCacheKeyId = Opaque<'RouteCacheKeyId', string>
65
export type NormalizedHref = Opaque<'NormalizedHref', string>
7-
type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>
6+
export type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>
87

98
export type RouteCacheKey = Opaque<
109
'RouteCacheKey',
1110
{
12-
id: RouteCacheKeyId
1311
href: NormalizedHref
1412
nextUrl: NormalizedNextUrl | null
1513
}
@@ -28,13 +26,9 @@ export function createCacheKey(
2826
originalUrl.search = ''
2927

3028
const normalizedHref = originalUrl.href as NormalizedHref
31-
const normalizedNextUrl = (
32-
nextUrl !== null ? nextUrl : ''
33-
) as NormalizedNextUrl
34-
const id = `|${normalizedHref}|${normalizedNextUrl}|` as RouteCacheKeyId
29+
const normalizedNextUrl = nextUrl as NormalizedNextUrl | null
3530

3631
const cacheKey = {
37-
id,
3832
href: normalizedHref,
3933
nextUrl: normalizedNextUrl,
4034
} as RouteCacheKey

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

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ import {
2626
} from './scheduler'
2727
import { getAppBuildId } from '../../app-build-id'
2828
import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
29-
import type { RouteCacheKey, RouteCacheKeyId } from './cache-key'
29+
import type {
30+
NormalizedHref,
31+
NormalizedNextUrl,
32+
RouteCacheKey,
33+
} from './cache-key'
34+
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
3035

3136
// A note on async/await when working in the prefetch cache:
3237
//
@@ -51,6 +56,9 @@ import type { RouteCacheKey, RouteCacheKeyId } from './cache-key'
5156

5257
type RouteCacheEntryShared = {
5358
staleAt: number
59+
// This is false only if we're certain the route cannot be intercepted. It's
60+
// true in all other cases, including on initialization when we haven't yet
61+
// received a response from the server.
5462
couldBeIntercepted: boolean
5563
}
5664

@@ -119,27 +127,55 @@ export type SegmentCacheEntry =
119127
| RejectedSegmentCacheEntry
120128
| FulfilledSegmentCacheEntry
121129

122-
const routeCache = new Map<RouteCacheKeyId, RouteCacheEntry>()
130+
// Route cache entries vary on multiple keys: the href and the Next-Url. Each of
131+
// these parts needs to be included in the internal cache key. Rather than
132+
// concatenate the keys into a single key, we use a multi-level map, where the
133+
// first level is keyed by href, the second level is keyed by Next-Url, and so
134+
// on (if were to add more levels).
135+
type RouteCacheKeypath = [NormalizedHref, NormalizedNextUrl]
136+
const routeCacheMap: TupleMap<RouteCacheKeypath, RouteCacheEntry> =
137+
createTupleMap()
138+
139+
// TODO: We may eventually store segment entries in a tuple map, too, to
140+
// account for search params.
123141
const segmentCache = new Map<string, SegmentCacheEntry>()
124142

125-
export function readRouteCacheEntry(
143+
export function readExactRouteCacheEntry(
126144
now: number,
127-
key: RouteCacheKey
145+
href: NormalizedHref,
146+
nextUrl: NormalizedNextUrl | null
128147
): RouteCacheEntry | null {
129-
const existingEntry = routeCache.get(key.id)
130-
if (existingEntry !== undefined) {
148+
const keypath: Prefix<RouteCacheKeypath> =
149+
nextUrl === null ? [href] : [href, nextUrl]
150+
const existingEntry = routeCacheMap.get(keypath)
151+
if (existingEntry !== null) {
131152
// Check if the entry is stale
132153
if (existingEntry.staleAt > now) {
133154
// Reuse the existing entry.
134155
return existingEntry
135156
} else {
136157
// Evict the stale entry from the cache.
137-
evictRouteCacheEntryFromCache(key)
158+
routeCacheMap.delete(keypath)
138159
}
139160
}
140161
return null
141162
}
142163

164+
export function readRouteCacheEntry(
165+
now: number,
166+
key: RouteCacheKey
167+
): RouteCacheEntry | null {
168+
// First check if there's a non-intercepted entry. Most routes cannot be
169+
// intercepted, so this is the common case.
170+
const nonInterceptedEntry = readExactRouteCacheEntry(now, key.href, null)
171+
if (nonInterceptedEntry !== null && !nonInterceptedEntry.couldBeIntercepted) {
172+
// Found a match, and the route cannot be intercepted. We can reuse it.
173+
return nonInterceptedEntry
174+
}
175+
// There was no match. Check again but include the Next-Url this time.
176+
return readExactRouteCacheEntry(now, key.href, key.nextUrl)
177+
}
178+
143179
export function readSegmentCacheEntry(
144180
now: number,
145181
path: string
@@ -182,9 +218,18 @@ export function requestRouteCacheEntryFromCache(
182218
now: number,
183219
task: PrefetchTask
184220
): RouteCacheEntry {
185-
const existingEntry = readRouteCacheEntry(now, task.key)
186-
if (existingEntry !== null) {
187-
return existingEntry
221+
const key = task.key
222+
// First check if there's a non-intercepted entry. Most routes cannot be
223+
// intercepted, so this is the common case.
224+
const nonInterceptedEntry = readExactRouteCacheEntry(now, key.href, null)
225+
if (nonInterceptedEntry !== null && !nonInterceptedEntry.couldBeIntercepted) {
226+
// Found a match, and the route cannot be intercepted. We can reuse it.
227+
return nonInterceptedEntry
228+
}
229+
// There was no match. Check again but include the Next-Url this time.
230+
const exactEntry = readExactRouteCacheEntry(now, key.href, key.nextUrl)
231+
if (exactEntry !== null) {
232+
return exactEntry
188233
}
189234
// Create a pending entry and spawn a request for its data.
190235
const pendingEntry: PendingRouteCacheEntry = {
@@ -199,11 +244,15 @@ export function requestRouteCacheEntryFromCache(
199244
// When the response is received, this value will be replaced by a new value
200245
// based on the stale time sent from the server.
201246
staleAt: now + 60 * 1000,
202-
couldBeIntercepted: false,
247+
// This is initialized to true because we don't know yet whether the route
248+
// could be intercepted. It's only set to false once we receive a response
249+
// from the server.
250+
couldBeIntercepted: true,
203251
}
204-
const key = task.key
205252
spawnPrefetchSubtask(fetchRouteOnCacheMiss(pendingEntry, task))
206-
routeCache.set(key.id, pendingEntry)
253+
const keypath: Prefix<RouteCacheKeypath> =
254+
key.nextUrl === null ? [key.href] : [key.href, key.nextUrl]
255+
routeCacheMap.set(keypath, pendingEntry)
207256
return pendingEntry
208257
}
209258

@@ -244,10 +293,6 @@ export function requestSegmentEntryFromCache(
244293
return pendingEntry
245294
}
246295

247-
function evictRouteCacheEntryFromCache(key: RouteCacheKey): void {
248-
routeCache.delete(key.id)
249-
}
250-
251296
function evictSegmentEntryFromCache(
252297
entry: SegmentCacheEntry,
253298
key: string
@@ -271,7 +316,7 @@ function fulfillRouteCacheEntry(
271316
staleAt: number,
272317
couldBeIntercepted: boolean,
273318
canonicalUrl: string
274-
) {
319+
): FulfilledRouteCacheEntry {
275320
const fulfilledEntry: FulfilledRouteCacheEntry = entry as any
276321
fulfilledEntry.status = EntryStatus.Fulfilled
277322
fulfilledEntry.tree = tree
@@ -286,6 +331,7 @@ function fulfillRouteCacheEntry(
286331
}
287332
fulfilledEntry.blockedTasks = null
288333
}
334+
return fulfilledEntry
289335
}
290336

291337
function fulfillSegmentCacheEntry(
@@ -348,8 +394,9 @@ async function fetchRouteOnCacheMiss(
348394
// pings the scheduler to unblock the corresponding prefetch task.
349395
const key = task.key
350396
const href = key.href
397+
const nextUrl = key.nextUrl
351398
try {
352-
const response = await fetchSegmentPrefetchResponse(href, '/_tree')
399+
const response = await fetchSegmentPrefetchResponse(href, '/_tree', nextUrl)
353400
if (!response || !response.ok || !response.body) {
354401
// Received an unexpected response.
355402
rejectRouteCacheEntry(entry, Date.now() + 10 * 1000)
@@ -376,9 +423,8 @@ async function fetchRouteOnCacheMiss(
376423

377424
// Check whether the response varies based on the Next-Url header.
378425
const varyHeader = response.headers.get('vary')
379-
const couldBeIntercepted = varyHeader
380-
? varyHeader.includes(NEXT_URL)
381-
: false
426+
const couldBeIntercepted =
427+
varyHeader !== null && varyHeader.includes(NEXT_URL)
382428

383429
fulfillRouteCacheEntry(
384430
entry,
@@ -388,6 +434,26 @@ async function fetchRouteOnCacheMiss(
388434
couldBeIntercepted,
389435
canonicalUrl
390436
)
437+
438+
if (!couldBeIntercepted && nextUrl !== null) {
439+
// This route will never be intercepted. So we can use this entry for all
440+
// requests to this route, regardless of the Next-Url header. This works
441+
// because when reading the cache we always check for a valid
442+
// non-intercepted entry first.
443+
//
444+
// Re-key the entry. Since we're in an async task, we must first confirm
445+
// that the entry hasn't been concurrently modified by a different task.
446+
const currentKeypath: Prefix<RouteCacheKeypath> = [href, nextUrl]
447+
const expectedEntry = routeCacheMap.get(currentKeypath)
448+
if (expectedEntry === entry) {
449+
routeCacheMap.delete(currentKeypath)
450+
const newKeypath: Prefix<RouteCacheKeypath> = [href]
451+
routeCacheMap.set(newKeypath, entry)
452+
} else {
453+
// Something else modified this entry already. Since the re-keying is
454+
// just a performance optimization, we can safely skip it.
455+
}
456+
}
391457
} catch (error) {
392458
// Either the connection itself failed, or something bad happened while
393459
// decoding the response.
@@ -412,7 +478,8 @@ async function fetchSegmentEntryOnCacheMiss(
412478
try {
413479
const response = await fetchSegmentPrefetchResponse(
414480
href,
415-
accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`
481+
accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`,
482+
routeKey.nextUrl
416483
)
417484
if (!response || !response.ok || !response.body) {
418485
// Server responded with an error. We should still cache the response, but
@@ -452,14 +519,18 @@ async function fetchSegmentEntryOnCacheMiss(
452519
}
453520

454521
async function fetchSegmentPrefetchResponse(
455-
href: string,
456-
segmentPath: string
522+
href: NormalizedHref,
523+
segmentPath: string,
524+
nextUrl: NormalizedNextUrl | null
457525
): Promise<Response | null> {
458526
const headers: RequestHeaders = {
459527
[RSC_HEADER]: '1',
460528
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
461529
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: segmentPath,
462530
}
531+
if (nextUrl !== null) {
532+
headers[NEXT_URL] = nextUrl
533+
}
463534
const fetchPriority = 'low'
464535
const responsePromise = createFetch(new URL(href), headers, fetchPriority)
465536
trackPrefetchRequestBandwidth(responsePromise)

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,9 @@ export function navigate(
8686
): AsyncNavigationResult | SuccessfulNavigationResult | NoOpNavigationResult {
8787
const now = Date.now()
8888

89-
// TODO: Interception routes not yet implemented in Segment Cache. Pass a
90-
// Next-URL to createCacheKey.
91-
const cacheKey = createCacheKey(url.href, null)
89+
const cacheKey = createCacheKey(url.href, nextUrl)
9290
const route = readRouteCacheEntry(now, cacheKey)
93-
if (
94-
route !== null &&
95-
route.status === EntryStatus.Fulfilled &&
96-
// TODO: Prefetching interception routes is not support yet by the Segment
97-
// Cache. For now, treat this as a cache miss and fallthrough to a full
98-
// dynamic navigation.
99-
!route.couldBeIntercepted
100-
) {
91+
if (route !== null && route.status === EntryStatus.Fulfilled) {
10192
// We have a matching prefetch.
10293
const snapshot = readRenderSnapshotFromCache(now, route.tree)
10394
const prefetchFlightRouterState = snapshot.flightRouterState

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,12 @@ import { schedulePrefetchTask } from './scheduler'
77
* @param href - The URL to prefetch. Typically this will come from a <Link>,
88
* or router.prefetch. It must be validated before we attempt to prefetch it.
99
*/
10-
export function prefetch(href: string) {
10+
export function prefetch(href: string, nextUrl: string | null) {
1111
const url = createPrefetchURL(href)
1212
if (url === null) {
1313
// This href should not be prefetched.
1414
return
1515
}
16-
17-
// TODO: Interception routes not yet implemented in Segment Cache. Pass a
18-
// Next-URL to createCacheKey.
19-
const cacheKey = createCacheKey(url.href, null)
16+
const cacheKey = createCacheKey(url.href, nextUrl)
2017
schedulePrefetchTask(cacheKey)
2118
}

0 commit comments

Comments
 (0)