Skip to content

Commit 52afd63

Browse files
committed
[Segment Cache] Search param fallback handling
This implements search param handling in the Segment Cache. When a cache entry is fetched via PPR, search params are treated as dynamic data. We can share the same cache entry for every set of search params for a given page segment. By contrast, when a cache entry is fetched using a dynamic request (e.g. using `<Link prefetch={true}`), the result may vary by the search param values. So we must include the search params in the cache key. During a navigation, we will first check for the more specific cache entry (i.e. one that includes search params), and then fallback to the PPR version if no such entry exists.
1 parent f20f389 commit 52afd63

File tree

12 files changed

+471
-55
lines changed

12 files changed

+471
-55
lines changed

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

+7-13
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ type Opaque<K, T> = T & { __brand: K }
33

44
// Only functions in this module should be allowed to create CacheKeys.
55
export type NormalizedHref = Opaque<'NormalizedHref', string>
6+
export type NormalizedSearch = Opaque<'NormalizedSearch', string>
67
export type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>
78

89
export type RouteCacheKey = Opaque<
910
'RouteCacheKey',
1011
{
1112
href: NormalizedHref
13+
search: NormalizedSearch
1214
nextUrl: NormalizedNextUrl | null
15+
16+
// TODO: Eventually the dynamic params will be added here, too.
1317
}
1418
>
1519

@@ -18,20 +22,10 @@ export function createCacheKey(
1822
nextUrl: string | null
1923
): RouteCacheKey {
2024
const originalUrl = new URL(originalHref)
21-
22-
// TODO: As of now, we never include search params in the cache key because
23-
// per-segment prefetch requests are always static, and cannot contain search
24-
// params. But to support <Link prefetch={true}>, we will sometimes populate
25-
// the cache with dynamic data, so this will have to change.
26-
originalUrl.search = ''
27-
28-
const normalizedHref = originalUrl.href as NormalizedHref
29-
const normalizedNextUrl = nextUrl as NormalizedNextUrl | null
30-
3125
const cacheKey = {
32-
href: normalizedHref,
33-
nextUrl: normalizedNextUrl,
26+
href: originalHref as NormalizedHref,
27+
search: originalUrl.search as NormalizedSearch,
28+
nextUrl: nextUrl as NormalizedNextUrl | null,
3429
} as RouteCacheKey
35-
3630
return cacheKey
3731
}

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

+113-29
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
3737
import type {
3838
NormalizedHref,
3939
NormalizedNextUrl,
40+
NormalizedSearch,
4041
RouteCacheKey,
4142
} from './cache-key'
4243
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
@@ -52,6 +53,7 @@ import type {
5253
} from '../../../server/app-render/types'
5354
import { normalizeFlightData } from '../../flight-data-helpers'
5455
import { STATIC_STALETIME_MS } from '../router-reducer/prefetch-cache-utils'
56+
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
5557

5658
// A note on async/await when working in the prefetch cache:
5759
//
@@ -156,9 +158,9 @@ type SegmentCacheEntryShared = {
156158
revalidating: SegmentCacheEntry | null
157159

158160
// LRU-related fields
159-
key: null | string
160-
next: null | RouteCacheEntry
161-
prev: null | RouteCacheEntry
161+
keypath: null | Prefix<SegmentCacheKeypath>
162+
next: null | SegmentCacheEntry
163+
prev: null | SegmentCacheEntry
162164
size: number
163165
}
164166

@@ -225,9 +227,9 @@ let routeCacheLru = createLRU<RouteCacheEntry>(
225227
onRouteLRUEviction
226228
)
227229

228-
// TODO: We may eventually store segment entries in a tuple map, too, to
229-
// account for search params.
230-
let segmentCacheMap = new Map<string, SegmentCacheEntry>()
230+
type SegmentCacheKeypath = [string, NormalizedSearch]
231+
let segmentCacheMap: TupleMap<SegmentCacheKeypath, SegmentCacheEntry> =
232+
createTupleMap()
231233
// NOTE: Segments and Route entries are managed by separate LRUs. We could
232234
// combine them into a single LRU, but because they are separate types, we'd
233235
// need to wrap each one in an extra LRU node (to maintain monomorphism, at the
@@ -252,7 +254,7 @@ export function revalidateEntireCache() {
252254
// correctly: background revalidations. See note in `upsertSegmentEntry`.
253255
routeCacheMap = createTupleMap()
254256
routeCacheLru = createLRU(maxRouteLruSize, onRouteLRUEviction)
255-
segmentCacheMap = new Map()
257+
segmentCacheMap = createTupleMap()
256258
segmentCacheLru = createLRU(maxSegmentLruSize, onSegmentLRUEviction)
257259
}
258260

@@ -296,12 +298,59 @@ export function readRouteCacheEntry(
296298
return readExactRouteCacheEntry(now, key.href, key.nextUrl)
297299
}
298300

301+
export function getSegmentKeypathForTask(
302+
task: PrefetchTask,
303+
path: string
304+
): Prefix<SegmentCacheKeypath> {
305+
// When a prefetch includes dynamic data, the search params are included
306+
// in the result, so we must include the search string in the segment
307+
// cache key. (Note that this is true even if the search string is empty.)
308+
//
309+
// If we're fetching using PPR, we do not need to include the search params in
310+
// the cache key, because the search params are treated as dynamic data. The
311+
// cache entry is valid for all possible search param values.
312+
return task.includeDynamicData && path.endsWith('/' + PAGE_SEGMENT_KEY)
313+
? [path, task.key.search]
314+
: [path]
315+
}
316+
299317
export function readSegmentCacheEntry(
300318
now: number,
319+
routeCacheKey: RouteCacheKey,
301320
path: string
302321
): SegmentCacheEntry | null {
303-
const existingEntry = segmentCacheMap.get(path)
304-
if (existingEntry !== undefined) {
322+
if (!path.endsWith('/' + PAGE_SEGMENT_KEY)) {
323+
// Fast path. Search params only exist on page segments.
324+
return readExactSegmentCacheEntry(now, [path])
325+
}
326+
327+
// Page segments may or may not contain search params. If they were prefetched
328+
// using a dynamic request, then we will have an entry with search params.
329+
// Check for that case first.
330+
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
331+
path,
332+
routeCacheKey.search,
333+
])
334+
if (entryWithSearchParams !== null) {
335+
return entryWithSearchParams
336+
}
337+
338+
// If we did not find an entry with the given search params, check for a
339+
// "fallback" entry, where the search params are treated as dynamic data. This
340+
// is the common case because PPR/static prerenders always treat search params
341+
// as dynamic.
342+
//
343+
// See corresponding logic in `getSegmentKeypathForTask`.
344+
const entryWithoutSearchParams = readExactSegmentCacheEntry(now, [path])
345+
return entryWithoutSearchParams
346+
}
347+
348+
function readExactSegmentCacheEntry(
349+
now: number,
350+
keypath: Prefix<SegmentCacheKeypath>
351+
): SegmentCacheEntry | null {
352+
const existingEntry = segmentCacheMap.get(keypath)
353+
if (existingEntry !== null) {
305354
// Check if the entry is stale
306355
if (existingEntry.staleAt > now) {
307356
// Reuse the existing entry.
@@ -315,14 +364,18 @@ export function readSegmentCacheEntry(
315364
const revalidatingEntry = existingEntry.revalidating
316365
if (revalidatingEntry !== null) {
317366
// There's a revalidation in progress. Upsert it.
318-
const upsertedEntry = upsertSegmentEntry(now, path, revalidatingEntry)
367+
const upsertedEntry = upsertSegmentEntry(
368+
now,
369+
keypath,
370+
revalidatingEntry
371+
)
319372
if (upsertedEntry !== null && upsertedEntry.staleAt > now) {
320373
// We can use the upserted revalidation entry.
321374
return upsertedEntry
322375
}
323376
} else {
324377
// Evict the stale entry from the cache.
325-
deleteSegmentFromCache(existingEntry, path)
378+
deleteSegmentFromCache(existingEntry, keypath)
326379
}
327380
}
328381
}
@@ -415,20 +468,22 @@ export function readOrCreateRouteCacheEntry(
415468
*/
416469
export function readOrCreateSegmentCacheEntry(
417470
now: number,
471+
task: PrefetchTask,
418472
// TODO: Don't need to pass the whole route. Just `staleAt`.
419473
route: FulfilledRouteCacheEntry,
420474
path: string
421475
): SegmentCacheEntry {
422-
const existingEntry = readSegmentCacheEntry(now, path)
476+
const keypath = getSegmentKeypathForTask(task, path)
477+
const existingEntry = readExactSegmentCacheEntry(now, keypath)
423478
if (existingEntry !== null) {
424479
return existingEntry
425480
}
426481
// Create a pending entry and add it to the cache.
427482
const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt)
428-
segmentCacheMap.set(path, pendingEntry)
483+
segmentCacheMap.set(keypath, pendingEntry)
429484
// Stash the keypath on the entry so we know how to remove it from the map
430485
// if it gets evicted from the LRU.
431-
pendingEntry.key = path
486+
pendingEntry.keypath = keypath
432487
segmentCacheLru.put(pendingEntry)
433488
return pendingEntry
434489
}
@@ -460,7 +515,7 @@ export function readOrCreateRevalidatingSegmentEntry(
460515

461516
export function upsertSegmentEntry(
462517
now: number,
463-
segmentKeyPath: string,
518+
keypath: Prefix<SegmentCacheKeypath>,
464519
candidateEntry: SegmentCacheEntry
465520
): SegmentCacheEntry | null {
466521
// We have a new entry that has not yet been inserted into the cache. Before
@@ -469,7 +524,7 @@ export function upsertSegmentEntry(
469524
// TODO: We should not upsert an entry if its key was invalidated in the time
470525
// since the request was made. We can do that by passing the "owner" entry to
471526
// this function and confirming it's the same as `existingEntry`.
472-
const existingEntry = readSegmentCacheEntry(now, segmentKeyPath)
527+
const existingEntry = readExactSegmentCacheEntry(now, keypath)
473528
if (existingEntry !== null) {
474529
if (candidateEntry.isPartial && !existingEntry.isPartial) {
475530
// Don't replace a full segment with a partial one. A case where this
@@ -488,12 +543,12 @@ export function upsertSegmentEntry(
488543
return null
489544
}
490545
// Evict the existing entry from the cache.
491-
deleteSegmentFromCache(existingEntry, segmentKeyPath)
546+
deleteSegmentFromCache(existingEntry, keypath)
492547
}
493-
segmentCacheMap.set(segmentKeyPath, candidateEntry)
548+
segmentCacheMap.set(keypath, candidateEntry)
494549
// Stash the keypath on the entry so we know how to remove it from the map
495550
// if it gets evicted from the LRU.
496-
candidateEntry.key = segmentKeyPath
551+
candidateEntry.keypath = keypath
497552
segmentCacheLru.put(candidateEntry)
498553
return candidateEntry
499554
}
@@ -514,7 +569,7 @@ export function createDetachedSegmentCacheEntry(
514569
promise: null,
515570

516571
// LRU-related fields
517-
key: null,
572+
keypath: null,
518573
next: null,
519574
prev: null,
520575
size: 0,
@@ -541,9 +596,12 @@ function deleteRouteFromCache(
541596
routeCacheLru.delete(entry)
542597
}
543598

544-
function deleteSegmentFromCache(entry: SegmentCacheEntry, key: string): void {
599+
function deleteSegmentFromCache(
600+
entry: SegmentCacheEntry,
601+
keypath: Prefix<SegmentCacheKeypath>
602+
): void {
545603
cancelEntryListeners(entry)
546-
segmentCacheMap.delete(key)
604+
segmentCacheMap.delete(keypath)
547605
segmentCacheLru.delete(entry)
548606
clearRevalidatingSegmentFromOwner(entry)
549607
}
@@ -581,11 +639,11 @@ function onRouteLRUEviction(entry: RouteCacheEntry): void {
581639

582640
function onSegmentLRUEviction(entry: SegmentCacheEntry): void {
583641
// The LRU evicted this entry. Remove it from the map.
584-
const key = entry.key
585-
if (key !== null) {
586-
entry.key = null
642+
const keypath = entry.keypath
643+
if (keypath !== null) {
644+
entry.keypath = null
587645
cancelEntryListeners(entry)
588-
segmentCacheMap.delete(key)
646+
segmentCacheMap.delete(keypath)
589647
}
590648
}
591649

@@ -765,9 +823,25 @@ function convertFlightRouterStateToRouteTree(
765823
}
766824
}
767825

826+
// The navigation implementation expects the search params to be included
827+
// in the segment. However, in the case of a static response, the search
828+
// params are omitted. So the client needs to add them back in when reading
829+
// from the Segment Cache.
830+
//
831+
// For consistency, we'll do this for dynamic responses, too.
832+
//
833+
// TODO: We should move search params out of FlightRouterState and handle them
834+
// entirely on the client, similar to our plan for dynamic params.
835+
const originalSegment = flightRouterState[0]
836+
const segmentWithoutSearchParams =
837+
typeof originalSegment === 'string' &&
838+
originalSegment.startsWith(PAGE_SEGMENT_KEY)
839+
? PAGE_SEGMENT_KEY
840+
: originalSegment
841+
768842
return {
769843
key,
770-
segment: flightRouterState[0],
844+
segment: segmentWithoutSearchParams,
771845
slots,
772846
isRootLayout: flightRouterState[4] === true,
773847
}
@@ -1093,6 +1167,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
10931167
// in the LRU as more data comes in.
10941168
fulfilledEntries = writeDynamicRenderResponseIntoCache(
10951169
Date.now(),
1170+
task,
10961171
response,
10971172
serverData,
10981173
route,
@@ -1181,6 +1256,7 @@ function rejectSegmentEntriesIfStillPending(
11811256

11821257
function writeDynamicRenderResponseIntoCache(
11831258
now: number,
1259+
task: PrefetchTask,
11841260
response: Response,
11851261
serverData: NavigationFlightResponse,
11861262
route: FulfilledRouteCacheEntry,
@@ -1231,6 +1307,7 @@ function writeDynamicRenderResponseIntoCache(
12311307
: STATIC_STALETIME_MS
12321308
writeSeedDataIntoCache(
12331309
now,
1310+
task,
12341311
route,
12351312
now + staleTimeMs,
12361313
seedData,
@@ -1256,6 +1333,7 @@ function writeDynamicRenderResponseIntoCache(
12561333

12571334
function writeSeedDataIntoCache(
12581335
now: number,
1336+
task: PrefetchTask,
12591337
route: FulfilledRouteCacheEntry,
12601338
staleAt: number,
12611339
seedData: CacheNodeSeedData,
@@ -1279,7 +1357,12 @@ function writeSeedDataIntoCache(
12791357
fulfillSegmentCacheEntry(ownedEntry, rsc, loading, staleAt, isPartial)
12801358
} else {
12811359
// There's no matching entry. Attempt to create a new one.
1282-
const possiblyNewEntry = readOrCreateSegmentCacheEntry(now, route, key)
1360+
const possiblyNewEntry = readOrCreateSegmentCacheEntry(
1361+
now,
1362+
task,
1363+
route,
1364+
key
1365+
)
12831366
if (possiblyNewEntry.status === EntryStatus.Empty) {
12841367
// Confirmed this is a new entry. We can fulfill it.
12851368
const newEntry = possiblyNewEntry
@@ -1294,7 +1377,7 @@ function writeSeedDataIntoCache(
12941377
staleAt,
12951378
isPartial
12961379
)
1297-
upsertSegmentEntry(now, key, newEntry)
1380+
upsertSegmentEntry(now, getSegmentKeypathForTask(task, key), newEntry)
12981381
}
12991382
}
13001383
// Recursively write the child data into the cache.
@@ -1306,6 +1389,7 @@ function writeSeedDataIntoCache(
13061389
const childSegment = childSeedData[0]
13071390
writeSeedDataIntoCache(
13081391
now,
1392+
task,
13091393
route,
13101394
staleAt,
13111395
childSeedData,

0 commit comments

Comments
 (0)