diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index 23c2dbd1b947c..35c4a03cefa6d 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -6,6 +6,8 @@ import { extractPathFromFlightRouterState } from './compute-changed-path' import type { AppRouterState } from './router-reducer-types' import { getFlightDataPartsFromPath } from '../../flight-data-helpers' import { createInitialCacheNodeForHydration } from './ppr-navigations' +import { convertRootFlightRouterStateToRouteTree } from '../segment-cache/cache' +import type { NormalizedSearch } from '../segment-cache/cache-key' export interface InitialRouterStateParameters { navigatedAt: number @@ -44,14 +46,41 @@ export function createInitialRouterState({ createHrefFromUrl(location) : initialCanonicalUrl + // Conver the initial FlightRouterState into the RouteTree type. + // NOTE: The metadataVaryPath isn't used for anything currently because the + // head is embedded into the CacheNode tree, but eventually we'll lift it out + // and store it on the top-level state object. + const acc = { metadataVaryPath: null } + const initialRouteTree = convertRootFlightRouterStateToRouteTree( + initialTree, + initialRenderedSearch as NormalizedSearch, + acc + ) + const initialTask = createInitialCacheNodeForHydration( + navigatedAt, + initialRouteTree, + initialSeedData, + initialHead + ) + + // NOTE: We intentionally don't check if any data needs to be fetched from the + // server. We assume the initial hydration payload is sufficient to render + // the page. + // + // The completeness of the initial data is an important property that we rely + // on as a last-ditch mechanism for recovering the app; we must always be able + // to reload a fresh HTML document to get to a consistent state. + // + // In the future, there may be cases where the server intentionally sends + // partial data and expects the client to fill in the rest, in which case this + // logic may change. (There already is a similar case where the server sends + // _no_ hydration data in the HTML document at all, and the client fetches it + // separately, but that's different because we still end up hydrating with a + // complete tree.) + const initialState = { - tree: initialTree, - cache: createInitialCacheNodeForHydration( - navigatedAt, - initialTree, - initialSeedData, - initialHead - ), + tree: initialTask.route, + cache: initialTask.node, pushRef: { pendingPush: false, mpaNavigation: false, diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index 6b845d1c9f307..502dc7e37b098 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -35,10 +35,7 @@ import { } from '../../flight-data-helpers' import { getAppBuildId } from '../../app-build-id' import { setCacheBustingSearchParam } from './set-cache-busting-search-param' -import { - getRenderedSearch, - urlToUrlWithoutFlightMarker, -} from '../../route-params' +import { urlToUrlWithoutFlightMarker } from '../../route-params' import type { NormalizedSearch } from '../segment-cache/cache-key' import { getDeploymentId } from '../../../shared/lib/deployment-id' @@ -256,7 +253,14 @@ export async function fetchServerResponse( return { flightData: normalizedFlightData, canonicalUrl: canonicalUrl, - renderedSearch: getRenderedSearch(res), + // TODO: We should be able to read this from the rewrite header, not the + // Flight response. Theoretically they should always agree, but there are + // currently some cases where it's incorrect for interception routes. We + // can always trust the value in the response body. However, per-segment + // prefetch responses don't embed the value in the body; they rely on the + // header alone. So we need to investigate why the header is sometimes + // wrong for interception routes. + renderedSearch: flightResponse.q as NormalizedSearch, couldBeIntercepted: interception, prerendered: flightResponse.S, postponed, diff --git a/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.test.ts b/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.test.ts deleted file mode 100644 index ac0d2cabf4e14..0000000000000 --- a/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { FlightRouterState } from '../../../shared/lib/app-router-types' -import { isNavigatingToNewRootLayout } from './is-navigating-to-new-root-layout' - -describe('isNavigatingToNewRootLayout', () => { - it('should return false if there is no new root layout', () => { - const getInitialRouterStateTree = (): FlightRouterState => [ - '', - { - children: [ - 'linking', - { - children: ['', {}], - }, - ], - }, - undefined, - undefined, - true, - ] - const initialRouterStateTree = getInitialRouterStateTree() - const getNewRouterStateTree = (): FlightRouterState => { - return [ - '', - { - children: [ - 'link-hard-push', - { - children: [ - ['id', '456', 'd'], - { - children: ['', {}], - }, - ], - }, - ], - }, - null, - null, - true, - ] - } - const newRouterState = getNewRouterStateTree() - - const result = isNavigatingToNewRootLayout( - newRouterState, - initialRouterStateTree - ) - - expect(result).toBe(false) - }) - - it('should return true if there is a mismatch between the root layouts', () => { - const getInitialRouterStateTree = (): FlightRouterState => [ - '', - { - children: [ - 'linking', - { - children: ['', {}], - }, - undefined, - undefined, - // Root layout at `linking` level. - true, - ], - }, - ] - const initialRouterStateTree = getInitialRouterStateTree() - const getNewRouterStateTree = (): FlightRouterState => { - return [ - '', - { - children: [ - 'link-hard-push', - { - children: [ - ['id', '456', 'd'], - { - children: ['', {}], - }, - ], - }, - null, - null, - // Root layout at `link-hard-push` level. - true, - ], - }, - ] - } - const newRouterState = getNewRouterStateTree() - - const result = isNavigatingToNewRootLayout( - newRouterState, - initialRouterStateTree - ) - - expect(result).toBe(true) - }) -}) diff --git a/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.ts b/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.ts index c0048461afa42..96c73e6a85237 100644 --- a/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.ts +++ b/packages/next/src/client/components/router-reducer/is-navigating-to-new-root-layout.ts @@ -1,12 +1,13 @@ import type { FlightRouterState } from '../../../shared/lib/app-router-types' +import type { RouteTree } from '../segment-cache/cache' export function isNavigatingToNewRootLayout( currentTree: FlightRouterState, - nextTree: FlightRouterState + nextTree: RouteTree ): boolean { // Compare segments const currentTreeSegment = currentTree[0] - const nextTreeSegment = nextTree[0] + const nextTreeSegment = nextTree.segment // If any segment is different before we find the root layout, the root layout has changed. // E.g. /same/(group1)/layout.js -> /same/(group2)/layout.js @@ -27,17 +28,26 @@ export function isNavigatingToNewRootLayout( // Current tree root layout found if (currentTree[4]) { // If the next tree doesn't have the root layout flag, it must have changed. - return !nextTree[4] + return !nextTree.isRootLayout } // Current tree didn't have its root layout here, must have changed. - if (nextTree[4]) { + if (nextTree.isRootLayout) { return true } - // We can't assume it's `parallelRoutes.children` here in case the root layout is `app/@something/layout.js` - // But it's not possible to be more than one parallelRoutes before the root layout is found - // TODO-APP: change to traverse all parallel routes - const currentTreeChild = Object.values(currentTree[1])[0] - const nextTreeChild = Object.values(nextTree[1])[0] - if (!currentTreeChild || !nextTreeChild) return true - return isNavigatingToNewRootLayout(currentTreeChild, nextTreeChild) + + const slots = nextTree.slots + const currentTreeChildren = currentTree[1] + if (slots !== null) { + for (const slot in slots) { + const nextTreeChild = slots[slot] + const currentTreeChild = currentTreeChildren[slot] + if ( + currentTreeChild === undefined || + isNavigatingToNewRootLayout(currentTreeChild, nextTreeChild) + ) { + return true + } + } + } + return true } 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 680fb13766ef7..6a4a89fcab776 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -2,6 +2,7 @@ import type { CacheNodeSeedData, FlightRouterState, FlightSegmentPath, + Segment, } from '../../../shared/lib/app-router-types' import type { ChildSegmentMap, @@ -12,6 +13,7 @@ import type { LoadingModuleData, } from '../../../shared/lib/app-router-types' import { + PAGE_SEGMENT_KEY, DEFAULT_SEGMENT_KEY, NOT_FOUND_SEGMENT_KEY, } from '../../../shared/lib/segment' @@ -30,6 +32,13 @@ import { convertServerPatchToFullTree, type NavigationSeed, } from '../segment-cache/navigation' +import { + type RouteTree, + type RefreshState, + convertRootFlightRouterStateToRouteTree, +} from '../segment-cache/cache' +import type { NormalizedSearch } from '../segment-cache/cache-key' +import { getRenderedSearchFromVaryPath } from '../segment-cache/vary-path' // This is yet another tree type that is used to track pending promises that // need to be fulfilled once the dynamic data is received. The terminal nodes of @@ -51,7 +60,7 @@ export type NavigationTask = { // 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 + refreshState: RefreshState | null children: Map | null } @@ -99,10 +108,10 @@ const noop = () => {} export function createInitialCacheNodeForHydration( navigatedAt: number, - initialTree: FlightRouterState, + initialTree: RouteTree, seedData: CacheNodeSeedData | null, seedHead: HeadData -): CacheNode { +): NavigationTask { // Create the initial cache node tree, using the data embedded into the // HTML document. const accumulation: NavigationRequestAccumulation = { @@ -124,23 +133,7 @@ export function createInitialCacheNodeForHydration( false, accumulation ) - - // NOTE: We intentionally don't check if any data needs to be fetched from the - // server. We assume the initial hydration payload is sufficient to render - // the page. - // - // The completeness of the initial data is an important property that we rely - // on as a last-ditch mechanism for recovering the app; we must always be able - // to reload a fresh HTML document to get to a consistent state. - // - // In the future, there may be cases where the server intentionally sends - // partial data and expects the client to fill in the rest, in which case this - // logic may change. (There already is a similar case where the server sends - // _no_ hydration data in the HTML document at all, and the client fetches it - // separately, but that's different because we still end up hydrating with a - // complete tree.) - - return task.node + return task } // Creates a new Cache Node tree (i.e. copy-on-write) that represents the @@ -175,9 +168,10 @@ export function createInitialCacheNodeForHydration( export function startPPRNavigation( navigatedAt: number, oldUrl: URL, + oldRenderedSearch: string, oldCacheNode: CacheNode | null, oldRouterState: FlightRouterState, - newRouterState: FlightRouterState, + newRouteTree: RouteTree, freshness: FreshnessPolicy, seedData: CacheNodeSeedData | null, seedHead: HeadData | null, @@ -189,13 +183,17 @@ export function startPPRNavigation( ): NavigationTask | null { const didFindRootLayout = false const parentNeedsDynamicRequest = false - const parentRefreshUrl = null + const parentRefreshState = null + const oldRootRefreshState: RefreshState = { + canonicalUrl: createHrefFromUrl(oldUrl), + renderedSearch: oldRenderedSearch as NormalizedSearch, + } return updateCacheNodeOnNavigation( navigatedAt, oldUrl, oldCacheNode !== null ? oldCacheNode : undefined, oldRouterState, - newRouterState, + newRouteTree, freshness, didFindRootLayout, seedData, @@ -207,7 +205,8 @@ export function startPPRNavigation( null, null, parentNeedsDynamicRequest, - parentRefreshUrl, + oldRootRefreshState, + parentRefreshState, accumulation ) } @@ -217,7 +216,7 @@ function updateCacheNodeOnNavigation( oldUrl: URL, oldCacheNode: CacheNode | void, oldRouterState: FlightRouterState, - newRouterState: FlightRouterState, + newRouteTree: RouteTree, freshness: FreshnessPolicy, didFindRootLayout: boolean, seedData: CacheNodeSeedData | null, @@ -229,12 +228,13 @@ function updateCacheNodeOnNavigation( parentSegmentPath: FlightSegmentPath | null, parentParallelRouteKey: string | null, parentNeedsDynamicRequest: boolean, - parentRefreshUrl: string | null, + oldRootRefreshState: RefreshState, + parentRefreshState: RefreshState | null, accumulation: NavigationRequestAccumulation ): NavigationTask | null { // Check if this segment matches the one in the previous route. const oldSegment = oldRouterState[0] - const newSegment = newRouterState[0] + const newSegment = createSegmentFromRouteTree(newRouteTree) if (!matchSegment(newSegment, oldSegment)) { // This segment does not match the previous route. We're now entering the // new part of the target route. Switch to the "create" path. @@ -261,7 +261,7 @@ function updateCacheNodeOnNavigation( // 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)) || + isNavigatingToNewRootLayout(oldRouterState, newRouteTree)) || // The global Not Found route (app/global-not-found.tsx) is a special // case, because it acts like a root layout, but in the router tree, it // is rendered in the same position as app/layout.tsx. @@ -284,7 +284,7 @@ function updateCacheNodeOnNavigation( } return createCacheNodeOnNavigation( navigatedAt, - newRouterState, + newRouteTree, oldCacheNode, freshness, seedData, @@ -310,7 +310,7 @@ function updateCacheNodeOnNavigation( : // NOTE: The root segment is intentionally omitted from the segment path [] - const newRouterStateChildren = newRouterState[1] + const newSlots = newRouteTree.slots const oldRouterStateChildren = oldRouterState[1] const seedDataChildren = seedData !== null ? seedData[1] : null const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null @@ -318,8 +318,7 @@ function updateCacheNodeOnNavigation( // 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 childDidFindRootLayout = didFindRootLayout || newRouteTree.isRootLayout const oldParallelRoutes = oldCacheNode !== undefined ? oldCacheNode.parallelRoutes : undefined @@ -364,7 +363,7 @@ function updateCacheNodeOnNavigation( // 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 + const isLeafSegment = newSlots === null // Get the data for this segment. Since it was part of the previous route, // usually we just clone the data from the old CacheNode. However, during a @@ -441,20 +440,20 @@ function updateCacheNodeOnNavigation( // 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' + const maybeRefreshState = newRouteTree.refreshState + const refreshState = + maybeRefreshState !== undefined && maybeRefreshState !== null ? // This segment is not present in the current route. Track its // refresh URL as we continue traversing the tree. - href + maybeRefreshState : // Inherit the refresh URL from the parent. - parentRefreshUrl + parentRefreshState // If this segment itself needs to fetch new data from the server, then by // definition it is being refreshed. Track its refresh URL so we know which // URL to request the data from. - if (needsDynamicRequest && refreshUrl !== null) { - accumulateRefreshUrl(accumulation, refreshUrl) + if (needsDynamicRequest && refreshState !== null) { + accumulateRefreshUrl(accumulation, refreshState) } // As we diff the trees, we may sometimes modify (copy-on-write, not mutate) @@ -488,144 +487,153 @@ function updateCacheNodeOnNavigation( [parallelRouteKey: string]: FlightRouterState } = {} - for (let parallelRouteKey in newRouterStateChildren) { - let newRouterStateChild: FlightRouterState = - newRouterStateChildren[parallelRouteKey] - const oldRouterStateChild: FlightRouterState | void = - oldRouterStateChildren[parallelRouteKey] - if (oldRouterStateChild === undefined) { - // This should never happen, but if it does, it suggests a malformed - // server response. Trigger a full-page navigation. - return null - } - 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 - - let newSegmentChild = newRouterStateChild[0] - let seedHeadChild = seedHead - let prefetchHeadChild = prefetchHead - let isPrefetchHeadPartialChild = isPrefetchHeadPartial - if ( - // Skip this branch during a history traversal. We restore the tree that - // was stashed in the history entry as-is. - freshness !== FreshnessPolicy.HistoryTraversal && - newSegmentChild === DEFAULT_SEGMENT_KEY - ) { - // 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 + if (newSlots !== null) { + for (let parallelRouteKey in newSlots) { + let newRouteTreeChild: RouteTree = newSlots[parallelRouteKey] + const oldRouterStateChild: FlightRouterState | void = + oldRouterStateChildren[parallelRouteKey] + if (oldRouterStateChild === undefined) { + // This should never happen, but if it does, it suggests a malformed + // server response. Trigger a full-page navigation. + return null + } + 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 taskChild = updateCacheNodeOnNavigation( - navigatedAt, - oldUrl, - oldCacheNodeChild, - oldRouterStateChild, - newRouterStateChild, - freshness, - childDidFindRootLayout, - seedDataChild ?? null, - seedHeadChild, - prefetchDataChild ?? null, - prefetchHeadChild, - isPrefetchHeadPartialChild, - isSamePageNavigation, - segmentPath, - parallelRouteKey, - parentNeedsDynamicRequest || needsDynamicRequest, - refreshUrl, - accumulation - ) + let newSegmentChild = createSegmentFromRouteTree(newRouteTreeChild) + let seedHeadChild = seedHead + let prefetchHeadChild = prefetchHead + let isPrefetchHeadPartialChild = isPrefetchHeadPartial + if ( + // Skip this branch during a history traversal. We restore the tree that + // was stashed in the history entry as-is. + freshness !== FreshnessPolicy.HistoryTraversal && + newSegmentChild === DEFAULT_SEGMENT_KEY + ) { + // This is a "default" segment. These are never sent by the server during + // a soft navigation; instead, the client reuses whatever segment was + // already active in that slot on the previous route. + newRouteTreeChild = reuseActiveSegmentInDefaultSlot( + oldRootRefreshState, + oldRouterStateChild + ) + newSegmentChild = createSegmentFromRouteTree(newRouteTreeChild) + + // Since we're switching to a different route tree, these are no + // longer valid, because they correspond to the outer tree. + seedDataChild = null + seedHeadChild = null + prefetchDataChild = null + prefetchHeadChild = null + isPrefetchHeadPartialChild = false + } - 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 - } + const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + const oldCacheNodeChild = + oldSegmentMapChild !== undefined + ? oldSegmentMapChild.get(newSegmentKeyChild) + : undefined - // 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( - shouldDropSiblingCaches ? undefined : oldSegmentMapChild + const taskChild = updateCacheNodeOnNavigation( + navigatedAt, + oldUrl, + oldCacheNodeChild, + oldRouterStateChild, + newRouteTreeChild, + freshness, + childDidFindRootLayout, + seedDataChild ?? null, + seedHeadChild, + prefetchDataChild ?? null, + prefetchHeadChild, + isPrefetchHeadPartialChild, + isSamePageNavigation, + segmentPath, + parallelRouteKey, + parentNeedsDynamicRequest || needsDynamicRequest, + oldRootRefreshState, + refreshState, + accumulation ) - 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. - childNeedsDynamicRequest = true - dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild - } else { - dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute + if (taskChild === null) { + // One of the child tasks discovered a change to the root layout. + // Immediately unwind from this recursive traversal. This will trigger a + // full-page navigation. + return null + } + + // Recursively propagate up the child tasks. + if (taskChildren === null) { + taskChildren = new Map() + } + taskChildren.set(parallelRouteKey, taskChild) + const newCacheNodeChild = taskChild.node + if (newCacheNodeChild !== null) { + const newSegmentMapChild: ChildSegmentMap = new Map( + shouldDropSiblingCaches ? 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. + childNeedsDynamicRequest = true + dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild + } else { + dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute + } } } + const newFlightRouterState: FlightRouterState = [ + createSegmentFromRouteTree(newRouteTree), + patchedRouterStateChildren, + refreshState !== null + ? [refreshState.canonicalUrl, refreshState.renderedSearch] + : null, + null, + newRouteTree.isRootLayout, + ] + return { status: needsDynamicRequest ? NavigationTaskStatus.Pending : NavigationTaskStatus.Fulfilled, - route: patchRouterStateWithNewChildren( - newRouterState, - patchedRouterStateChildren - ), + route: newFlightRouterState, node: newCacheNode, dynamicRequestTree: createDynamicRequestTree( - newRouterState, + newFlightRouterState, dynamicRequestTreeChildren, needsDynamicRequest, childNeedsDynamicRequest, parentNeedsDynamicRequest ), - refreshUrl, + refreshState, children: taskChildren, } } function createCacheNodeOnNavigation( navigatedAt: number, - newRouterState: FlightRouterState, + newRouteTree: RouteTree, oldCacheNode: CacheNode | void, freshness: FreshnessPolicy, seedData: CacheNodeSeedData | null, @@ -648,14 +656,14 @@ function createCacheNodeOnNavigation( // one, too. However there are some places where the behavior intentionally // diverges, which is why we keep them separate. - const newSegment = newRouterState[0] + const newSegment = createSegmentFromRouteTree(newRouteTree) 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 newSlots = newRouteTree.slots const prefetchDataChildren = prefetchData !== null ? prefetchData[1] : null const seedDataChildren = seedData !== null ? seedData[1] : null const oldParallelRoutes = @@ -729,7 +737,7 @@ function createCacheNodeOnNavigation( const newParallelRoutes = new Map( shouldDropSiblingCaches ? undefined : oldParallelRoutes ) - const isLeafSegment = Object.keys(newRouterStateChildren).length === 0 + const isLeafSegment = newSlots === null if (isLeafSegment) { // The segment path of every leaf segment (i.e. page) is collected into @@ -842,80 +850,86 @@ function createCacheNodeOnNavigation( [parallelRouteKey: string]: FlightRouterState } = {} - 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, - freshness, - seedDataChild ?? null, - seedHead, - prefetchDataChild ?? null, - prefetchHead, - isPrefetchHeadPartial, - segmentPath, - parallelRouteKey, - parentNeedsDynamicRequest || needsDynamicRequest, - accumulation - ) + if (newSlots !== null) { + for (let parallelRouteKey in newSlots) { + const newRouteTreeChild: RouteTree = newSlots[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 - if (taskChildren === null) { - taskChildren = new Map() - } - taskChildren.set(parallelRouteKey, taskChild) - const newCacheNodeChild = taskChild.node - if (newCacheNodeChild !== null) { - const newSegmentMapChild: ChildSegmentMap = new Map( - shouldDropSiblingCaches ? undefined : oldSegmentMapChild + const newSegmentChild = createSegmentFromRouteTree(newRouteTreeChild) + const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + + const oldCacheNodeChild = + oldSegmentMapChild !== undefined + ? oldSegmentMapChild.get(newSegmentKeyChild) + : undefined + + const taskChild = createCacheNodeOnNavigation( + navigatedAt, + newRouteTreeChild, + oldCacheNodeChild, + freshness, + seedDataChild ?? null, + seedHead, + prefetchDataChild ?? null, + prefetchHead, + isPrefetchHeadPartial, + segmentPath, + parallelRouteKey, + parentNeedsDynamicRequest || needsDynamicRequest, + accumulation ) - newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) - newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) - } - const taskChildRoute = taskChild.route - patchedRouterStateChildren[parallelRouteKey] = taskChildRoute + if (taskChildren === null) { + taskChildren = new Map() + } + taskChildren.set(parallelRouteKey, taskChild) + const newCacheNodeChild = taskChild.node + if (newCacheNodeChild !== null) { + const newSegmentMapChild: ChildSegmentMap = new Map( + shouldDropSiblingCaches ? undefined : oldSegmentMapChild + ) + newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild) + newParallelRoutes.set(parallelRouteKey, newSegmentMapChild) + } + + const taskChildRoute = taskChild.route + patchedRouterStateChildren[parallelRouteKey] = taskChildRoute - const dynamicRequestTreeChild = taskChild.dynamicRequestTree - if (dynamicRequestTreeChild !== null) { - childNeedsDynamicRequest = true - dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild - } else { - dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute + const dynamicRequestTreeChild = taskChild.dynamicRequestTree + if (dynamicRequestTreeChild !== null) { + childNeedsDynamicRequest = true + dynamicRequestTreeChildren[parallelRouteKey] = dynamicRequestTreeChild + } else { + dynamicRequestTreeChildren[parallelRouteKey] = taskChildRoute + } } } + const newFlightRouterState: FlightRouterState = [ + newSegment, + patchedRouterStateChildren, + null, + null, + newRouteTree.isRootLayout, + ] + return { status: needsDynamicRequest ? NavigationTaskStatus.Pending : NavigationTaskStatus.Fulfilled, - route: patchRouterStateWithNewChildren( - newRouterState, - patchedRouterStateChildren - ), + route: newFlightRouterState, node: newCacheNode, dynamicRequestTree: createDynamicRequestTree( - newRouterState, + newFlightRouterState, dynamicRequestTreeChildren, needsDynamicRequest, childNeedsDynamicRequest, @@ -923,11 +937,40 @@ function createCacheNodeOnNavigation( ), // This route is not part of the current tree, so there's no reason to // track the refresh URL. - refreshUrl: null, + refreshState: null, children: taskChildren, } } +function createSegmentFromRouteTree(newRouteTree: RouteTree): Segment { + if (newRouteTree.isPage) { + // In a dynamic server response, the server embeds the search params into + // the segment key, but in a static one it's omitted. The client handles + // this inconsistency by adding the search params back right at the end. + // + // TODO: The only thing this is used for is to create a cache key for + // ChildSegmentMap. But we already track the `renderedSearch` everywhere as + // part of the varyPath. The plan is get rid of ChildSegmentMap and + // store the page data in a CacheMap using the varyPath, like we do + // for prefetches. Then we can remove it from the segment key. + // + // As an incremental step, we can grab the search params from the varyPath. + const renderedSearch = getRenderedSearchFromVaryPath(newRouteTree.varyPath) + if (renderedSearch === null) { + return PAGE_SEGMENT_KEY + } + // This is based on equivalent logic in addSearchParamsIfPageSegment, used + // on the server. + const stringifiedQuery = JSON.stringify( + Object.fromEntries(new URLSearchParams(renderedSearch)) + ) + return stringifiedQuery !== '{}' + ? PAGE_SEGMENT_KEY + '?' + stringifiedQuery + : PAGE_SEGMENT_KEY + } + return newRouteTree.segment +} + function patchRouterStateWithNewChildren( baseRouterState: FlightRouterState, newChildren: { [parallelRouteKey: string]: FlightRouterState } @@ -986,7 +1029,7 @@ function createDynamicRequestTree( function accumulateRefreshUrl( accumulation: NavigationRequestAccumulation, - refreshUrl: string + refreshState: RefreshState ) { // This is a refresh navigation, and we're inside a "default" slot that's // not part of the current route; it was reused from an older route. In @@ -998,6 +1041,7 @@ function accumulateRefreshUrl( // we don't do it immediately here is so we can deduplicate multiple // instances of the same URL into a single request. See // listenForDynamicRequest for more details. + const refreshUrl = refreshState.canonicalUrl const separateRefreshUrls = accumulation.separateRefreshUrls if (separateRefreshUrls === null) { accumulation.separateRefreshUrls = new Set([refreshUrl]) @@ -1007,37 +1051,42 @@ function accumulateRefreshUrl( } function reuseActiveSegmentInDefaultSlot( - oldUrl: URL, + oldRootRefreshState: RefreshState, oldRouterState: FlightRouterState -): FlightRouterState { +): RouteTree { // This is a "default" segment. These are never sent by the server during a // soft navigation; instead, the client reuses whatever segment was already // active in that slot on the previous route. This means if we later need to // refresh the segment, it will have to be refetched from the previous route's // URL. We store it in the Flight Router State. - // - // TODO: We also mark the segment with a "refresh" marker but I think we can - // get rid of that eventually by making sure we only add URLs to page segments - // that are reused. Then the presence of the URL alone is enough. - let reusedRouterState - const oldRefreshMarker = oldRouterState[3] - if (oldRefreshMarker === 'refresh') { + let reusedUrl: string + let reusedRenderedSearch: NormalizedSearch + const oldRefreshState = oldRouterState[2] + if (oldRefreshState !== undefined && oldRefreshState !== null) { // This segment was already reused from an even older route. Keep its - // existing URL and refresh marker. - reusedRouterState = oldRouterState + // existing URL and refresh state. + reusedUrl = oldRefreshState[0] + reusedRenderedSearch = oldRefreshState[1] as NormalizedSearch } else { - // This segment was not previously reused, and it's not on the new route. - // So it must have been delivered in the old route. - reusedRouterState = patchRouterStateWithNewChildren( - oldRouterState, - oldRouterState[1] - ) - reusedRouterState[2] = createHrefFromUrl(oldUrl) - reusedRouterState[3] = 'refresh' + // Since this route didn't already have a refresh state, it must have been + // reachable from the root of the old route. So we use the refresh state + // that represents the old route. + reusedUrl = oldRootRefreshState.canonicalUrl + reusedRenderedSearch = oldRootRefreshState.renderedSearch } - return reusedRouterState + const acc = { metadataVaryPath: null } + const reusedRouteTree = convertRootFlightRouterStateToRouteTree( + oldRouterState, + reusedRenderedSearch, + acc + ) + reusedRouteTree.refreshState = { + canonicalUrl: reusedUrl, + renderedSearch: reusedRenderedSearch, + } + return reusedRouteTree } function reuseDynamicCacheNode( @@ -1452,7 +1501,7 @@ async function fetchMissingDynamicData( ) const didReceiveUnknownParallelRoute = writeDynamicDataIntoNavigationTask( task, - seed.tree, + seed.routeTree, seed.data, seed.head, result.debugInfo @@ -1478,7 +1527,7 @@ async function fetchMissingDynamicData( function writeDynamicDataIntoNavigationTask( task: NavigationTask, - serverRouterState: FlightRouterState, + serverRouteTree: RouteTree, dynamicData: CacheNodeSeedData | null, dynamicHead: HeadData, debugInfo: Array | null @@ -1489,7 +1538,7 @@ function writeDynamicDataIntoNavigationTask( } const taskChildren = task.children - const serverChildren = serverRouterState[1] + const serverChildren = serverRouteTree.slots const dynamicDataChildren = dynamicData !== null ? dynamicData[1] : null // Detect whether the server sends a parallel route slot that the client @@ -1497,52 +1546,59 @@ function writeDynamicDataIntoNavigationTask( let didReceiveUnknownParallelRoute = false if (taskChildren !== null) { - for (const parallelRouteKey in serverChildren) { - const serverRouterStateChild: FlightRouterState = - serverChildren[parallelRouteKey] - const dynamicDataChild: CacheNodeSeedData | null | void = - dynamicDataChildren !== null - ? dynamicDataChildren[parallelRouteKey] - : null - - const taskChild = taskChildren.get(parallelRouteKey) - if (taskChild === undefined) { - // The server sent a child segment that the client doesn't know about. - // - // When we receive an unknown parallel route, we must consider it a - // mismatch. This is unlike the case where the segment itself - // mismatches, because multiple routes can be active simultaneously. - // But a given layout should never have a mismatching set of - // child slots. - // - // Theoretically, this should only happen in development during an HMR - // refresh, because the set of parallel routes for a layout does not - // change over the lifetime of a build/deployment. In production, we - // should have already mismatched on either the build id or the segment - // path. But as an extra precaution, we validate in prod, too. - didReceiveUnknownParallelRoute = true - } else { - const taskSegment = taskChild.route[0] - if ( - matchSegment(serverRouterStateChild[0], taskSegment) && - dynamicDataChild !== null && - dynamicDataChild !== undefined - ) { - // Found a match for this task. Keep traversing down the task tree. - const childDidReceiveUnknownParallelRoute = - writeDynamicDataIntoNavigationTask( - taskChild, - serverRouterStateChild, - dynamicDataChild, - dynamicHead, - debugInfo - ) - if (childDidReceiveUnknownParallelRoute) { - didReceiveUnknownParallelRoute = true + if (serverChildren !== null) { + for (const parallelRouteKey in serverChildren) { + const serverRouteTreeChild: RouteTree = serverChildren[parallelRouteKey] + const dynamicDataChild: CacheNodeSeedData | null | void = + dynamicDataChildren !== null + ? dynamicDataChildren[parallelRouteKey] + : null + + const taskChild = taskChildren.get(parallelRouteKey) + if (taskChild === undefined) { + // The server sent a child segment that the client doesn't know about. + // + // When we receive an unknown parallel route, we must consider it a + // mismatch. This is unlike the case where the segment itself + // mismatches, because multiple routes can be active simultaneously. + // But a given layout should never have a mismatching set of + // child slots. + // + // Theoretically, this should only happen in development during an HMR + // refresh, because the set of parallel routes for a layout does not + // change over the lifetime of a build/deployment. In production, we + // should have already mismatched on either the build id or the segment + // path. But as an extra precaution, we validate in prod, too. + didReceiveUnknownParallelRoute = true + } else { + const taskSegment = taskChild.route[0] + const serverSegment = createSegmentFromRouteTree(serverRouteTreeChild) + if ( + matchSegment(serverSegment, taskSegment) && + dynamicDataChild !== null && + dynamicDataChild !== undefined + ) { + // Found a match for this task. Keep traversing down the task tree. + const childDidReceiveUnknownParallelRoute = + writeDynamicDataIntoNavigationTask( + taskChild, + serverRouteTreeChild, + dynamicDataChild, + dynamicHead, + debugInfo + ) + if (childDidReceiveUnknownParallelRoute) { + didReceiveUnknownParallelRoute = true + } } } } } + } else { + if (serverChildren !== null) { + // The server sent a child segment that the client doesn't know about. + didReceiveUnknownParallelRoute = true + } } return didReceiveUnknownParallelRoute @@ -1631,7 +1687,7 @@ function abortRemainingPendingTasks( // // When this happens, we treat this the same as a refresh(). The entire // tree will be re-rendered from the root. - if (task.refreshUrl === null) { + if (task.refreshState === null) { // Trigger a "soft" refresh. Essentially the same as calling `refresh()` // in a Server Action. exitStatus = NavigationTaskExitStatus.SoftRetry 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 9484c7702df10..f2f4742bb8e72 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 @@ -162,9 +162,11 @@ export function navigateReducer( // implementation. Eventually we'll rewrite the router reducer to a // state machine. const currentUrl = new URL(state.canonicalUrl, location.origin) + const currentRenderedSearch = state.renderedSearch const result = navigateUsingSegmentCache( url, currentUrl, + currentRenderedSearch, state.cache, state.tree, state.nextUrl, 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 ada23b8920b6b..11f69fd4e2b06 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 @@ -4,7 +4,10 @@ import type { ReducerState, } from '../router-reducer-types' import { handleNavigationResult } from './navigate-reducer' -import { navigateToSeededRoute } from '../../segment-cache/navigation' +import { + convertServerPatchToFullTree, + navigateToSeededRoute, +} from '../../segment-cache/navigation' import { revalidateEntireCache } from '../../segment-cache/cache' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' import { FreshnessPolicy } from '../ppr-navigations' @@ -36,23 +39,28 @@ export function refreshDynamicData( // existing dynamic data (including in shared layouts) is re-fetched. const currentCanonicalUrl = state.canonicalUrl const currentUrl = new URL(currentCanonicalUrl, location.origin) + const currentRenderedSearch = state.renderedSearch const currentFlightRouterState = state.tree const shouldScroll = true - const navigationSeed = { - tree: state.tree, - renderedSearch: state.renderedSearch, - data: null, - head: null, - } + // Create a NavigationSeed from the current FlightRouterState. + // TODO: Eventually we will store this type directly on the state object + // instead of reconstructing it on demand. Part of a larger series of + // refactors to unify the various tree types that the client deals with. + const refreshSeed = convertServerPatchToFullTree( + currentFlightRouterState, + null, + currentRenderedSearch + ) const now = Date.now() const result = navigateToSeededRoute( now, currentUrl, currentCanonicalUrl, - navigationSeed, + refreshSeed, currentUrl, + currentRenderedSearch, state.cache, currentFlightRouterState, freshnessPolicy, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index 9484feba57bee..c2c1372c171bd 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -14,6 +14,7 @@ import { import type { FlightRouterState } from '../../../../shared/lib/app-router-types' import { handleExternalUrl } from './navigate-reducer' import type { Mutable } from '../router-reducer-types' +import { convertServerPatchToFullTree } from '../../segment-cache/navigation' export function restoreReducer( state: ReadonlyReducerState, @@ -47,12 +48,18 @@ export function restoreReducer( scrollableSegments: null, separateRefreshUrls: null, } + const restoreSeed = convertServerPatchToFullTree( + treeToRestore, + null, + renderedSearch + ) const task = startPPRNavigation( now, currentUrl, + state.renderedSearch, state.cache, state.tree, - treeToRestore, + restoreSeed.routeTree, FreshnessPolicy.HistoryTraversal, null, null, @@ -91,7 +98,7 @@ export function restoreReducer( focusAndScrollRef: state.focusAndScrollRef, cache: task.node, // Restore provided tree - tree: treeToRestore, + tree: task.route, nextUrl: restoredNextUrl, // TODO: We need to restore previousNextUrl, too, which represents the 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 562ed32962ed0..f65b2a3c48ccb 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 @@ -50,6 +50,7 @@ import { import { revalidateEntireCache } from '../../segment-cache/cache' import { getDeploymentId } from '../../../../shared/lib/deployment-id' import { + convertServerPatchToFullTree, navigateToSeededRoute, navigate as navigateUsingSegmentCache, } from '../../segment-cache/navigation' @@ -89,7 +90,6 @@ type FetchServerActionResult = { actionResult: ActionResult | undefined actionFlightData: NormalizedFlightData[] | string | undefined actionFlightDataRenderedSearch: NormalizedSearch | undefined - actionFlightDataCouldBeIntercepted: boolean | undefined isPrerender: boolean } @@ -208,7 +208,6 @@ 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( @@ -227,21 +226,18 @@ async function fetchServerAction( 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, revalidationKind, @@ -283,7 +279,6 @@ export function serverActionReducer( actionResult, actionFlightData: flightData, actionFlightDataRenderedSearch: flightDataRenderedSearch, - actionFlightDataCouldBeIntercepted: flightDataCouldBeIntercepted, redirectLocation, redirectType, }) => { @@ -382,6 +377,7 @@ export function serverActionReducer( // 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 currentRenderedSearch = state.renderedSearch const redirectUrl = redirectLocation !== undefined ? redirectLocation : currentUrl const currentFlightRouterState = state.tree @@ -396,49 +392,41 @@ export function serverActionReducer( // 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 redirectCanonicalUrl = createHrefFromUrl(redirectUrl) - const navigationSeed = { - tree: normalizedFlightData.tree, - renderedSearch: flightDataRenderedSearch, - data: normalizedFlightData.seedData, - head: normalizedFlightData.head, - } - const now = Date.now() - const result = navigateToSeededRoute( - now, - redirectUrl, - redirectCanonicalUrl, - navigationSeed, - currentUrl, - state.cache, - currentFlightRouterState, - freshnessPolicy, - nextUrl, - shouldScroll - ) - return handleNavigationResult( - redirectUrl, - state, - mutable, - pendingPush, - result - ) - } + // 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. + if (flightData !== undefined && flightDataRenderedSearch !== 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 redirectCanonicalUrl = createHrefFromUrl(redirectUrl) + const redirectSeed = convertServerPatchToFullTree( + currentFlightRouterState, + flightData, + flightDataRenderedSearch + ) + const now = Date.now() + const result = navigateToSeededRoute( + now, + redirectUrl, + redirectCanonicalUrl, + redirectSeed, + currentUrl, + currentRenderedSearch, + state.cache, + currentFlightRouterState, + freshnessPolicy, + nextUrl, + shouldScroll + ) + return handleNavigationResult( + redirectUrl, + state, + mutable, + pendingPush, + result + ) } // The server did not send back new data. We'll perform a regular, non- @@ -446,6 +434,7 @@ export function serverActionReducer( const result = navigateUsingSegmentCache( redirectUrl, currentUrl, + currentRenderedSearch, state.cache, currentFlightRouterState, nextUrl, diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts index 977186b42bf05..697498f09f112 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts @@ -30,6 +30,7 @@ export function serverPatchReducer( return handleExternalUrl(state, mutable, retryUrl.href, false) } const currentUrl = new URL(state.canonicalUrl, location.origin) + const currentRenderedSearch = state.renderedSearch if (action.previousTree !== state.tree) { // There was another, more recent navigation since the once that // mismatched. We can abort the retry, but we still need to refresh the @@ -50,6 +51,7 @@ export function serverPatchReducer( retryCanonicalUrl, retrySeed, currentUrl, + currentRenderedSearch, state.cache, state.tree, FreshnessPolicy.RefreshAll, diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 02feeb2379a03..2d579ca0e1ea1 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -222,7 +222,17 @@ export type AppRouterState = { * - This is the url you see in the browser. */ canonicalUrl: string + + /** + * The search query observed by the server during rendering. This may be + * different from the canonical URL's search query if the server performed + * a rewrite. Even though a client component won't observe this (unless it + * were passed from a Server component), the client router needs to know this + * so it can properly cache segment data; it'ss part of a page segment's + * cache key. + */ renderedSearch: string + /** * The underlying "url" representing the UI state, which is used for intercepting routes. */ diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 81e183d000f4e..a85e8f3e71a44 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -124,6 +124,7 @@ type RouteTreeShared = { // TODO: Remove the `segment` field, now that it can be reconstructed // from `param`. segment: FlightRouterStateSegment + refreshState: RefreshState | null slots: null | { [parallelRouteKey: string]: RouteTree } @@ -143,6 +144,11 @@ type RouteTreeShared = { hasRuntimePrefetch: boolean } +export type RefreshState = { + canonicalUrl: string + renderedSearch: NormalizedSearch +} + type LayoutRouteTree = RouteTreeShared & { isPage: false varyPath: SegmentVaryPath @@ -638,6 +644,7 @@ function createOptimisticRouteTree( return { requestKey: tree.requestKey, segment: tree.segment, + refreshState: tree.refreshState, varyPath: clonePageVaryPathWithNewSearchParams( tree.varyPath, newRenderedSearch @@ -653,6 +660,7 @@ function createOptimisticRouteTree( return { requestKey: tree.requestKey, segment: tree.segment, + refreshState: tree.refreshState, varyPath: tree.varyPath, isPage: false, slots: clonedSlots, @@ -889,6 +897,7 @@ function fulfillRouteCacheEntry( const metadata: RouteTree = { requestKey: HEAD_REQUEST_KEY, segment: HEAD_REQUEST_KEY, + refreshState: null, varyPath: metadataVaryPath, // The metadata isn't really a "page" (though it isn't really a "segment" // either) but for the purposes of how this field is used, it behaves like @@ -1117,6 +1126,7 @@ function convertTreePrefetchToRouteTree( return { requestKey, segment, + refreshState: null, varyPath, // TODO: Cheating the type system here a bit because TypeScript can't tell // that the type of isPage and varyPath are consistent. The fix would be to @@ -1134,7 +1144,7 @@ function convertTreePrefetchToRouteTree( } } -function convertRootFlightRouterStateToRouteTree( +export function convertRootFlightRouterStateToRouteTree( flightRouterState: FlightRouterState, renderedSearch: NormalizedSearch, acc: RouteTreeAccumulator @@ -1152,11 +1162,26 @@ function convertFlightRouterStateToRouteTree( flightRouterState: FlightRouterState, requestKey: SegmentRequestKey, parentPartialVaryPath: PartialSegmentVaryPath | null, - renderedSearch: NormalizedSearch, + parentRenderedSearch: NormalizedSearch, acc: RouteTreeAccumulator ): RouteTree { const originalSegment = flightRouterState[0] + // If the FlightRouterState has a refresh state, then this segment is part of + // an inactive parallel route. It has a different rendered search query than + // the outer parent route. In order to construct the inactive route correctly, + // we must restore the query that was originally used to render it. + const compressedRefreshState = flightRouterState[2] ?? null + const refreshState = + compressedRefreshState !== null + ? { + canonicalUrl: compressedRefreshState[0] as string, + renderedSearch: compressedRefreshState[1] as NormalizedSearch, + } + : null + const renderedSearch = + refreshState !== null ? refreshState.renderedSearch : parentRenderedSearch + let segment: FlightRouterStateSegment let partialVaryPath: PartialSegmentVaryPath | null let isPage: boolean @@ -1245,6 +1270,7 @@ function convertFlightRouterStateToRouteTree( return { requestKey, segment, + refreshState, varyPath, // TODO: Cheating the type system here a bit because TypeScript can't tell // that the type of isPage and varyPath are consistent. The fix would be to diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 3422fffb1bd6b..4f69846a12af2 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -24,12 +24,13 @@ import { readSegmentCacheEntry, waitForSegmentCacheEntry, requestOptimisticRouteCacheEntry, + convertRootFlightRouterStateToRouteTree, type RouteTree, type FulfilledRouteCacheEntry, } from './cache' -import { createCacheKey } from './cache-key' -import { addSearchParamsIfPageSegment } from '../../../shared/lib/segment' +import { createCacheKey, type NormalizedSearch } from './cache-key' import { NavigationResultTag } from './types' +import type { PageVaryPath } from './vary-path' type MPANavigationResult = { tag: NavigationResultTag.MPA @@ -70,6 +71,7 @@ export type NavigationResult = export function navigate( url: URL, currentUrl: URL, + currentRenderedSearch: string, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, nextUrl: string | null, @@ -104,9 +106,7 @@ export function navigate( const route = readRouteCacheEntry(now, cacheKey) if (route !== null && route.status === EntryStatus.Fulfilled) { // We have a matching prefetch. - const snapshot = readRenderSnapshotFromCache(now, route, route.tree) - const prefetchFlightRouterState = snapshot.flightRouterState - const prefetchSeedData = snapshot.seedData + const prefetchData = readRenderSnapshotFromCache(now, route, route.tree) const headSnapshot = readHeadSnapshotFromCache(now, route) const prefetchHead = headSnapshot.rsc const isPrefetchHeadPartial = headSnapshot.isPartial @@ -122,12 +122,13 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, isSamePageNavigation, currentCacheNode, currentFlightRouterState, - prefetchFlightRouterState, - prefetchSeedData, + route.tree, + prefetchData, prefetchHead, isPrefetchHeadPartial, newCanonicalUrl, @@ -150,13 +151,11 @@ export function navigate( const optimisticRoute = requestOptimisticRouteCacheEntry(now, url, nextUrl) if (optimisticRoute !== null) { // We have an optimistic route tree. Proceed with the normal flow. - const snapshot = readRenderSnapshotFromCache( + const prefetchData = readRenderSnapshotFromCache( now, optimisticRoute, optimisticRoute.tree ) - const prefetchFlightRouterState = snapshot.flightRouterState - const prefetchSeedData = snapshot.seedData const headSnapshot = readHeadSnapshotFromCache(now, optimisticRoute) const prefetchHead = headSnapshot.rsc const isPrefetchHeadPartial = headSnapshot.isPartial @@ -166,12 +165,13 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, isSamePageNavigation, currentCacheNode, currentFlightRouterState, - prefetchFlightRouterState, - prefetchSeedData, + optimisticRoute.tree, + prefetchData, prefetchHead, isPrefetchHeadPartial, newCanonicalUrl, @@ -193,6 +193,7 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, @@ -209,6 +210,7 @@ export function navigateToSeededRoute( canonicalUrl: string, navigationSeed: NavigationSeed, currentUrl: URL, + currentRenderedSearch: string, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, freshnessPolicy: FreshnessPolicy, @@ -225,9 +227,10 @@ export function navigateToSeededRoute( const task = startPPRNavigation( now, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, - navigationSeed.tree, + navigationSeed.routeTree, freshnessPolicy, navigationSeed.data, navigationSeed.head, @@ -259,11 +262,12 @@ function navigateUsingPrefetchedRouteTree( now: number, url: URL, currentUrl: URL, + currentRenderedSearch: string, nextUrl: string | null, isSamePageNavigation: boolean, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, - prefetchFlightRouterState: FlightRouterState, + routeTree: RouteTree, prefetchSeedData: CacheNodeSeedData | null, prefetchHead: HeadData | null, isPrefetchHeadPartial: boolean, @@ -287,9 +291,10 @@ function navigateUsingPrefetchedRouteTree( const task = startPPRNavigation( now, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, - prefetchFlightRouterState, + routeTree, freshnessPolicy, seedData, seedHead, @@ -343,8 +348,7 @@ function readRenderSnapshotFromCache( now: number, route: FulfilledRouteCacheEntry, tree: RouteTree -): { flightRouterState: FlightRouterState; seedData: CacheNodeSeedData } { - let childRouterStates: { [parallelRouteKey: string]: FlightRouterState } = {} +): CacheNodeSeedData { let childSeedDatas: { [parallelRouteKey: string]: CacheNodeSeedData | null } = {} @@ -352,9 +356,11 @@ function readRenderSnapshotFromCache( if (slots !== null) { for (const parallelRouteKey in slots) { const childTree = slots[parallelRouteKey] - const childResult = readRenderSnapshotFromCache(now, route, childTree) - childRouterStates[parallelRouteKey] = childResult.flightRouterState - childSeedDatas[parallelRouteKey] = childResult.seedData + childSeedDatas[parallelRouteKey] = readRenderSnapshotFromCache( + now, + route, + childTree + ) } } @@ -404,34 +410,10 @@ function readRenderSnapshotFromCache( } } - // The navigation implementation expects the search params to be - // included in the segment. However, the Segment Cache tracks search - // params separately from the rest of the segment key. So we need to - // add them back here. - // - // See corresponding comment in convertFlightRouterStateToTree. - // - // TODO: What we should do instead is update the navigation diffing - // logic to compare search params explicitly. This is a temporary - // solution until more of the Segment Cache implementation has settled. - const segment = addSearchParamsIfPageSegment( - tree.segment, - Object.fromEntries(new URLSearchParams(route.renderedSearch)) - ) - // We don't need this information in a render snapshot, so this can just be a placeholder. const hasRuntimePrefetch = false - return { - flightRouterState: [ - segment, - childRouterStates, - null, - null, - tree.isRootLayout, - ], - seedData: [rsc, childSeedDatas, loading, isPartial, hasRuntimePrefetch], - } + return [rsc, childSeedDatas, loading, isPartial, hasRuntimePrefetch] } function readHeadSnapshotFromCache( @@ -483,6 +465,7 @@ async function navigateDynamicallyWithNoPrefetch( now: number, url: URL, currentUrl: URL, + currentRenderedSearch: string, nextUrl: string | null, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, @@ -558,6 +541,7 @@ async function navigateDynamicallyWithNoPrefetch( createHrefFromUrl(canonicalUrl), navigationSeed, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, freshnessPolicy, @@ -567,15 +551,16 @@ async function navigateDynamicallyWithNoPrefetch( } export type NavigationSeed = { - tree: FlightRouterState renderedSearch: string + routeTree: RouteTree + metadataVaryPath: PageVaryPath | null data: CacheNodeSeedData | null head: HeadData | null } export function convertServerPatchToFullTree( currentTree: FlightRouterState, - flightData: Array, + flightData: Array | null, renderedSearch: string ): NavigationSeed { // During a client navigation or prefetch, the server sends back only a patch @@ -597,29 +582,47 @@ export function convertServerPatchToFullTree( let baseTree: FlightRouterState = currentTree let baseData: CacheNodeSeedData | null = null let head: HeadData | null = null - for (const { - segmentPath, - tree: treePatch, - seedData: dataPatch, - head: headPatch, - } of flightData) { - const result = convertServerPatchToFullTreeImpl( - baseTree, - baseData, - treePatch, - dataPatch, + if (flightData !== null) { + for (const { segmentPath, - 0 - ) - baseTree = result.tree - baseData = result.data - // This is the same for all patches per response, so just pick an - // arbitrary one - head = headPatch + tree: treePatch, + seedData: dataPatch, + head: headPatch, + } of flightData) { + const result = convertServerPatchToFullTreeImpl( + baseTree, + baseData, + treePatch, + dataPatch, + segmentPath, + renderedSearch, + 0 + ) + baseTree = result.tree + baseData = result.data + // This is the same for all patches per response, so just pick an + // arbitrary one + head = headPatch + } } + const finalFlightRouterState = baseTree + + // Convert the final FlightRouterState into a RouteTree type. + // + // TODO: Eventually, FlightRouterState will evolve to being a transport format + // only. The RouteTree type will become the main type used for dealing with + // routes on the client, and we'll store it in the state directly. + const acc = { metadataVaryPath: null } + const routeTree = convertRootFlightRouterStateToRouteTree( + finalFlightRouterState, + renderedSearch as NormalizedSearch, + acc + ) + return { - tree: baseTree, + routeTree, + metadataVaryPath: acc.metadataVaryPath, data: baseData, renderedSearch, head, @@ -632,6 +635,7 @@ function convertServerPatchToFullTreeImpl( treePatch: FlightRouterState, dataPatch: CacheNodeSeedData | null, segmentPath: FlightSegmentPath, + renderedSearch: string, index: number ): { tree: FlightRouterState; data: CacheNodeSeedData | null } { if (index === segmentPath.length) { @@ -672,6 +676,7 @@ function convertServerPatchToFullTreeImpl( treePatch, dataPatch, segmentPath, + renderedSearch, // Advance the index by two and keep cloning until we reach // the end of the segment path. index + 2 @@ -696,7 +701,19 @@ function convertServerPatchToFullTreeImpl( // refetch marker. clonedTree = [baseRouterState[0], newTreeChildren] if (2 in baseRouterState) { - clonedTree[2] = baseRouterState[2] + const compressedRefreshState = baseRouterState[2] + if ( + compressedRefreshState !== undefined && + compressedRefreshState !== null + ) { + // Since this part of the tree was patched with new data, any parent + // refresh states should be updated to reflect the new rendered search + // value. (The refresh state acts like a "context provider".) All pages + // within the same server response share the same renderedSearch value, + // but the same RouteTree could be composed from multiple different + // routes, and multiple responses. + clonedTree[2] = [compressedRefreshState[0], renderedSearch] + } } if (3 in baseRouterState) { clonedTree[3] = baseRouterState[3] diff --git a/packages/next/src/client/components/segment-cache/vary-path.ts b/packages/next/src/client/components/segment-cache/vary-path.ts index b54a5f04989c7..6e14743ec3040 100644 --- a/packages/next/src/client/components/segment-cache/vary-path.ts +++ b/packages/next/src/client/components/segment-cache/vary-path.ts @@ -277,3 +277,12 @@ export function clonePageVaryPathWithNewSearchParams( } return clonedVaryPath as PageVaryPath } + +export function getRenderedSearchFromVaryPath( + varyPath: PageVaryPath +): NormalizedSearch | null { + const searchParams = varyPath.parent.value + return typeof searchParams === 'string' + ? (searchParams as NormalizedSearch) + : null +} diff --git a/packages/next/src/client/flight-data-helpers.test.ts b/packages/next/src/client/flight-data-helpers.test.ts index b8765f6df50a9..fed137b6dff22 100644 --- a/packages/next/src/client/flight-data-helpers.test.ts +++ b/packages/next/src/client/flight-data-helpers.test.ts @@ -10,8 +10,8 @@ describe('prepareFlightRouterStateForRequest', () => { const flightRouterState: FlightRouterState = [ '__PAGE__?{"sensitive":"data"}', {}, - '/some/url', - 'refresh', + ['/some/url', ''], + 'refetch', true, 1, ] @@ -61,14 +61,13 @@ describe('prepareFlightRouterStateForRequest', () => { const flightRouterState: FlightRouterState = [ 'segment', {}, - '/sensitive/url/path', - null, + ['/sensitive/url/path', ''], ] const result = prepareFlightRouterStateForRequest(flightRouterState) const decoded = JSON.parse(decodeURIComponent(result)) - expect(decoded[2]).toBeNull() + expect(decoded[2]).toBeUndefined() }) }) @@ -77,7 +76,7 @@ describe('prepareFlightRouterStateForRequest', () => { const flightRouterState: FlightRouterState = [ 'segment', {}, - '/url', + ['/url', ''], 'refetch', ] @@ -91,7 +90,7 @@ describe('prepareFlightRouterStateForRequest', () => { const flightRouterState: FlightRouterState = [ 'segment', {}, - '/url', + ['/url', ''], 'inside-shared-layout', ] @@ -101,27 +100,27 @@ describe('prepareFlightRouterStateForRequest', () => { expect(decoded[3]).toBe('inside-shared-layout') }) - it('should strip "refresh" marker (client-only)', () => { - const flightRouterState: FlightRouterState = [ - 'segment', - {}, - '/url', - 'refresh', - ] + it('should strip "refresh" state (client-only)', () => { + const flightRouterState: FlightRouterState = ['segment', {}, ['/url', '']] const result = prepareFlightRouterStateForRequest(flightRouterState) const decoded = JSON.parse(decodeURIComponent(result)) - expect(decoded[3]).toBeNull() + expect(decoded[2]).toBeUndefined() }) it('should strip null refresh marker', () => { - const flightRouterState: FlightRouterState = ['segment', {}, '/url', null] + const flightRouterState: FlightRouterState = [ + 'segment', + {}, + ['/url', ''], + null, + ] const result = prepareFlightRouterStateForRequest(flightRouterState) const decoded = JSON.parse(decodeURIComponent(result)) - expect(decoded[3]).toBeNull() + expect(decoded[3]).toBeUndefined() }) }) @@ -181,8 +180,8 @@ describe('prepareFlightRouterStateForRequest', () => { expect(decoded).toEqual([ 'segment', {}, - null, // URL - null, // refresh marker + // URL + // refresh marker ]) }) }) @@ -192,15 +191,10 @@ describe('prepareFlightRouterStateForRequest', () => { const flightRouterState: FlightRouterState = [ 'parent', { - children: [ - '__PAGE__?{"nested":"param"}', - {}, - '/nested/url', - 'refresh', - ], - modal: ['modal-segment', {}, '/modal/url', 'refetch'], + children: ['__PAGE__?{"nested":"param"}', {}, ['/nested/url', '']], + modal: ['modal-segment', {}, ['/modal/url', ''], 'refetch'], }, - '/parent/url', + ['/parent/url', ''], 'inside-shared-layout', ] @@ -213,8 +207,8 @@ describe('prepareFlightRouterStateForRequest', () => { children: [ '__PAGE__', // search params stripped {}, - null, // URL stripped - null, // 'refresh' marker stripped + // URL stripped + // 'refresh' marker stripped ], modal: [ 'modal-segment', @@ -238,7 +232,7 @@ describe('prepareFlightRouterStateForRequest', () => { children: [ '__PAGE__?{"deep":"nesting"}', {}, - '/deep/url', + ['/deep/url', ''], 'refetch', ], }, @@ -266,20 +260,20 @@ describe('prepareFlightRouterStateForRequest', () => { modal: [ '__PAGE__?{"modalParam":"data"}', {}, - '/modal/path', - 'refresh', + ['/modal/path', ''], + null, false, HasLoadingBoundary.SegmentHasLoadingBoundary, ], }, - '/dashboard/url', + ['/dashboard/url', ''], 'refetch', true, 1, ], - sidebar: [['slug', 'user-123', 'd'], {}, '/sidebar/url', null], + sidebar: [['slug', 'user-123', 'd'], {}, ['/sidebar/url', ''], null], }, - '/main/url', + ['/main/url', ''], 'inside-shared-layout', true, 1, @@ -312,8 +306,8 @@ describe('prepareFlightRouterStateForRequest', () => { // Sidebar route (dynamic segment) checks const sidebarRoute = decoded[1].sidebar expect(sidebarRoute[0]).toEqual(['slug', 'user-123', 'd']) // dynamic segment preserved - expect(sidebarRoute[2]).toBeNull() // URL stripped - expect(sidebarRoute[3]).toBeNull() // null marker remains null + expect(sidebarRoute[2]).toBeUndefined() // URL stripped + expect(sidebarRoute[3]).toBeUndefined() // null marker stripped }) }) }) diff --git a/packages/next/src/client/flight-data-helpers.ts b/packages/next/src/client/flight-data-helpers.ts index dde9ed5a9e5bf..2b36ba7141745 100644 --- a/packages/next/src/client/flight-data-helpers.ts +++ b/packages/next/src/client/flight-data-helpers.ts @@ -244,7 +244,7 @@ function stripClientOnlyDataFromFlightRouterState( const [ segment, parallelRoutes, - _url, // Intentionally unused - URLs are client-only + _refreshState, // Intentionally unused - URLs are client-only refreshMarker, isRootLayout, hasLoadingBoundary, @@ -261,12 +261,11 @@ function stripClientOnlyDataFromFlightRouterState( stripClientOnlyDataFromFlightRouterState(childState) } - const result: FlightRouterState = [ - cleanedSegment, - cleanedParallelRoutes, - null, // URLs omitted - server reconstructs paths from segments - shouldPreserveRefreshMarker(refreshMarker) ? refreshMarker : null, - ] + const result: FlightRouterState = [cleanedSegment, cleanedParallelRoutes] + if (refreshMarker) { + result[2] = null // null slightly more compact than undefined + result[3] = refreshMarker + } // Append optional fields if present if (isRootLayout !== undefined) { @@ -276,6 +275,7 @@ function stripClientOnlyDataFromFlightRouterState( result[5] = hasLoadingBoundary } + // Everything else is used only by the client and is not needed for requests. return result } @@ -292,14 +292,3 @@ function stripSearchParamsFromPageSegment(segment: Segment): Segment { } return segment } - -/** - * Determines whether the refresh marker should be sent to the server - * Client-only markers like 'refresh' are stripped, while server-needed markers - * like 'refetch' and 'inside-shared-layout' are preserved. - */ -function shouldPreserveRefreshMarker( - refreshMarker: FlightRouterState[3] -): boolean { - return Boolean(refreshMarker && refreshMarker !== 'refresh') -} diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index d98b94b864f75..0428fb3ab2558 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -61,7 +61,7 @@ export const flightRouterStateSchema: s.Describe = s.tuple([ s.string(), s.lazy(() => flightRouterStateSchema) ), - s.optional(s.nullable(s.string())), + s.optional(s.nullable(s.tuple([s.string(), s.string()]))), s.optional( s.nullable( s.union([ diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index 60927b3d5ec51..3b0f34385f006 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -108,17 +108,11 @@ export type Segment = export type FlightRouterState = [ segment: Segment, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, - url?: string | null, + refreshState?: CompressedRefreshState | null, /** - * "refresh" and "refetch", despite being similarly named, have different - * semantics: * - "refetch" is used during a request to inform the server where rendering * should start from. * - * - "refresh" is used by the client to mark that a segment should re-fetch the - * data from the server for the current segment. It uses the "url" property - * above to determine where to fetch from. - * * - "inside-shared-layout" is used during a prefetch request to inform the * server that even if the segment matches, it should be treated as if it's * within the "new" part of a navigation — inside the shared layout. If @@ -137,12 +131,7 @@ export type FlightRouterState = [ * make sense for the client to send a FlightRouterState, since this type is * overloaded with concerns. */ - refresh?: - | 'refetch' - | 'refresh' - | 'inside-shared-layout' - | 'metadata-only' - | null, + refresh?: 'refetch' | 'inside-shared-layout' | 'metadata-only' | null, isRootLayout?: boolean, /** * Only present when responding to a tree prefetch request. Indicates whether @@ -152,6 +141,18 @@ export type FlightRouterState = [ hasLoadingBoundary?: HasLoadingBoundary, ] +/** + * When rendering a parallel route, some of the parallel paths may not match + * the current URL. In that case, the Next client has to render something, + * so it will render whichever was the last route to match that slot. We use + * this type to track when this has happened. It's a tuple of the original + * URL that was used to fetch the segment, and the (possibly rewritten) search + * query that was rendered by the server. The URL is needed when performing + * a refresh of the segment, and the search query is needed for looking up + * matching entries in the segment cache. + */ +export type CompressedRefreshState = [url: string, renderedSearch: string] + export const enum HasLoadingBoundary { // There is a loading boundary in this particular segment SegmentHasLoadingBoundary = 1,