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 680fb13766ef78..bffd3112b879ab 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, + RefreshState, } from '../../../shared/lib/app-router-types' import type { ChildSegmentMap, @@ -51,7 +52,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 } @@ -175,6 +176,7 @@ export function createInitialCacheNodeForHydration( export function startPPRNavigation( navigatedAt: number, oldUrl: URL, + oldRenderedSearch: string, oldCacheNode: CacheNode | null, oldRouterState: FlightRouterState, newRouterState: FlightRouterState, @@ -189,7 +191,11 @@ export function startPPRNavigation( ): NavigationTask | null { const didFindRootLayout = false const parentNeedsDynamicRequest = false - const parentRefreshUrl = null + const parentRefreshState = null + const oldRootRefreshState: RefreshState = [ + createHrefFromUrl(oldUrl), + oldRenderedSearch, + ] return updateCacheNodeOnNavigation( navigatedAt, oldUrl, @@ -207,7 +213,8 @@ export function startPPRNavigation( null, null, parentNeedsDynamicRequest, - parentRefreshUrl, + oldRootRefreshState, + parentRefreshState, accumulation ) } @@ -229,7 +236,8 @@ 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. @@ -441,20 +449,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 = newRouterState[2] + 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) @@ -524,7 +532,7 @@ function updateCacheNodeOnNavigation( // a soft navigation; instead, the client reuses whatever segment was // already active in that slot on the previous route. newRouterStateChild = reuseActiveSegmentInDefaultSlot( - oldUrl, + oldRootRefreshState, oldRouterStateChild ) newSegmentChild = newRouterStateChild[0] @@ -561,7 +569,8 @@ function updateCacheNodeOnNavigation( segmentPath, parallelRouteKey, parentNeedsDynamicRequest || needsDynamicRequest, - refreshUrl, + oldRootRefreshState, + refreshState, accumulation ) @@ -618,7 +627,7 @@ function updateCacheNodeOnNavigation( childNeedsDynamicRequest, parentNeedsDynamicRequest ), - refreshUrl, + refreshState, children: taskChildren, } } @@ -923,7 +932,7 @@ 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, } } @@ -986,7 +995,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 +1007,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[0] const separateRefreshUrls = accumulation.separateRefreshUrls if (separateRefreshUrls === null) { accumulation.separateRefreshUrls = new Set([refreshUrl]) @@ -1007,7 +1017,7 @@ function accumulateRefreshUrl( } function reuseActiveSegmentInDefaultSlot( - oldUrl: URL, + oldRootRefreshState: RefreshState, oldRouterState: FlightRouterState ): FlightRouterState { // This is a "default" segment. These are never sent by the server during a @@ -1015,26 +1025,22 @@ function reuseActiveSegmentInDefaultSlot( // 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') { + 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. + // existing URL and refresh state. reusedRouterState = oldRouterState } 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. + // 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. reusedRouterState = patchRouterStateWithNewChildren( oldRouterState, oldRouterState[1] ) - reusedRouterState[2] = createHrefFromUrl(oldUrl) - reusedRouterState[3] = 'refresh' + reusedRouterState[2] = oldRootRefreshState } return reusedRouterState @@ -1631,7 +1637,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 9484c7702df103..f2f4742bb8e728 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 ada23b8920b6bd..d9940aeca0fdaa 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 @@ -36,6 +36,7 @@ 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 @@ -53,6 +54,7 @@ export function refreshDynamicData( currentCanonicalUrl, navigationSeed, 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 9484feba57bee1..f580b84f4912ff 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 @@ -50,6 +50,7 @@ export function restoreReducer( const task = startPPRNavigation( now, currentUrl, + state.renderedSearch, state.cache, state.tree, treeToRestore, 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 562ed32962ed06..ef2a4b50f5e42a 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 @@ -382,6 +382,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 @@ -425,6 +426,7 @@ export function serverActionReducer( redirectCanonicalUrl, navigationSeed, currentUrl, + currentRenderedSearch, state.cache, currentFlightRouterState, freshnessPolicy, @@ -446,6 +448,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 977186b42bf050..697498f09f112d 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 02feeb2379a032..2d579ca0e1ea15 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/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 3422fffb1bd6be..bfc3c2bbedac96 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -70,6 +70,7 @@ export type NavigationResult = export function navigate( url: URL, currentUrl: URL, + currentRenderedSearch: string, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, nextUrl: string | null, @@ -122,6 +123,7 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, isSamePageNavigation, currentCacheNode, @@ -166,6 +168,7 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, isSamePageNavigation, currentCacheNode, @@ -193,6 +196,7 @@ export function navigate( now, url, currentUrl, + currentRenderedSearch, nextUrl, currentCacheNode, currentFlightRouterState, @@ -209,6 +213,7 @@ export function navigateToSeededRoute( canonicalUrl: string, navigationSeed: NavigationSeed, currentUrl: URL, + currentRenderedSearch: string, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, freshnessPolicy: FreshnessPolicy, @@ -225,6 +230,7 @@ export function navigateToSeededRoute( const task = startPPRNavigation( now, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, navigationSeed.tree, @@ -259,6 +265,7 @@ function navigateUsingPrefetchedRouteTree( now: number, url: URL, currentUrl: URL, + currentRenderedSearch: string, nextUrl: string | null, isSamePageNavigation: boolean, currentCacheNode: CacheNode | null, @@ -287,6 +294,7 @@ function navigateUsingPrefetchedRouteTree( const task = startPPRNavigation( now, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, prefetchFlightRouterState, @@ -483,6 +491,7 @@ async function navigateDynamicallyWithNoPrefetch( now: number, url: URL, currentUrl: URL, + currentRenderedSearch: string, nextUrl: string | null, currentCacheNode: CacheNode | null, currentFlightRouterState: FlightRouterState, @@ -558,6 +567,7 @@ async function navigateDynamicallyWithNoPrefetch( createHrefFromUrl(canonicalUrl), navigationSeed, currentUrl, + currentRenderedSearch, currentCacheNode, currentFlightRouterState, freshnessPolicy, diff --git a/packages/next/src/client/flight-data-helpers.test.ts b/packages/next/src/client/flight-data-helpers.test.ts index b8765f6df50a9b..fed137b6dff220 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 dde9ed5a9e5bfd..2b36ba7141745c 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 d98b94b864f75c..0428fb3ab25588 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 60927b3d5ec517..d8b2686a97d965 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?: RefreshState | 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 RefreshState = [url: string, renderedSearch: string] + export const enum HasLoadingBoundary { // There is a loading boundary in this particular segment SegmentHasLoadingBoundary = 1,