From ce746ef5c2722d4740d09e4f5beac74371e68354 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 10 May 2023 13:59:48 -0700 Subject: [PATCH] Handle unstable_cache in pages (#49624) Ensures this method works in pages the same as in app as discussed. x-ref: [slack thread](https://vercel.slack.com/archives/C042LHPJ1NX/p1683242040712889) x-ref: [slack thread](https://vercel.slack.com/archives/C042LHPJ1NX/p1683724596461329) --- packages/next/src/server/base-server.ts | 3 +- .../lib/incremental-cache/fetch-cache.ts | 49 ++++++------ .../src/server/lib/incremental-cache/index.ts | 24 +++++- packages/next/src/server/lib/patch-fetch.ts | 7 +- packages/next/src/server/next-server.ts | 4 +- packages/next/src/server/web/adapter.ts | 2 +- .../next/src/server/web/sandbox/sandbox.ts | 5 +- .../web/spec-extension/unstable-cache.ts | 74 +++++++++++-------- .../e2e/app-dir/app-static/app-static.test.ts | 41 ++++++++++ .../pages/api/unstable-cache-edge.js | 25 +++++++ .../pages/api/unstable-cache-node.js | 14 ++++ .../app-static/pages/unstable-cache-edge.js | 29 ++++++++ .../app-static/pages/unstable-cache-node.js | 25 +++++++ 13 files changed, 240 insertions(+), 62 deletions(-) create mode 100644 test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js create mode 100644 test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js create mode 100644 test/e2e/app-dir/app-static/pages/unstable-cache-edge.js create mode 100644 test/e2e/app-dir/app-static/pages/unstable-cache-node.js diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 75ed633a6dd64..eb3399bcc9cf6 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -1051,7 +1051,7 @@ export default abstract class Server { // be passed down for edge functions and the fetch disk // cache can be leveraged locally if ( - !(globalThis as any).__incrementalCache && + !(this.serverOptions as any).webServerConfig && !getRequestMeta(req, '_nextIncrementalCache') ) { let protocol: 'http:' | 'https:' = 'https:' @@ -1071,6 +1071,7 @@ export default abstract class Server { | 'https', }) addRequestMeta(req, '_nextIncrementalCache', incrementalCache) + ;(globalThis as any).__incrementalCache = incrementalCache } try { diff --git a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts index c60fc3bfd6d53..2bf1844ffa6ed 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -45,30 +45,33 @@ export default class FetchCache implements CacheHandler { console.log('no cache endpoint available') } - if (ctx.maxMemoryCacheSize && !memoryCache) { - if (this.debug) { - console.log('using memory store for fetch cache') + if (ctx.maxMemoryCacheSize) { + if (!memoryCache) { + if (this.debug) { + console.log('using memory store for fetch cache') + } + + memoryCache = new LRUCache({ + max: ctx.maxMemoryCacheSize, + length({ value }) { + if (!value) { + return 25 + } else if (value.kind === 'REDIRECT') { + return JSON.stringify(value.props).length + } else if (value.kind === 'IMAGE') { + throw new Error('invariant image should not be incremental-cache') + } else if (value.kind === 'FETCH') { + return JSON.stringify(value.data || '').length + } else if (value.kind === 'ROUTE') { + return value.body.length + } + // rough estimate of size of cache value + return ( + value.html.length + (JSON.stringify(value.pageData)?.length || 0) + ) + }, + }) } - memoryCache = new LRUCache({ - max: ctx.maxMemoryCacheSize, - length({ value }) { - if (!value) { - return 25 - } else if (value.kind === 'REDIRECT') { - return JSON.stringify(value.props).length - } else if (value.kind === 'IMAGE') { - throw new Error('invariant image should not be incremental-cache') - } else if (value.kind === 'FETCH') { - return JSON.stringify(value.data || '').length - } else if (value.kind === 'ROUTE') { - return value.body.length - } - // rough estimate of size of cache value - return ( - value.html.length + (JSON.stringify(value.pageData)?.length || 0) - ) - }, - }) } else { if (this.debug) { console.log('not using memory store for fetch cache') diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 74afd8b047273..103b4bd0615dd 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -10,7 +10,10 @@ import { } from '../../response-cache' import { encode } from '../../../shared/lib/bloom-filter/base64-arraybuffer' import { encodeText } from '../../stream-utils/encode-decode' -import { CACHE_ONE_YEAR } from '../../../lib/constants' +import { + CACHE_ONE_YEAR, + PRERENDER_REVALIDATE_HEADER, +} from '../../../lib/constants' function toRoute(pathname: string): string { return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/' @@ -70,6 +73,7 @@ export class IncrementalCache { minimalMode?: boolean fetchCacheKeyPrefix?: string revalidatedTags?: string[] + isOnDemandRevalidate?: boolean constructor({ fs, @@ -102,13 +106,22 @@ export class IncrementalCache { fetchCacheKeyPrefix?: string CurCacheHandler?: typeof CacheHandler }) { + const debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE if (!CurCacheHandler) { if (fs && serverDistDir) { + if (debug) { + console.log('using filesystem cache handler') + } CurCacheHandler = FileSystemCache } if (minimalMode && fetchCache) { + if (debug) { + console.log('using fetch cache handler') + } CurCacheHandler = FetchCache } + } else if (debug) { + console.log('using custom cache handler', CurCacheHandler.name) } if (process.env.__NEXT_TEST_MAX_ISR_CACHE) { @@ -124,6 +137,13 @@ export class IncrementalCache { this.fetchCacheKeyPrefix = fetchCacheKeyPrefix let revalidatedTags: string[] = [] + if ( + requestHeaders[PRERENDER_REVALIDATE_HEADER] === + this.prerenderManifest?.preview?.previewModeId + ) { + this.isOnDemandRevalidate = true + } + if ( minimalMode && typeof requestHeaders['x-next-revalidated-tags'] === 'string' && @@ -298,7 +318,7 @@ export class IncrementalCache { async get( pathname: string, fetchCache?: boolean, - revalidate?: number, + revalidate?: number | false, fetchUrl?: string, fetchIdx?: number ): Promise { diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index c1513144d781d..4e6cb7092208f 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -301,7 +301,8 @@ export function patchFetch({ const fetchIdx = staticGenerationStore.nextFetchId ?? 1 staticGenerationStore.nextFetchId = fetchIdx + 1 - const normalizedRevalidate = !revalidate ? CACHE_ONE_YEAR : revalidate + const normalizedRevalidate = + typeof revalidate !== 'number' ? CACHE_ONE_YEAR : revalidate const doOriginalFetch = async (isStale?: boolean) => { // add metadata to init without editing the original @@ -341,7 +342,7 @@ export function patchFetch({ }, revalidate: normalizedRevalidate, }, - normalizedRevalidate, + revalidate, true, fetchUrl, fetchIdx @@ -365,7 +366,7 @@ export function patchFetch({ : await staticGenerationStore.incrementalCache.get( cacheKey, true, - normalizedRevalidate, + revalidate, fetchUrl, fetchIdx ) diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index c1d268d2d94d8..35934f3ae78d2 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -2838,7 +2838,9 @@ export default class NextNodeServer extends BaseServer { }, useCache: true, onWarning: params.onWarning, - incrementalCache: getRequestMeta(params.req, '_nextIncrementalCache'), + incrementalCache: + (globalThis as any).__incrementalCache || + getRequestMeta(params.req, '_nextIncrementalCache'), }) params.res.statusCode = result.response.status diff --git a/packages/next/src/server/web/adapter.ts b/packages/next/src/server/web/adapter.ts index e96f5d44224c2..62948a47e5c9e 100644 --- a/packages/next/src/server/web/adapter.ts +++ b/packages/next/src/server/web/adapter.ts @@ -180,7 +180,7 @@ export async function adapter( ).IncrementalCache({ appDir: true, fetchCache: true, - minimalMode: true, + minimalMode: process.env.NODE_ENV !== 'development', fetchCacheKeyPrefix: process.env.__NEXT_FETCH_CACHE_KEY_PREFIX, dev: process.env.NODE_ENV === 'development', requestHeaders: params.request.headers as any, diff --git a/packages/next/src/server/web/sandbox/sandbox.ts b/packages/next/src/server/web/sandbox/sandbox.ts index 6e50eaf178bcc..2398275b05802 100644 --- a/packages/next/src/server/web/sandbox/sandbox.ts +++ b/packages/next/src/server/web/sandbox/sandbox.ts @@ -63,7 +63,10 @@ export const getRuntimeContext = async (params: { edgeFunctionEntry: params.edgeFunctionEntry, distDir: params.distDir, }) - runtime.context.globalThis.__incrementalCache = params.incrementalCache + + if (params.incrementalCache) { + runtime.context.globalThis.__incrementalCache = params.incrementalCache + } for (const paramPath of params.paths) { evaluateInContext(paramPath) diff --git a/packages/next/src/server/web/spec-extension/unstable-cache.ts b/packages/next/src/server/web/spec-extension/unstable-cache.ts index b555da0d119af..962580a636b91 100644 --- a/packages/next/src/server/web/spec-extension/unstable-cache.ts +++ b/packages/next/src/server/web/spec-extension/unstable-cache.ts @@ -1,33 +1,40 @@ import { - StaticGenerationAsyncStorage, StaticGenerationStore, + staticGenerationAsyncStorage as _staticGenerationAsyncStorage, + StaticGenerationAsyncStorage, } from '../../../client/components/static-generation-async-storage' +import { CACHE_ONE_YEAR } from '../../../lib/constants' import { addImplicitTags } from '../../lib/patch-fetch' type Callback = (...args: any[]) => Promise export function unstable_cache( cb: T, - keyParts: string[], + keyParts?: string[], options: { - revalidate: number | false + revalidate?: number | false tags?: string[] - } + } = {} ): T { - const joinedKey = cb.toString() + '-' + keyParts.join(', ') - const staticGenerationAsyncStorage = ( - fetch as any - ).__nextGetStaticStore?.() as undefined | StaticGenerationAsyncStorage + const joinedKey = + keyParts && keyParts.length > 0 ? keyParts.join(',') : cb.toString() + + const staticGenerationAsyncStorage: StaticGenerationAsyncStorage = + (fetch as any).__nextGetStaticStore?.() || _staticGenerationAsyncStorage const store: undefined | StaticGenerationStore = staticGenerationAsyncStorage?.getStore() - if (!store || !store.incrementalCache) { + const incrementalCache: + | import('../../lib/incremental-cache').IncrementalCache + | undefined = + store?.incrementalCache || (globalThis as any).__incrementalCache + + if (!incrementalCache) { throw new Error( - `Invariant: static generation store missing in unstable_cache ${joinedKey}` + `Invariant: incrementalCache missing in unstable_cache ${joinedKey}` ) } - if (options.revalidate === 0) { throw new Error( `Invariant revalidate: 0 can not be passed to unstable_cache(), must be "false" or "> 0" ${joinedKey}` @@ -39,21 +46,21 @@ export function unstable_cache( // cache callback so that we only cache the specific values returned // from the callback instead of also caching any fetches done inside // of the callback as well - return staticGenerationAsyncStorage?.run( + return staticGenerationAsyncStorage.run( { ...store, fetchCache: 'only-no-store', + isStaticGeneration: !!store?.isStaticGeneration, + pathname: store?.pathname || '/', }, async () => { - const cacheKey = await store.incrementalCache?.fetchCacheKey(joinedKey) + const cacheKey = await incrementalCache?.fetchCacheKey(joinedKey) const cacheEntry = cacheKey && - !store.isOnDemandRevalidate && - (await store.incrementalCache?.get( - cacheKey, - true, - options.revalidate as number - )) + !( + store?.isOnDemandRevalidate || incrementalCache.isOnDemandRevalidate + ) && + (await incrementalCache?.get(cacheKey, true, options.revalidate)) const tags = options.tags || [] const implicitTags = addImplicitTags(store) @@ -67,8 +74,8 @@ export function unstable_cache( const invokeCallback = async () => { const result = await cb(...args) - if (cacheKey && store.incrementalCache) { - await store.incrementalCache.set( + if (cacheKey && incrementalCache) { + await incrementalCache.set( cacheKey, { kind: 'FETCH', @@ -79,7 +86,10 @@ export function unstable_cache( status: 200, tags, }, - revalidate: options.revalidate as number, + revalidate: + typeof options.revalidate !== 'number' + ? CACHE_ONE_YEAR + : options.revalidate, }, options.revalidate, true @@ -108,14 +118,18 @@ export function unstable_cache( const currentTags = cacheEntry.value.data.tags if (isStale) { - if (!store.pendingRevalidates) { - store.pendingRevalidates = [] - } - store.pendingRevalidates.push( - invokeCallback().catch((err) => - console.error(`revalidating cache with key: ${joinedKey}`, err) + if (!store) { + return invokeCallback() + } else { + if (!store.pendingRevalidates) { + store.pendingRevalidates = [] + } + store.pendingRevalidates.push( + invokeCallback().catch((err) => + console.error(`revalidating cache with key: ${joinedKey}`, err) + ) ) - ) + } } else if (tags && !tags.every((tag) => currentTags?.includes(tag))) { if (!cacheEntry.value.data.tags) { cacheEntry.value.data.tags = [] @@ -126,7 +140,7 @@ export function unstable_cache( cacheEntry.value.data.tags.push(tag) } } - store.incrementalCache?.set( + incrementalCache?.set( cacheKey, cacheEntry.value, options.revalidate, diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index de7112261872b..3984cbce1ce72 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -14,6 +14,7 @@ createNextDescribe( files: __dirname, env: { NEXT_DEBUG_BUILD: '1', + NEXT_PRIVATE_DEBUG_CACHE: '1', ...(process.env.CUSTOM_CACHE_HANDLER ? { CUSTOM_CACHE_HANDLER: process.env.CUSTOM_CACHE_HANDLER, @@ -34,6 +35,46 @@ createNextDescribe( } }) + it.each([ + { pathname: '/unstable-cache-node' }, + { pathname: '/unstable-cache-edge' }, + { pathname: '/api/unstable-cache-node' }, + { pathname: '/api/unstable-cache-edge' }, + ])('unstable-cache should work in pages$pathname', async ({ pathname }) => { + let res = await next.fetch(pathname) + expect(res.status).toBe(200) + const isApi = pathname.startsWith('/api') + let prevData + + if (isApi) { + prevData = await res.json() + } else { + const initialHtml = await res.text() + const initial$ = isApi ? undefined : cheerio.load(initialHtml) + prevData = JSON.parse(initial$('#props').text()) + } + + expect(prevData.data.random).toBeTruthy() + + await check(async () => { + res = await next.fetch(pathname) + expect(res.status).toBe(200) + let curData + + if (isApi) { + curData = await res.json() + } else { + const curHtml = await res.text() + const cur$ = cheerio.load(curHtml) + curData = JSON.parse(cur$('#props').text()) + } + + expect(curData.data.random).toBeTruthy() + expect(curData.data.random).toBe(prevData.data.random) + return 'success' + }, 'success') + }) + it('should not have cache tags header for non-minimal mode', async () => { for (const path of [ '/ssr-forced', diff --git a/test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js b/test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js new file mode 100644 index 0000000000000..27e190536736c --- /dev/null +++ b/test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js @@ -0,0 +1,25 @@ +import { unstable_cache } from 'next/cache' + +export const config = { + runtime: 'edge', +} + +export default async function handler(req) { + const data = await unstable_cache(async () => { + return { + random: Math.random(), + } + })() + + return new Response( + JSON.stringify({ + now: Date.now(), + data, + }), + { + headers: { + 'content-type': 'application/json', + }, + } + ) +} diff --git a/test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js b/test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js new file mode 100644 index 0000000000000..33fdcfa734dc1 --- /dev/null +++ b/test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js @@ -0,0 +1,14 @@ +import { unstable_cache } from 'next/cache' + +export default async function handler(req, res) { + const data = await unstable_cache(async () => { + return { + random: Math.random(), + } + })() + + res.json({ + now: Date.now(), + data, + }) +} diff --git a/test/e2e/app-dir/app-static/pages/unstable-cache-edge.js b/test/e2e/app-dir/app-static/pages/unstable-cache-edge.js new file mode 100644 index 0000000000000..5574cec8ddeb6 --- /dev/null +++ b/test/e2e/app-dir/app-static/pages/unstable-cache-edge.js @@ -0,0 +1,29 @@ +import { unstable_cache } from 'next/cache' + +export const config = { + runtime: 'experimental-edge', +} + +export async function getServerSideProps() { + const data = await unstable_cache(async () => { + return { + random: Math.random(), + } + })() + + return { + props: { + now: Date.now(), + data, + }, + } +} + +export default function Page(props) { + return ( + <> +

/unstable-cache-edge

+

{JSON.stringify(props)}

+ + ) +} diff --git a/test/e2e/app-dir/app-static/pages/unstable-cache-node.js b/test/e2e/app-dir/app-static/pages/unstable-cache-node.js new file mode 100644 index 0000000000000..09f0f34e23270 --- /dev/null +++ b/test/e2e/app-dir/app-static/pages/unstable-cache-node.js @@ -0,0 +1,25 @@ +import { unstable_cache } from 'next/cache' + +export async function getServerSideProps() { + const data = await unstable_cache(async () => { + return { + random: Math.random(), + } + })() + + return { + props: { + now: Date.now(), + data, + }, + } +} + +export default function Page(props) { + return ( + <> +

/unstable-cache-node

+

{JSON.stringify(props)}

+ + ) +}