Skip to content

[pull] canary from vercel:canary #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { NEXT_REWRITTEN_QUERY_HEADER } from '../app-router-headers'
import type { RSCResponse } from '../router-reducer/fetch-server-response'

// TypeScript trick to simulate opaque types, like in Flow.
type Opaque<K, T> = T & { __brand: K }

Expand Down Expand Up @@ -29,3 +32,18 @@ export function createCacheKey(
} as RouteCacheKey
return cacheKey
}

export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
// If the server performed a rewrite, the search params used to render the
// page will be different from the params in the request URL. In this case,
// the response will include a header that gives the rewritten search query.
const rewrittenQuery = response.headers.get(NEXT_REWRITTEN_QUERY_HEADER)
if (rewrittenQuery !== null) {
return (
rewrittenQuery === '' ? '' : '?' + rewrittenQuery
) as NormalizedSearch
}
// If the header is not present, there was no rewrite, so we use the search
// query of the response URL.
return new URL(response.url).search as NormalizedSearch
}
57 changes: 46 additions & 11 deletions packages/next/src/client/components/segment-cache-impl/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type {
NormalizedSearch,
RouteCacheKey,
} from './cache-key'
import { getRenderedSearch } from './cache-key'
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
import { createLRU } from './lru'
import {
Expand Down Expand Up @@ -130,6 +131,7 @@ type PendingRouteCacheEntry = RouteCacheEntryShared & {
status: EntryStatus.Empty | EntryStatus.Pending
blockedTasks: Set<PrefetchTask> | null
canonicalUrl: null
renderedSearch: null
tree: null
head: HeadData | null
isHeadPartial: true
Expand All @@ -140,6 +142,7 @@ type RejectedRouteCacheEntry = RouteCacheEntryShared & {
status: EntryStatus.Rejected
blockedTasks: Set<PrefetchTask> | null
canonicalUrl: null
renderedSearch: null
tree: null
head: null
isHeadPartial: true
Expand All @@ -150,6 +153,7 @@ export type FulfilledRouteCacheEntry = RouteCacheEntryShared & {
status: EntryStatus.Fulfilled
blockedTasks: null
canonicalUrl: string
renderedSearch: NormalizedSearch
tree: RouteTree
head: HeadData
isHeadPartial: boolean
Expand Down Expand Up @@ -413,29 +417,32 @@ export function getSegmentKeypathForTask(
// cache entry is valid for all possible search param values.
const isDynamicTask = task.includeDynamicData || !route.isPPREnabled
return isDynamicTask && path.endsWith('/' + PAGE_SEGMENT_KEY)
? [path, task.key.search]
? [path, route.renderedSearch]
: [path]
}

export function readSegmentCacheEntry(
now: number,
routeCacheKey: RouteCacheKey,
route: FulfilledRouteCacheEntry,
path: string
): SegmentCacheEntry | null {
if (!path.endsWith('/' + PAGE_SEGMENT_KEY)) {
// Fast path. Search params only exist on page segments.
return readExactSegmentCacheEntry(now, [path])
}

// Page segments may or may not contain search params. If they were prefetched
// using a dynamic request, then we will have an entry with search params.
// Check for that case first.
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
path,
routeCacheKey.search,
])
if (entryWithSearchParams !== null) {
return entryWithSearchParams
const renderedSearch = route.renderedSearch
if (renderedSearch !== null) {
// Page segments may or may not contain search params. If they were prefetched
// using a dynamic request, then we will have an entry with search params.
// Check for that case first.
const entryWithSearchParams = readExactSegmentCacheEntry(now, [
path,
renderedSearch,
])
if (entryWithSearchParams !== null) {
return entryWithSearchParams
}
}

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

// LRU-related fields
keypath: null,
Expand Down Expand Up @@ -783,6 +791,7 @@ function fulfillRouteCacheEntry(
staleAt: number,
couldBeIntercepted: boolean,
canonicalUrl: string,
renderedSearch: NormalizedSearch,
isPPREnabled: boolean
): FulfilledRouteCacheEntry {
const fulfilledEntry: FulfilledRouteCacheEntry = entry as any
Expand All @@ -793,6 +802,7 @@ function fulfillRouteCacheEntry(
fulfilledEntry.staleAt = staleAt
fulfilledEntry.couldBeIntercepted = couldBeIntercepted
fulfilledEntry.canonicalUrl = canonicalUrl
fulfilledEntry.renderedSearch = renderedSearch
fulfilledEntry.isPPREnabled = isPPREnabled
pingBlockedTasks(entry)
return fulfilledEntry
Expand Down Expand Up @@ -1133,6 +1143,11 @@ export async function fetchRouteOnCacheMiss(
return null
}

// Get the search params that were used to render the target page. This may
// be different from the search params in the request URL, if the page
// was rewritten.
const renderedSearch = getRenderedSearch(response)

const staleTimeMs = serverData.staleTime * 1000
fulfillRouteCacheEntry(
entry,
Expand All @@ -1142,6 +1157,7 @@ export async function fetchRouteOnCacheMiss(
Date.now() + staleTimeMs,
couldBeIntercepted,
canonicalUrl,
renderedSearch,
routeIsPPREnabled
)
} else {
Expand Down Expand Up @@ -1366,6 +1382,19 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
return null
}

const renderedSearch = getRenderedSearch(response)
if (renderedSearch !== route.renderedSearch) {
// The search params that were used to render the target page are
// different from the search params in the request URL. This only happens
// when there's a dynamic rewrite in between the tree prefetch and the
// data prefetch.
// TODO: For now, since this is an edge case, we reject the prefetch, but
// the proper way to handle this is to evict the stale route tree entry
// then fill the cache with the new response.
rejectSegmentEntriesIfStillPending(spawnedEntries, Date.now() + 10 * 1000)
return null
}

// Track when the network connection closes.
const closed = createPromiseWithResolvers<void>()

Expand Down Expand Up @@ -1462,6 +1491,11 @@ function writeDynamicTreeResponseIntoCache(
const isResponsePartial =
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'

// Get the search params that were used to render the target page. This may
// be different from the search params in the request URL, if the page
// was rewritten.
const renderedSearch = getRenderedSearch(response)

const fulfilledEntry = fulfillRouteCacheEntry(
entry,
convertRootFlightRouterStateToRouteTree(flightRouterState),
Expand All @@ -1470,6 +1504,7 @@ function writeDynamicTreeResponseIntoCache(
now + staleTimeMs,
couldBeIntercepted,
canonicalUrl,
renderedSearch,
routeIsPPREnabled
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ import {
readSegmentCacheEntry,
waitForSegmentCacheEntry,
type RouteTree,
type FulfilledRouteCacheEntry,
} from './cache'
import { createCacheKey, type RouteCacheKey } from './cache-key'
import {
addSearchParamsIfPageSegment,
PAGE_SEGMENT_KEY,
} from '../../../shared/lib/segment'
import { createCacheKey } from './cache-key'
import { addSearchParamsIfPageSegment } from '../../../shared/lib/segment'
import { NavigationResultTag } from '../segment-cache'

type MPANavigationResult = {
Expand Down Expand Up @@ -116,7 +114,7 @@ export function navigate(
const route = readRouteCacheEntry(now, cacheKey)
if (route !== null && route.status === EntryStatus.Fulfilled) {
// We have a matching prefetch.
const snapshot = readRenderSnapshotFromCache(now, cacheKey, route.tree)
const snapshot = readRenderSnapshotFromCache(now, route, route.tree)
const prefetchFlightRouterState = snapshot.flightRouterState
const prefetchSeedData = snapshot.seedData
const prefetchHead = route.head
Expand Down Expand Up @@ -255,7 +253,7 @@ function navigationTaskToResult(

function readRenderSnapshotFromCache(
now: number,
routeCacheKey: RouteCacheKey,
route: FulfilledRouteCacheEntry,
tree: RouteTree
): { flightRouterState: FlightRouterState; seedData: CacheNodeSeedData } {
let childRouterStates: { [parallelRouteKey: string]: FlightRouterState } = {}
Expand All @@ -266,11 +264,7 @@ function readRenderSnapshotFromCache(
if (slots !== null) {
for (const parallelRouteKey in slots) {
const childTree = slots[parallelRouteKey]
const childResult = readRenderSnapshotFromCache(
now,
routeCacheKey,
childTree
)
const childResult = readRenderSnapshotFromCache(now, route, childTree)
childRouterStates[parallelRouteKey] = childResult.flightRouterState
childSeedDatas[parallelRouteKey] = childResult.seedData
}
Expand All @@ -280,7 +274,7 @@ function readRenderSnapshotFromCache(
let loading: LoadingModuleData | Promise<LoadingModuleData> = null
let isPartial: boolean = true

const segmentEntry = readSegmentCacheEntry(now, routeCacheKey, tree.key)
const segmentEntry = readSegmentCacheEntry(now, route, tree.key)
if (segmentEntry !== null) {
switch (segmentEntry.status) {
case EntryStatus.Fulfilled: {
Expand Down Expand Up @@ -315,23 +309,20 @@ function readRenderSnapshotFromCache(
}
}

const segment =
tree.segment === PAGE_SEGMENT_KEY && routeCacheKey.search
? // The navigation implementation expects the search params to be
// included in the segment. However, the Segment Cache tracks search
// params separately from the rest of the segment key. So we need to
// add them back here.
//
// See corresponding comment in convertFlightRouterStateToTree.
//
// TODO: What we should do instead is update the navigation diffing
// logic to compare search params explicitly. This is a temporary
// solution until more of the Segment Cache implementation has settled.
addSearchParamsIfPageSegment(
tree.segment,
Object.fromEntries(new URLSearchParams(routeCacheKey.search))
)
: tree.segment
// The navigation implementation expects the search params to be
// included in the segment. However, the Segment Cache tracks search
// params separately from the rest of the segment key. So we need to
// add them back here.
//
// See corresponding comment in convertFlightRouterStateToTree.
//
// TODO: What we should do instead is update the navigation diffing
// logic to compare search params explicitly. This is a temporary
// solution until more of the Segment Cache implementation has settled.
const segment = addSearchParamsIfPageSegment(
tree.segment,
Object.fromEntries(new URLSearchParams(route.renderedSearch))
)

return {
flightRouterState: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
FlightRouterState,
Segment as FlightRouterStateSegment,
Segment,
} from '../../../server/app-render/types'
import { HasLoadingBoundary } from '../../../server/app-render/types'
import { matchSegment } from '../match-segments'
Expand Down Expand Up @@ -28,6 +29,10 @@ import {
} from './cache'
import type { RouteCacheKey } from './cache-key'
import { getCurrentCacheVersion, PrefetchPriority } from '../segment-cache'
import {
addSearchParamsIfPageSegment,
PAGE_SEGMENT_KEY,
} from '../../../shared/lib/segment'

const scheduleMicrotask =
typeof queueMicrotask === 'function'
Expand Down Expand Up @@ -657,7 +662,11 @@ function diffRouteTreeAgainstCurrent(
oldTreeChild?.[0]
if (
oldTreeChildSegment !== undefined &&
matchSegment(newTreeChildSegment, oldTreeChildSegment)
doesCurrentSegmentMatchCachedSegment(
route,
newTreeChildSegment,
oldTreeChildSegment
)
) {
// This segment is already part of the current route. Keep traversing.
const requestTreeChild = diffRouteTreeAgainstCurrent(
Expand Down Expand Up @@ -1177,6 +1186,34 @@ function upsertSegmentOnCompletion(
}, noop)
}

function doesCurrentSegmentMatchCachedSegment(
route: FulfilledRouteCacheEntry,
currentSegment: Segment,
cachedSegment: Segment
): boolean {
if (cachedSegment === PAGE_SEGMENT_KEY) {
// In the FlightRouterState stored by the router, the page segment has the
// rendered search params appended to the name of the segment. In the
// prefetch cache, however, this is stored separately. So, when comparing
// the router's current FlightRouterState to the cached FlightRouterState,
// we need to make sure we compare both parts of the segment.
// TODO: This is not modeled clearly. We use the same type,
// FlightRouterState, for both the CacheNode tree _and_ the prefetch cache
// _and_ the server response format, when conceptually those are three
// different things and treated in different ways. We should encode more of
// this information into the type design so mistakes are less likely.
return (
currentSegment ===
addSearchParamsIfPageSegment(
PAGE_SEGMENT_KEY,
Object.fromEntries(new URLSearchParams(route.renderedSearch))
)
)
}
// Non-page segments are compared using the same function as the server
return matchSegment(cachedSegment, currentSegment)
}

// -----------------------------------------------------------------------------
// The remainder of the module is a MinHeap implementation. Try not to put any
// logic below here unless it's related to the heap algorithm. We can extract
Expand Down
Loading
Loading