diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 02edee0fe8da7..8b99571acd92b 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -25,6 +25,7 @@ import { ACTION_REFRESH, ACTION_RESTORE, ACTION_SERVER_PATCH, + PrefetchKind, } from './router-reducer/router-reducer-types' import { createHrefFromUrl } from './router-reducer/create-href-from-url' import { @@ -234,7 +235,7 @@ function Router({ const routerInstance: AppRouterInstance = { back: () => window.history.back(), forward: () => window.history.forward(), - prefetch: async (href) => { + prefetch: async (href, options) => { // If prefetch has already been triggered, don't trigger it again. if (isBot(window.navigator.userAgent)) { return @@ -244,12 +245,12 @@ function Router({ if (isExternalURL(url)) { return } - // @ts-ignore startTransition exists React.startTransition(() => { dispatch({ type: ACTION_PREFETCH, url, + kind: options?.kind ?? PrefetchKind.FULL, }) }) }, diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index fb57a0dbe308a..5f93463c79b74 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -277,16 +277,7 @@ function InnerLayoutRouter({ // TODO-APP: verify if this can be null based on user code childProp.current !== null ) { - if (childNode) { - if (childNode.status === CacheStates.LAZY_INITIALIZED) { - // @ts-expect-error we're changing it's type! - childNode.status = CacheStates.READY - // @ts-expect-error - childNode.subTreeData = childProp.current - // Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache. - childProp.current = null - } - } else { + if (!childNode) { // Add the segment's subTreeData to the cache. // This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode. childNodes.set(cacheKey, { @@ -295,10 +286,15 @@ function InnerLayoutRouter({ subTreeData: childProp.current, parallelRoutes: new Map(), }) - // Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache. - childProp.current = null // In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again. childNode = childNodes.get(cacheKey) + } else { + if (childNode.status === CacheStates.LAZY_INITIALIZED) { + // @ts-expect-error we're changing it's type! + childNode.status = CacheStates.READY + // @ts-expect-error + childNode.subTreeData = childProp.current + } } } diff --git a/packages/next/src/client/components/router-reducer/apply-flight-data.ts b/packages/next/src/client/components/router-reducer/apply-flight-data.ts index eba71a3f901c4..e7a2f11a84f48 100644 --- a/packages/next/src/client/components/router-reducer/apply-flight-data.ts +++ b/packages/next/src/client/components/router-reducer/apply-flight-data.ts @@ -7,7 +7,7 @@ export function applyFlightData( existingCache: CacheNode, cache: CacheNode, flightDataPath: FlightDataPath, - wasPrefetched?: boolean + wasPrefetched: boolean = false ): boolean { // The one before last item is the router state tree patch const [treePatch, subTreeData, head] = flightDataPath.slice(-3) @@ -33,7 +33,12 @@ export function applyFlightData( cache.subTreeData = existingCache.subTreeData cache.parallelRoutes = new Map(existingCache.parallelRoutes) // Create a copy of the existing cache with the subTreeData applied. - fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath) + fillCacheWithNewSubTreeData( + cache, + existingCache, + flightDataPath, + wasPrefetched + ) } return true diff --git a/packages/next/src/client/components/router-reducer/fetch-server-response.ts b/packages/next/src/client/components/router-reducer/fetch-server-response.ts index ae11340ca5645..b4cec07e24bfb 100644 --- a/packages/next/src/client/components/router-reducer/fetch-server-response.ts +++ b/packages/next/src/client/components/router-reducer/fetch-server-response.ts @@ -14,6 +14,7 @@ import { } from '../app-router-headers' import { urlToUrlWithoutFlightMarker } from '../app-router' import { callServer } from '../../app-call-server' +import { PrefetchKind } from './router-reducer-types' /** * Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side. @@ -23,7 +24,7 @@ export async function fetchServerResponse( url: URL, flightRouterState: FlightRouterState, nextUrl: string | null, - prefetch?: true + prefetchKind?: PrefetchKind ): Promise<[FlightData: FlightData, canonicalUrlOverride: URL | undefined]> { const headers: { [RSC]: '1' @@ -36,8 +37,14 @@ export async function fetchServerResponse( // Provide the current router state [NEXT_ROUTER_STATE_TREE]: JSON.stringify(flightRouterState), } - if (prefetch) { - // Enable prefetch response + + /** + * Three cases: + * - `prefetchKind` is `undefined`, it means it's a normal navigation, so we want to prefetch the page data fully + * - `prefetchKind` is `full` - we want to prefetch the whole page so same as above + * - `prefetchKind` is `auto` - if the page is dynamic, prefetch the page data partially, if static prefetch the page data fully + */ + if (prefetchKind === PrefetchKind.AUTO) { headers[NEXT_ROUTER_PREFETCH] = '1' } diff --git a/packages/next/src/client/components/router-reducer/fill-cache-with-data-property.ts b/packages/next/src/client/components/router-reducer/fill-cache-with-data-property.ts index 482485286629a..81df295dba302 100644 --- a/packages/next/src/client/components/router-reducer/fill-cache-with-data-property.ts +++ b/packages/next/src/client/components/router-reducer/fill-cache-with-data-property.ts @@ -1,4 +1,6 @@ +import { FlightSegmentPath } from '../../../server/app-render/types' import { CacheNode, CacheStates } from '../../../shared/lib/app-router-context' +import { createRouterCacheKey } from './create-router-cache-key' import { fetchServerResponse } from './fetch-server-response' /** @@ -7,19 +9,24 @@ import { fetchServerResponse } from './fetch-server-response' export function fillCacheWithDataProperty( newCache: CacheNode, existingCache: CacheNode, - segments: string[], - fetchResponse: () => ReturnType + flightSegmentPath: FlightSegmentPath, + fetchResponse: () => ReturnType, + bailOnParallelRoutes: boolean = false ): { bailOptimistic: boolean } | undefined { - const isLastEntry = segments.length === 1 + const isLastEntry = flightSegmentPath.length <= 2 - const parallelRouteKey = 'children' - const [segment] = segments + const [parallelRouteKey, segment] = flightSegmentPath + const cacheKey = createRouterCacheKey(segment) const existingChildSegmentMap = existingCache.parallelRoutes.get(parallelRouteKey) - if (!existingChildSegmentMap) { + if ( + !existingChildSegmentMap || + (bailOnParallelRoutes && existingCache.parallelRoutes.size > 1) + ) { // Bailout because the existing cache does not have the path to the leaf node + // or the existing cache has multiple parallel routes // Will trigger lazy fetch in layout-router because of missing segment return { bailOptimistic: true } } @@ -31,8 +38,8 @@ export function fillCacheWithDataProperty( newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap) } - const existingChildCacheNode = existingChildSegmentMap.get(segment) - let childCacheNode = childSegmentMap.get(segment) + const existingChildCacheNode = existingChildSegmentMap.get(cacheKey) + let childCacheNode = childSegmentMap.get(cacheKey) // In case of last segment start off the fetch at this level and don't copy further down. if (isLastEntry) { @@ -41,7 +48,7 @@ export function fillCacheWithDataProperty( !childCacheNode.data || childCacheNode === existingChildCacheNode ) { - childSegmentMap.set(segment, { + childSegmentMap.set(cacheKey, { status: CacheStates.DATA_FETCH, data: fetchResponse(), subTreeData: null, @@ -54,7 +61,7 @@ export function fillCacheWithDataProperty( if (!childCacheNode || !existingChildCacheNode) { // Start fetch in the place where the existing cache doesn't have the data yet. if (!childCacheNode) { - childSegmentMap.set(segment, { + childSegmentMap.set(cacheKey, { status: CacheStates.DATA_FETCH, data: fetchResponse(), subTreeData: null, @@ -71,13 +78,13 @@ export function fillCacheWithDataProperty( subTreeData: childCacheNode.subTreeData, parallelRoutes: new Map(childCacheNode.parallelRoutes), } as CacheNode - childSegmentMap.set(segment, childCacheNode) + childSegmentMap.set(cacheKey, childCacheNode) } return fillCacheWithDataProperty( childCacheNode, existingChildCacheNode, - segments.slice(1), + flightSegmentPath.slice(2), fetchResponse ) } diff --git a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx index c099852e24407..187f86a478751 100644 --- a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx +++ b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.test.tsx @@ -78,7 +78,7 @@ describe('fillCacheWithNewSubtreeData', () => { // Mirrors the way router-reducer values are passed in. const flightDataPath = flightData[0] - fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath) + fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath, false) const expectedCache: CacheNode = { data: null, diff --git a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts index f45d556546990..5d48eaee9ef9f 100644 --- a/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts +++ b/packages/next/src/client/components/router-reducer/fill-cache-with-new-subtree-data.ts @@ -10,7 +10,8 @@ import { createRouterCacheKey } from './create-router-cache-key' export function fillCacheWithNewSubTreeData( newCache: CacheNode, existingCache: CacheNode, - flightDataPath: FlightDataPath + flightDataPath: FlightDataPath, + wasPrefetched?: boolean ): void { const isLastEntry = flightDataPath.length <= 5 const [parallelRouteKey, segment] = flightDataPath @@ -63,7 +64,8 @@ export function fillCacheWithNewSubTreeData( childCacheNode, existingChildCacheNode, flightDataPath[2], - flightDataPath[4] + flightDataPath[4], + wasPrefetched ) childSegmentMap.set(cacheKey, childCacheNode) @@ -90,6 +92,7 @@ export function fillCacheWithNewSubTreeData( fillCacheWithNewSubTreeData( childCacheNode, existingChildCacheNode, - flightDataPath.slice(2) + flightDataPath.slice(2), + wasPrefetched ) } diff --git a/packages/next/src/client/components/router-reducer/get-prefetch-cache-entry-status.ts b/packages/next/src/client/components/router-reducer/get-prefetch-cache-entry-status.ts new file mode 100644 index 0000000000000..f2a6cedb134b0 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/get-prefetch-cache-entry-status.ts @@ -0,0 +1,40 @@ +import { PrefetchCacheEntry } from './router-reducer-types' + +const FIVE_MINUTES = 5 * 60 * 1000 +const THIRTY_SECONDS = 30 * 1000 + +export enum PrefetchCacheEntryStatus { + fresh = 'fresh', + reusable = 'reusable', + expired = 'expired', + stale = 'stale', +} + +export function getPrefetchEntryCacheStatus({ + kind, + prefetchTime, + lastUsedTime, +}: PrefetchCacheEntry): PrefetchCacheEntryStatus { + // if the cache entry was prefetched or read less than 30s ago, then we want to re-use it + if (Date.now() < (lastUsedTime ?? prefetchTime) + THIRTY_SECONDS) { + return lastUsedTime + ? PrefetchCacheEntryStatus.reusable + : PrefetchCacheEntryStatus.fresh + } + + // if the cache entry was prefetched less than 5 mins ago, then we want to re-use only the loading state + if (kind === 'auto') { + if (Date.now() < prefetchTime + FIVE_MINUTES) { + return PrefetchCacheEntryStatus.stale + } + } + + // if the cache entry was prefetched less than 5 mins ago and was a "full" prefetch, then we want to re-use it "full + if (kind === 'full') { + if (Date.now() < prefetchTime + FIVE_MINUTES) { + return PrefetchCacheEntryStatus.reusable + } + } + + return PrefetchCacheEntryStatus.expired +} diff --git a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx index 87e35073e1fd2..915f09cae0cae 100644 --- a/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx +++ b/packages/next/src/client/components/router-reducer/invalidate-cache-below-flight-segmentpath.test.tsx @@ -86,7 +86,7 @@ describe('invalidateCacheBelowFlightSegmentPath', () => { // @ts-expect-error TODO-APP: investigate why this is not a TS error in router-reducer. cache.subTreeData = existingCache.subTreeData // Create a copy of the existing cache with the subTreeData applied. - fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath) + fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath, false) // Invalidate the cache below the flight segment path. This should remove the 'about' node. invalidateCacheBelowFlightSegmentPath( 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 aae677c72878c..4a4424ccfb7c1 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 @@ -80,6 +80,7 @@ import { ACTION_NAVIGATE, ACTION_PREFETCH, PrefetchAction, + PrefetchKind, } from '../router-reducer-types' import { navigateReducer } from './navigate-reducer' import { prefetchReducer } from './prefetch-reducer' @@ -1005,6 +1006,7 @@ describe('navigateReducer', () => { const prefetchAction: PrefetchAction = { type: ACTION_PREFETCH, url, + kind: PrefetchKind.AUTO, } const state = createInitialRouterState({ @@ -1086,6 +1088,9 @@ describe('navigateReducer', () => { '/linking/about', { data: record, + kind: PrefetchKind.AUTO, + lastUsedTime: null, + prefetchTime: expect.any(Number), treeAtTimeOfPrefetch: [ '', { 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 8c7bea095aa2e..b39f0886545f7 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 @@ -1,4 +1,7 @@ -import { CacheStates } from '../../../../shared/lib/app-router-context' +import { + CacheNode, + CacheStates, +} from '../../../../shared/lib/app-router-context' import type { FlightRouterState, FlightSegmentPath, @@ -13,14 +16,20 @@ import { createOptimisticTree } from '../create-optimistic-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 type { +import { Mutable, NavigateAction, + PrefetchKind, ReadonlyReducerState, ReducerState, } from '../router-reducer-types' import { handleMutable } from '../handle-mutable' import { applyFlightData } from '../apply-flight-data' +import { + PrefetchCacheEntryStatus, + getPrefetchEntryCacheStatus, +} from '../get-prefetch-cache-entry-status' +import { prunePrefetchCache } from './prune-prefetch-cache' export function handleExternalUrl( state: ReadonlyReducerState, @@ -63,6 +72,37 @@ function generateSegmentsFromPatch( return segments } +function addRefetchToLeafSegments( + newCache: CacheNode, + currentCache: CacheNode, + flightSegmentPath: FlightSegmentPath, + treePatch: FlightRouterState, + data: () => ReturnType +) { + let appliedPatch = false + + newCache.status = CacheStates.READY + newCache.subTreeData = currentCache.subTreeData + newCache.parallelRoutes = new Map(currentCache.parallelRoutes) + + const segmentPathsToFill = generateSegmentsFromPatch(treePatch).map( + (segment) => [...flightSegmentPath, ...segment] + ) + + for (const segmentPaths of segmentPathsToFill) { + const res = fillCacheWithDataProperty( + newCache, + currentCache, + segmentPaths, + data + ) + if (!res?.bailOptimistic) { + appliedPatch = true + } + } + + return appliedPatch +} export function navigateReducer( state: ReadonlyReducerState, action: NavigateAction @@ -78,6 +118,8 @@ export function navigateReducer( const { pathname, hash } = url const href = createHrefFromUrl(url) const pendingPush = navigateType === 'push' + // we want to prune the prefetch cache on every navigation to avoid it growing too large + prunePrefetchCache(state.prefetchCache) const isForCurrentTree = JSON.stringify(mutable.previousTree) === JSON.stringify(state.tree) @@ -90,116 +132,12 @@ export function navigateReducer( return handleExternalUrl(state, mutable, url.toString(), pendingPush) } - const prefetchValues = state.prefetchCache.get(createHrefFromUrl(url, false)) - if (prefetchValues) { - // The one before last item is the router state tree patch - const { treeAtTimeOfPrefetch, data } = prefetchValues - - // Unwrap cache data with `use` to suspend here (in the reducer) until the fetch resolves. - const [flightData, canonicalUrlOverride] = readRecordValue(data!) - - // Handle case when navigating to page in `pages` from `app` - if (typeof flightData === 'string') { - return handleExternalUrl(state, mutable, flightData, pendingPush) - } - - let currentTree = state.tree - let currentCache = state.cache - let scrollableSegments: FlightSegmentPath[] = [] - for (const flightDataPath of flightData) { - const flightSegmentPath = flightDataPath.slice( - 0, - -3 - ) as unknown as FlightSegmentPath - - // The one before last item is the router state tree patch - const [treePatch] = flightDataPath.slice(-3) as [FlightRouterState] - - // Create new tree based on the flightSegmentPath and router state patch - let newTree = applyRouterStatePatchToTree( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - currentTree, - treePatch - ) - - // 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 = applyRouterStatePatchToTree( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - treeAtTimeOfPrefetch, - treePatch - ) - } - - if (newTree !== null) { - if (isNavigatingToNewRootLayout(currentTree, newTree)) { - return handleExternalUrl(state, mutable, href, pendingPush) - } - - const applied = applyFlightData( - currentCache, - cache, - flightDataPath, - true - ) - - const hardNavigate = shouldHardNavigate( - // TODO-APP: remove '' - ['', ...flightSegmentPath], - currentTree - ) - - if (hardNavigate) { - cache.status = CacheStates.READY - // Copy subTreeData for the root node of the cache. - cache.subTreeData = currentCache.subTreeData - - invalidateCacheBelowFlightSegmentPath( - cache, - currentCache, - flightSegmentPath - ) - // Ensure the existing cache value is used when the cache was not invalidated. - mutable.cache = cache - } else if (applied) { - mutable.cache = cache - } - - currentCache = cache - currentTree = newTree - - for (const subSegment of generateSegmentsFromPatch(treePatch)) { - scrollableSegments.push( - // the last segment is the same as the first segment in the patch - [...flightSegmentPath.slice(0, -1), ...subSegment].filter( - (segment) => segment !== '__PAGE__' - ) - ) - } - } - } - - mutable.previousTree = state.tree - mutable.patchedTree = currentTree - mutable.scrollableSegments = scrollableSegments - mutable.canonicalUrl = canonicalUrlOverride - ? createHrefFromUrl(canonicalUrlOverride) - : href - mutable.pendingPush = pendingPush - mutable.hashFragment = hash - - return handleMutable(state, mutable) - } - - // When doing a hard push there can be two cases: with optimistic tree and without - // The with optimistic tree case only happens when the layouts have a loading state (loading.js) - // The without optimistic tree case happens when there is no loading state, in that case we suspend in this reducer + let prefetchValues = state.prefetchCache.get(createHrefFromUrl(url, false)) - // forceOptimisticNavigation is used for links that have `prefetch={false}`. - if (forceOptimisticNavigation) { + if ( + forceOptimisticNavigation && + prefetchValues?.kind !== PrefetchKind.TEMPORARY + ) { const segments = pathname.split('/') // TODO-APP: figure out something better for index pages segments.push('') @@ -208,18 +146,36 @@ export function navigateReducer( // If the optimistic tree is deeper than the current state leave that deeper part out of the fetch const optimisticTree = createOptimisticTree(segments, state.tree, false) + // we need a copy of the cache in case we need to revert to it + const temporaryCacheNode: CacheNode = { + ...cache, + } + // Copy subTreeData for the root node of the cache. - cache.status = CacheStates.READY - cache.subTreeData = state.cache.subTreeData + // Note: didn't do it above because typescript doesn't like it. + temporaryCacheNode.status = CacheStates.READY + temporaryCacheNode.subTreeData = state.cache.subTreeData + temporaryCacheNode.parallelRoutes = new Map(state.cache.parallelRoutes) + + const data = createRecordFromThenable( + fetchServerResponse(url, optimisticTree, state.nextUrl) + ) + + // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. + // TODO-APP: re-evaluate if we need to strip the last segment + const optimisticFlightSegmentPath = segments + .slice(1) + .map((segment) => ['children', segment === '' ? '__PAGE__' : segment]) + .flat() // Copy existing cache nodes as far as possible and fill in `data` property with the started data fetch. // The `data` property is used to suspend in layout-router during render if it hasn't resolved yet by the time it renders. const res = fillCacheWithDataProperty( - cache, + temporaryCacheNode, state.cache, - // TODO-APP: segments.slice(1) strips '', we can get rid of '' altogether. - segments.slice(1), - () => fetchServerResponse(url, optimisticTree, state.nextUrl) + optimisticFlightSegmentPath, + () => data, + true ) // If optimistic fetch couldn't happen it falls back to the non-optimistic case. @@ -229,83 +185,154 @@ export function navigateReducer( mutable.pendingPush = pendingPush mutable.hashFragment = hash mutable.scrollableSegments = [] - mutable.cache = cache + mutable.cache = temporaryCacheNode mutable.canonicalUrl = href + state.prefetchCache.set(createHrefFromUrl(url, false), { + data: Promise.resolve(data), + // this will make sure that the entry will be discarded after 30s + kind: PrefetchKind.TEMPORARY, + prefetchTime: Date.now(), + treeAtTimeOfPrefetch: state.tree, + lastUsedTime: Date.now(), + }) + return handleMutable(state, mutable) } } - // Below is the not-optimistic case. Data is fetched at the root and suspended there without a suspense boundary. - - // If no in-flight fetch at the top, start it. - if (!cache.data) { - cache.data = createRecordFromThenable( + // If we don't have a prefetch value, we need to create one + if (!prefetchValues) { + const data = createRecordFromThenable( fetchServerResponse(url, state.tree, state.nextUrl) ) + + const newPrefetchValue = { + data: Promise.resolve(data), + // this will make sure that the entry will be discarded after 30s + kind: PrefetchKind.TEMPORARY, + prefetchTime: Date.now(), + treeAtTimeOfPrefetch: state.tree, + lastUsedTime: null, + } + + state.prefetchCache.set(createHrefFromUrl(url, false), newPrefetchValue) + prefetchValues = newPrefetchValue } + const prefetchEntryCacheStatus = getPrefetchEntryCacheStatus(prefetchValues) + + // The one before last item is the router state tree patch + const { treeAtTimeOfPrefetch, data } = prefetchValues + // Unwrap cache data with `use` to suspend here (in the reducer) until the fetch resolves. - const [flightData, canonicalUrlOverride] = readRecordValue(cache.data!) + const [flightData, canonicalUrlOverride] = readRecordValue(data!) + + // important: we should only mark the cache node as dirty after we unsuspend from the call above + prefetchValues.lastUsedTime = Date.now() // Handle case when navigating to page in `pages` from `app` if (typeof flightData === 'string') { return handleExternalUrl(state, mutable, flightData, pendingPush) } - // Remove cache.data as it has been resolved at this point. - cache.data = null - let currentTree = state.tree let currentCache = state.cache let scrollableSegments: FlightSegmentPath[] = [] for (const flightDataPath of flightData) { + const flightSegmentPath = flightDataPath.slice( + 0, + -4 + ) as unknown as FlightSegmentPath // The one before last item is the router state tree patch - const [treePatch] = flightDataPath.slice(-3, -2) - - // Path without the last segment, router state, and the subTreeData - const flightSegmentPath = flightDataPath.slice(0, -4) + const [treePatch] = flightDataPath.slice(-3) as [FlightRouterState] // Create new tree based on the flightSegmentPath and router state patch - const newTree = applyRouterStatePatchToTree( + let newTree = applyRouterStatePatchToTree( // TODO-APP: remove '' ['', ...flightSegmentPath], currentTree, treePatch ) + // 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) { - throw new Error('SEGMENT MISMATCH') + newTree = applyRouterStatePatchToTree( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + treeAtTimeOfPrefetch, + treePatch + ) } - if (isNavigatingToNewRootLayout(currentTree, newTree)) { - return handleExternalUrl(state, mutable, href, pendingPush) - } + if (newTree !== null) { + if (isNavigatingToNewRootLayout(currentTree, newTree)) { + return handleExternalUrl(state, mutable, href, pendingPush) + } - mutable.canonicalUrl = canonicalUrlOverride - ? createHrefFromUrl(canonicalUrlOverride) - : href + let applied = applyFlightData( + currentCache, + cache, + flightDataPath, + prefetchValues.kind === 'auto' && + prefetchEntryCacheStatus === PrefetchCacheEntryStatus.reusable + ) - const applied = applyFlightData(currentCache, cache, flightDataPath) - if (applied) { - mutable.cache = cache - currentCache = cache - } + if ( + !applied && + prefetchEntryCacheStatus === PrefetchCacheEntryStatus.stale + ) { + applied = addRefetchToLeafSegments( + cache, + currentCache, + flightSegmentPath, + treePatch, + () => fetchServerResponse(url, newTree!, state.nextUrl) + ) + } + + const hardNavigate = shouldHardNavigate( + // TODO-APP: remove '' + ['', ...flightSegmentPath], + currentTree + ) - currentTree = newTree + if (hardNavigate) { + cache.status = CacheStates.READY + // Copy subTreeData for the root node of the cache. + cache.subTreeData = currentCache.subTreeData - for (const subSegment of generateSegmentsFromPatch(treePatch)) { - scrollableSegments.push( - [...flightSegmentPath, ...subSegment].filter( - (segment) => segment !== '__PAGE__' + invalidateCacheBelowFlightSegmentPath( + cache, + currentCache, + flightSegmentPath ) - ) + // Ensure the existing cache value is used when the cache was not invalidated. + mutable.cache = cache + } else if (applied) { + mutable.cache = cache + } + + currentCache = cache + currentTree = newTree + + for (const subSegment of generateSegmentsFromPatch(treePatch)) { + scrollableSegments.push( + [...flightSegmentPath, ...subSegment].filter( + (segment) => segment !== '__PAGE__' + ) + ) + } } } mutable.previousTree = state.tree mutable.patchedTree = currentTree mutable.scrollableSegments = scrollableSegments + mutable.canonicalUrl = canonicalUrlOverride + ? createHrefFromUrl(canonicalUrlOverride) + : href mutable.pendingPush = pendingPush mutable.hashFragment = hash 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 f340a042e63db..4f5e8788cc838 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 @@ -38,7 +38,11 @@ import { CacheStates, } from '../../../../shared/lib/app-router-context' import { createInitialRouterState } from '../create-initial-router-state' -import { PrefetchAction, ACTION_PREFETCH } from '../router-reducer-types' +import { + PrefetchAction, + ACTION_PREFETCH, + PrefetchKind, +} from '../router-reducer-types' import { prefetchReducer } from './prefetch-reducer' import { fetchServerResponse } from '../fetch-server-response' import { createRecordFromThenable } from '../create-record-from-thenable' @@ -128,11 +132,12 @@ describe('prefetchReducer', () => { url, initialTree, null, - true + PrefetchKind.AUTO ) const action: PrefetchAction = { type: ACTION_PREFETCH, url, + kind: PrefetchKind.AUTO, } const newState = await runPromiseThrowChain(() => @@ -149,6 +154,9 @@ describe('prefetchReducer', () => { '/linking/about', { data: record, + kind: PrefetchKind.AUTO, + lastUsedTime: null, + prefetchTime: expect.any(Number), treeAtTimeOfPrefetch: [ '', { @@ -273,11 +281,12 @@ describe('prefetchReducer', () => { url, initialTree, null, - true + PrefetchKind.AUTO ) const action: PrefetchAction = { type: ACTION_PREFETCH, url, + kind: PrefetchKind.AUTO, } await runPromiseThrowChain(() => prefetchReducer(state, action)) @@ -296,6 +305,9 @@ describe('prefetchReducer', () => { '/linking/about', { data: record, + prefetchTime: expect.any(Number), + kind: PrefetchKind.AUTO, + lastUsedTime: null, treeAtTimeOfPrefetch: [ '', { diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts index c46dbda969d8f..6353c3d9864da 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.ts @@ -4,13 +4,18 @@ import { PrefetchAction, ReducerState, ReadonlyReducerState, + PrefetchKind, } from '../router-reducer-types' import { createRecordFromThenable } from '../create-record-from-thenable' +import { prunePrefetchCache } from './prune-prefetch-cache' export function prefetchReducer( state: ReadonlyReducerState, action: PrefetchAction ): ReducerState { + // let's prune the prefetch cache before we do anything else + prunePrefetchCache(state.prefetchCache) + const { url } = action const href = createHrefFromUrl( url, @@ -18,9 +23,32 @@ export function prefetchReducer( false ) - // If the href was already prefetched it is not necessary to prefetch it again - if (state.prefetchCache.has(href)) { - return state + const cacheEntry = state.prefetchCache.get(href) + if (cacheEntry) { + /** + * If the cache entry present was marked as temporary, it means that we prefetched it from the navigate reducer, + * where we didn't have the prefetch intent. We want to update it to the new, more accurate, kind here. + */ + if (cacheEntry.kind === PrefetchKind.TEMPORARY) { + console.log(href, action.kind, cacheEntry) + state.prefetchCache.set(href, { + ...cacheEntry, + kind: action.kind, + }) + } + + /** + * if the prefetch action was a full prefetch and that the current cache entry wasn't one, we want to re-prefetch, + * otherwise we can re-use the current cache entry + **/ + if ( + !( + cacheEntry.kind === PrefetchKind.AUTO && + action.kind === PrefetchKind.FULL + ) + ) { + return state + } } // fetchServerResponse is intentionally not awaited so that it can be unwrapped in the navigate-reducer @@ -30,7 +58,7 @@ export function prefetchReducer( // initialTree is used when history.state.tree is missing because the history state is set in `useEffect` below, it being missing means this is the hydration case. state.tree, state.nextUrl, - true + action.kind ) ) @@ -39,6 +67,9 @@ export function prefetchReducer( // Create new tree based on the flightSegmentPath and router state patch treeAtTimeOfPrefetch: state.tree, data: serverResponse, + kind: action.kind, + prefetchTime: Date.now(), + lastUsedTime: null, }) return state diff --git a/packages/next/src/client/components/router-reducer/reducers/prune-prefetch-cache.ts b/packages/next/src/client/components/router-reducer/reducers/prune-prefetch-cache.ts new file mode 100644 index 0000000000000..e6ba388d9e2c6 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/reducers/prune-prefetch-cache.ts @@ -0,0 +1,18 @@ +import type { ReducerState } from '../router-reducer-types' +import { + PrefetchCacheEntryStatus, + getPrefetchEntryCacheStatus, +} from '../get-prefetch-cache-entry-status' + +export function prunePrefetchCache( + prefetchCache: ReducerState['prefetchCache'] +) { + for (const [href, prefetchCacheEntry] of prefetchCache) { + if ( + getPrefetchEntryCacheStatus(prefetchCacheEntry) === + PrefetchCacheEntryStatus.expired + ) { + prefetchCache.delete(href) + } + } +} 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 318c85dc9c454..a7d69a662b5c3 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 @@ -117,6 +117,19 @@ export interface ServerPatchAction { mutable: Mutable } +/** + * PrefetchKind defines the type of prefetching that should be done. + * - `auto` - if the page is dynamic, prefetch the page data partially, if static prefetch the page data fully. + * - `full` - prefetch the page data fully. + * - `temporary` - a temporary prefetch entry is added to the cache, this is used when prefetch={false} is used in next/link or when you push a route programmatically. + */ + +export enum PrefetchKind { + AUTO = 'auto', + FULL = 'full', + TEMPORARY = 'temporary', +} + /** * Prefetch adds the provided FlightData to the prefetch cache * - Creates the router state tree based on the patch in FlightData @@ -126,6 +139,7 @@ export interface ServerPatchAction { export interface PrefetchAction { type: typeof ACTION_PREFETCH url: URL + kind: PrefetchKind } interface PushRef { @@ -154,6 +168,14 @@ export type FocusAndScrollRef = { segmentPaths: FlightSegmentPath[] } +export type PrefetchCacheEntry = { + treeAtTimeOfPrefetch: FlightRouterState + data: ReturnType | null + kind: PrefetchKind + prefetchTime: number + lastUsedTime: number | null +} + /** * Handles keeping the state of app-router. */ @@ -173,13 +195,7 @@ export type AppRouterState = { /** * Cache that holds prefetched Flight responses keyed by url. */ - prefetchCache: Map< - string, - { - treeAtTimeOfPrefetch: FlightRouterState - data: ReturnType | null - } - > + prefetchCache: Map /** * Decides if the update should create a new history entry and if the navigation has to trigger a browser navigation. */ diff --git a/packages/next/src/client/link.tsx b/packages/next/src/client/link.tsx index 16951ff666c4e..b04f645b7e4a4 100644 --- a/packages/next/src/client/link.tsx +++ b/packages/next/src/client/link.tsx @@ -16,10 +16,12 @@ import { RouterContext } from '../shared/lib/router-context' import { AppRouterContext, AppRouterInstance, + PrefetchOptions as AppRouterPrefetchOptions, } from '../shared/lib/app-router-context' import { useIntersection } from './use-intersection' import { getDomainLocale } from './get-domain-locale' import { addBasePath } from './add-base-path' +import { PrefetchKind } from './components/router-reducer/router-reducer-types' type Url = string | UrlObject type RequiredKeys = { @@ -120,6 +122,7 @@ function prefetch( href: string, as: string, options: PrefetchOptions, + appOptions: AppRouterPrefetchOptions, isAppRouter: boolean ): void { if (typeof window === 'undefined') { @@ -154,11 +157,15 @@ function prefetch( prefetched.add(prefetchedKey) } + const prefetchPromise = isAppRouter + ? (router as AppRouterInstance).prefetch(href, appOptions) + : (router as NextRouter).prefetch(href, as, options) + // Prefetch the JSON page if asked (only in the client) // We need to handle a prefetch error here since we may be // loading with priority which can reject but we don't // want to force navigation since this is only a prefetch - Promise.resolve(router.prefetch(href, as, options)).catch((err) => { + Promise.resolve(prefetchPromise).catch((err) => { if (process.env.NODE_ENV !== 'production') { // rethrow to show invalid URL errors throw err @@ -248,6 +255,51 @@ function formatStringOrUrl(urlObjOrString: UrlObject | string): string { */ const Link = React.forwardRef( function LinkComponent(props, forwardedRef) { + let children: React.ReactNode + + const { + href: hrefProp, + as: asProp, + children: childrenProp, + prefetch: prefetchProp = null, + passHref, + replace, + shallow, + scroll, + locale, + onClick, + onMouseEnter: onMouseEnterProp, + onTouchStart: onTouchStartProp, + // @ts-expect-error this is inlined as a literal boolean not a string + legacyBehavior = process.env.__NEXT_NEW_LINK_BEHAVIOR === false, + ...restProps + } = props + + children = childrenProp + + if ( + legacyBehavior && + (typeof children === 'string' || typeof children === 'number') + ) { + children = {children} + } + + const prefetchEnabled = prefetchProp !== false + /** + * The possible states for prefetch are: + * - null: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport + * - true: we will prefetch if the link is visible and prefetch the full page, not just partially + * - false: we will not prefetch if in the viewport at all + */ + const appPrefetchKind = + prefetchProp === null ? PrefetchKind.AUTO : PrefetchKind.FULL + + const pagesRouter = React.useContext(RouterContext) + const appRouter = React.useContext(AppRouterContext) + const router = pagesRouter ?? appRouter + + // We're in the app directory if there is no pages router. + const isAppRouter = !pagesRouter if (process.env.NODE_ENV !== 'production') { function createPropError(args: { key: string @@ -361,7 +413,7 @@ const Link = React.forwardRef( // This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes // eslint-disable-next-line react-hooks/rules-of-hooks const hasWarned = React.useRef(false) - if (props.prefetch && !hasWarned.current) { + if (props.prefetch && !hasWarned.current && !isAppRouter) { hasWarned.current = true console.warn( 'Next.js auto-prefetches automatically based on viewport. The prefetch attribute is no longer needed. More: https://nextjs.org/docs/messages/prefetch-true-deprecated' @@ -369,44 +421,6 @@ const Link = React.forwardRef( } } - let children: React.ReactNode - - const { - href: hrefProp, - as: asProp, - children: childrenProp, - prefetch: prefetchProp, - passHref, - replace, - shallow, - scroll, - locale, - onClick, - onMouseEnter: onMouseEnterProp, - onTouchStart: onTouchStartProp, - // @ts-expect-error this is inlined as a literal boolean not a string - legacyBehavior = process.env.__NEXT_NEW_LINK_BEHAVIOR === false, - ...restProps - } = props - - children = childrenProp - - if ( - legacyBehavior && - (typeof children === 'string' || typeof children === 'number') - ) { - children = {children} - } - - const prefetchEnabled = prefetchProp !== false - - const pagesRouter = React.useContext(RouterContext) - const appRouter = React.useContext(AppRouterContext) - const router = pagesRouter ?? appRouter - - // We're in the app directory if there is no pages router. - const isAppRouter = !pagesRouter - if (process.env.NODE_ENV !== 'production') { if (isAppRouter && !asProp) { let href: string | undefined @@ -546,7 +560,16 @@ const Link = React.forwardRef( } // Prefetch the URL. - prefetch(router, href, as, { locale }, isAppRouter) + prefetch( + router, + href, + as, + { locale }, + { + kind: appPrefetchKind, + }, + isAppRouter + ) }, [ as, href, @@ -556,6 +579,7 @@ const Link = React.forwardRef( pagesRouter?.locale, router, isAppRouter, + appPrefetchKind, ]) const childProps: { @@ -639,6 +663,9 @@ const Link = React.forwardRef( // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} bypassPrefetchedCheck: true, }, + { + kind: appPrefetchKind, + }, isAppRouter ) }, @@ -673,6 +700,9 @@ const Link = React.forwardRef( // @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642} bypassPrefetchedCheck: true, }, + { + kind: appPrefetchKind, + }, isAppRouter ) }, diff --git a/packages/next/src/shared/lib/app-router-context.ts b/packages/next/src/shared/lib/app-router-context.ts index cd9af26dc15c5..ba56247ca28b9 100644 --- a/packages/next/src/shared/lib/app-router-context.ts +++ b/packages/next/src/shared/lib/app-router-context.ts @@ -1,6 +1,9 @@ 'use client' -import { FocusAndScrollRef } from '../../client/components/router-reducer/router-reducer-types' +import { + FocusAndScrollRef, + PrefetchKind, +} from '../../client/components/router-reducer/router-reducer-types' import type { fetchServerResponse } from '../../client/components/router-reducer/fetch-server-response' import type { FlightRouterState, @@ -68,6 +71,10 @@ export interface NavigateOptions { forceOptimisticNavigation?: boolean } +export interface PrefetchOptions { + kind: PrefetchKind +} + export interface AppRouterInstance { /** * Navigate to the previous history entry. @@ -94,7 +101,7 @@ export interface AppRouterInstance { /** * Prefetch the provided href. */ - prefetch(href: string): void + prefetch(href: string, options?: PrefetchOptions): void } export const AppRouterContext = React.createContext( diff --git a/test/e2e/app-dir/app-client-cache/app/[id]/loading.js b/test/e2e/app-dir/app-client-cache/app/[id]/loading.js new file mode 100644 index 0000000000000..8ff2e61e4aee4 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/[id]/loading.js @@ -0,0 +1,9 @@ +export default async function Page() { + const randomNumber = Math.random() + return ( +
+
LOADING
+
{randomNumber}
+
+ ) +} diff --git a/test/e2e/app-dir/app-client-cache/app/[id]/page.js b/test/e2e/app-dir/app-client-cache/app/[id]/page.js new file mode 100644 index 0000000000000..34ebdb761501d --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/[id]/page.js @@ -0,0 +1,21 @@ +import Link from 'next/link' + +export default async function Page({ searchParams: { timeout } }) { + const randomNumber = await new Promise((resolve) => { + setTimeout( + () => { + resolve(Math.random()) + }, + timeout !== undefined ? Number.parseInt(timeout, 10) : 0 + ) + }) + + return ( + <> +
+ Back to Home +
+
{randomNumber}
+ + ) +} diff --git a/test/e2e/app-dir/app-client-cache/app/layout.js b/test/e2e/app-dir/app-client-cache/app/layout.js new file mode 100644 index 0000000000000..0facb2f8c269d --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/layout.js @@ -0,0 +1,8 @@ +export default function Root({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/app-client-cache/app/page.js b/test/e2e/app-dir/app-client-cache/app/page.js new file mode 100644 index 0000000000000..28215fb9129f4 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/page.js @@ -0,0 +1,25 @@ +import Link from 'next/link' +export default function HomePage() { + return ( + <> +
+ + To Random Number - prefetch: true + +
+
+ To Random Number - prefetch: auto +
+
+ + To Random Number 2 - prefetch: false + +
+
+ + To Random Number - prefetch: auto, slow + +
+ + ) +} 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 new file mode 100644 index 0000000000000..4ad9125685270 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/client-cache.test.ts @@ -0,0 +1,386 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' +import { BrowserInterface } from 'test/lib/browsers/base' +import { Request } from 'playwright-chromium' + +const getPathname = (url: string) => { + const urlObj = new URL(url) + return urlObj.pathname +} + +const browserConfigWithFixedTime = { + beforePageLoad: (page) => { + page.addInitScript(() => { + const startTime = new Date() + const fixedTime = new Date('2023-04-17T00:00:00Z') + + // Override the Date constructor + // @ts-ignore + // eslint-disable-next-line no-native-reassign + Date = class extends Date { + constructor() { + super() + // @ts-ignore + return new startTime.constructor(fixedTime) + } + + static now() { + return fixedTime.getTime() + } + } + }) + }, +} + +const fastForwardTo = (ms) => { + // Increment the fixed time by the specified duration + const currentTime = new Date() + currentTime.setTime(currentTime.getTime() + ms) + + // Update the Date constructor to use the new fixed time + // @ts-ignore + // eslint-disable-next-line no-native-reassign + Date = class extends Date { + constructor() { + super() + // @ts-ignore + return new currentTime.constructor(currentTime) + } + + static now() { + return currentTime.getTime() + } + } +} + +const createRequestsListener = async (browser: BrowserInterface) => { + // wait for network idle + await browser.waitForIdleNetwork() + + let requests = [] + + browser.on('request', (req: Request) => { + requests.push([req.url(), !!req.headers()['next-router-prefetch']]) + }) + + await browser.refresh() + + return { + getRequests: () => requests, + clearRequests: () => { + requests = [] + }, + } +} + +createNextDescribe( + 'app dir client cache semantics', + { + files: __dirname, + }, + ({ next, isNextDev }) => { + if (isNextDev) { + // since the router behavior is different in dev mode (no viewport prefetching + liberal revalidation) + // we only check the production behavior + it('should skip dev', () => {}) + } else { + describe('prefetch={true}', () => { + let browser: BrowserInterface + + beforeEach(async () => { + browser = (await next.browser( + '/', + browserConfigWithFixedTime + )) as BrowserInterface + }) + + it('should prefetch the full page', async () => { + const { getRequests, clearRequests } = await createRequestsListener( + browser + ) + await check(() => { + return getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/0' && !didPartialPrefetch + ) + ? 'success' + : 'fail' + }, 'success') + + clearRequests() + + await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + + expect( + getRequests().every(([url]) => getPathname(url) !== '/0') + ).toEqual(true) + }) + it('should re-use the cache for the full page, only for 5 mins', async () => { + const randomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + await browser.elementByCss('[href="/"]').click() + + const number = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(number).toBe(randomNumber) + + await browser.eval(fastForwardTo, 5 * 60 * 1000) + + await browser.elementByCss('[href="/"]').click() + + const newNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(newNumber).not.toBe(randomNumber) + }) + it('should prefetch again after 5 mins if the link is visible again', async () => { + const { getRequests, clearRequests } = await createRequestsListener( + browser + ) + + await check(() => { + return getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/0' && !didPartialPrefetch + ) + ? 'success' + : 'fail' + }, 'success') + + const randomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + await browser.eval(fastForwardTo, 5 * 60 * 1000) + clearRequests() + + await browser.elementByCss('[href="/"]').click() + + await check(() => { + return getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/0' && !didPartialPrefetch + ) + ? 'success' + : 'fail' + }, 'success') + + const number = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(number).not.toBe(randomNumber) + }) + }) + describe('prefetch={false}', () => { + let browser: BrowserInterface + + beforeEach(async () => { + browser = (await next.browser( + '/', + browserConfigWithFixedTime + )) as BrowserInterface + }) + it('should not prefetch the page at all', async () => { + const { getRequests } = await createRequestsListener(browser) + + await browser + .elementByCss('[href="/2"]') + .click() + .waitForElementByCss('#random-number') + + expect( + getRequests().filter(([url]) => getPathname(url) === '/2') + ).toHaveLength(1) + + expect( + getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/2' && didPartialPrefetch + ) + ).toBe(false) + }) + it('should re-use the cache only for 30 seconds', async () => { + const randomNumber = await browser + .elementByCss('[href="/2"]') + .click() + .waitForElementByCss('#random-number') + .text() + + await browser.elementByCss('[href="/"]').click() + + const number = await browser + .elementByCss('[href="/2"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(number).toBe(randomNumber) + + await browser.eval(fastForwardTo, 30 * 1000) + + await browser.elementByCss('[href="/"]').click() + + const newNumber = await browser + .elementByCss('[href="/2"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(newNumber).not.toBe(randomNumber) + }) + }) + describe('prefetch={undefined} - default', () => { + let browser: BrowserInterface + + beforeEach(async () => { + browser = (await next.browser( + '/', + browserConfigWithFixedTime + )) as BrowserInterface + }) + + it('should prefetch partially a dynamic page', async () => { + const { getRequests, clearRequests } = await createRequestsListener( + browser + ) + + await check(() => { + return getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/1' && didPartialPrefetch + ) + ? 'success' + : 'fail' + }, 'success') + + clearRequests() + + await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + + expect( + getRequests().some( + ([url, didPartialPrefetch]) => + getPathname(url) === '/1' && !didPartialPrefetch + ) + ).toBe(true) + }) + it('should re-use the full cache for only 30 seconds', async () => { + const randomNumber = await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + .text() + + await browser.elementByCss('[href="/"]').click() + + const number = await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(number).toBe(randomNumber) + + await browser.eval(fastForwardTo, 30 * 1000) + + await browser.elementByCss('[href="/"]').click() + + const newNumber = await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(newNumber).not.toBe(randomNumber) + }) + it('should refetch below the fold after 30 seconds', async () => { + const randomLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + const randomNumber = await browser + .waitForElementByCss('#random-number') + .text() + + await browser.elementByCss('[href="/"]').click() + + await browser.eval(fastForwardTo, 30 * 1000) + + const newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + const newNumber = await browser + .waitForElementByCss('#random-number') + .text() + + expect(newLoadingNumber).toBe(randomLoadingNumber) + + expect(newNumber).not.toBe(randomNumber) + }) + it('should refetch the full page after 5 mins', async () => { + const randomLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + const randomNumber = await browser + .waitForElementByCss('#random-number') + .text() + + await browser.eval(fastForwardTo, 5 * 60 * 1000) + + await browser + .elementByCss('[href="/"]') + .click() + .waitForElementByCss('[href="/1?timeout=1000"]') + + const newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + const newNumber = await browser + .waitForElementByCss('#random-number') + .text() + + expect(newLoadingNumber).not.toBe(randomLoadingNumber) + + expect(newNumber).not.toBe(randomNumber) + }) + }) + describe('router.push', () => { + it('should re-use the cache for 30 seconds', async () => {}) + it('should fully refetch the page after 30 seconds', async () => {}) + }) + } + } +) diff --git a/test/e2e/app-dir/app-client-cache/next.config.js b/test/e2e/app-dir/app-client-cache/next.config.js new file mode 100644 index 0000000000000..cfa3ac3d7aa94 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + appDir: true, + }, +} diff --git a/test/e2e/app-dir/app-prefetch/prefetching.test.ts b/test/e2e/app-dir/app-prefetch/prefetching.test.ts index 27b72e3ee3c11..03b3020824bc7 100644 --- a/test/e2e/app-dir/app-prefetch/prefetching.test.ts +++ b/test/e2e/app-dir/app-prefetch/prefetching.test.ts @@ -1,6 +1,30 @@ import { createNextDescribe } from 'e2e-utils' import { check, waitFor } from 'next-test-utils' +const browserConfigWithFixedTime = { + beforePageLoad: (page) => { + page.addInitScript(() => { + const startTime = new Date() + const fixedTime = new Date('2023-04-17T00:00:00Z') + + // Override the Date constructor + // @ts-ignore + // eslint-disable-next-line no-native-reassign + Date = class extends Date { + constructor() { + super() + // @ts-ignore + return new startTime.constructor(fixedTime) + } + + static now() { + return fixedTime.getTime() + } + } + }) + }, +} + createNextDescribe( 'app dir prefetching', { @@ -15,7 +39,7 @@ createNextDescribe( } it('should show layout eagerly when prefetched with loading one level down', async () => { - const browser = await next.browser('/') + const browser = await next.browser('/', browserConfigWithFixedTime) // Ensure the page is prefetched await waitFor(1000) @@ -50,7 +74,7 @@ createNextDescribe( }) it('should not fetch again when a static page was prefetched', async () => { - const browser = await next.browser('/404') + const browser = await next.browser('/404', browserConfigWithFixedTime) let requests: string[] = [] browser.on('request', (req) => { @@ -78,7 +102,7 @@ createNextDescribe( }) it('should not fetch again when a static page was prefetched when navigating to it twice', async () => { - const browser = await next.browser('/404') + const browser = await next.browser('/404', browserConfigWithFixedTime) let requests: string[] = [] browser.on('request', (req) => { diff --git a/test/e2e/app-dir/app/index.test.ts b/test/e2e/app-dir/app/index.test.ts index 6735262b29801..18d4edfaf32cb 100644 --- a/test/e2e/app-dir/app/index.test.ts +++ b/test/e2e/app-dir/app/index.test.ts @@ -522,17 +522,10 @@ createNextDescribe( await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(3) - // Get the id on the rendered page. - const firstID = await browser.elementById('render-id-456').text() - // Go back, and redo the navigation by clicking the link. await browser.back() await browser.elementById('link').click() await browser.waitForElementByCss('#render-id-456') - - // Get the id again, and compare, they should not be the same. - const secondID = await browser.elementById('render-id-456').text() - expect(secondID).not.toBe(firstID) } finally { await browser.close() } @@ -549,9 +542,6 @@ createNextDescribe( await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(2) - // Get the date again, and compare, they should not be the same. - const firstId = await browser.elementById('render-id-456').text() - // Navigate to the subpage, verify that the history entry was NOT added. await browser.elementById('link').click() await browser.waitForElementByCss('#render-id-123') @@ -561,10 +551,6 @@ createNextDescribe( await browser.elementById('link').click() await browser.waitForElementByCss('#render-id-456') expect(await browser.eval('window.history.length')).toBe(2) - - // Get the date again, and compare, they should not be the same. - const secondId = await browser.elementById('render-id-456').text() - expect(firstId).not.toBe(secondId) } finally { await browser.close() } diff --git a/test/lib/browsers/base.ts b/test/lib/browsers/base.ts index 4ea941bb9f947..b9a8e97edc381 100644 --- a/test/lib/browsers/base.ts +++ b/test/lib/browsers/base.ts @@ -122,10 +122,13 @@ export class BrowserInterface implements PromiseLike { async getAttribute(name: string): Promise { return } - async eval(snippet: string | Function): Promise { + async eval(snippet: string | Function, ...args: any[]): Promise { return } - async evalAsync(snippet: string | Function): Promise { + async evalAsync( + snippet: string | Function, + ...args: any[] + ): Promise { return } async text(): Promise { @@ -148,4 +151,6 @@ export class BrowserInterface implements PromiseLike { async url(): Promise { return '' } + + async waitForIdleNetwork(): Promise {} } diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index ab638cc3a697b..bb3ee60ed49fd 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -350,10 +350,10 @@ export class Playwright extends BrowserInterface { }) } - eval(snippet): Promise { + eval(fn: any, ...args: any[]): Promise { return this.chainWithReturnValue(() => page - .evaluate(snippet) + .evaluate(fn, ...args) .catch((err) => { console.error('eval error:', err) return null @@ -365,15 +365,15 @@ export class Playwright extends BrowserInterface { ) } - async evalAsync(snippet) { - if (typeof snippet === 'function') { - snippet = snippet.toString() + async evalAsync(fn: any, ...args: any[]) { + if (typeof fn === 'function') { + fn = fn.toString() } - if (snippet.includes(`var callback = arguments[arguments.length - 1]`)) { - snippet = `(function() { + if (fn.includes(`var callback = arguments[arguments.length - 1]`)) { + fn = `(function() { return new Promise((resolve, reject) => { - const origFunc = ${snippet} + const origFunc = ${fn} try { origFunc(resolve) } catch (err) { @@ -383,7 +383,7 @@ export class Playwright extends BrowserInterface { })()` } - return page.evaluate(snippet).catch(() => null) + return page.evaluate(fn).catch(() => null) } async log() { @@ -397,4 +397,10 @@ export class Playwright extends BrowserInterface { async url() { return this.chain(() => page.evaluate('window.location.href')) as any } + + async waitForIdleNetwork(): Promise { + return this.chain(() => { + return page.waitForLoadState('networkidle') + }) + } }