Re-implement hmr-refresh in terms of refresh (#87135)

Based on:
- #87134 

---

hmr-refresh-reducer is identical to refresh-reducer except for the fact
that it doesn't evict the prefetch cache. So this refactors both
implementations to call the same shared function.
This commit is contained in:
Andrew Clark
2025-12-15 18:27:14 -05:00
committed by GitHub
parent 1271708e22
commit 3ff99bd0e8
21 changed files with 1045 additions and 710 deletions
@@ -79,13 +79,7 @@ function runRemainingActions(
// after the navigation has already finished and the queue is empty
if (actionQueue.needsRefresh) {
actionQueue.needsRefresh = false
actionQueue.dispatch(
{
type: ACTION_REFRESH,
origin: window.location.origin,
},
setState
)
actionQueue.dispatch({ type: ACTION_REFRESH }, setState)
}
}
}
@@ -383,7 +377,6 @@ export const publicAppRouterInstance: AppRouterInstance = {
startTransition(() => {
dispatchAppRouterAction({
type: ACTION_REFRESH,
origin: window.location.origin,
})
})
},
@@ -396,7 +389,6 @@ export const publicAppRouterInstance: AppRouterInstance = {
startTransition(() => {
dispatchAppRouterAction({
type: ACTION_HMR_REFRESH,
origin: window.location.origin,
})
})
}
@@ -50,6 +50,7 @@ import {
} from '../../shared/lib/hooks-client-context.shared-runtime'
import { getParamValueFromCacheKey } from '../route-params'
import type { Params } from '../../server/request/params'
import { isDeferredRsc } from './router-reducer/ppr-navigations'
/**
* Add refetch marker to router state at the point of the current layout segment.
@@ -375,11 +376,27 @@ function InnerLayoutRouter({
// special case `null` to represent that this segment's data is missing. If
// it's a promise, we need to unwrap it so we can determine whether or not the
// data is missing.
const resolvedRsc: React.ReactNode =
typeof rsc === 'object' && rsc !== null && typeof rsc.then === 'function'
? use(rsc)
: rsc
let resolvedRsc: React.ReactNode
if (isDeferredRsc(rsc)) {
const unwrappedRsc = use(rsc)
if (unwrappedRsc === null) {
// If the promise was resolved to `null`, it means the data for this
// segment was not returned by the server. Suspend indefinitely. When this
// happens, the router is responsible for triggering a new state update to
// un-suspend this segment.
use(unresolvedThenable) as never
}
resolvedRsc = unwrappedRsc
} else {
// This is not a deferred RSC promise. Don't need to unwrap it.
resolvedRsc = rsc
}
// TODO: At this point, the only reason `resolvedRsc` would be null is if the
// data for this segment was fetched by a reducer that hasn't been migrated
// yet to the Segment Cache implementation. It shouldn't happen for regular
// navigations. Once we convert the remaining reducers, we can delete the
// lazy fetching block below.
if (!resolvedRsc) {
// The data for this segment is not available, and there's no pending
// navigation that will be able to fulfill it. We need to fetch more from
@@ -418,6 +435,7 @@ function InnerLayoutRouter({
previousTree: fullTree,
serverResponse,
navigatedAt,
retry: null,
})
})
@@ -610,6 +628,14 @@ export default function OuterLayoutRouter({
// (This only applies to page segments; layout segments cannot access search
// params on the server.)
const activeTree = parentTree[1][parallelRouterKey]
if (activeTree === undefined) {
// Could not find a matching segment. The client tree is inconsistent with
// the server tree. Suspend indefinitely; the router will have already
// detected the inconsistency when handling the server response, and
// triggered a refresh of the page to recover.
use(unresolvedThenable) as never
}
const activeSegment = activeTree[0]
const activeStateKey = createRouterCacheKey(activeSegment, true) // no search params
@@ -2,7 +2,6 @@ import type {
CacheNodeSeedData,
FlightRouterState,
FlightSegmentPath,
Segment,
} from '../../../shared/lib/app-router-types'
import type {
CacheNode,
@@ -20,12 +19,18 @@ import {
import { matchSegment } from '../match-segments'
import { createHrefFromUrl } from './create-href-from-url'
import { createRouterCacheKey } from './create-router-cache-key'
import { fetchServerResponse } from './fetch-server-response'
import { dispatchAppRouterAction } from '../use-action-queue'
import {
fetchServerResponse,
type FetchServerResponseResult,
} from './fetch-server-response'
ACTION_SERVER_PATCH,
type ServerPatchAction,
} from './router-reducer-types'
import { isNavigatingToNewRootLayout } from './is-navigating-to-new-root-layout'
import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer'
import {
convertServerPatchToFullTree,
type NavigationSeed,
} from '../segment-cache/navigation'
// 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
@@ -34,6 +39,7 @@ import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer'
// 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.
@@ -55,6 +61,34 @@ export const enum FreshnessPolicy {
Hydration,
HistoryTraversal,
RefreshAll,
HMRRefresh,
}
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 = {
@@ -62,6 +96,8 @@ export type NavigationRequestAccumulation = {
separateRefreshUrls: Set<string> | null
}
const noop = () => {}
export function createInitialCacheNodeForHydration(
navigatedAt: number,
initialTree: FlightRouterState,
@@ -312,6 +348,7 @@ function updateCacheNodeOnNavigation(
shouldRefreshDynamicData = false
break
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
shouldDropSiblingCaches = true
shouldRefreshDynamicData = true
break
@@ -350,7 +387,7 @@ function updateCacheNodeOnNavigation(
newParallelRoutes
)
needsDynamicRequest = false
} else if (seedData !== null) {
} else if (seedData !== null && seedData[0] !== null) {
// If this navigation was the result of an action, then check if the
// server sent back data in the action response. We should favor using
// that, rather than performing a separate request. This is both better
@@ -567,6 +604,9 @@ function updateCacheNodeOnNavigation(
}
return {
status: needsDynamicRequest
? NavigationTaskStatus.Pending
: NavigationTaskStatus.Fulfilled,
route: patchRouterStateWithNewChildren(
newRouterState,
patchedRouterStateChildren
@@ -580,13 +620,7 @@ function updateCacheNodeOnNavigation(
parentNeedsDynamicRequest
),
refreshUrl,
// NavigationTasks only have children if neither itself nor any of its
// parents require a dynamic request. When writing dynamic data into the
// tree, we can skip over any tasks that have children.
// TODO: This is probably an unncessary optimization. The task tree only
// lives for as long as the navigation request, anyway.
children:
parentNeedsDynamicRequest || needsDynamicRequest ? null : taskChildren,
children: taskChildren,
}
}
@@ -682,6 +716,7 @@ function createCacheNodeOnNavigation(
}
break
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
// Drop all dynamic data.
shouldRefreshDynamicData = true
shouldDropSiblingCaches = true
@@ -723,7 +758,7 @@ function createCacheNodeOnNavigation(
newParallelRoutes
)
needsDynamicRequest = false
} else if (seedData !== null) {
} else if (seedData !== null && seedData[0] !== null) {
// If this navigation was the result of an action, then check if the
// server sent back data in the action response. We should favor using
// that, rather than performing a separate request. This is both better
@@ -872,6 +907,9 @@ function createCacheNodeOnNavigation(
}
return {
status: needsDynamicRequest
? NavigationTaskStatus.Pending
: NavigationTaskStatus.Fulfilled,
route: patchRouterStateWithNewChildren(
newRouterState,
patchedRouterStateChildren
@@ -887,8 +925,7 @@ function createCacheNodeOnNavigation(
// This route is not part of the current tree, so there's no reason to
// track the refresh URL.
refreshUrl: null,
children:
parentNeedsDynamicRequest || needsDynamicRequest ? null : taskChildren,
children: taskChildren,
}
}
@@ -1122,6 +1159,11 @@ function spawnNewCacheNode(
return cacheNode
}
// 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
@@ -1137,80 +1179,58 @@ function spawnNewCacheNode(
//
// 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 listenForDynamicRequest(
url: URL,
nextUrl: string | null,
export function spawnDynamicRequests(
task: NavigationTask,
dynamicRequestTree: FlightRouterState,
// TODO: Rather than pass this into listenForDynamicRequest, we should seed
// the data into the CacheNode tree during the first traversal. Similar to
// what we will do for seeding navigations from a Server Action.
existingDynamicRequestPromise: Promise<FetchServerResponseResult> | null,
primaryUrl: URL,
nextUrl: string | null,
freshnessPolicy: FreshnessPolicy,
accumulation: NavigationRequestAccumulation
): void {
const requestPromises = []
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
)
const separateRefreshUrls = accumulation.separateRefreshUrls
if (separateRefreshUrls === null) {
// Normal case. All the data can be fetched from the same URL.
if (existingDynamicRequestPromise !== null) {
// A dynamic request was already initiated. This can happen if the route
// tree was not already prefetched/cached before navigation.
requestPromises.push(
attachServerResponseListener(task, existingDynamicRequestPromise)
)
} else {
// Initiate a new dynamic request.
requestPromises.push(
attachServerResponseListener(
task,
fetchServerResponse(url, {
flightRouterState: dynamicRequestTree,
nextUrl,
})
)
)
}
} else {
// This is a refresh navigation, and 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.
//
// First construct a request tree for the main URL. This will prune away
// the parts of the tree that are not present in the current route. (`null`
// as the second argument is used to represent the main URL.)
if (existingDynamicRequestPromise !== null) {
// A dynamic request was already initiated. This can happen if the route
// tree was not already prefetched/cached before navigation.
requestPromises.push(
attachServerResponseListener(task, existingDynamicRequestPromise)
)
} else {
// Initiate a new dynamic request.
// 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 primaryDynamicRequestTree = splitTaskByURL(task, null)
const primaryDynamicRequestTree = dynamicRequestTree
if (primaryDynamicRequestTree !== null) {
requestPromises.push(
attachServerResponseListener(
task,
fetchServerResponse(url, {
flightRouterState: primaryDynamicRequestTree,
nextUrl,
})
)
)
}
}
// Then construct a request tree for each additional refresh URL. This will
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.
const canonicalUrl = createHrefFromUrl(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.
@@ -1227,188 +1247,317 @@ export function listenForDynamicRequest(
// const scopedDynamicRequestTree = splitTaskByURL(task, refreshUrl)
const scopedDynamicRequestTree = dynamicRequestTree
if (scopedDynamicRequestTree !== null) {
requestPromises.push(
attachServerResponseListener(
refreshRequestPromises.push(
fetchMissingDynamicData(
task,
fetchServerResponse(new URL(refreshUrl, url.origin), {
flightRouterState: scopedDynamicRequestTree,
nextUrl,
})
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
)
)
}
}
}
// Once we've exhausted all the data we received from the server, if there are
// any remaining pending tasks in the tree, abort them. As a last ditch
// effort, this will trigger the "old" fetching path (server-patch-reducer)
// in LayoutRouter, though in the future we'll remove server-patch-reducer
// and handle server failures using some more robust mechanism. Perhaps by
// throwing a special offline error, or by triggering an MPA refresh.
Promise.all(requestPromises).then(
() => abortTask(task, null, null),
() => abortTask(task, null, null)
// Further async operations are moved into this separate function to
// discourage sequential network requests.
const voidPromise = finishNavigationTask(
task,
nextUrl,
primaryRequestPromise,
refreshRequestPromises
)
// `finishNavigationTask` is responsible for error handling, so we can attach
// noop callbacks to this promise.
voidPromise.then(noop, noop)
}
function attachServerResponseListener(
async function finishNavigationTask(
task: NavigationTask,
requestPromise: Promise<FetchServerResponseResult>
nextUrl: string | null,
primaryRequestPromise: ReturnType<typeof fetchMissingDynamicData>,
refreshRequestPromises: Array<
ReturnType<typeof fetchMissingDynamicData>
> | null
): Promise<void> {
return requestPromise.then((result) => {
if (typeof result === 'string') {
// Happens when navigating to page in `pages` from `app`. We shouldn't
// get here because should have already handled this during
// the prefetch.
// 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
}
const { flightData, debugInfo } = result
for (const normalizedFlightData of flightData) {
const {
segmentPath,
tree: serverRouterState,
seedData: dynamicData,
head: dynamicHead,
} = normalizedFlightData
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
)
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
)
return
}
default: {
return exitStatus satisfies never
}
}
}
if (!dynamicData) {
// This shouldn't happen. PPR should always send back a response.
// However, `FlightDataPath` is a shared type and the pre-PPR handling of
// this might return null.
continue
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)
writeDynamicDataIntoPendingTask(
task,
segmentPath,
serverRouterState,
dynamicData,
dynamicHead,
debugInfo
// 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 writeDynamicDataIntoPendingTask(
rootTask: NavigationTask,
segmentPath: FlightSegmentPath,
function dispatchRetryDueToTreeMismatch(
isHardRetry: boolean,
retryUrl: URL,
retryNextUrl: string | null,
seed: NavigationSeed | null,
baseTree: FlightRouterState
) {
// 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
const retryAction: ServerPatchAction = {
type: ACTION_SERVER_PATCH,
navigatedAt: Date.now(),
previousTree: baseTree,
serverResponse: null,
retry: {
url: retryUrl,
nextUrl: retryNextUrl,
seed,
mpa: isHardRetry,
},
}
dispatchAppRouterAction(retryAction)
}
async function fetchMissingDynamicData(
task: NavigationTask,
dynamicRequestTree: FlightRouterState,
url: URL,
nextUrl: string | null,
freshnessPolicy: FreshnessPolicy
): 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 seed = convertServerPatchToFullTree(
task.route,
result.flightData,
result.renderedSearch
)
const didReceiveUnknownParallelRoute = writeDynamicDataIntoNavigationTask(
task,
seed.tree,
seed.data,
seed.head,
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,
serverRouterState: FlightRouterState,
dynamicData: CacheNodeSeedData,
dynamicData: CacheNodeSeedData | null,
dynamicHead: HeadData,
debugInfo: Array<any> | null
) {
// The data sent by the server represents only a subtree of the app. We need
// to find the part of the task tree that matches the server response, and
// fulfill it using the dynamic data.
//
// segmentPath represents the parent path of subtree. It's a repeating pattern
// of parallel route key and segment:
//
// [string, Segment, string, Segment, string, Segment, ...]
//
// Iterate through the path and finish any tasks that match this payload.
let task = rootTask
for (let i = 0; i < segmentPath.length; i += 2) {
const parallelRouteKey: string = segmentPath[i]
const segment: Segment = segmentPath[i + 1]
const taskChildren = task.children
if (taskChildren !== null) {
): boolean {
if (task.status === NavigationTaskStatus.Pending && dynamicData !== null) {
task.status = NavigationTaskStatus.Fulfilled
finishPendingCacheNode(task.node, dynamicData, dynamicHead, debugInfo)
}
const taskChildren = task.children
const serverChildren = serverRouterState[1]
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) {
for (const parallelRouteKey in serverChildren) {
const serverRouterStateChild: FlightRouterState =
serverChildren[parallelRouteKey]
const dynamicDataChild: CacheNodeSeedData | null | void =
dynamicDataChildren !== null
? dynamicDataChildren[parallelRouteKey]
: null
const taskChild = taskChildren.get(parallelRouteKey)
if (taskChild !== undefined) {
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]
if (matchSegment(segment, taskSegment)) {
if (
matchSegment(serverRouterStateChild[0], taskSegment) &&
dynamicDataChild !== null &&
dynamicDataChild !== undefined
) {
// Found a match for this task. Keep traversing down the task tree.
task = taskChild
continue
const childDidReceiveUnknownParallelRoute =
writeDynamicDataIntoNavigationTask(
taskChild,
serverRouterStateChild,
dynamicDataChild,
dynamicHead,
debugInfo
)
if (childDidReceiveUnknownParallelRoute) {
didReceiveUnknownParallelRoute = true
}
}
}
}
// We didn't find a child task that matches the server data. Exit. We won't
// abort the task, though, because a different FlightDataPath may be able to
// fulfill it (see loop in listenForDynamicRequest). We only abort tasks
// once we've run out of data.
return
}
finishTaskUsingDynamicDataPayload(
task,
serverRouterState,
dynamicData,
dynamicHead,
debugInfo
)
}
function finishTaskUsingDynamicDataPayload(
task: NavigationTask,
serverRouterState: FlightRouterState,
dynamicData: CacheNodeSeedData,
dynamicHead: HeadData,
debugInfo: Array<any> | null
) {
if (task.dynamicRequestTree === null) {
// Everything in this subtree is already complete. Bail out.
return
}
// dynamicData may represent a larger subtree than the task. Before we can
// finish the task, we need to line them up.
const taskChildren = task.children
const taskNode = task.node
if (taskChildren === null) {
// We've reached the leaf node of the pending task. The server data tree
// lines up the pending Cache Node tree. We can now switch to the
// normal algorithm.
if (taskNode !== null) {
finishPendingCacheNode(
taskNode,
task.route,
serverRouterState,
dynamicData,
dynamicHead,
debugInfo
)
}
return
}
// The server returned more data than we need to finish the task. Skip over
// the extra segments until we reach the leaf task node.
const serverChildren = serverRouterState[1]
const dynamicDataChildren = dynamicData[1]
for (const parallelRouteKey in serverChildren) {
const serverRouterStateChild: FlightRouterState =
serverChildren[parallelRouteKey]
const dynamicDataChild: CacheNodeSeedData | null | void =
dynamicDataChildren[parallelRouteKey]
const taskChild = taskChildren.get(parallelRouteKey)
if (taskChild !== undefined) {
const taskSegment = taskChild.route[0]
if (
matchSegment(serverRouterStateChild[0], taskSegment) &&
dynamicDataChild !== null &&
dynamicDataChild !== undefined
) {
// Found a match for this task. Keep traversing down the task tree.
finishTaskUsingDynamicDataPayload(
taskChild,
serverRouterStateChild,
dynamicDataChild,
dynamicHead,
debugInfo
)
}
}
}
return didReceiveUnknownParallelRoute
}
function finishPendingCacheNode(
cacheNode: CacheNode,
taskState: FlightRouterState,
serverState: FlightRouterState,
dynamicData: CacheNodeSeedData,
dynamicHead: HeadData,
debugInfo: Array<any> | null
@@ -1423,54 +1572,19 @@ function finishPendingCacheNode(
// 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.
const taskStateChildren = taskState[1]
const serverStateChildren = serverState[1]
const dataChildren = dynamicData[1]
// The router state that we traverse the tree with (taskState) is the same one
// that we used to construct the pending Cache Node tree. That way we're sure
// to resolve all the pending promises.
const parallelRoutes = cacheNode.parallelRoutes
for (let parallelRouteKey in taskStateChildren) {
const taskStateChild: FlightRouterState =
taskStateChildren[parallelRouteKey]
const serverStateChild: FlightRouterState | void =
serverStateChildren[parallelRouteKey]
const dataChild: CacheNodeSeedData | null | void =
dataChildren[parallelRouteKey]
const segmentMapChild = parallelRoutes.get(parallelRouteKey)
const taskSegmentChild = taskStateChild[0]
const taskSegmentKeyChild = createRouterCacheKey(taskSegmentChild)
const cacheNodeChild =
segmentMapChild !== undefined
? segmentMapChild.get(taskSegmentKeyChild)
: undefined
if (cacheNodeChild !== undefined) {
if (
serverStateChild !== undefined &&
matchSegment(taskSegmentChild, serverStateChild[0]) &&
dataChild !== undefined &&
dataChild !== null
) {
finishPendingCacheNode(
cacheNodeChild,
taskStateChild,
serverStateChild,
dataChild,
dynamicHead,
debugInfo
)
}
}
}
// 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`.
@@ -1502,67 +1616,72 @@ function finishPendingCacheNode(
}
}
export function abortTask(
function abortRemainingPendingTasks(
task: NavigationTask,
error: any,
debugInfo: Array<any> | null
): void {
const cacheNode = task.node
if (cacheNode === null) {
// This indicates the task is already complete.
return
): 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.refreshUrl === 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) {
// Reached the leaf task node. This is the root of a pending cache
// node tree.
abortPendingCacheNode(task.route, cacheNode, error, debugInfo)
} else {
// This is an intermediate task node. Keep traversing until we reach a
// task node with no children. That will be the root of the cache node tree
// that needs to be resolved.
for (const taskChild of taskChildren.values()) {
abortTask(taskChild, error, debugInfo)
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
}
}
}
// Set this to null to indicate that this task is now complete.
task.dynamicRequestTree = null
return exitStatus
}
function abortPendingCacheNode(
routerState: FlightRouterState,
cacheNode: CacheNode,
error: any,
debugInfo: Array<any> | null
): void {
// For every pending segment in the tree, resolve its `rsc` promise to `null`
// to trigger a lazy fetch during render.
//
// Or, if an error object is provided, it will error instead.
const routerStateChildren = routerState[1]
const parallelRoutes = cacheNode.parallelRoutes
for (let parallelRouteKey in routerStateChildren) {
const routerStateChild: FlightRouterState =
routerStateChildren[parallelRouteKey]
const segmentMapChild = parallelRoutes.get(parallelRouteKey)
if (segmentMapChild === undefined) {
// This shouldn't happen because we're traversing the same tree that was
// used to construct the cache nodes in the first place.
continue
}
const segmentChild = routerStateChild[0]
const segmentKeyChild = createRouterCacheKey(segmentChild)
const cacheNodeChild = segmentMapChild.get(segmentKeyChild)
if (cacheNodeChild !== undefined) {
abortPendingCacheNode(routerStateChild, cacheNodeChild, error, debugInfo)
} else {
// This shouldn't happen because we're traversing the same tree that was
// used to construct the cache nodes in the first place.
}
}
const rsc = cacheNode.rsc
if (isDeferredRsc(rsc)) {
if (error === null) {
@@ -1626,7 +1745,7 @@ type DeferredRsc<T extends React.ReactNode = React.ReactNode> =
// 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.
function isDeferredRsc(value: any): value is DeferredRsc {
export function isDeferredRsc(value: any): value is DeferredRsc {
return value && typeof value === 'object' && value.tag === DEFERRED
}
@@ -1,130 +1,10 @@
import {
fetchServerResponse,
type FetchServerResponseResult,
} from '../fetch-server-response'
import { createHrefFromUrl } from '../create-href-from-url'
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type {
ReadonlyReducerState,
ReducerState,
HmrRefreshAction,
Mutable,
} from '../router-reducer-types'
import { handleExternalUrl } from './navigate-reducer'
import { handleMutable } from '../handle-mutable'
import { applyFlightData } from '../apply-flight-data'
import type { CacheNode } from '../../../../shared/lib/app-router-types'
import { createEmptyCacheNode } from '../../app-router'
import { handleSegmentMismatch } from '../handle-segment-mismatch'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
import { refreshDynamicData } from './refresh-reducer'
import { FreshnessPolicy } from '../ppr-navigations'
// A version of refresh reducer that keeps the cache around instead of wiping all of it.
function hmrRefreshReducerImpl(
state: ReadonlyReducerState,
action: HmrRefreshAction
): ReducerState {
const { origin } = action
const mutable: Mutable = {}
const href = state.canonicalUrl
mutable.preserveCustomHistoryState = false
const cache: CacheNode = createEmptyCacheNode()
// If the current tree was intercepted, the nextUrl should be included in the request.
// This is to ensure that the refresh request doesn't get intercepted, accidentally triggering the interception route.
const includeNextUrl = hasInterceptionRouteInCurrentTree(state.tree)
// TODO-APP: verify that `href` is not an external url.
// Fetch data from the root of the tree.
const navigatedAt = Date.now()
cache.lazyData = fetchServerResponse(new URL(href, origin), {
flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'],
nextUrl: includeNextUrl ? state.nextUrl : null,
isHmrRefresh: true,
})
return cache.lazyData.then(
(result: FetchServerResponseResult) => {
// Handle case when navigating to page in `pages` from `app`
if (typeof result === 'string') {
return handleExternalUrl(
state,
mutable,
result,
state.pushRef.pendingPush
)
}
const { flightData, canonicalUrl, renderedSearch } = result
// Remove cache.lazyData as it has been resolved at this point.
cache.lazyData = null
let currentTree = state.tree
let currentCache = state.cache
for (const normalizedFlightData of flightData) {
const { tree: treePatch, isRootRender } = normalizedFlightData
if (!isRootRender) {
// TODO-APP: handle this case better
console.log('REFRESH FAILED')
return state
}
const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove ''
[''],
currentTree,
treePatch,
state.canonicalUrl
)
if (newTree === null) {
return handleSegmentMismatch(state, action, treePatch)
}
if (isNavigatingToNewRootLayout(currentTree, newTree)) {
return handleExternalUrl(
state,
mutable,
href,
state.pushRef.pendingPush
)
}
const applied = applyFlightData(
navigatedAt,
currentCache,
cache,
normalizedFlightData
)
if (applied) {
mutable.cache = cache
currentCache = cache
}
mutable.patchedTree = newTree
mutable.renderedSearch = renderedSearch
mutable.canonicalUrl = createHrefFromUrl(canonicalUrl)
currentTree = newTree
}
return handleMutable(state, mutable)
},
() => state
)
export function hmrRefreshReducer(state: ReadonlyReducerState): ReducerState {
return refreshDynamicData(state, FreshnessPolicy.HMRRefresh)
}
function hmrRefreshReducerNoop(
state: ReadonlyReducerState,
_action: HmrRefreshAction
): ReducerState {
return state
}
export const hmrRefreshReducer =
process.env.NODE_ENV === 'production'
? hmrRefreshReducerNoop
: hmrRefreshReducerImpl
@@ -2,7 +2,6 @@ import type {
Mutable,
ReadonlyReducerState,
ReducerState,
RefreshAction,
} from '../router-reducer-types'
import { handleNavigationResult } from './navigate-reducer'
import { navigateToSeededRoute } from '../../segment-cache/navigation'
@@ -10,16 +9,21 @@ import { revalidateEntireCache } from '../../segment-cache/cache'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
import { FreshnessPolicy } from '../ppr-navigations'
export function refreshReducer(
state: ReadonlyReducerState,
action: RefreshAction
): ReducerState {
export function refreshReducer(state: ReadonlyReducerState): ReducerState {
// TODO: Currently, all refreshes purge the prefetch cache. In the future,
// only client-side refreshes will have this behavior; the server-side
// `refresh` should send new data without purging the prefetch cache.
const currentNextUrl = state.nextUrl
const currentRouterState = state.tree
revalidateEntireCache(currentNextUrl, currentRouterState)
return refreshDynamicData(state, FreshnessPolicy.RefreshAll)
}
export function refreshDynamicData(
state: ReadonlyReducerState,
freshnessPolicy: FreshnessPolicy.RefreshAll | FreshnessPolicy.HMRRefresh
): ReducerState {
const currentNextUrl = state.nextUrl
// We always send the last next-url, not the current when performing a dynamic
// request. This is because we update the next-url after a navigation, but we
@@ -30,26 +34,28 @@ export function refreshReducer(
// A refresh is modeled as a navigation to the current URL, but where any
// existing dynamic data (including in shared layouts) is re-fetched.
const currentUrl = new URL(state.canonicalUrl, action.origin)
const url = currentUrl
const currentCanonicalUrl = state.canonicalUrl
const currentUrl = new URL(currentCanonicalUrl, location.origin)
const currentFlightRouterState = state.tree
const shouldScroll = true
const seedFlightRouterState = state.tree
const seedRenderedSearch = state.renderedSearch
const seedData = null
const seedHead = null
const navigationSeed = {
tree: state.tree,
renderedSearch: state.renderedSearch,
data: null,
head: null,
}
const now = Date.now()
const result = navigateToSeededRoute(
url,
now,
currentUrl,
currentCanonicalUrl,
navigationSeed,
currentUrl,
state.cache,
currentFlightRouterState,
seedFlightRouterState,
seedRenderedSearch,
seedData,
seedHead,
FreshnessPolicy.RefreshAll,
freshnessPolicy,
nextUrlForRefresh,
shouldScroll
)
@@ -7,7 +7,7 @@ import type {
import { extractPathFromFlightRouterState } from '../compute-changed-path'
import {
FreshnessPolicy,
listenForDynamicRequest,
spawnDynamicRequests,
startPPRNavigation,
type NavigationRequestAccumulation,
} from '../ppr-navigations'
@@ -70,16 +70,13 @@ export function restoreReducer(
return handleExternalUrl(state, mutable, restoredCanonicalUrl, false)
}
if (task.dynamicRequestTree !== null) {
listenForDynamicRequest(
restoredUrl,
restoredNextUrl,
task,
task.dynamicRequestTree,
null,
accumulation
)
}
spawnDynamicRequests(
task,
restoredUrl,
restoredNextUrl,
FreshnessPolicy.HistoryTraversal,
accumulation
)
return {
// Set canonical url
@@ -411,19 +411,22 @@ export function serverActionReducer(
// will use this to render the new page. If this happens to be only a
// subset of the data needed to render the new page, we'll initiate a
// new fetch, like we would for a normal navigation.
const seedFlightRouterState = normalizedFlightData.tree
const seedRenderedSearch = flightDataRenderedSearch
const seedData = normalizedFlightData.seedData
const seedHead = normalizedFlightData.head
const redirectCanonicalUrl = createHrefFromUrl(redirectUrl)
const navigationSeed = {
tree: normalizedFlightData.tree,
renderedSearch: flightDataRenderedSearch,
data: normalizedFlightData.seedData,
head: normalizedFlightData.head,
}
const now = Date.now()
const result = navigateToSeededRoute(
now,
redirectUrl,
redirectCanonicalUrl,
navigationSeed,
currentUrl,
state.cache,
currentFlightRouterState,
seedFlightRouterState,
seedRenderedSearch,
seedData,
seedHead,
freshnessPolicy,
nextUrl,
shouldScroll
@@ -7,22 +7,82 @@ import type {
ReadonlyReducerState,
Mutable,
} from '../router-reducer-types'
import { handleExternalUrl } from './navigate-reducer'
import { handleExternalUrl, handleNavigationResult } from './navigate-reducer'
import { applyFlightData } from '../apply-flight-data'
import { handleMutable } from '../handle-mutable'
import type { CacheNode } from '../../../../shared/lib/app-router-types'
import { createEmptyCacheNode } from '../../app-router'
import { navigateToSeededRoute } from '../../segment-cache/navigation'
import { refreshReducer } from './refresh-reducer'
import { FreshnessPolicy } from '../ppr-navigations'
export function serverPatchReducer(
state: ReadonlyReducerState,
action: ServerPatchAction
): ReducerState {
const { serverResponse, navigatedAt } = action
const { serverResponse, navigatedAt, retry, previousTree } = action
const mutable: Mutable = {}
mutable.preserveCustomHistoryState = false
if (retry !== null) {
// A "retry" is a navigation that happens due to a route mismatch. It's
// similar to a refresh, because we will omit any existing dynamic data on
// the page. But we seed the retry navigation with the exact tree that the
// server just responded with.
const retryMpa = retry.mpa
const retryUrl = new URL(retry.url, location.origin)
const retrySeed = retry.seed
if (retryMpa || retrySeed === null) {
// If the server did not send back data during the mismatch, fall back to
// an MPA navigation.
return handleExternalUrl(state, mutable, retryUrl.href, false)
}
const currentUrl = new URL(state.canonicalUrl, location.origin)
if (previousTree !== state.tree) {
// There was another, more recent navigation since the once that
// mismatched. We can abort the retry, but we still need to refresh the
// page to evict any stale dynamic data.
return refreshReducer(state)
}
// There have been no new navigations since the mismatched one. Refresh,
// using the tree we just received from the server.
const retryCanonicalUrl = createHrefFromUrl(retryUrl)
const retryNextUrl = retry.nextUrl
// A retry should not create a new history entry.
const pendingPush = false
const shouldScroll = true
const now = Date.now()
const result = navigateToSeededRoute(
now,
retryUrl,
retryCanonicalUrl,
retrySeed,
currentUrl,
state.cache,
state.tree,
FreshnessPolicy.RefreshAll,
retryNextUrl,
shouldScroll
)
return handleNavigationResult(retryUrl, state, mutable, pendingPush, result)
}
// TODO: The rest of this reducer will be deleted once we migrate the
// remaining to reducers to no longer rely on the lazy data fetch that happens
// on mismatch in LayoutRouter.
if (serverResponse === null) {
// No data provided. Fall back to a hard refresh.
return handleExternalUrl(
state,
mutable,
state.canonicalUrl,
state.pushRef.pendingPush
)
}
// Handle case when navigating to page in `pages` from `app`
if (typeof serverResponse === 'string') {
return handleExternalUrl(
@@ -3,6 +3,7 @@ import type {
FlightRouterState,
FlightSegmentPath,
} from '../../../shared/lib/app-router-types'
import type { NavigationSeed } from '../segment-cache/navigation'
import type { FetchServerResponseResult } from './fetch-server-response'
export const ACTION_REFRESH = 'refresh'
@@ -48,12 +49,10 @@ export interface ServerActionMutable extends Mutable {
*/
export interface RefreshAction {
type: typeof ACTION_REFRESH
origin: Location['origin']
}
export interface HmrRefreshAction {
type: typeof ACTION_HMR_REFRESH
origin: Location['origin']
}
export type ServerActionDispatcher = (
@@ -139,8 +138,14 @@ export type AppHistoryState = {
export interface ServerPatchAction {
type: typeof ACTION_SERVER_PATCH
navigatedAt: number
serverResponse: FetchServerResponseResult
serverResponse: FetchServerResponseResult | null
previousTree: FlightRouterState
retry: null | {
url: URL
nextUrl: string | null
seed: NavigationSeed | null
mpa: boolean
}
}
/**
@@ -36,10 +36,10 @@ function clientReducer(
return restoreReducer(state, action)
}
case ACTION_REFRESH: {
return refreshReducer(state, action)
return refreshReducer(state)
}
case ACTION_HMR_REFRESH: {
return hmrRefreshReducer(state, action)
return hmrRefreshReducer(state)
}
case ACTION_SERVER_ACTION: {
return serverActionReducer(state, action)
@@ -12,7 +12,7 @@ import type { NormalizedFlightData } from '../../flight-data-helpers'
import { fetchServerResponse } from '../router-reducer/fetch-server-response'
import {
startPPRNavigation,
listenForDynamicRequest,
spawnDynamicRequests,
FreshnessPolicy,
type NavigationTask,
type NavigationRequestAccumulation,
@@ -133,8 +133,7 @@ export function navigate(
newCanonicalUrl,
renderedSearch,
freshnessPolicy,
shouldScroll,
url.hash
shouldScroll
)
}
@@ -178,8 +177,7 @@ export function navigate(
newCanonicalUrl,
newRenderedSearch,
freshnessPolicy,
shouldScroll,
url.hash
shouldScroll
)
}
}
@@ -196,34 +194,29 @@ export function navigate(
url,
currentUrl,
nextUrl,
isSamePageNavigation,
currentCacheNode,
currentFlightRouterState,
freshnessPolicy,
shouldScroll,
url.hash,
collectedDebugInfo
),
}
}
export function navigateToSeededRoute(
now: number,
url: URL,
canonicalUrl: string,
navigationSeed: NavigationSeed,
currentUrl: URL,
currentCacheNode: CacheNode,
currentCacheNode: CacheNode | null,
currentFlightRouterState: FlightRouterState,
seedFlightRouterState: FlightRouterState,
seedRenderedSearch: string,
seedData: CacheNodeSeedData | null,
seedHead: HeadData | null,
freshnessPolicy: FreshnessPolicy,
nextUrl: string | null,
shouldScroll: boolean
): SuccessfulNavigationResult | MPANavigationResult {
// A version of navigate() that accepts the target route tree as an argument
// rather than reading it from the prefetch cache.
const now = Date.now()
const canonicalUrl = createHrefFromUrl(url)
const accumulation: NavigationRequestAccumulation = {
scrollableSegments: null,
separateRefreshUrls: null,
@@ -234,10 +227,10 @@ export function navigateToSeededRoute(
currentUrl,
currentCacheNode,
currentFlightRouterState,
seedFlightRouterState,
navigationSeed.tree,
freshnessPolicy,
seedData,
seedHead,
navigationSeed.data,
navigationSeed.head,
null,
null,
false,
@@ -245,20 +238,11 @@ export function navigateToSeededRoute(
accumulation
)
if (task !== null) {
if (task.dynamicRequestTree !== null) {
listenForDynamicRequest(
url,
nextUrl,
task,
task.dynamicRequestTree,
null,
accumulation
)
}
spawnDynamicRequests(task, url, nextUrl, freshnessPolicy, accumulation)
return navigationTaskToResult(
task,
canonicalUrl,
seedRenderedSearch,
navigationSeed.renderedSearch,
accumulation.scrollableSegments,
shouldScroll,
url.hash
@@ -286,8 +270,7 @@ function navigateUsingPrefetchedRouteTree(
canonicalUrl: string,
renderedSearch: string,
freshnessPolicy: FreshnessPolicy,
shouldScroll: boolean,
hash: string
shouldScroll: boolean
): SuccessfulNavigationResult | MPANavigationResult {
// Recursively construct a prefetch tree by reading from the Segment Cache. To
// maintain compatibility, we output the same data structures as the old
@@ -317,23 +300,14 @@ function navigateUsingPrefetchedRouteTree(
accumulation
)
if (task !== null) {
if (task.dynamicRequestTree !== null) {
listenForDynamicRequest(
url,
nextUrl,
task,
task.dynamicRequestTree,
null,
accumulation
)
}
spawnDynamicRequests(task, url, nextUrl, freshnessPolicy, accumulation)
return navigationTaskToResult(
task,
canonicalUrl,
renderedSearch,
accumulation.scrollableSegments,
shouldScroll,
hash
url.hash
)
}
// Could not perform a SPA navigation. Revert to a full-page (MPA) navigation.
@@ -510,12 +484,10 @@ async function navigateDynamicallyWithNoPrefetch(
url: URL,
currentUrl: URL,
nextUrl: string | null,
isSamePageNavigation: boolean,
currentCacheNode: CacheNode | null,
currentFlightRouterState: FlightRouterState,
freshnessPolicy: FreshnessPolicy,
shouldScroll: boolean,
hash: string,
collectedDebugInfo: Array<unknown>
): Promise<MPANavigationResult | SuccessfulNavigationResult> {
// Runs when a navigation happens but there's no cached prefetch we can use.
@@ -538,6 +510,7 @@ async function navigateDynamicallyWithNoPrefetch(
break
case FreshnessPolicy.Hydration: // <- shouldn't happen during client nav
case FreshnessPolicy.RefreshAll:
case FreshnessPolicy.HMRRefresh:
dynamicRequestTree = DynamicRequestTreeForEntireRoute
break
default:
@@ -573,116 +546,100 @@ async function navigateDynamicallyWithNoPrefetch(
// Since the response format of dynamic requests and prefetches is slightly
// different, we'll need to massage the data a bit. Create FlightRouterState
// tree that simulates what we'd receive as the result of a prefetch.
const prefetchFlightRouterState = simulatePrefetchTreeUsingDynamicTreePatch(
const navigationSeed = convertServerPatchToFullTree(
currentFlightRouterState,
flightData
flightData,
renderedSearch
)
// In our simulated prefetch payload, we pretend that there's no prefetch data
// nor a prefetch head.
const seedData = null
const seedHead = null
const prefetchData = null
const prefetchHead = null
const isPrefetchHeadPartial = true
// Now we proceed exactly as we would for normal navigation.
const accumulation: NavigationRequestAccumulation = {
scrollableSegments: null,
separateRefreshUrls: null,
}
const task = startPPRNavigation(
return navigateToSeededRoute(
now,
url,
createHrefFromUrl(canonicalUrl),
navigationSeed,
currentUrl,
currentCacheNode,
currentFlightRouterState,
prefetchFlightRouterState,
freshnessPolicy,
seedData,
seedHead,
prefetchData,
prefetchHead,
isPrefetchHeadPartial,
isSamePageNavigation,
accumulation
nextUrl,
shouldScroll
)
if (task !== null) {
// In this case, we've already sent the dynamic request, so we don't
// actually use the request tree created by `startPPRNavigation`,
// except to check if it contains dynamic holes.
//
// This is almost always true, but it could be false if all the segment data
// was present in the cache, but the route tree was not. E.g. navigating
// to a URL that was not prefetched but rewrites to a different URL
// that was.
if (task.dynamicRequestTree !== null) {
listenForDynamicRequest(
url,
nextUrl,
task,
task.dynamicRequestTree,
promiseForDynamicServerResponse,
accumulation
)
} else {
// The prefetched tree does not contain dynamic holes — it's
// fully static. We don't need to process the server response further.
}
return navigationTaskToResult(
task,
createHrefFromUrl(canonicalUrl),
renderedSearch,
accumulation.scrollableSegments,
shouldScroll,
hash
)
}
// Could not perform a SPA navigation. Revert to a full-page (MPA) navigation.
return {
tag: NavigationResultTag.MPA,
data: createHrefFromUrl(canonicalUrl),
}
}
function simulatePrefetchTreeUsingDynamicTreePatch(
export type NavigationSeed = {
tree: FlightRouterState
renderedSearch: string
data: CacheNodeSeedData | null
head: HeadData | null
}
export function convertServerPatchToFullTree(
currentTree: FlightRouterState,
flightData: Array<NormalizedFlightData>
): FlightRouterState {
// Takes the current FlightRouterState and applies the router state patch
// received from the server, to create a full FlightRouterState tree that we
// can pretend was returned by a prefetch.
flightData: Array<NormalizedFlightData>,
renderedSearch: string
): NavigationSeed {
// During a client navigation or prefetch, the server sends back only a patch
// for the parts of the tree that have changed.
//
// (It sounds similar to what applyRouterStatePatch does, but it doesn't need
// to handle stuff like interception routes or diffing since that will be
// handled later.)
let baseTree = currentTree
for (const { segmentPath, tree: treePatch } of flightData) {
// If the server sends us multiple tree patches, we only need to clone the
// base tree when applying the first patch. After the first patch, we can
// apply the remaining patches in place without copying.
const canMutateInPlace = baseTree !== currentTree
baseTree = simulatePrefetchTreeUsingDynamicTreePatchImpl(
// This applies the patch to the base tree to create a full representation of
// the resulting tree.
//
// The return type includes a full FlightRouterState tree and a full
// CacheNodeSeedData tree. (Conceptually these are the same tree, and should
// eventually be unified, but there's still lots of existing code that
// operates on FlightRouterState trees alone without the CacheNodeSeedData.)
//
// TODO: This similar to what apply-router-state-patch-to-tree does. It
// will eventually fully replace it. We should get rid of all the remaining
// places where we iterate over the server patch format. This should also
// eventually replace normalizeFlightData.
let baseTree: FlightRouterState = currentTree
let baseData: CacheNodeSeedData | null = null
let head: HeadData | null = null
for (const {
segmentPath,
tree: treePatch,
seedData: dataPatch,
head: headPatch,
} of flightData) {
const result = convertServerPatchToFullTreeImpl(
baseTree,
baseData,
treePatch,
dataPatch,
segmentPath,
canMutateInPlace,
0
)
baseTree = result.tree
baseData = result.data
// This is the same for all patches per response, so just pick an
// arbitrary one
head = headPatch
}
return baseTree
return {
tree: baseTree,
data: baseData,
renderedSearch,
head,
}
}
function simulatePrefetchTreeUsingDynamicTreePatchImpl(
function convertServerPatchToFullTreeImpl(
baseRouterState: FlightRouterState,
patch: FlightRouterState,
baseData: CacheNodeSeedData | null,
treePatch: FlightRouterState,
dataPatch: CacheNodeSeedData | null,
segmentPath: FlightSegmentPath,
canMutateInPlace: boolean,
index: number
) {
): { tree: FlightRouterState; data: CacheNodeSeedData | null } {
if (index === segmentPath.length) {
// We reached the part of the tree that we need to patch.
return patch
return {
tree: treePatch,
data: dataPatch,
}
}
// segmentPath represents the parent path of subtree. It's a repeating
@@ -692,55 +649,74 @@ function simulatePrefetchTreeUsingDynamicTreePatchImpl(
//
// This path tells us which part of the base tree to apply the tree patch.
//
// NOTE: In the case of a fully dynamic request with no prefetch, we receive
// the FlightRouterState patch in the same request as the dynamic data.
// Therefore we don't need to worry about diffing the segment values; we can
// assume the server sent us a correct result.
// NOTE: We receive the FlightRouterState patch in the same request as the
// seed data patch. Therefore we don't need to worry about diffing the segment
// values; we can assume the server sent us a correct result.
const updatedParallelRouteKey: string = segmentPath[index]
// const segment: Segment = segmentPath[index + 1] <-- Not used, see note above
const baseChildren = baseRouterState[1]
const newChildren: { [parallelRouteKey: string]: FlightRouterState } = {}
for (const parallelRouteKey in baseChildren) {
const baseTreeChildren = baseRouterState[1]
const baseSeedDataChildren = baseData !== null ? baseData[1] : null
const newTreeChildren: Record<string, FlightRouterState> = {}
const newSeedDataChildren: Record<string, CacheNodeSeedData | null> = {}
for (const parallelRouteKey in baseTreeChildren) {
const childBaseRouterState = baseTreeChildren[parallelRouteKey]
const childBaseSeedData =
baseSeedDataChildren !== null
? (baseSeedDataChildren[parallelRouteKey] ?? null)
: null
if (parallelRouteKey === updatedParallelRouteKey) {
const childBaseRouterState = baseChildren[parallelRouteKey]
newChildren[parallelRouteKey] =
simulatePrefetchTreeUsingDynamicTreePatchImpl(
childBaseRouterState,
patch,
segmentPath,
canMutateInPlace,
// Advance the index by two and keep cloning until we reach
// the end of the segment path.
index + 2
)
const result = convertServerPatchToFullTreeImpl(
childBaseRouterState,
childBaseSeedData,
treePatch,
dataPatch,
segmentPath,
// Advance the index by two and keep cloning until we reach
// the end of the segment path.
index + 2
)
newTreeChildren[parallelRouteKey] = result.tree
newSeedDataChildren[parallelRouteKey] = result.data
} else {
// This child is not being patched. Copy it over as-is.
newChildren[parallelRouteKey] = baseChildren[parallelRouteKey]
newTreeChildren[parallelRouteKey] = childBaseRouterState
newSeedDataChildren[parallelRouteKey] = childBaseSeedData
}
}
if (canMutateInPlace) {
// We can mutate the base tree in place, because the base tree is already
// a clone.
baseRouterState[1] = newChildren
return baseRouterState
}
let clonedTree: FlightRouterState
let clonedSeedData: CacheNodeSeedData
// Clone all the fields except the children.
//
// 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.
const clone: FlightRouterState = [baseRouterState[0], newChildren]
// Clone the FlightRouterState tree. 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.
clonedTree = [baseRouterState[0], newTreeChildren]
if (2 in baseRouterState) {
clone[2] = baseRouterState[2]
clonedTree[2] = baseRouterState[2]
}
if (3 in baseRouterState) {
clone[3] = baseRouterState[3]
clonedTree[3] = baseRouterState[3]
}
if (4 in baseRouterState) {
clone[4] = baseRouterState[4]
clonedTree[4] = baseRouterState[4]
}
// Clone the CacheNodeSeedData tree.
const isEmptySeedDataPartial = true
clonedSeedData = [
null,
newSeedDataChildren,
null,
isEmptySeedDataPartial,
false,
]
return {
tree: clonedTree,
data: clonedSeedData,
}
return clone
}
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,30 @@
import { connection } from 'next/server'
import { Suspense } from 'react'
export async function generateStaticParams() {
return [{ param: 'a' }, { param: 'b' }]
}
async function DynamicContent({ children }: { children: React.ReactNode }) {
await connection()
return children
}
export default async function Page({
params,
}: PageProps<'/mismatching-prefetch/dynamic-page/[param]'>) {
const { param } = await params
return (
<Suspense
fallback={
<div id={`dynamic-page-loading-${param}`}>{`Loading ${param}...`}</div>
}
>
<DynamicContent>
<div
id={`dynamic-page-content-${param}`}
>{`Dynamic page ${param}`}</div>
</DynamicContent>
</Suspense>
)
}
@@ -0,0 +1,26 @@
import { LinkAccordion } from '../../components/link-accordion'
export default function Page() {
return (
<div>
<p>
Tests what happens if a navigation resolves to a different route than
the one that was prefetched.
</p>
<div>
<ul>
<li>
<LinkAccordion href="/mismatching-prefetch/dynamic-page/a?mismatch-redirect=./b">
<code>{`/mismatching-prefetch/dynamic-page/a ──[ redirects to ]──→ /mismatching-prefetch/dynamic-page/b`}</code>
</LinkAccordion>
</li>
<li>
<LinkAccordion href="/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b">
<code>{`/mismatching-prefetch/dynamic-page/a ──[ rewrites to ]──→ /mismatching-prefetch/dynamic-page/b`}</code>
</LinkAccordion>
</li>
</ul>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
'use client'
import Link, { type LinkProps } from 'next/link'
import { useState } from 'react'
export function LinkAccordion({
href,
children,
prefetch,
}: {
href: string
children?: React.ReactNode
prefetch?: LinkProps['prefetch']
}) {
const [isVisible, setIsVisible] = useState(false)
const resolvedChildren = children ?? href
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{resolvedChildren}
</Link>
) : (
<>{resolvedChildren} (link is hidden)</>
)}
</>
)
}
@@ -0,0 +1,82 @@
import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
describe('mismatching prefetch', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
test('disabled in development', () => {})
return
}
function relativeHref(href: string) {
const url = new URL(href)
return url.pathname + url.search + url.hash
}
it(
'recovers when a navigation rewrites to a different route than the one ' +
'that was prefetched',
async () => {
let page: Playwright.Page
const browser = await next.browser('/mismatching-prefetch', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
// Reveal the link to trigger a prefetch of page A.
const toggle = await browser.elementByCss(
'input[data-link-accordion="/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b"]'
)
await act(async () => await toggle.click(), {
includes: 'Loading a...',
})
// When we click the link to navigate, the navigation will rewrite to
// a different route than the one that was prefetched.
await act(
async () => {
const link = await browser.elementByCss(
'a[href="/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b"]'
)
await link.click()
// Immeidately after the click, the app navigates to the loading state
// that was prefetched, which is for page A.
const pageALoading = await browser.elementById(
'dynamic-page-loading-a'
)
expect(relativeHref(await browser.url())).toBe(
'/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b'
)
expect(await pageALoading.text()).toBe('Loading a...')
// Simultaneously, the dynamic content for page A is requested.
},
// When the dynamic request is received, Next.js will discover that the
// route has changed and rewrite to page B.
[
{ includes: 'Dynamic page b' },
// It's expected that the dynamic page for B is requested twice:
// once due to the mismatching prefetch, and again during the
// retry, because a retry caused by a mismatch implicitly
// performs a soft refresh of all the dynamic data on the page.
{ includes: 'Dynamic page b' },
]
)
// The redirected page loads successfully.
const pageBContent = await browser.elementById('dynamic-page-content-b')
expect(await pageBContent.text()).toBe('Dynamic page b')
// The browser's URL hasn't changed, because this was a rewrite, not
// a redirect.
expect(relativeHref(await browser.url())).toBe(
'/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b'
)
}
)
})
@@ -0,0 +1,9 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
cacheComponents: true,
productionBrowserSourceMaps: true,
}
module.exports = nextConfig
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
// Simulates what might happen if a proxy or firewall modifies the
// request based on a condition that changes after the prefetch but before
// the actual navigation.
//
// The proxy modifies the request based on special search parameters, but only
// during a navigation — not during a prefetch.
export const config = {
matcher: [
{
source: '/:path*',
// Exclude prefetch requests
missing: [{ type: 'header', key: 'Next-Router-Prefetch' }],
},
],
}
export default function proxy(req: NextRequest) {
const mismatchRedirect = req.nextUrl.searchParams.get('mismatch-redirect')
if (mismatchRedirect) {
// Redirect to the given URL.
return NextResponse.redirect(new URL(mismatchRedirect, req.url))
}
const mismatchRewrite = req.nextUrl.searchParams.get('mismatch-rewrite')
if (mismatchRewrite) {
// Rewrite to the given URL.
return NextResponse.rewrite(new URL(mismatchRewrite, req.url))
}
}
@@ -16,6 +16,8 @@ describe('parallel-routes-and-interception-from-root', () => {
expect(next.cliOutput).toInclude('RootLayout rendered, locale: en')
}
// Referenced by commented out assertion below, see TODO message
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const cliOutputLength = next.cliOutput.length
await browser.elementByCss('a').click()
@@ -31,9 +33,25 @@ describe('parallel-routes-and-interception-from-root', () => {
// ...and that the root layout was not rerendered.
if (!isNextDeploy) {
expect(next.cliOutput.slice(cliOutputLength)).not.toInclude(
'RootLayout rendered, locale: en'
)
// FIXME: This assertion is temporarily disabled. Clicking the link should
// not re-render the root layout. This is happening because the response
// includes extra search params in the page segment that shouldn't be
// there: "__PAGE__?{\"locale":\"en\"}" instead of "__PAGE__". On the
// surface, it looks like the route params are accidentally being treated
// as search params.
//
// This assertion used to pass despite the mismatch, because client was
// more permissive about validating the tree when receiving a dynamic
// response from the server. But now we intentionally compare all the
// segments, including the search params.
//
// Regardless, we need to fix whatever's causing the params to be treated
// as search params.
//
// Correct behavior:
// expect(next.cliOutput.slice(cliOutputLength)).not.toInclude(
// 'RootLayout rendered, locale: en'
// )
}
})
})
@@ -25,14 +25,14 @@ describe('segment cache (metadata)', () => {
)
await checkbox.click()
}, [
{
includes: 'Target page',
},
// Because the link is prefetched with prefetch={true},
// we should be able to prefetch the title, even though it's dynamic.
{
includes: 'Dynamic Title',
},
{
includes: 'Target page',
},
])
// Now prefetch a link that rewrites to the same underlying page.
@@ -42,16 +42,16 @@ describe('segment cache (metadata)', () => {
)
await checkbox.click()
}, [
{
includes: 'Target page',
block: 'reject',
},
// It should not prefetch the page title or content again, because it
// was already cached.
{
includes: 'Dynamic Title',
block: 'reject',
},
{
includes: 'Target page',
block: 'reject',
},
])
// When we navigate to the page, it should not make any additional
@@ -85,14 +85,14 @@ describe('segment cache (metadata)', () => {
)
await checkbox.click()
}, [
{
includes: 'Target page',
},
// Because the link is prefetched with prefetch={true},
// we should be able to prefetch the title, even though it's dynamic.
{
includes: 'Runtime-prefetchable title',
},
{
includes: 'Target page',
},
])
// Now runtime-prefetch a link that rewrites to the same underlying page.
@@ -102,16 +102,16 @@ describe('segment cache (metadata)', () => {
)
await checkbox.click()
}, [
{
includes: 'Target page',
block: 'reject',
},
// It should not prefetch the page title or content again, because it
// was already cached.
{
includes: 'Runtime-prefetchable title',
block: 'reject',
},
{
includes: 'Target page',
block: 'reject',
},
])
// When we navigate to the page, it should not make any additional
+64 -35
View File
@@ -24,7 +24,6 @@ let currentBatch: Batch | null = null
type ExpectedResponseConfig = {
includes: string
block?: boolean | 'reject'
allowMultipleResponses?: boolean
}
/**
@@ -330,7 +329,8 @@ export function createRouterAct(
// keep checking for more requests until the queue has settled.
const remaining = new Set<PendingRSCRequest>()
let actualResponses: Array<ExpectedResponseConfig> = []
let alreadyMatched = new Map<string, string>()
let claimedExpectations = new Set<ExpectedResponseConfig>()
// Track when the queue was last empty to implement a settling period
let queueEmptyStartTime: number | null = null
@@ -416,47 +416,73 @@ ${fulfilled.body}
}
}
if (expectedResponses !== null) {
// Check if this response matches any of the expectations.
//
//
// The same response may match multiple expectations, but within
// that response the expected strings must appear in order. So
// once something matches, keep track of the remaining
// response body.
const entireResponseBody = fulfilled.body
let remainingUnclaimedBody = entireResponseBody
// If the response doesn't match any of the expectations, that's
// fine. If it does match an expectation, but the only thing
// it matches is an expectation that was already claimed, then
// that's an error — each occurence of an expectation must be
// given separately.
let responseWasClaimed = false
let firstAlreadyClaimedMatch: ExpectedResponseConfig | null = null
for (const expectedResponse of expectedResponses) {
const includes = expectedResponse.includes
const block = expectedResponse.block
if (fulfilled.body.includes(includes)) {
// Match. Don't check yet whether the responses are received
// in the expected order. Instead collect all the matches and
// check at the end so we can include a diff in the
// error message.
const otherResponse = alreadyMatched.get(includes)
if (otherResponse !== undefined) {
if (!expectedResponse.allowMultipleResponses) {
error.message = `
Received multiple responses containing the same expected substring.
if (!claimedExpectations.has(expectedResponse)) {
// This expectation was not already claimed. Check if we
// can claim it.
if (remainingUnclaimedBody.includes(includes)) {
// Match.
responseWasClaimed = true
// Remove everything up to and including the first
// occurrence of the matched substring.
remainingUnclaimedBody = remainingUnclaimedBody.slice(
remainingUnclaimedBody.indexOf(includes) + includes.length
)
claimedExpectations.add(expectedResponse)
actualResponses.push(expectedResponse)
if (block) {
shouldBlock = true
}
continue
}
}
Expected substring:
${includes}
// This expectation was already claimed, but let's check if the
// same string occurs later, too. If it does, it implies that
// the server sent the same string multiple times. This is fine
// as long as there's a separate expectation for
// each occurrence.
if (
firstAlreadyClaimedMatch === null &&
remainingUnclaimedBody.includes(includes)
) {
firstAlreadyClaimedMatch = expectedResponse
}
}
Responses:
if (!responseWasClaimed && firstAlreadyClaimedMatch !== null) {
// This response did not match any of the _unclaimed_
// expecations, but it did match something that had already
// been claimed by an earlier response. This is an error —
// if the same expectation matches multiple times, you must
// list out a separate expectation for each occurrence.
error.message = `
The same expected substring was sent multiple times by the server:
${otherResponse}
${fulfilled.body}
${firstAlreadyClaimedMatch.includes}
Choose a more specific substring to assert on.
`
throw error
}
} else {
alreadyMatched.set(includes, fulfilled.body)
if (actualResponses === null) {
actualResponses = [expectedResponse]
} else {
actualResponses.push(expectedResponse)
}
}
if (block) {
shouldBlock = true
}
// Keep checking all the expected responses to verify there
// are no duplicate matches
}
throw error
}
}
}
@@ -586,7 +612,10 @@ ${fulfilled.body}
error.message =
'Expected sequence of responses does not match:\n\n' +
diff(expectedSubstrings, actualSubstrings) +
'\n'
'\n\n' +
'NOTE: Assertions are checked in order, so if an expectation ' +
'is missing, it may have actually appeared earlier in the ' +
'sequence than expected. Make sure the order is correct.'
}
throw error
}