app-router: new client-side cache semantics (#48383)
This PR implements new cache semantics for the app router on the client. ## Context Currently, on the App Router, every Link navigation is prefetched and kept forever in the cache. This means that once you visit it, you will always see the same version of the page for the duration of your navigation. ## This PR This PR introduces new semantics for how the App Router will cache during navigations. Here's a TL;DR of the changes: - all navigations (prefetched/unprefetched) are cached for a maximum of 30s from the time it was last accessed or created (in this order). - in addition to this, the App Router will cache differently depending on the `prefetch` prop passed to a `<Link>` component: - `prefetch={undefined}`/default behaviour: - the router will prefetch the full page for static pages/partially for dynamic pages - if accessed within 30s, it will use the cache - after that, if accessed within 5 mins, it will re-fetch and suspend below the nearest loading.js - after those 5 mins, it will re-fetch the full content (with a new loading.js boundary) - `prefetch={false}`: - the router will not prefetch anything - if accessed within 30s again, it will re-use the page - after that, it will re-fetch fully - `prefetch={true}` - this will prefetch the full content of your page, dynamic or static - if accessed within 5 mins, it will re-use the page ## Follow ups - we may add another API to control the cache TTL at the page level - a way to opt-in for prefetch on hover even with prefetch={false} <!-- Thanks for opening a PR! Your contribution is much appreciated. To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below. Choose the right checklist for the change(s) that you're making: ## For Contributors ### Improving Documentation or adding/fixing Examples - The "examples guidelines" are followed from our contributing doc https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md - Make sure the linting passes by running `pnpm build && pnpm lint`. See https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md ### Fixing a bug - Related issues linked using `fixes #number` - Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ### Adding a feature - Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. (A discussion must be opened, see https://github.com/vercel/next.js/discussions/new?category=ideas) - Related issues/discussions are linked using `fixes #number` - e2e tests added (https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs - Documentation added - Telemetry added. In case of a feature if it's used or not. - Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md ## For Maintainers - Minimal description (aim for explaining to someone not on the team to understand the PR) - When linking to a Slack thread, you might want to share details of the conclusion - Link both the Linear (Fixes NEXT-xxx) and the GitHub issues - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Closes NEXT- Fixes # --> link NEXT-1011
This commit is contained in:
parent
db0086703e
commit
658c600534
27 changed files with 957 additions and 277 deletions
|
@ -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,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
@ -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<typeof fetchServerResponse>
|
||||
flightSegmentPath: FlightSegmentPath,
|
||||
fetchResponse: () => ReturnType<typeof fetchServerResponse>,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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: [
|
||||
'',
|
||||
{
|
||||
|
|
|
@ -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<typeof fetchServerResponse>
|
||||
) {
|
||||
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
|
||||
let prefetchValues = state.prefetchCache.get(createHrefFromUrl(url, false))
|
||||
|
||||
// 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
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
if (isNavigatingToNewRootLayout(currentTree, newTree)) {
|
||||
return handleExternalUrl(state, mutable, href, pendingPush)
|
||||
}
|
||||
|
||||
mutable.canonicalUrl = canonicalUrlOverride
|
||||
? createHrefFromUrl(canonicalUrlOverride)
|
||||
: href
|
||||
|
||||
const applied = applyFlightData(currentCache, cache, flightDataPath)
|
||||
if (applied) {
|
||||
mutable.cache = cache
|
||||
currentCache = cache
|
||||
}
|
||||
|
||||
currentTree = newTree
|
||||
|
||||
for (const subSegment of generateSegmentsFromPatch(treePatch)) {
|
||||
scrollableSegments.push(
|
||||
[...flightSegmentPath, ...subSegment].filter(
|
||||
(segment) => segment !== '__PAGE__'
|
||||
)
|
||||
newTree = applyRouterStatePatchToTree(
|
||||
// TODO-APP: remove ''
|
||||
['', ...flightSegmentPath],
|
||||
treeAtTimeOfPrefetch,
|
||||
treePatch
|
||||
)
|
||||
}
|
||||
|
||||
if (newTree !== null) {
|
||||
if (isNavigatingToNewRootLayout(currentTree, newTree)) {
|
||||
return handleExternalUrl(state, mutable, href, pendingPush)
|
||||
}
|
||||
|
||||
let applied = applyFlightData(
|
||||
currentCache,
|
||||
cache,
|
||||
flightDataPath,
|
||||
prefetchValues.kind === 'auto' &&
|
||||
prefetchEntryCacheStatus === PrefetchCacheEntryStatus.reusable
|
||||
)
|
||||
|
||||
if (
|
||||
!applied &&
|
||||
prefetchEntryCacheStatus === PrefetchCacheEntryStatus.stale
|
||||
) {
|
||||
applied = addRefetchToLeafSegments(
|
||||
cache,
|
||||
currentCache,
|
||||
flightSegmentPath,
|
||||
treePatch,
|
||||
() => fetchServerResponse(url, newTree!, state.nextUrl)
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
[...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
|
||||
|
||||
|
|
|
@ -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: [
|
||||
'',
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<typeof fetchServerResponse> | 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<typeof fetchServerResponse> | null
|
||||
}
|
||||
>
|
||||
prefetchCache: Map<string, PrefetchCacheEntry>
|
||||
/**
|
||||
* Decides if the update should create a new history entry and if the navigation has to trigger a browser navigation.
|
||||
*/
|
||||
|
|
|
@ -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<T> = {
|
||||
|
@ -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<HTMLAnchorElement, LinkPropsReal>(
|
||||
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 = <a>{children}</a>
|
||||
}
|
||||
|
||||
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<HTMLAnchorElement, LinkPropsReal>(
|
|||
// 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<HTMLAnchorElement, LinkPropsReal>(
|
|||
}
|
||||
}
|
||||
|
||||
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 = <a>{children}</a>
|
||||
}
|
||||
|
||||
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<HTMLAnchorElement, LinkPropsReal>(
|
|||
}
|
||||
|
||||
// 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<HTMLAnchorElement, LinkPropsReal>(
|
|||
pagesRouter?.locale,
|
||||
router,
|
||||
isAppRouter,
|
||||
appPrefetchKind,
|
||||
])
|
||||
|
||||
const childProps: {
|
||||
|
@ -639,6 +663,9 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
|
|||
// @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<HTMLAnchorElement, LinkPropsReal>(
|
|||
// @see {https://github.com/vercel/next.js/discussions/40268?sort=top#discussioncomment-3572642}
|
||||
bypassPrefetchedCheck: true,
|
||||
},
|
||||
{
|
||||
kind: appPrefetchKind,
|
||||
},
|
||||
isAppRouter
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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<AppRouterInstance | null>(
|
||||
|
|
9
test/e2e/app-dir/app-client-cache/app/[id]/loading.js
Normal file
9
test/e2e/app-dir/app-client-cache/app/[id]/loading.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default async function Page() {
|
||||
const randomNumber = Math.random()
|
||||
return (
|
||||
<div>
|
||||
<div>LOADING</div>
|
||||
<div id="loading">{randomNumber}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
21
test/e2e/app-dir/app-client-cache/app/[id]/page.js
Normal file
21
test/e2e/app-dir/app-client-cache/app/[id]/page.js
Normal file
|
@ -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 (
|
||||
<>
|
||||
<div>
|
||||
<Link href="/"> Back to Home </Link>
|
||||
</div>
|
||||
<div id="random-number">{randomNumber}</div>
|
||||
</>
|
||||
)
|
||||
}
|
8
test/e2e/app-dir/app-client-cache/app/layout.js
Normal file
8
test/e2e/app-dir/app-client-cache/app/layout.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default function Root({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<head></head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
25
test/e2e/app-dir/app-client-cache/app/page.js
Normal file
25
test/e2e/app-dir/app-client-cache/app/page.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Link from 'next/link'
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Link href="/0?timeout=0" prefetch={true}>
|
||||
To Random Number - prefetch: true
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/1">To Random Number - prefetch: auto</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/2" prefetch={false}>
|
||||
To Random Number 2 - prefetch: false
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href="/1?timeout=1000">
|
||||
To Random Number - prefetch: auto, slow
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
386
test/e2e/app-dir/app-client-cache/client-cache.test.ts
Normal file
386
test/e2e/app-dir/app-client-cache/client-cache.test.ts
Normal file
|
@ -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 () => {})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
5
test/e2e/app-dir/app-client-cache/next.config.js
Normal file
5
test/e2e/app-dir/app-client-cache/next.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
}
|
|
@ -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) => {
|
||||
|
@ -76,7 +100,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) => {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -122,10 +122,13 @@ export class BrowserInterface implements PromiseLike<any> {
|
|||
async getAttribute<T = any>(name: string): Promise<T> {
|
||||
return
|
||||
}
|
||||
async eval<T = any>(snippet: string | Function): Promise<T> {
|
||||
async eval<T = any>(snippet: string | Function, ...args: any[]): Promise<T> {
|
||||
return
|
||||
}
|
||||
async evalAsync<T = any>(snippet: string | Function): Promise<T> {
|
||||
async evalAsync<T = any>(
|
||||
snippet: string | Function,
|
||||
...args: any[]
|
||||
): Promise<T> {
|
||||
return
|
||||
}
|
||||
async text(): Promise<string> {
|
||||
|
@ -148,4 +151,6 @@ export class BrowserInterface implements PromiseLike<any> {
|
|||
async url(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
async waitForIdleNetwork(): Promise<void> {}
|
||||
}
|
||||
|
|
|
@ -350,10 +350,10 @@ export class Playwright extends BrowserInterface {
|
|||
})
|
||||
}
|
||||
|
||||
eval<T = any>(snippet): Promise<T> {
|
||||
eval<T = any>(fn: any, ...args: any[]): Promise<T> {
|
||||
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<T = any>(snippet) {
|
||||
if (typeof snippet === 'function') {
|
||||
snippet = snippet.toString()
|
||||
async evalAsync<T = any>(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<T>(snippet).catch(() => null)
|
||||
return page.evaluate<T>(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<void> {
|
||||
return this.chain(() => {
|
||||
return page.waitForLoadState('networkidle')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue