diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index e096a38f1836b..483dc8d0391fb 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -264,6 +264,7 @@ function Router({ initialTree, initialCanonicalUrl, initialSeedData, + initialFlightData, assetPrefix, missingSlots, }: AppRouterProps) { @@ -275,11 +276,18 @@ function Router({ initialCanonicalUrl, initialTree, initialParallelRoutes, - isServer, location: !isServer ? window.location : null, initialHead, + initialFlightData, }), - [buildId, initialSeedData, initialCanonicalUrl, initialTree, initialHead] + [ + buildId, + initialSeedData, + initialCanonicalUrl, + initialTree, + initialHead, + initialFlightData, + ] ) const [reducerState, dispatch, sync] = useReducerWithReduxDevtools(initialState) diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx index 523a259fbac9a..f959febfa6940 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import type { FlightRouterState } from '../../../server/app-render/types' import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime' import { createInitialRouterState } from './create-initial-router-state' +import { PrefetchKind } from './router-reducer-types' const buildId = 'development' @@ -37,8 +38,8 @@ describe('createInitialRouterState', () => { initialTree, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, initialHead: Test, }) @@ -48,8 +49,8 @@ describe('createInitialRouterState', () => { initialTree, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, initialHead: Test, }) @@ -96,7 +97,19 @@ describe('createInitialRouterState', () => { buildId, tree: initialTree, canonicalUrl: initialCanonicalUrl, - prefetchCache: new Map(), + prefetchCache: new Map([ + [ + '/linking', + { + key: '/linking', + data: expect.any(Promise), + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + treeAtTimeOfPrefetch: initialTree, + }, + ], + ]), pushRef: { pendingPush: false, mpaNavigation: 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 deaca80dc00ff..1b98ae0f6d1e0 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 @@ -3,11 +3,14 @@ import type { CacheNode } from '../../../shared/lib/app-router-context.shared-ru import type { FlightRouterState, CacheNodeSeedData, + FlightData, } from '../../../server/app-render/types' import { createHrefFromUrl } from './create-href-from-url' import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-head' import { extractPathFromFlightRouterState } from './compute-changed-path' +import { createPrefetchCacheKey } from './reducers/prefetch-cache-utils' +import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types' export interface InitialRouterStateParameters { buildId: string @@ -15,7 +18,7 @@ export interface InitialRouterStateParameters { initialCanonicalUrl: string initialSeedData: CacheNodeSeedData initialParallelRoutes: CacheNode['parallelRoutes'] - isServer: boolean + initialFlightData: FlightData location: Location | null initialHead: ReactNode } @@ -26,10 +29,11 @@ export function createInitialRouterState({ initialSeedData, initialCanonicalUrl, initialParallelRoutes, - isServer, + initialFlightData, location, initialHead, }: InitialRouterStateParameters) { + const isServer = !location const rsc = initialSeedData[2] const cache: CacheNode = { @@ -40,6 +44,25 @@ export function createInitialRouterState({ parallelRoutes: isServer ? new Map() : initialParallelRoutes, } + const prefetchCache = new Map() + + if (location && initialFlightData.length > 0) { + // Seed the prefetch cache with this page's data. + // This is to prevent needlessly re-prefetching a page that is already reusable, + // and will avoid triggering a loading state/data fetch stall when navigating back to the page. + const url = new URL(location.pathname, location.origin) + const cacheKey = createPrefetchCacheKey(url) + + prefetchCache.set(cacheKey, { + data: Promise.resolve([initialFlightData, undefined, false, false]), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + prefetchTime: Date.now(), + key: cacheKey, + treeAtTimeOfPrefetch: initialTree, + }) + } + // When the cache hasn't been seeded yet we fill the cache with the head. if (initialParallelRoutes === null || initialParallelRoutes.size === 0) { fillLazyItemsTillLeafWithHead( @@ -55,7 +78,7 @@ export function createInitialRouterState({ buildId, tree: initialTree, cache, - prefetchCache: new Map(), + prefetchCache, pushRef: { pendingPush: false, mpaNavigation: false, diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx index b7cf15ce625f4..387efa7a7c072 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx @@ -163,8 +163,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) const action: NavigateAction = { @@ -254,6 +254,30 @@ describe('navigateReducer', () => { }, "nextUrl": "/linking/about", "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, "/linking/about" => { "data": Promise {}, "key": "/linking/about", @@ -357,8 +381,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -449,6 +473,30 @@ describe('navigateReducer', () => { }, "nextUrl": "/linking/about", "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, "/linking/about" => { "data": Promise {}, "key": "/linking/about", @@ -552,8 +600,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -615,7 +663,32 @@ describe('navigateReducer', () => { "segmentPaths": [], }, "nextUrl": "/linking", - "prefetchCache": Map {}, + "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, + }, "pushRef": { "mpaNavigation": true, "pendingPush": true, @@ -689,8 +762,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -752,7 +825,32 @@ describe('navigateReducer', () => { "segmentPaths": [], }, "nextUrl": "/linking", - "prefetchCache": Map {}, + "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, + }, "pushRef": { "mpaNavigation": true, "pendingPush": false, @@ -826,8 +924,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking#hash', 'https://localhost') as any, }) @@ -878,7 +976,7 @@ describe('navigateReducer', () => { , }, - "canonicalUrl": "", + "canonicalUrl": "/linking#hash", "focusAndScrollRef": { "apply": false, "hashFragment": null, @@ -890,7 +988,7 @@ describe('navigateReducer', () => { "/linking" => { "data": Promise {}, "key": "/linking", - "kind": "temporary", + "kind": "auto", "lastUsedTime": 1690329600000, "prefetchTime": 1690329600000, "treeAtTimeOfPrefetch": [ @@ -992,8 +1090,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -1114,6 +1212,30 @@ describe('navigateReducer', () => { }, "nextUrl": "/linking/about", "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, "/linking/about" => { "data": Promise {}, "key": "/linking/about", @@ -1261,8 +1383,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/parallel-tab-bar', 'https://localhost') as any, }) @@ -1369,6 +1491,38 @@ describe('navigateReducer', () => { }, "nextUrl": "/parallel-tab-bar/demographics", "prefetchCache": Map { + "/parallel-tab-bar" => { + "data": Promise {}, + "key": "/parallel-tab-bar", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "parallel-tab-bar", + { + "audience": [ + "__PAGE__", + {}, + ], + "children": [ + "__PAGE__", + {}, + ], + "views": [ + "__PAGE__", + {}, + ], + }, + ], + }, + null, + null, + true, + ], + }, "/parallel-tab-bar/demographics" => { "data": Promise {}, "key": "/parallel-tab-bar/demographics", @@ -1488,8 +1642,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking#hash', 'https://localhost') as any, }) @@ -1548,7 +1702,32 @@ describe('navigateReducer', () => { "segmentPaths": [], }, "nextUrl": "/linking", - "prefetchCache": Map {}, + "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, + }, "pushRef": { "mpaNavigation": false, "pendingPush": true, @@ -1622,8 +1801,8 @@ describe('navigateReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) const action: NavigateAction = { @@ -1713,6 +1892,30 @@ describe('navigateReducer', () => { }, "nextUrl": "/linking/about", "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "__PAGE__", + {}, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, "/linking/about" => { "data": Promise {}, "key": "/linking/about", diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx index 88efb764f6b3a..6d64a7401d24c 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx @@ -5,7 +5,10 @@ import type { FlightRouterState } from '../../../../server/app-render/types' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { createInitialRouterState } from '../create-initial-router-state' import { ACTION_PREFETCH, PrefetchKind } from '../router-reducer-types' -import type { PrefetchAction } from '../router-reducer-types' +import type { + PrefetchAction, + PrefetchCacheEntry, +} from '../router-reducer-types' import { prefetchReducer } from './prefetch-reducer' import { fetchServerResponse } from '../fetch-server-response' @@ -117,8 +120,8 @@ describe('prefetchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -145,6 +148,17 @@ describe('prefetchReducer', () => { const expectedState: ReturnType = { buildId: 'development', prefetchCache: new Map([ + [ + '/linking', + { + key: '/linking', + data: expect.any(Promise), + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + treeAtTimeOfPrefetch: initialTree, + }, + ], [ '/linking/about', { @@ -260,8 +274,8 @@ describe('prefetchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -271,8 +285,8 @@ describe('prefetchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -299,9 +313,30 @@ describe('prefetchReducer', () => { const prom = Promise.resolve(serverResponse) await prom + const prefetchCache = new Map() + prefetchCache.set('/linking', { + data: expect.any(Promise), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + prefetchTime: expect.any(Number), + treeAtTimeOfPrefetch: initialTree, + key: '/linking', + }) + const expectedState: ReturnType = { buildId: 'development', prefetchCache: new Map([ + [ + '/linking', + { + key: '/linking', + data: expect.any(Promise), + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + treeAtTimeOfPrefetch: initialTree, + }, + ], [ '/linking/about', { diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx index d3223f5cc74bd..27d75e9ab90dd 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx @@ -128,8 +128,8 @@ describe('refreshReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) const action: RefreshAction = { @@ -271,8 +271,8 @@ describe('refreshReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -282,8 +282,8 @@ describe('refreshReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -452,8 +452,8 @@ describe('refreshReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -463,8 +463,8 @@ describe('refreshReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -627,76 +627,27 @@ describe('refreshReducer', () => { ], ]) - const prefetchItem = { - canonicalUrlOverride: undefined, - flightData: [ - [ - '', - { - children: [ - 'linking', - { - children: [ - 'about', - { - children: ['', {}], - }, - ], - }, - ], - }, - undefined, - undefined, - true, - ], - <>About, - <>Head, - ], - tree: [ - '', - { - children: [ - 'linking', - { - children: [ - 'about', - { - children: ['', {}], - }, - ], - }, - ], - }, - undefined, - undefined, - true, - ], - } - const state = createInitialRouterState({ buildId, initialTree, initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) - state.prefetchCache.set('/linking/about', prefetchItem) - const state2 = createInitialRouterState({ buildId, initialTree, initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) - state2.prefetchCache.set('/linking/about', prefetchItem) const action: RefreshAction = { type: ACTION_REFRESH, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx index e90c46e1673ec..b3944f00dd74f 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import type { FlightRouterState } from '../../../../server/app-render/types' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { createInitialRouterState } from '../create-initial-router-state' -import { ACTION_RESTORE } from '../router-reducer-types' +import { ACTION_RESTORE, PrefetchKind } from '../router-reducer-types' import type { RestoreAction } from '../router-reducer-types' import { restoreReducer } from './restore-reducer' @@ -84,8 +84,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) const action: RestoreAction = { @@ -118,7 +118,19 @@ describe('serverPatchReducer', () => { const expectedState: ReturnType = { buildId, - prefetchCache: new Map(), + prefetchCache: new Map([ + [ + '/linking', + { + key: '/linking', + data: expect.any(Promise), + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + treeAtTimeOfPrefetch: initialTree, + }, + ], + ]), pushRef: { mpaNavigation: false, pendingPush: false, @@ -239,8 +251,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) const state2 = createInitialRouterState({ @@ -249,8 +261,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking', 'https://localhost') as any, }) @@ -286,7 +298,19 @@ describe('serverPatchReducer', () => { const expectedState: ReturnType = { buildId, - prefetchCache: new Map(), + prefetchCache: new Map([ + [ + '/linking', + { + key: '/linking', + data: expect.any(Promise), + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, + treeAtTimeOfPrefetch: initialTree, + }, + ], + ]), pushRef: { mpaNavigation: false, pendingPush: false, diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx index 3ad5b14e2b146..a9eabcc08d433 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx @@ -135,8 +135,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL('/linking/about', 'https://localhost') as any, }) const action: ServerPatchAction = { @@ -227,7 +227,37 @@ describe('serverPatchReducer', () => { "segmentPaths": [], }, "nextUrl": "/linking/somewhere-else", - "prefetchCache": Map {}, + "prefetchCache": Map { + "/linking/about" => { + "data": Promise {}, + "key": "/linking/about", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "about", + { + "children": [ + "", + {}, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, + }, "pushRef": { "mpaNavigation": false, "pendingPush": false, @@ -315,8 +345,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes, - isServer: false, location: new URL(initialCanonicalUrl, 'https://localhost') as any, }) @@ -442,6 +472,35 @@ describe('serverPatchReducer', () => { }, "nextUrl": "/linking/somewhere-else", "prefetchCache": Map { + "/linking" => { + "data": Promise {}, + "key": "/linking", + "kind": "auto", + "lastUsedTime": null, + "prefetchTime": 1690329600000, + "treeAtTimeOfPrefetch": [ + "", + { + "children": [ + "linking", + { + "children": [ + "about", + { + "children": [ + "", + {}, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ], + }, "/linking/about" => { "data": Promise {}, "key": "/linking/about", @@ -519,8 +578,8 @@ describe('serverPatchReducer', () => { initialHead: null, initialCanonicalUrl, initialSeedData: ['', {}, children], + initialFlightData: [['']], initialParallelRoutes: new Map(), - isServer: false, location: new URL('/linking/about', 'https://localhost') as any, }) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index bdfef0f288bd0..57e8bf7719bc0 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -86,6 +86,8 @@ import { useFlightResponse } from './use-flight-response' import { isStaticGenBailoutError } from '../../client/components/static-generation-bailout' import { isInterceptionRouteAppPath } from '../future/helpers/interception-routes' import { getStackWithoutErrorMessage } from '../../lib/format-server-error' +import { hasLoadingComponentInTree } from './has-loading-component-in-tree' +import type { ComponentsType } from '../../build/webpack/loaders/next-app-loader' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -240,6 +242,24 @@ function makeGetDynamicParamFromSegment( } } +/** + * Signals to `walkTreeWithFlightRouterState` that it does not need to call `createComponentTree` + */ +function shouldSkipComponentTree( + ctx: AppRenderContext, + components: ComponentsType +): boolean { + return Boolean( + // loading.tsx has no effect on prefetching when PPR is enabled + !ctx.renderOpts.experimental.ppr && + ctx.isPrefetch && + !Boolean(components.loading) && + (ctx.providedFlightRouterState || + // If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root + !hasLoadingComponentInTree(ctx.componentMod.tree)) + ) +} + // Handle Flight render request. This is only used when client-side navigating. E.g. when you `router.push('/dashboard')` or `router.reload()`. async function generateFlight( ctx: AppRenderContext, @@ -295,6 +315,7 @@ async function generateFlight( rootLayoutIncluded: false, asNotFound: ctx.isNotFoundPath || options?.asNotFound, metadataOutlet: , + shouldSkipComponentTree, }) ).map((path) => path.slice(1)) // remove the '' (root) segment } @@ -422,6 +443,27 @@ async function ReactServerApp({ missingSlots, }) + const initialFlightData = await walkTreeWithFlightRouterState({ + ctx, + createSegmentPath: (child) => child, + loaderTreeToFilter: tree, + parentParams: {}, + flightRouterState: ctx.providedFlightRouterState, + isFirst: true, + // For flight, render metadata inside leaf page + rscPayloadHead: ( + // Adding requestId as react key to make metadata remount for each render + + ), + injectedCSS: new Set(), + injectedJS: new Set(), + injectedFontPreloadTags: new Set(), + rootLayoutIncluded: false, + asNotFound: ctx.isNotFoundPath, + metadataOutlet: , + shouldSkipComponentTree: () => true, // no need to create a component tree as we've already done that above + }) + return ( <> {styles} @@ -433,6 +475,13 @@ async function ReactServerApp({ initialTree={initialTree} // This is the tree of React nodes that are seeded into the cache initialSeedData={seedData} + // This is the FlightDataPath that is used to seed the prefetch cache with the loaded page + initialFlightData={ + // Avoid seeding the prefetch cache with the page if it could be handled by an interception route + // Technically we should be able to seed the cache for these pages, but to avoid the complexity of + // creating prefixed cache keys on both the server and client, we'll just omit it for now. + ctx.renderOpts.couldBeIntercepted ? [] : initialFlightData + } initialHead={ <> {ctx.res.statusCode > 400 && ( @@ -522,6 +571,7 @@ async function ReactServerError({ assetPrefix={ctx.assetPrefix} initialCanonicalUrl={urlPathname} initialTree={initialTree} + initialFlightData={[]} initialHead={head} globalErrorComponent={GlobalError} initialSeedData={initialSeedData} diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 800e7a53d5a24..726ed66638805 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -144,6 +144,7 @@ export interface RenderOptsPartial { isPrefetch?: boolean experimental: { ppr: boolean; missingSuspenseWithCSRBailout: boolean } postponed?: string + couldBeIntercepted?: boolean } export type RenderOpts = LoadComponentsReturnType & diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index 3a0dd77ea61ec..53b6536a9eb47 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -19,9 +19,9 @@ import { import { parseLoaderTree } from './parse-loader-tree' import type { CreateSegmentPath, AppRenderContext } from './app-render' import { getLayerAssets } from './get-layer-assets' -import { hasLoadingComponentInTree } from './has-loading-component-in-tree' import { createComponentTree } from './create-component-tree' import { DEFAULT_SEGMENT_KEY } from '../../shared/lib/segment' +import type { ComponentsType } from '../../build/webpack/loaders/next-app-loader' /** * Use router state to decide at what common layout to render the page. @@ -42,6 +42,7 @@ export async function walkTreeWithFlightRouterState({ asNotFound, metadataOutlet, ctx, + shouldSkipComponentTree, }: { createSegmentPath: CreateSegmentPath loaderTreeToFilter: LoaderTree @@ -57,13 +58,15 @@ export async function walkTreeWithFlightRouterState({ asNotFound?: boolean metadataOutlet: React.ReactNode ctx: AppRenderContext + shouldSkipComponentTree: ( + ctx: AppRenderContext, + components: ComponentsType + ) => boolean }): Promise { const { - renderOpts: { nextFontManifest, experimental }, + renderOpts: { nextFontManifest }, query, - isPrefetch, getDynamicParamFromSegment, - componentMod: { tree: loaderTree }, } = ctx const [segment, parallelRoutes, components] = loaderTreeToFilter @@ -111,15 +114,6 @@ export async function walkTreeWithFlightRouterState({ // Explicit refresh flightRouterState[3] === 'refetch' - const shouldSkipComponentTree = - // loading.tsx has no effect on prefetching when PPR is enabled - !experimental.ppr && - isPrefetch && - !Boolean(components.loading) && - (flightRouterState || - // If there is no flightRouterState, we need to check the entire loader tree, as otherwise we'll be only checking the root - !hasLoadingComponentInTree(loaderTree)) - if (!parentRendered && renderComponentsOnThisLevel) { const overriddenSegment = flightRouterState && @@ -134,7 +128,7 @@ export async function walkTreeWithFlightRouterState({ query ) - if (shouldSkipComponentTree) { + if (shouldSkipComponentTree(ctx, components)) { // Send only the router state return [[overriddenSegment, routerState, null, null]] } else { @@ -230,6 +224,7 @@ export async function walkTreeWithFlightRouterState({ rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove, asNotFound, metadataOutlet, + shouldSkipComponentTree, }) return path diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e3fbc3a43d4b4..342387213c283 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1765,6 +1765,7 @@ export default abstract class Server { const isServerAction = getIsServerAction(req) const hasGetInitialProps = !!components.Component?.getInitialProps let isSSG = !!components.getStaticProps + let couldBeIntercepted = false // Compute the iSSG cache key. We use the rewroteUrl since // pages with fallback: false are allowed to be rewritten to @@ -2000,11 +2001,9 @@ export default abstract class Server { if (isAppPath) { res.setHeader('vary', RSC_VARY_HEADER) - const couldBeIntercepted = this.interceptionRouteRewrites?.some( - (rewrite) => { - return new RegExp(rewrite.regex).test(resolvedUrlPathname) - } - ) + couldBeIntercepted = this.interceptionRouteRewrites?.some((rewrite) => { + return new RegExp(rewrite.regex).test(resolvedUrlPathname) + }) // Interception route responses can vary based on the `Next-URL` header as they're rewritten to different components. // This means that multiple route interception responses can resolve to the same URL. We use the Vary header to signal this @@ -2244,6 +2243,7 @@ export default abstract class Server { isDraftMode: isPreviewMode, isServerAction, postponed, + couldBeIntercepted, } // Legacy render methods will return a render result that needs to be diff --git a/test/e2e/app-dir/app-client-cache/client-cache.test.ts b/test/e2e/app-dir/app-client-cache/client-cache.test.ts index 8d369220885d8..581dc1f23d08b 100644 --- a/test/e2e/app-dir/app-client-cache/client-cache.test.ts +++ b/test/e2e/app-dir/app-client-cache/client-cache.test.ts @@ -390,6 +390,25 @@ createNextDescribe( expect(newNumber).not.toBe(randomNumber) }) }) + + it('should seed the prefetch cache with the fetched page data', async () => { + const browser = (await next.browser( + '/1', + browserConfigWithFixedTime + )) as BrowserInterface + + const initialNumber = await browser.elementById('random-number').text() + + // Move forward a few seconds, navigate off the page and then back to it + await browser.eval(fastForwardTo, 5 * 1000) + await browser.elementByCss('[href="/"]').click() + await browser.elementByCss('[href="/1"]').click() + + const newNumber = await browser.elementById('random-number').text() + + // The number should be the same as we've seeded it in the prefetch cache when we loaded the full page + expect(newNumber).toBe(initialNumber) + }) describe('router.push', () => { it('should re-use the cache for 30 seconds', async () => {}) it('should fully refetch the page after 30 seconds', async () => {})