From e9c1b189a3fd9709062f2bc9b8e36b0738a83e65 Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Tue, 26 Mar 2024 17:28:26 -0700 Subject: [PATCH] simplify API to only support 'live' | 'default' modes --- .../webpack/plugins/define-env-plugin.ts | 9 +- .../router-reducer/prefetch-cache-utils.ts | 17 +- .../router-reducer/router-reducer-types.ts | 10 +- packages/next/src/client/link.tsx | 11 +- packages/next/src/server/config-schema.ts | 5 +- packages/next/src/server/config-shared.ts | 12 +- .../client-cache.experimental.test.ts | 250 +----- .../app-client-cache/client-cache.test.ts | 747 +++++++++--------- 8 files changed, 435 insertions(+), 626 deletions(-) diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index 86e636874cbf7f..2a3e7fb1f14865 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -171,13 +171,8 @@ export function getDefineEnv({ 'process.env.__NEXT_MIDDLEWARE_MATCHERS': middlewareMatchers ?? [], 'process.env.__NEXT_MANUAL_CLIENT_BASE_PATH': config.experimental.manualClientBasePath ?? false, - 'process.env.__NEXT_CLIENT_ROUTER_CACHE_STALETIME_MS': JSON.stringify( - config.experimental.clientRouterCache - ? 31556952000 // 1 year (ms) - : config.experimental.clientRouterCache === false - ? 0 - : 30000 // 30 seconds (ms) - ), + 'process.env.__NEXT_CLIENT_ROUTER_CACHE_MODE': + config.experimental.clientRouterCacheMode ?? 'default', 'process.env.__NEXT_CLIENT_ROUTER_FILTER_ENABLED': config.experimental.clientRouterFilter ?? true, 'process.env.__NEXT_CLIENT_ROUTER_S_FILTER': diff --git a/packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts b/packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts index f029680864185b..d9c39d70d766b5 100644 --- a/packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts +++ b/packages/next/src/client/components/router-reducer/prefetch-cache-utils.ts @@ -8,7 +8,7 @@ import { type PrefetchCacheEntry, PrefetchKind, type ReadonlyReducerState, - PREFETCH_STALE_TIME, + PREFETCH_CACHE_MODE, } from './router-reducer-types' import { prefetchQueue } from './reducers/prefetch-reducer' @@ -114,8 +114,7 @@ export function getOrCreatePrefetchCacheEntry({ kind: kind || // in dev, there's never gonna be a prefetch entry so we want to prefetch here - // when staletime is 0, there'll never be a "FULL" prefetch kind, so we default to auto - (process.env.NODE_ENV === 'development' || PREFETCH_STALE_TIME === 0 + (process.env.NODE_ENV === 'development' ? PrefetchKind.AUTO : PrefetchKind.TEMPORARY), }) @@ -246,21 +245,23 @@ export function prunePrefetchCache( } const FIVE_MINUTES = 5 * 60 * 1000 +const THIRTY_SECONDS = 30 * 1000 function getPrefetchEntryCacheStatus({ kind, prefetchTime, lastUsedTime, }: PrefetchCacheEntry): PrefetchCacheEntryStatus { - if (PREFETCH_STALE_TIME === 0) { - // a value of 0 means we never want to use the prefetch data, only the prefetched loading state (if it exists) - // we mark it stale here so that the router will not attempt to apply the cache node data and will instead know to lazily - // fetch the full data + if (kind !== PrefetchKind.FULL && PREFETCH_CACHE_MODE === 'live') { + // When the cache mode is set to "live", we only want to re-use the loading state. We mark the entry as stale + // regardless of the lastUsedTime so that the router will not attempt to apply the cache node data and will instead only + // re-use the loading state while lazy fetching the page data. + // We don't do this for a full prefetch, as if there's explicit caching intent it should respect existing heuristics. return PrefetchCacheEntryStatus.stale } // if the cache entry was prefetched or read less than the specified staletime window, then we want to re-use it - if (Date.now() < (lastUsedTime ?? prefetchTime) + PREFETCH_STALE_TIME) { + if (Date.now() < (lastUsedTime ?? prefetchTime) + THIRTY_SECONDS) { return lastUsedTime ? PrefetchCacheEntryStatus.reusable : PrefetchCacheEntryStatus.fresh diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 22acff45ff0f20..1c43bfb9b21b26 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -284,9 +284,9 @@ export function isThenable(value: any): value is Promise { } /** - * Time (in ms) that a prefetch entry can be reused by the client router cache. + * A `live` value will indicate that the client router should always fetch the latest data from the server when + * navigating to a new route when auto prefetching is used. A `default` value will use existing + * cache heuristics (router cache will persist for 30s before being invalidated). Defaults to `default`. */ -export const PREFETCH_STALE_TIME = - typeof process.env.__NEXT_CLIENT_ROUTER_CACHE_STALETIME_MS !== 'undefined' - ? parseInt(process.env.__NEXT_CLIENT_ROUTER_CACHE_STALETIME_MS, 10) - : 30 * 1000 // thirty seconds (in ms) +export const PREFETCH_CACHE_MODE = + process.env.__NEXT_CLIENT_ROUTER_CACHE_MODE === 'live' ? 'live' : 'default' diff --git a/packages/next/src/client/link.tsx b/packages/next/src/client/link.tsx index d37be47e34aa91..dec2bfc7bc0c2c 100644 --- a/packages/next/src/client/link.tsx +++ b/packages/next/src/client/link.tsx @@ -21,10 +21,7 @@ import type { import { useIntersection } from './use-intersection' import { getDomainLocale } from './get-domain-locale' import { addBasePath } from './add-base-path' -import { - PREFETCH_STALE_TIME, - PrefetchKind, -} from './components/router-reducer/router-reducer-types' +import { PrefetchKind } from './components/router-reducer/router-reducer-types' type Url = string | UrlObject type RequiredKeys = { @@ -310,11 +307,7 @@ const Link = React.forwardRef( * - false: we will not prefetch if in the viewport at all */ const appPrefetchKind = - // If the prefetch staletime is 0, then a full prefetch would be wasteful, as it'd never get used. - // These get switched into "auto" so at least the loading state can be re-used. - prefetchProp === null || PREFETCH_STALE_TIME === 0 - ? PrefetchKind.AUTO - : PrefetchKind.FULL + prefetchProp === null ? PrefetchKind.AUTO : PrefetchKind.FULL if (process.env.NODE_ENV !== 'production') { function createPropError(args: { diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index a56cbf9e062569..d19d732197a2dd 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -247,7 +247,10 @@ export const configSchema: zod.ZodType = z.lazy(() => validator: z.string().optional(), }) .optional(), - clientRouterCache: z.boolean().optional(), + clientRouterCacheMode: z.union([ + z.literal('default'), + z.literal('live'), + ]), clientRouterFilter: z.boolean().optional(), clientRouterFilterRedirects: z.boolean().optional(), clientRouterFilterAllowedRate: z.number().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 3d49c0202a8cf0..5afaf4591f18a2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -188,12 +188,12 @@ export interface ExperimentalConfig { clientRouterFilter?: boolean clientRouterFilterRedirects?: boolean /** - * Used to control the router cache "stale time" value. This value is used to determine - * if a cache entry can be re-used, and for how long. `true` means "forever" (implemented as - * one year), `false` means "never" (will always fetch from the server), and undefined will use - * the existing heuristics (30s for dynamic routes, 5min for static routes) + * This value can be used to override the cache behavior for the client router. A `live` value + * will indicate that the client router should always fetch the latest data from the server when + * navigating to a new route when auto prefetching is used. A `default` value will use existing + * cache heuristics (router cache will persist for 30s before being invalidated). Defaults to `default`. */ - clientRouterCache?: boolean + clientRouterCacheMode?: 'live' | 'default' // decimal for percent for possible false positives // e.g. 0.01 for 10% potential false matches lower // percent increases size of the filter @@ -933,7 +933,7 @@ export const defaultConfig: NextConfig = { missingSuspenseWithCSRBailout: true, optimizeServerReact: true, useEarlyImport: false, - clientRouterCache: undefined, + clientRouterCacheMode: 'default', }, } diff --git a/test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts b/test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts index 23098dcef3a7b4..5b0f4b0bcbbf5f 100644 --- a/test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts +++ b/test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts @@ -1,20 +1,28 @@ import { nextTestSetup } from 'e2e-utils' import { browserConfigWithFixedTime, fastForwardTo } from './test-utils' +import { runTests } from './client-cache.test' describe('app dir client cache semantics (experimental clientRouterCache)', () => { - describe('clientRouterCache = true', () => { - const { next } = nextTestSetup({ + describe('clientRouterCache = live', () => { + const { next, isNextDev } = nextTestSetup({ files: __dirname, nextConfig: { - experimental: { clientRouterCache: true }, + experimental: { clientRouterCacheMode: 'live' }, }, }) + if (isNextDev) { + // since the router behavior is different in development mode (no viewport prefetching + liberal revalidation) + // we only check the production behavior + it('should skip dev', () => {}) + return + } + describe('prefetch={true}', () => { - test('we should get a cached version of the page every request', async () => { + it('should re-use the cache for 5 minutes', async () => { const browser = await next.browser('/', browserConfigWithFixedTime) - const initialRandomNumber = await browser + let initialRandomNumber = await browser .elementByCss('[href="/0?timeout=0"]') .click() .waitForElementByCss('#random-number') @@ -30,7 +38,7 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = expect(initialRandomNumber).toBe(newRandomNumber) - await browser.eval(fastForwardTo, 2 * 60 * 60 * 1000) // fast forward 2 hours + await browser.eval(fastForwardTo, 30 * 1000) // fast forward 30 seconds await browser.elementByCss('[href="/"]').click() @@ -41,148 +49,8 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = .text() expect(initialRandomNumber).toBe(newRandomNumber) - }) - }) - - describe('prefetch={false}', () => { - test('we should get a loading state before fetching the page, followed by a cached version of the page every request', async () => { - const browser = await next.browser('/', browserConfigWithFixedTime) - // verify we rendered the loading state - await browser - .elementByCss('[href="/2?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - - const initialRandomNumber = await browser - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/"]').click() - - await browser.eval(fastForwardTo, 2 * 60 * 60 * 1000) // fast forward 2 hours - - const newRandomNumber = await browser - .elementByCss('[href="/2?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).toBe(newRandomNumber) - }) - - describe('without a loading boundary', () => { - test('we should get a cached version of the page every request', async () => { - const browser = await next.browser( - '/without-loading', - browserConfigWithFixedTime - ) - - const initialRandomNumber = await browser - .elementByCss('[href="/without-loading/2?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/without-loading"]').click() - - await browser.eval(fastForwardTo, 2 * 60 * 60 * 1000) // fast forward 2 hours - - const newRandomNumber = await browser - .elementByCss('[href="/without-loading/2?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).toBe(newRandomNumber) - }) - }) - }) - - describe('prefetch={undefined} - default', () => { - test('we should get a loading state before fetching the page, followed by a cached version of the page every request', async () => { - const browser = await next.browser('/', browserConfigWithFixedTime) - - // verify we rendered the loading state - await browser - .elementByCss('[href="/1?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - - const initialRandomNumber = await browser - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/"]').click() - - await browser.eval(fastForwardTo, 2 * 60 * 60 * 1000) // fast forward 2 hours - - const newRandomNumber = await browser - .elementByCss('[href="/1?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).toBe(newRandomNumber) - }) - - describe('without a loading boundary', () => { - test('we should get a cached version of the page every request', async () => { - const browser = await next.browser( - '/without-loading', - browserConfigWithFixedTime - ) - - const initialRandomNumber = await browser - .elementByCss('[href="/without-loading/1?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/without-loading"]').click() - - await browser.eval(fastForwardTo, 2 * 60 * 60 * 1000) // fast forward 2 hours - - const newRandomNumber = await browser - .elementByCss('[href="/without-loading/1?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).toBe(newRandomNumber) - }) - }) - }) - }) - - describe('clientRouterCache = false', () => { - const { next } = nextTestSetup({ - files: __dirname, - nextConfig: { - experimental: { clientRouterCache: false }, - }, - }) - describe('prefetch={true}', () => { - test('we should get fresh data on every subsequent navigation', async () => { - const browser = await next.browser('/', browserConfigWithFixedTime) - - const initialRandomNumber = await browser - .elementByCss('[href="/0?timeout=0"]') - .click() - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/"]').click() - - let newRandomNumber = await browser - .elementByCss('[href="/0?timeout=0"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).not.toBe(newRandomNumber) - - await browser.eval(fastForwardTo, 5 * 1000) // fast forward 5 seconds + await browser.eval(fastForwardTo, 5 * 60 * 1000) // fast forward 5 minutes await browser.elementByCss('[href="/"]').click() @@ -194,73 +62,10 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = expect(initialRandomNumber).not.toBe(newRandomNumber) }) - - test('we should get a loading state before fetching the page, followed by fresh data on every subsequent navigation', async () => { - const browser = await next.browser('/', browserConfigWithFixedTime) - - // this test introduces an artificial delay in rendering the requested page, so we verify a loading state is rendered - await browser - .elementByCss('[href="/0?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - - const initialRandomNumber = await browser - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/"]').click() - - await browser.eval(fastForwardTo, 5 * 1000) // fast forward 5 seconds - - const newRandomNumber = await browser - .elementByCss('[href="/0?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).not.toBe(newRandomNumber) - }) - - describe('without a loading boundary', () => { - test('we should get fresh data on every subsequent navigation', async () => { - const browser = await next.browser( - '/without-loading', - browserConfigWithFixedTime - ) - - const initialRandomNumber = await browser - .elementByCss('[href="/without-loading/0?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - await browser.elementByCss('[href="/without-loading"]').click() - - let newRandomNumber = await browser - .elementByCss('[href="/without-loading/0?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).not.toBe(newRandomNumber) - - await browser.eval(fastForwardTo, 5 * 1000) // fast forward 5 seconds - - await browser.elementByCss('[href="/without-loading"]').click() - - newRandomNumber = await browser - .elementByCss('[href="/without-loading/0?timeout=1000"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(initialRandomNumber).not.toBe(newRandomNumber) - }) - }) }) describe('prefetch={false}', () => { - test('we should get a loading state before fetching the page, followed by fresh data on every subsequent navigation', async () => { + it('should trigger a loading state before fetching the page, followed by fresh data on every subsequent navigation', async () => { const browser = await next.browser('/', browserConfigWithFixedTime) // this test introduces an artificial delay in rendering the requested page, so we verify a loading state is rendered @@ -287,7 +92,7 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = }) describe('without a loading boundary', () => { - test('we should get fresh data on every subsequent navigation', async () => { + it('should get fresh data on every subsequent navigation', async () => { const browser = await next.browser('/', browserConfigWithFixedTime) const initialRandomNumber = await browser @@ -312,7 +117,7 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = }) describe('prefetch={undefined} - default', () => { - test('we should get a loading state before fetching the page, followed by fresh data on every subsequent navigation', async () => { + it('should trigger a loading state before fetching the page, followed by fresh data on every subsequent navigation', async () => { const browser = await next.browser('/', browserConfigWithFixedTime) // this test introduces an artificial delay in rendering the requested page, so we verify a loading state is rendered @@ -339,7 +144,7 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = }) describe('without a loading boundary', () => { - test('we should get fresh data on every subsequent navigation', async () => { + it('should get fresh data on every subsequent navigation', async () => { const browser = await next.browser( '/without-loading', browserConfigWithFixedTime @@ -364,4 +169,21 @@ describe('app dir client cache semantics (experimental clientRouterCache)', () = }) }) }) + + describe('clientRouterCache = default', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + nextConfig: { + experimental: { clientRouterCacheMode: 'default' }, + }, + }) + + if (isNextDev) { + // since the router behavior is different in development mode (no viewport prefetching + liberal revalidation) + // we only check the production behavior + it('should skip dev', () => {}) + } else { + runTests(next) + } + }) }) diff --git a/test/e2e/app-dir/app-client-cache/client-cache.test.ts b/test/e2e/app-dir/app-client-cache/client-cache.test.ts index b3c4eaf291bac4..1d29bb06408ec3 100644 --- a/test/e2e/app-dir/app-client-cache/client-cache.test.ts +++ b/test/e2e/app-dir/app-client-cache/client-cache.test.ts @@ -1,4 +1,4 @@ -import { createNextDescribe } from 'e2e-utils' +import { NextInstance, createNextDescribe } from 'e2e-utils' import { check } from 'next-test-utils' import { BrowserInterface } from 'test/lib/browsers/base' import { @@ -8,411 +8,406 @@ import { getPathname, } from './test-utils' -createNextDescribe( - 'app dir client cache semantics', - { - files: __dirname, - }, - ({ next, isNextDev }) => { - if (isNextDev) { - // since the router behavior is different in development 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, 5 * 1000) - - await browser.elementByCss('[href="/"]').click() - - const newNumber = await browser - .elementByCss('[href="/1"]') - .click() - .waitForElementByCss('#random-number') - .text() - - expect(newNumber).toBe(randomNumber) - - await browser.eval(fastForwardTo, 30 * 1000) - - await browser.elementByCss('[href="/"]').click() +export function runTests(next: NextInstance) { + 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, 5 * 1000) + + await browser.elementByCss('[href="/"]').click() + + const newNumber = await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(newNumber).toBe(randomNumber) + + await browser.eval(fastForwardTo, 30 * 1000) + + await browser.elementByCss('[href="/"]').click() + + const newNumber2 = await browser + .elementByCss('[href="/1"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(newNumber2).not.toBe(newNumber) + }) + + it('should renew the 30s cache once the data is revalidated', async () => { + // navigate to prefetch-auto page + await browser.elementByCss('[href="/1"]').click() + + let initialNumber = await browser.elementById('random-number').text() - const newNumber2 = await browser - .elementByCss('[href="/1"]') - .click() - .waitForElementByCss('#random-number') - .text() + // Navigate back to the index, and then back to the prefetch-auto page + await browser.elementByCss('[href="/"]').click() + await browser.eval(fastForwardTo, 5 * 1000) + await browser.elementByCss('[href="/1"]').click() - expect(newNumber2).not.toBe(newNumber) - }) - - it('should renew the 30s cache once the data is revalidated', async () => { - // navigate to prefetch-auto page - await browser.elementByCss('[href="/1"]').click() + let newNumber = await browser.elementById('random-number').text() + + // the number should be the same, as we navigated within 30s. + expect(newNumber).toBe(initialNumber) - let initialNumber = await browser.elementById('random-number').text() + // Fast forward to expire the cache + await browser.eval(fastForwardTo, 30 * 1000) - // Navigate back to the index, and then back to the prefetch-auto page - await browser.elementByCss('[href="/"]').click() - await browser.eval(fastForwardTo, 5 * 1000) - await browser.elementByCss('[href="/1"]').click() + // Navigate back to the index, and then back to the prefetch-auto page + await browser.elementByCss('[href="/"]').click() + await browser.elementByCss('[href="/1"]').click() - let newNumber = await browser.elementById('random-number').text() - - // the number should be the same, as we navigated within 30s. - expect(newNumber).toBe(initialNumber) + newNumber = await browser.elementById('random-number').text() + + // ~35s have passed, so the cache should be expired and the number should be different + expect(newNumber).not.toBe(initialNumber) - // Fast forward to expire the cache - await browser.eval(fastForwardTo, 30 * 1000) + // once the number is updated, we should have a renewed 30s cache for this entry + // store this new number so we can check that it stays the same + initialNumber = newNumber - // Navigate back to the index, and then back to the prefetch-auto page - await browser.elementByCss('[href="/"]').click() - await browser.elementByCss('[href="/1"]').click() + await browser.eval(fastForwardTo, 5 * 1000) - newNumber = await browser.elementById('random-number').text() - - // ~35s have passed, so the cache should be expired and the number should be different - expect(newNumber).not.toBe(initialNumber) + // Navigate back to the index, and then back to the prefetch-auto page + await browser.elementByCss('[href="/"]').click() + await browser.elementByCss('[href="/1"]').click() - // once the number is updated, we should have a renewed 30s cache for this entry - // store this new number so we can check that it stays the same - initialNumber = newNumber - - await browser.eval(fastForwardTo, 5 * 1000) + newNumber = await browser.elementById('random-number').text() - // Navigate back to the index, and then back to the prefetch-auto page - await browser.elementByCss('[href="/"]').click() - await browser.elementByCss('[href="/1"]').click() + // the number should be the same, as we navigated within 30s (part 2). + expect(newNumber).toBe(initialNumber) + }) - newNumber = await browser.elementById('random-number').text() + it('should refetch below the fold after 30 seconds', async () => { + const randomLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() - // the number should be the same, as we navigated within 30s (part 2). - expect(newNumber).toBe(initialNumber) - }) + const randomNumber = await browser + .waitForElementByCss('#random-number') + .text() - it('should refetch below the fold after 30 seconds', async () => { - const randomLoadingNumber = await browser - .elementByCss('[href="/1?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - .text() + await browser.elementByCss('[href="/"]').click() - const randomNumber = await browser - .waitForElementByCss('#random-number') - .text() + await browser.eval(fastForwardTo, 30 * 1000) - await browser.elementByCss('[href="/"]').click() + const newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() - await browser.eval(fastForwardTo, 30 * 1000) + const newNumber = await browser + .waitForElementByCss('#random-number') + .text() - const newLoadingNumber = await browser - .elementByCss('[href="/1?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - .text() + expect(newLoadingNumber).toBe(randomLoadingNumber) - const newNumber = await browser - .waitForElementByCss('#random-number') - .text() + 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() - expect(newLoadingNumber).toBe(randomLoadingNumber) + const randomNumber = await browser + .waitForElementByCss('#random-number') + .text() - 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() + await browser.eval(fastForwardTo, 5 * 60 * 1000) - const randomNumber = await browser - .waitForElementByCss('#random-number') - .text() + await browser + .elementByCss('[href="/"]') + .click() + .waitForElementByCss('[href="/1?timeout=1000"]') - await browser.eval(fastForwardTo, 5 * 60 * 1000) + const newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() - await browser - .elementByCss('[href="/"]') - .click() - .waitForElementByCss('[href="/1?timeout=1000"]') + const newNumber = await browser + .waitForElementByCss('#random-number') + .text() - const newLoadingNumber = await browser - .elementByCss('[href="/1?timeout=1000"]') - .click() - .waitForElementByCss('#loading') - .text() + expect(newLoadingNumber).not.toBe(randomLoadingNumber) - const newNumber = await browser - .waitForElementByCss('#random-number') - .text() + expect(newNumber).not.toBe(randomNumber) + }) - expect(newLoadingNumber).not.toBe(randomLoadingNumber) + it('should respect a loading boundary that returns `null`', async () => { + await browser.elementByCss('[href="/null-loading"]').click() - expect(newNumber).not.toBe(randomNumber) - }) + // the page content should disappear immediately + expect( + await browser.hasElementByCssSelector('[href="/null-loading"]') + ).toBeFalse() - it('should respect a loading boundary that returns `null`', async () => { - await browser.elementByCss('[href="/null-loading"]').click() + // the root layout should still be visible + expect(await browser.hasElementByCssSelector('#root-layout')).toBeTrue() - // the page content should disappear immediately - expect( - await browser.hasElementByCssSelector('[href="/null-loading"]') - ).toBeFalse() - - // the root layout should still be visible - expect( - await browser.hasElementByCssSelector('#root-layout') - ).toBeTrue() + // the dynamic content should eventually appear + await browser.waitForElementByCss('#random-number') + expect(await browser.hasElementByCssSelector('#random-number')).toBeTrue() + }) + }) + it('should seed the prefetch cache with the fetched page data', async () => { + const browser = (await next.browser( + '/1', + browserConfigWithFixedTime + )) as BrowserInterface - // the dynamic content should eventually appear - await browser.waitForElementByCss('#random-number') - expect( - await browser.hasElementByCssSelector('#random-number') - ).toBeTrue() - }) - }) - - it('should seed the prefetch cache with the fetched page data', async () => { - const browser = (await next.browser( - '/1', - browserConfigWithFixedTime - )) as BrowserInterface - - const initialNumber = await browser.elementById('random-number').text() - - // Move forward a few seconds, navigate off the page and then back to it - await browser.eval(fastForwardTo, 5 * 1000) - await browser.elementByCss('[href="/"]').click() - await browser.elementByCss('[href="/1"]').click() - - const newNumber = await browser.elementById('random-number').text() - - // The number should be the same as we've seeded it in the prefetch cache when we loaded the full page - expect(newNumber).toBe(initialNumber) - }) - describe('router.push', () => { - it('should re-use the cache for 30 seconds', async () => {}) - it('should fully refetch the page after 30 seconds', async () => {}) - }) + const initialNumber = await browser.elementById('random-number').text() + + // Move forward a few seconds, navigate off the page and then back to it + await browser.eval(fastForwardTo, 5 * 1000) + await browser.elementByCss('[href="/"]').click() + await browser.elementByCss('[href="/1"]').click() + + const newNumber = await browser.elementById('random-number').text() + + // The number should be the same as we've seeded it in the prefetch cache when we loaded the full page + expect(newNumber).toBe(initialNumber) + }) +} + +createNextDescribe( + 'app dir client cache semantics', + { + files: __dirname, + }, + ({ next, isNextDev }) => { + if (isNextDev) { + // since the router behavior is different in development mode (no viewport prefetching + liberal revalidation) + // we only check the production behavior + it('should skip dev', () => {}) + } else { + runTests(next) } } )