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:
Jimmy Lai 2023-04-21 14:29:39 +02:00 committed by GitHub
parent db0086703e
commit 658c600534
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 957 additions and 277 deletions

View file

@ -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,
})
})
},

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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'
}

View file

@ -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
)
}

View file

@ -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,

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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(

View file

@ -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: [
'',
{

View file

@ -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

View file

@ -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: [
'',
{

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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.
*/

View file

@ -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
)
},

View file

@ -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>(

View 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>
)
}

View 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>
</>
)
}

View file

@ -0,0 +1,8 @@
export default function Root({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}

View 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>
</>
)
}

View 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 () => {})
})
}
}
)

View file

@ -0,0 +1,5 @@
module.exports = {
experimental: {
appDir: true,
},
}

View file

@ -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) => {

View file

@ -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()
}

View file

@ -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> {}
}

View file

@ -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')
})
}
}