From 43c4e6a997645f38023e90eb6540f9c520626e82 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 4 Dec 2025 17:33:14 -0500 Subject: [PATCH 1/3] Refactor: Navigate and refresh simultaneously This is a refactor of the ppr-navigations module to support navigating to a new tree and refreshing shared layouts within the same navigation. Rather than modeling navigations and refreshes/revalidations as separate operations, a refresh is considered a special case of a navigation where any existing dynamic data is disregarded, and new data is fetched from the server. The rest of the tree is diffed as usual, and the routing behavior is the same. Previously, the ppr-navigations module would revert to the "create" path whenever it was missing dynamic data (the CacheNode) for a shared segment. Now, the FlightRouterState alone is used to diff the old and new trees. When dynamic data is missing, any shared segment (one that exists in both the old and new trees) is considered to be part of a "refresh", and any segment that is present only in the new tree is considered to be part of a "navigation". There are subtle distinctions in behavior between refreshes and navigations. For example, refreshed page segments aren't scrolled to, but navigated page segments are. --- .../router-reducer/ppr-navigations.ts | 1304 ++++++++--------- .../reducers/navigate-reducer.ts | 37 +- .../reducers/refresh-reducer.ts | 9 + .../components/segment-cache/navigation.ts | 129 +- packages/next/src/shared/lib/segment.ts | 1 + 5 files changed, 659 insertions(+), 821 deletions(-) diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index fd8f18db21ebf..6530dbac39996 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -13,9 +13,11 @@ import type { HeadData, LoadingModuleData, } from '../../../shared/lib/app-router-types' -import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment' +import { + DEFAULT_SEGMENT_KEY, + NOT_FOUND_SEGMENT_KEY, +} from '../../../shared/lib/segment' import { matchSegment } from '../match-segments' -import { revalidateEntireCache } from '../segment-cache/cache' import { createHrefFromUrl } from './create-href-from-url' import { createRouterCacheKey } from './create-router-cache-key' import { @@ -31,46 +33,23 @@ import { DYNAMIC_STALETIME_MS } from './reducers/navigate-reducer' // 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. -type SPANavigationTask = { +export type NavigationTask = { // 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. If - // `children` is null (i.e. if this is a terminal task node), then `node` - // represents a brand new Cache Node tree, which way or may not need to be - // filled with dynamic data from the server. - node: CacheNode | null - // The tree sent to the server during the dynamic request. This is the - // same as `route`, except with the `refetch` marker set on dynamic segments. - // If all the segments are static, then this will be null, and no server - // request is required. + // 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. refreshUrl: string | null - children: Map | null -} - -// A special type used to bail out and trigger a full-page navigation. -type MPANavigationTask = { - // MPA tasks are distinguised from SPA tasks by having a null `route`. - route: null - node: null - dynamicRequestTree: null - refreshUrl: null - children: null + children: Map | null } -const MPA_NAVIGATION_TASK: MPANavigationTask = { - route: null, - node: null, - dynamicRequestTree: null, - refreshUrl: null, - children: null, -} - -export type Task = SPANavigationTask | MPANavigationTask - export type NavigationRequestAccumulation = { scrollableSegments: Array separateRefreshUrls: Set | null @@ -108,69 +87,35 @@ export type NavigationRequestAccumulation = { export function startPPRNavigation( navigatedAt: number, oldUrl: URL, - oldCacheNode: CacheNode, + oldCacheNode: CacheNode | null, oldRouterState: FlightRouterState, newRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, prefetchData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, isSamePageNavigation: boolean, accumulation: NavigationRequestAccumulation -): Task | null { - const segmentPath: Array = [] +): NavigationTask | null { + const didFindRootLayout = false + const parentNeedsDynamicRequest = false + const parentRefreshUrl = null return updateCacheNodeOnNavigation( navigatedAt, oldUrl, - oldCacheNode, + oldCacheNode !== null ? oldCacheNode : undefined, oldRouterState, newRouterState, - false, + shouldRefreshDynamicData, + didFindRootLayout, prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, - segmentPath, - accumulation - ) -} - -export function startPPRRefresh( - navigatedAt: number, - currentRouterState: FlightRouterState, - currentNextUrl: string | null, - accumulation: NavigationRequestAccumulation -): Task | null { - // A refresh is a special case of a navigation where all the dynamic data in - // the page is re-fetched. There is no "shared layout" to consider because - // the route hasn't changed. - - // 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. - revalidateEntireCache(currentNextUrl, currentRouterState) - - // TODO: Currently refreshes do not read from the prefetch cache, as in the - // pre-Segment Cache implementation. This will be added in a subsequent PR. - const prefetchData = null - const prefetchHead = null - const isPrefetchHeadPartial = true - - const isRefresh = true - const refreshUrl = null - // During a refresh, we intentionally don't pass in the previous - // CacheNode tree. - const existingCacheNode = undefined - const segmentPath: FlightSegmentPath = [] - return createCacheNodeOnNavigation( - isRefresh, - refreshUrl, - navigatedAt, - currentRouterState, - existingCacheNode, - prefetchData, - prefetchHead, - isPrefetchHeadPartial, - segmentPath, + null, + null, + parentNeedsDynamicRequest, + parentRefreshUrl, accumulation ) } @@ -178,34 +123,111 @@ export function startPPRRefresh( function updateCacheNodeOnNavigation( navigatedAt: number, oldUrl: URL, - oldCacheNode: CacheNode, + oldCacheNode: CacheNode | void, oldRouterState: FlightRouterState, newRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, didFindRootLayout: boolean, prefetchData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, isSamePageNavigation: boolean, - segmentPath: FlightSegmentPath, + parentSegmentPath: FlightSegmentPath | null, + parentParallelRouteKey: string | null, + parentNeedsDynamicRequest: boolean, + parentRefreshUrl: string | null, accumulation: NavigationRequestAccumulation -): Task | null { - // Diff the old and new trees to reuse the shared layouts. - const oldRouterStateChildren = oldRouterState[1] +): NavigationTask | null { + // Check if this segment matches the one in the previous route. + const oldSegment = oldRouterState[0] + const newSegment = newRouterState[0] + 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, newRouterState)) || + // 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 + } + if (parentSegmentPath === null || parentParallelRouteKey === null) { + // The root should never mismatch. If it does, it suggests an internal + // Next.js error, or a malformed server response. Trigger a full- + // page navigation. + return null + } + return createCacheNodeOnNavigation( + navigatedAt, + newRouterState, + oldCacheNode, + shouldRefreshDynamicData, + seedData, + seedHead, + prefetchData, + prefetchHead, + isPrefetchHeadPartial, + parentSegmentPath, + parentParallelRouteKey, + parentNeedsDynamicRequest, + accumulation + ) + } + + // TODO: The segment paths are tracked so that LayoutRouter knows which + // segments to scroll to after a navigation. But we should just mark this + // information on the CacheNode directly. It used to be necessary to do this + // separately because CacheNodes were created lazily during render, not when + // rather than when creating the route tree. + const segmentPath = + parentParallelRouteKey !== null && parentSegmentPath !== null + ? parentSegmentPath.concat([parentParallelRouteKey, newSegment]) + : // NOTE: The root segment is intentionally omitted from the segment path + [] + const newRouterStateChildren = newRouterState[1] + const oldRouterStateChildren = oldRouterState[1] const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null - if (!didFindRootLayout) { - // 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. See beginRenderingNewRouteTree for context. - const isRootLayout = newRouterState[4] === true - if (isRootLayout) { - // Found a matching root layout. - didFindRootLayout = true - } - } + // 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 isRootLayout = newRouterState[4] === true + const childDidFindRootLayout = didFindRootLayout || isRootLayout - const oldParallelRoutes = oldCacheNode.parallelRoutes + const oldParallelRoutes = + oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined // Clone the current set of segment children, even if they aren't active in // the new tree. @@ -218,7 +240,79 @@ function updateCacheNodeOnNavigation( // leak. We should figure out a better model for the lifetime of inactive // segments, so we can maintain instant back/forward navigations without // leaking memory indefinitely. - const prefetchParallelRoutes = new Map(oldParallelRoutes) + const newParallelRoutes = new Map( + shouldRefreshDynamicData ? undefined : oldParallelRoutes + ) + + // 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 = Object.keys(newRouterStateChildren).length === 0 + + // 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: ReadyCacheNode + let needsDynamicRequest: boolean + if ( + oldCacheNode !== undefined && + !shouldRefreshDynamicData && + // During a same-page navigation, we always refetch the page segments + !(isLeafSegment && isSamePageNavigation) + ) { + // Reuse the existing CacheNode + newCacheNode = reuseDynamicCacheNode(oldCacheNode, newParallelRoutes) + needsDynamicRequest = false + } else if (prefetchData !== null) { + // Consult the prefetch cache. + const prefetchRsc = prefetchData[0] + const prefetchLoading = prefetchData[2] + const isPrefetchRSCPartial = prefetchData[3] + newCacheNode = readCacheNodeFromSeedData( + prefetchRsc, + prefetchLoading, + isPrefetchRSCPartial, + prefetchHead, + isPrefetchHeadPartial, + isLeafSegment, + newParallelRoutes, + navigatedAt + ) + needsDynamicRequest = + isPrefetchRSCPartial || (isLeafSegment && isPrefetchHeadPartial) + } else { + // Spawn a request to fetch new data from the server. + newCacheNode = spawnNewCacheNode( + newParallelRoutes, + isLeafSegment, + navigatedAt + ) + needsDynamicRequest = true + } + + // 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 href = newRouterState[2] + const refreshUrl = + typeof href === 'string' && newRouterState[3] === 'refresh' + ? // This segment is not present in the current route. Track its + // refresh URL as we continue traversing the tree. + href + : // Inherit the refresh URL from the parent. + parentRefreshUrl + + // 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 && refreshUrl !== null) { + accumulateRefreshUrl(accumulation, refreshUrl) + } // 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 @@ -239,11 +333,11 @@ function updateCacheNodeOnNavigation( // // This starts off as `false`, and is set to `true` if any of the child // routes requires a dynamic request. - let needsDynamicRequest = false + 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. needsDynamicRequest is false at the - // end), then this will be discarded. + // 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. @@ -252,353 +346,184 @@ function updateCacheNodeOnNavigation( } = {} for (let parallelRouteKey in newRouterStateChildren) { - const newRouterStateChild: FlightRouterState = + let newRouterStateChild: FlightRouterState = newRouterStateChildren[parallelRouteKey] const oldRouterStateChild: FlightRouterState | void = oldRouterStateChildren[parallelRouteKey] - const oldSegmentMapChild = oldParallelRoutes.get(parallelRouteKey) - const prefetchDataChild: CacheNodeSeedData | void | null = + if (oldRouterStateChild === undefined) { + // This should never happen, but if it does, it suggests a malformed + // server response. Trigger a full-page navigation. + return null + } + const oldSegmentMapChild = + oldParallelRoutes !== undefined + ? oldParallelRoutes.get(parallelRouteKey) + : undefined + let prefetchDataChild: CacheNodeSeedData | void | null = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] : null - const newSegmentChild = newRouterStateChild[0] - const newSegmentPathChild = segmentPath.concat([ - parallelRouteKey, - newSegmentChild, - ]) - const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + let newSegmentChild = newRouterStateChild[0] + let prefetchHeadChild = prefetchHead + let isPrefetchHeadPartialChild = isPrefetchHeadPartial + if (newSegmentChild === 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. + newRouterStateChild = reuseActiveSegmentInDefaultSlot( + oldUrl, + oldRouterStateChild + ) + newSegmentChild = newRouterStateChild[0] - const oldSegmentChild = - oldRouterStateChild !== undefined ? oldRouterStateChild[0] : undefined + // Since we're switching to a different route tree, these are no + // longer valid, because they correspond to the outer tree. + prefetchDataChild = null + prefetchHeadChild = null + isPrefetchHeadPartialChild = false + } + const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) const oldCacheNodeChild = oldSegmentMapChild !== undefined ? oldSegmentMapChild.get(newSegmentKeyChild) : undefined - let taskChild: Task | null - if (newSegmentChild === DEFAULT_SEGMENT_KEY) { - // This is another kind of leaf segment — a default route. - // - // Default routes have special behavior. When there's no matching segment - // for a parallel route, Next.js preserves the currently active segment - // during a client navigation — but not for initial render. The server - // leaves it to the client to account for this. So we need to handle - // it here. - if (oldRouterStateChild !== undefined) { - // Reuse the existing Router State for this segment. We spawn a "task" - // just to keep track of the updated router state; unlike most, it's - // already fulfilled and won't be affected by the dynamic response. - taskChild = reuseActiveSegmentInDefaultSlot(oldUrl, oldRouterStateChild) - } else { - // There's no currently active segment. Switch to the "create" path. - taskChild = beginRenderingNewRouteTree( - navigatedAt, - oldRouterStateChild, - newRouterStateChild, - oldCacheNodeChild, - didFindRootLayout, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead, - isPrefetchHeadPartial, - newSegmentPathChild, - accumulation - ) - } - } else if ( - isSamePageNavigation && - // Check if this is a page segment. - // 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. - Object.keys(newRouterStateChild[1]).length === 0 - ) { - // We special case navigations to the exact same URL as the current - // location. It's a common UI pattern for apps to refresh when you click a - // link to the current page. So when this happens, we refresh the dynamic - // data in the page segments. - // - // Note that this does not apply if the any part of the hash or search - // query has changed. This might feel a bit weird but it makes more sense - // when you consider that the way to trigger this behavior is to click - // the same link multiple times. - // - // TODO: We should probably refresh the *entire* route when this case - // occurs, not just the page segments. Essentially treating it the same as - // a refresh() triggered by an action, which is the more explicit way of - // modeling the UI pattern described above. - // - // Also note that this only refreshes the dynamic data, not static/ - // cached data. If the page segment is fully static and prefetched, the - // request is skipped. (This is also how refresh() works.) - taskChild = beginRenderingNewRouteTree( - navigatedAt, - oldRouterStateChild, - newRouterStateChild, - oldCacheNodeChild, - didFindRootLayout, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead, - isPrefetchHeadPartial, - newSegmentPathChild, - accumulation - ) - } else if ( - oldRouterStateChild !== undefined && - oldSegmentChild !== undefined && - matchSegment(newSegmentChild, oldSegmentChild) - ) { - if ( - oldCacheNodeChild !== undefined && - oldRouterStateChild !== undefined - ) { - // This segment exists in both the old and new trees. Recursively update - // the children. - taskChild = updateCacheNodeOnNavigation( - navigatedAt, - oldUrl, - oldCacheNodeChild, - oldRouterStateChild, - newRouterStateChild, - didFindRootLayout, - prefetchDataChild, - prefetchHead, - isPrefetchHeadPartial, - isSamePageNavigation, - newSegmentPathChild, - accumulation - ) - } else { - // There's no existing Cache Node for this segment. Switch to the - // "create" path. - taskChild = beginRenderingNewRouteTree( - navigatedAt, - oldRouterStateChild, - newRouterStateChild, - oldCacheNodeChild, - didFindRootLayout, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead, - isPrefetchHeadPartial, - newSegmentPathChild, - accumulation - ) - } - } else { - // This is a new tree. Switch to the "create" path. - taskChild = beginRenderingNewRouteTree( - navigatedAt, - oldRouterStateChild, - newRouterStateChild, - oldCacheNodeChild, - didFindRootLayout, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead, - isPrefetchHeadPartial, - newSegmentPathChild, - accumulation - ) - } - - if (taskChild !== null) { - // Recursively propagate up the child tasks. + const taskChild = updateCacheNodeOnNavigation( + navigatedAt, + oldUrl, + oldCacheNodeChild, + oldRouterStateChild, + newRouterStateChild, + shouldRefreshDynamicData, + childDidFindRootLayout, + prefetchDataChild ?? null, + prefetchHeadChild, + isPrefetchHeadPartialChild, + isSamePageNavigation, + segmentPath, + parallelRouteKey, + parentNeedsDynamicRequest || needsDynamicRequest, + refreshUrl, + accumulation + ) - if (taskChild.route === null) { - // One of the child tasks discovered a change to the root layout. - // Immediately unwind from this recursive traversal. - return MPA_NAVIGATION_TASK - } + 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 + } - if (taskChildren === null) { - taskChildren = new Map() - } - taskChildren.set(parallelRouteKey, taskChild) - const newCacheNodeChild = taskChild.node - if (newCacheNodeChild !== null) { - const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild) - newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) - prefetchParallelRoutes.set(parallelRouteKey, newSegmentMapChild) - } + // Recursively propagate up the child tasks. + if (taskChildren === null) { + taskChildren = new Map() + } + taskChildren.set(parallelRouteKey, taskChild) + const newCacheNodeChild = taskChild.node + if (newCacheNodeChild !== null) { + const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild) + newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) + newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) + } - // 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. - needsDynamicRequest = true - dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild - } else { - dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute - } + // 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 { - // The child didn't change. We can use the prefetched router state. - patchedRouterStateChildren[parallelRouteKey] = newRouterStateChild - dynamicRequestTreeChildren[parallelRouteKey] = newRouterStateChild + dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute } } - if (taskChildren === null) { - // No new tasks were spawned. - return null - } - - const newCacheNode: ReadyCacheNode = { - lazyData: null, - rsc: oldCacheNode.rsc, - // We intentionally aren't updating the prefetchRsc field, since this node - // is already part of the current tree, because it would be weird for - // prefetch data to be newer than the final data. It probably won't ever be - // observable anyway, but it could happen if the segment is unmounted then - // mounted again, because LayoutRouter will momentarily switch to rendering - // prefetchRsc, via useDeferredValue. - prefetchRsc: oldCacheNode.prefetchRsc, - head: oldCacheNode.head, - prefetchHead: oldCacheNode.prefetchHead, - loading: oldCacheNode.loading, - - // Everything is cloned except for the children, which we computed above. - parallelRoutes: prefetchParallelRoutes, - - navigatedAt, - } - return { - // Return a cloned copy of the router state with updated children. route: patchRouterStateWithNewChildren( newRouterState, patchedRouterStateChildren ), node: newCacheNode, - dynamicRequestTree: needsDynamicRequest - ? patchRouterStateWithNewChildren( - newRouterState, - dynamicRequestTreeChildren - ) - : null, - // This function is never called during a refresh, only a regular - // navigation, so we can always set this to null. - refreshUrl: null, - children: taskChildren, + dynamicRequestTree: createDynamicRequestTree( + newRouterState, + dynamicRequestTreeChildren, + needsDynamicRequest, + childNeedsDynamicRequest, + 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, } } -function beginRenderingNewRouteTree( +function createCacheNodeOnNavigation( navigatedAt: number, - oldRouterState: FlightRouterState | void, newRouterState: FlightRouterState, - existingCacheNode: CacheNode | void, - didFindRootLayout: boolean, + oldCacheNode: CacheNode | void, + shouldRefreshDynamicData: boolean, prefetchData: CacheNodeSeedData | null, - possiblyPartialPrefetchHead: HeadData | null, + prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, - segmentPath: FlightSegmentPath, + parentSegmentPath: FlightSegmentPath, + parentParallelRouteKey: string, + parentNeedsDynamicRequest: boolean, accumulation: NavigationRequestAccumulation -): Task { - if (!didFindRootLayout) { - // 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. - // In the common case, this branch is skipped completely. - if ( - oldRouterState === undefined || - isNavigatingToNewRootLayout(oldRouterState, newRouterState) - ) { - // The root layout changed. Perform a full-page navigation. - return MPA_NAVIGATION_TASK - } - } - const isRefresh = false - const refreshUrl = null - return createCacheNodeOnNavigation( - isRefresh, - refreshUrl, - navigatedAt, - newRouterState, - existingCacheNode, - prefetchData, - possiblyPartialPrefetchHead, - isPrefetchHeadPartial, - segmentPath, - accumulation - ) -} +): 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. -function createCacheNodeOnNavigation( - isRefresh: boolean, - parentRefreshUrl: string | null, - navigatedAt: number, - routerState: FlightRouterState, - existingCacheNode: CacheNode | void, - prefetchData: CacheNodeSeedData | null, - possiblyPartialPrefetchHead: HeadData | null, - isPrefetchHeadPartial: boolean, - segmentPath: FlightSegmentPath, - accumulation: NavigationRequestAccumulation -): SPANavigationTask { - // Same traversal as updateCacheNodeNavigation, but 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. + const newSegment = newRouterState[0] + const segmentPath = parentSegmentPath.concat([ + parentParallelRouteKey, + newSegment, + ]) - // The head is assigned to every leaf segment delivered by the server. Based - // on corresponding logic in fill-lazy-items-till-leaf-with-head.ts - const routerStateChildren = routerState[1] - const isLeafSegment = Object.keys(routerStateChildren).length === 0 - - let refreshUrl: string | null - if (isRefresh) { - // 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. See - // spawnPendingTask for more details. - const href = routerState[2] - refreshUrl = - typeof href === 'string' && routerState[3] === 'refresh' - ? // This segment is not present in the current route. Track its - // refresh URL as we continue traversing the tree. - href - : // Inherit the refresh URL from the parent. - parentRefreshUrl - } else { - // This is not a refresh, so there's no need to track the refresh URL as - // we traverse the tree. - refreshUrl = null + const newRouterStateChildren = newRouterState[1] + const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null + const oldParallelRoutes = + oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined + const newParallelRoutes = new Map( + shouldRefreshDynamicData ? undefined : oldParallelRoutes + ) + const isLeafSegment = Object.keys(newRouterStateChildren).length === 0 + + if (isLeafSegment) { + // The segment path of every leaf segment (i.e. page) is collected into + // a result array. This is used by the LayoutRouter to scroll to ensure that + // new pages are visible after a navigation. + // + // This only happens for new pages, not for refreshed pages. + // + // TODO: We should use a string to represent the segment path instead of + // an array. We already use a string representation for the path when + // accessing the Segment Cache, so we can use the same one. + accumulation.scrollableSegments.push(segmentPath) } - // Even we're rendering inside the "new" part of the target tree, we may have - // a locally cached segment that we can reuse. This may come from either 1) - // the CacheNode tree, which lives in React state and is populated by previous - // navigations; or 2) the prefetch cache, which is a separate cache that is - // populated by prefetches. - let rsc: React.ReactNode - let loading: LoadingModuleData | Promise - let head: HeadData | null - let cacheNodeNavigatedAt: number + let newCacheNode: ReadyCacheNode + let needsDynamicRequest: boolean if ( - existingCacheNode !== undefined && + !shouldRefreshDynamicData && + oldCacheNode !== undefined && // DYNAMIC_STALETIME_MS defaults to 0, but it can be increased using // the experimental.staleTimes.dynamic config. When set, we'll avoid // refetching dynamic data if it was fetched within the given threshold. @@ -606,166 +531,123 @@ function createCacheNodeOnNavigation( // the `updateCacheNodeOnPopstateRestoration` function. That way we can // handle the case where the data is missing here, like we would for a // normal navigation, rather than rely on the lazy fetch in LazyRouter. - existingCacheNode.navigatedAt + DYNAMIC_STALETIME_MS > navigatedAt + oldCacheNode.navigatedAt + DYNAMIC_STALETIME_MS > navigatedAt ) { - // We have an existing CacheNode for this segment, and it's not stale. We - // should reuse it rather than request a new one. - rsc = existingCacheNode.rsc - loading = existingCacheNode.loading - head = existingCacheNode.head - - // Don't update the navigatedAt timestamp, since we're reusing stale data. - cacheNodeNavigatedAt = existingCacheNode.navigatedAt + // Reuse the existing CacheNode + newCacheNode = reuseDynamicCacheNode(oldCacheNode, newParallelRoutes) + needsDynamicRequest = false } else if (prefetchData !== null) { - // There's no existing CacheNode for this segment, but we do have prefetch - // data. If the prefetch data is fully static (i.e. does not contain any - // dynamic holes), we don't need to request it from the server. - rsc = prefetchData[0] - loading = prefetchData[2] - head = isLeafSegment ? possiblyPartialPrefetchHead : null - // Even though we're accessing the data from the prefetch cache, this is - // conceptually a new segment, not a reused one. So we should update the - // navigatedAt timestamp. - cacheNodeNavigatedAt = navigatedAt - const isPrefetchRscPartial = prefetchData[3] - if ( - // Check if the segment data is partial - isPrefetchRscPartial || - // Check if the head is partial (only relevant if this is a leaf segment) - (isPrefetchHeadPartial && isLeafSegment) - ) { - // We only have partial data from this segment. Like missing segments, we - // must request the full data from the server. - return spawnPendingTask( - isRefresh, - refreshUrl, - navigatedAt, - routerState, - prefetchData, - possiblyPartialPrefetchHead, - isPrefetchHeadPartial, - segmentPath, - accumulation - ) - } else { - // The prefetch data is fully static, so we can omit it from the - // navigation request. - } - } else { - // There's no prefetch for this segment. Everything from this point will be - // requested from the server, even if there are static children below it. - // Create a terminal task node that will later be fulfilled by - // server response. - return spawnPendingTask( - isRefresh, - refreshUrl, - navigatedAt, - routerState, - null, - possiblyPartialPrefetchHead, + // Consult the prefetch cache. + const prefetchRsc = prefetchData[0] + const prefetchLoading = prefetchData[2] + const isPrefetchRSCPartial = prefetchData[3] + newCacheNode = readCacheNodeFromSeedData( + prefetchRsc, + prefetchLoading, + isPrefetchRSCPartial, + prefetchHead, isPrefetchHeadPartial, - segmentPath, - accumulation + isLeafSegment, + newParallelRoutes, + navigatedAt ) + needsDynamicRequest = + isPrefetchRSCPartial || (isLeafSegment && isPrefetchHeadPartial) + } else { + // Spawn a request to fetch new data from the server. + newCacheNode = spawnNewCacheNode( + newParallelRoutes, + isLeafSegment, + navigatedAt + ) + needsDynamicRequest = true } - // We already have a full segment we can render, so we don't need to request a - // new one from the server. Keep traversing down the tree until we reach - // something that requires a dynamic request. - const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null - const taskChildren = new Map() - const existingCacheNodeChildren = - existingCacheNode !== undefined ? existingCacheNode.parallelRoutes : null - const cacheNodeChildren = new Map(existingCacheNodeChildren) + let patchedRouterStateChildren: { + [parallelRouteKey: string]: FlightRouterState + } = {} + let taskChildren = null + + let childNeedsDynamicRequest = false let dynamicRequestTreeChildren: { [parallelRouteKey: string]: FlightRouterState } = {} - let needsDynamicRequest = false - if (isLeafSegment) { - // The segment path of every leaf segment (i.e. page) is collected into - // a result array. This is used by the LayoutRouter to scroll to ensure that - // new pages are visible after a navigation. - // TODO: We should use a string to represent the segment path instead of - // an array. We already use a string representation for the path when - // accessing the Segment Cache, so we can use the same one. - accumulation.scrollableSegments.push(segmentPath) - } else { - for (let parallelRouteKey in routerStateChildren) { - const routerStateChild: FlightRouterState = - routerStateChildren[parallelRouteKey] - const prefetchDataChild: CacheNodeSeedData | void | null = - prefetchDataChildren !== null - ? prefetchDataChildren[parallelRouteKey] - : null - const existingSegmentMapChild = - existingCacheNodeChildren !== null - ? existingCacheNodeChildren.get(parallelRouteKey) - : undefined - const segmentChild = routerStateChild[0] - const segmentPathChild = segmentPath.concat([ - parallelRouteKey, - segmentChild, - ]) - const segmentKeyChild = createRouterCacheKey(segmentChild) - - const existingCacheNodeChild = - existingSegmentMapChild !== undefined - ? existingSegmentMapChild.get(segmentKeyChild) - : undefined - - const taskChild = createCacheNodeOnNavigation( - isRefresh, - refreshUrl, - navigatedAt, - routerStateChild, - existingCacheNodeChild, - prefetchDataChild, - possiblyPartialPrefetchHead, - isPrefetchHeadPartial, - segmentPathChild, - accumulation - ) - taskChildren.set(parallelRouteKey, taskChild) - const dynamicRequestTreeChild = taskChild.dynamicRequestTree - if (dynamicRequestTreeChild !== null) { - // Something in the child tree is dynamic. - needsDynamicRequest = true - dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild - } else { - dynamicRequestTreeChildren[parallelRouteKey] = routerStateChild - } - const newCacheNodeChild = taskChild.node - if (newCacheNodeChild !== null) { - const newSegmentMapChild: ChildSegmentMap = new Map() - newSegmentMapChild.set(segmentKeyChild, newCacheNodeChild) - cacheNodeChildren.set(parallelRouteKey, newSegmentMapChild) - } + + for (let parallelRouteKey in newRouterStateChildren) { + const newRouterStateChild: FlightRouterState = + newRouterStateChildren[parallelRouteKey] + const oldSegmentMapChild = + oldParallelRoutes !== undefined + ? oldParallelRoutes.get(parallelRouteKey) + : undefined + const prefetchDataChild: CacheNodeSeedData | void | null = + prefetchDataChildren !== null + ? prefetchDataChildren[parallelRouteKey] + : null + + const newSegmentChild = newRouterStateChild[0] + const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + + const oldCacheNodeChild = + oldSegmentMapChild !== undefined + ? oldSegmentMapChild.get(newSegmentKeyChild) + : undefined + + const taskChild = createCacheNodeOnNavigation( + navigatedAt, + newRouterStateChild, + oldCacheNodeChild, + shouldRefreshDynamicData, + prefetchDataChild ?? null, + prefetchHead, + isPrefetchHeadPartial, + segmentPath, + parallelRouteKey, + parentNeedsDynamicRequest || needsDynamicRequest, + accumulation + ) + + if (taskChildren === null) { + taskChildren = new Map() + } + taskChildren.set(parallelRouteKey, taskChild) + const newCacheNodeChild = taskChild.node + if (newCacheNodeChild !== null) { + const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild) + newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) + newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) + } + + const taskChildRoute = taskChild.route + patchedRouterStateChildren[parallelRouteKey] = taskChildRoute + + const dynamicRequestTreeChild = taskChild.dynamicRequestTree + if (dynamicRequestTreeChild !== null) { + childNeedsDynamicRequest = true + dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild + } else { + dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute } } return { - // Since we're inside a new route tree, unlike the - // `updateCacheNodeOnNavigation` path, the router state on the children - // tasks is always the same as the router state we pass in. So we don't need - // to clone/modify it. - route: routerState, - node: { - lazyData: null, - // Since this segment is already full, we don't need to use the - // `prefetchRsc` field. - rsc, - prefetchRsc: null, - head, - prefetchHead: null, - loading, - parallelRoutes: cacheNodeChildren, - navigatedAt: cacheNodeNavigatedAt, - }, - dynamicRequestTree: needsDynamicRequest - ? patchRouterStateWithNewChildren(routerState, dynamicRequestTreeChildren) - : null, - refreshUrl, - children: taskChildren, + route: patchRouterStateWithNewChildren( + newRouterState, + patchedRouterStateChildren + ), + node: newCacheNode, + dynamicRequestTree: createDynamicRequestTree( + newRouterState, + dynamicRequestTreeChildren, + needsDynamicRequest, + childNeedsDynamicRequest, + parentNeedsDynamicRequest + ), + // 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, } } @@ -789,52 +671,40 @@ function patchRouterStateWithNewChildren( return clone } -function spawnPendingTask( - isRefresh: boolean, - refreshUrl: string | null, - navigatedAt: number, - routerState: FlightRouterState, - prefetchData: CacheNodeSeedData | null, - prefetchHead: HeadData | null, - isPrefetchHeadPartial: boolean, - segmentPath: FlightSegmentPath, - accumulation: NavigationRequestAccumulation -): SPANavigationTask { - // Create a task that will later be fulfilled by data from the server. - - // Clone the prefetched route tree and the `refetch` marker to it. We'll send - // this to the server so it knows where to start rendering. - const dynamicRequestTree = patchRouterStateWithNewChildren( - routerState, - routerState[1] - ) - dynamicRequestTree[3] = 'refetch' - - if (isRefresh && refreshUrl !== null) { - accumulateRefreshUrl(accumulation, refreshUrl) - } - - const newTask: Task = { - route: routerState, - - // Corresponds to the part of the route that will be rendered on the server. - node: createPendingCacheNode( - isRefresh, - navigatedAt, - routerState, - prefetchData, - prefetchHead, - isPrefetchHeadPartial, - segmentPath, - accumulation - ), - // Because this is non-null, and it gets propagated up through the parent - // tasks, the root task will know that it needs to perform a server request. - dynamicRequestTree, - refreshUrl, - children: null, +function createDynamicRequestTree( + newRouterState: FlightRouterState, + dynamicRequestTreeChildren: Record, + 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 newTask + return dynamicRequestTree } function accumulateRefreshUrl( @@ -862,7 +732,7 @@ function accumulateRefreshUrl( function reuseActiveSegmentInDefaultSlot( oldUrl: URL, oldRouterState: FlightRouterState -): Task { +): FlightRouterState { // 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 @@ -890,15 +760,103 @@ function reuseActiveSegmentInDefaultSlot( reusedRouterState[3] = 'refresh' } - return { - route: reusedRouterState, - node: null, - dynamicRequestTree: null, - // This function is never called during a refresh, only a regular - // navigation, so we can always set this to null. - refreshUrl: null, - children: null, + return reusedRouterState +} + +function reuseDynamicCacheNode( + existingCacheNode: CacheNode, + parallelRoutes: Map +): ReadyCacheNode { + // Clone an existing CacheNode's data, with (possibly) new children. + const cacheNode: ReadyCacheNode = { + lazyData: null, + rsc: existingCacheNode.rsc, + prefetchRsc: existingCacheNode.prefetchRsc, + head: existingCacheNode.head, + prefetchHead: existingCacheNode.prefetchHead, + loading: existingCacheNode.loading, + + parallelRoutes, + + // Don't update the navigatedAt timestamp, since we're reusing + // existing data. + navigatedAt: existingCacheNode.navigatedAt, } + return cacheNode +} + +function readCacheNodeFromSeedData( + prefetchRsc: React.ReactNode, + prefetchLoading: LoadingModuleData | Promise, + isPrefetchRSCPartial: boolean, + prefetchHead: HeadData | null, + isPrefetchHeadPartial: boolean, + isPageSegment: boolean, + parallelRoutes: Map, + navigatedAt: number +): ReadyCacheNode { + // TODO: Currently this is threaded through the navigation logic using the + // CacheNodeSeedData type, but in the future this will read directly from + // the Segment Cache. See readRenderSnapshotFromCache. + + let rsc: React.ReactNode + if (isPrefetchRSCPartial) { + // The prefetched data contains dynamic holes. Create a pending promise that + // will be fulfilled when the dynamic data is received from the server. + rsc = createDeferredRsc() + } else { + // The prefetched data is complete. Use it directly. + rsc = prefetchRsc + } + + // If this is a page segment, also read the head. + let resolvedPrefetchHead: HeadData | null + let resolvedHead: HeadData | null + if (isPageSegment) { + resolvedPrefetchHead = prefetchHead + if (isPrefetchHeadPartial) { + resolvedHead = createDeferredRsc() + } else { + resolvedHead = prefetchHead + } + } else { + resolvedPrefetchHead = null + resolvedHead = null + } + + const cacheNode: ReadyCacheNode = { + lazyData: null, + rsc, + prefetchRsc, + head: resolvedHead, + prefetchHead: resolvedPrefetchHead, + // TODO: Technically, a loading boundary could contain dynamic data. We + // should have separate `loading` and `prefetchLoading` fields to handle + // this, like we do for the segment data and head. + loading: prefetchLoading, + parallelRoutes, + navigatedAt, + } + + return cacheNode +} + +function spawnNewCacheNode( + parallelRoutes: Map, + isLeafSegment: boolean, + navigatedAt: number +): ReadyCacheNode { + const cacheNode: ReadyCacheNode = { + lazyData: null, + rsc: createDeferredRsc(), + prefetchRsc: null, + head: isLeafSegment ? createDeferredRsc() : null, + prefetchHead: null, + loading: createDeferredRsc(), + parallelRoutes, + navigatedAt, + } + return cacheNode } // Writes a dynamic server response into the tree created by @@ -919,8 +877,11 @@ function reuseActiveSegmentInDefaultSlot( export function listenForDynamicRequest( url: URL, nextUrl: string | null, - task: SPANavigationTask, + 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 | null, accumulation: NavigationRequestAccumulation ): void { @@ -1029,7 +990,7 @@ export function listenForDynamicRequest( } function attachServerResponseListener( - task: SPANavigationTask, + task: NavigationTask, requestPromise: Promise ): Promise { return requestPromise.then((result) => { @@ -1068,7 +1029,7 @@ function attachServerResponseListener( } function writeDynamicDataIntoPendingTask( - rootTask: SPANavigationTask, + rootTask: NavigationTask, segmentPath: FlightSegmentPath, serverRouterState: FlightRouterState, dynamicData: CacheNodeSeedData, @@ -1118,7 +1079,7 @@ function writeDynamicDataIntoPendingTask( } function finishTaskUsingDynamicDataPayload( - task: SPANavigationTask, + task: NavigationTask, serverRouterState: FlightRouterState, dynamicData: CacheNodeSeedData, dynamicHead: HeadData, @@ -1154,7 +1115,7 @@ function finishTaskUsingDynamicDataPayload( const serverChildren = serverRouterState[1] const dynamicDataChildren = dynamicData[1] - for (const parallelRouteKey in serverRouterState) { + for (const parallelRouteKey in serverChildren) { const serverRouterStateChild: FlightRouterState = serverChildren[parallelRouteKey] const dynamicDataChild: CacheNodeSeedData | null | void = @@ -1169,7 +1130,7 @@ function finishTaskUsingDynamicDataPayload( dynamicDataChild !== undefined ) { // Found a match for this task. Keep traversing down the task tree. - return finishTaskUsingDynamicDataPayload( + finishTaskUsingDynamicDataPayload( taskChild, serverRouterStateChild, dynamicDataChild, @@ -1178,103 +1139,6 @@ function finishTaskUsingDynamicDataPayload( ) } } - // We didn't find a child task that matches the server data. 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. - } -} - -function createPendingCacheNode( - isRefresh: boolean, - navigatedAt: number, - routerState: FlightRouterState, - prefetchData: CacheNodeSeedData | null, - prefetchHead: HeadData | null, - isPrefetchHeadPartial: boolean, - segmentPath: FlightSegmentPath, - accumulation: NavigationRequestAccumulation -): ReadyCacheNode { - const routerStateChildren = routerState[1] - const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null - - if (isRefresh) { - const refreshUrl = routerState[2] - if (typeof refreshUrl === 'string' && routerState[3] === 'refresh') { - accumulateRefreshUrl(accumulation, refreshUrl) - } - } - - const parallelRoutes = new Map() - for (let parallelRouteKey in routerStateChildren) { - const routerStateChild: FlightRouterState = - routerStateChildren[parallelRouteKey] - const prefetchDataChild: CacheNodeSeedData | null | void = - prefetchDataChildren !== null - ? prefetchDataChildren[parallelRouteKey] - : null - - const segmentChild = routerStateChild[0] - const segmentPathChild = segmentPath.concat([ - parallelRouteKey, - segmentChild, - ]) - const segmentKeyChild = createRouterCacheKey(segmentChild) - - const newCacheNodeChild = createPendingCacheNode( - isRefresh, - navigatedAt, - routerStateChild, - prefetchDataChild === undefined ? null : prefetchDataChild, - prefetchHead, - isPrefetchHeadPartial, - segmentPathChild, - accumulation - ) - - const newSegmentMapChild: ChildSegmentMap = new Map() - newSegmentMapChild.set(segmentKeyChild, newCacheNodeChild) - parallelRoutes.set(parallelRouteKey, newSegmentMapChild) - } - - // The head is assigned to every leaf segment delivered by the server. Based - // on corresponding logic in fill-lazy-items-till-leaf-with-head.ts - const isLeafSegment = parallelRoutes.size === 0 - - if (isLeafSegment) { - // The segment path of every leaf segment (i.e. page) is collected into - // a result array. This is used by the LayoutRouter to scroll to ensure that - // new pages are visible after a navigation. - // TODO: We should use a string to represent the segment path instead of - // an array. We already use a string representation for the path when - // accessing the Segment Cache, so we can use the same one. - accumulation.scrollableSegments.push(segmentPath) - } - - const maybePrefetchRsc = prefetchData !== null ? prefetchData[0] : null - return { - lazyData: null, - parallelRoutes: parallelRoutes, - - prefetchRsc: maybePrefetchRsc !== undefined ? maybePrefetchRsc : null, - prefetchHead: isLeafSegment ? prefetchHead : [null, null], - - // Create a deferred promise. This will be fulfilled once the dynamic - // response is received from the server. - rsc: createDeferredRsc() as React.ReactNode, - head: isLeafSegment ? (createDeferredRsc() as React.ReactNode) : null, - - // TODO: Technically, a loading boundary could contain dynamic data. We must - // have separate `loading` and `prefetchLoading` fields to handle this, like - // we do for the segment data and head. - loading: - prefetchData !== null - ? (prefetchData[2] ?? null) - : // If we don't have a prefetch, then we don't know if there's a loading component. - // We'll fulfill it based on the dynamic response, just like `rsc` and `head`. - createDeferredRsc(), - - navigatedAt, } } @@ -1336,15 +1200,7 @@ function finishPendingCacheNode( dynamicHead, debugInfo ) - } else { - // The response does not include data for this segment, but it may - // be included in a separate response. Don't abort the task until all - // responses are received. } - } else { - // There's no matching Cache Node in the task tree. This is a bug in the - // implementation because we should have created a node for every segment - // in the tree that's associated with this task. } } @@ -1384,7 +1240,7 @@ function finishPendingCacheNode( } export function abortTask( - task: SPANavigationTask, + task: NavigationTask, error: any, debugInfo: Array | null ): void { @@ -1483,7 +1339,13 @@ export function updateCacheNodeOnPopstateRestoration( // This function clones the entire cache node tree and sets the `prefetchRsc` // field to `null` to prevent it from being rendered. We can't mutate the node // in place because this is a concurrent data structure. - + // + // TODO: Delete this function and instead move the logic into the normal + // navigation path (updateCacheNodeOnNavigation) to ensure we handle all the + // same cases. The only difference is that whenever a segment is missing, we + // should always check for existing dynamic data rather than spawning a new + // request. We can handle this using the same branch that handles stale + // dynamic data (see createCacheNodeOnNavigation). const routerStateChildren = routerState[1] const oldParallelRoutes = oldCacheNode.parallelRoutes const newParallelRoutes = new Map(oldParallelRoutes) diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index 3dd2239d6205e..8afaa1c40ac0d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -80,11 +80,21 @@ export function handleNavigationResult( const newUrl = result.data return handleExternalUrl(state, mutable, newUrl, pendingPush) } - case NavigationResultTag.NoOp: { - // The server responded with no change to the current page. However, if - // the URL changed, we still need to update that. - const newCanonicalUrl = result.data.canonicalUrl - mutable.canonicalUrl = newCanonicalUrl + case NavigationResultTag.Success: { + // Received a new result. + mutable.cache = result.data.cacheNode + mutable.patchedTree = result.data.flightRouterState + mutable.renderedSearch = result.data.renderedSearch + mutable.canonicalUrl = result.data.canonicalUrl + // TODO: During a refresh, we don't set the `scrollableSegments`. There's + // some confusing and subtle logic in `handleMutable` that decides what + // to do when `shouldScroll` is set but `scrollableSegments` is not. I'm + // not convinced it's totally coherent but the tests assert on this + // particular behavior so I've ported the logic as-is from the previous + // router implementation, for now. + mutable.scrollableSegments = result.data.scrollableSegments ?? undefined + mutable.shouldScroll = result.data.shouldScroll + mutable.hashFragment = result.data.hash // Check if the only thing that changed was the hash fragment. const oldUrl = new URL(state.canonicalUrl, url) @@ -106,23 +116,6 @@ export function handleNavigationResult( return handleMutable(state, mutable) } - case NavigationResultTag.Success: { - // Received a new result. - mutable.cache = result.data.cacheNode - mutable.patchedTree = result.data.flightRouterState - mutable.renderedSearch = result.data.renderedSearch - mutable.canonicalUrl = result.data.canonicalUrl - // TODO: During a refresh, we don't set the `scrollableSegments`. There's - // some confusing and subtle logic in `handleMutable` that decides what - // to do when `shouldScroll` is set but `scrollableSegments` is not. I'm - // not convinced it's totally coherent but the tests assert on this - // particular behavior so I've ported the logic as-is from the previous - // router implementation, for now. - mutable.scrollableSegments = result.data.scrollableSegments ?? undefined - mutable.shouldScroll = result.data.shouldScroll - mutable.hashFragment = result.data.hash - return handleMutable(state, mutable) - } case NavigationResultTag.Async: { return result.data.then( (asyncResult) => diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index 9a96ee767c624..1c47f3c72ef3d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -6,14 +6,23 @@ import type { } from '../router-reducer-types' import { handleNavigationResult } from './navigate-reducer' import { refresh as refreshUsingSegmentCache } from '../../segment-cache/navigation' +import { revalidateEntireCache } from '../../segment-cache/cache' export function refreshReducer( state: ReadonlyReducerState, action: RefreshAction ): 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) + const currentUrl = new URL(state.canonicalUrl, action.origin) const result = refreshUsingSegmentCache( currentUrl, + state.cache, state.tree, state.nextUrl, state.renderedSearch, diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index c941d2badcf3f..452f2c13a1378 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -12,9 +12,8 @@ import type { NormalizedFlightData } from '../../flight-data-helpers' import { fetchServerResponse } from '../router-reducer/fetch-server-response' import { startPPRNavigation, - startPPRRefresh, listenForDynamicRequest, - type Task as PPRNavigationTask, + type NavigationTask, type NavigationRequestAccumulation, } from '../router-reducer/ppr-navigations' import { createHrefFromUrl } from '../router-reducer/create-href-from-url' @@ -37,14 +36,6 @@ type MPANavigationResult = { data: string } -type NoOpNavigationResult = { - tag: NavigationResultTag.NoOp - data: { - canonicalUrl: string - shouldScroll: boolean - } -} - type SuccessfulNavigationResult = { tag: NavigationResultTag.Success data: { @@ -60,15 +51,12 @@ type SuccessfulNavigationResult = { type AsyncNavigationResult = { tag: NavigationResultTag.Async - data: Promise< - MPANavigationResult | NoOpNavigationResult | SuccessfulNavigationResult - > + data: Promise } export type NavigationResult = | MPANavigationResult | SuccessfulNavigationResult - | NoOpNavigationResult | AsyncNavigationResult /** @@ -82,7 +70,7 @@ export type NavigationResult = export function navigate( url: URL, currentUrl: URL, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, nextUrl: string | null, shouldScroll: boolean, @@ -223,25 +211,34 @@ export function navigate( export function refresh( currentUrl: URL, + currentCacheNode: CacheNode, currentFlightRouterState: FlightRouterState, currentNextUrl: string | null, currentRenderedSearch: string, currentCanonicalUrl: string -): SuccessfulNavigationResult | NoOpNavigationResult | MPANavigationResult { +): SuccessfulNavigationResult | MPANavigationResult { // A refresh is a special case of a navigation where all the dynamic data // on the current router is re-fetched. Most of the logic is handled within - // the ppr-navigations module. The main difference here is that we call - // startPPRRefresh instead of startPPRNavigation. + // the ppr-navigations module. The main difference here is that we set + // shouldRefreshDynamicData to true. const now = Date.now() const shouldScroll = true const accumulation: NavigationRequestAccumulation = { scrollableSegments: [], separateRefreshUrls: null, } - const task = startPPRRefresh( + const shouldRefreshDynamicData = true + const task = startPPRNavigation( now, + currentUrl, + currentCacheNode, + currentFlightRouterState, currentFlightRouterState, - currentNextUrl, + shouldRefreshDynamicData, + null, + null, + false, + true, accumulation ) if (task !== null) { @@ -268,31 +265,26 @@ export function refresh( const newTree = task.route const newCacheNode = task.node - if (newTree !== null && newCacheNode !== null) { - // Re-render with the new data. All the other data remains the same. - return { - tag: NavigationResultTag.Success, - data: { - flightRouterState: newTree, - cacheNode: newCacheNode, - canonicalUrl: currentCanonicalUrl, - renderedSearch: currentRenderedSearch, - // During a refresh, we don't set the `scrollableSegments`. See - // corresponding comment in navigate-reducer.ts for context. - scrollableSegments: null, - shouldScroll, - hash: currentUrl.hash, - }, - } + // Re-render with the new data. All the other data remains the same. + return { + tag: NavigationResultTag.Success, + data: { + flightRouterState: newTree, + cacheNode: newCacheNode, + canonicalUrl: currentCanonicalUrl, + renderedSearch: currentRenderedSearch, + // During a refresh, we don't set the `scrollableSegments`. See + // corresponding comment in navigate-reducer.ts for context. + scrollableSegments: null, + shouldScroll, + hash: currentUrl.hash, + }, } } - + // Could not perform a SPA navigation. Revert to a full-page (MPA) navigation. return { - tag: NavigationResultTag.NoOp, - data: { - canonicalUrl: currentCanonicalUrl, - shouldScroll, - }, + tag: NavigationResultTag.MPA, + data: currentCanonicalUrl, } } @@ -302,7 +294,7 @@ function navigateUsingPrefetchedRouteTree( currentUrl: URL, nextUrl: string | null, isSamePageNavigation: boolean, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, prefetchFlightRouterState: FlightRouterState, prefetchSeedData: CacheNodeSeedData | null, @@ -312,7 +304,7 @@ function navigateUsingPrefetchedRouteTree( renderedSearch: string, shouldScroll: boolean, hash: string -): SuccessfulNavigationResult | NoOpNavigationResult | MPANavigationResult { +): 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 // prefetching implementation: FlightRouterState and CacheNodeSeedData. @@ -323,12 +315,14 @@ function navigateUsingPrefetchedRouteTree( scrollableSegments: [], separateRefreshUrls: null, } + const shouldRefreshDynamicData = false const task = startPPRNavigation( now, currentUrl, currentCacheNode, currentFlightRouterState, prefetchFlightRouterState, + shouldRefreshDynamicData, prefetchSeedData, prefetchHead, isPrefetchHeadPartial, @@ -348,7 +342,6 @@ function navigateUsingPrefetchedRouteTree( } return navigationTaskToResult( task, - currentCacheNode, canonicalUrl, renderedSearch, accumulation.scrollableSegments, @@ -356,41 +349,26 @@ function navigateUsingPrefetchedRouteTree( hash ) } - // The server sent back an empty tree patch. There's nothing to update, except - // possibly the URL. + // Could not perform a SPA navigation. Revert to a full-page (MPA) navigation. return { - tag: NavigationResultTag.NoOp, - data: { - canonicalUrl, - shouldScroll, - }, + tag: NavigationResultTag.MPA, + data: canonicalUrl, } } function navigationTaskToResult( - task: PPRNavigationTask, - currentCacheNode: CacheNode, + task: NavigationTask, canonicalUrl: string, renderedSearch: string, scrollableSegments: Array, shouldScroll: boolean, hash: string ): SuccessfulNavigationResult | MPANavigationResult { - const flightRouterState = task.route - if (flightRouterState === null) { - // When no router state is provided, it signals that we should perform an - // MPA navigation. - return { - tag: NavigationResultTag.MPA, - data: canonicalUrl, - } - } - const newCacheNode = task.node return { tag: NavigationResultTag.Success, data: { - flightRouterState, - cacheNode: newCacheNode !== null ? newCacheNode : currentCacheNode, + flightRouterState: task.route, + cacheNode: task.node, canonicalUrl, renderedSearch, scrollableSegments, @@ -534,14 +512,12 @@ async function navigateDynamicallyWithNoPrefetch( currentUrl: URL, nextUrl: string | null, isSamePageNavigation: boolean, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, shouldScroll: boolean, hash: string, collectedDebugInfo: Array -): Promise< - MPANavigationResult | SuccessfulNavigationResult | NoOpNavigationResult -> { +): Promise { // Runs when a navigation happens but there's no cached prefetch we can use. // Don't bother to wait for a prefetch response; go straight to a full // navigation that contains both static and dynamic data in a single stream. @@ -597,12 +573,14 @@ async function navigateDynamicallyWithNoPrefetch( scrollableSegments: [], separateRefreshUrls: null, } + const shouldRefreshDynamicData = false const task = startPPRNavigation( now, currentUrl, currentCacheNode, currentFlightRouterState, prefetchFlightRouterState, + shouldRefreshDynamicData, prefetchSeedData, prefetchHead, isPrefetchHeadPartial, @@ -633,7 +611,6 @@ async function navigateDynamicallyWithNoPrefetch( } return navigationTaskToResult( task, - currentCacheNode, createHrefFromUrl(canonicalUrl), renderedSearch, accumulation.scrollableSegments, @@ -641,14 +618,10 @@ async function navigateDynamicallyWithNoPrefetch( hash ) } - // The server sent back an empty tree patch. There's nothing to update, except - // possibly the URL. + // Could not perform a SPA navigation. Revert to a full-page (MPA) navigation. return { - tag: NavigationResultTag.NoOp, - data: { - canonicalUrl: createHrefFromUrl(canonicalUrl), - shouldScroll, - }, + tag: NavigationResultTag.MPA, + data: createHrefFromUrl(canonicalUrl), } } diff --git a/packages/next/src/shared/lib/segment.ts b/packages/next/src/shared/lib/segment.ts index 4da4cb75deb90..8d8117f598e65 100644 --- a/packages/next/src/shared/lib/segment.ts +++ b/packages/next/src/shared/lib/segment.ts @@ -86,3 +86,4 @@ export function getSelectedLayoutSegmentPath( export const PAGE_SEGMENT_KEY = '__PAGE__' export const DEFAULT_SEGMENT_KEY = '__DEFAULT__' +export const NOT_FOUND_SEGMENT_KEY = '/_not-found' From 6e25064ac7937e616b8d12dee83aa22fb11dddfc Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 3 Dec 2025 00:23:15 -0500 Subject: [PATCH 2/3] Re-implement Server Action reducer Rewrite of the Server Action reducer to use the PPR/Segment Cache navigation implementation, rather than the old lazy fetch implementation. Server Actions may trigger a revalidation, a redirect, or both. They may also invalidate the cache. The behavior could be naively implemented using router.refresh() and router.push(). Semantically, the routing behavior is equivalent. The main difference is that the server that invokes the action may also send back new data for the page within the same response. Compared to a separate request, this data is more likely to be consistent with any data that may have been mutated by the action, due to global data propagation races. (It's also faster since it avoids an extra server waterfall.) So, navigations initiated by a Server action must be able to "seed" the navigation with the data it just received from the server. I've added a new internal method, navigateToSeededRoute, that implements this behavior. --- .../router-reducer/ppr-navigations.ts | 79 ++++- .../reducers/navigate-reducer.ts | 2 + .../reducers/refresh-reducer.ts | 34 +- .../reducers/server-action-reducer.ts | 297 +++++++++--------- .../components/segment-cache/navigation.ts | 130 ++++---- .../next/src/server/app-render/app-render.tsx | 8 + .../next/src/shared/lib/app-router-types.ts | 8 + 7 files changed, 346 insertions(+), 212 deletions(-) diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index 6530dbac39996..43eedef8a4163 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -51,7 +51,7 @@ export type NavigationTask = { } export type NavigationRequestAccumulation = { - scrollableSegments: Array + scrollableSegments: Array | null separateRefreshUrls: Set | null } @@ -91,6 +91,8 @@ export function startPPRNavigation( oldRouterState: FlightRouterState, newRouterState: FlightRouterState, shouldRefreshDynamicData: boolean, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, prefetchData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, @@ -108,6 +110,8 @@ export function startPPRNavigation( newRouterState, shouldRefreshDynamicData, didFindRootLayout, + seedData, + seedHead, prefetchData, prefetchHead, isPrefetchHeadPartial, @@ -128,6 +132,8 @@ function updateCacheNodeOnNavigation( newRouterState: FlightRouterState, shouldRefreshDynamicData: boolean, didFindRootLayout: boolean, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, prefetchData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, @@ -218,6 +224,7 @@ function updateCacheNodeOnNavigation( const newRouterStateChildren = newRouterState[1] const oldRouterStateChildren = oldRouterState[1] + const seedDataChildren = seedData !== null ? seedData[1] : null const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null // We're currently traversing the part of the tree that was also part of @@ -266,6 +273,28 @@ function updateCacheNodeOnNavigation( // Reuse the existing CacheNode newCacheNode = reuseDynamicCacheNode(oldCacheNode, newParallelRoutes) needsDynamicRequest = false + } else if (seedData !== 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 + // for performance and it's more likely to be consistent with any + // writes that were just performed by the action, compared to a + // separate request. + const seedRsc = seedData[0] + const seedLoading = seedData[2] + const isSeedRscPartial = false + const isSeedHeadPartial = seedHead === null + newCacheNode = readCacheNodeFromSeedData( + seedRsc, + seedLoading, + isSeedRscPartial, + seedHead, + isSeedHeadPartial, + isLeafSegment, + newParallelRoutes, + navigatedAt + ) + needsDynamicRequest = isLeafSegment && isSeedHeadPartial } else if (prefetchData !== null) { // Consult the prefetch cache. const prefetchRsc = prefetchData[0] @@ -359,12 +388,16 @@ function updateCacheNodeOnNavigation( oldParallelRoutes !== undefined ? oldParallelRoutes.get(parallelRouteKey) : undefined + + let seedDataChild: CacheNodeSeedData | void | null = + seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null let prefetchDataChild: CacheNodeSeedData | void | null = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] : null let newSegmentChild = newRouterStateChild[0] + let seedHeadChild = seedHead let prefetchHeadChild = prefetchHead let isPrefetchHeadPartialChild = isPrefetchHeadPartial if (newSegmentChild === DEFAULT_SEGMENT_KEY) { @@ -379,6 +412,8 @@ function updateCacheNodeOnNavigation( // 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 prefetchDataChild = null prefetchHeadChild = null isPrefetchHeadPartialChild = false @@ -398,6 +433,8 @@ function updateCacheNodeOnNavigation( newRouterStateChild, shouldRefreshDynamicData, childDidFindRootLayout, + seedDataChild ?? null, + seedHeadChild, prefetchDataChild ?? null, prefetchHeadChild, isPrefetchHeadPartialChild, @@ -423,7 +460,9 @@ function updateCacheNodeOnNavigation( taskChildren.set(parallelRouteKey, taskChild) const newCacheNodeChild = taskChild.node if (newCacheNodeChild !== null) { - const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild) + const newSegmentMapChild: ChildSegmentMap = new Map( + shouldRefreshDynamicData ? undefined : oldSegmentMapChild + ) newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) } @@ -473,6 +512,8 @@ function createCacheNodeOnNavigation( newRouterState: FlightRouterState, oldCacheNode: CacheNode | void, shouldRefreshDynamicData: boolean, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, prefetchData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, @@ -499,6 +540,7 @@ function createCacheNodeOnNavigation( const newRouterStateChildren = newRouterState[1] const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null + const seedDataChildren = seedData !== null ? seedData[1] : null const oldParallelRoutes = oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined const newParallelRoutes = new Map( @@ -516,6 +558,9 @@ function createCacheNodeOnNavigation( // TODO: We should use a string to represent the segment path instead of // an array. We already use a string representation for the path when // accessing the Segment Cache, so we can use the same one. + if (accumulation.scrollableSegments === null) { + accumulation.scrollableSegments = [] + } accumulation.scrollableSegments.push(segmentPath) } @@ -536,6 +581,28 @@ function createCacheNodeOnNavigation( // Reuse the existing CacheNode newCacheNode = reuseDynamicCacheNode(oldCacheNode, newParallelRoutes) needsDynamicRequest = false + } else if (seedData !== 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 + // for performance and it's more likely to be consistent with any + // writes that were just performed by the action, compared to a + // separate request. + const seedRsc = seedData[0] + const seedLoading = seedData[2] + const isSeedRscPartial = false + const isSeedHeadPartial = seedHead === null + newCacheNode = readCacheNodeFromSeedData( + seedRsc, + seedLoading, + isSeedRscPartial, + seedHead, + isSeedHeadPartial, + isLeafSegment, + newParallelRoutes, + navigatedAt + ) + needsDynamicRequest = isLeafSegment && isSeedHeadPartial } else if (prefetchData !== null) { // Consult the prefetch cache. const prefetchRsc = prefetchData[0] @@ -580,6 +647,8 @@ function createCacheNodeOnNavigation( oldParallelRoutes !== undefined ? oldParallelRoutes.get(parallelRouteKey) : undefined + const seedDataChild: CacheNodeSeedData | void | null = + seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null const prefetchDataChild: CacheNodeSeedData | void | null = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] @@ -598,6 +667,8 @@ function createCacheNodeOnNavigation( newRouterStateChild, oldCacheNodeChild, shouldRefreshDynamicData, + seedDataChild ?? null, + seedHead, prefetchDataChild ?? null, prefetchHead, isPrefetchHeadPartial, @@ -613,7 +684,9 @@ function createCacheNodeOnNavigation( taskChildren.set(parallelRouteKey, taskChild) const newCacheNodeChild = taskChild.node if (newCacheNodeChild !== null) { - const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild) + const newSegmentMapChild: ChildSegmentMap = new Map( + shouldRefreshDynamicData ? undefined : oldSegmentMapChild + ) newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) } diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index 8afaa1c40ac0d..b8df489e0da03 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -161,12 +161,14 @@ export function navigateReducer( // implementation. Eventually we'll rewrite the router reducer to a // state machine. const currentUrl = new URL(state.canonicalUrl, location.origin) + const shouldRefreshDynamicData = false const result = navigateUsingSegmentCache( url, currentUrl, state.cache, state.tree, state.nextUrl, + shouldRefreshDynamicData, shouldScroll, mutable ) diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index 1c47f3c72ef3d..5686260aeafdb 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -5,8 +5,9 @@ import type { RefreshAction, } from '../router-reducer-types' import { handleNavigationResult } from './navigate-reducer' -import { refresh as refreshUsingSegmentCache } from '../../segment-cache/navigation' +import { navigateToSeededRoute } from '../../segment-cache/navigation' import { revalidateEntireCache } from '../../segment-cache/cache' +import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' export function refreshReducer( state: ReadonlyReducerState, @@ -19,14 +20,37 @@ export function refreshReducer( const currentRouterState = state.tree revalidateEntireCache(currentNextUrl, currentRouterState) + // 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 + // want the same interception route to be matched that used the last next-url. + const nextUrlForRefresh = hasInterceptionRouteInCurrentTree(state.tree) + ? state.previousNextUrl || currentNextUrl + : null + + // 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 result = refreshUsingSegmentCache( + const url = currentUrl + const currentFlightRouterState = state.tree + const shouldScroll = true + const shouldRefreshDynamicData = true + + const seedFlightRouterState = state.tree + const seedData = null + const seedHead = null + + const result = navigateToSeededRoute( + url, currentUrl, state.cache, - state.tree, - state.nextUrl, + currentFlightRouterState, + seedFlightRouterState, + seedData, + seedHead, + shouldRefreshDynamicData, + nextUrlForRefresh, state.renderedSearch, - state.canonicalUrl + shouldScroll ) const mutable: Mutable = {} diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index a6d721d0f0805..e28d62950dad3 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -32,16 +32,8 @@ import type { } from '../router-reducer-types' import { assignLocation } from '../../../assign-location' import { createHrefFromUrl } from '../create-href-from-url' -import { handleExternalUrl } from './navigate-reducer' -import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' -import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' -import type { CacheNode } from '../../../../shared/lib/app-router-types' -import { handleMutable } from '../handle-mutable' -import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with-head' -import { createEmptyCacheNode } from '../../app-router' +import { handleExternalUrl, handleNavigationResult } from './navigate-reducer' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' -import { handleSegmentMismatch } from '../handle-segment-mismatch' -import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments' import { normalizeFlightData, prepareFlightRouterStateForRequest, @@ -57,6 +49,11 @@ import { } from '../../../../shared/lib/server-reference-info' import { revalidateEntireCache } from '../../segment-cache/cache' import { getDeploymentId } from '../../../../shared/lib/deployment-id' +import { + navigateToSeededRoute, + navigate as navigateUsingSegmentCache, +} from '../../segment-cache/navigation' +import type { NormalizedSearch } from '../../segment-cache/cache-key' const createFromFetch = createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch'] @@ -74,11 +71,16 @@ if ( ).createDebugChannel } +// TODO: Refactor to be a discriminated union. Or just get rid of it; +// fetchServerAction only has one caller, no reason this intermediate type has +// to exist. type FetchServerActionResult = { redirectLocation: URL | undefined redirectType: RedirectType | undefined actionResult: ActionResult | undefined actionFlightData: NormalizedFlightData[] | string | undefined + actionFlightDataRenderedSearch: NormalizedSearch | undefined + actionFlightDataCouldBeIntercepted: boolean | undefined isPrerender: boolean revalidatedParts: { tag: boolean @@ -166,7 +168,7 @@ async function fetchServerAction( revalidatedParts = { paths: revalidatedHeader[0] || [], tag: !!revalidatedHeader[1], - cookie: revalidatedHeader[2], + cookie: !!revalidatedHeader[2], } } catch (e) { revalidatedParts = NO_REVALIDATED_PARTS @@ -200,6 +202,8 @@ async function fetchServerAction( let actionResult: FetchServerActionResult['actionResult'] let actionFlightData: FetchServerActionResult['actionFlightData'] + let actionFlightDataRenderedSearch: FetchServerActionResult['actionFlightDataRenderedSearch'] + let actionFlightDataCouldBeIntercepted: FetchServerActionResult['actionFlightDataCouldBeIntercepted'] if (isRscResponse) { const response: ActionFlightResponse = await createFromFetch( @@ -214,16 +218,25 @@ async function fetchServerAction( // An internal redirect can send an RSC response, but does not have a useful `actionResult`. actionResult = redirectLocation ? undefined : response.a - actionFlightData = normalizeFlightData(response.f) + const maybeFlightData = normalizeFlightData(response.f) + if (maybeFlightData !== '') { + actionFlightData = maybeFlightData + actionFlightDataRenderedSearch = response.q as NormalizedSearch + actionFlightDataCouldBeIntercepted = response.i + } } else { // An external redirect doesn't contain RSC data. actionResult = undefined actionFlightData = undefined + actionFlightDataRenderedSearch = undefined + actionFlightDataCouldBeIntercepted = undefined } return { actionResult, actionFlightData, + actionFlightDataRenderedSearch, + actionFlightDataCouldBeIntercepted, redirectLocation, redirectType, revalidatedParts, @@ -248,8 +261,6 @@ export function serverActionReducer( const { resolve, reject } = action const mutable: ServerActionMutable = {} - let currentTree = state.tree - mutable.preserveCustomHistoryState = false // only pass along the `nextUrl` param (used for interception routes) if the current route was intercepted. @@ -267,161 +278,47 @@ export function serverActionReducer( ? state.previousNextUrl || state.nextUrl : null - const navigatedAt = Date.now() - return fetchServerAction(state, nextUrl, action).then( async ({ actionResult, actionFlightData: flightData, + actionFlightDataRenderedSearch: flightDataRenderedSearch, + actionFlightDataCouldBeIntercepted: flightDataCouldBeIntercepted, redirectLocation, redirectType, revalidatedParts, }) => { - let redirectHref: string | undefined - - // honor the redirect type instead of defaulting to push in case of server actions. - if (redirectLocation) { - if (redirectType === RedirectType.replace) { - state.pushRef.pendingPush = false - mutable.pendingPush = false - } else { - state.pushRef.pendingPush = true - mutable.pendingPush = true - } - - redirectHref = createHrefFromUrl(redirectLocation, false) - mutable.canonicalUrl = redirectHref - } - - if (!flightData) { - resolve(actionResult) - - // If there is a redirect but no flight data we need to do a mpaNavigation. - if (redirectLocation) { - return handleExternalUrl( - state, - mutable, - redirectLocation.href, - state.pushRef.pendingPush - ) - } - return state - } - - if (typeof flightData === 'string') { - // Handle case when navigating to page in `pages` from `app` - resolve(actionResult) - - return handleExternalUrl( - state, - mutable, - flightData, - state.pushRef.pendingPush - ) - } - - const actionRevalidated = + const actionDidRevalidateCache = revalidatedParts.paths.length > 0 || revalidatedParts.tag || revalidatedParts.cookie - // Store whether this action triggered any revalidation - // The action queue will use this information to potentially - // trigger a refresh action if the action was discarded - // (ie, due to a navigation, before the action completed) - if (actionRevalidated) { + if (actionDidRevalidateCache) { + // Store whether this action triggered any revalidation + // The action queue will use this information to potentially + // trigger a refresh action if the action was discarded + // (ie, due to a navigation, before the action completed) action.didRevalidate = true - } - - for (const normalizedFlightData of flightData) { - const { - tree: treePatch, - seedData: cacheNodeSeedData, - head, - isRootRender, - } = normalizedFlightData - - if (!isRootRender) { - // TODO-APP: handle this case better - console.log('SERVER ACTION APPLY FAILED') - resolve(actionResult) - - return state - } - - // Given the path can only have two items the items are only the router state and rsc for the root. - const newTree = applyRouterStatePatchToTree( - // TODO-APP: remove '' - [''], - currentTree, - treePatch, - redirectHref ? redirectHref : state.canonicalUrl - ) - - if (newTree === null) { - resolve(actionResult) - return handleSegmentMismatch(state, action, treePatch) - } - - if (isNavigatingToNewRootLayout(currentTree, newTree)) { - resolve(actionResult) - - return handleExternalUrl( - state, - mutable, - redirectHref || state.canonicalUrl, - state.pushRef.pendingPush - ) - } - - // The server sent back RSC data for the server action, so we need to apply it to the cache. - if (cacheNodeSeedData !== null) { - const rsc = cacheNodeSeedData[0] - const cache: CacheNode = createEmptyCacheNode() - cache.rsc = rsc - cache.prefetchRsc = null - cache.loading = cacheNodeSeedData[2] - fillLazyItemsTillLeafWithHead( - navigatedAt, - cache, - // Existing cache is not passed in as server actions have to invalidate the entire cache. - undefined, - treePatch, - cacheNodeSeedData, - head - ) - - mutable.cache = cache - revalidateEntireCache(state.nextUrl, newTree) - if (actionRevalidated) { - await refreshInactiveParallelSegments({ - navigatedAt, - state, - updatedTree: newTree, - updatedCache: cache, - includeNextUrl: Boolean(nextUrl), - canonicalUrl: mutable.canonicalUrl || state.canonicalUrl, - }) - } - } - - mutable.patchedTree = newTree - currentTree = newTree + // If there was a revalidation, evict the entire prefetch cache. + // TODO: Evict only segments with matching tags and/or paths. + revalidateEntireCache(state.nextUrl, state.tree) } - if (redirectLocation && redirectHref) { + if (redirectLocation !== undefined) { // If the action triggered a redirect, the action promise will be rejected with // a redirect so that it's handled by RedirectBoundary as we won't have a valid // action result to resolve the promise with. This will effectively reset the state of // the component that called the action as the error boundary will remount the tree. // The status code doesn't matter here as the action handler will have already sent // a response with the correct status code. + const redirectHref = createHrefFromUrl(redirectLocation, false) + const resolvedRedirectType = redirectType || RedirectType.push const redirectError = getRedirectError( hasBasePath(redirectHref) ? removeBasePath(redirectHref) : redirectHref, - redirectType || RedirectType.push + resolvedRedirectType ) // We mark the error as handled because we don't want the redirect to be tried later by // the RedirectBoundary, in case the user goes back and `Activity` triggers the redirect @@ -431,10 +328,124 @@ export function serverActionReducer( ;(redirectError as any).handled = true reject(redirectError) } else { + // If there's no redirect, resolve the action with the result. resolve(actionResult) } - return handleMutable(state, mutable) + const pendingPush = redirectType !== RedirectType.replace + state.pushRef.pendingPush = pendingPush + mutable.pendingPush = pendingPush + + // Check if we can bail out without updating any state. + if ( + // Did the action trigger a redirect? + redirectLocation === undefined && + // Did the action revalidate any data? + !actionDidRevalidateCache && + // Did the server render new data? + flightData === undefined + ) { + // The action did not trigger any revalidations or redirects. No + // navigation is required. + return state + } + + if (flightData === undefined && redirectLocation !== undefined) { + // The server redirected, but did not send any Flight data. This implies + // an external redirect. + // TODO: We should refactor the action response type to be more explicit + // about the various response types. + return handleExternalUrl( + state, + mutable, + redirectLocation.href, + pendingPush + ) + } + + if (typeof flightData === 'string') { + // If the flight data is just a string, something earlier in the + // response handling triggered an external redirect. + return handleExternalUrl(state, mutable, flightData, pendingPush) + } + + // The action triggered a navigation — either a redirect, a revalidation, + // or both. + + // If there was no redirect, then the target URL is the same as the + // current URL. + const currentUrl = new URL(state.canonicalUrl, location.origin) + const redirectUrl = + redirectLocation !== undefined ? redirectLocation : currentUrl + const currentFlightRouterState = state.tree + const shouldScroll = true + + // If the action triggered a revalidation of the cache, we should also + // refresh all the dynamic data. + const shouldRefreshDynamicData = actionDidRevalidateCache + + // The server may have sent back new data. If so, we will perform a + // "seeded" navigation that uses the data from the response. + if (flightData !== undefined) { + const normalizedFlightData = flightData[0] + if ( + normalizedFlightData !== undefined && + // TODO: Currently the server always renders from the root in + // response to a Server Action. In the case of a normal redirect + // with no revalidation, it should skip over the shared layouts. + normalizedFlightData.isRootRender && + flightDataRenderedSearch !== undefined && + flightDataCouldBeIntercepted !== undefined + ) { + // The server sent back new route data as part of the response. We + // 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 seedData = normalizedFlightData.seedData + const seedHead = normalizedFlightData.head + const result = navigateToSeededRoute( + redirectUrl, + currentUrl, + state.cache, + currentFlightRouterState, + seedFlightRouterState, + seedData, + seedHead, + shouldRefreshDynamicData, + state.nextUrl, + flightDataRenderedSearch, + shouldScroll + ) + return handleNavigationResult( + redirectUrl, + state, + mutable, + pendingPush, + result + ) + } + } + + // The server did not send back new data. We'll perform a regular, non- + // seeded navigation — effectively the same as or router.push(). + const result = navigateUsingSegmentCache( + redirectUrl, + currentUrl, + state.cache, + currentFlightRouterState, + state.nextUrl, + shouldRefreshDynamicData, + shouldScroll, + mutable + ) + return handleNavigationResult( + redirectUrl, + state, + mutable, + pendingPush, + result + ) }, (e: any) => { // When the server action is rejected we don't update the state and instead call the reject handler of the promise. diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 452f2c13a1378..d4d6a670d6737 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -29,7 +29,6 @@ import { import { createCacheKey } from './cache-key' import { addSearchParamsIfPageSegment } from '../../../shared/lib/segment' import { NavigationResultTag } from './types' -import { hasInterceptionRouteInCurrentTree } from '../router-reducer/reducers/has-interception-route-in-current-tree' type MPANavigationResult = { tag: NavigationResultTag.MPA @@ -73,6 +72,7 @@ export function navigate( currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, nextUrl: string | null, + shouldRefreshDynamicData: boolean, shouldScroll: boolean, accumulation: { collectedDebugInfo?: Array } ): NavigationResult { @@ -97,13 +97,7 @@ export function navigate( // Also note that this only refreshes the dynamic data, not static/ cached // data. If the page segment is fully static and prefetched, the request is // skipped. (This is also how refresh() works.) - const isSamePageNavigation = - // TODO: This is not the only place we read from the location, but we should - // consider storing the current URL in the router state instead of reading - // from the location object. In practice I don't think this matters much - // since we keep them in sync anyway, but having two sources of truth can - // lead to subtle bugs and race conditions. - href === window.location.href + const isSamePageNavigation = href === currentUrl.href const cacheKey = createCacheKey(href, nextUrl) const route = readRouteCacheEntry(now, cacheKey) @@ -137,6 +131,7 @@ export function navigate( isPrefetchHeadPartial, newCanonicalUrl, renderedSearch, + shouldRefreshDynamicData, shouldScroll, url.hash ) @@ -181,6 +176,7 @@ export function navigate( isPrefetchHeadPartial, newCanonicalUrl, newRenderedSearch, + shouldRefreshDynamicData, shouldScroll, url.hash ) @@ -202,6 +198,7 @@ export function navigate( isSamePageNavigation, currentCacheNode, currentFlightRouterState, + shouldRefreshDynamicData, shouldScroll, url.hash, collectedDebugInfo @@ -209,82 +206,71 @@ export function navigate( } } -export function refresh( +export function navigateToSeededRoute( + url: URL, currentUrl: URL, currentCacheNode: CacheNode, currentFlightRouterState: FlightRouterState, - currentNextUrl: string | null, - currentRenderedSearch: string, - currentCanonicalUrl: string + seedFlightRouterState: FlightRouterState, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, + shouldRefreshDynamicData: boolean, + nextUrl: string | null, + renderedSearch: string, + shouldScroll: boolean ): SuccessfulNavigationResult | MPANavigationResult { - // A refresh is a special case of a navigation where all the dynamic data - // on the current router is re-fetched. Most of the logic is handled within - // the ppr-navigations module. The main difference here is that we set - // shouldRefreshDynamicData to true. + // A version of navigate() that accepts the target route tree as an argument + // rather than reading it from the prefetch cache. + // + // This is used for navigations triggered by Server Actions, because the + // server has already + const now = Date.now() - const shouldScroll = true + const canonicalUrl = createHrefFromUrl(url) const accumulation: NavigationRequestAccumulation = { - scrollableSegments: [], + scrollableSegments: null, separateRefreshUrls: null, } - const shouldRefreshDynamicData = true + const isSamePageNavigation = url.href === currentUrl.href const task = startPPRNavigation( now, currentUrl, currentCacheNode, currentFlightRouterState, - currentFlightRouterState, + seedFlightRouterState, shouldRefreshDynamicData, + seedData, + seedHead, null, null, false, - true, + isSamePageNavigation, accumulation ) if (task !== null) { if (task.dynamicRequestTree !== null) { - // 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. - // TODO: This logic was copied from the old implementation. It works, but - // a simpler way to model this would be to track whether any navigation - // has occurred since the initial (SSR) navigation, since that's the only - // one that should not be intercepted. - const includeNextUrl = hasInterceptionRouteInCurrentTree( - currentFlightRouterState - ) listenForDynamicRequest( - currentUrl, - includeNextUrl ? currentNextUrl : null, + url, + nextUrl, task, task.dynamicRequestTree, null, accumulation ) } - - const newTree = task.route - const newCacheNode = task.node - // Re-render with the new data. All the other data remains the same. - return { - tag: NavigationResultTag.Success, - data: { - flightRouterState: newTree, - cacheNode: newCacheNode, - canonicalUrl: currentCanonicalUrl, - renderedSearch: currentRenderedSearch, - // During a refresh, we don't set the `scrollableSegments`. See - // corresponding comment in navigate-reducer.ts for context. - scrollableSegments: null, - shouldScroll, - hash: currentUrl.hash, - }, - } + return navigationTaskToResult( + task, + canonicalUrl, + renderedSearch, + accumulation.scrollableSegments, + shouldScroll, + url.hash + ) } // Could not perform a SPA navigation. Revert to a full-page (MPA) navigation. return { tag: NavigationResultTag.MPA, - data: currentCanonicalUrl, + data: canonicalUrl, } } @@ -302,6 +288,7 @@ function navigateUsingPrefetchedRouteTree( isPrefetchHeadPartial: boolean, canonicalUrl: string, renderedSearch: string, + shouldRefreshDynamicData: boolean, shouldScroll: boolean, hash: string ): SuccessfulNavigationResult | MPANavigationResult { @@ -312,10 +299,11 @@ function navigateUsingPrefetchedRouteTree( // read from the Segment Cache directly. It's only structured this way for now // so we can share code with the old prefetching implementation. const accumulation: NavigationRequestAccumulation = { - scrollableSegments: [], + scrollableSegments: null, separateRefreshUrls: null, } - const shouldRefreshDynamicData = false + const seedData = null + const seedHead = null const task = startPPRNavigation( now, currentUrl, @@ -323,6 +311,8 @@ function navigateUsingPrefetchedRouteTree( currentFlightRouterState, prefetchFlightRouterState, shouldRefreshDynamicData, + seedData, + seedHead, prefetchSeedData, prefetchHead, isPrefetchHeadPartial, @@ -360,7 +350,7 @@ function navigationTaskToResult( task: NavigationTask, canonicalUrl: string, renderedSearch: string, - scrollableSegments: Array, + scrollableSegments: Array | null, shouldScroll: boolean, hash: string ): SuccessfulNavigationResult | MPANavigationResult { @@ -506,6 +496,18 @@ function readHeadSnapshotFromCache( return { rsc, isPartial } } +// Used to request all the dynamic data for a route, rather than just a subset, +// e.g. during a refresh or a revalidation. Typically this gets constructed +// during the normal flow when diffing the route tree, but for an unprefetched +// navigation, where we don't know the structure of the target route, we use +// this instead. +const DynamicRequestTreeForEntireRoute: FlightRouterState = [ + '', + {}, + null, + 'refetch', +] + async function navigateDynamicallyWithNoPrefetch( now: number, url: URL, @@ -514,6 +516,7 @@ async function navigateDynamicallyWithNoPrefetch( isSamePageNavigation: boolean, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, shouldScroll: boolean, hash: string, collectedDebugInfo: Array @@ -531,7 +534,9 @@ async function navigateDynamicallyWithNoPrefetch( // navigation), except we use a single server response for both stages. const promiseForDynamicServerResponse = fetchServerResponse(url, { - flightRouterState: currentFlightRouterState, + flightRouterState: shouldRefreshDynamicData + ? DynamicRequestTreeForEntireRoute + : currentFlightRouterState, nextUrl, }) const result = await promiseForDynamicServerResponse @@ -562,18 +567,19 @@ async function navigateDynamicallyWithNoPrefetch( flightData ) - // In our simulated prefetch payload, we pretend that there's no seed data + // In our simulated prefetch payload, we pretend that there's no prefetch data // nor a prefetch head. - const prefetchSeedData = null + 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: [], + scrollableSegments: null, separateRefreshUrls: null, } - const shouldRefreshDynamicData = false const task = startPPRNavigation( now, currentUrl, @@ -581,7 +587,9 @@ async function navigateDynamicallyWithNoPrefetch( currentFlightRouterState, prefetchFlightRouterState, shouldRefreshDynamicData, - prefetchSeedData, + seedData, + seedHead, + prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 5d4802c2b6b8f..b21bf2dc5be5a 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -532,6 +532,10 @@ async function generateDynamicRSCPayload( ).map((path) => path.slice(1)) // remove the '' (root) segment } + const varyHeader = ctx.res.getHeader('vary') + const couldBeIntercepted = + typeof varyHeader === 'string' && varyHeader.includes(NEXT_URL) + // If we have an action result, then this is a server action response. // We can rely on this because `ActionResult` will always be a promise, even if // the result is falsey. @@ -540,6 +544,8 @@ async function generateDynamicRSCPayload( a: options.actionResult, f: flightData, b: ctx.sharedContext.buildId, + q: getRenderedSearch(query), + i: !!couldBeIntercepted, } } @@ -547,6 +553,8 @@ async function generateDynamicRSCPayload( const baseResponse = { b: ctx.sharedContext.buildId, f: flightData, + q: getRenderedSearch(query), + i: !!couldBeIntercepted, S: workStore.isStaticGeneration, } diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 46cb4d8b29b4c..6d28eee58931b 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -300,6 +300,10 @@ export type NavigationFlightResponse = { f: FlightData /** prerendered */ S: boolean + /** renderedSearch */ + q: string + /** couldBeIntercepted */ + i: boolean /** runtimePrefetch - [isPartial, staleTime]. Only present in runtime prefetch responses. */ rp?: [boolean, number] } @@ -312,6 +316,10 @@ export type ActionFlightResponse = { b: string /** flightData */ f: FlightData + /** renderedSearch */ + q: string + /** couldBeIntercepted */ + i: boolean } export type RSCPayload = From f8da0c816890470299ce5298ebe2f729d2f5ba7f Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 5 Dec 2025 12:34:23 -0500 Subject: [PATCH 3/3] Fix: Server refresh() should not purge client cache The server form of refresh() is used to re-render all the dynamic data on the current page. Unlike updateTag/revalidateTag, it does not affect any cached data, so the client cache should not evict any of its entries. --- .../client/components/app-router-headers.ts | 3 + .../reducers/server-action-reducer.ts | 61 ++++++------ .../src/server/app-render/action-handler.ts | 40 ++++++-- .../app-render/work-async-storage.external.ts | 3 +- .../adapters/request-cookies.ts | 3 +- .../server/web/spec-extension/revalidate.ts | 11 ++- .../shared/lib/action-revalidation-kind.ts | 5 + .../refresh/app/dashboard/client.tsx | 2 +- .../refresh/app/dashboard/layout.tsx | 18 ++++ .../segment-cache/refresh/app/docs/page.tsx | 3 + .../refresh/segment-cache-refresh.test.ts | 96 ++++++++++++++++++- 11 files changed, 192 insertions(+), 53 deletions(-) create mode 100644 packages/next/src/shared/lib/action-revalidation-kind.ts create mode 100644 test/e2e/app-dir/segment-cache/refresh/app/docs/page.tsx diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index adaa686cbdd90..0fb127005bf5a 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -34,3 +34,6 @@ export const NEXT_IS_PRERENDER_HEADER = 'x-nextjs-prerender' as const export const NEXT_ACTION_NOT_FOUND_HEADER = 'x-nextjs-action-not-found' as const export const NEXT_REQUEST_ID_HEADER = 'x-nextjs-request-id' as const export const NEXT_HTML_REQUEST_ID_HEADER = 'x-nextjs-html-request-id' as const + +// TODO: Should this include nextjs in the name, like the others? +export const NEXT_ACTION_REVALIDATED_HEADER = 'x-action-revalidated' as const diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index e28d62950dad3..6b9b29b5770a5 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -54,6 +54,12 @@ import { navigate as navigateUsingSegmentCache, } from '../../segment-cache/navigation' import type { NormalizedSearch } from '../../segment-cache/cache-key' +import { + ActionDidNotRevalidate, + ActionDidRevalidateDynamicOnly, + ActionDidRevalidateStaticAndDynamic, + type ActionRevalidationKind, +} from '../../../../shared/lib/action-revalidation-kind' const createFromFetch = createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch'] @@ -77,16 +83,12 @@ if ( type FetchServerActionResult = { redirectLocation: URL | undefined redirectType: RedirectType | undefined + revalidationKind: ActionRevalidationKind actionResult: ActionResult | undefined actionFlightData: NormalizedFlightData[] | string | undefined actionFlightDataRenderedSearch: NormalizedSearch | undefined actionFlightDataCouldBeIntercepted: boolean | undefined isPrerender: boolean - revalidatedParts: { - tag: boolean - cookie: boolean - paths: string[] - } } async function fetchServerAction( @@ -160,19 +162,20 @@ async function fetchServerAction( } const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER) - let revalidatedParts: FetchServerActionResult['revalidatedParts'] + + let revalidationKind: ActionRevalidationKind = ActionDidNotRevalidate try { - const revalidatedHeader = JSON.parse( - res.headers.get('x-action-revalidated') || '[[],0,0]' - ) - revalidatedParts = { - paths: revalidatedHeader[0] || [], - tag: !!revalidatedHeader[1], - cookie: !!revalidatedHeader[2], + const revalidationHeader = res.headers.get('x-action-revalidated') + if (revalidationHeader) { + const parsedKind = JSON.parse(revalidationHeader) + if ( + parsedKind === ActionDidRevalidateStaticAndDynamic || + parsedKind === ActionDidRevalidateDynamicOnly + ) { + revalidationKind = parsedKind + } } - } catch (e) { - revalidatedParts = NO_REVALIDATED_PARTS - } + } catch {} const redirectLocation = location ? assignLocation( @@ -239,17 +242,11 @@ async function fetchServerAction( actionFlightDataCouldBeIntercepted, redirectLocation, redirectType, - revalidatedParts, + revalidationKind, isPrerender, } } -const NO_REVALIDATED_PARTS = { - paths: [], - tag: false, - cookie: false, -} - /* * This reducer is responsible for calling the server action and processing any side-effects from the server action. * It does not mutate the state by itself but rather delegates to other reducers to do the actual mutation. @@ -280,20 +277,15 @@ export function serverActionReducer( return fetchServerAction(state, nextUrl, action).then( async ({ + revalidationKind, actionResult, actionFlightData: flightData, actionFlightDataRenderedSearch: flightDataRenderedSearch, actionFlightDataCouldBeIntercepted: flightDataCouldBeIntercepted, redirectLocation, redirectType, - revalidatedParts, }) => { - const actionDidRevalidateCache = - revalidatedParts.paths.length > 0 || - revalidatedParts.tag || - revalidatedParts.cookie - - if (actionDidRevalidateCache) { + if (revalidationKind !== ActionDidNotRevalidate) { // Store whether this action triggered any revalidation // The action queue will use this information to potentially // trigger a refresh action if the action was discarded @@ -302,7 +294,9 @@ export function serverActionReducer( // If there was a revalidation, evict the entire prefetch cache. // TODO: Evict only segments with matching tags and/or paths. - revalidateEntireCache(state.nextUrl, state.tree) + if (revalidationKind === ActionDidRevalidateStaticAndDynamic) { + revalidateEntireCache(state.nextUrl, state.tree) + } } if (redirectLocation !== undefined) { @@ -341,7 +335,7 @@ export function serverActionReducer( // Did the action trigger a redirect? redirectLocation === undefined && // Did the action revalidate any data? - !actionDidRevalidateCache && + revalidationKind === ActionDidNotRevalidate && // Did the server render new data? flightData === undefined ) { @@ -382,7 +376,8 @@ export function serverActionReducer( // If the action triggered a revalidation of the cache, we should also // refresh all the dynamic data. - const shouldRefreshDynamicData = actionDidRevalidateCache + const shouldRefreshDynamicData = + revalidationKind !== ActionDidNotRevalidate // The server may have sent back new data. If so, we will perform a // "seeded" navigation that uses the data from the response. diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index bb7f2cc4ecf00..b59d153f89d14 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -14,6 +14,7 @@ import { NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, NEXT_URL, + NEXT_ACTION_REVALIDATED_HEADER, } from '../../client/components/app-router-headers' import { getAccessFallbackHTTPStatus, @@ -63,6 +64,10 @@ import { executeRevalidates } from '../revalidation-utils' import { getRequestMeta } from '../request-meta' import { setCacheBustingSearchParam } from '../../client/components/router-reducer/set-cache-busting-search-param' import { getServerModuleMap } from './manifests-singleton' +import { + ActionDidNotRevalidate, + ActionDidRevalidateStaticAndDynamic, +} from '../../shared/lib/action-revalidation-kind' function formDataFromSearchQueryString(query: string) { const searchParams = new URLSearchParams(query) @@ -141,9 +146,10 @@ function addRevalidationHeader( // client router cache as they may be stale. And if a path was revalidated, the // client needs to invalidate all subtrees below that path. - // To keep the header size small, we use a tuple of - // [[revalidatedPaths], isTagRevalidated ? 1 : 0, isCookieRevalidated ? 1 : 0] - // instead of a JSON object. + // TODO: Currently we don't send the specific tags or paths to the client, + // we just send a flag indicating that all the static data on the client + // should be invalidated. In the future, this will likely be a Bloom filter + // or bitmask of some kind. // TODO-APP: Currently the prefetch cache doesn't have subtree information, // so we need to invalidate the entire cache if a path was revalidated. @@ -157,10 +163,22 @@ function addRevalidationHeader( ? 1 : 0 - res.setHeader( - 'x-action-revalidated', - JSON.stringify([[], isTagRevalidated, isCookieRevalidated]) - ) + // First check if a tag, cookie, or path was revalidated. + if (isTagRevalidated || isCookieRevalidated) { + res.setHeader( + NEXT_ACTION_REVALIDATED_HEADER, + JSON.stringify(ActionDidRevalidateStaticAndDynamic) + ) + } else if ( + // Check for refresh() actions. This will invalidate only the dynamic data. + workStore.pathWasRevalidated !== undefined && + workStore.pathWasRevalidated !== ActionDidNotRevalidate + ) { + res.setHeader( + NEXT_ACTION_REVALIDATED_HEADER, + JSON.stringify(workStore.pathWasRevalidated) + ) + } } /** @@ -1137,7 +1155,9 @@ export async function handleAction({ // If the page was not revalidated, or if the action was forwarded // from another worker, we can skip rendering the page. skipPageRendering: - !workStore.pathWasRevalidated || actionWasForwarded, + workStore.pathWasRevalidated === undefined || + workStore.pathWasRevalidated === ActionDidNotRevalidate || + actionWasForwarded, temporaryReferences, }), } @@ -1170,7 +1190,9 @@ async function executeActionAndPrepareForRender< // If the page was not revalidated, or if the action was forwarded from // another worker, we can skip rendering the page. - skipPageRendering ||= !workStore.pathWasRevalidated + skipPageRendering ||= + workStore.pathWasRevalidated === undefined || + workStore.pathWasRevalidated === ActionDidNotRevalidate return { actionResult, skipPageRendering } } finally { diff --git a/packages/next/src/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index 76d0953d82b6e..ca22465538d82 100644 --- a/packages/next/src/server/app-render/work-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-async-storage.external.ts @@ -10,6 +10,7 @@ import type { CacheLife } from '../use-cache/cache-life' import { workAsyncStorageInstance } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' } import type { LazyResult } from '../lib/lazy-result' import type { DigestedError } from './create-error-handler' +import type { ActionRevalidationKind } from '../../shared/lib/action-revalidation-kind' export interface WorkStore { readonly isStaticGeneration: boolean @@ -61,7 +62,7 @@ export interface WorkStore { invalidDynamicUsageError?: Error nextFetchId?: number - pathWasRevalidated?: boolean + pathWasRevalidated?: ActionRevalidationKind /** * Tags that were revalidated during the current request. They need to be sent diff --git a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts index 41e63198cf415..2120d7e5ab3ee 100644 --- a/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts +++ b/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts @@ -4,6 +4,7 @@ import { ResponseCookies } from '../cookies' import { ReflectAdapter } from './reflect' import { workAsyncStorage } from '../../../app-render/work-async-storage.external' import type { RequestStore } from '../../../app-render/work-unit-async-storage.external' +import { ActionDidRevalidateStaticAndDynamic } from '../../../../shared/lib/action-revalidation-kind' /** * @internal @@ -116,7 +117,7 @@ export class MutableRequestCookiesAdapter { // TODO-APP: change method of getting workStore const workStore = workAsyncStorage.getStore() if (workStore) { - workStore.pathWasRevalidated = true + workStore.pathWasRevalidated = ActionDidRevalidateStaticAndDynamic } const allCookies = responseCookies.getAll() diff --git a/packages/next/src/server/web/spec-extension/revalidate.ts b/packages/next/src/server/web/spec-extension/revalidate.ts index aa051d7cf3097..93109275f21c2 100644 --- a/packages/next/src/server/web/spec-extension/revalidate.ts +++ b/packages/next/src/server/web/spec-extension/revalidate.ts @@ -11,6 +11,10 @@ import { workAsyncStorage } from '../../app-render/work-async-storage.external' import { workUnitAsyncStorage } from '../../app-render/work-unit-async-storage.external' import { DynamicServerError } from '../../../client/components/hooks-server-context' import { InvariantError } from '../../../shared/lib/invariant-error' +import { + ActionDidRevalidateDynamicOnly, + ActionDidRevalidateStaticAndDynamic as ActionDidRevalidate, +} from '../../../shared/lib/action-revalidation-kind' type CacheLifeConfig = { expire?: number @@ -73,8 +77,9 @@ export function refresh() { } if (workStore) { - // TODO: break this to it's own field - workStore.pathWasRevalidated = true + // The Server Action version of refresh() only revalidates the dynamic data + // on the client. It doesn't affect cached data. + workStore.pathWasRevalidated = ActionDidRevalidateDynamicOnly } } @@ -226,6 +231,6 @@ function revalidate( if (!profile || cacheLife?.expire === 0) { // TODO: only revalidate if the path matches - store.pathWasRevalidated = true + store.pathWasRevalidated = ActionDidRevalidate } } diff --git a/packages/next/src/shared/lib/action-revalidation-kind.ts b/packages/next/src/shared/lib/action-revalidation-kind.ts new file mode 100644 index 0000000000000..2cd640d8bcb9c --- /dev/null +++ b/packages/next/src/shared/lib/action-revalidation-kind.ts @@ -0,0 +1,5 @@ +export type ActionRevalidationKind = 0 | 1 | 2 + +export const ActionDidNotRevalidate = 0 +export const ActionDidRevalidateStaticAndDynamic = 1 +export const ActionDidRevalidateDynamicOnly = 2 diff --git a/test/e2e/app-dir/segment-cache/refresh/app/dashboard/client.tsx b/test/e2e/app-dir/segment-cache/refresh/app/dashboard/client.tsx index a6f3d06470b96..8a68b3cf11124 100644 --- a/test/e2e/app-dir/segment-cache/refresh/app/dashboard/client.tsx +++ b/test/e2e/app-dir/segment-cache/refresh/app/dashboard/client.tsx @@ -11,7 +11,7 @@ export function ClientRefreshButton() { router.refresh() }} > - Refresh + Client refresh ) } diff --git a/test/e2e/app-dir/segment-cache/refresh/app/dashboard/layout.tsx b/test/e2e/app-dir/segment-cache/refresh/app/dashboard/layout.tsx index 8e10b0f1054d6..31ff623530e73 100644 --- a/test/e2e/app-dir/segment-cache/refresh/app/dashboard/layout.tsx +++ b/test/e2e/app-dir/segment-cache/refresh/app/dashboard/layout.tsx @@ -1,5 +1,19 @@ import { LinkAccordion } from '../../components/link-accordion' import { ClientRefreshButton } from './client' +import { refresh } from 'next/cache' + +function ServerRefreshButton() { + return ( +
{ + 'use server' + refresh() + }} + > + +
+ ) +} export default function DashboardLayout({ navbar, @@ -14,6 +28,7 @@ export default function DashboardLayout({ {navbar}
+
  • @@ -22,6 +37,9 @@ export default function DashboardLayout({
  • Analytics
  • +
  • + Docs +
{main}
diff --git a/test/e2e/app-dir/segment-cache/refresh/app/docs/page.tsx b/test/e2e/app-dir/segment-cache/refresh/app/docs/page.tsx new file mode 100644 index 0000000000000..90f7877bc8cb3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/refresh/app/docs/page.tsx @@ -0,0 +1,3 @@ +export default function DocsPage() { + return
Static docs page
+} diff --git a/test/e2e/app-dir/segment-cache/refresh/segment-cache-refresh.test.ts b/test/e2e/app-dir/segment-cache/refresh/segment-cache-refresh.test.ts index aa78fbab36705..70ba86fd1af3c 100644 --- a/test/e2e/app-dir/segment-cache/refresh/segment-cache-refresh.test.ts +++ b/test/e2e/app-dir/segment-cache/refresh/segment-cache-refresh.test.ts @@ -11,7 +11,7 @@ describe('segment cache (refresh)', () => { return } - it('refreshes data inside reused default parallel route slots', async () => { + it('router.refresh() refreshes both cached and dynamic data', async () => { // Load the main Dashboard page. This will render the nav bar into the // @navbar slot. let page: Playwright.Page @@ -34,22 +34,108 @@ describe('segment cache (refresh)', () => { await link.click() }) - // Click the refresh button and confirm the navigation bar is re-rendered, - // even though it's not part of the Analytics page. + // Reveal the link to the docs page to prefetch it. await act( async () => { - const refreshButton = await browser.elementById('client-refresh-button') - await refreshButton.click() + const toggleDocsLink = await browser.elementByCss( + 'input[data-link-accordion="/docs"]' + ) + await toggleDocsLink.click() }, + { + includes: 'Static docs page', + } + ) + + // Click the client refresh button and confirm the navigation bar is + // re-rendered, even though it's not part of the Analytics page. + await act(async () => { + const refreshButton = await browser.elementById('client-refresh-button') + await refreshButton.click() + }, [ { includes: 'Navbar dynamic render counter', + }, + { + // router.refresh() also purges Cache Components from the client cache, + // so we must re-prefetch the docs page + includes: 'Static docs page', + }, + ]) + + const navbarDynamicRenderCounter = await browser.elementById( + 'navbar-dynamic-render-counter' + ) + // If this is still 0, then the nav bar was not successfully refreshed + expect(await navbarDynamicRenderCounter.textContent()).toBe('1') + }) + + it('Server Action refresh() refreshes dynamic data only, not cached', async () => { + // Load the main Dashboard page. This will render the nav bar into the + // @navbar slot. + let page: Playwright.Page + const browser = await next.browser('/dashboard', { + beforePageLoad(p: Playwright.Page) { + page = p + }, + }) + const act = createRouterAct(page) + + // Navigate to the Analytics page. The analytics page does not match the + // @navbar slot, so the client reuses the one that was rendered by the + // previous page. + await act(async () => { + const toggleAnalyticsLink = await browser.elementByCss( + 'input[data-link-accordion="/dashboard/analytics"]' + ) + await toggleAnalyticsLink.click() + const link = await browser.elementByCss('a[href="/dashboard/analytics"]') + await link.click() + }) + + // Reveal the link to the docs page to prefetch it. + await act( + async () => { + const toggleDocsLink = await browser.elementByCss( + 'input[data-link-accordion="/docs"]' + ) + await toggleDocsLink.click() + }, + { + includes: 'Static docs page', } ) + // Click the server refresh button and confirm the navigation bar is + // re-rendered, even though it's not part of the Analytics page. + await act(async () => { + const refreshButton = await browser.elementById('server-refresh-button') + await refreshButton.click() + }, [ + { + includes: 'Navbar dynamic render counter', + }, + { + // The server form of refresh() does _not_ purge Cache Components from + // the client cache, so we shouldn't need to re-prefetch the docs page. + includes: 'Static docs page', + block: 'reject', + }, + ]) + const navbarDynamicRenderCounter = await browser.elementById( 'navbar-dynamic-render-counter' ) // If this is still 0, then the nav bar was not successfully refreshed expect(await navbarDynamicRenderCounter.textContent()).toBe('1') + + // Confirm that navigating the the docs page does not require any + // additional requests. + await act(async () => { + const link = await browser.elementByCss('a[href="/docs"]') + await link.click() + const docsPage = await browser.elementById('docs-page') + expect(await docsPage.textContent()).toBe('Static docs page') + }, 'no-requests') }) })