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)
This commit is contained in:
parent
2f3a503d8b
commit
ce746ef5c2
13 changed files with 240 additions and 62 deletions
|
@ -1051,7 +1051,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
|
|||
// 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<ServerOptions extends Options = Options> {
|
|||
| 'https',
|
||||
})
|
||||
addRequestMeta(req, '_nextIncrementalCache', incrementalCache)
|
||||
;(globalThis as any).__incrementalCache = incrementalCache
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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<IncrementalCacheEntry | null> {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<any>
|
||||
|
||||
export function unstable_cache<T extends Callback>(
|
||||
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<T extends Callback>(
|
|||
// 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<T extends Callback>(
|
|||
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<T extends Callback>(
|
|||
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<T extends Callback>(
|
|||
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<T extends Callback>(
|
|||
cacheEntry.value.data.tags.push(tag)
|
||||
}
|
||||
}
|
||||
store.incrementalCache?.set(
|
||||
incrementalCache?.set(
|
||||
cacheKey,
|
||||
cacheEntry.value,
|
||||
options.revalidate,
|
||||
|
|
|
@ -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',
|
||||
|
|
25
test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js
Normal file
25
test/e2e/app-dir/app-static/pages/api/unstable-cache-edge.js
Normal file
|
@ -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',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
14
test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js
Normal file
14
test/e2e/app-dir/app-static/pages/api/unstable-cache-node.js
Normal file
|
@ -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,
|
||||
})
|
||||
}
|
29
test/e2e/app-dir/app-static/pages/unstable-cache-edge.js
Normal file
29
test/e2e/app-dir/app-static/pages/unstable-cache-edge.js
Normal file
|
@ -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 (
|
||||
<>
|
||||
<p>/unstable-cache-edge</p>
|
||||
<p id="props">{JSON.stringify(props)}</p>
|
||||
</>
|
||||
)
|
||||
}
|
25
test/e2e/app-dir/app-static/pages/unstable-cache-node.js
Normal file
25
test/e2e/app-dir/app-static/pages/unstable-cache-node.js
Normal file
|
@ -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 (
|
||||
<>
|
||||
<p>/unstable-cache-node</p>
|
||||
<p id="props">{JSON.stringify(props)}</p>
|
||||
</>
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue