From 4bb2117d923ede62b55603176d7f08c2136de7ca Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 28 Jul 2023 17:22:03 -0700 Subject: [PATCH 1/4] Update tag handling for app cache --- packages/next/src/build/utils.ts | 2 +- .../client/components/app-router-headers.ts | 1 - .../static-generation-async-storage.ts | 4 +- packages/next/src/export/worker.ts | 9 +- packages/next/src/lib/constants.ts | 7 ++ .../src/server/app-render/action-handler.ts | 8 +- .../next/src/server/app-render/app-render.tsx | 2 +- ...static-generation-async-storage-wrapper.ts | 8 +- packages/next/src/server/base-server.ts | 17 +-- .../future/route-modules/app-route/module.ts | 3 +- packages/next/src/server/image-optimizer.ts | 6 +- .../lib/incremental-cache/fetch-cache.ts | 87 +++++++------- .../incremental-cache/file-system-cache.ts | 83 +++++-------- .../src/server/lib/incremental-cache/index.ts | 106 ++++++++--------- .../src/server/lib/incremental-cache/utils.ts | 26 ----- packages/next/src/server/lib/patch-fetch.ts | 109 ++++++++++-------- .../next/src/server/response-cache/index.ts | 15 ++- .../next/src/server/response-cache/types.ts | 8 +- packages/next/src/server/web/adapter.ts | 2 - .../web/spec-extension/revalidate-path.ts | 8 +- .../web/spec-extension/unstable-cache.ts | 46 +++----- .../e2e/app-dir/app-static/app-static.test.ts | 5 +- .../required-server-files-app.test.ts | 20 +++- 23 files changed, 290 insertions(+), 292 deletions(-) delete mode 100644 packages/next/src/server/lib/incremental-cache/utils.ts diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 35a3b907a53bc..867429c2ba901 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1241,7 +1241,7 @@ export async function buildAppStaticPaths({ return StaticGenerationAsyncStorageWrapper.wrap( staticGenerationAsyncStorage, { - pathname: page, + urlPathname: page, renderOpts: { originalPathname: page, incrementalCache, diff --git a/packages/next/src/client/components/app-router-headers.ts b/packages/next/src/client/components/app-router-headers.ts index f8d042317f049..c181fd9b9e6e3 100644 --- a/packages/next/src/client/components/app-router-headers.ts +++ b/packages/next/src/client/components/app-router-headers.ts @@ -4,7 +4,6 @@ export const ACTION = 'Next-Action' as const export const NEXT_ROUTER_STATE_TREE = 'Next-Router-State-Tree' as const export const NEXT_ROUTER_PREFETCH = 'Next-Router-Prefetch' as const export const NEXT_URL = 'Next-Url' as const -export const FETCH_CACHE_HEADER = 'x-vercel-sc-headers' as const export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const export const RSC_VARY_HEADER = `${RSC}, ${NEXT_ROUTER_STATE_TREE}, ${NEXT_ROUTER_PREFETCH}, ${NEXT_URL}` as const diff --git a/packages/next/src/client/components/static-generation-async-storage.ts b/packages/next/src/client/components/static-generation-async-storage.ts index 9f481c4b4e0a3..60cedc7a8d7d8 100644 --- a/packages/next/src/client/components/static-generation-async-storage.ts +++ b/packages/next/src/client/components/static-generation-async-storage.ts @@ -5,8 +5,8 @@ import { createAsyncLocalStorage } from './async-local-storage' export interface StaticGenerationStore { readonly isStaticGeneration: boolean - readonly pathname: string - readonly originalPathname?: string + readonly pagePath?: string + readonly urlPathname: string readonly incrementalCache?: IncrementalCache readonly isOnDemandRevalidate?: boolean readonly isPrerendering?: boolean diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 832774878b41b..d7fab6e20a910 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -25,7 +25,10 @@ import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic' import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' -import { SERVER_PROPS_EXPORT_ERROR } from '../lib/constants' +import { + NEXT_CACHE_TAGS_HEADER, + SERVER_PROPS_EXPORT_ERROR, +} from '../lib/constants' import { requireFontManifest } from '../server/require' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { trace } from '../trace' @@ -499,7 +502,7 @@ export default async function exportPage({ .fetchTags if (cacheTags) { - headers['x-next-cache-tags'] = cacheTags + headers[NEXT_CACHE_TAGS_HEADER] = cacheTags } if (!headers['content-type'] && body.type) { @@ -554,7 +557,7 @@ export default async function exportPage({ const cacheTags = (curRenderOpts as any).fetchTags const headers = cacheTags ? { - 'x-next-cache-tags': cacheTags, + [NEXT_CACHE_TAGS_HEADER]: cacheTags, } : undefined diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index 8c12e34ab199b..df8e645f46eb9 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -6,6 +6,13 @@ export const PRERENDER_REVALIDATE_HEADER = 'x-prerender-revalidate' export const PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER = 'x-prerender-revalidate-if-generated' +export const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags' +export const NEXT_CACHE_REVALIDATED_TAGS_HEADER = 'x-next-revalidated-tags' +export const NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER = + 'x-next-revalidate-tag-token' + +export const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_' + // in seconds export const CACHE_ONE_YEAR = 31536000 diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 6f184fab120a4..6b8bc3228a005 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -32,6 +32,10 @@ import { getModifiedCookieValues, } from '../web/spec-extension/adapters/request-cookies' import { RequestStore } from '../../client/components/request-async-storage' +import { + NEXT_CACHE_REVALIDATED_TAGS_HEADER, + NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, +} from '../../lib/constants' function nodeToWebReadableStream(nodeReadable: import('stream').Readable) { if (process.env.NEXT_RUNTIME !== 'edge') { @@ -178,11 +182,11 @@ async function createRedirectRenderResult( if (staticGenerationStore.revalidatedTags) { forwardedHeaders.set( - 'x-next-revalidated-tags', + NEXT_CACHE_REVALIDATED_TAGS_HEADER, staticGenerationStore.revalidatedTags.join(',') ) forwardedHeaders.set( - 'x-next-revalidate-tag-token', + NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, staticGenerationStore.incrementalCache?.prerenderManifest?.preview ?.previewModeId || '' ) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 638a445e2f7b6..0e94f9a48f826 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1786,7 +1786,7 @@ export async function renderToHTMLOrFlight( () => StaticGenerationAsyncStorageWrapper.wrap( staticGenerationAsyncStorage, - { pathname: pagePath, renderOpts }, + { urlPathname: pathname, renderOpts }, () => wrappedRender() ) ) diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts index 663dd6dc10601..a28ac0e8ecb2a 100644 --- a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts @@ -4,7 +4,7 @@ import type { AsyncLocalStorage } from 'async_hooks' import type { IncrementalCache } from '../lib/incremental-cache' export type StaticGenerationContext = { - pathname: string + urlPathname: string renderOpts: { originalPathname?: string incrementalCache?: IncrementalCache @@ -34,7 +34,7 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper< > = { wrap( storage: AsyncLocalStorage, - { pathname, renderOpts }: StaticGenerationContext, + { urlPathname, renderOpts }: StaticGenerationContext, callback: (store: StaticGenerationStore) => Result ): Result { /** @@ -57,8 +57,8 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper< const store: StaticGenerationStore = { isStaticGeneration, - pathname, - originalPathname: renderOpts.originalPathname, + urlPathname, + pagePath: renderOpts.originalPathname, incrementalCache: // we fallback to a global incremental cache for edge-runtime locally // so that it can access the fs cache without mocks diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 2229e95293b94..9fcc795a885a2 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -113,7 +113,10 @@ import { fromNodeOutgoingHttpHeaders, toNodeOutgoingHttpHeaders, } from './web/utils' -import { NEXT_QUERY_PARAM_PREFIX } from '../lib/constants' +import { + NEXT_CACHE_TAGS_HEADER, + NEXT_QUERY_PARAM_PREFIX, +} from '../lib/constants' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { NextRequestAdapter, @@ -1997,7 +2000,7 @@ export default abstract class Server { headers = toNodeOutgoingHttpHeaders(response.headers) if (cacheTags) { - headers['x-next-cache-tags'] = cacheTags + headers[NEXT_CACHE_TAGS_HEADER] = cacheTags } if (!headers['content-type'] && blob.type) { @@ -2116,7 +2119,7 @@ export default abstract class Server { const cacheTags = (renderOpts as any).fetchTags if (cacheTags) { headers = { - 'x-next-cache-tags': cacheTags, + [NEXT_CACHE_TAGS_HEADER]: cacheTags, } } @@ -2407,7 +2410,7 @@ export default abstract class Server { const headers = { ...cachedData.headers } if (!(this.minimalMode && isSSG)) { - delete headers['x-next-cache-tags'] + delete headers[NEXT_CACHE_TAGS_HEADER] } await sendResponse( @@ -2424,11 +2427,11 @@ export default abstract class Server { if ( this.minimalMode && isSSG && - cachedData.headers?.['x-next-cache-tags'] + cachedData.headers?.[NEXT_CACHE_TAGS_HEADER] ) { res.setHeader( - 'x-next-cache-tags', - cachedData.headers['x-next-cache-tags'] as string + NEXT_CACHE_TAGS_HEADER, + cachedData.headers[NEXT_CACHE_TAGS_HEADER] as string ) } if (isDataReq && typeof cachedData.pageData !== 'string') { diff --git a/packages/next/src/server/future/route-modules/app-route/module.ts b/packages/next/src/server/future/route-modules/app-route/module.ts index 1bee78f92179c..bf47738d35fea 100644 --- a/packages/next/src/server/future/route-modules/app-route/module.ts +++ b/packages/next/src/server/future/route-modules/app-route/module.ts @@ -264,12 +264,13 @@ export class AppRouteRouteModule extends RouteModule< // Get the context for the static generation. const staticGenerationContext: StaticGenerationContext = { - pathname: this.definition.pathname, + urlPathname: request.nextUrl.pathname, renderOpts: // If the staticGenerationContext is not provided then we default to // the default values. context.staticGenerationContext ?? { supportsDynamicHTML: false, + originalPathname: this.definition.pathname, }, } diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index a7e9928de4a0e..5c80a1b96a0a9 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -321,7 +321,11 @@ export class ImageOptimizerCache { async set( cacheKey: string, value: IncrementalCacheValue | null, - revalidate?: number | false + { + revalidate, + }: { + revalidate?: number | false + } ) { if (value?.kind !== 'IMAGE') { throw new Error('invariant attempted to set non-image to image-cache') 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 a0948eccf4d22..15cc0a460b956 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -1,12 +1,10 @@ -import LRUCache from 'next/dist/compiled/lru-cache' -import { FETCH_CACHE_HEADER } from '../../../client/components/app-router-headers' -import { CACHE_ONE_YEAR } from '../../../lib/constants' import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' -import { getDerivedTags } from './utils' -let memoryCache: LRUCache | undefined +import LRUCache from 'next/dist/compiled/lru-cache' +import { CACHE_ONE_YEAR, NEXT_CACHE_TAGS_HEADER } from '../../../lib/constants' let rateLimitedUntil = 0 +let memoryCache: LRUCache | undefined interface NextFetchCacheParams { internal?: boolean @@ -15,6 +13,13 @@ interface NextFetchCacheParams { fetchUrl?: string } +const CACHE_HEADERS_HEADER = 'x-vercel-sc-headers' as const +const CACHE_STATE_HEADER = 'x-vercel-cache-state' as const +const CACHE_VERSION_HEADER = 'x-data-cache-version' as const +const CACHE_REVALIDATE_HEADER = 'x-vercel-revalidate' as const +const CACHE_FETCH_URL_HEADER = 'x-vercel-cache-item-name' as const +const CACHE_CONTROL_VALUE_HEADER = 'x-vercel-cache-control' as const + export default class FetchCache implements CacheHandler { private headers: Record private cacheEndpoint?: string @@ -33,16 +38,17 @@ export default class FetchCache implements CacheHandler { this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE this.headers = {} this.revalidatedTags = ctx.revalidatedTags + this.headers[CACHE_VERSION_HEADER] = '2' this.headers['Content-Type'] = 'application/json' - if (FETCH_CACHE_HEADER in ctx._requestHeaders) { + if (CACHE_HEADERS_HEADER in ctx._requestHeaders) { const newHeaders = JSON.parse( - ctx._requestHeaders[FETCH_CACHE_HEADER] as string + ctx._requestHeaders[CACHE_HEADERS_HEADER] as string ) for (const k in newHeaders) { this.headers[k] = newHeaders[k] } - delete ctx._requestHeaders[FETCH_CACHE_HEADER] + delete ctx._requestHeaders[CACHE_HEADERS_HEADER] } const scHost = ctx._requestHeaders['x-vercel-sc-host'] || process.env.SUSPENSE_CACHE_URL @@ -118,7 +124,7 @@ export default class FetchCache implements CacheHandler { { method: 'POST', headers: this.headers, - // @ts-expect-error + // @ts-expect-error not on public type next: { internal: true }, } ) @@ -138,9 +144,17 @@ export default class FetchCache implements CacheHandler { public async get( key: string, - fetchCache?: boolean, - fetchUrl?: string, - fetchIdx?: number + { + fetchCache, + fetchIdx, + fetchUrl, + tags, + }: { + fetchCache?: boolean + fetchUrl?: string + fetchIdx?: number + tags?: string[] + } ) { if (!fetchCache) return null @@ -175,7 +189,8 @@ export default class FetchCache implements CacheHandler { method: 'GET', headers: { ...this.headers, - 'X-Vercel-Cache-Item-Name': fetchUrl, + [NEXT_CACHE_TAGS_HEADER]: tags?.join(','), + [CACHE_FETCH_URL_HEADER]: fetchUrl, } as any, next: fetchParams as NextFetchRequestConfig, } @@ -209,7 +224,7 @@ export default class FetchCache implements CacheHandler { throw new Error(`invalid cache value`) } - const cacheState = res.headers.get('x-vercel-cache-state') + const cacheState = res.headers.get(CACHE_STATE_HEADER) const age = res.headers.get('age') data = { @@ -242,29 +257,21 @@ export default class FetchCache implements CacheHandler { } } - // if a tag was revalidated we don't return stale data - if (data?.value?.kind === 'FETCH') { - const innerData = data.value.data - const derivedTags = getDerivedTags(innerData.tags || []) - - if ( - derivedTags.some((tag) => { - return this.revalidatedTags.includes(tag) - }) - ) { - data = undefined - } - } - return data || null } public async set( key: string, data: CacheHandlerValue['value'], - fetchCache?: boolean, - fetchUrl?: string, - fetchIdx?: number + { + fetchCache, + fetchIdx, + fetchUrl, + }: { + fetchCache?: boolean + fetchUrl?: string + fetchIdx?: number + } ) { if (!fetchCache) return @@ -284,26 +291,20 @@ export default class FetchCache implements CacheHandler { try { const start = Date.now() if (data !== null && 'revalidate' in data) { - this.headers['x-vercel-revalidate'] = data.revalidate.toString() + this.headers[CACHE_REVALIDATE_HEADER] = data.revalidate.toString() } if ( - !this.headers['x-vercel-revalidate'] && + !this.headers[CACHE_REVALIDATE_HEADER] && data !== null && 'data' in data ) { - this.headers['x-vercel-cache-control'] = + this.headers[CACHE_CONTROL_VALUE_HEADER] = data.data.headers['cache-control'] } const body = JSON.stringify(data) - const headers = { ...this.headers } - if (data !== null && 'data' in data && data.data.tags) { - headers['x-vercel-cache-tags'] = data.data.tags.join(',') - } if (this.debug) { - console.log('set cache', key, { - tags: headers['x-vercel-cache-tags'], - }) + console.log('set cache', key) } const fetchParams: NextFetchCacheParams = { internal: true, @@ -316,8 +317,8 @@ export default class FetchCache implements CacheHandler { { method: 'POST', headers: { - ...headers, - 'X-Vercel-Cache-Item-Name': fetchUrl || '', + ...this.headers, + '': fetchUrl || '', }, body: body, next: fetchParams as NextFetchRequestConfig, diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index ab829a20d4110..8e1077f27abe4 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -1,10 +1,11 @@ import type { OutgoingHttpHeaders } from 'http' import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' + import LRUCache from 'next/dist/compiled/lru-cache' import { CacheFs } from '../../../shared/lib/utils' import path from '../../../shared/lib/isomorphic/path' import { CachedFetchValue } from '../../response-cache' -import { getDerivedTags } from './utils' +import { NEXT_CACHE_TAGS_HEADER } from '../../../lib/constants' type FileSystemCacheContext = Omit< CacheHandlerContext, @@ -16,7 +17,7 @@ type FileSystemCacheContext = Omit< type TagsManifest = { version: 1 - items: { [tag: string]: { keys: string[]; revalidatedAt: number } } + items: { [tag: string]: { revalidatedAt: number } } } let memoryCache: LRUCache | undefined let tagsManifest: TagsManifest | undefined @@ -81,31 +82,6 @@ export default class FileSystemCache implements CacheHandler { } } - async setTags(key: string, tags: string[]) { - this.loadTagsManifest() - if (!tagsManifest || !this.tagsManifestPath) { - return - } - - for (const tag of tags) { - const data = tagsManifest.items[tag] || { keys: [] } - if (!data.keys.includes(key)) { - data.keys.push(key) - } - tagsManifest.items[tag] = data - } - - try { - await this.fs.mkdir(path.dirname(this.tagsManifestPath)) - await this.fs.writeFile( - this.tagsManifestPath, - JSON.stringify(tagsManifest || {}) - ) - } catch (err: any) { - console.warn('Failed to update tags manifest.', err) - } - } - public async revalidateTag(tag: string) { // we need to ensure the tagsManifest is refreshed // since separate workers can be updating it at the same @@ -115,7 +91,7 @@ export default class FileSystemCache implements CacheHandler { return } - const data = tagsManifest.items[tag] || { keys: [] } + const data = tagsManifest.items[tag] || {} data.revalidatedAt = Date.now() tagsManifest.items[tag] = data @@ -130,7 +106,16 @@ export default class FileSystemCache implements CacheHandler { } } - public async get(key: string, fetchCache?: boolean) { + public async get( + key: string, + { + fetchCache, + tags, + }: { + fetchCache?: boolean + tags?: string[] + } + ) { let data = memoryCache?.get(key) // let's check the disk for seed data @@ -234,42 +219,39 @@ export default class FileSystemCache implements CacheHandler { // unable to get data from disk } } - let cacheTags: undefined | string[] if (data?.value?.kind === 'PAGE') { - const tagsHeader = data.value.headers?.['x-next-cache-tags'] + let cacheTags: undefined | string[] + const tagsHeader = data.value.headers?.[NEXT_CACHE_TAGS_HEADER] if (typeof tagsHeader === 'string') { cacheTags = tagsHeader.split(',') } - } - if (data?.value?.kind === 'PAGE' && cacheTags?.length) { - this.loadTagsManifest() - const derivedTags = getDerivedTags(cacheTags || []) + if (cacheTags?.length) { + this.loadTagsManifest() - const isStale = derivedTags.some((tag) => { - return ( - tagsManifest?.items[tag]?.revalidatedAt && - tagsManifest?.items[tag].revalidatedAt >= - (data?.lastModified || Date.now()) - ) - }) + const isStale = cacheTags.some((tag) => { + return ( + tagsManifest?.items[tag]?.revalidatedAt && + tagsManifest?.items[tag].revalidatedAt >= + (data?.lastModified || Date.now()) + ) + }) - // we trigger a blocking validation if an ISR page - // had a tag revalidated, if we want to be a background - // revalidation instead we return data.lastModified = -1 - if (isStale) { - data = undefined + // we trigger a blocking validation if an ISR page + // had a tag revalidated, if we want to be a background + // revalidation instead we return data.lastModified = -1 + if (isStale) { + data = undefined + } } } if (data && data?.value?.kind === 'FETCH') { this.loadTagsManifest() - const innerData = data.value.data - const derivedTags = getDerivedTags(innerData.tags || []) - const wasRevalidated = derivedTags.some((tag) => { + const wasRevalidated = tags?.some((tag) => { if (this.revalidatedTags.includes(tag)) { return true } @@ -346,7 +328,6 @@ export default class FileSystemCache implements CacheHandler { }) await this.fs.mkdir(path.dirname(filePath)) await this.fs.writeFile(filePath, JSON.stringify(data)) - await this.setTags(key, data.data.tags || []) } } diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 588bc0162a89d..ca79a74dcd927 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -1,17 +1,20 @@ import type { CacheFs } from '../../../shared/lib/utils' + +import FetchCache from './fetch-cache' import FileSystemCache from './file-system-cache' import { PrerenderManifest } from '../../../build' import path from '../../../shared/lib/isomorphic/path' +import { encodeText } from '../../stream-utils/encode-decode' +import { encode } from '../../../shared/lib/base64-arraybuffer' import { normalizePagePath } from '../../../shared/lib/page-path/normalize-page-path' -import FetchCache from './fetch-cache' import { IncrementalCacheValue, IncrementalCacheEntry, } from '../../response-cache' -import { encode } from '../../../shared/lib/base64-arraybuffer' -import { encodeText } from '../../stream-utils/encode-decode' import { CACHE_ONE_YEAR, + NEXT_CACHE_REVALIDATED_TAGS_HEADER, + NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER, PRERENDER_REVALIDATE_HEADER, } from '../../../lib/constants' @@ -44,20 +47,13 @@ export class CacheHandler { constructor(_ctx: CacheHandlerContext) {} public async get( - _key: string, - _fetchCache?: boolean, - _fetchUrl?: string, - _fetchIdx?: number + ..._args: Parameters ): Promise { return {} as any } public async set( - _key: string, - _data: IncrementalCacheValue | null, - _fetchCache?: boolean, - _fetchUrl?: string, - _fetchIdx?: number + ..._args: Parameters ): Promise {} public async revalidateTag(_tag: string): Promise {} @@ -152,11 +148,12 @@ export class IncrementalCache { if ( minimalMode && - typeof requestHeaders['x-next-revalidated-tags'] === 'string' && - requestHeaders['x-next-revalidate-tag-token'] === + typeof requestHeaders[NEXT_CACHE_REVALIDATED_TAGS_HEADER] === 'string' && + requestHeaders[NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER] === this.prerenderManifest?.preview?.previewModeId ) { - revalidatedTags = requestHeaders['x-next-revalidated-tags'].split(',') + revalidatedTags = + requestHeaders[NEXT_CACHE_REVALIDATED_TAGS_HEADER].split(',') } if (CurCacheHandler) { @@ -393,11 +390,14 @@ export class IncrementalCache { // get data from cache if available async get( - pathname: string, - fetchCache?: boolean, - revalidate?: number | false, - fetchUrl?: string, - fetchIdx?: number + cacheKey: string, + ctx: { + fetchCache?: boolean + revalidate?: number | false + fetchUrl?: string + fetchIdx?: number + tags?: string[] + } = {} ): Promise { if ( process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && @@ -419,22 +419,27 @@ export class IncrementalCache { // so that getStaticProps is always called for easier debugging if ( this.dev && - (!fetchCache || this.requestHeaders['cache-control'] === 'no-cache') + (!ctx.fetchCache || this.requestHeaders['cache-control'] === 'no-cache') ) { return null } - pathname = this._getPathname(pathname, fetchCache) + cacheKey = this._getPathname(cacheKey, ctx.fetchCache) let entry: IncrementalCacheEntry | null = null + let revalidate = ctx.revalidate - const cacheData = await this.cacheHandler?.get( - pathname, - fetchCache, - fetchUrl, - fetchIdx - ) + const cacheData = await this.cacheHandler?.get(cacheKey, ctx) if (cacheData?.value?.kind === 'FETCH') { + // if a tag was revalidated we don't return stale data + if ( + ctx.tags?.some((tag) => { + return this.revalidatedTags?.includes(tag) + }) + ) { + return null + } + revalidate = revalidate || cacheData.value.revalidate const age = Math.round( (Date.now() - (cacheData.lastModified || 0)) / 1000 @@ -455,7 +460,7 @@ export class IncrementalCache { } const curRevalidate = - this.prerenderManifest.routes[toRoute(pathname)]?.initialRevalidateSeconds + this.prerenderManifest.routes[toRoute(cacheKey)]?.initialRevalidateSeconds let isStale: boolean | -1 | undefined let revalidateAfter: false | number @@ -465,9 +470,9 @@ export class IncrementalCache { revalidateAfter = -1 * CACHE_ONE_YEAR } else { revalidateAfter = this.calculateRevalidate( - pathname, + cacheKey, cacheData?.lastModified || Date.now(), - this.dev && !fetchCache + this.dev && !ctx.fetchCache ) isStale = revalidateAfter !== false && revalidateAfter < Date.now() @@ -486,7 +491,7 @@ export class IncrementalCache { if ( !cacheData && - this.prerenderManifest.notFoundRoutes.includes(pathname) + this.prerenderManifest.notFoundRoutes.includes(cacheKey) ) { // for the first hit after starting the server the cache // may not have a way to save notFound: true so if @@ -499,14 +504,7 @@ export class IncrementalCache { curRevalidate, revalidateAfter, } - this.set( - pathname, - entry.value, - curRevalidate, - fetchCache, - fetchUrl, - fetchIdx - ) + this.set(cacheKey, entry.value, ctx) } return entry } @@ -515,10 +513,12 @@ export class IncrementalCache { async set( pathname: string, data: IncrementalCacheValue | null, - revalidateSeconds?: number | false, - fetchCache?: boolean, - fetchUrl?: string, - fetchIdx?: number + ctx: { + revalidate?: number | false + fetchCache?: boolean + fetchUrl?: string + fetchIdx?: number + } ) { if ( process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && @@ -536,38 +536,32 @@ export class IncrementalCache { }) } - if (this.dev && !fetchCache) return + if (this.dev && !ctx.fetchCache) return // fetchCache has upper limit of 2MB per-entry currently - if (fetchCache && JSON.stringify(data).length > 2 * 1024 * 1024) { + if (ctx.fetchCache && JSON.stringify(data).length > 2 * 1024 * 1024) { if (this.dev) { throw new Error(`fetch for over 2MB of data can not be cached`) } return } - pathname = this._getPathname(pathname, fetchCache) + pathname = this._getPathname(pathname, ctx.fetchCache) try { // we use the prerender manifest memory instance // to store revalidate timings for calculating // revalidateAfter values so we update this on set - if (typeof revalidateSeconds !== 'undefined' && !fetchCache) { + if (typeof ctx.revalidate !== 'undefined' && !ctx.fetchCache) { this.prerenderManifest.routes[pathname] = { dataRoute: path.posix.join( '/_next/data', `${normalizePagePath(pathname)}.json` ), srcRoute: null, // FIXME: provide actual source route, however, when dynamically appending it doesn't really matter - initialRevalidateSeconds: revalidateSeconds, + initialRevalidateSeconds: ctx.revalidate, } } - await this.cacheHandler?.set( - pathname, - data, - fetchCache, - fetchUrl, - fetchIdx - ) + await this.cacheHandler?.set(pathname, data, ctx) } catch (error) { console.warn('Failed to update prerender cache for', pathname, error) } diff --git a/packages/next/src/server/lib/incremental-cache/utils.ts b/packages/next/src/server/lib/incremental-cache/utils.ts deleted file mode 100644 index 78e80e3c89658..0000000000000 --- a/packages/next/src/server/lib/incremental-cache/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const getDerivedTags = (tags: string[]): string[] => { - const derivedTags: string[] = ['/'] - - for (const tag of tags || []) { - if (tag.startsWith('/')) { - const pathnameParts = tag.split('/') - - // we automatically add the current path segments as tags - // for revalidatePath handling - for (let i = 1; i < pathnameParts.length + 1; i++) { - const curPathname = pathnameParts.slice(0, i).join('/') - - if (curPathname) { - derivedTags.push(curPathname) - - if (!derivedTags.includes(curPathname)) { - derivedTags.push(curPathname) - } - } - } - } else if (!derivedTags.includes(tag)) { - derivedTags.push(tag) - } - } - return derivedTags -} diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 29cac001b617c..326efa58016af 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -3,26 +3,67 @@ import type * as ServerHooks from '../../client/components/hooks-server-context' import { AppRenderSpan, NextNodeServerSpan } from './trace/constants' import { getTracer, SpanKind } from './trace/tracer' -import { CACHE_ONE_YEAR } from '../../lib/constants' +import { CACHE_ONE_YEAR, NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' +const getDerivedTags = (pathname: string): string[] => { + const derivedTags: string[] = [`/layout`] + + // we automatically add the current path segments as tags + // for revalidatePath handling + if (pathname.startsWith('/')) { + const pathnameParts = pathname.split('/') + + for (let i = 1; i < pathnameParts.length + 1; i++) { + let curPathname = pathnameParts.slice(0, i).join('/') + + if (curPathname) { + // all derived tags other than the page are layout tags + if (!curPathname.endsWith('/page') && !curPathname.endsWith('/route')) { + curPathname = `${curPathname}${ + !curPathname.endsWith('/') ? '/' : '' + }layout` + } + derivedTags.push(curPathname) + } + } + } + return derivedTags +} + export function addImplicitTags( staticGenerationStore: ReturnType ) { const newTags: string[] = [] - const pathname = staticGenerationStore?.originalPathname - if (!pathname) { + if (!staticGenerationStore) { return newTags } + const { pagePath, urlPathname } = staticGenerationStore if (!Array.isArray(staticGenerationStore.tags)) { staticGenerationStore.tags = [] } - if (!staticGenerationStore.tags.includes(pathname)) { - staticGenerationStore.tags.push(pathname) + + if (pagePath) { + const derivedTags = getDerivedTags(pagePath) + + for (let tag of derivedTags) { + tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${tag}` + if (!staticGenerationStore.tags?.includes(tag)) { + staticGenerationStore.tags.push(tag) + } + newTags.push(tag) + } + } + + if (urlPathname) { + const tag = `${NEXT_CACHE_IMPLICIT_TAG_ID}${urlPathname}` + if (!staticGenerationStore.tags?.includes(tag)) { + staticGenerationStore.tags.push(tag) + } + newTags.push(tag) } - newTags.push(pathname) return newTags } @@ -190,7 +231,7 @@ export function patchFetch({ typeof curRevalidate !== 'undefined' ) { console.warn( - `Warning: fetch for ${fetchUrl} on ${staticGenerationStore.pathname} specified "cache: ${_cache}" and "revalidate: ${curRevalidate}", only one should be specified.` + `Warning: fetch for ${fetchUrl} on ${staticGenerationStore.urlPathname} specified "cache: ${_cache}" and "revalidate: ${curRevalidate}", only one should be specified.` ) _cache = undefined } @@ -392,15 +433,16 @@ export function patchFetch({ headers: Object.fromEntries(res.headers.entries()), body: bodyBuffer.toString('base64'), status: res.status, - tags, url: res.url, }, revalidate: normalizedRevalidate, }, - revalidate, - true, - fetchUrl, - fetchIdx + { + fetchCache: true, + revalidate, + fetchUrl, + fetchIdx, + } ) } catch (err) { console.warn(`Failed to set fetch cache`, input, err) @@ -427,13 +469,13 @@ export function patchFetch({ const entry = staticGenerationStore.isOnDemandRevalidate ? null - : await staticGenerationStore.incrementalCache.get( - cacheKey, - true, + : await staticGenerationStore.incrementalCache.get(cacheKey, { + fetchCache: true, revalidate, fetchUrl, - fetchIdx - ) + fetchIdx, + tags, + }) if (entry) { await handleUnlock() @@ -443,7 +485,6 @@ export function patchFetch({ } if (entry?.value && entry.value.kind === 'FETCH') { - const currentTags = entry.value.data.tags // when stale and is revalidating we wait for fresh data // so the revalidated entry has the updated data if (!(staticGenerationStore.isRevalidate && entry.isStale)) { @@ -454,31 +495,7 @@ export function patchFetch({ staticGenerationStore.pendingRevalidates.push( doOriginalFetch(true).catch(console.error) ) - } else if ( - tags && - !tags.every((tag) => currentTags?.includes(tag)) - ) { - // if new tags are being added we need to set even if - // the data isn't stale - if (!entry.value.data.tags) { - entry.value.data.tags = [] - } - - for (const tag of tags) { - if (!entry.value.data.tags.includes(tag)) { - entry.value.data.tags.push(tag) - } - } - staticGenerationStore.incrementalCache?.set( - cacheKey, - entry.value, - revalidate, - true, - fetchUrl, - fetchIdx - ) } - const resData = entry.value.data let decodedBody: ArrayBuffer @@ -521,8 +538,8 @@ export function patchFetch({ if (cache === 'no-store') { staticGenerationStore.revalidate = 0 const dynamicUsageReason = `no-store fetch ${input}${ - staticGenerationStore.pathname - ? ` ${staticGenerationStore.pathname}` + staticGenerationStore.urlPathname + ? ` ${staticGenerationStore.urlPathname}` : '' }` const err = new DynamicServerError(dynamicUsageReason) @@ -549,8 +566,8 @@ export function patchFetch({ const dynamicUsageReason = `revalidate: ${ next.revalidate } fetch ${input}${ - staticGenerationStore.pathname - ? ` ${staticGenerationStore.pathname}` + staticGenerationStore.urlPathname + ? ` ${staticGenerationStore.urlPathname}` : '' }` const err = new DynamicServerError(dynamicUsageReason) diff --git a/packages/next/src/server/response-cache/index.ts b/packages/next/src/server/response-cache/index.ts index 39bb478d8bdcc..6d135da26e939 100644 --- a/packages/next/src/server/response-cache/index.ts +++ b/packages/next/src/server/response-cache/index.ts @@ -156,7 +156,9 @@ export default class ResponseCache { status: cacheEntry.value.status, } : cacheEntry.value, - cacheEntry.revalidate + { + revalidate: cacheEntry.revalidate, + } ) } } else { @@ -170,11 +172,12 @@ export default class ResponseCache { // when a getStaticProps path is erroring we automatically re-set the // existing cache under a new expiration to prevent non-stop retrying if (cachedResponse && key) { - await incrementalCache.set( - key, - cachedResponse.value, - Math.min(Math.max(cachedResponse.revalidate || 3, 3), 30) - ) + await incrementalCache.set(key, cachedResponse.value, { + revalidate: Math.min( + Math.max(cachedResponse.revalidate || 3, 3), + 30 + ), + }) } // while revalidating in the background we can't reject as // we already resolved the cache entry so log the error here diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 23814b11fb5c5..182178c9ba100 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -20,7 +20,6 @@ export interface CachedFetchValue { body: string url: string status?: number - tags?: string[] } revalidate: number } @@ -112,10 +111,13 @@ export type IncrementalCacheItem = { } | null export interface IncrementalCache { - get: (key: string) => Promise + get: ( + key: string, + ctx?: { fetchCache?: boolean } + ) => Promise set: ( key: string, data: IncrementalCacheValue | null, - revalidate?: number | false + ctx: { revalidate: number | false } ) => Promise } diff --git a/packages/next/src/server/web/adapter.ts b/packages/next/src/server/web/adapter.ts index 2b62c89e6acbf..b93956b64acb5 100644 --- a/packages/next/src/server/web/adapter.ts +++ b/packages/next/src/server/web/adapter.ts @@ -11,7 +11,6 @@ import { NextURL } from './next-url' import { stripInternalSearchParams } from '../internal-utils' import { normalizeRscPath } from '../../shared/lib/router/utils/app-paths' import { - FETCH_CACHE_HEADER, NEXT_ROUTER_PREFETCH, NEXT_ROUTER_STATE_TREE, RSC, @@ -51,7 +50,6 @@ const FLIGHT_PARAMETERS = [ [RSC], [NEXT_ROUTER_STATE_TREE], [NEXT_ROUTER_PREFETCH], - [FETCH_CACHE_HEADER], ] as const export type AdapterOptions = { diff --git a/packages/next/src/server/web/spec-extension/revalidate-path.ts b/packages/next/src/server/web/spec-extension/revalidate-path.ts index 3ab54f7b1f2e5..8629a14b4994a 100644 --- a/packages/next/src/server/web/spec-extension/revalidate-path.ts +++ b/packages/next/src/server/web/spec-extension/revalidate-path.ts @@ -1,5 +1,11 @@ import { revalidateTag } from './revalidate-tag' +import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../../lib/constants' -export function revalidatePath(path: string) { +export function revalidatePath(path: string, type?: 'layout' | 'page') { + path = `${NEXT_CACHE_IMPLICIT_TAG_ID}${path}` + + if (type) { + path += `${path.endsWith('/') ? '' : '/'}${type}` + } return revalidateTag(path) } 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 c424ddb67c568..127bfd448326c 100644 --- a/packages/next/src/server/web/spec-extension/unstable-cache.ts +++ b/packages/next/src/server/web/spec-extension/unstable-cache.ts @@ -51,18 +51,10 @@ export function unstable_cache( { ...store, fetchCache: 'only-no-store', + urlPathname: store?.urlPathname || '/', isStaticGeneration: !!store?.isStaticGeneration, - pathname: store?.pathname || '/', }, async () => { - const cacheKey = await incrementalCache?.fetchCacheKey(joinedKey) - const cacheEntry = - cacheKey && - !( - store?.isOnDemandRevalidate || incrementalCache.isOnDemandRevalidate - ) && - (await incrementalCache?.get(cacheKey, true, options.revalidate)) - const tags = options.tags || [] if (Array.isArray(tags) && store) { @@ -83,6 +75,18 @@ export function unstable_cache( } } + const cacheKey = await incrementalCache?.fetchCacheKey(joinedKey) + const cacheEntry = + cacheKey && + !( + store?.isOnDemandRevalidate || incrementalCache.isOnDemandRevalidate + ) && + (await incrementalCache?.get(cacheKey, { + fetchCache: true, + revalidate: options.revalidate, + tags, + })) + const invokeCallback = async () => { const result = await cb(...args) @@ -96,7 +100,6 @@ export function unstable_cache( // TODO: handle non-JSON values? body: JSON.stringify(result), status: 200, - tags, url: '', }, revalidate: @@ -104,8 +107,10 @@ export function unstable_cache( ? CACHE_ONE_YEAR : options.revalidate, }, - options.revalidate, - true + { + revalidate: options.revalidate, + fetchCache: true, + } ) } return result @@ -128,7 +133,6 @@ export function unstable_cache( const resData = cacheEntry.value.data cachedValue = JSON.parse(resData.body) } - const currentTags = cacheEntry.value.data.tags if (isStale) { if (!store) { @@ -143,22 +147,6 @@ export function unstable_cache( ) ) } - } else if (tags && !tags.every((tag) => currentTags?.includes(tag))) { - if (!cacheEntry.value.data.tags) { - cacheEntry.value.data.tags = [] - } - - for (const tag of tags) { - if (!cacheEntry.value.data.tags.includes(tag)) { - cacheEntry.value.data.tags.push(tag) - } - } - incrementalCache?.set( - cacheKey, - cacheEntry.value, - options.revalidate, - true - ) } return cachedValue } 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 83a5b21316f34..24fce3f306179 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -895,7 +895,7 @@ createNextDescribe( initialHeaders: { 'content-type': 'application/json', 'x-next-cache-tags': - 'thankyounext,/route-handler/revalidate-360-isr/route', + 'thankyounext,_N_T_/layout,_N_T_/route-handler/layout,_N_T_/route-handler/revalidate-360-isr/layout,_N_T_/route-handler/revalidate-360-isr/route,_N_T_/route-handler/revalidate-360-isr', }, initialRevalidateSeconds: 10, srcRoute: '/route-handler/revalidate-360-isr', @@ -904,7 +904,8 @@ createNextDescribe( dataRoute: null, initialHeaders: { 'set-cookie': 'theme=light; Path=/,my_company=ACME; Path=/', - 'x-next-cache-tags': '/route-handler/static-cookies/route', + 'x-next-cache-tags': + '_N_T_/layout,_N_T_/route-handler/layout,_N_T_/route-handler/static-cookies/layout,_N_T_/route-handler/static-cookies/route,_N_T_/route-handler/static-cookies', }, initialRevalidateSeconds: false, srcRoute: '/route-handler/static-cookies', diff --git a/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts index a941dfea3c884..5e95f0945c04d 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts @@ -156,10 +156,22 @@ describe('should set-up next', () => { it('should send cache tags in minimal mode for ISR', async () => { for (const [path, tags] of [ - ['/isr/first', 'isr-page,/isr/[slug]/page'], - ['/isr/second', 'isr-page,/isr/[slug]/page'], - ['/api/isr/first', 'isr-page,/api/isr/[slug]/route'], - ['/api/isr/second', 'isr-page,/api/isr/[slug]/route'], + [ + '/isr/first', + 'isr-page,_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/first', + ], + [ + '/isr/second', + 'isr-page,_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/second', + ], + [ + '/api/isr/first', + 'isr-page,_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/first', + ], + [ + '/api/isr/second', + 'isr-page,_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/second', + ], ]) { require('console').error('checking', { path, tags }) const res = await fetchViaHTTP(appPort, path, undefined, { From 65fa94c1993394541c2eb62949672893700f189e Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 28 Jul 2023 17:27:05 -0700 Subject: [PATCH 2/4] fix type --- .../next/src/server/lib/incremental-cache/file-system-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 8e1077f27abe4..aa6d0435c0957 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -114,7 +114,7 @@ export default class FileSystemCache implements CacheHandler { }: { fetchCache?: boolean tags?: string[] - } + } = {} ) { let data = memoryCache?.get(key) From e4ad99fbafedb5c1735f2372b276c5e5dabf1204 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 23 Aug 2023 13:16:15 -0700 Subject: [PATCH 3/4] tweak tags handling a bit more --- packages/next/src/lib/constants.ts | 1 + .../lib/incremental-cache/fetch-cache.ts | 40 ++++++++++++------- .../incremental-cache/file-system-cache.ts | 37 ++++++++++++++--- .../src/server/lib/incremental-cache/index.ts | 5 ++- packages/next/src/server/lib/patch-fetch.ts | 7 +--- .../next/src/server/response-cache/types.ts | 3 ++ .../web/spec-extension/unstable-cache.ts | 35 ++++++++-------- test/e2e/app-dir/actions/app-action.test.ts | 22 +++++----- .../file-system-cache.test.ts | 18 +++++---- 9 files changed, 106 insertions(+), 62 deletions(-) diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index df8e645f46eb9..217fd710cb554 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -7,6 +7,7 @@ export const PRERENDER_REVALIDATE_ONLY_GENERATED_HEADER = 'x-prerender-revalidate-if-generated' export const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags' +export const NEXT_CACHE_SOFT_TAGS_HEADER = 'x-next-cache-soft-tags' export const NEXT_CACHE_REVALIDATED_TAGS_HEADER = 'x-next-revalidated-tags' export const NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER = 'x-next-revalidate-tag-token' 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 15cc0a460b956..7c38eb6c19bf1 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -1,7 +1,10 @@ import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' import LRUCache from 'next/dist/compiled/lru-cache' -import { CACHE_ONE_YEAR, NEXT_CACHE_TAGS_HEADER } from '../../../lib/constants' +import { + CACHE_ONE_YEAR, + NEXT_CACHE_SOFT_TAGS_HEADER, +} from '../../../lib/constants' let rateLimitedUntil = 0 let memoryCache: LRUCache | undefined @@ -13,6 +16,7 @@ interface NextFetchCacheParams { fetchUrl?: string } +const CACHE_TAGS_HEADER = 'x-vercel-cache-tags' as const const CACHE_HEADERS_HEADER = 'x-vercel-sc-headers' as const const CACHE_STATE_HEADER = 'x-vercel-cache-state' as const const CACHE_VERSION_HEADER = 'x-data-cache-version' as const @@ -24,7 +28,6 @@ export default class FetchCache implements CacheHandler { private headers: Record private cacheEndpoint?: string private debug: boolean - private revalidatedTags: string[] static isAvailable(ctx: { _requestHeaders: CacheHandlerContext['_requestHeaders'] @@ -37,7 +40,6 @@ export default class FetchCache implements CacheHandler { constructor(ctx: CacheHandlerContext) { this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE this.headers = {} - this.revalidatedTags = ctx.revalidatedTags this.headers[CACHE_VERSION_HEADER] = '2' this.headers['Content-Type'] = 'application/json' @@ -144,18 +146,16 @@ export default class FetchCache implements CacheHandler { public async get( key: string, - { - fetchCache, - fetchIdx, - fetchUrl, - tags, - }: { + ctx: { + tags?: string[] + softTags?: string[] fetchCache?: boolean fetchUrl?: string fetchIdx?: number - tags?: string[] } ) { + const { tags, softTags, fetchCache, fetchIdx, fetchUrl } = ctx + if (!fetchCache) return null if (Date.now() < rateLimitedUntil) { @@ -189,8 +189,9 @@ export default class FetchCache implements CacheHandler { method: 'GET', headers: { ...this.headers, - [NEXT_CACHE_TAGS_HEADER]: tags?.join(','), [CACHE_FETCH_URL_HEADER]: fetchUrl, + [CACHE_TAGS_HEADER]: tags?.join(',') || '', + [NEXT_CACHE_SOFT_TAGS_HEADER]: softTags?.join(',') || '', } as any, next: fetchParams as NextFetchRequestConfig, } @@ -236,13 +237,16 @@ export default class FetchCache implements CacheHandler { ? Date.now() - CACHE_ONE_YEAR : Date.now() - parseInt(age || '0', 10) * 1000, } + if (this.debug) { console.log( `got fetch cache entry for ${key}, duration: ${ Date.now() - start }ms, size: ${ Object.keys(cached).length - }, cache-state: ${cacheState}` + }, cache-state: ${cacheState} tags: ${tags?.join( + ',' + )} softTags: ${softTags?.join(',')}` ) } @@ -267,7 +271,9 @@ export default class FetchCache implements CacheHandler { fetchCache, fetchIdx, fetchUrl, + tags, }: { + tags?: string[] fetchCache?: boolean fetchUrl?: string fetchIdx?: number @@ -301,7 +307,12 @@ export default class FetchCache implements CacheHandler { this.headers[CACHE_CONTROL_VALUE_HEADER] = data.data.headers['cache-control'] } - const body = JSON.stringify(data) + const body = JSON.stringify({ + ...data, + // we send the tags in the header instead + // of in the body here + tags: undefined, + }) if (this.debug) { console.log('set cache', key) @@ -318,7 +329,8 @@ export default class FetchCache implements CacheHandler { method: 'POST', headers: { ...this.headers, - '': fetchUrl || '', + [CACHE_FETCH_URL_HEADER]: fetchUrl || '', + [CACHE_TAGS_HEADER]: tags?.join(',') || '', }, body: body, next: fetchParams as NextFetchRequestConfig, diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index aa6d0435c0957..79e18ad3b996b 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -109,11 +109,13 @@ export default class FileSystemCache implements CacheHandler { public async get( key: string, { - fetchCache, tags, + softTags, + fetchCache, }: { - fetchCache?: boolean tags?: string[] + softTags?: string[] + fetchCache?: boolean } = {} ) { let data = memoryCache?.get(key) @@ -163,6 +165,17 @@ export default class FileSystemCache implements CacheHandler { lastModified, value: parsedData, } + + if (data.value?.kind === 'FETCH') { + const storedTags = data.value?.data?.tags + + // update stored tags if a new one is being added + // TODO: remove this when we can send the tags + // via header on GET same as SET + if (!tags?.every((tag) => storedTags?.includes(tag))) { + await this.set(key, data.value, { tags }) + } + } } else { const pageData = isAppPath ? ( @@ -251,7 +264,9 @@ export default class FileSystemCache implements CacheHandler { if (data && data?.value?.kind === 'FETCH') { this.loadTagsManifest() - const wasRevalidated = tags?.some((tag) => { + const combinedTags = [...(tags || []), ...(softTags || [])] + + const wasRevalidated = combinedTags.some((tag) => { if (this.revalidatedTags.includes(tag)) { return true } @@ -272,7 +287,13 @@ export default class FileSystemCache implements CacheHandler { return data || null } - public async set(key: string, data: CacheHandlerValue['value']) { + public async set( + key: string, + data: CacheHandlerValue['value'], + ctx: { + tags?: string[] + } + ) { memoryCache?.set(key, { value: data, lastModified: Date.now(), @@ -327,7 +348,13 @@ export default class FileSystemCache implements CacheHandler { fetchCache: true, }) await this.fs.mkdir(path.dirname(filePath)) - await this.fs.writeFile(filePath, JSON.stringify(data)) + await this.fs.writeFile( + filePath, + JSON.stringify({ + ...data, + tags: ctx.tags, + }) + ) } } diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index ca79a74dcd927..ae2b11b4dc00f 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -397,6 +397,7 @@ export class IncrementalCache { fetchUrl?: string fetchIdx?: number tags?: string[] + softTags?: string[] } = {} ): Promise { if ( @@ -431,9 +432,10 @@ export class IncrementalCache { const cacheData = await this.cacheHandler?.get(cacheKey, ctx) if (cacheData?.value?.kind === 'FETCH') { + const combinedTags = [...(ctx.tags || []), ...(ctx.softTags || [])] // if a tag was revalidated we don't return stale data if ( - ctx.tags?.some((tag) => { + combinedTags.some((tag) => { return this.revalidatedTags?.includes(tag) }) ) { @@ -518,6 +520,7 @@ export class IncrementalCache { fetchCache?: boolean fetchUrl?: string fetchIdx?: number + tags?: string[] } ) { if ( diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 326efa58016af..25151cf067db7 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -207,11 +207,6 @@ export function patchFetch({ } const implicitTags = addImplicitTags(staticGenerationStore) - for (const tag of implicitTags || []) { - if (!tags.includes(tag)) { - tags.push(tag) - } - } const isOnlyCache = staticGenerationStore.fetchCache === 'only-cache' const isForceCache = staticGenerationStore.fetchCache === 'force-cache' const isDefaultCache = @@ -442,6 +437,7 @@ export function patchFetch({ revalidate, fetchUrl, fetchIdx, + tags, } ) } catch (err) { @@ -475,6 +471,7 @@ export function patchFetch({ fetchUrl, fetchIdx, tags, + softTags: implicitTags, }) if (entry) { diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 182178c9ba100..93015b6282505 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -20,6 +20,9 @@ export interface CachedFetchValue { body: string url: string status?: number + // tags are only present with file-system-cache + // fetch cache stores tags outside of cache entry + tags?: string[] } revalidate: number } 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 127bfd448326c..aad3ed2baf20a 100644 --- a/packages/next/src/server/web/spec-extension/unstable-cache.ts +++ b/packages/next/src/server/web/spec-extension/unstable-cache.ts @@ -19,19 +19,6 @@ export function unstable_cache( const staticGenerationAsyncStorage: StaticGenerationAsyncStorage = (fetch as any).__nextGetStaticStore?.() || _staticGenerationAsyncStorage - const store: undefined | StaticGenerationStore = - staticGenerationAsyncStorage?.getStore() - - const incrementalCache: - | import('../../lib/incremental-cache').IncrementalCache - | undefined = - store?.incrementalCache || (globalThis as any).__incrementalCache - - if (!incrementalCache) { - throw new Error( - `Invariant: incrementalCache missing in unstable_cache ${cb.toString()}` - ) - } if (options.revalidate === 0) { throw new Error( `Invariant revalidate: 0 can not be passed to unstable_cache(), must be "false" or "> 0" ${cb.toString()}` @@ -39,6 +26,20 @@ export function unstable_cache( } const cachedCb = async (...args: any[]) => { + const store: undefined | StaticGenerationStore = + staticGenerationAsyncStorage?.getStore() + + const incrementalCache: + | import('../../lib/incremental-cache').IncrementalCache + | undefined = + store?.incrementalCache || (globalThis as any).__incrementalCache + + if (!incrementalCache) { + throw new Error( + `Invariant: incrementalCache missing in unstable_cache ${cb.toString()}` + ) + } + const joinedKey = `${cb.toString()}-${ Array.isArray(keyParts) && keyParts.join(',') }-${JSON.stringify(args)}` @@ -69,12 +70,6 @@ export function unstable_cache( } const implicitTags = addImplicitTags(store) - for (const tag of implicitTags) { - if (!tags.includes(tag)) { - tags.push(tag) - } - } - const cacheKey = await incrementalCache?.fetchCacheKey(joinedKey) const cacheEntry = cacheKey && @@ -85,6 +80,7 @@ export function unstable_cache( fetchCache: true, revalidate: options.revalidate, tags, + softTags: implicitTags, })) const invokeCallback = async () => { @@ -110,6 +106,7 @@ export function unstable_cache( { revalidate: options.revalidate, fetchCache: true, + tags, } ) } diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index cf075fb6cfe0c..e8ee897e9f129 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -730,20 +730,20 @@ createNextDescribe( await browser.elementByCss('#back').click() - switch (type) { - case 'tag': - await browser.elementByCss('#revalidate-thankyounext').click() - break - case 'path': - await browser.elementByCss('#revalidate-path').click() - break - default: - throw new Error(`Invalid type: ${type}`) - } - // Should be different let revalidatedThankYouNext await check(async () => { + switch (type) { + case 'tag': + await browser.elementByCss('#revalidate-thankyounext').click() + break + case 'path': + await browser.elementByCss('#revalidate-path').click() + break + default: + throw new Error(`Invalid type: ${type}`) + } + revalidatedThankYouNext = await browser .elementByCss('#thankyounext') .text() diff --git a/test/unit/incremental-cache/file-system-cache.test.ts b/test/unit/incremental-cache/file-system-cache.test.ts index 38ff3a9433483..caa6d44cc6fc9 100644 --- a/test/unit/incremental-cache/file-system-cache.test.ts +++ b/test/unit/incremental-cache/file-system-cache.test.ts @@ -20,14 +20,18 @@ describe('FileSystemCache', () => { fileURLToPath(new URL('./images/icon.png', import.meta.url)) ) - await fsCache.set('icon.png', { - body: binary, - headers: { - 'Content-Type': 'image/png', + await fsCache.set( + 'icon.png', + { + body: binary, + headers: { + 'Content-Type': 'image/png', + }, + status: 200, + kind: 'ROUTE', }, - status: 200, - kind: 'ROUTE', - }) + {} + ) expect((await fsCache.get('icon.png')).value).toEqual({ body: binary, From 017623f03ebcac817a6f8892067a96d32222b2ad Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 31 Aug 2023 12:37:11 -0700 Subject: [PATCH 4/4] remove extra header --- packages/next/src/server/lib/incremental-cache/fetch-cache.ts | 2 -- 1 file changed, 2 deletions(-) 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 7c38eb6c19bf1..896795ec08737 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -19,7 +19,6 @@ interface NextFetchCacheParams { const CACHE_TAGS_HEADER = 'x-vercel-cache-tags' as const const CACHE_HEADERS_HEADER = 'x-vercel-sc-headers' as const const CACHE_STATE_HEADER = 'x-vercel-cache-state' as const -const CACHE_VERSION_HEADER = 'x-data-cache-version' as const const CACHE_REVALIDATE_HEADER = 'x-vercel-revalidate' as const const CACHE_FETCH_URL_HEADER = 'x-vercel-cache-item-name' as const const CACHE_CONTROL_VALUE_HEADER = 'x-vercel-cache-control' as const @@ -40,7 +39,6 @@ export default class FetchCache implements CacheHandler { constructor(ctx: CacheHandlerContext) { this.debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE this.headers = {} - this.headers[CACHE_VERSION_HEADER] = '2' this.headers['Content-Type'] = 'application/json' if (CACHE_HEADERS_HEADER in ctx._requestHeaders) {