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 abdfd37a2dd18..aea7e34537f2d 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -171,6 +171,16 @@ 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_DYNAMIC_STALETIME': JSON.stringify( + isNaN(Number(config.experimental.staleTimes?.dynamic)) + ? 30 // 30 seconds + : config.experimental.staleTimes?.dynamic + ), + 'process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME': JSON.stringify( + isNaN(Number(config.experimental.staleTimes?.static)) + ? 5 * 60 // 5 minutes + : config.experimental.staleTimes?.static + ), '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 522ccc6aab11e..abddbe04484ed 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 @@ -243,31 +243,38 @@ export function prunePrefetchCache( } } -const FIVE_MINUTES = 5 * 60 * 1000 -const THIRTY_SECONDS = 30 * 1000 +// These values are set by `define-env-plugin` (based on `nextConfig.experimental.staleTimes`) +// and default to 5 minutes (static) / 30 seconds (dynamic) +const DYNAMIC_STALETIME_MS = + Number(process.env.__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME) * 1000 + +const STATIC_STALETIME_MS = + Number(process.env.__NEXT_CLIENT_ROUTER_STATIC_STALETIME) * 1000 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) { + // We will re-use the cache entry data for up to the `dynamic` staletime window. + if (Date.now() < (lastUsedTime ?? prefetchTime) + DYNAMIC_STALETIME_MS) { 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 + // For "auto" prefetching, we'll re-use only the loading boundary for up to `static` staletime window. + // A stale entry will only re-use the `loading` boundary, not the full data. + // This will trigger a "lazy fetch" for the full data. if (kind === 'auto') { - if (Date.now() < prefetchTime + FIVE_MINUTES) { + if (Date.now() < prefetchTime + STATIC_STALETIME_MS) { 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 + // for "full" prefetching, we'll re-use the cache entry data for up to `static` staletime window. if (kind === 'full') { - if (Date.now() < prefetchTime + FIVE_MINUTES) { + if (Date.now() < prefetchTime + STATIC_STALETIME_MS) { return PrefetchCacheEntryStatus.reusable } } diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 88b04fbd25d20..22179d9dca80f 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -247,6 +247,12 @@ export const configSchema: zod.ZodType = z.lazy(() => validator: z.string().optional(), }) .optional(), + staleTimes: z + .object({ + dynamic: z.number().optional(), + static: z.number().optional(), + }) + .optional(), 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 d9c7a176efa43..7ae8af4569bba 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -187,6 +187,16 @@ export interface ExperimentalConfig { strictNextHead?: boolean clientRouterFilter?: boolean clientRouterFilterRedirects?: boolean + /** + * This config can be used to override the cache behavior for the client router. + * These values indicate the time, in seconds, that the cache should be considered + * reusable. When the `prefetch` Link prop is left unspecified, this will use the `dynamic` value. + * When the `prefetch` Link prop is set to `true`, this will use the `static` value. + */ + staleTimes?: { + dynamic?: number + static?: number + } // decimal for percent for possible false positives // e.g. 0.01 for 10% potential false matches lower // percent increases size of the filter @@ -926,6 +936,10 @@ export const defaultConfig: NextConfig = { missingSuspenseWithCSRBailout: true, optimizeServerReact: true, useEarlyImport: false, + staleTimes: { + dynamic: 30, + static: 300, + }, }, } diff --git a/test/e2e/app-dir/app-client-cache/app/page.js b/test/e2e/app-dir/app-client-cache/app/page.js index bc234a78fa2b8..0face5b346ea3 100644 --- a/test/e2e/app-dir/app-client-cache/app/page.js +++ b/test/e2e/app-dir/app-client-cache/app/page.js @@ -7,6 +7,11 @@ export default function HomePage() { To Random Number - prefetch: true +
+ + To Random Number - prefetch: true, slow + +
To Random Number - prefetch: auto
@@ -15,6 +20,11 @@ export default function HomePage() { To Random Number 2 - prefetch: false +
+ + To Random Number 2 - prefetch: false, slow + +
To Random Number - prefetch: auto, slow diff --git a/test/e2e/app-dir/app-client-cache/app/without-loading/[id]/page.js b/test/e2e/app-dir/app-client-cache/app/without-loading/[id]/page.js new file mode 100644 index 0000000000000..f2292666f8cf3 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/without-loading/[id]/page.js @@ -0,0 +1,21 @@ +import Link from 'next/link' + +export default async function Page({ searchParams: { timeout } }) { + const randomNumber = await new Promise((resolve) => { + setTimeout( + () => { + resolve(Math.random()) + }, + timeout !== undefined ? Number.parseInt(timeout, 10) : 0 + ) + }) + + return ( + <> +
+ Back to Home +
+
{randomNumber}
+ + ) +} diff --git a/test/e2e/app-dir/app-client-cache/app/without-loading/page.js b/test/e2e/app-dir/app-client-cache/app/without-loading/page.js new file mode 100644 index 0000000000000..29c650bcbcddf --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/app/without-loading/page.js @@ -0,0 +1,36 @@ +import Link from 'next/link' + +export default function Page() { + return ( + <> +
+ + To Random Number - prefetch: true + +
+
+ + To Random Number - prefetch: true, slow + +
+
+ To Random Number - prefetch: auto +
+
+ + To Random Number 2 - prefetch: false + +
+
+ + To Random Number 2 - prefetch: false, slow + +
+
+ + To Random Number - prefetch: auto, slow + +
+ + ) +} 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 new file mode 100644 index 0000000000000..6b65beeeb91f0 --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts @@ -0,0 +1,269 @@ +import { nextTestSetup } from 'e2e-utils' +import { browserConfigWithFixedTime, fastForwardTo } from './test-utils' + +describe('app dir client cache semantics (experimental staleTimes)', () => { + describe('dynamic: 0', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + nextConfig: { + experimental: { staleTimes: { dynamic: 0 } }, + }, + }) + + 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}', () => { + it('should re-use the cache for 5 minutes (default "static" time)', async () => { + const browser = await next.browser('/', browserConfigWithFixedTime) + + let 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).toBe(newRandomNumber) + + await browser.eval(fastForwardTo, 30 * 1000) // fast forward 30 seconds + + await browser.elementByCss('[href="/"]').click() + + newRandomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).toBe(newRandomNumber) + + await browser.eval(fastForwardTo, 5 * 60 * 1000) // fast forward 5 minutes + + await browser.elementByCss('[href="/"]').click() + + newRandomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + }) + + describe('prefetch={false}', () => { + 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 + 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, 5 * 1000) // fast forward 5 seconds + + const newRandomNumber = await browser + .elementByCss('[href="/2?timeout=1000"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + + describe('without a loading boundary', () => { + it('should get fresh data on every subsequent navigation', async () => { + const browser = await next.browser('/', browserConfigWithFixedTime) + + const initialRandomNumber = await browser + .elementByCss('[href="/2?timeout=1000"]') + .click() + .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="/2?timeout=1000"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + }) + }) + + describe('prefetch={undefined} - default', () => { + 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 + 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, 5 * 1000) // fast forward 5 seconds + + const newRandomNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + + describe('without a loading boundary', () => { + it('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/1?timeout=1000"]') + .click() + .waitForElementByCss('#random-number') + .text() + + await browser.elementByCss('[href="/without-loading"]').click() + + const newRandomNumber = await browser + .elementByCss('[href="/without-loading/1?timeout=1000"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + }) + }) + }) + + describe('static: 180', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + nextConfig: { + experimental: { staleTimes: { static: 180 } }, + }, + }) + + 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}', () => { + it('should use the custom static override time (3 minutes)', async () => { + const browser = await next.browser('/', browserConfigWithFixedTime) + + let 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).toBe(newRandomNumber) + + await browser.eval(fastForwardTo, 30 * 1000) // fast forward 30 seconds + + await browser.elementByCss('[href="/"]').click() + + newRandomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).toBe(newRandomNumber) + + await browser.eval(fastForwardTo, 3 * 60 * 1000) // fast forward 3 minutes + + await browser.elementByCss('[href="/"]').click() + + newRandomNumber = await browser + .elementByCss('[href="/0?timeout=0"]') + .click() + .waitForElementByCss('#random-number') + .text() + + expect(initialRandomNumber).not.toBe(newRandomNumber) + }) + }) + + describe('prefetch={undefined} - default', () => { + it('should re-use the loading boundary for the custom static override time (3 minutes)', async () => { + const browser = await next.browser('/', browserConfigWithFixedTime) + + const loadingRandomNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + await browser.elementByCss('[href="/"]').click() + + await browser.eval(fastForwardTo, 2 * 60 * 1000) // fast forward 2 minutes + + let newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + expect(loadingRandomNumber).toBe(newLoadingNumber) + + await browser.elementByCss('[href="/"]').click() + + await browser.eval(fastForwardTo, 2 * 60 * 1000) // fast forward 2 minutes + + newLoadingNumber = await browser + .elementByCss('[href="/1?timeout=1000"]') + .click() + .waitForElementByCss('#loading') + .text() + + expect(loadingRandomNumber).not.toBe(newLoadingNumber) + }) + }) + }) +}) 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 b696f86fc3ed9..67a576aa8d237 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,77 +1,12 @@ import { createNextDescribe } from 'e2e-utils' import { check } from 'next-test-utils' import { BrowserInterface } from 'test/lib/browsers/base' -import type { Request } from 'playwright' - -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 = [] - }, - } -} +import { + browserConfigWithFixedTime, + createRequestsListener, + fastForwardTo, + getPathname, +} from './test-utils' createNextDescribe( 'app dir client cache semantics', @@ -455,7 +390,6 @@ createNextDescribe( ).toBeTrue() }) }) - it('should seed the prefetch cache with the fetched page data', async () => { const browser = (await next.browser( '/1', @@ -474,10 +408,6 @@ createNextDescribe( // 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 () => {}) - }) } } ) diff --git a/test/e2e/app-dir/app-client-cache/next.config.js b/test/e2e/app-dir/app-client-cache/next.config.js deleted file mode 100644 index 4ba52ba2c8df6..0000000000000 --- a/test/e2e/app-dir/app-client-cache/next.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {} diff --git a/test/e2e/app-dir/app-client-cache/test-utils.ts b/test/e2e/app-dir/app-client-cache/test-utils.ts new file mode 100644 index 0000000000000..56c1199c13d0e --- /dev/null +++ b/test/e2e/app-dir/app-client-cache/test-utils.ts @@ -0,0 +1,72 @@ +import { BrowserInterface } from 'test/lib/browsers/base' +import type { Request } from 'playwright' + +export const getPathname = (url: string) => { + const urlObj = new URL(url) + return urlObj.pathname +} + +export 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() + } + } + }) + }, +} + +export 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() + } + } +} + +export 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 = [] + }, + } +} diff --git a/test/ppr-tests-manifest.json b/test/ppr-tests-manifest.json index dd2e05e89fa70..f9429ddda79fb 100644 --- a/test/ppr-tests-manifest.json +++ b/test/ppr-tests-manifest.json @@ -75,7 +75,8 @@ "test/e2e/app-dir/ppr/**/*", "test/e2e/app-dir/ppr-*/**/*", "test/e2e/app-dir/app-prefetch*/**/*", - "test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts" + "test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts", + "test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts" ] } }