import type {
CacheNodeSeedData,
FlightRouterState,
Segment,
} from '../../../shared/lib/app-router-types'
import type { CacheNode } from '../../../shared/lib/app-router-types'
import type { HeadData, ScrollRef } from '../../../shared/lib/app-router-types'
import { PrefetchHint } from '../../../shared/lib/app-router-types'
import {
PAGE_SEGMENT_KEY,
DEFAULT_SEGMENT_KEY,
NOT_FOUND_SEGMENT_KEY,
} from '../../../shared/lib/segment'
import { matchSegment } from '../match-segments'
import { createHrefFromUrl } from './create-href-from-url'
import { fetchServerResponse } from './fetch-server-response'
import { dispatchAppRouterAction } from '../use-action-queue'
import {
ACTION_SERVER_PATCH,
type ServerPatchAction,
} from './router-reducer-types'
import { isNavigatingToNewRootLayout } from './is-navigating-to-new-root-layout'
import { getLastCommittedTree } from './reducers/committed-state'
import {
convertServerPatchToFullTree,
type NavigationSeed,
} from '../segment-cache/navigation'
import {
type RouteTree,
type RefreshState,
type FulfilledRouteCacheEntry,
convertReusedFlightRouterStateToRouteTree,
readSegmentCacheEntry,
waitForSegmentCacheEntry,
markRouteEntryAsDynamicRewrite,
invalidateRouteCacheEntries,
getStaleAt,
writeStaticStageResponseIntoCache,
processRuntimePrefetchStream,
writeDynamicRenderResponseIntoCache,
EntryStatus,
} from '../segment-cache/cache'
import { FetchStrategy } from '../segment-cache/types'
import { discoverKnownRoute } from '../segment-cache/optimistic-routes'
import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'
import type { NormalizedSearch } from '../segment-cache/cache-key'
import {
getRenderedSearchFromVaryPath,
type PageVaryPath,
} from '../segment-cache/vary-path'
import {
readFromBFCache,
readFromBFCacheDuringRegularNavigation,
writeToBFCache,
writeHeadToBFCache,
updateBFCacheEntryStaleAt,
computeDynamicStaleAt,
} from '../segment-cache/bfcache'
// This is yet another tree type that is used to track pending promises that
// need to be fulfilled once the dynamic data is received. The terminal nodes of
// this tree represent the new Cache Node trees that were created during this
// request. We can't use the Cache Node tree or Route State tree directly
// because those include reused nodes, too. This tree is discarded as soon as
// the navigation response is received.
export type NavigationTask = {
status: NavigationTaskStatus
// The router state that corresponds to the tree that this Task represents.
route: FlightRouterState
// The CacheNode that corresponds to the tree that this Task represents.
node: CacheNode
// The tree sent to the server during the dynamic request. If all the segments
// are static, then this will be null, and no server request is required.
// Otherwise, this is the same as `route`, except with the `refetch` marker
// set on the top-most segment that needs to be fetched.
dynamicRequestTree: FlightRouterState | null
// The URL that should be used to fetch the dynamic data. This is only set
// when the segment cannot be refetched from the current route, because it's
// part of a "default" parallel slot that was reused during a navigation.
refreshState: RefreshState | null
children: Map<string, NavigationTask> | null
}
export const enum FreshnessPolicy {
Default,
Hydration,
HistoryTraversal,
RefreshAll,
HMRRefresh,
Gesture,
}
const enum NavigationTaskStatus {
Pending,
Fulfilled,
Rejected,
}
/**
* When a NavigationTask finishes, there may or may not be data still missing,
* necessitating a retry.
*/
const enum NavigationTaskExitStatus {
/**
* No additional navigation is required.
*/
Done = 0,
/**
* Some data failed to load, presumably due to a route tree mismatch. Perform
* a soft retry to reload the entire tree.
*/
SoftRetry = 1,
/**
* Some data failed to load in an unrecoverable way, e.g. in an inactive
* parallel route. Fall back to a hard (MPA-style) retry.
*/
HardRetry = 2,
}
export type NavigationRequestAccumulation = {
separateRefreshUrls: Set<string> | null
/**
* Set when a navigation creates new leaf segments that should be
* scrolled to. Stays null when no new segments are created (e.g.
* during a refresh where the route structure didn't change).
*/
scrollRef: ScrollRef | null
}
const noop = () => {}
export function createInitialCacheNodeForHydration(
navigatedAt: number,
initialTree: RouteTree,
seedData: CacheNodeSeedData | null,
seedHead: HeadData,
seedDynamicStaleAt: number
): NavigationTask {
// Create the initial cache node tree, using the data embedded into the
// HTML document.
const accumulation: NavigationRequestAccumulation = {
separateRefreshUrls: null,
scrollRef: null,
}
const task = createCacheNodeOnNavigation(
navigatedAt,
initialTree,
null,
FreshnessPolicy.Hydration,
seedData,
seedHead,
seedDynamicStaleAt,
false,
accumulation
)
return task
}
// Creates a new Cache Node tree (i.e. copy-on-write) that represents the
// optimistic result of a navigation, using both the current Cache Node tree and
// data that was prefetched prior to navigation.
//
// At the moment we call this function, we haven't yet received the navigation
// response from the server. It could send back something completely different
// from the tree that was prefetched — due to rewrites, default routes, parallel
// routes, etc.
//
// But in most cases, it will return the same tree that we prefetched, just with
// the dynamic holes filled in. So we optimistically assume this will happen,
// and accept that the real result could be arbitrarily different.
//
// We'll reuse anything that was already in the previous tree, since that's what
// the server does.
//
// New segments (ones that don't appear in the old tree) are assigned an
// unresolved promise. The data for these promises will be fulfilled later, when
// the navigation response is received.
//
// The tree can be rendered immediately after it is created (that's why this is
// a synchronous function). Any new trees that do not have prefetch data will
// suspend during rendering, until the dynamic data streams in.
//
// Returns a Task object, which contains both the updated Cache Node and a path
// to the pending subtrees that need to be resolved by the navigation response.
//
// A return value of `null` means there were no changes, and the previous tree
// can be reused without initiating a server request.
export function startPPRNavigation(
navigatedAt: number,
oldUrl: URL,
oldRenderedSearch: string,
oldCacheNode: CacheNode | null,
oldRouterState: FlightRouterState,
newRouteTree: RouteTree,
newMetadataVaryPath: PageVaryPath | null,
freshness: FreshnessPolicy,
seedData: CacheNodeSeedData | null,
seedHead: HeadData | null,
seedDynamicStaleAt: number,
isSamePageNavigation: boolean,
accumulation: NavigationRequestAccumulation
): NavigationTask | null {
const didFindRootLayout = false
const parentNeedsDynamicRequest = false
const parentRefreshState = null
const oldRootRefreshState: RefreshState = {
canonicalUrl: createHrefFromUrl(oldUrl),
renderedSearch: oldRenderedSearch as NormalizedSearch,
}
return updateCacheNodeOnNavigation(
navigatedAt,
oldUrl,
oldCacheNode !== null ? oldCacheNode : undefined,
oldRouterState,
newRouteTree,
newMetadataVaryPath,
freshness,
didFindRootLayout,
seedData,
seedHead,
seedDynamicStaleAt,
isSamePageNavigation,
parentNeedsDynamicRequest,
oldRootRefreshState,
parentRefreshState,
accumulation
)
}
function updateCacheNodeOnNavigation(
navigatedAt: number,
oldUrl: URL,
oldCacheNode: CacheNode | void,
oldRouterState: FlightRouterState,
newRouteTree: RouteTree,
newMetadataVaryPath: PageVaryPath | null,
freshness: FreshnessPolicy,
didFindRootLayout: boolean,
seedData: CacheNodeSeedData | null,
seedHead: HeadData | null,
seedDynamicStaleAt: number,
isSamePageNavigation: boolean,
parentNeedsDynamicRequest: boolean,
oldRootRefreshState: RefreshState,
parentRefreshState: RefreshState | null,
accumulation: NavigationRequestAccumulation
): NavigationTask | null {
// Check if this segment matches the one in the previous route.
const oldSegment = oldRouterState[0]
const newSegment = createSegmentFromRouteTree(newRouteTree)
if (!matchSegment(newSegment, oldSegment)) {
// This segment does not match the previous route. We're now entering the
// new part of the target route. Switch to the "create" path.
if (
// Check if the route tree changed before we reached a layout. (The
// highest-level layout in a route tree is referred to as the "root"
// layout.) This could mean that we're navigating between two different
// root layouts. When this happens, we perform a full-page (MPA-style)
// navigation.
//
// However, the algorithm for deciding where to start rendering a route
// (i.e. the one performed in order to reach this function) is stricter
// than the one used to detect a change in the root layout. So just
// because we're re-rendering a segment outside of the root layout does
// not mean we should trigger a full-page navigation.
//
// Specifically, we handle dynamic parameters differently: two segments
// are considered the same even if their parameter values are different.
//
// Refer to isNavigatingToNewRootLayout for details.
//
// Note that we only have to perform this extra traversal if we didn't
// already discover a root layout in the part of the tree that is
// unchanged. We also only need to compare the subtree that is not
// shared. In the common case, this branch is skipped completely.
(!didFindRootLayout &&
isNavigatingToNewRootLayout(oldRouterState, newRouteTree)) ||
// The global Not Found route (app/global-not-found.tsx) is a special
// case, because it acts like a root layout, but in the router tree, it
// is rendered in the same position as app/layout.tsx.
//
// Any navigation to the global Not Found route should trigger a
// full-page navigation.
//
// TODO: We should probably model this by changing the key of the root
// segment when this happens. Then the root layout check would work
// as expected, without a special case.
newSegment === NOT_FOUND_SEGMENT_KEY
) {
return null
}
return createCacheNodeOnNavigation(
navigatedAt,
newRouteTree,
newMetadataVaryPath,
freshness,
seedData,
seedHead,
seedDynamicStaleAt,
parentNeedsDynamicRequest,
accumulation
)
}
const newSlots = newRouteTree.slots
const oldRouterStateChildren = oldRouterState[1]
const seedDataChildren = seedData !== null ? seedData[1] : null
// We're currently traversing the part of the tree that was also part of
// the previous route. If we discover a root layout, then we don't need to
// trigger an MPA navigation.
const childDidFindRootLayout =
didFindRootLayout ||
(newRouteTree.prefetchHints & PrefetchHint.IsRootLayout) !== 0
let shouldRefreshDynamicData: boolean = false
switch (freshness) {
case FreshnessPolicy.Default:
case FreshnessPolicy.HistoryTraversal:
case FreshnessPolicy.Hydration:
case FreshnessPolicy.Gesture:
shouldRefreshDynamicData = false
break
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
shouldRefreshDynamicData = true
break
default:
freshness satisfies never
break
}
// TODO: We're not consistent about how we do this check. Some places
// check if the segment starts with PAGE_SEGMENT_KEY, but most seem to
// check if there any any children, which is why I'm doing it here. We
// should probably encode an empty children set as `null` though. Either
// way, we should update all the checks to be consistent.
const isLeafSegment = newSlots === null
// Get the data for this segment. Since it was part of the previous route,
// usually we just clone the data from the old CacheNode. However, during a
// refresh or a revalidation, there won't be any existing CacheNode. So we
// may need to consult the prefetch cache, like we would for a new segment.
let newCacheNode: CacheNode
let needsDynamicRequest: boolean
if (
oldCacheNode !== undefined &&
!shouldRefreshDynamicData &&
// During a same-page navigation, we always refetch the page segments
!(isLeafSegment && isSamePageNavigation)
) {
// Reuse the existing CacheNode
const dropPrefetchRsc = false
newCacheNode = reuseSharedCacheNode(dropPrefetchRsc, oldCacheNode)
needsDynamicRequest = false
} else {
// If this is part of a refresh, ignore the existing CacheNode and create a
// new one.
const seedRsc = seedData !== null ? seedData[0] : null
const result = createCacheNodeForSegment(
navigatedAt,
newRouteTree,
seedRsc,
newMetadataVaryPath,
seedHead,
freshness,
seedDynamicStaleAt
)
newCacheNode = result.cacheNode
needsDynamicRequest = result.needsDynamicRequest
// Carry forward the old node's scrollRef. This preserves scroll
// intent when a prior navigation's cache node is replaced by a
// refresh before the scroll handler has had a chance to fire —
// e.g. when router.push() and router.refresh() are called in the
// same startTransition batch.
if (oldCacheNode !== undefined) {
newCacheNode.scrollRef = oldCacheNode.scrollRef
}
}
// During a refresh navigation, there's a special case that happens when
// entering a "default" slot. The default slot may not be part of the
// current route; it may have been reused from an older route. If so,
// we need to fetch its data from the old route's URL rather than current
// route's URL. Keep track of this as we traverse the tree.
const maybeRefreshState = newRouteTree.refreshState
const refreshState =
maybeRefreshState !== undefined && maybeRefreshState !== null
? // This segment is not present in the current route. Track its
// refresh URL as we continue traversing the tree.
maybeRefreshState
: // Inherit the refresh URL from the parent.
parentRefreshState
// If this segment itself needs to fetch new data from the server, then by
// definition it is being refreshed. Track its refresh URL so we know which
// URL to request the data from.
if (needsDynamicRequest && refreshState !== null) {
accumulateRefreshUrl(accumulation, refreshState)
}
// As we diff the trees, we may sometimes modify (copy-on-write, not mutate)
// the Route Tree that was returned by the server — for example, in the case
// of default parallel routes, we preserve the currently active segment. To
// avoid mutating the original tree, we clone the router state children along
// the return path.
let patchedRouterStateChildren: {
[parallelRouteKey: string]: FlightRouterState
} = {}
let taskChildren = null
// Most navigations require a request to fetch additional data from the
// server, either because the data was not already prefetched, or because the
// target route contains dynamic data that cannot be prefetched.
//
// However, if the target route is fully static, and it's already completely
// loaded into the segment cache, then we can skip the server request.
//
// This starts off as `false`, and is set to `true` if any of the child
// routes requires a dynamic request.
let childNeedsDynamicRequest = false
// As we traverse the children, we'll construct a FlightRouterState that can
// be sent to the server to request the dynamic data. If it turns out that
// nothing in the subtree is dynamic (i.e. childNeedsDynamicRequest is false
// at the end), then this will be discarded.
// TODO: We can probably optimize the format of this data structure to only
// include paths that are dynamic. Instead of reusing the
// FlightRouterState type.
let dynamicRequestTreeChildren: {
[parallelRouteKey: string]: FlightRouterState
} = {}
let newCacheNodeSlots: Record<string, CacheNode> | null = null
if (newSlots !== null) {
const oldCacheNodeSlots =
oldCacheNode !== undefined ? oldCacheNode.slots : null
newCacheNode.slots = newCacheNodeSlots = {}
taskChildren = new Map()
for (let parallelRouteKey in newSlots) {
let newRouteTreeChild: RouteTree = newSlots[parallelRouteKey]
const oldRouterStateChild: FlightRouterState | void =
oldRouterStateChildren[parallelRouteKey]
if (oldRouterStateChild === undefined) {
// This should never happen, but if it does, it suggests a malformed
// server response. Trigger a full-page navigation.
return null
}
let seedDataChild: CacheNodeSeedData | void | null =
seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null
const oldSegmentChild = oldRouterStateChild[0]
let newSegmentChild = createSegmentFromRouteTree(newRouteTreeChild)
let seedHeadChild = seedHead
if (
// Skip this branch during a history traversal. We restore the tree that
// was stashed in the history entry as-is.
freshness !== FreshnessPolicy.HistoryTraversal &&
newSegmentChild === DEFAULT_SEGMENT_KEY &&
oldSegmentChild !== DEFAULT_SEGMENT_KEY
) {
// This is a "default" segment. These are never sent by the server during
// a soft navigation; instead, the client reuses whatever segment was
// already active in that slot on the previous route.
newRouteTreeChild = reuseActiveSegmentInDefaultSlot(
newRouteTree,
parallelRouteKey,
oldRootRefreshState,
oldRouterStateChild
)
newSegmentChild = createSegmentFromRouteTree(newRouteTreeChild)
// Since we're switching to a different route tree, these are no
// longer valid, because they correspond to the outer tree.
seedDataChild = null
seedHeadChild = null
}
const oldCacheNodeChild =
oldCacheNodeSlots !== null
? oldCacheNodeSlots[parallelRouteKey]
: undefined
const taskChild = updateCacheNodeOnNavigation(
navigatedAt,
oldUrl,
oldCacheNodeChild,
oldRouterStateChild,
newRouteTreeChild,
newMetadataVaryPath,
freshness,
childDidFindRootLayout,
seedDataChild ?? null,
seedHeadChild,
seedDynamicStaleAt,
isSamePageNavigation,
parentNeedsDynamicRequest || needsDynamicRequest,
oldRootRefreshState,
refreshState,
accumulation
)
if (taskChild === null) {
// One of the child tasks discovered a change to the root layout.
// Immediately unwind from this recursive traversal. This will trigger a
// full-page navigation.
return null
}
// Recursively propagate up the child tasks.
taskChildren.set(parallelRouteKey, taskChild)
newCacheNodeSlots[parallelRouteKey] = taskChild.node
// The child tree's route state may be different from the prefetched
// route sent by the server. We need to clone it as we traverse back up
// the tree.
const taskChildRoute = taskChild.route
patchedRouterStateChildren[parallelRouteKey] = taskChildRoute
const dynamicRequestTreeChild = taskChild.dynamicRequestTree
if (dynamicRequestTreeChild !== null) {
// Something in the child tree is dynamic.
childNeedsDynamicRequest = true
dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild
} else {
dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute
}
}
}
const newFlightRouterState: FlightRouterState = [
createSegmentFromRouteTree(newRouteTree),
patchedRouterStateChildren,
refreshState !== null
? [refreshState.canonicalUrl, refreshState.renderedSearch]
: null,
null,
newRouteTree.prefetchHints,
]
return {
status: needsDynamicRequest
? NavigationTaskStatus.Pending
: NavigationTaskStatus.Fulfilled,
route: newFlightRouterState,
node: newCacheNode,
dynamicRequestTree: createDynamicRequestTree(
newFlightRouterState,
dynamicRequestTreeChildren,
needsDynamicRequest,
childNeedsDynamicRequest,
parentNeedsDynamicRequest
),
refreshState,
children: taskChildren,
}
}
/**
* Assigns a ScrollRef to a new leaf CacheNode so the scroll handler
* knows to scroll to it after navigation. All leaves in the same
* navigation share the same ScrollRef — the first segment to scroll
* consumes it, preventing others from also scrolling.
*
* This is only called inside `createCacheNodeOnNavigation`, which only
* runs when segments diverge from the previous route. So for a refresh
* where the route structure stays the same, segments match, the update
* path is taken, and this function is never called — no scroll ref is
* assigned. A scroll ref is only assigned when the route actually
* changed (e.g. a redirect, or a dynamic condition on the server that
* produces a different route).
*
* Skipped during hydration (initial render should not scroll) and
* history traversal (scroll restoration is handled separately).
*/
function accumulateScrollRef(
freshness: FreshnessPolicy,
cacheNode: CacheNode,
accumulation: NavigationRequestAccumulation
): void {
switch (freshness) {
case FreshnessPolicy.Default:
case FreshnessPolicy.Gesture:
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
if (accumulation.scrollRef === null) {
accumulation.scrollRef = { current: true }
}
cacheNode.scrollRef = accumulation.scrollRef
break
case FreshnessPolicy.Hydration:
// Initial render — no scroll.
break
case FreshnessPolicy.HistoryTraversal:
// Back/forward — scroll restoration is handled separately.
break
default:
freshness satisfies never
break
}
}
function createCacheNodeOnNavigation(
navigatedAt: number,
newRouteTree: RouteTree,
newMetadataVaryPath: PageVaryPath | null,
freshness: FreshnessPolicy,
seedData: CacheNodeSeedData | null,
seedHead: HeadData | null,
seedDynamicStaleAt: number,
parentNeedsDynamicRequest: boolean,
accumulation: NavigationRequestAccumulation
): NavigationTask {
// Same traversal as updateCacheNodeNavigation, but simpler. We switch to this
// path once we reach the part of the tree that was not in the previous route.
// We don't need to diff against the old tree, we just need to create a new
// one. We also don't need to worry about any refresh-related logic.
//
// For the most part, this is a subset of updateCacheNodeOnNavigation, so any
// change that happens in this function likely needs to be applied to that
// one, too. However there are some places where the behavior intentionally
// diverges, which is why we keep them separate.
const newSegment = createSegmentFromRouteTree(newRouteTree)
const newSlots = newRouteTree.slots
const seedDataChildren = seedData !== null ? seedData[1] : null
const seedRsc = seedData !== null ? seedData[0] : null
const result = createCacheNodeForSegment(
navigatedAt,
newRouteTree,
seedRsc,
newMetadataVaryPath,
seedHead,
freshness,
seedDynamicStaleAt
)
const newCacheNode = result.cacheNode
const needsDynamicRequest = result.needsDynamicRequest
const isLeafSegment = newSlots === null
if (isLeafSegment) {
accumulateScrollRef(freshness, newCacheNode, accumulation)
}
let patchedRouterStateChildren: {
[parallelRouteKey: string]: FlightRouterState
} = {}
let taskChildren = null
let childNeedsDynamicRequest = false
let dynamicRequestTreeChildren: {
[parallelRouteKey: string]: FlightRouterState
} = {}
let newCacheNodeSlots: Record<string, CacheNode> | null = null
if (newSlots !== null) {
newCacheNode.slots = newCacheNodeSlots = {}
taskChildren = new Map()
for (let parallelRouteKey in newSlots) {
const newRouteTreeChild: RouteTree = newSlots[parallelRouteKey]
const seedDataChild: CacheNodeSeedData | void | null =
seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null
const taskChild = createCacheNodeOnNavigation(
navigatedAt,
newRouteTreeChild,
newMetadataVaryPath,
freshness,
seedDataChild ?? null,
seedHead,
seedDynamicStaleAt,
parentNeedsDynamicRequest || needsDynamicRequest,
accumulation
)
taskChildren.set(parallelRouteKey, taskChild)
newCacheNodeSlots[parallelRouteKey] = taskChild.node
const taskChildRoute = taskChild.route
patchedRouterStateChildren[parallelRouteKey] = taskChildRoute
const dynamicRequestTreeChild = taskChild.dynamicRequestTree
if (dynamicRequestTreeChild !== null) {
childNeedsDynamicRequest = true
dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild
} else {
dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute
}
}
}
const newFlightRouterState: FlightRouterState = [
newSegment,
patchedRouterStateChildren,
null,
null,
newRouteTree.prefetchHints,
]
return {
status: needsDynamicRequest
? NavigationTaskStatus.Pending
: NavigationTaskStatus.Fulfilled,
route: newFlightRouterState,
node: newCacheNode,
dynamicRequestTree: createDynamicRequestTree(
newFlightRouterState,
dynamicRequestTreeChildren,
needsDynamicRequest,
childNeedsDynamicRequest,
parentNeedsDynamicRequest
),
// This route is not part of the current tree, so there's no reason to
// track the refresh URL.
refreshState: null,
children: taskChildren,
}
}
function createSegmentFromRouteTree(newRouteTree: RouteTree): Segment {
if (newRouteTree.isPage) {
// In a dynamic server response, the server embeds the search params into
// the segment key, but in a static one it's omitted. The client handles
// this inconsistency by adding the search params back right at the end.
//
// TODO: The only thing this is used for is to create a cache key for
// ChildSegmentMap. But we already track the `renderedSearch` everywhere as
// part of the varyPath. The plan is get rid of ChildSegmentMap and
// store the page data in a CacheMap using the varyPath, like we do
// for prefetches. Then we can remove it from the segment key.
//
// As an incremental step, we can grab the search params from the varyPath.
const renderedSearch = getRenderedSearchFromVaryPath(newRouteTree.varyPath)
if (renderedSearch === null) {
return PAGE_SEGMENT_KEY
}
// This is based on equivalent logic in addSearchParamsIfPageSegment, used
// on the server.
const stringifiedQuery = JSON.stringify(
Object.fromEntries(new URLSearchParams(renderedSearch))
)
return stringifiedQuery !== '{}'
? PAGE_SEGMENT_KEY + '?' + stringifiedQuery
: PAGE_SEGMENT_KEY
}
return newRouteTree.segment
}
function patchRouterStateWithNewChildren(
baseRouterState: FlightRouterState,
newChildren: { [parallelRouteKey: string]: FlightRouterState }
): FlightRouterState {
const clone: FlightRouterState = [baseRouterState[0], newChildren]
// Based on equivalent logic in apply-router-state-patch-to-tree, but should
// confirm whether we need to copy all of these fields. Not sure the server
// ever sends, e.g. the refetch marker.
if (2 in baseRouterState) {
clone[2] = baseRouterState[2]
}
if (3 in baseRouterState) {
clone[3] = baseRouterState[3]
}
if (4 in baseRouterState) {
clone[4] = baseRouterState[4]
}
return clone
}
function createDynamicRequestTree(
newRouterState: FlightRouterState,
dynamicRequestTreeChildren: Record<string, FlightRouterState>,
needsDynamicRequest: boolean,
childNeedsDynamicRequest: boolean,
parentNeedsDynamicRequest: boolean
): FlightRouterState | null {
// Create a FlightRouterState that instructs the server how to render the
// requested segment.
//
// Or, if neither this segment nor any of the children require a new data,
// then we return `null` to skip the request.
let dynamicRequestTree: FlightRouterState | null = null
if (needsDynamicRequest) {
dynamicRequestTree = patchRouterStateWithNewChildren(
newRouterState,
dynamicRequestTreeChildren
)
// The "refetch" marker is set on the top-most segment that requires new
// data. We can omit it if a parent was already marked.
if (!parentNeedsDynamicRequest) {
dynamicRequestTree[3] = 'refetch'
}
} else if (childNeedsDynamicRequest) {
// This segment does not request new data, but at least one of its
// children does.
dynamicRequestTree = patchRouterStateWithNewChildren(
newRouterState,
dynamicRequestTreeChildren
)
} else {
dynamicRequestTree = null
}
return dynamicRequestTree
}
function accumulateRefreshUrl(
accumulation: NavigationRequestAccumulation,
refreshState: RefreshState
) {
// This is a refresh navigation, and we're inside a "default" slot that's
// not part of the current route; it was reused from an older route. In
// order to get fresh data for this reused route, we need to issue a
// separate request using the old route's URL.
//
// Track these extra URLs in the accumulated result. Later, we'll construct
// an appropriate request for each unique URL in the final set. The reason
// we don't do it immediately here is so we can deduplicate multiple
// instances of the same URL into a single request. See
// listenForDynamicRequest for more details.
const refreshUrl = refreshState.canonicalUrl
const separateRefreshUrls = accumulation.separateRefreshUrls
if (separateRefreshUrls === null) {
accumulation.separateRefreshUrls = new Set([refreshUrl])
} else {
separateRefreshUrls.add(refreshUrl)
}
}
function reuseActiveSegmentInDefaultSlot(
parentRouteTree: RouteTree,
parallelRouteKey: string,
oldRootRefreshState: RefreshState,
oldRouterState: FlightRouterState
): RouteTree {
// This is a "default" segment. These are never sent by the server during a
// soft navigation; instead, the client reuses whatever segment was already
// active in that slot on the previous route. This means if we later need to
// refresh the segment, it will have to be refetched from the previous route's
// URL. We store it in the Flight Router State.
let reusedUrl: string
let reusedRenderedSearch: NormalizedSearch
const oldRefreshState = oldRouterState[2]
if (oldRefreshState !== undefined && oldRefreshState !== null) {
// This segment was already reused from an even older route. Keep its
// existing URL and refresh state.
reusedUrl = oldRefreshState[0]
reusedRenderedSearch = oldRefreshState[1] as NormalizedSearch
} else {
// Since this route didn't already have a refresh state, it must have been
// reachable from the root of the old route. So we use the refresh state
// that represents the old route.
reusedUrl = oldRootRefreshState.canonicalUrl
reusedRenderedSearch = oldRootRefreshState.renderedSearch
}
const acc = { metadataVaryPath: null }
const reusedRouteTree = convertReusedFlightRouterStateToRouteTree(
parentRouteTree,
parallelRouteKey,
oldRouterState,
reusedRenderedSearch,
acc
)
reusedRouteTree.refreshState = {
canonicalUrl: reusedUrl,
renderedSearch: reusedRenderedSearch,
}
return reusedRouteTree
}
function reuseSharedCacheNode(
dropPrefetchRsc: boolean,
existingCacheNode: CacheNode
): CacheNode {
// Clone the CacheNode that was already present in the previous tree.
// Carry forward the scrollRef so scroll intent from a prior navigation
// survives tree rebuilds (e.g. push + refresh in the same batch).
return createCacheNode(
existingCacheNode.rsc,
dropPrefetchRsc ? null : existingCacheNode.prefetchRsc,
existingCacheNode.head,
dropPrefetchRsc ? null : existingCacheNode.prefetchHead,
existingCacheNode.scrollRef
)
}
function createCacheNodeForSegment(
now: number,
tree: RouteTree,
seedRsc: React.ReactNode | null,
metadataVaryPath: PageVaryPath | null,
seedHead: HeadData | null,
freshness: FreshnessPolicy,
dynamicStaleAt: number
): { cacheNode: CacheNode; needsDynamicRequest: boolean } {
// Construct a new CacheNode using data from the BFCache, the client's
// Segment Cache, or seeded from a server response.
//
// If there's a cache miss, or if we only have a partial hit, we'll render
// the partial state immediately, and spawn a request to the server to fill
// in the missing data.
//
// If the segment is fully cached on the client already, we can omit this
// segment from the server request.
//
// If we already have a dynamic data response associated with this navigation,
// as in the case of a Server Action-initiated redirect or refresh, we may
// also be able to use that data without spawning a new request. (This is
// referred to as the "seed" data.)
const isPage = tree.isPage
// During certain kinds of navigations, we may be able to render from
// the BFCache.
switch (freshness) {
case FreshnessPolicy.Default: {
// Check BFCache during regular navigations. The entry's staleAt
// determines whether it's still fresh. This is used when
// staleTimes.dynamic is configured globally or when a page exports
// unstable_dynamicStaleTime for per-page control.
const bfcacheEntry = readFromBFCacheDuringRegularNavigation(
now,
tree.varyPath
)
if (bfcacheEntry !== null) {
return {
cacheNode: createCacheNode(
bfcacheEntry.rsc,
bfcacheEntry.prefetchRsc,
bfcacheEntry.head,
bfcacheEntry.prefetchHead
),
needsDynamicRequest: false,
}
}
break
}
case FreshnessPolicy.Hydration: {
// This is not related to the BFCache but it is a special case.
//
// We should never spawn network requests during hydration. We must treat
// the initial payload as authoritative, because the initial page load is
// used as a last-ditch mechanism for recovering the app.
//
// This is also an important safety check because if this leaks into the
// server rendering path (which theoretically it never should because the
// server payload should be consistent), the server would hang because these
// promises would never resolve.
//
// TODO: There is an existing case where the global "not found" boundary
// triggers this path. But it does render correctly despite that. That's an
// unusual render path so it's not surprising, but we should look into
// modeling it in a more consistent way. See also the /_notFound special
// case in updateCacheNodeOnNavigation.
const rsc = seedRsc
const prefetchRsc = null
const head = isPage ? seedHead : null
const prefetchHead = null
writeToBFCache(
now,
tree.varyPath,
rsc,
prefetchRsc,
head,
prefetchHead,
dynamicStaleAt
)
if (isPage && metadataVaryPath !== null) {
writeHeadToBFCache(
now,
metadataVaryPath,
head,
prefetchHead,
dynamicStaleAt
)
}
return {
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
needsDynamicRequest: false,
}
}
case FreshnessPolicy.HistoryTraversal:
const bfcacheEntry = readFromBFCache(tree.varyPath)
if (bfcacheEntry !== null) {
// Only show prefetched data if the dynamic data is still pending. This
// avoids a flash back to the prefetch state in a case where it's highly
// likely to have already streamed in.
//
// Tehnically, what we're actually checking is whether the dynamic
// network response was received. But since it's a streaming response,
// this does not mean that all the dynamic data has fully streamed in.
// It just means that _some_ of the dynamic data was received. But as a
// heuristic, we assume that the rest dynamic data will stream in
// quickly, so it's still better to skip the prefetch state.
const oldRsc = bfcacheEntry.rsc
const oldRscDidResolve =
!isDeferredRsc(oldRsc) || oldRsc.status !== 'pending'
const dropPrefetchRsc = oldRscDidResolve
return {
cacheNode: createCacheNode(
bfcacheEntry.rsc,
dropPrefetchRsc ? null : bfcacheEntry.prefetchRsc,
bfcacheEntry.head,
dropPrefetchRsc ? null : bfcacheEntry.prefetchHead
),
needsDynamicRequest: false,
}
}
break
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
case FreshnessPolicy.Gesture:
// Don't consult the BFCache.
break
default:
freshness satisfies never
break
}
let cachedRsc: React.ReactNode | null = null
let isCachedRscPartial: boolean = true
const segmentEntry = readSegmentCacheEntry(now, tree.varyPath)
if (segmentEntry !== null) {
switch (segmentEntry.status) {
case EntryStatus.Fulfilled: {
// Happy path: a cache hit
cachedRsc = segmentEntry.rsc
isCachedRscPartial = segmentEntry.isPartial
break
}
case EntryStatus.Pending: {
// We haven't received data for this segment yet, but there's already
// an in-progress request. Since it's extremely likely to arrive
// before the dynamic data response, we might as well use it.
const promiseForFulfilledEntry = waitForSegmentCacheEntry(segmentEntry)
cachedRsc = promiseForFulfilledEntry.then((entry) =>
entry !== null ? entry.rsc : null
)
// Because the request is still pending, we typically don't know yet
// whether the response will be partial. We shouldn't skip this segment
// during the dynamic navigation request. Otherwise, we might need to
// do yet another request to fill in the remaining data, creating
// a waterfall.
//
// The one exception is if this segment is being fetched with via
// prefetch={true} (i.e. the "force stale" or "full" strategy). If so,
// we can assume the response will be full. This field is set to `false`
// for such segments.
isCachedRscPartial = segmentEntry.isPartial
break
}
case EntryStatus.Empty:
case EntryStatus.Rejected: {
break
}
default: {
segmentEntry satisfies never
break
}
}
}
// Now combine the cached data with the seed data to determine what we can
// render immediately, versus what needs to stream in later.
// A partial state to show immediately while we wait for the final data to
// arrive. If `rsc` is already a complete value (not partial), or if we
// don't have any useful partial state, this will be `null`.
let prefetchRsc: React.ReactNode | null
// The final, resolved segment data. If the data is missing, this will be a
// promise that resolves to the eventual data. A resolved value of `null`
// means the data failed to load; the LayoutRouter will suspend indefinitely
// until the router updates again (refer to finishNavigationTask).
let rsc: React.ReactNode | null
let doesSegmentNeedDynamicRequest: boolean
if (seedRsc !== null) {
// We already have a dynamic server response for this segment.
if (isCachedRscPartial) {
// The seed data may still be streaming in, so it's worth showing the
// partial cached state in the meantime.
prefetchRsc = cachedRsc
rsc = seedRsc
} else {
// We already have a completely cached segment. Ignore the seed data,
// which may still be streaming in. This shouldn't happen in the normal
// case because the client will inform the server which segments are
// already fully cached, and the server will skip rendering them.
prefetchRsc = null
rsc = cachedRsc
}
doesSegmentNeedDynamicRequest = false
} else {
if (isCachedRscPartial) {
// The cached data contains dynamic holes, or it's missing entirely. We'll
// show the partial state immediately (if available), and stream in the
// final data.
//
// Create a pending promise that we can later write to when the
// data arrives from the server.
prefetchRsc = cachedRsc
rsc = createDeferredRsc()
} else {
// The data is fully cached.
prefetchRsc = null
rsc = cachedRsc
}
doesSegmentNeedDynamicRequest = isCachedRscPartial
}
// If this is a page segment, we need to do the same for the head. This
// follows analogous logic to the segment data above.
// TODO: We don't need to store the head on the page segment's CacheNode; we
// can lift it to the main state object. Then we can also delete
// findHeadCache.
let prefetchHead: HeadData | null = null
let head: React.ReactNode | null = null
let doesHeadNeedDynamicRequest: boolean = isPage
if (isPage) {
let cachedHead: HeadData | null = null
let isCachedHeadPartial: boolean = true
if (metadataVaryPath !== null) {
const metadataEntry = readSegmentCacheEntry(now, metadataVaryPath)
if (metadataEntry !== null) {
switch (metadataEntry.status) {
case EntryStatus.Fulfilled: {
cachedHead = metadataEntry.rsc
isCachedHeadPartial = metadataEntry.isPartial
break
}
case EntryStatus.Pending: {
cachedHead = waitForSegmentCacheEntry(metadataEntry).then(
(entry) => (entry !== null ? entry.rsc : null)
)
isCachedHeadPartial = metadataEntry.isPartial
break
}
case EntryStatus.Empty:
case EntryStatus.Rejected: {
break
}
default: {
metadataEntry satisfies never
break
}
}
}
}
if (process.env.__NEXT_OPTIMISTIC_ROUTING && isCachedHeadPartial) {
// TODO: When optimistic routing is enabled, don't block on waiting for
// the viewport to resolve. This is a temporary workaround until Vary
// Params are tracked when rendering the metadata. We'll fix it before
// this feature is stable. However, it's not a critical issue because 1)
// it will stream in eventually anyway 2) metadata is wrapped in an
// internal Suspense boundary, so is always non-blocking; this only
// affects the viewport node, which is meant to blocking, however... 3)
// before Segment Cache landed this wasn't always the case, anyway, so
// it's unlikely that many people are relying on this behavior. Still,
// will be fixed before stable. It's the very next step in the sequence of
// work on this project.
//
// This line of code works because the App Router treats `null` as
// "no renderable head available", rather than an empty head. React treats
// an empty string as empty.
cachedHead = ''
}
if (seedHead !== null) {
if (isCachedHeadPartial) {
prefetchHead = cachedHead
head = seedHead
} else {
prefetchHead = null
head = cachedHead
}
doesHeadNeedDynamicRequest = false
} else {
if (isCachedHeadPartial) {
prefetchHead = cachedHead
head = createDeferredRsc()
} else {
prefetchHead = null
head = cachedHead
}
doesHeadNeedDynamicRequest = isCachedHeadPartial
}
}
// Now that we're creating a new segment, write its data to the BFCache. A
// subsequent back/forward navigation will reuse this same data, until or
// unless it's cleared by a refresh/revalidation.
//
// Skip BFCache writes for optimistic navigations since they are transient
// and will be replaced by the canonical navigation.
if (freshness !== FreshnessPolicy.Gesture) {
writeToBFCache(
now,
tree.varyPath,
rsc,
prefetchRsc,
head,
prefetchHead,
dynamicStaleAt
)
if (isPage && metadataVaryPath !== null) {
writeHeadToBFCache(
now,
metadataVaryPath,
head,
prefetchHead,
dynamicStaleAt
)
}
}
return {
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
// TODO: We should store this field on the CacheNode itself. I think we can
// probably unify NavigationTask, CacheNode, and DeferredRsc into a
// single type. Or at least CacheNode and DeferredRsc.
needsDynamicRequest:
doesSegmentNeedDynamicRequest || doesHeadNeedDynamicRequest,
}
}
function createCacheNode(
rsc: React.ReactNode | null,
prefetchRsc: React.ReactNode | null,
head: React.ReactNode | null,
prefetchHead: HeadData | null,
scrollRef: ScrollRef | null = null
): CacheNode {
return {
rsc,
prefetchRsc,
head,
prefetchHead,
slots: null,
scrollRef,
}
}
// Represents whether the previuos navigation resulted in a route tree mismatch.
// A mismatch results in a refresh of the page. If there are two successive
// mismatches, we will fall back to an MPA navigation, to prevent a retry loop.
let previousNavigationDidMismatch = false
// Writes a dynamic server response into the tree created by
// updateCacheNodeOnNavigation. All pending promises that were spawned by the
// navigation will be resolved, either with dynamic data from the server, or
// `null` to indicate that the data is missing.
//
// A `null` value will trigger a lazy fetch during render, which will then patch
// up the tree using the same mechanism as the non-PPR implementation
// (serverPatchReducer).
//
// Usually, the server will respond with exactly the subset of data that we're
// waiting for — everything below the nearest shared layout. But technically,
// the server can return anything it wants.
//
// This does _not_ create a new tree; it modifies the existing one in place.
// Which means it must follow the Suspense rules of cache safety.
export function spawnDynamicRequests(
task: NavigationTask,
primaryUrl: URL,
nextUrl: string | null,
freshnessPolicy: FreshnessPolicy,
accumulation: NavigationRequestAccumulation,
// The route cache entry used for this navigation, if it came from route
// prediction. Passed through so it can be marked as having a dynamic rewrite
// if the server returns a different pathname than expected (indicating
// dynamic rewrite behavior that varies by param value).
routeCacheEntry: FulfilledRouteCacheEntry | null,
// The original navigation's push/replace intent. Threaded through to the
// server-patch retry logic so it can inherit the intent if the original
// transition hasn't committed yet.
navigateType: 'push' | 'replace'
): void {
const dynamicRequestTree = task.dynamicRequestTree
if (dynamicRequestTree === null) {
// This navigation was fully cached. There are no dynamic requests to spawn.
previousNavigationDidMismatch = false
return
}
// This is intentionally not an async function to discourage the caller from
// awaiting the result. Any subsequent async operations spawned by this
// function should result in a separate navigation task, rather than
// block the original one.
//
// In this function we spawn (but do not await) all the network requests that
// block the navigation, and collect the promises. The next function,
// `finishNavigationTask`, can await the promises in any order without
// accidentally introducing a network waterfall.
const primaryRequestPromise = fetchMissingDynamicData(
task,
dynamicRequestTree,
primaryUrl,
nextUrl,
freshnessPolicy,
routeCacheEntry
)
const separateRefreshUrls = accumulation.separateRefreshUrls
let refreshRequestPromises: Array<
ReturnType<typeof fetchMissingDynamicData>
> | null = null
if (separateRefreshUrls !== null) {
// There are multiple URLs that we need to request the data from. This
// happens when a "default" parallel route slot is present in the tree, and
// its data cannot be fetched from the current route. We need to split the
// combined dynamic request tree into separate requests per URL.
// TODO: Create a scoped dynamic request tree that omits anything that
// is not relevant to the given URL. Without doing this, the server may
// sometimes render more data than necessary; this is not a regression
// compared to the pre-Segment Cache implementation, though, just an
// optimization we can make in the future.
// Construct a request tree for each additional refresh URL. This will
// prune away everything except the parts of the tree that match the
// given refresh URL.
refreshRequestPromises = []
const canonicalUrl = createHrefFromUrl(primaryUrl)
for (const refreshUrl of separateRefreshUrls) {
if (refreshUrl === canonicalUrl) {
// We already initiated a request for the this URL, above. Skip it.
// TODO: This only happens because the main URL is not tracked as
// part of the separateRefreshURLs set. There's probably a better way
// to structure this so this case doesn't happen.
continue
}
// TODO: Create a scoped dynamic request tree that omits anything that
// is not relevant to the given URL. Without doing this, the server may
// sometimes render more data than necessary; this is not a regression
// compared to the pre-Segment Cache implementation, though, just an
// optimization we can make in the future.
// const scopedDynamicRequestTree = splitTaskByURL(task, refreshUrl)
const scopedDynamicRequestTree = dynamicRequestTree
if (scopedDynamicRequestTree !== null) {
refreshRequestPromises.push(
fetchMissingDynamicData(
task,
scopedDynamicRequestTree,
new URL(refreshUrl, location.origin),
// TODO: Just noticed that this should actually the Next-Url at the
// time the refresh URL was set, not the current Next-Url. Need to
// start tracking this alongside the refresh URL. In the meantime,
// if a refresh fails due to a mismatch, it will trigger a
// hard refresh.
nextUrl,
freshnessPolicy,
routeCacheEntry
)
)
}
}
}
// Further async operations are moved into this separate function to
// discourage sequential network requests.
const voidPromise = finishNavigationTask(
task,
nextUrl,
primaryRequestPromise,
refreshRequestPromises,
routeCacheEntry,
navigateType
)
// `finishNavigationTask` is responsible for error handling, so we can attach
// noop callbacks to this promise.
voidPromise.then(noop, noop)
}
async function finishNavigationTask(
task: NavigationTask,
nextUrl: string | null,
primaryRequestPromise: ReturnType<typeof fetchMissingDynamicData>,
refreshRequestPromises: Array<
ReturnType<typeof fetchMissingDynamicData>
> | null,
routeCacheEntry: FulfilledRouteCacheEntry | null,
navigateType: 'push' | 'replace'
): Promise<void> {
// Wait for all the requests to finish, or for the first one to fail.
let exitStatus = await waitForRequestsToFinish(
primaryRequestPromise,
refreshRequestPromises
)
// Once the all the requests have finished, check the tree for any remaining
// pending tasks. If anything is still pending, it means the server response
// does not match the client, and we must refresh to get back to a consistent
// state. We can skip this step if we already detected a mismatch during the
// first phase; it doesn't matter in that case because we're going to refresh
// the whole tree regardless.
if (exitStatus === NavigationTaskExitStatus.Done) {
exitStatus = abortRemainingPendingTasks(task, null, null)
}
switch (exitStatus) {
case NavigationTaskExitStatus.Done: {
// The task has completely finished. There's no missing data. Exit.
previousNavigationDidMismatch = false
return
}
case NavigationTaskExitStatus.SoftRetry: {
// Some data failed to finish loading. Trigger a soft retry.
// TODO: As an extra precaution against soft retry loops, consider
// tracking whether a navigation was itself triggered by a retry. If two
// happen in a row, fall back to a hard retry.
const isHardRetry = false
const primaryRequestResult = await primaryRequestPromise
dispatchRetryDueToTreeMismatch(
isHardRetry,
primaryRequestResult.url,
nextUrl,
primaryRequestResult.seed,
task.route,
routeCacheEntry,
navigateType
)
return
}
case NavigationTaskExitStatus.HardRetry: {
// Some data failed to finish loading in a non-recoverable way, such as a
// network error. Trigger an MPA navigation.
//
// Hard navigating/refreshing is how we prevent an infinite retry loop
// caused by a network error — when the network fails, we fall back to the
// browser behavior for offline navigations. In the future, Next.js may
// introduce its own custom handling of offline navigations, but that
// doesn't exist yet.
const isHardRetry = true
const primaryRequestResult = await primaryRequestPromise
dispatchRetryDueToTreeMismatch(
isHardRetry,
primaryRequestResult.url,
nextUrl,
primaryRequestResult.seed,
task.route,
routeCacheEntry,
navigateType
)
return
}
default: {
return exitStatus satisfies never
}
}
}
function waitForRequestsToFinish(
primaryRequestPromise: ReturnType<typeof fetchMissingDynamicData>,
refreshRequestPromises: Array<
ReturnType<typeof fetchMissingDynamicData>
> | null
) {
// Custom async combinator logic. This could be replaced by Promise.any but
// we don't assume that's available.
//
// Each promise resolves once the server responsds and the data is written
// into the CacheNode tree. Resolve the combined promise once all the
// requests finish.
//
// Or, resolve as soon as one of the requests fails, without waiting for the
// others to finish.
return new Promise<NavigationTaskExitStatus>((resolve) => {
const onFulfill = (result: { exitStatus: NavigationTaskExitStatus }) => {
if (result.exitStatus === NavigationTaskExitStatus.Done) {
remainingCount--
if (remainingCount === 0) {
// All the requests finished successfully.
resolve(NavigationTaskExitStatus.Done)
}
} else {
// One of the requests failed. Exit with a failing status.
// NOTE: It's possible for one of the requests to fail with SoftRetry
// and a later one to fail with HardRetry. In this case, we choose to
// retry immediately, rather than delay the retry until all the requests
// finish. If it fails again, we will hard retry on the next
// attempt, anyway.
resolve(result.exitStatus)
}
}
// onReject shouldn't ever be called because fetchMissingDynamicData's
// entire body is wrapped in a try/catch. This is just defensive.
const onReject = () => resolve(NavigationTaskExitStatus.HardRetry)
// Attach the listeners to the promises.
let remainingCount = 1
primaryRequestPromise.then(onFulfill, onReject)
if (refreshRequestPromises !== null) {
remainingCount += refreshRequestPromises.length
refreshRequestPromises.forEach((refreshRequestPromise) =>
refreshRequestPromise.then(onFulfill, onReject)
)
}
})
}
function dispatchRetryDueToTreeMismatch(
isHardRetry: boolean,
retryUrl: URL,
retryNextUrl: string | null,
seed: NavigationSeed | null,
baseTree: FlightRouterState,
// The route cache entry used for this navigation, if it came from route
// prediction. If the navigation results in a mismatch, we mark it as having
// a dynamic rewrite so future predictions bail out.
routeCacheEntry: FulfilledRouteCacheEntry | null,
// The original navigation's push/replace intent.
originalNavigateType: 'push' | 'replace'
) {
// If the navigation used a route prediction, mark it as having a dynamic
// rewrite since it resulted in a mismatch.
if (routeCacheEntry !== null) {
markRouteEntryAsDynamicRewrite(routeCacheEntry)
} else if (seed !== null) {
// Even without a direct reference to the route cache entry, we can still
// mark the route as having a dynamic rewrite by traversing the known route
// tree. This handles cases where the navigation didn't originate from a
// route prediction, but still needs to mark the pattern.
const metadataVaryPath = seed.metadataVaryPath
if (metadataVaryPath !== null) {
const now = Date.now()
discoverKnownRoute(
now,
retryUrl.pathname,
retryNextUrl,
null,
seed.routeTree,
metadataVaryPath,
false, // couldBeIntercepted - doesn't matter, we're just marking hasDynamicRewrite
createHrefFromUrl(retryUrl),
false, // supportsPerSegmentPrefetching - doesn't matter, we're just marking hasDynamicRewrite
true // hasDynamicRewrite
)
}
}
// Invalidate all route cache entries. Other entries may have been derived
// from the template before we knew it had a dynamic rewrite. This also
// triggers re-prefetching of visible links.
invalidateRouteCacheEntries(retryNextUrl, baseTree)
// If this is the second time in a row that a navigation resulted in a
// mismatch, fall back to a hard (MPA) refresh.
isHardRetry = isHardRetry || previousNavigationDidMismatch
previousNavigationDidMismatch = true
// If the original navigation hasn't committed to the browser history yet
// (the transition suspended before React committed), inherit its push/replace
// intent. Otherwise, the pushState already ran, so use 'replace' to avoid
// creating a duplicate history entry.
//
// This works because React entangles the retry's state update with the
// original pending transition — they commit together as a single batch,
// so the navigate type from the retry is what HistoryUpdater ultimately sees.
//
// TODO: Ideally this check would happen right before we schedule the React
// update (i.e., closer to where the action is dispatched into the queue),
// not here where the action is constructed. But the current action queue
// doesn't provide a natural place for that. Revisit when we refactor the
// action queue into a more reactive navigation model.
const lastCommitted = getLastCommittedTree()
const retryNavigateType: 'push' | 'replace' =
lastCommitted !== null && baseTree !== lastCommitted
? originalNavigateType
: 'replace'
const retryAction: ServerPatchAction = {
type: ACTION_SERVER_PATCH,
previousTree: baseTree,
url: retryUrl,
nextUrl: retryNextUrl,
seed,
mpa: isHardRetry,
navigateType: retryNavigateType,
}
dispatchAppRouterAction(retryAction)
}
async function fetchMissingDynamicData(
task: NavigationTask,
dynamicRequestTree: FlightRouterState,
url: URL,
nextUrl: string | null,
freshnessPolicy: FreshnessPolicy,
routeCacheEntry: FulfilledRouteCacheEntry | null
): Promise<{
exitStatus: NavigationTaskExitStatus
url: URL
seed: NavigationSeed | null
}> {
try {
const result = await fetchServerResponse(url, {
flightRouterState: dynamicRequestTree,
nextUrl,
isHmrRefresh: freshnessPolicy === FreshnessPolicy.HMRRefresh,
})
if (typeof result === 'string') {
// fetchServerResponse will return an href to indicate that the SPA
// navigation failed. For example, if the server triggered a hard
// redirect, or the fetch request errored. Initiate an MPA navigation
// to the given href.
return {
exitStatus: NavigationTaskExitStatus.HardRetry,
url: new URL(result, location.origin),
seed: null,
}
}
const now = Date.now()
const seed = convertServerPatchToFullTree(
now,
task.route,
result.flightData,
result.renderedSearch,
result.dynamicStaleTime
)
// If the navigation lock is active, wait for it to be released before
// writing the dynamic data. This allows tests to assert on the prefetched
// UI state.
if (process.env.__NEXT_EXPOSE_TESTING_API) {
await waitForNavigationLock()
}
if (routeCacheEntry !== null && result.staticStageData !== null) {
const { response: staticStageResponse, isResponsePartial } =
result.staticStageData
getStaleAt(now, staticStageResponse.s)
.then((staleAt) => {
const buildId =
result.responseHeaders.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ??
staticStageResponse.b
writeStaticStageResponseIntoCache(
now,
staticStageResponse.f,
buildId,
staticStageResponse.h,
staleAt,
dynamicRequestTree,
result.renderedSearch,
isResponsePartial
)
})
.catch(() => {
// The static stage processing failed. Not fatal — the navigation
// completed normally, we just won't write into the cache.
})
}
if (routeCacheEntry !== null && result.runtimePrefetchStream !== null) {
processRuntimePrefetchStream(
now,
result.runtimePrefetchStream,
dynamicRequestTree,
result.renderedSearch
)
.then((processed) => {
if (processed !== null) {
writeDynamicRenderResponseIntoCache(
now,
FetchStrategy.PPRRuntime,
processed.flightDatas,
processed.buildId,
processed.isResponsePartial,
processed.headVaryParams,
processed.staleAt,
processed.navigationSeed,
null
)
}
})
.catch(() => {
// The runtime prefetch cache write failed. Not fatal — the
// navigation completed normally, we just won't cache runtime data.
})
}
// result.dynamicStaleTime is in seconds (from the server's `d` field).
// Convert to an absolute timestamp using the centralized helper.
const dynamicStaleAt = computeDynamicStaleAt(now, result.dynamicStaleTime)
const didReceiveUnknownParallelRoute = writeDynamicDataIntoNavigationTask(
task,
seed.routeTree,
seed.data,
seed.head,
dynamicStaleAt,
result.debugInfo
)
return {
exitStatus: didReceiveUnknownParallelRoute
? NavigationTaskExitStatus.SoftRetry
: NavigationTaskExitStatus.Done,
url: new URL(result.canonicalUrl, location.origin),
seed,
}
} catch {
// This shouldn't happen because fetchServerResponse's entire body is
// wrapped in a try/catch. If it does, though, it implies the server failed
// to respond with any tree at all. So we must fall back to a hard retry.
return {
exitStatus: NavigationTaskExitStatus.HardRetry,
url: url,
seed: null,
}
}
}
function writeDynamicDataIntoNavigationTask(
task: NavigationTask,
serverRouteTree: RouteTree,
dynamicData: CacheNodeSeedData | null,
dynamicHead: HeadData,
dynamicStaleAt: number,
debugInfo: Array<any> | null
): boolean {
if (task.status === NavigationTaskStatus.Pending && dynamicData !== null) {
task.status = NavigationTaskStatus.Fulfilled
finishPendingCacheNode(task.node, dynamicData, dynamicHead, debugInfo)
// Update the BFCache entry's staleAt for this segment with the value
// from the dynamic response. This applies the per-page
// unstable_dynamicStaleTime if set, or the default DYNAMIC_STALETIME_MS.
// We only update segments that received dynamic data — static segments
// are unaffected.
updateBFCacheEntryStaleAt(serverRouteTree.varyPath, dynamicStaleAt)
}
const taskChildren = task.children
const serverChildren = serverRouteTree.slots
const dynamicDataChildren = dynamicData !== null ? dynamicData[1] : null
// Detect whether the server sends a parallel route slot that the client
// doesn't know about.
let didReceiveUnknownParallelRoute = false
if (taskChildren !== null) {
if (serverChildren !== null) {
for (const parallelRouteKey in serverChildren) {
const serverRouteTreeChild: RouteTree = serverChildren[parallelRouteKey]
const dynamicDataChild: CacheNodeSeedData | null | void =
dynamicDataChildren !== null
? dynamicDataChildren[parallelRouteKey]
: null
const taskChild = taskChildren.get(parallelRouteKey)
if (taskChild === undefined) {
// The server sent a child segment that the client doesn't know about.
//
// When we receive an unknown parallel route, we must consider it a
// mismatch. This is unlike the case where the segment itself
// mismatches, because multiple routes can be active simultaneously.
// But a given layout should never have a mismatching set of
// child slots.
//
// Theoretically, this should only happen in development during an HMR
// refresh, because the set of parallel routes for a layout does not
// change over the lifetime of a build/deployment. In production, we
// should have already mismatched on either the build id or the segment
// path. But as an extra precaution, we validate in prod, too.
didReceiveUnknownParallelRoute = true
} else {
const taskSegment = taskChild.route[0]
const serverSegment = createSegmentFromRouteTree(serverRouteTreeChild)
if (
matchSegment(serverSegment, taskSegment) &&
dynamicDataChild !== null &&
dynamicDataChild !== undefined
) {
// Found a match for this task. Keep traversing down the task tree.
const childDidReceiveUnknownParallelRoute =
writeDynamicDataIntoNavigationTask(
taskChild,
serverRouteTreeChild,
dynamicDataChild,
dynamicHead,
dynamicStaleAt,
debugInfo
)
if (childDidReceiveUnknownParallelRoute) {
didReceiveUnknownParallelRoute = true
}
}
}
}
} else {
if (serverChildren !== null) {
// The server sent a child segment that the client doesn't know about.
didReceiveUnknownParallelRoute = true
}
}
}
return didReceiveUnknownParallelRoute
}
function finishPendingCacheNode(
cacheNode: CacheNode,
dynamicData: CacheNodeSeedData,
dynamicHead: HeadData,
debugInfo: Array<any> | null
): void {
// Writes a dynamic response into an existing Cache Node tree. This does _not_
// create a new tree, it updates the existing tree in-place. So it must follow
// the Suspense rules of cache safety — it can resolve pending promises, but
// it cannot overwrite existing data. It can add segments to the tree (because
// a missing segment will cause the layout router to suspend).
// but it cannot delete them.
//
// We must resolve every promise in the tree, or else it will suspend
// indefinitely. If we did not receive data for a segment, we will resolve its
// data promise to `null` to trigger a lazy fetch during render.
// Use the dynamic data from the server to fulfill the deferred RSC promise
// on the Cache Node.
const rsc = cacheNode.rsc
const dynamicSegmentData = dynamicData[0]
if (dynamicSegmentData === null) {
// This is an empty CacheNode; this particular server request did not
// render this segment. There may be a separate pending request that will,
// though, so we won't abort the task until all pending requests finish.
return
}
if (rsc === null) {
// This is a lazy cache node. We can overwrite it. This is only safe
// because we know that the LayoutRouter suspends if `rsc` is `null`.
cacheNode.rsc = dynamicSegmentData
} else if (isDeferredRsc(rsc)) {
// This is a deferred RSC promise. We can fulfill it with the data we just
// received from the server. If it was already resolved by a different
// navigation, then this does nothing because we can't overwrite data.
rsc.resolve(dynamicSegmentData, debugInfo)
} else {
// This is not a deferred RSC promise, nor is it empty, so it must have
// been populated by a different navigation. We must not overwrite it.
}
// Check if this is a leaf segment. If so, it will have a `head` property with
// a pending promise that needs to be resolved with the dynamic head from
// the server.
const head = cacheNode.head
if (isDeferredRsc(head)) {
head.resolve(dynamicHead, debugInfo)
}
}
function abortRemainingPendingTasks(
task: NavigationTask,
error: any,
debugInfo: Array<any> | null
): NavigationTaskExitStatus {
let exitStatus
if (task.status === NavigationTaskStatus.Pending) {
// The data for this segment is still missing.
task.status = NavigationTaskStatus.Rejected
abortPendingCacheNode(task.node, error, debugInfo)
// If the server failed to fulfill the data for this segment, it implies
// that the route tree received from the server mismatched the tree that
// was previously prefetched.
//
// In an app with fully static routes and no proxy-driven redirects or
// rewrites, this should never happen, because the route for a URL would
// always be the same across multiple requests. So, this implies that some
// runtime routing condition changed, likely in a proxy, without being
// pushed to the client.
//
// When this happens, we treat this the same as a refresh(). The entire
// tree will be re-rendered from the root.
if (task.refreshState === null) {
// Trigger a "soft" refresh. Essentially the same as calling `refresh()`
// in a Server Action.
exitStatus = NavigationTaskExitStatus.SoftRetry
} else {
// The mismatch was discovered inside an inactive parallel route. This
// implies the inactive parallel route is no longer reachable at the URL
// that originally rendered it. Fall back to an MPA refresh.
// TODO: An alternative could be to trigger a soft refresh but to _not_
// re-use the inactive parallel routes this time. Similar to what would
// happen if were to do a hard refrehs, but without the HTML page.
exitStatus = NavigationTaskExitStatus.HardRetry
}
} else {
// This segment finished. (An error here is treated as Done because they are
// surfaced to the application during render.)
exitStatus = NavigationTaskExitStatus.Done
}
const taskChildren = task.children
if (taskChildren !== null) {
for (const [, taskChild] of taskChildren) {
const childExitStatus = abortRemainingPendingTasks(
taskChild,
error,
debugInfo
)
// Propagate the exit status up the tree. The statuses are ordered by
// their precedence.
if (childExitStatus > exitStatus) {
exitStatus = childExitStatus
}
}
}
return exitStatus
}
function abortPendingCacheNode(
cacheNode: CacheNode,
error: any,
debugInfo: Array<any> | null
): void {
const rsc = cacheNode.rsc
if (isDeferredRsc(rsc)) {
if (error === null) {
// This will trigger a lazy fetch during render.
rsc.resolve(null, debugInfo)
} else {
// This will trigger an error during rendering.
rsc.reject(error, debugInfo)
}
}
// Check if this is a leaf segment. If so, it will have a `head` property with
// a pending promise that needs to be resolved. If an error was provided, we
// will not resolve it with an error, since this is rendered at the root of
// the app. We want the segment to error, not the entire app.
const head = cacheNode.head
if (isDeferredRsc(head)) {
head.resolve(null, debugInfo)
}
}
const DEFERRED = Symbol()
type PendingDeferredRsc<T> = Promise<T> & {
status: 'pending'
resolve: (value: T, debugInfo: Array<any> | null) => void
reject: (error: any, debugInfo: Array<any> | null) => void
tag: Symbol
_debugInfo: Array<any>
}
type FulfilledDeferredRsc<T> = Promise<T> & {
status: 'fulfilled'
value: T
resolve: (value: T, debugInfo: Array<any> | null) => void
reject: (error: any, debugInfo: Array<any> | null) => void
tag: Symbol
_debugInfo: Array<any>
}
type RejectedDeferredRsc<T> = Promise<T> & {
status: 'rejected'
reason: any
resolve: (value: T, debugInfo: Array<any> | null) => void
reject: (error: any, debugInfo: Array<any> | null) => void
tag: Symbol
_debugInfo: Array<any>
}
type DeferredRsc<T extends React.ReactNode = React.ReactNode> =
| PendingDeferredRsc<T>
| FulfilledDeferredRsc<T>
| RejectedDeferredRsc<T>
// This type exists to distinguish a DeferredRsc from a Flight promise. It's a
// compromise to avoid adding an extra field on every Cache Node, which would be
// awkward because the pre-PPR parts of codebase would need to account for it,
// too. We can remove it once type Cache Node type is more settled.
export function isDeferredRsc(value: any): value is DeferredRsc {
return value && typeof value === 'object' && value.tag === DEFERRED
}
function createDeferredRsc<
T extends React.ReactNode = React.ReactNode,
>(): PendingDeferredRsc<T> {
// Create an unresolved promise that represents data derived from a Flight
// response. The promise will be resolved later as soon as we start receiving
// data from the server, i.e. as soon as the Flight client decodes and returns
// the top-level response object.
// The `_debugInfo` field contains profiling information. Promises that are
// created by Flight already have this info added by React; for any derived
// promise created by the router, we need to transfer the Flight debug info
// onto the derived promise.
//
// The debug info represents the latency between the start of the navigation
// and the start of rendering. (It does not represent the time it takes for
// whole stream to finish.)
const debugInfo: Array<any> = []
let resolve: any
let reject: any
const pendingRsc = new Promise<T>((res, rej) => {
resolve = res
reject = rej
}) as PendingDeferredRsc<T>
pendingRsc.status = 'pending'
pendingRsc.resolve = (value: T, responseDebugInfo: Array<any> | null) => {
if (pendingRsc.status === 'pending') {
const fulfilledRsc: FulfilledDeferredRsc<T> = pendingRsc as any
fulfilledRsc.status = 'fulfilled'
fulfilledRsc.value = value
if (responseDebugInfo !== null) {
// Transfer the debug info to the derived promise.
debugInfo.push.apply(debugInfo, responseDebugInfo)
}
resolve(value)
}
}
pendingRsc.reject = (error: any, responseDebugInfo: Array<any> | null) => {
if (pendingRsc.status === 'pending') {
const rejectedRsc: RejectedDeferredRsc<T> = pendingRsc as any
rejectedRsc.status = 'rejected'
rejectedRsc.reason = error
if (responseDebugInfo !== null) {
// Transfer the debug info to the derived promise.
debugInfo.push.apply(debugInfo, responseDebugInfo)
}
reject(error)
}
}
pendingRsc.tag = DEFERRED
pendingRsc._debugInfo = debugInfo
return pendingRsc
}
/**
* Helper for the Instant Navigation Testing API. Waits for the navigation lock
* to be released before returning. The network request has already completed by
* the time this is called, so this only delays writing the dynamic data.
*
* Not exposed in production builds by default.
*/
async function waitForNavigationLock(): Promise<void> {
if (process.env.__NEXT_EXPOSE_TESTING_API) {
const { waitForNavigationLockIfActive } =
require('../segment-cache/navigation-testing-lock') as typeof import('../segment-cache/navigation-testing-lock')
await waitForNavigationLockIfActive()
}
}