diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fd4fa321bbd2..80a7160321fa 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1526,6 +1526,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_NEW_LINK_BEHAVIOR': JSON.stringify( config.experimental.newNextLinkBehavior ), + 'process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE': JSON.stringify( + config.experimental.optimisticClientCache + ), 'process.env.__NEXT_CROSS_ORIGIN': JSON.stringify(crossOrigin), 'process.browser': JSON.stringify(isClient), 'process.env.__NEXT_TEST_MODE': JSON.stringify( diff --git a/packages/next/server/config-schema.ts b/packages/next/server/config-schema.ts index 4b08545e3676..fccfc70e8ac3 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -328,6 +328,9 @@ const configSchema = { }, ] as any, }, + optimisticClientCache: { + type: 'boolean', + }, outputFileTracingRoot: { minLength: 1, type: 'string', diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index 52bac7ea06c3..55dd0a722850 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -79,6 +79,7 @@ export interface NextJsWebpackConfig { } export interface ExperimentalConfig { + optimisticClientCache?: boolean legacyBrowsers?: boolean browsersListForSwc?: boolean manualClientBasePath?: boolean @@ -517,6 +518,7 @@ export const defaultConfig: NextConfig = { swcMinify: false, output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, experimental: { + optimisticClientCache: true, runtime: undefined, manualClientBasePath: false, // TODO: change default in next major release (current v12.1.5) diff --git a/packages/next/shared/lib/router/router.ts b/packages/next/shared/lib/router/router.ts index 6fc99336b2b1..29294c48702b 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -2158,7 +2158,9 @@ export default class Router implements BaseRouter { persistCache: !this.isPreview, isPrefetch: true, unstable_skipClientCache: - options.unstable_skipClientCache || options.priority, + options.unstable_skipClientCache || + (options.priority && + !!process.env.__NEXT_OPTIMISTIC_CLIENT_CACHE), }).then(() => false) : false }), diff --git a/test/production/prerender-prefetch/index.test.ts b/test/production/prerender-prefetch/index.test.ts index 27d203c4bced..9ddd87611427 100644 --- a/test/production/prerender-prefetch/index.test.ts +++ b/test/production/prerender-prefetch/index.test.ts @@ -9,133 +9,86 @@ import assert from 'assert' describe('Prerender prefetch', () => { let next: NextInstance - beforeAll(async () => { - next = await createNext({ - files: { - pages: new FileRef(join(__dirname, 'app/pages')), - }, - dependencies: {}, - }) - }) - afterAll(() => next.destroy()) + const runTests = ({ + optimisticClientCache, + }: { + optimisticClientCache?: boolean + }) => { + it('should not revalidate during prefetching', async () => { + const reqs = {} + + // get initial values + for (const path of ['/blog/first', '/blog/second']) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) + + const $ = cheerio.load(await res.text()) + const props = JSON.parse($('#props').text()) + reqs[path] = props + } - it('should not revalidate during prefetching', async () => { - const reqs = {} + const browser = await webdriver(next.url, '/') - // get initial values - for (const path of ['/blog/first', '/blog/second']) { - const res = await fetchViaHTTP(next.url, path) - expect(res.status).toBe(200) + // wait for prefetch to occur + await check(async () => { + const cache = await browser.eval( + 'JSON.stringify(window.next.router.sdc)' + ) + return cache.includes('/blog/first') && cache.includes('/blog/second') + ? 'success' + : cache + }, 'success') - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - reqs[path] = props - } + await waitFor(3000) + await browser.refresh() - const browser = await webdriver(next.url, '/') - - // wait for prefetch to occur - await check(async () => { - const cache = await browser.eval('JSON.stringify(window.next.router.sdc)') - return cache.includes('/blog/first') && cache.includes('/blog/second') - ? 'success' - : cache - }, 'success') - - await waitFor(3000) - await browser.refresh() - - // reload after revalidate period and wait for prefetch again - await check(async () => { - const cache = await browser.eval('JSON.stringify(window.next.router.sdc)') - return cache.includes('/blog/first') && cache.includes('/blog/second') - ? 'success' - : cache - }, 'success') - - // ensure revalidate did not occur from prefetch - for (const path of ['/blog/first', '/blog/second']) { - const res = await fetchViaHTTP(next.url, path) - expect(res.status).toBe(200) - - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - expect(props).toEqual(reqs[path]) - } - }) + // reload after revalidate period and wait for prefetch again + await check(async () => { + const cache = await browser.eval( + 'JSON.stringify(window.next.router.sdc)' + ) + return cache.includes('/blog/first') && cache.includes('/blog/second') + ? 'success' + : cache + }, 'success') - it('should trigger revalidation after navigation', async () => { - const getData = () => - fetchViaHTTP( - next.url, - `/_next/data/${next.buildId}/blog/first.json`, - undefined, - { - headers: { - purpose: 'prefetch', - }, - } - ) - const initialDataRes = await getData() - const initialData = await initialDataRes.json() - const browser = await webdriver(next.url, '/') + // ensure revalidate did not occur from prefetch + for (const path of ['/blog/first', '/blog/second']) { + const res = await fetchViaHTTP(next.url, path) + expect(res.status).toBe(200) - await browser.elementByCss('#to-blog-first').click() + const $ = cheerio.load(await res.text()) + const props = JSON.parse($('#props').text()) + expect(props).toEqual(reqs[path]) + } + }) - await check(async () => { - const data = await getData() - assert.notDeepEqual(initialData, data) - return 'success' - }, 'success') - }) + it('should trigger revalidation after navigation', async () => { + const getData = () => + fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/blog/first.json`, + undefined, + { + headers: { + purpose: 'prefetch', + }, + } + ) + const initialDataRes = await getData() + const initialData = await initialDataRes.json() + const browser = await webdriver(next.url, '/') - it('should update cache using prefetch with unstable_skipClientCache', async () => { - const browser = await webdriver(next.url, '/') - const timeRes = await fetchViaHTTP( - next.url, - `/_next/data/${next.buildId}/blog/first.json`, - undefined, - { - headers: { - purpose: 'prefetch', - }, - } - ) - const startTime = (await timeRes.json()).pageProps.now - - // ensure stale data is used by default - await browser.elementByCss('#to-blog-first').click() - const outputIndex = next.cliOutput.length - - await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') - - expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe( - startTime - ) - await browser.back().waitForElementByCss('#to-blog-first') - - // trigger revalidation of /blog/first - await check(async () => { - await renderViaHTTP(next.url, '/blog/first') - return next.cliOutput.substring(outputIndex) - }, /revalidating \/blog first/) - - // now trigger cache update and navigate again - await browser.eval( - 'next.router.prefetch("/blog/first", undefined, { unstable_skipClientCache: true }).finally(() => { window.prefetchDone = "yes" })' - ) - await check(() => browser.eval('window.prefetchDone'), 'yes') - - await browser.elementByCss('#to-blog-first').click() - await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') - - const newTime = JSON.parse(await browser.elementByCss('#props').text()).now - expect(newTime).not.toBe(startTime) - expect(isNaN(newTime)).toBe(false) - }) + await browser.elementByCss('#to-blog-first').click() + + await check(async () => { + const data = await getData() + assert.notDeepEqual(initialData, data) + return 'success' + }, 'success') + }) - if (process.env.DEVICE_NAME) { - it('should attempt cache update on touchstart', async () => { + it('should update cache using prefetch with unstable_skipClientCache', async () => { const browser = await webdriver(next.url, '/') const timeRes = await fetchViaHTTP( next.url, @@ -151,85 +104,165 @@ describe('Prerender prefetch', () => { // ensure stale data is used by default await browser.elementByCss('#to-blog-first').click() + const outputIndex = next.cliOutput.length + await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe( startTime ) await browser.back().waitForElementByCss('#to-blog-first') - const requests = [] - browser.on('request', (req) => { - requests.push(req.url()) - }) + // trigger revalidation of /blog/first + await check(async () => { + await renderViaHTTP(next.url, '/blog/first') + return next.cliOutput.substring(outputIndex) + }, /revalidating \/blog first/) // now trigger cache update and navigate again - await check(async () => { - await browser.elementByCss('#to-blog-second').touchStart() - await browser.elementByCss('#to-blog-first').touchStart() - return requests.some((url) => url.includes('/blog/first.json')) - ? 'success' - : requests - }, 'success') - }) - } else { - it('should attempt cache update on link hover', async () => { - const browser = await webdriver(next.url, '/') - const timeRes = await fetchViaHTTP( - next.url, - `/_next/data/${next.buildId}/blog/first.json`, - undefined, - { - headers: { - purpose: 'prefetch', - }, - } + await browser.eval( + 'next.router.prefetch("/blog/first", undefined, { unstable_skipClientCache: true }).finally(() => { window.prefetchDone = "yes" })' ) - const startTime = (await timeRes.json()).pageProps.now + await check(() => browser.eval('window.prefetchDone'), 'yes') - // ensure stale data is used by default await browser.elementByCss('#to-blog-first').click() await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') - expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe( - startTime - ) - await browser.back().waitForElementByCss('#to-blog-first') - const requests = [] + const newTime = JSON.parse( + await browser.elementByCss('#props').text() + ).now + expect(newTime).not.toBe(startTime) + expect(isNaN(newTime)).toBe(false) + }) - browser.on('request', (req) => { - requests.push(req.url()) + if (optimisticClientCache) { + it('should attempt cache update on link hover/touch start', async () => { + const browser = await webdriver(next.url, '/') + const timeRes = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/blog/first.json`, + undefined, + { + headers: { + purpose: 'prefetch', + }, + } + ) + const startTime = (await timeRes.json()).pageProps.now + + // ensure stale data is used by default + await browser.elementByCss('#to-blog-first').click() + await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') + + expect( + JSON.parse(await browser.elementByCss('#props').text()).now + ).toBe(startTime) + await browser.back().waitForElementByCss('#to-blog-first') + const requests = [] + + browser.on('request', (req) => { + requests.push(req.url()) + }) + + // now trigger cache update and navigate again + await check(async () => { + if (process.env.DEVICE_NAME) { + await browser.elementByCss('#to-blog-second').touchStart() + await browser.elementByCss('#to-blog-first').touchStart() + } else { + await browser.elementByCss('#to-blog-second').moveTo() + await browser.elementByCss('#to-blog-first').moveTo() + } + return requests.some((url) => url.includes('/blog/first.json')) + ? 'success' + : requests + }, 'success') }) + } else { + it('should not attempt client cache update on link hover/touch start', async () => { + const browser = await webdriver(next.url, '/') + let requests = [] + + browser.on('request', (req) => { + requests.push(req.url()) + }) + + await check(async () => { + const cacheKeys = await browser.eval( + 'Object.keys(window.next.router.sdc)' + ) + return cacheKeys.some((url) => url.includes('/blog/first')) && + cacheKeys.some((url) => url.includes('/blog/second')) + ? 'success' + : JSON.stringify(requests, null, 2) + }, 'success') + + requests = [] + + if (process.env.DEVICE_NAME) { + await browser.elementByCss('#to-blog-second').touchStart() + await browser.elementByCss('#to-blog-first').touchStart() + } else { + await browser.elementByCss('#to-blog-second').moveTo() + await browser.elementByCss('#to-blog-first').moveTo() + } - // now trigger cache update and navigate again - await check(async () => { - await browser.elementByCss('#to-blog-second').moveTo() - await browser.elementByCss('#to-blog-first').moveTo() - return requests.some((url) => url.includes('/blog/first.json')) - ? 'success' - : requests - }, 'success') + expect(requests.filter((url) => url.includes('.json'))).toEqual([]) + }) + } + + it('should handle failed data fetch and empty cache correctly', async () => { + const browser = await webdriver(next.url, '/') + await browser.elementByCss('#to-blog-first').click() + + // ensure we use the same port when restarting + const port = new URL(next.url).port + next.forcedPort = port + + // trigger new build so buildId changes + await next.stop() + await next.start() + + // clear router cache + await browser.eval('window.next.router.sdc = {}') + await browser.eval('window.beforeNav = 1') + + await browser.back() + await browser.waitForElementByCss('#to-blog-first') + expect(await browser.eval('window.beforeNav')).toBeFalsy() }) } - it('should handle failed data fetch and empty cache correctly', async () => { - const browser = await webdriver(next.url, '/') - await browser.elementByCss('#to-blog-first').click() - - // ensure we use the same port when restarting - const port = new URL(next.url).port - next.forcedPort = port + describe('with optimisticClientCache enabled', () => { + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) - // trigger new build so buildId changes - await next.stop() - await next.start() + runTests({ optimisticClientCache: true }) + }) - // clear router cache - await browser.eval('window.next.router.sdc = {}') - await browser.eval('window.beforeNav = 1') + describe('with optimisticClientCache disabled', () => { + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + nextConfig: { + experimental: { + optimisticClientCache: false, + }, + }, + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) - await browser.back() - await browser.waitForElementByCss('#to-blog-first') - expect(await browser.eval('window.beforeNav')).toBeFalsy() + runTests({ optimisticClientCache: false }) }) })