Skip to content

Commit 9fd1766

Browse files
authored
[Segment Cache] Search param fallback handling (#75990)
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 6a30eb4 commit 9fd1766

File tree

12 files changed

+484
-56
lines changed

12 files changed

+484
-56
lines changed

packages/next/src/client/components/segment-cache-impl/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-impl/cache.ts

+119-30
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
3636
import type {
3737
NormalizedHref,
3838
NormalizedNextUrl,
39+
NormalizedSearch,
3940
RouteCacheKey,
4041
} from './cache-key'
4142
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
@@ -53,6 +54,7 @@ import type {
5354
import { normalizeFlightData } from '../../flight-data-helpers'
5455
import { STATIC_STALETIME_MS } from '../router-reducer/prefetch-cache-utils'
5556
import { pingVisibleLinks } from '../links'
57+
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
5658

5759
// A note on async/await when working in the prefetch cache:
5860
//
@@ -157,9 +159,9 @@ type SegmentCacheEntryShared = {
157159
revalidating: SegmentCacheEntry | null
158160

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

@@ -230,9 +232,9 @@ let routeCacheLru = createLRU<RouteCacheEntry>(
230232
onRouteLRUEviction
231233
)
232234

233-
// TODO: We may eventually store segment entries in a tuple map, too, to
234-
// account for search params.
235-
let segmentCacheMap = new Map<string, SegmentCacheEntry>()
235+
type SegmentCacheKeypath = [string, NormalizedSearch]
236+
let segmentCacheMap: TupleMap<SegmentCacheKeypath, SegmentCacheEntry> =
237+
createTupleMap()
236238
// NOTE: Segments and Route entries are managed by separate LRUs. We could
237239
// combine them into a single LRU, but because they are separate types, we'd
238240
// need to wrap each one in an extra LRU node (to maintain monomorphism, at the
@@ -269,7 +271,7 @@ export function revalidateEntireCache(
269271
// correctly: background revalidations. See note in `upsertSegmentEntry`.
270272
routeCacheMap = createTupleMap()
271273
routeCacheLru = createLRU(maxRouteLruSize, onRouteLRUEviction)
272-
segmentCacheMap = new Map()
274+
segmentCacheMap = createTupleMap()
273275
segmentCacheLru = createLRU(maxSegmentLruSize, onSegmentLRUEviction)
274276

275277
// Prefetch all the currently visible links again, to re-fill the cache.
@@ -316,12 +318,61 @@ export function readRouteCacheEntry(
316318
return readExactRouteCacheEntry(now, key.href, key.nextUrl)
317319
}
318320

321+
export function getSegmentKeypathForTask(
322+
task: PrefetchTask,
323+
route: FulfilledRouteCacheEntry,
324+
path: string
325+
): Prefix<SegmentCacheKeypath> {
326+
// When a prefetch includes dynamic data, the search params are included
327+
// in the result, so we must include the search string in the segment
328+
// cache key. (Note that this is true even if the search string is empty.)
329+
//
330+
// If we're fetching using PPR, we do not need to include the search params in
331+
// the cache key, because the search params are treated as dynamic data. The
332+
// cache entry is valid for all possible search param values.
333+
const isDynamicTask = task.includeDynamicData || !route.isPPREnabled
334+
return isDynamicTask && path.endsWith('/' + PAGE_SEGMENT_KEY)
335+
? [path, task.key.search]
336+
: [path]
337+
}
338+
319339
export function readSegmentCacheEntry(
320340
now: number,
341+
routeCacheKey: RouteCacheKey,
321342
path: string
322343
): SegmentCacheEntry | null {
323-
const existingEntry = segmentCacheMap.get(path)
324-
if (existingEntry !== undefined) {
344+
if (!path.endsWith('/' + PAGE_SEGMENT_KEY)) {
345+
// Fast path. Search params only exist on page segments.
346+
return readExactSegmentCacheEntry(now, [path])
347+
}
348+
349+
// Page segments may or may not contain search params. If they were prefetched
350+
// using a dynamic request, then we will have an entry with search params.
351+
// Check for that case first.
352+
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
353+
path,
354+
routeCacheKey.search,
355+
])
356+
if (entryWithSearchParams !== null) {
357+
return entryWithSearchParams
358+
}
359+
360+
// If we did not find an entry with the given search params, check for a
361+
// "fallback" entry, where the search params are treated as dynamic data. This
362+
// is the common case because PPR/static prerenders always treat search params
363+
// as dynamic.
364+
//
365+
// See corresponding logic in `getSegmentKeypathForTask`.
366+
const entryWithoutSearchParams = readExactSegmentCacheEntry(now, [path])
367+
return entryWithoutSearchParams
368+
}
369+
370+
function readExactSegmentCacheEntry(
371+
now: number,
372+
keypath: Prefix<SegmentCacheKeypath>
373+
): SegmentCacheEntry | null {
374+
const existingEntry = segmentCacheMap.get(keypath)
375+
if (existingEntry !== null) {
325376
// Check if the entry is stale
326377
if (existingEntry.staleAt > now) {
327378
// Reuse the existing entry.
@@ -335,14 +386,18 @@ export function readSegmentCacheEntry(
335386
const revalidatingEntry = existingEntry.revalidating
336387
if (revalidatingEntry !== null) {
337388
// There's a revalidation in progress. Upsert it.
338-
const upsertedEntry = upsertSegmentEntry(now, path, revalidatingEntry)
389+
const upsertedEntry = upsertSegmentEntry(
390+
now,
391+
keypath,
392+
revalidatingEntry
393+
)
339394
if (upsertedEntry !== null && upsertedEntry.staleAt > now) {
340395
// We can use the upserted revalidation entry.
341396
return upsertedEntry
342397
}
343398
} else {
344399
// Evict the stale entry from the cache.
345-
deleteSegmentFromCache(existingEntry, path)
400+
deleteSegmentFromCache(existingEntry, keypath)
346401
}
347402
}
348403
}
@@ -435,20 +490,21 @@ export function readOrCreateRouteCacheEntry(
435490
*/
436491
export function readOrCreateSegmentCacheEntry(
437492
now: number,
438-
// TODO: Don't need to pass the whole route. Just `staleAt`.
493+
task: PrefetchTask,
439494
route: FulfilledRouteCacheEntry,
440495
path: string
441496
): SegmentCacheEntry {
442-
const existingEntry = readSegmentCacheEntry(now, path)
497+
const keypath = getSegmentKeypathForTask(task, route, path)
498+
const existingEntry = readExactSegmentCacheEntry(now, keypath)
443499
if (existingEntry !== null) {
444500
return existingEntry
445501
}
446502
// Create a pending entry and add it to the cache.
447503
const pendingEntry = createDetachedSegmentCacheEntry(route.staleAt)
448-
segmentCacheMap.set(path, pendingEntry)
504+
segmentCacheMap.set(keypath, pendingEntry)
449505
// Stash the keypath on the entry so we know how to remove it from the map
450506
// if it gets evicted from the LRU.
451-
pendingEntry.key = path
507+
pendingEntry.keypath = keypath
452508
segmentCacheLru.put(pendingEntry)
453509
return pendingEntry
454510
}
@@ -480,7 +536,7 @@ export function readOrCreateRevalidatingSegmentEntry(
480536

481537
export function upsertSegmentEntry(
482538
now: number,
483-
segmentKeyPath: string,
539+
keypath: Prefix<SegmentCacheKeypath>,
484540
candidateEntry: SegmentCacheEntry
485541
): SegmentCacheEntry | null {
486542
// We have a new entry that has not yet been inserted into the cache. Before
@@ -489,7 +545,7 @@ export function upsertSegmentEntry(
489545
// TODO: We should not upsert an entry if its key was invalidated in the time
490546
// since the request was made. We can do that by passing the "owner" entry to
491547
// this function and confirming it's the same as `existingEntry`.
492-
const existingEntry = readSegmentCacheEntry(now, segmentKeyPath)
548+
const existingEntry = readExactSegmentCacheEntry(now, keypath)
493549
if (existingEntry !== null) {
494550
if (candidateEntry.isPartial && !existingEntry.isPartial) {
495551
// Don't replace a full segment with a partial one. A case where this
@@ -508,12 +564,12 @@ export function upsertSegmentEntry(
508564
return null
509565
}
510566
// Evict the existing entry from the cache.
511-
deleteSegmentFromCache(existingEntry, segmentKeyPath)
567+
deleteSegmentFromCache(existingEntry, keypath)
512568
}
513-
segmentCacheMap.set(segmentKeyPath, candidateEntry)
569+
segmentCacheMap.set(keypath, candidateEntry)
514570
// Stash the keypath on the entry so we know how to remove it from the map
515571
// if it gets evicted from the LRU.
516-
candidateEntry.key = segmentKeyPath
572+
candidateEntry.keypath = keypath
517573
segmentCacheLru.put(candidateEntry)
518574
return candidateEntry
519575
}
@@ -534,7 +590,7 @@ export function createDetachedSegmentCacheEntry(
534590
promise: null,
535591

536592
// LRU-related fields
537-
key: null,
593+
keypath: null,
538594
next: null,
539595
prev: null,
540596
size: 0,
@@ -561,9 +617,12 @@ function deleteRouteFromCache(
561617
routeCacheLru.delete(entry)
562618
}
563619

564-
function deleteSegmentFromCache(entry: SegmentCacheEntry, key: string): void {
620+
function deleteSegmentFromCache(
621+
entry: SegmentCacheEntry,
622+
keypath: Prefix<SegmentCacheKeypath>
623+
): void {
565624
cancelEntryListeners(entry)
566-
segmentCacheMap.delete(key)
625+
segmentCacheMap.delete(keypath)
567626
segmentCacheLru.delete(entry)
568627
clearRevalidatingSegmentFromOwner(entry)
569628
}
@@ -601,11 +660,11 @@ function onRouteLRUEviction(entry: RouteCacheEntry): void {
601660

602661
function onSegmentLRUEviction(entry: SegmentCacheEntry): void {
603662
// The LRU evicted this entry. Remove it from the map.
604-
const key = entry.key
605-
if (key !== null) {
606-
entry.key = null
663+
const keypath = entry.keypath
664+
if (keypath !== null) {
665+
entry.keypath = null
607666
cancelEntryListeners(entry)
608-
segmentCacheMap.delete(key)
667+
segmentCacheMap.delete(keypath)
609668
}
610669
}
611670

@@ -785,9 +844,25 @@ function convertFlightRouterStateToRouteTree(
785844
}
786845
}
787846

847+
// The navigation implementation expects the search params to be included
848+
// in the segment. However, in the case of a static response, the search
849+
// params are omitted. So the client needs to add them back in when reading
850+
// from the Segment Cache.
851+
//
852+
// For consistency, we'll do this for dynamic responses, too.
853+
//
854+
// TODO: We should move search params out of FlightRouterState and handle them
855+
// entirely on the client, similar to our plan for dynamic params.
856+
const originalSegment = flightRouterState[0]
857+
const segmentWithoutSearchParams =
858+
typeof originalSegment === 'string' &&
859+
originalSegment.startsWith(PAGE_SEGMENT_KEY)
860+
? PAGE_SEGMENT_KEY
861+
: originalSegment
862+
788863
return {
789864
key,
790-
segment: flightRouterState[0],
865+
segment: segmentWithoutSearchParams,
791866
slots,
792867
isRootLayout: flightRouterState[4] === true,
793868
}
@@ -1174,6 +1249,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
11741249
// in the LRU as more data comes in.
11751250
fulfilledEntries = writeDynamicRenderResponseIntoCache(
11761251
Date.now(),
1252+
task,
11771253
response,
11781254
serverData,
11791255
route,
@@ -1262,6 +1338,7 @@ function rejectSegmentEntriesIfStillPending(
12621338

12631339
function writeDynamicRenderResponseIntoCache(
12641340
now: number,
1341+
task: PrefetchTask,
12651342
response: Response,
12661343
serverData: NavigationFlightResponse,
12671344
route: FulfilledRouteCacheEntry,
@@ -1312,6 +1389,7 @@ function writeDynamicRenderResponseIntoCache(
13121389
: STATIC_STALETIME_MS
13131390
writeSeedDataIntoCache(
13141391
now,
1392+
task,
13151393
route,
13161394
now + staleTimeMs,
13171395
seedData,
@@ -1337,6 +1415,7 @@ function writeDynamicRenderResponseIntoCache(
13371415

13381416
function writeSeedDataIntoCache(
13391417
now: number,
1418+
task: PrefetchTask,
13401419
route: FulfilledRouteCacheEntry,
13411420
staleAt: number,
13421421
seedData: CacheNodeSeedData,
@@ -1360,7 +1439,12 @@ function writeSeedDataIntoCache(
13601439
fulfillSegmentCacheEntry(ownedEntry, rsc, loading, staleAt, isPartial)
13611440
} else {
13621441
// There's no matching entry. Attempt to create a new one.
1363-
const possiblyNewEntry = readOrCreateSegmentCacheEntry(now, route, key)
1442+
const possiblyNewEntry = readOrCreateSegmentCacheEntry(
1443+
now,
1444+
task,
1445+
route,
1446+
key
1447+
)
13641448
if (possiblyNewEntry.status === EntryStatus.Empty) {
13651449
// Confirmed this is a new entry. We can fulfill it.
13661450
const newEntry = possiblyNewEntry
@@ -1375,7 +1459,11 @@ function writeSeedDataIntoCache(
13751459
staleAt,
13761460
isPartial
13771461
)
1378-
upsertSegmentEntry(now, key, newEntry)
1462+
upsertSegmentEntry(
1463+
now,
1464+
getSegmentKeypathForTask(task, route, key),
1465+
newEntry
1466+
)
13791467
}
13801468
}
13811469
// Recursively write the child data into the cache.
@@ -1387,6 +1475,7 @@ function writeSeedDataIntoCache(
13871475
const childSegment = childSeedData[0]
13881476
writeSeedDataIntoCache(
13891477
now,
1478+
task,
13901479
route,
13911480
staleAt,
13921481
childSeedData,

0 commit comments

Comments
 (0)