@@ -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'
4648import {
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
0 commit comments