diff --git a/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.test.tsx b/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.test.tsx index 523fb15c57..86c16eac07 100644 --- a/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.test.tsx +++ b/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.test.tsx @@ -3,7 +3,7 @@ import type { FlightData, FlightRouterState, } from '../../../server/app-render/types' -import { applyRouterStatePatchToTreeSkipDefault } from './apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from './apply-router-state-patch-to-tree' const getInitialRouterStateTree = (): FlightRouterState => [ '', @@ -55,10 +55,11 @@ describe('applyRouterStatePatchToTree', () => { const [treePatch /*, cacheNodeSeedData, head*/] = flightDataPath.slice(-3) const flightSegmentPath = flightDataPath.slice(0, -4) - const newRouterStateTree = applyRouterStatePatchToTreeSkipDefault( + const newRouterStateTree = applyRouterStatePatchToTree( ['', ...flightSegmentPath], initialRouterStateTree, - treePatch + treePatch, + '' ) expect(newRouterStateTree).toMatchObject([ diff --git a/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts b/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts index decd147036..c3d3997301 100644 --- a/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts +++ b/packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts @@ -4,6 +4,7 @@ import type { } from '../../../server/app-render/types' import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment' import { matchSegment } from '../match-segments' +import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments' /** * Deep merge of the two router states. Parallel route keys are preserved if the patch doesn't have them. @@ -11,17 +12,14 @@ import { matchSegment } from '../match-segments' function applyPatch( initialTree: FlightRouterState, patchTree: FlightRouterState, - applyPatchToDefaultSegment: boolean = false + flightSegmentPath: FlightSegmentPath ): FlightRouterState { const [initialSegment, initialParallelRoutes] = initialTree const [patchSegment, patchParallelRoutes] = patchTree // if the applied patch segment is __DEFAULT__ then it can be ignored in favor of the initial tree // this is because the __DEFAULT__ segment is used as a placeholder on navigation - // however, there are cases where we _do_ want to apply the patch to the default segment, - // such as when revalidating the router cache with router.refresh/revalidatePath if ( - !applyPatchToDefaultSegment && patchSegment === DEFAULT_SEGMENT_KEY && initialSegment !== DEFAULT_SEGMENT_KEY ) { @@ -37,7 +35,7 @@ function applyPatch( newParallelRoutes[key] = applyPatch( initialParallelRoutes[key], patchParallelRoutes[key], - applyPatchToDefaultSegment + flightSegmentPath ) } else { newParallelRoutes[key] = initialParallelRoutes[key] @@ -54,6 +52,7 @@ function applyPatch( const tree: FlightRouterState = [initialSegment, newParallelRoutes] + // Copy over the existing tree if (initialTree[2]) { tree[2] = initialTree[2] } @@ -72,20 +71,26 @@ function applyPatch( return patchTree } -function applyRouterStatePatchToTreeImpl( +/** + * Apply the router state from the Flight response, but skip patching default segments. + * Useful for patching the router cache when navigating, where we persist the existing default segment if there isn't a new one. + * Creates a new router state tree. + */ +export function applyRouterStatePatchToTree( flightSegmentPath: FlightSegmentPath, flightRouterState: FlightRouterState, treePatch: FlightRouterState, - applyPatchDefaultSegment: boolean = false + pathname: string ): FlightRouterState | null { - const [segment, parallelRoutes, , , isRootLayout] = flightRouterState + const [segment, parallelRoutes, url, refetch, isRootLayout] = + flightRouterState // Root refresh if (flightSegmentPath.length === 1) { const tree: FlightRouterState = applyPatch( flightRouterState, treePatch, - applyPatchDefaultSegment + flightSegmentPath ) return tree @@ -105,14 +110,14 @@ function applyRouterStatePatchToTreeImpl( parallelRoutePatch = applyPatch( parallelRoutes[parallelRouteKey], treePatch, - applyPatchDefaultSegment + flightSegmentPath ) } else { - parallelRoutePatch = applyRouterStatePatchToTreeImpl( + parallelRoutePatch = applyRouterStatePatchToTree( flightSegmentPath.slice(2), parallelRoutes[parallelRouteKey], treePatch, - applyPatchDefaultSegment + pathname ) if (parallelRoutePatch === null) { @@ -126,6 +131,8 @@ function applyRouterStatePatchToTreeImpl( ...parallelRoutes, [parallelRouteKey]: parallelRoutePatch, }, + url, + refetch, ] // Current segment is the root layout @@ -133,41 +140,7 @@ function applyRouterStatePatchToTreeImpl( tree[4] = true } + addRefreshMarkerToActiveParallelSegments(tree, pathname) + return tree } - -/** - * Apply the router state from the Flight response to the tree, including default segments. - * Useful for patching the router cache when we expect to revalidate the full tree, such as with router.refresh or revalidatePath. - * Creates a new router state tree. - */ -export function applyRouterStatePatchToFullTree( - flightSegmentPath: FlightSegmentPath, - flightRouterState: FlightRouterState, - treePatch: FlightRouterState -): FlightRouterState | null { - return applyRouterStatePatchToTreeImpl( - flightSegmentPath, - flightRouterState, - treePatch, - true - ) -} - -/** - * Apply the router state from the Flight response, but skip patching default segments. - * Useful for patching the router cache when navigating, where we persist the existing default segment if there isn't a new one. - * Creates a new router state tree. - */ -export function applyRouterStatePatchToTreeSkipDefault( - flightSegmentPath: FlightSegmentPath, - flightRouterState: FlightRouterState, - treePatch: FlightRouterState -): FlightRouterState | null { - return applyRouterStatePatchToTreeImpl( - flightSegmentPath, - flightRouterState, - treePatch, - false - ) -} 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 e57ca36052..d4c0e138ad 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 @@ -11,6 +11,7 @@ import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with- import { extractPathFromFlightRouterState } from './compute-changed-path' import { createPrefetchCacheEntryForInitialLoad } from './prefetch-cache-utils' import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types' +import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments' export interface InitialRouterStateParameters { buildId: string @@ -48,6 +49,8 @@ export function createInitialRouterState({ loading: initialSeedData[3], } + addRefreshMarkerToActiveParallelSegments(initialTree, initialCanonicalUrl) + const prefetchCache = new Map() // When the cache hasn't been seeded yet we fill the cache with the head. diff --git a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts index 716624e5e5..9ccd1da6c9 100644 --- a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts @@ -1,6 +1,6 @@ import { fetchServerResponse } from '../fetch-server-response' import { createHrefFromUrl } from '../create-href-from-url' -import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import type { ReadonlyReducerState, @@ -69,11 +69,12 @@ function fastRefreshReducerImpl( // Given the path can only have two items the items are only the router state and rsc for the root. const [treePatch] = flightDataPath - const newTree = applyRouterStatePatchToTreeSkipDefault( + const newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' [''], currentTree, - treePatch + treePatch, + location.pathname ) if (newTree === null) { 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 f364bd31e4..2208533f5c 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 @@ -6,7 +6,7 @@ import type { import { fetchServerResponse } from '../fetch-server-response' import { createHrefFromUrl } from '../create-href-from-url' import { invalidateCacheBelowFlightSegmentPath } from '../invalidate-cache-below-flight-segmentpath' -import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' import { shouldHardNavigate } from '../should-hard-navigate' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { @@ -165,21 +165,23 @@ function navigateReducer_noPPR( const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath] // Create new tree based on the flightSegmentPath and router state patch - let newTree = applyRouterStatePatchToTreeSkipDefault( + let newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' flightSegmentPathWithLeadingEmpty, currentTree, - treePatch + treePatch, + url.pathname ) // If the tree patch can't be applied to the current tree then we use the tree at time of prefetch // TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch. if (newTree === null) { - newTree = applyRouterStatePatchToTreeSkipDefault( + newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' flightSegmentPathWithLeadingEmpty, treeAtTimeOfPrefetch, - treePatch + treePatch, + url.pathname ) } @@ -335,21 +337,23 @@ function navigateReducer_PPR( const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath] // Create new tree based on the flightSegmentPath and router state patch - let newTree = applyRouterStatePatchToTreeSkipDefault( + let newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' flightSegmentPathWithLeadingEmpty, currentTree, - treePatch + treePatch, + url.pathname ) // If the tree patch can't be applied to the current tree then we use the tree at time of prefetch // TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch. if (newTree === null) { - newTree = applyRouterStatePatchToTreeSkipDefault( + newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' flightSegmentPathWithLeadingEmpty, treeAtTimeOfPrefetch, - treePatch + treePatch, + url.pathname ) } @@ -384,8 +388,8 @@ function navigateReducer_PPR( // version of the next page. This can be rendered instantly. // Use the tree computed by updateCacheNodeOnNavigation instead - // of the one computed by applyRouterStatePatchToTreeSkipDefault. - // TODO: We should remove applyRouterStatePatchToTreeSkipDefault + // of the one computed by applyRouterStatePatchToTree. + // TODO: We should remove applyRouterStatePatchToTree // from the PPR path entirely. const patchedRouterState: FlightRouterState = task.route newTree = patchedRouterState 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 7c2a9ed7ce..11629c93e8 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 @@ -1,6 +1,6 @@ import { fetchServerResponse } from '../fetch-server-response' import { createHrefFromUrl } from '../create-href-from-url' -import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import type { Mutable, @@ -15,6 +15,7 @@ import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with import { createEmptyCacheNode } from '../../app-router' import { handleSegmentMismatch } from '../handle-segment-mismatch' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' +import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments' export function refreshReducer( state: ReadonlyReducerState, @@ -44,7 +45,7 @@ export function refreshReducer( ) return cache.lazyData.then( - ([flightData, canonicalUrlOverride]) => { + async ([flightData, canonicalUrlOverride]) => { // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return handleExternalUrl( @@ -68,11 +69,12 @@ export function refreshReducer( // Given the path can only have two items the items are only the router state and rsc for the root. const [treePatch] = flightDataPath - const newTree = applyRouterStatePatchToFullTree( + const newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' [''], currentTree, - treePatch + treePatch, + location.pathname ) if (newTree === null) { @@ -112,10 +114,17 @@ export function refreshReducer( cacheNodeSeedData, head ) - mutable.cache = cache mutable.prefetchCache = new Map() } + await refreshInactiveParallelSegments({ + state, + updatedTree: newTree, + updatedCache: cache, + includeNextUrl, + }) + + mutable.cache = cache mutable.patchedTree = newTree mutable.canonicalUrl = href 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 4c5b2e6da8..c9ccabe3f8 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 @@ -31,7 +31,7 @@ import type { import { addBasePath } from '../../../add-base-path' import { createHrefFromUrl } from '../create-href-from-url' import { handleExternalUrl } from './navigate-reducer' -import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { handleMutable } from '../handle-mutable' @@ -39,6 +39,7 @@ import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with import { createEmptyCacheNode } from '../../app-router' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' import { handleSegmentMismatch } from '../handle-segment-mismatch' +import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments' type FetchServerActionResult = { redirectLocation: URL | undefined @@ -53,17 +54,11 @@ type FetchServerActionResult = { async function fetchServerAction( state: ReadonlyReducerState, + nextUrl: ReadonlyReducerState['nextUrl'], { actionId, actionArgs }: ServerActionAction ): Promise { const body = await encodeReply(actionArgs) - // only pass along the `nextUrl` param (used for interception routes) if the current route was intercepted. - // If the route has been intercepted, the action should be as well. - // Otherwise the server action might be intercepted with the wrong action id - // (ie, one that corresponds with the intercepted route) - const includeNextUrl = - state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree) - const res = await fetch('', { method: 'POST', headers: { @@ -75,9 +70,9 @@ async function fetchServerAction( 'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID, } : {}), - ...(includeNextUrl + ...(nextUrl ? { - [NEXT_URL]: state.nextUrl, + [NEXT_URL]: nextUrl, } : {}), }, @@ -162,10 +157,24 @@ export function serverActionReducer( let currentTree = state.tree mutable.preserveCustomHistoryState = false - mutable.inFlightServerAction = fetchServerAction(state, action) + + // only pass along the `nextUrl` param (used for interception routes) if the current route was intercepted. + // If the route has been intercepted, the action should be as well. + // Otherwise the server action might be intercepted with the wrong action id + // (ie, one that corresponds with the intercepted route) + const nextUrl = + state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree) + ? state.nextUrl + : null + + mutable.inFlightServerAction = fetchServerAction(state, nextUrl, action) return mutable.inFlightServerAction.then( - ({ actionResult, actionFlightData: flightData, redirectLocation }) => { + async ({ + actionResult, + actionFlightData: flightData, + redirectLocation, + }) => { // Make sure the redirection is a push instead of a replace. // Issue: https://github.com/vercel/next.js/issues/53911 if (redirectLocation) { @@ -211,11 +220,12 @@ export function serverActionReducer( // Given the path can only have two items the items are only the router state and rsc for the root. const [treePatch] = flightDataPath - const newTree = applyRouterStatePatchToFullTree( + const newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' [''], currentTree, - treePatch + treePatch, + location.pathname ) if (newTree === null) { @@ -248,6 +258,14 @@ export function serverActionReducer( cacheNodeSeedData, head ) + + await refreshInactiveParallelSegments({ + state, + updatedTree: newTree, + updatedCache: cache, + includeNextUrl: Boolean(nextUrl), + }) + mutable.cache = cache mutable.prefetchCache = new Map() } 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 7d4b3b30d0..3d0d73e3f4 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 @@ -1,5 +1,5 @@ import { createHrefFromUrl } from '../create-href-from-url' -import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' +import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import type { ServerPatchAction, @@ -43,11 +43,12 @@ export function serverPatchReducer( const flightSegmentPath = flightDataPath.slice(0, -4) const [treePatch] = flightDataPath.slice(-3, -2) - const newTree = applyRouterStatePatchToTreeSkipDefault( + const newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' ['', ...flightSegmentPath], currentTree, - treePatch + treePatch, + location.pathname ) if (newTree === null) { diff --git a/packages/next/src/client/components/router-reducer/refetch-inactive-parallel-segments.ts b/packages/next/src/client/components/router-reducer/refetch-inactive-parallel-segments.ts new file mode 100644 index 0000000000..0da1da7e9e --- /dev/null +++ b/packages/next/src/client/components/router-reducer/refetch-inactive-parallel-segments.ts @@ -0,0 +1,115 @@ +import type { FlightRouterState } from '../../../server/app-render/types' +import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime' +import type { AppRouterState } from './router-reducer-types' +import { applyFlightData } from './apply-flight-data' +import { fetchServerResponse } from './fetch-server-response' +import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment' + +interface RefreshInactiveParallelSegments { + state: AppRouterState + updatedTree: FlightRouterState + updatedCache: CacheNode + includeNextUrl: boolean +} + +/** + * Refreshes inactive segments that are still in the current FlightRouterState. + * A segment is considered "inactive" when the server response indicates it didn't match to a page component. + * This happens during a soft-navigation, where the server will want to patch in the segment + * with the "default" component, but we explicitly ignore the server in this case + * and keep the existing state for that segment. New data for inactive segments are inherently + * not part of the server response when we patch the tree, because they were associated with a response + * from an earlier navigation/request. For each segment, once it becomes "active", we encode the URL that provided + * the data for it. This function traverses parallel routes looking for these markers so that it can re-fetch + * and patch the new data into the tree. + */ +export async function refreshInactiveParallelSegments( + options: RefreshInactiveParallelSegments +) { + const fetchedSegments = new Set() + await refreshInactiveParallelSegmentsImpl({ ...options, fetchedSegments }) +} + +async function refreshInactiveParallelSegmentsImpl({ + state, + updatedTree, + updatedCache, + includeNextUrl, + fetchedSegments, +}: RefreshInactiveParallelSegments & { fetchedSegments: Set }) { + const [, parallelRoutes, refetchPathname, refetchMarker] = updatedTree + const fetchPromises = [] + + if ( + refetchPathname && + refetchPathname !== location.pathname && + refetchMarker === 'refresh' && + // it's possible for the tree to contain multiple segments that contain data at the same URL + // we keep track of them so we can dedupe the requests + !fetchedSegments.has(refetchPathname) + ) { + fetchedSegments.add(refetchPathname) // Mark this URL as fetched + + // Eagerly kick off the fetch for the refetch path & the parallel routes. This should be fine to do as they each operate + // independently on their own cache nodes, and `applyFlightData` will copy anything it doesn't care about from the existing cache. + const fetchPromise = fetchServerResponse( + // we capture the pathname of the refetch without search params, so that it can be refetched with + // the "latest" search params when it comes time to actually trigger the fetch (below) + new URL(refetchPathname + location.search, location.origin), + [updatedTree[0], updatedTree[1], updatedTree[2], 'refetch'], + includeNextUrl ? state.nextUrl : null, + state.buildId + ).then((fetchResponse) => { + const flightData = fetchResponse[0] + if (typeof flightData !== 'string') { + for (const flightDataPath of flightData) { + // we only pass the new cache as this function is called after clearing the router cache + // and filling in the new page data from the server. Meaning the existing cache is actually the cache that's + // just been created & has been written to, but hasn't been "committed" yet. + applyFlightData(updatedCache, updatedCache, flightDataPath) + } + } else { + // When flightData is a string, it suggests that the server response should have triggered an MPA navigation + // I'm not 100% sure of this decision, but it seems unlikely that we'd want to introduce a redirect side effect + // when refreshing on-screen data, so handling this has been ommitted. + } + }) + + fetchPromises.push(fetchPromise) + } + + for (const key in parallelRoutes) { + const parallelFetchPromise = refreshInactiveParallelSegmentsImpl({ + state, + updatedTree: parallelRoutes[key], + updatedCache, + includeNextUrl, + fetchedSegments, + }) + + fetchPromises.push(parallelFetchPromise) + } + + await Promise.all(fetchPromises) +} + +/** + * Walks the current parallel segments to determine if they are "active". + * An active parallel route will have a `__PAGE__` segment in the FlightRouterState. + * As opposed to a `__DEFAULT__` segment, which means there was no match for that parallel route. + * We add a special marker here so that we know how to refresh its data when the router is revalidated. + */ +export function addRefreshMarkerToActiveParallelSegments( + tree: FlightRouterState, + pathname: string +) { + const [segment, parallelRoutes, , refetchMarker] = tree + if (segment === PAGE_SEGMENT_KEY && refetchMarker !== 'refresh') { + tree[2] = pathname + tree[3] = 'refresh' + } + + for (const key in parallelRoutes) { + addRefreshMarkerToActiveParallelSegments(parallelRoutes[key], pathname) + } +} diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 60f5520fb4..7ed9c21b9e 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -38,7 +38,7 @@ export const flightRouterStateSchema: s.Describe = s.tuple([ s.lazy(() => flightRouterStateSchema) ), s.optional(s.nullable(s.string())), - s.optional(s.nullable(s.literal('refetch'))), + s.optional(s.nullable(s.union([s.literal('refetch'), s.literal('refresh')]))), s.optional(s.boolean()), ]) @@ -49,7 +49,13 @@ export type FlightRouterState = [ segment: Segment, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, url?: string | null, - refresh?: 'refetch' | null, + /* + /* "refresh" and "refetch", despite being similarly named, have different semantics. + * - "refetch" is a server indicator which informs where rendering should start from. + * - "refresh" is a client router indicator that it should re-fetch the data from the server for the current segment. + * It uses the "url" property above to determine where to fetch from. + */ + refresh?: 'refetch' | 'refresh' | null, isRootLayout?: boolean ] diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/@interception/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/@interception/page.tsx new file mode 100644 index 0000000000..c17431379f --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/@interception/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/layout.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/layout.tsx index 2f859f4c66..f0b8bab90f 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/app/layout.tsx +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/layout.tsx @@ -11,6 +11,9 @@ export default function Root({ }) { return ( + + + {children} {dialog} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/default.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/default.tsx new file mode 100644 index 0000000000..86b9e9a388 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/drawer/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/drawer/page.tsx new file mode 100644 index 0000000000..e03c9ead85 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/drawer/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { revalidateAction } from '../../@modal/modal/action' + +export default function Page() { + const router = useRouter() + + const handleRevalidateSubmit = async () => { + const result = await revalidateAction() + if (result.success) { + close() + } + } + + const close = () => { + router.back() + } + + return ( +
+

Drawer

+

{Date.now()}

+ + +

Drawer

+
+ + Open modal + +
+ +
+
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/default.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/default.tsx new file mode 100644 index 0000000000..86b9e9a388 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts new file mode 100644 index 0000000000..3a45e68c6e --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts @@ -0,0 +1,11 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +export async function revalidateAction() { + console.log('revalidate action') + revalidatePath('/') + return { + success: true, + } +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/page.tsx new file mode 100644 index 0000000000..dd36fae113 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/page.tsx @@ -0,0 +1,43 @@ +'use client' +import { useRouter } from 'next/navigation' +import { revalidateAction } from './action' + +export default function Page() { + const router = useRouter() + + const handleRevalidateSubmit = async () => { + const result = await revalidateAction() + if (result.success) { + close() + } + } + + const close = () => { + router.back() + } + + return ( +
+
+ + +
+
+ +
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/layout.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/layout.tsx new file mode 100644 index 0000000000..3aa8736a49 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/layout.tsx @@ -0,0 +1,19 @@ +export const dynamic = 'force-dynamic' + +export default function Layout({ + children, + modal, + drawer, +}: { + children: React.ReactNode + modal: React.ReactNode + drawer: React.ReactNode +}) { + return ( +
+
{children}
+
{modal}
+
{drawer}
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/page.tsx new file mode 100644 index 0000000000..d79d96d3d0 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+

Nested parallel routes demo.

+

Date.now {Date.now()}

+
+ + Open Drawer + + + Open Modal + +
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx new file mode 100644 index 0000000000..cdab476c92 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/(.)login/page.tsx @@ -0,0 +1,19 @@ +import { Button } from '../../buttonRefresh' + +const getRandom = async () => Math.random() + +export default async function Page() { + const someProp = await getRandom() + + return ( + +
+
+ Modal Page + {someProp} +
+
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/default.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/default.tsx new file mode 100644 index 0000000000..86b9e9a388 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return null +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/buttonRefresh.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/buttonRefresh.tsx new file mode 100644 index 0000000000..a839189509 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/buttonRefresh.tsx @@ -0,0 +1,16 @@ +'use client' +import { useRouter } from 'next/navigation' + +export function Button() { + const router = useRouter() + + return ( + + ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/layout.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/layout.tsx new file mode 100644 index 0000000000..deb3fac9dc --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/layout.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export const dynamic = 'force-dynamic' + +export default function Layout({ + children, + modal, +}: { + children: React.ReactNode + modal: React.ReactNode +}) { + return ( +
+
{children}
+
{modal}
+ Go to Other Page +
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/login/page.tsx b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/login/page.tsx new file mode 100644 index 0000000000..950b92a332 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-revalidation/app/refreshing/login/page.tsx @@ -0,0 +1,11 @@ +import { Button } from '../buttonRefresh' + +export default function Page() { + return ( + <> + Login Page + + +
+ Random # from Root Page: {Math.random()} +
+ + ) +} diff --git a/test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts b/test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts index a3c594b7e9..033f35457d 100644 --- a/test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts +++ b/test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts @@ -157,5 +157,135 @@ createNextDescribe( expect(await browser.elementById('params').text()).toBe('foobar') }) }) + + describe('router.refresh', () => { + it('should correctly refresh data for the intercepted route and previously active page slot', async () => { + const browser = await next.browser('/refreshing') + const initialRandomNumber = await browser.elementById('random-number') + + await browser.elementByCss("[href='/refreshing/login']").click() + + // interception modal should be visible + const initialModalRandomNumber = await browser + .elementById('modal-random') + .text() + + // trigger a refresh + await browser.elementById('refresh-button').click() + + await retry(async () => { + const newRandomNumber = await browser + .elementById('random-number') + .text() + const newModalRandomNumber = await browser + .elementById('modal-random') + .text() + expect(initialRandomNumber).not.toBe(newRandomNumber) + expect(initialModalRandomNumber).not.toBe(newModalRandomNumber) + }) + + // reload the page, triggering which will remove the interception route and show the full page + await browser.refresh() + + const initialLoginPageRandomNumber = await browser + .elementById('login-page-random') + .text() + + // trigger a refresh + await browser.elementById('refresh-button').click() + + await retry(async () => { + const newLoginPageRandomNumber = await browser + .elementById('login-page-random') + .text() + + expect(newLoginPageRandomNumber).not.toBe( + initialLoginPageRandomNumber + ) + }) + }) + + it('should correctly refresh data for previously intercepted modal and active page slot', async () => { + const browser = await next.browser('/refreshing') + + await browser.elementByCss("[href='/refreshing/login']").click() + + // interception modal should be visible + const initialModalRandomNumber = await browser + .elementById('modal-random') + .text() + + await browser.elementByCss("[href='/refreshing/other']").click() + // data for the /other page should be visible + + const initialOtherPageRandomNumber = await browser + .elementById('other-page-random') + .text() + + // trigger a refresh + await browser.elementById('refresh-button').click() + + await retry(async () => { + const newModalRandomNumber = await browser + .elementById('modal-random') + .text() + + const newOtherPageRandomNumber = await browser + .elementById('other-page-random') + .text() + expect(initialModalRandomNumber).not.toBe(newModalRandomNumber) + expect(initialOtherPageRandomNumber).not.toBe( + newOtherPageRandomNumber + ) + }) + }) + }) + + describe('server action revalidation', () => { + it('handles refreshing when multiple parallel slots are active', async () => { + const browser = await next.browser('/nested-revalidate') + + const currentPageTime = await browser.elementById('page-now').text() + + expect(await browser.hasElementByCssSelector('#modal')).toBe(false) + expect(await browser.hasElementByCssSelector('#drawer')).toBe(false) + + // renders the drawer parallel slot + await browser.elementByCss("[href='/nested-revalidate/drawer']").click() + await browser.waitForElementByCss('#drawer') + + // renders the modal slot + await browser.elementByCss("[href='/nested-revalidate/modal']").click() + await browser.waitForElementByCss('#modal') + + // Both should be visible, despite only one "matching" + expect(await browser.hasElementByCssSelector('#modal')).toBe(true) + expect(await browser.hasElementByCssSelector('#drawer')).toBe(true) + + // grab the current time of the drawer + const currentDrawerTime = await browser.elementById('drawer-now').text() + + // trigger the revalidation action in the modal. + await browser.elementById('modal-submit-button').click() + + await retry(async () => { + // Revalidation should close the modal + expect(await browser.hasElementByCssSelector('#modal')).toBe(false) + + // But the drawer should still be open + expect(await browser.hasElementByCssSelector('#drawer')).toBe(true) + + // And the drawer should have a new time + expect(await browser.elementById('drawer-now').text()).not.toEqual( + currentDrawerTime + ) + + // And the underlying page should have a new time + expect(await browser.elementById('page-now').text()).not.toEqual( + currentPageTime + ) + }) + }) + }) } ) diff --git a/test/turbopack-build-tests-manifest.json b/test/turbopack-build-tests-manifest.json index 1982d98a49..7129efe017 100644 --- a/test/turbopack-build-tests-manifest.json +++ b/test/turbopack-build-tests-manifest.json @@ -2251,7 +2251,10 @@ "failed": [ "parallel-routes-revalidation should handle a redirect action when called in a slot", "parallel-routes-revalidation should handle router.refresh() when called in a slot", - "parallel-routes-revalidation should not trigger full page when calling router.refresh() on an intercepted route" + "parallel-routes-revalidation should not trigger full page when calling router.refresh() on an intercepted route", + "parallel-routes-revalidation router.refresh should correctly refresh data for the intercepted route and previously active page slot", + "parallel-routes-revalidation router.refresh should correctly refresh data for previously intercepted modal and active page slot", + "parallel-routes-revalidation server action revalidation handles refreshing when multiple parallel slots are active" ], "pending": [], "flakey": [],