Skip to content

Commit ea5b03b

Browse files
acdliteztanner
andauthored
[Segment Cache] Optimistic prefetch for search params (#82586)
When there is no matching route tree in the prefetch cache, before we de-opt to a blocking navigation, we will first attempt to construct an "optimistic" route tree by checking the cache for routes are likely to be similar to the one we're missing. If there's a route with the same pathname, but with different search params, we can base our optimistic route on that entry. Conceptually, we are simulating what would happen if we did perform a prefetch the requested URL, under the assumption that the server will not redirect or rewrite the request in a different manner than the base route tree. This assumption might not hold, in which case we'll have to recover when we perform the dynamic navigation request. However, this is what would happen if a route were dynamically rewritten/ redirected in between the prefetch and the navigation. So the logic needs to exist to handle this case regardless. The implementation in this PR is a bit of an incremental step; it's not as general as it should be. Notably, it will bail out if the base route tree contains dynamic metadata, because we currently don't store the route tree separately from the metadata. We are also currently special-casing prefetch entries with an empty search string. To take advantage of the optimistic prefetch behavior, there must be a prefetch entry for the target URL with no search params, e.g. to navigate to a page at `/target-page?search=foobar`, you must first prefetch `/target-page` (no search string). This is a somewhat arbitrary limitation chosen as a concession to implementation complexity; the empty search string is only used because it's the one that's most likely to already be cached. We will generalize this later to match any search string. I've added some TODO comments to describe the work necessary to enable this mechanism in more cases. Co-authored-by: Zack Tanner <[email protected]>
1 parent 823b9f5 commit ea5b03b

File tree

6 files changed

+318
-3
lines changed

6 files changed

+318
-3
lines changed

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

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import type {
4343
NormalizedSearch,
4444
RouteCacheKey,
4545
} from './cache-key'
46+
// TODO: Rename this module to avoid confusion with other types of cache keys
47+
import { createCacheKey as createPrefetchRequestKey } from './cache-key'
4648
import {
4749
doesStaticSegmentAppearInURL,
4850
getCacheKeyForDynamicParam,
@@ -130,6 +132,7 @@ type RouteCacheEntryShared = {
130132

131133
// See comment in scheduler.ts for context
132134
TODO_metadataStatus: EntryStatus.Empty | EntryStatus.Fulfilled
135+
TODO_isHeadDynamic: boolean
133136

134137
// LRU-related fields
135138
keypath: null | Prefix<RouteCacheKeypath>
@@ -588,6 +591,7 @@ export function readOrCreateRouteCacheEntry(
588591
renderedSearch: null,
589592

590593
TODO_metadataStatus: EntryStatus.Empty,
594+
TODO_isHeadDynamic: false,
591595

592596
// LRU-related fields
593597
keypath: null,
@@ -605,6 +609,135 @@ export function readOrCreateRouteCacheEntry(
605609
return pendingEntry
606610
}
607611

612+
export function requestOptimisticRouteCacheEntry(
613+
now: number,
614+
requestedUrl: URL,
615+
nextUrl: string | null
616+
): FulfilledRouteCacheEntry | null {
617+
// This function is called during a navigation when there was no matching
618+
// route tree in the prefetch cache. Before de-opting to a blocking,
619+
// unprefetched navigation, we will first attempt to construct an "optimistic"
620+
// route tree by checking the cache for similar routes.
621+
//
622+
// Check if there's a route with the same pathname, but with different
623+
// search params. We can then base our optimistic route tree on this entry.
624+
//
625+
// Conceptually, we are simulating what would happen if we did perform a
626+
// prefetch the requested URL, under the assumption that the server will
627+
// not redirect or rewrite the request in a different manner than the
628+
// base route tree. This assumption might not hold, in which case we'll have
629+
// to recover when we perform the dynamic navigation request. However, this
630+
// is what would happen if a route were dynamically rewritten/redirected
631+
// in between the prefetch and the navigation. So the logic needs to exist
632+
// to handle this case regardless.
633+
634+
// Look for a route with the same pathname, but with an empty search string.
635+
// TODO: There's nothing inherently special about the empty search string;
636+
// it's chosen somewhat arbitrarily, with the rationale that it's the most
637+
// likely one to exist. But we should update this to match _any_ search
638+
// string. The plan is to generalize this logic alongside other improvements
639+
// related to "fallback" cache entries.
640+
const requestedSearch = requestedUrl.search as NormalizedSearch
641+
if (requestedSearch === '') {
642+
// The caller would have already checked if a route with an empty search
643+
// string is in the cache. So we can bail out here.
644+
return null
645+
}
646+
const routeWithNoSearchParams = readRouteCacheEntry(
647+
now,
648+
createPrefetchRequestKey(
649+
requestedUrl.origin + requestedUrl.pathname,
650+
nextUrl
651+
)
652+
)
653+
654+
if (
655+
routeWithNoSearchParams === null ||
656+
routeWithNoSearchParams.status !== EntryStatus.Fulfilled ||
657+
// There's no point constructing an optimistic route tree if the metadata
658+
// isn't fully available, because we'll have to do a blocking
659+
// navigation anyway.
660+
routeWithNoSearchParams.isHeadPartial ||
661+
// We cannot reuse this route if it has dynamic metadata.
662+
// TODO: Move the metadata out of the route cache entry so the route
663+
// tree is reusable separately from the metadata. Then we can remove
664+
// these checks.
665+
routeWithNoSearchParams.TODO_metadataStatus !== EntryStatus.Empty ||
666+
routeWithNoSearchParams.TODO_isHeadDynamic
667+
) {
668+
// Bail out of constructing an optimistic route tree. This will result in
669+
// a blocking, unprefetched navigation.
670+
return null
671+
}
672+
673+
// Now we have a base route tree we can "patch" with our optimistic values.
674+
675+
// Optimistically assume that redirects for the requested pathname do
676+
// not vary on the search string. Therefore, if the base route was
677+
// redirected to a different search string, then the optimistic route
678+
// should be redirected to the same search string. Otherwise, we use
679+
// the requested search string.
680+
const canonicalUrlForRouteWithNoSearchParams = new URL(
681+
routeWithNoSearchParams.canonicalUrl,
682+
requestedUrl.origin
683+
)
684+
const optimisticCanonicalSearch =
685+
canonicalUrlForRouteWithNoSearchParams.search !== ''
686+
? // Base route was redirected. Reuse the same redirected search string.
687+
canonicalUrlForRouteWithNoSearchParams.search
688+
: requestedSearch
689+
690+
// Similarly, optimistically assume that rewrites for the requested
691+
// pathname do not vary on the search string. Therefore, if the base
692+
// route was rewritten to a different search string, then the optimistic
693+
// route should be rewritten to the same search string. Otherwise, we use
694+
// the requested search string.
695+
const optimisticRenderedSearch =
696+
routeWithNoSearchParams.renderedSearch !== ''
697+
? // Base route was rewritten. Reuse the same rewritten search string.
698+
routeWithNoSearchParams.renderedSearch
699+
: requestedSearch
700+
701+
const optimisticUrl = new URL(
702+
routeWithNoSearchParams.canonicalUrl,
703+
location.origin
704+
)
705+
optimisticUrl.search = optimisticCanonicalSearch
706+
const optimisticCanonicalUrl = createHrefFromUrl(optimisticUrl)
707+
708+
// Clone the base route tree, and override the relevant fields with our
709+
// optimistic values.
710+
const optimisticEntry: FulfilledRouteCacheEntry = {
711+
canonicalUrl: optimisticCanonicalUrl,
712+
713+
status: EntryStatus.Fulfilled,
714+
// This isn't cloned because it's instance-specific
715+
blockedTasks: null,
716+
tree: routeWithNoSearchParams.tree,
717+
head: routeWithNoSearchParams.head,
718+
isHeadPartial: routeWithNoSearchParams.isHeadPartial,
719+
staleAt: routeWithNoSearchParams.staleAt,
720+
couldBeIntercepted: routeWithNoSearchParams.couldBeIntercepted,
721+
isPPREnabled: routeWithNoSearchParams.isPPREnabled,
722+
723+
// Override the rendered search with the optimistic value.
724+
renderedSearch: optimisticRenderedSearch,
725+
726+
TODO_metadataStatus: routeWithNoSearchParams.TODO_metadataStatus,
727+
TODO_isHeadDynamic: routeWithNoSearchParams.TODO_isHeadDynamic,
728+
729+
// LRU-related fields
730+
keypath: null,
731+
next: null,
732+
prev: null,
733+
size: 0,
734+
}
735+
736+
// Do not insert this entry into the cache. It only exists so we can
737+
// perform the current navigation. Just return it to the caller.
738+
return optimisticEntry
739+
}
740+
608741
/**
609742
* Checks if an entry for a segment exists in the cache. If so, it returns the
610743
* entry, If not, it adds an empty entry to the cache and returns it.
@@ -833,7 +966,8 @@ function fulfillRouteCacheEntry(
833966
couldBeIntercepted: boolean,
834967
canonicalUrl: string,
835968
renderedSearch: NormalizedSearch,
836-
isPPREnabled: boolean
969+
isPPREnabled: boolean,
970+
isHeadDynamic: boolean
837971
): FulfilledRouteCacheEntry {
838972
const fulfilledEntry: FulfilledRouteCacheEntry = entry as any
839973
fulfilledEntry.status = EntryStatus.Fulfilled
@@ -845,6 +979,7 @@ function fulfillRouteCacheEntry(
845979
fulfilledEntry.canonicalUrl = canonicalUrl
846980
fulfilledEntry.renderedSearch = renderedSearch
847981
fulfilledEntry.isPPREnabled = isPPREnabled
982+
fulfilledEntry.TODO_isHeadDynamic = isHeadDynamic
848983
pingBlockedTasks(entry)
849984
return fulfilledEntry
850985
}
@@ -1272,6 +1407,10 @@ export async function fetchRouteOnCacheMiss(
12721407
// because all data is static in this mode.
12731408
isOutputExportMode
12741409

1410+
// Regardless of the type of response, we will never receive dynamic
1411+
// metadata as part of this prefetch request.
1412+
const isHeadDynamic = false
1413+
12751414
if (routeIsPPREnabled) {
12761415
const prefetchStream = createPrefetchResponseStream(
12771416
response.body,
@@ -1315,7 +1454,8 @@ export async function fetchRouteOnCacheMiss(
13151454
couldBeIntercepted,
13161455
canonicalUrl,
13171456
renderedSearch,
1318-
routeIsPPREnabled
1457+
routeIsPPREnabled,
1458+
isHeadDynamic
13191459
)
13201460
} else {
13211461
// PPR is not enabled for this route. The server responds with a
@@ -1681,6 +1821,10 @@ function writeDynamicTreeResponseIntoCache(
16811821
const isResponsePartial =
16821822
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'
16831823

1824+
// Since this is a dynamic response, we must conservatively assume that the
1825+
// head responded with dynamic data.
1826+
const isHeadDynamic = true
1827+
16841828
const fulfilledEntry = fulfillRouteCacheEntry(
16851829
entry,
16861830
convertRootFlightRouterStateToRouteTree(flightRouterState),
@@ -1690,7 +1834,8 @@ function writeDynamicTreeResponseIntoCache(
16901834
couldBeIntercepted,
16911835
canonicalUrl,
16921836
renderedSearch,
1693-
routeIsPPREnabled
1837+
routeIsPPREnabled,
1838+
isHeadDynamic
16941839
)
16951840

16961841
// If the server sent segment data as part of the response, we should write
@@ -1822,6 +1967,8 @@ function writeDynamicRenderResponseIntoCache(
18221967
// segment data may be reused from a previous request).
18231968
route.head = flightData.head
18241969
route.isHeadPartial = flightData.isHeadPartial
1970+
route.TODO_isHeadDynamic = true
1971+
18251972
// TODO: Currently the stale time of the route tree represents the
18261973
// stale time of both the route tree *and* all the segment data. So we
18271974
// can't just overwrite this field; we have to use whichever value is

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
readRouteCacheEntry,
2222
readSegmentCacheEntry,
2323
waitForSegmentCacheEntry,
24+
requestOptimisticRouteCacheEntry,
2425
type RouteTree,
2526
type FulfilledRouteCacheEntry,
2627
} from './cache'
@@ -136,6 +137,39 @@ export function navigate(
136137
url.hash
137138
)
138139
}
140+
141+
// There was no matching route tree in the cache. Let's see if we can
142+
// construct an "optimistic" route tree.
143+
const optimisticRoute = requestOptimisticRouteCacheEntry(now, url, nextUrl)
144+
if (optimisticRoute !== null) {
145+
// We have an optimistic route tree. Proceed with the normal flow.
146+
const snapshot = readRenderSnapshotFromCache(
147+
now,
148+
optimisticRoute,
149+
optimisticRoute.tree
150+
)
151+
const prefetchFlightRouterState = snapshot.flightRouterState
152+
const prefetchSeedData = snapshot.seedData
153+
const prefetchHead = optimisticRoute.head
154+
const isPrefetchHeadPartial = optimisticRoute.isHeadPartial
155+
const newCanonicalUrl = optimisticRoute.canonicalUrl
156+
return navigateUsingPrefetchedRouteTree(
157+
now,
158+
url,
159+
nextUrl,
160+
isSamePageNavigation,
161+
currentCacheNode,
162+
currentFlightRouterState,
163+
prefetchFlightRouterState,
164+
prefetchSeedData,
165+
prefetchHead,
166+
isPrefetchHeadPartial,
167+
newCanonicalUrl,
168+
shouldScroll,
169+
url.hash
170+
)
171+
}
172+
139173
// There's no matching prefetch for this route in the cache.
140174
return {
141175
tag: NavigationResultTag.Async,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { LinkAccordion } from '../../components/link-accordion'
2+
3+
export default function SearchParamsSharedLoadingStatePage() {
4+
return (
5+
<div>
6+
<p>
7+
This page tests whether a prefetched URL without search params can share
8+
its loading state with a navigation to the same URL with search params.
9+
</p>
10+
11+
<ul>
12+
<li>
13+
<LinkAccordion href="/search-params-shared-loading-state/target-page">
14+
Prefetch target (no search params)
15+
</LinkAccordion>
16+
</li>
17+
<li>
18+
<LinkAccordion href="/search-params-shared-loading-state/target-page?param=test">
19+
Prefetch target (with search params)
20+
</LinkAccordion>
21+
</li>
22+
</ul>
23+
</div>
24+
)
25+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use client'
2+
import { useSearchParams } from 'next/navigation'
3+
4+
export function SearchParamsDisplay() {
5+
const searchParams = useSearchParams()
6+
const param = searchParams.get('param')
7+
8+
return (
9+
<div id="search-params-content">Search param value: {param || 'none'}</div>
10+
)
11+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Suspense } from 'react'
2+
import { SearchParamsDisplay } from './client'
3+
4+
export default function TargetPage() {
5+
return (
6+
<div>
7+
<h1>Target Page</h1>
8+
<p id="static-content">Static content</p>
9+
<Suspense
10+
fallback={
11+
<div id="search-params-loading">Loading search params...</div>
12+
}
13+
>
14+
<SearchParamsDisplay />
15+
</Suspense>
16+
</div>
17+
)
18+
}

0 commit comments

Comments
 (0)