diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index adaa686cbdd901..0fb127005bf5a6 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/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index fd8f18db21ebf9..43eedef8a4163e 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,48 +33,25 @@ 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 + scrollableSegments: Array | null separateRefreshUrls: Set | null } @@ -108,69 +87,39 @@ export type NavigationRequestAccumulation = { export function startPPRNavigation( navigatedAt: number, oldUrl: URL, - oldCacheNode: CacheNode, + oldCacheNode: CacheNode | null, oldRouterState: FlightRouterState, newRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, 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, + seedData, + seedHead, 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 +127,114 @@ export function startPPRRefresh( function updateCacheNodeOnNavigation( navigatedAt: number, oldUrl: URL, - oldCacheNode: CacheNode, + oldCacheNode: CacheNode | void, oldRouterState: FlightRouterState, newRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, didFindRootLayout: boolean, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, 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 seedDataChildren = seedData !== null ? seedData[1] : null 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 +247,101 @@ 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 (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] + 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 +362,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 +375,200 @@ 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 seedDataChild: CacheNodeSeedData | void | null = + seedDataChildren !== null ? seedDataChildren[parallelRouteKey] : null + let prefetchDataChild: CacheNodeSeedData | void | null = prefetchDataChildren !== null ? prefetchDataChildren[parallelRouteKey] : null - const newSegmentChild = newRouterStateChild[0] - const newSegmentPathChild = segmentPath.concat([ - parallelRouteKey, - newSegmentChild, - ]) - const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) - - const oldSegmentChild = - oldRouterStateChild !== undefined ? oldRouterStateChild[0] : undefined + let newSegmentChild = newRouterStateChild[0] + let seedHeadChild = seedHead + 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] + + // 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 + } + 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, + seedDataChild ?? null, + seedHeadChild, + 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( + shouldRefreshDynamicData ? undefined : 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, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, 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. +): NavigationTask { + // Same traversal as updateCacheNodeNavigation, but simpler. We switch to this + // path once we reach the part of the tree that was not in the previous route. + // We don't need to diff against the old tree, we just need to create a new + // one. We also don't need to worry about any refresh-related logic. + // + // For the most part, this is a subset of updateCacheNodeOnNavigation, so any + // change that happens in this function likely needs to be applied to that + // one, too. However there are some places where the behavior intentionally + // diverges, which is why we keep them separate. + + const newSegment = newRouterState[0] + const segmentPath = parentSegmentPath.concat([ + parentParallelRouteKey, + newSegment, + ]) + + 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( + 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. // - // Refer to isNavigatingToNewRootLayout for details. + // This only happens for new pages, not for refreshed pages. // - // 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 + // 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 = [] } - } - const isRefresh = false - const refreshUrl = null - return createCacheNodeOnNavigation( - isRefresh, - refreshUrl, - navigatedAt, - newRouterState, - existingCacheNode, - prefetchData, - possiblyPartialPrefetchHead, - isPrefetchHeadPartial, - segmentPath, - accumulation - ) -} - -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. - - // 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 + 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 +576,151 @@ 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 (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) { - // 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 + + for (let parallelRouteKey in newRouterStateChildren) { + const newRouterStateChild: FlightRouterState = + newRouterStateChildren[parallelRouteKey] + const oldSegmentMapChild = + 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] + : null + + const newSegmentChild = newRouterStateChild[0] + const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + + const oldCacheNodeChild = + oldSegmentMapChild !== undefined + ? oldSegmentMapChild.get(newSegmentKeyChild) + : undefined + + const taskChild = createCacheNodeOnNavigation( + navigatedAt, + newRouterStateChild, + oldCacheNodeChild, + shouldRefreshDynamicData, + seedDataChild ?? null, + seedHead, + 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( + shouldRefreshDynamicData ? undefined : oldSegmentMapChild ) - 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) - } + 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 +744,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 +805,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 +833,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 +950,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 +1063,7 @@ export function listenForDynamicRequest( } function attachServerResponseListener( - task: SPANavigationTask, + task: NavigationTask, requestPromise: Promise ): Promise { return requestPromise.then((result) => { @@ -1068,7 +1102,7 @@ function attachServerResponseListener( } function writeDynamicDataIntoPendingTask( - rootTask: SPANavigationTask, + rootTask: NavigationTask, segmentPath: FlightSegmentPath, serverRouterState: FlightRouterState, dynamicData: CacheNodeSeedData, @@ -1118,7 +1152,7 @@ function writeDynamicDataIntoPendingTask( } function finishTaskUsingDynamicDataPayload( - task: SPANavigationTask, + task: NavigationTask, serverRouterState: FlightRouterState, dynamicData: CacheNodeSeedData, dynamicHead: HeadData, @@ -1154,7 +1188,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 +1203,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 +1212,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 +1273,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 +1313,7 @@ function finishPendingCacheNode( } export function abortTask( - task: SPANavigationTask, + task: NavigationTask, error: any, debugInfo: Array | null ): void { @@ -1483,7 +1412,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 3dd2239d6205e3..b8df489e0da033 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) => @@ -168,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 9a96ee767c6245..5686260aeafdb3 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,19 +5,52 @@ 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, 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) + + // 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.tree, - state.nextUrl, + state.cache, + 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 a6d721d0f08058..6b9b29b5770a55 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,17 @@ 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' +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'] @@ -74,17 +77,18 @@ 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 + 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( @@ -158,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( @@ -200,6 +205,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,29 +221,32 @@ 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, + 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. @@ -248,8 +258,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 +275,44 @@ export function serverActionReducer( ? state.previousNextUrl || state.nextUrl : null - const navigatedAt = Date.now() - return fetchServerAction(state, nextUrl, action).then( async ({ + revalidationKind, 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 = - 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 (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 + // (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 + // If there was a revalidation, evict the entire prefetch cache. + // TODO: Evict only segments with matching tags and/or paths. + if (revalidationKind === ActionDidRevalidateStaticAndDynamic) { + revalidateEntireCache(state.nextUrl, state.tree) } - - // 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 (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 +322,125 @@ 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? + revalidationKind === ActionDidNotRevalidate && + // 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 = + 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. + 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 c941d2badcf3f3..d4d6a670d67371 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' @@ -30,21 +29,12 @@ 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 data: string } -type NoOpNavigationResult = { - tag: NavigationResultTag.NoOp - data: { - canonicalUrl: string - shouldScroll: boolean - } -} - type SuccessfulNavigationResult = { tag: NavigationResultTag.Success data: { @@ -60,15 +50,12 @@ type SuccessfulNavigationResult = { type AsyncNavigationResult = { tag: NavigationResultTag.Async - data: Promise< - MPANavigationResult | NoOpNavigationResult | SuccessfulNavigationResult - > + data: Promise } export type NavigationResult = | MPANavigationResult | SuccessfulNavigationResult - | NoOpNavigationResult | AsyncNavigationResult /** @@ -82,9 +69,10 @@ export type NavigationResult = export function navigate( url: URL, currentUrl: URL, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, nextUrl: string | null, + shouldRefreshDynamicData: boolean, shouldScroll: boolean, accumulation: { collectedDebugInfo?: Array } ): NavigationResult { @@ -109,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) @@ -149,6 +131,7 @@ export function navigate( isPrefetchHeadPartial, newCanonicalUrl, renderedSearch, + shouldRefreshDynamicData, shouldScroll, url.hash ) @@ -193,6 +176,7 @@ export function navigate( isPrefetchHeadPartial, newCanonicalUrl, newRenderedSearch, + shouldRefreshDynamicData, shouldScroll, url.hash ) @@ -214,6 +198,7 @@ export function navigate( isSamePageNavigation, currentCacheNode, currentFlightRouterState, + shouldRefreshDynamicData, shouldScroll, url.hash, collectedDebugInfo @@ -221,78 +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 -): SuccessfulNavigationResult | NoOpNavigationResult | 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. + seedFlightRouterState: FlightRouterState, + seedData: CacheNodeSeedData | null, + seedHead: HeadData | null, + shouldRefreshDynamicData: boolean, + nextUrl: string | null, + renderedSearch: string, + shouldScroll: boolean +): SuccessfulNavigationResult | MPANavigationResult { + // A version of navigate() that accepts the target route tree as an argument + // rather than reading it from the prefetch cache. + // + // 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 task = startPPRRefresh( + const isSamePageNavigation = url.href === currentUrl.href + const task = startPPRNavigation( now, + currentUrl, + currentCacheNode, currentFlightRouterState, - currentNextUrl, + seedFlightRouterState, + shouldRefreshDynamicData, + seedData, + seedHead, + null, + null, + false, + 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 - 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, - }, - } - } + 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.NoOp, - data: { - canonicalUrl: currentCanonicalUrl, - shouldScroll, - }, + tag: NavigationResultTag.MPA, + data: canonicalUrl, } } @@ -302,7 +280,7 @@ function navigateUsingPrefetchedRouteTree( currentUrl: URL, nextUrl: string | null, isSamePageNavigation: boolean, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, prefetchFlightRouterState: FlightRouterState, prefetchSeedData: CacheNodeSeedData | null, @@ -310,9 +288,10 @@ function navigateUsingPrefetchedRouteTree( isPrefetchHeadPartial: boolean, canonicalUrl: string, renderedSearch: string, + shouldRefreshDynamicData: boolean, 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. @@ -320,15 +299,20 @@ 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 seedData = null + const seedHead = null const task = startPPRNavigation( now, currentUrl, currentCacheNode, currentFlightRouterState, prefetchFlightRouterState, + shouldRefreshDynamicData, + seedData, + seedHead, prefetchSeedData, prefetchHead, isPrefetchHeadPartial, @@ -348,7 +332,6 @@ function navigateUsingPrefetchedRouteTree( } return navigationTaskToResult( task, - currentCacheNode, canonicalUrl, renderedSearch, accumulation.scrollableSegments, @@ -356,41 +339,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, + scrollableSegments: Array | null, 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, @@ -528,20 +496,31 @@ 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, currentUrl: URL, nextUrl: string | null, isSamePageNavigation: boolean, - currentCacheNode: CacheNode, + currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, + shouldRefreshDynamicData: boolean, 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. @@ -555,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 @@ -586,15 +567,17 @@ 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 task = startPPRNavigation( @@ -603,7 +586,10 @@ async function navigateDynamicallyWithNoPrefetch( currentCacheNode, currentFlightRouterState, prefetchFlightRouterState, - prefetchSeedData, + shouldRefreshDynamicData, + seedData, + seedHead, + prefetchData, prefetchHead, isPrefetchHeadPartial, isSamePageNavigation, @@ -633,7 +619,6 @@ async function navigateDynamicallyWithNoPrefetch( } return navigationTaskToResult( task, - currentCacheNode, createHrefFromUrl(canonicalUrl), renderedSearch, accumulation.scrollableSegments, @@ -641,14 +626,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/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index bb7f2cc4ecf002..b59d153f89d144 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/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 5d4802c2b6b8f4..b21bf2dc5be5a1 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/server/app-render/work-async-storage.external.ts b/packages/next/src/server/app-render/work-async-storage.external.ts index 76d0953d82b6ef..ca22465538d828 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 41e63198cf4154..2120d7e5ab3ee1 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 aa051d7cf3097a..93109275f21c2a 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 00000000000000..2cd640d8bcb9cd --- /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/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 46cb4d8b29b4cc..6d28eee58931b4 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 = diff --git a/packages/next/src/shared/lib/segment.ts b/packages/next/src/shared/lib/segment.ts index 4da4cb75deb907..8d8117f598e65d 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' 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 a6f3d06470b96d..8a68b3cf111244 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 8e10b0f1054d64..31ff623530e730 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 00000000000000..90f7877bc8cb31 --- /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 aa78fbab36705a..70ba86fd1af3c5 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') }) })