From a5ab0eb473e0bcfbdbca12d0a866fe44acaedb89 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 18 Jul 2022 14:56:15 -0500 Subject: [PATCH 1/2] Add config for opting out of optimistic client cache behavior --- packages/next/build/webpack-config.ts | 3 + packages/next/server/config-schema.ts | 3 + packages/next/server/config-shared.ts | 2 + packages/next/shared/lib/router/router.ts | 4 +- .../prerender-prefetch/index.test.ts | 372 ++++++++++-------- 5 files changed, 227 insertions(+), 157 deletions(-) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index c2f0c3d11a1cc0d..6c96d866d07e160 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1525,6 +1525,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 f9d18748f444277..18e07d535fd6c6f 100644 --- a/packages/next/server/config-schema.ts +++ b/packages/next/server/config-schema.ts @@ -335,6 +335,9 @@ const configSchema = { optimizeCss: { type: 'boolean', }, + 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 a1dbbb5110e0da6..7e3109bdd0606aa 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 @@ -505,6 +506,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 daf100f8920751c..05cb1efe4724644 100644 --- a/packages/next/shared/lib/router/router.ts +++ b/packages/next/shared/lib/router/router.ts @@ -2147,7 +2147,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 3d89fc122a0a635..108b377cc963f44 100644 --- a/test/production/prerender-prefetch/index.test.ts +++ b/test/production/prerender-prefetch/index.test.ts @@ -9,64 +9,88 @@ 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()) - - 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 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 + } - const $ = cheerio.load(await res.text()) - const props = JSON.parse($('#props').text()) - reqs[path] = props - } + 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]) + } + }) - 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]) - } - }) + 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, '/') + + await browser.elementByCss('#to-blog-first').click() + + await check(async () => { + const data = await getData() + assert.notDeepEqual(initialData, data) + return 'success' + }, 'success') + }) - it('should trigger revalidation after navigation', async () => { - const getData = () => - fetchViaHTTP( + 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, @@ -76,120 +100,156 @@ describe('Prerender prefetch', () => { }, } ) - const initialDataRes = await getData() - const initialData = await initialDataRes.json() - const browser = await webdriver(next.url, '/') + const startTime = (await timeRes.json()).pageProps.now - await browser.elementByCss('#to-blog-first').click() + // ensure stale data is used by default + await browser.elementByCss('#to-blog-first').click() + const outputIndex = next.cliOutput.length - await check(async () => { - const data = await getData() - assert.notDeepEqual(initialData, data) - return 'success' - }, 'success') - }) + await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') - 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) - }) + expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe( + startTime + ) + await browser.back().waitForElementByCss('#to-blog-first') - 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', - }, - } - ) - const startTime = (await timeRes.json()).pageProps.now + // trigger revalidation of /blog/first + await check(async () => { + await renderViaHTTP(next.url, '/blog/first') + return next.cliOutput.substring(outputIndex) + }, /revalidating \/blog first/) - // ensure stale data is used by default - await browser.elementByCss('#to-blog-first').click() - await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') + // 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') - expect(JSON.parse(await browser.elementByCss('#props').text()).now).toBe( - startTime - ) - await browser.back().waitForElementByCss('#to-blog-first') - const requests = [] + await browser.elementByCss('#to-blog-first').click() + await check(() => browser.elementByCss('#page').text(), 'blog/[slug]') - browser.on('request', (req) => { - requests.push(req.url()) + const newTime = JSON.parse( + await browser.elementByCss('#props').text() + ).now + expect(newTime).not.toBe(startTime) + expect(isNaN(newTime)).toBe(false) }) - // 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') - }) + if (optimisticClientCache) { + 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', + }, + } + ) + 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 () => { + 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', async () => { + const browser = await webdriver(next.url, '/') + let requests = [] + + browser.on('request', (req) => { + requests.push(req.url()) + }) + + await check(async () => { + return requests.some((url) => url.includes('/blog/first')) && + requests.some((url) => url.includes('/blog/second')) + ? 'success' + : JSON.stringify(requests, null, 2) + }, 'success') + + requests = [] + + await browser.elementByCss('#to-blog-second').moveTo() + await browser.elementByCss('#to-blog-first').moveTo() + + 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') - it('should handle failed data fetch and empty cache correctly', async () => { - const browser = await webdriver(next.url, '/') - await browser.elementByCss('#to-blog-first').click() + await browser.back() + await browser.waitForElementByCss('#to-blog-first') + expect(await browser.eval('window.beforeNav')).toBeFalsy() + }) + } - // 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 }) }) }) From 27771d02609d3091026fff8e850b2d0eaf82deca Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 18 Jul 2022 15:26:48 -0500 Subject: [PATCH 2/2] update test --- test/production/prerender-prefetch/index.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/production/prerender-prefetch/index.test.ts b/test/production/prerender-prefetch/index.test.ts index 108b377cc963f44..55b6ab0382addd7 100644 --- a/test/production/prerender-prefetch/index.test.ts +++ b/test/production/prerender-prefetch/index.test.ts @@ -183,8 +183,11 @@ describe('Prerender prefetch', () => { }) await check(async () => { - return requests.some((url) => url.includes('/blog/first')) && - requests.some((url) => url.includes('/blog/second')) + 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')