Skip to content

Commit 1964b17

Browse files
acdlitelubieowoce
andauthored
[Segment Cache] Fix: Key by rewritten search (vercel#81986)
When a request URL is rewritten, the resulting data must be keyed by its rewritten search params — the ones that were used by the server to render the page — not the original search params. This works in most places by encoding the rewritten search params into page's segment key in the server response. However, the Segment Cache implementation did not handle this correctly. The fix is to read the rewritten search params from the x-nextjs-rewritten-query header in the server response. In upcoming PRs, we will use a similar approach for regular route params, too, so that we can lift the params out of the body of the server response. --------- Co-authored-by: Janka Uryga <[email protected]>
1 parent 329bcdc commit 1964b17

File tree

8 files changed

+371
-44
lines changed

8 files changed

+371
-44
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { NEXT_REWRITTEN_QUERY_HEADER } from '../app-router-headers'
2+
import type { RSCResponse } from '../router-reducer/fetch-server-response'
3+
14
// TypeScript trick to simulate opaque types, like in Flow.
25
type Opaque<K, T> = T & { __brand: K }
36

@@ -29,3 +32,18 @@ export function createCacheKey(
2932
} as RouteCacheKey
3033
return cacheKey
3134
}
35+
36+
export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
37+
// If the server performed a rewrite, the search params used to render the
38+
// page will be different from the params in the request URL. In this case,
39+
// the response will include a header that gives the rewritten search query.
40+
const rewrittenQuery = response.headers.get(NEXT_REWRITTEN_QUERY_HEADER)
41+
if (rewrittenQuery !== null) {
42+
return (
43+
rewrittenQuery === '' ? '' : '?' + rewrittenQuery
44+
) as NormalizedSearch
45+
}
46+
// If the header is not present, there was no rewrite, so we use the search
47+
// query of the response URL.
48+
return new URL(response.url).search as NormalizedSearch
49+
}

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

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type {
4242
NormalizedSearch,
4343
RouteCacheKey,
4444
} from './cache-key'
45+
import { getRenderedSearch } from './cache-key'
4546
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
4647
import { createLRU } from './lru'
4748
import {
@@ -130,6 +131,7 @@ type PendingRouteCacheEntry = RouteCacheEntryShared & {
130131
status: EntryStatus.Empty | EntryStatus.Pending
131132
blockedTasks: Set<PrefetchTask> | null
132133
canonicalUrl: null
134+
renderedSearch: null
133135
tree: null
134136
head: HeadData | null
135137
isHeadPartial: true
@@ -140,6 +142,7 @@ type RejectedRouteCacheEntry = RouteCacheEntryShared & {
140142
status: EntryStatus.Rejected
141143
blockedTasks: Set<PrefetchTask> | null
142144
canonicalUrl: null
145+
renderedSearch: null
143146
tree: null
144147
head: null
145148
isHeadPartial: true
@@ -150,6 +153,7 @@ export type FulfilledRouteCacheEntry = RouteCacheEntryShared & {
150153
status: EntryStatus.Fulfilled
151154
blockedTasks: null
152155
canonicalUrl: string
156+
renderedSearch: NormalizedSearch
153157
tree: RouteTree
154158
head: HeadData
155159
isHeadPartial: boolean
@@ -413,29 +417,32 @@ export function getSegmentKeypathForTask(
413417
// cache entry is valid for all possible search param values.
414418
const isDynamicTask = task.includeDynamicData || !route.isPPREnabled
415419
return isDynamicTask && path.endsWith('/' + PAGE_SEGMENT_KEY)
416-
? [path, task.key.search]
420+
? [path, route.renderedSearch]
417421
: [path]
418422
}
419423

420424
export function readSegmentCacheEntry(
421425
now: number,
422-
routeCacheKey: RouteCacheKey,
426+
route: FulfilledRouteCacheEntry,
423427
path: string
424428
): SegmentCacheEntry | null {
425429
if (!path.endsWith('/' + PAGE_SEGMENT_KEY)) {
426430
// Fast path. Search params only exist on page segments.
427431
return readExactSegmentCacheEntry(now, [path])
428432
}
429433

430-
// Page segments may or may not contain search params. If they were prefetched
431-
// using a dynamic request, then we will have an entry with search params.
432-
// Check for that case first.
433-
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
434-
path,
435-
routeCacheKey.search,
436-
])
437-
if (entryWithSearchParams !== null) {
438-
return entryWithSearchParams
434+
const renderedSearch = route.renderedSearch
435+
if (renderedSearch !== null) {
436+
// Page segments may or may not contain search params. If they were prefetched
437+
// using a dynamic request, then we will have an entry with search params.
438+
// Check for that case first.
439+
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
440+
path,
441+
renderedSearch,
442+
])
443+
if (entryWithSearchParams !== null) {
444+
return entryWithSearchParams
445+
}
439446
}
440447

441448
// If we did not find an entry with the given search params, check for a
@@ -550,6 +557,7 @@ export function readOrCreateRouteCacheEntry(
550557
couldBeIntercepted: true,
551558
// Similarly, we don't yet know if the route supports PPR.
552559
isPPREnabled: false,
560+
renderedSearch: null,
553561

554562
// LRU-related fields
555563
keypath: null,
@@ -783,6 +791,7 @@ function fulfillRouteCacheEntry(
783791
staleAt: number,
784792
couldBeIntercepted: boolean,
785793
canonicalUrl: string,
794+
renderedSearch: NormalizedSearch,
786795
isPPREnabled: boolean
787796
): FulfilledRouteCacheEntry {
788797
const fulfilledEntry: FulfilledRouteCacheEntry = entry as any
@@ -793,6 +802,7 @@ function fulfillRouteCacheEntry(
793802
fulfilledEntry.staleAt = staleAt
794803
fulfilledEntry.couldBeIntercepted = couldBeIntercepted
795804
fulfilledEntry.canonicalUrl = canonicalUrl
805+
fulfilledEntry.renderedSearch = renderedSearch
796806
fulfilledEntry.isPPREnabled = isPPREnabled
797807
pingBlockedTasks(entry)
798808
return fulfilledEntry
@@ -1133,6 +1143,11 @@ export async function fetchRouteOnCacheMiss(
11331143
return null
11341144
}
11351145

1146+
// Get the search params that were used to render the target page. This may
1147+
// be different from the search params in the request URL, if the page
1148+
// was rewritten.
1149+
const renderedSearch = getRenderedSearch(response)
1150+
11361151
const staleTimeMs = serverData.staleTime * 1000
11371152
fulfillRouteCacheEntry(
11381153
entry,
@@ -1142,6 +1157,7 @@ export async function fetchRouteOnCacheMiss(
11421157
Date.now() + staleTimeMs,
11431158
couldBeIntercepted,
11441159
canonicalUrl,
1160+
renderedSearch,
11451161
routeIsPPREnabled
11461162
)
11471163
} else {
@@ -1366,6 +1382,19 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
13661382
return null
13671383
}
13681384

1385+
const renderedSearch = getRenderedSearch(response)
1386+
if (renderedSearch !== route.renderedSearch) {
1387+
// The search params that were used to render the target page are
1388+
// different from the search params in the request URL. This only happens
1389+
// when there's a dynamic rewrite in between the tree prefetch and the
1390+
// data prefetch.
1391+
// TODO: For now, since this is an edge case, we reject the prefetch, but
1392+
// the proper way to handle this is to evict the stale route tree entry
1393+
// then fill the cache with the new response.
1394+
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
1395+
return null
1396+
}
1397+
13691398
// Track when the network connection closes.
13701399
const closed = createPromiseWithResolvers<void>()
13711400

@@ -1462,6 +1491,11 @@ function writeDynamicTreeResponseIntoCache(
14621491
const isResponsePartial =
14631492
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'
14641493

1494+
// Get the search params that were used to render the target page. This may
1495+
// be different from the search params in the request URL, if the page
1496+
// was rewritten.
1497+
const renderedSearch = getRenderedSearch(response)
1498+
14651499
const fulfilledEntry = fulfillRouteCacheEntry(
14661500
entry,
14671501
convertRootFlightRouterStateToRouteTree(flightRouterState),
@@ -1470,6 +1504,7 @@ function writeDynamicTreeResponseIntoCache(
14701504
now + staleTimeMs,
14711505
couldBeIntercepted,
14721506
canonicalUrl,
1507+
renderedSearch,
14731508
routeIsPPREnabled
14741509
)
14751510

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

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,10 @@ import {
2222
readSegmentCacheEntry,
2323
waitForSegmentCacheEntry,
2424
type RouteTree,
25+
type FulfilledRouteCacheEntry,
2526
} from './cache'
26-
import { createCacheKey, type RouteCacheKey } from './cache-key'
27-
import {
28-
addSearchParamsIfPageSegment,
29-
PAGE_SEGMENT_KEY,
30-
} from '../../../shared/lib/segment'
27+
import { createCacheKey } from './cache-key'
28+
import { addSearchParamsIfPageSegment } from '../../../shared/lib/segment'
3129
import { NavigationResultTag } from '../segment-cache'
3230

3331
type MPANavigationResult = {
@@ -116,7 +114,7 @@ export function navigate(
116114
const route = readRouteCacheEntry(now, cacheKey)
117115
if (route !== null && route.status === EntryStatus.Fulfilled) {
118116
// We have a matching prefetch.
119-
const snapshot = readRenderSnapshotFromCache(now, cacheKey, route.tree)
117+
const snapshot = readRenderSnapshotFromCache(now, route, route.tree)
120118
const prefetchFlightRouterState = snapshot.flightRouterState
121119
const prefetchSeedData = snapshot.seedData
122120
const prefetchHead = route.head
@@ -255,7 +253,7 @@ function navigationTaskToResult(
255253

256254
function readRenderSnapshotFromCache(
257255
now: number,
258-
routeCacheKey: RouteCacheKey,
256+
route: FulfilledRouteCacheEntry,
259257
tree: RouteTree
260258
): { flightRouterState: FlightRouterState; seedData: CacheNodeSeedData } {
261259
let childRouterStates: { [parallelRouteKey: string]: FlightRouterState } = {}
@@ -266,11 +264,7 @@ function readRenderSnapshotFromCache(
266264
if (slots !== null) {
267265
for (const parallelRouteKey in slots) {
268266
const childTree = slots[parallelRouteKey]
269-
const childResult = readRenderSnapshotFromCache(
270-
now,
271-
routeCacheKey,
272-
childTree
273-
)
267+
const childResult = readRenderSnapshotFromCache(now, route, childTree)
274268
childRouterStates[parallelRouteKey] = childResult.flightRouterState
275269
childSeedDatas[parallelRouteKey] = childResult.seedData
276270
}
@@ -280,7 +274,7 @@ function readRenderSnapshotFromCache(
280274
let loading: LoadingModuleData | Promise<LoadingModuleData> = null
281275
let isPartial: boolean = true
282276

283-
const segmentEntry = readSegmentCacheEntry(now, routeCacheKey, tree.key)
277+
const segmentEntry = readSegmentCacheEntry(now, route, tree.key)
284278
if (segmentEntry !== null) {
285279
switch (segmentEntry.status) {
286280
case EntryStatus.Fulfilled: {
@@ -315,23 +309,20 @@ function readRenderSnapshotFromCache(
315309
}
316310
}
317311

318-
const segment =
319-
tree.segment === PAGE_SEGMENT_KEY && routeCacheKey.search
320-
? // The navigation implementation expects the search params to be
321-
// included in the segment. However, the Segment Cache tracks search
322-
// params separately from the rest of the segment key. So we need to
323-
// add them back here.
324-
//
325-
// See corresponding comment in convertFlightRouterStateToTree.
326-
//
327-
// TODO: What we should do instead is update the navigation diffing
328-
// logic to compare search params explicitly. This is a temporary
329-
// solution until more of the Segment Cache implementation has settled.
330-
addSearchParamsIfPageSegment(
331-
tree.segment,
332-
Object.fromEntries(new URLSearchParams(routeCacheKey.search))
333-
)
334-
: tree.segment
312+
// The navigation implementation expects the search params to be
313+
// included in the segment. However, the Segment Cache tracks search
314+
// params separately from the rest of the segment key. So we need to
315+
// add them back here.
316+
//
317+
// See corresponding comment in convertFlightRouterStateToTree.
318+
//
319+
// TODO: What we should do instead is update the navigation diffing
320+
// logic to compare search params explicitly. This is a temporary
321+
// solution until more of the Segment Cache implementation has settled.
322+
const segment = addSearchParamsIfPageSegment(
323+
tree.segment,
324+
Object.fromEntries(new URLSearchParams(route.renderedSearch))
325+
)
335326

336327
return {
337328
flightRouterState: [

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
FlightRouterState,
33
Segment as FlightRouterStateSegment,
4+
Segment,
45
} from '../../../server/app-render/types'
56
import { HasLoadingBoundary } from '../../../server/app-render/types'
67
import { matchSegment } from '../match-segments'
@@ -28,6 +29,10 @@ import {
2829
} from './cache'
2930
import type { RouteCacheKey } from './cache-key'
3031
import { getCurrentCacheVersion, PrefetchPriority } from '../segment-cache'
32+
import {
33+
addSearchParamsIfPageSegment,
34+
PAGE_SEGMENT_KEY,
35+
} from '../../../shared/lib/segment'
3136

3237
const scheduleMicrotask =
3338
typeof queueMicrotask === 'function'
@@ -657,7 +662,11 @@ function diffRouteTreeAgainstCurrent(
657662
oldTreeChild?.[0]
658663
if (
659664
oldTreeChildSegment !== undefined &&
660-
matchSegment(newTreeChildSegment, oldTreeChildSegment)
665+
doesCurrentSegmentMatchCachedSegment(
666+
route,
667+
newTreeChildSegment,
668+
oldTreeChildSegment
669+
)
661670
) {
662671
// This segment is already part of the current route. Keep traversing.
663672
const requestTreeChild = diffRouteTreeAgainstCurrent(
@@ -1177,6 +1186,34 @@ function upsertSegmentOnCompletion(
11771186
}, noop)
11781187
}
11791188

1189+
function doesCurrentSegmentMatchCachedSegment(
1190+
route: FulfilledRouteCacheEntry,
1191+
currentSegment: Segment,
1192+
cachedSegment: Segment
1193+
): boolean {
1194+
if (cachedSegment === PAGE_SEGMENT_KEY) {
1195+
// In the FlightRouterState stored by the router, the page segment has the
1196+
// rendered search params appended to the name of the segment. In the
1197+
// prefetch cache, however, this is stored separately. So, when comparing
1198+
// the router's current FlightRouterState to the cached FlightRouterState,
1199+
// we need to make sure we compare both parts of the segment.
1200+
// TODO: This is not modeled clearly. We use the same type,
1201+
// FlightRouterState, for both the CacheNode tree _and_ the prefetch cache
1202+
// _and_ the server response format, when conceptually those are three
1203+
// different things and treated in different ways. We should encode more of
1204+
// this information into the type design so mistakes are less likely.
1205+
return (
1206+
currentSegment ===
1207+
addSearchParamsIfPageSegment(
1208+
PAGE_SEGMENT_KEY,
1209+
Object.fromEntries(new URLSearchParams(route.renderedSearch))
1210+
)
1211+
)
1212+
}
1213+
// Non-page segments are compared using the same function as the server
1214+
return matchSegment(cachedSegment, currentSegment)
1215+
}
1216+
11801217
// -----------------------------------------------------------------------------
11811218
// The remainder of the module is a MinHeap implementation. Try not to put any
11821219
// logic below here unless it's related to the heap algorithm. We can extract

0 commit comments

Comments
 (0)