diff --git a/packages/next/errors.json b/packages/next/errors.json index db893689ed163..9bc6f65a62ff3 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -652,5 +652,10 @@ "651": "Unexpected module type %s", "652": "Expected cached value for cache key %s not to be a %s kind, got \"FETCH\" instead.", "653": "Expected cached value for cache key %s to be a \"FETCH\" kind, got %s instead.", - "654": "Segment Cache experiment is not enabled. This is a bug in Next.js." + "654": "Segment Cache experiment is not enabled. This is a bug in Next.js.", + "655": "If providing both the stale and expire options, the expire option must be greater than the stale option. The expire option indicates how many seconds from the start until it can no longer be used.", + "656": "If providing both the revalidate and expire options, the expire option must be greater than the revalidate option. The expire option indicates how many seconds from the start until it can no longer be used.", + "657": "revalidate must be a number for image-cache", + "658": "Pass `Infinity` instead of `false` if you want to cache on the server forever without checking with the origin.", + "659": "SSG should not return an image cache value" } diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 6a149844a0601..5e218507825f3 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -3,7 +3,7 @@ import type { PagesManifest } from './webpack/plugins/pages-manifest-plugin' import type { ExportPathMap, NextConfigComplete } from '../server/config-shared' import type { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import type { ActionManifest } from './webpack/plugins/flight-client-entry-plugin' -import type { Revalidate } from '../server/lib/revalidate' +import type { CacheControl, Revalidate } from '../server/lib/cache-control' import '../lib/setup-exception-listeners' @@ -209,7 +209,7 @@ import { turbopackBuild } from './turbopack-build' type Fallback = null | boolean | string -export interface SsgRoute { +export interface PrerenderManifestRoute { dataRoute: string | null experimentalBypassFor?: RouteHas[] @@ -224,10 +224,20 @@ export interface SsgRoute { initialStatus?: number /** - * The revalidation configuration for this route. + * The revalidate value for this route. This might be inferred from: + * - route segment configs + * - fetch calls + * - unstable_cache + * - "use cache" */ initialRevalidateSeconds: Revalidate + /** + * The expire value for this route, which is inferred from the "use cache" + * functions that are used by the route, or the expireTime config. + */ + initialExpireSeconds: number | undefined + /** * The prefetch data route associated with this page. If not defined, this * page does not support prefetching. @@ -258,7 +268,7 @@ export interface SsgRoute { allowHeader: string[] } -export interface DynamicSsgRoute { +export interface DynamicPrerenderManifestRoute { dataRoute: string | null dataRouteRegex: string | null experimentalBypassFor?: RouteHas[] @@ -270,6 +280,11 @@ export interface DynamicSsgRoute { */ fallbackRevalidate: Revalidate | undefined + /** + * When defined, it describes the expire configuration for the fallback route. + */ + fallbackExpire: number | undefined + /** * The headers that should used when serving the fallback. */ @@ -328,8 +343,8 @@ const ALLOWED_HEADERS: string[] = [ export type PrerenderManifest = { version: 4 - routes: { [route: string]: SsgRoute } - dynamicRoutes: { [route: string]: DynamicSsgRoute } + routes: { [route: string]: PrerenderManifestRoute } + dynamicRoutes: { [route: string]: DynamicPrerenderManifestRoute } notFoundRoutes: string[] preview: __ApiPreviewProps } @@ -2128,7 +2143,7 @@ export default async function build( isRoutePPREnabled, isHybridAmp, ssgPageRoutes, - initialRevalidateSeconds: false, + initialCacheControl: undefined, runtime: pageRuntime, pageDuration: undefined, ssgPageDurations: undefined, @@ -2708,6 +2723,31 @@ export default async function build( // If there was no result, there's nothing more to do. if (!exportResult) return + const getCacheControl = ( + exportPath: string, + defaultRevalidate: Revalidate = false + ): CacheControl => { + const cacheControl = + exportResult.byPath.get(exportPath)?.cacheControl + + if (!cacheControl) { + return { revalidate: defaultRevalidate, expire: undefined } + } + + if ( + cacheControl.revalidate !== false && + cacheControl.revalidate > 0 && + cacheControl.expire === undefined + ) { + return { + revalidate: cacheControl.revalidate, + expire: config.expireTime, + } + } + + return cacheControl + } + if (debugOutput || process.env.NEXT_SSG_FETCH_METRICS === '1') { recordFetchMetrics(exportResult) } @@ -2739,7 +2779,7 @@ export default async function build( let hasRevalidateZero = appConfig.revalidate === 0 || - exportResult.byPath.get(page)?.revalidate === 0 + getCacheControl(page).revalidate === 0 if (hasRevalidateZero && pageInfos.get(page)?.isStatic) { // if the page was marked as being static, but it contains dynamic data @@ -2855,16 +2895,25 @@ export default async function build( if (route.pathname === UNDERSCORE_NOT_FOUND_ROUTE) continue const { - revalidate = appConfig.revalidate ?? false, metadata = {}, hasEmptyPrelude, hasPostponed, } = exportResult.byPath.get(route.pathname) ?? {} + const cacheControl = getCacheControl( + route.pathname, + appConfig.revalidate + ) + pageInfos.set(route.pathname, { ...(pageInfos.get(route.pathname) as PageInfo), hasPostponed, hasEmptyPrelude, + // TODO: Enable the following line to show "ISR" status in build + // output. Requires different presentation to also work for app + // router routes. + // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH + // initialCacheControl: cacheControl, }) // update the page (eg /blog/[slug]) to also have the postpone metadata @@ -2872,9 +2921,14 @@ export default async function build( ...(pageInfos.get(page) as PageInfo), hasPostponed, hasEmptyPrelude, + // TODO: Enable the following line to show "ISR" status in build + // output. Requires different presentation to also work for app + // router routes. + // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH + // initialCacheControl: cacheControl, }) - if (revalidate !== 0) { + if (cacheControl.revalidate !== 0) { const normalizedRoute = normalizePagePath(route.pathname) let dataRoute: string | null @@ -2907,7 +2961,8 @@ export default async function build( : undefined, experimentalPPR: isRoutePPREnabled, experimentalBypassFor: bypassFor, - initialRevalidateSeconds: revalidate, + initialRevalidateSeconds: cacheControl.revalidate, + initialExpireSeconds: cacheControl.expire, srcRoute: page, dataRoute, prefetchDataRoute, @@ -2944,8 +2999,11 @@ export default async function build( for (const route of dynamicRoutes) { const normalizedRoute = normalizePagePath(route.pathname) - const { metadata, revalidate } = - exportResult.byPath.get(route.pathname) ?? {} + const metadata = exportResult.byPath.get( + route.pathname + )?.metadata + + const cacheControl = getCacheControl(route.pathname) let dataRoute: string | null = null if (!isAppRouteHandler) { @@ -2988,12 +3046,13 @@ export default async function build( const fallbackMode = route.fallbackMode ?? FallbackMode.NOT_FOUND - // When we're configured to serve a prerender, we should use the - // fallback revalidate from the export result. If it can't be - // found, mark that we should keep the shell forever (`false`). - let fallbackRevalidate: Revalidate | undefined = + // When the route is configured to serve a prerender, we should + // use the cache control from the export result. If it can't be + // found, mark that we should keep the shell forever + // (revalidate: `false` via `getCacheControl()`). + const fallbackCacheControl = isRoutePPREnabled && fallbackMode === FallbackMode.PRERENDER - ? revalidate ?? false + ? cacheControl : undefined const fallback: Fallback = fallbackModeToFallbackField( @@ -3023,7 +3082,8 @@ export default async function build( ), dataRoute, fallback, - fallbackRevalidate, + fallbackRevalidate: fallbackCacheControl?.revalidate, + fallbackExpire: fallbackCacheControl?.expire, fallbackStatus: meta.status, fallbackHeaders: meta.headers, fallbackRootParams: route.fallbackRootParams, @@ -3268,10 +3328,11 @@ export default async function build( for (const locale of i18n.locales) { const localePage = `/${locale}${page === '/' ? '' : page}` + const cacheControl = getCacheControl(localePage) + prerenderManifest.routes[localePage] = { - initialRevalidateSeconds: - exportResult.byPath.get(localePage)?.revalidate ?? - false, + initialRevalidateSeconds: cacheControl.revalidate, + initialExpireSeconds: cacheControl.expire, experimentalPPR: undefined, renderingMode: undefined, srcRoute: null, @@ -3285,9 +3346,11 @@ export default async function build( } } } else { + const cacheControl = getCacheControl(page) + prerenderManifest.routes[page] = { - initialRevalidateSeconds: - exportResult.byPath.get(page)?.revalidate ?? false, + initialRevalidateSeconds: cacheControl.revalidate, + initialExpireSeconds: cacheControl.expire, experimentalPPR: undefined, renderingMode: undefined, srcRoute: null, @@ -3301,10 +3364,8 @@ export default async function build( allowHeader: ALLOWED_HEADERS, } } - // Set Page Revalidation Interval if (pageInfo) { - pageInfo.initialRevalidateSeconds = - exportResult.byPath.get(page)?.revalidate ?? false + pageInfo.initialCacheControl = getCacheControl(page) } } else { // For a dynamic SSG page, we did not copy its data exports and only @@ -3350,15 +3411,11 @@ export default async function build( ) } - const initialRevalidateSeconds = - exportResult.byPath.get(route.pathname)?.revalidate ?? false - - if (typeof initialRevalidateSeconds === 'undefined') { - throw new Error("Invariant: page wasn't built") - } + const cacheControl = getCacheControl(route.pathname) prerenderManifest.routes[route.pathname] = { - initialRevalidateSeconds, + initialRevalidateSeconds: cacheControl.revalidate, + initialExpireSeconds: cacheControl.expire, experimentalPPR: undefined, renderingMode: undefined, srcRoute: page, @@ -3372,9 +3429,8 @@ export default async function build( allowHeader: ALLOWED_HEADERS, } - // Set route Revalidation Interval if (pageInfo) { - pageInfo.initialRevalidateSeconds = initialRevalidateSeconds + pageInfo.initialCacheControl = cacheControl } } } @@ -3476,6 +3532,7 @@ export default async function build( ? `${normalizedRoute}.html` : false, fallbackRevalidate: undefined, + fallbackExpire: undefined, fallbackSourceRoute: undefined, fallbackRootParams: undefined, dataRouteRegex: normalizeRouteRegex( diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index ea0d3a39e77ba..36e6c25e8df85 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -81,6 +81,7 @@ import { collectRootParamKeys } from './segment-config/app/collect-root-param-ke import { buildAppStaticPaths } from './static-paths/app' import { buildPagesStaticPaths } from './static-paths/pages' import type { PrerenderedRoute } from './static-paths/types' +import type { CacheControl } from '../server/lib/cache-control' export type ROUTER_TYPE = 'pages' | 'app' @@ -346,7 +347,8 @@ export interface PageInfo { */ isRoutePPREnabled: boolean ssgPageRoutes: string[] | null - initialRevalidateSeconds: number | false + // TODO: initialCacheControl should be set per prerendered route. + initialCacheControl: CacheControl | undefined pageDuration: number | undefined ssgPageDurations: number[] | undefined runtime: ServerRuntime @@ -520,12 +522,14 @@ export async function printTreeView( usedSymbols.add(symbol) - if (pageInfo?.initialRevalidateSeconds) usedSymbols.add('ISR') + // TODO: Rework this to be usable for app router routes. + // See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH + if (pageInfo?.initialCacheControl?.revalidate) usedSymbols.add('ISR') messages.push([ `${border} ${symbol} ${ - pageInfo?.initialRevalidateSeconds - ? `${item} (ISR: ${pageInfo?.initialRevalidateSeconds} Seconds)` + pageInfo?.initialCacheControl?.revalidate + ? `${item} (ISR: ${pageInfo?.initialCacheControl.revalidate} Seconds)` : item }${ totalDuration > MIN_DURATION diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index ad5321648d37b..06088024258fe 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -646,8 +646,8 @@ async function exportAppImpl( if (options.buildExport) { // Update path info by path. const info = collector.byPath.get(path) ?? {} - if (typeof result.revalidate !== 'undefined') { - info.revalidate = result.revalidate + if (result.cacheControl) { + info.cacheControl = result.cacheControl } if (typeof result.metadata !== 'undefined') { info.metadata = result.metadata diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index e2c961b4d74cd..4bd0b3527a626 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -139,7 +139,7 @@ export async function exportAppPage( const { metadata } = result const { flightData, - revalidate = false, + cacheControl = { revalidate: false, expire: undefined }, postponed, fetchTags, fetchMetrics, @@ -151,7 +151,7 @@ export async function exportAppPage( throw new Error('Invariant: page postponed without PPR being enabled') } - if (revalidate === 0) { + if (cacheControl.revalidate === 0) { if (isDynamicError) { throw new Error( `Page with dynamic = "error" encountered dynamic data method on ${path}.` @@ -159,7 +159,7 @@ export async function exportAppPage( } const { staticBailoutInfo = {} } = metadata - if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) { + if (debugOutput && staticBailoutInfo?.description) { logDynamicUsageWarning({ path, description: staticBailoutInfo.description, @@ -167,7 +167,7 @@ export async function exportAppPage( }) } - return { revalidate: 0, fetchMetrics } + return { cacheControl, fetchMetrics } } // If page data isn't available, it means that the page couldn't be rendered @@ -270,7 +270,7 @@ export async function exportAppPage( metadata: hasNextSupport ? meta : undefined, hasEmptyPrelude: Boolean(postponed) && html === '', hasPostponed: Boolean(postponed), - revalidate, + cacheControl, fetchMetrics, } } catch (err) { @@ -298,7 +298,7 @@ export async function exportAppPage( }) } - return { revalidate: 0, fetchMetrics } + return { cacheControl: { revalidate: 0, expire: undefined }, fetchMetrics } } } diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 9567a91a33bd9..b204e7b79ed62 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -114,14 +114,14 @@ export async function exportAppRoute( // unless specifically opted into experimental.dynamicIO !== true ) { - return { revalidate: 0 } + return { cacheControl: { revalidate: 0, expire: undefined } } } const response = await module.handle(request, context) const isValidStatus = response.status < 400 || response.status === 404 if (!isValidStatus) { - return { revalidate: 0 } + return { cacheControl: { revalidate: 0, expire: undefined } } } const blob = await response.blob() @@ -136,6 +136,12 @@ export async function exportAppRoute( ? false : context.renderOpts.collectedRevalidate + const expire = + typeof context.renderOpts.collectedExpire === 'undefined' || + context.renderOpts.collectedExpire >= INFINITE_CACHE + ? undefined + : context.renderOpts.collectedExpire + const headers = toNodeOutgoingHttpHeaders(response.headers) const cacheTags = context.renderOpts.collectedTags @@ -159,7 +165,7 @@ export async function exportAppRoute( ) return { - revalidate: revalidate, + cacheControl: { revalidate, expire }, metadata: meta, } } catch (err) { @@ -167,6 +173,6 @@ export async function exportAppRoute( throw err } - return { revalidate: 0 } + return { cacheControl: { revalidate: 0, expire: undefined } } } } diff --git a/packages/next/src/export/routes/pages.ts b/packages/next/src/export/routes/pages.ts index 4ef43d149c9c1..a0f761e015bc5 100644 --- a/packages/next/src/export/routes/pages.ts +++ b/packages/next/src/export/routes/pages.ts @@ -219,7 +219,10 @@ export async function exportPagesPage( return { ampValidations, - revalidate: metadata.revalidate ?? false, + cacheControl: metadata.cacheControl ?? { + revalidate: false, + expire: undefined, + }, ssgNotFound, } } diff --git a/packages/next/src/export/types.ts b/packages/next/src/export/types.ts index d2602bc6e7632..84d90274cd5aa 100644 --- a/packages/next/src/export/types.ts +++ b/packages/next/src/export/types.ts @@ -4,7 +4,7 @@ import type { LoadComponentsReturnType } from '../server/load-components' import type { OutgoingHttpHeaders } from 'http' import type AmpHtmlValidator from 'next/dist/compiled/amphtml-validator' import type { ExportPathMap, NextConfigComplete } from '../server/config-shared' -import type { Revalidate } from '../server/lib/revalidate' +import type { CacheControl } from '../server/lib/cache-control' import type { NextEnabledDirectories } from '../server/base-server' import type { SerializableTurborepoAccessTraceResult, @@ -68,7 +68,7 @@ export interface ExportPageInput { export type ExportRouteResult = | { ampValidations?: AmpValidation[] - revalidate: Revalidate + cacheControl: CacheControl metadata?: Partial ssgNotFound?: boolean hasEmptyPrelude?: boolean @@ -128,9 +128,9 @@ export type ExportAppResult = { string, { /** - * The revalidation time for the page in seconds. + * The cache control for the page. */ - revalidate?: Revalidate + cacheControl?: CacheControl /** * The metadata for the page. */ diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 0fc58c579ea27..8e28db7a4d635 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -595,7 +595,7 @@ async function exportPage( return { duration: Date.now() - start, ampValidations: result.ampValidations, - revalidate: result.revalidate, + cacheControl: result.cacheControl, metadata: result.metadata, ssgNotFound: result.ssgNotFound, hasEmptyPrelude: result.hasEmptyPrelude, diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index b32f28352f8c7..9881709dc6a10 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -1422,17 +1422,23 @@ async function renderToHTMLOrFlightImpl( // If force static is specifically set to false, we should not revalidate // the page. if (workStore.forceStatic === false || response.collectedRevalidate === 0) { - metadata.revalidate = 0 + metadata.cacheControl = { revalidate: 0, expire: undefined } } else { - // Copy the revalidation value onto the render result metadata. - metadata.revalidate = - response.collectedRevalidate >= INFINITE_CACHE - ? false - : response.collectedRevalidate + // Copy the cache control value onto the render result metadata. + metadata.cacheControl = { + revalidate: + response.collectedRevalidate >= INFINITE_CACHE + ? false + : response.collectedRevalidate, + expire: + response.collectedExpire >= INFINITE_CACHE + ? undefined + : response.collectedExpire, + } } // provide bailout info for debugging - if (metadata.revalidate === 0) { + if (metadata.cacheControl?.revalidate === 0) { metadata.staticBailoutInfo = { description: workStore.dynamicUsageDescription, stack: workStore.dynamicUsageStack, diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 05a82d1e69fd1..31dc45393ef50 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -5,7 +5,6 @@ import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin' import type { ParsedUrlQuery } from 'querystring' import type { AppPageModule } from '../route-modules/app-page/module' -import type { ExpireTime } from '../lib/revalidate' import type { HeadData, LoadingModuleData, @@ -212,7 +211,7 @@ export interface RenderOptsPartial { * prerendering. */ isRoutePPREnabled?: boolean - expireTime: ExpireTime | undefined + expireTime: number | undefined clientTraceMetadata: string[] | undefined dynamicIO: boolean clientSegmentCache: boolean diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 7c2580a2228d4..c87ecf85ca254 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -76,11 +76,7 @@ import { import { isDynamicRoute } from '../shared/lib/router/utils' import { checkIsOnDemandRevalidate } from './api-utils' import { setConfig } from '../shared/lib/runtime-config.external' -import { - formatRevalidate, - type Revalidate, - type ExpireTime, -} from './lib/revalidate' +import { getCacheControlHeader, type CacheControl } from './lib/cache-control' import { execOnce } from '../shared/lib/utils' import { isBlockedPage } from './utils' import { getBotType, isBot } from '../shared/lib/router/utils/is-bot' @@ -181,6 +177,7 @@ import { isHtmlBotRequest, } from './lib/streaming-metadata' import { getCacheHandlers } from './use-cache/handlers' +import { InvariantError } from '../shared/lib/invariant-error' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -327,7 +324,7 @@ export class WrappedBuildError extends Error { type ResponsePayload = { type: 'html' | 'json' | 'rsc' body: RenderResult - revalidate?: Revalidate | undefined + cacheControl?: CacheControl } export type NextEnabledDirectories = { @@ -405,8 +402,7 @@ export default abstract class Server< type: 'html' | 'json' | 'rsc' generateEtags: boolean poweredByHeader: boolean - revalidate: Revalidate | undefined - expireTime: ExpireTime | undefined + cacheControl: CacheControl | undefined } ): Promise @@ -1783,14 +1779,18 @@ export default abstract class Server< const { req, res } = ctx const originalStatus = res.statusCode const { body, type } = payload - let { revalidate } = payload + let { cacheControl } = payload if (!res.sent) { const { generateEtags, poweredByHeader, dev } = this.renderOpts // In dev, we should not cache pages for any reason. if (dev) { res.setHeader('Cache-Control', 'no-store, must-revalidate') - revalidate = undefined + cacheControl = undefined + } + + if (cacheControl && cacheControl.expire === undefined) { + cacheControl.expire = this.nextConfig.expireTime } await this.sendRenderResult(req, res, { @@ -1798,8 +1798,7 @@ export default abstract class Server< type, generateEtags, poweredByHeader, - revalidate, - expireTime: this.nextConfig.expireTime, + cacheControl, }) res.statusCode = originalStatus } @@ -2622,6 +2621,12 @@ export default abstract class Server< ? false : context.renderOpts.collectedRevalidate + const expire = + typeof context.renderOpts.collectedExpire === 'undefined' || + context.renderOpts.collectedExpire >= INFINITE_CACHE + ? undefined + : context.renderOpts.collectedExpire + // Create the cache entry for the response. const cacheEntry: ResponseCacheEntry = { value: { @@ -2630,7 +2635,7 @@ export default abstract class Server< body: Buffer.from(await blob.arrayBuffer()), headers, }, - revalidate, + cacheControl: { revalidate, expire }, isFallback: false, } @@ -2788,6 +2793,7 @@ export default abstract class Server< const { metadata } = result const { + cacheControl, headers = {}, // Add any fetch tags that were on the page to the response headers. fetchTags: cacheTags, @@ -2806,7 +2812,7 @@ export default abstract class Server< if ( isAppPath && isSSG && - metadata.revalidate === 0 && + cacheControl?.revalidate === 0 && !this.renderOpts.dev && !isRoutePPREnabled ) { @@ -2836,7 +2842,7 @@ export default abstract class Server< if ('isNotFound' in metadata && metadata.isNotFound) { return { value: null, - revalidate: metadata.revalidate, + cacheControl, isFallback: false, } satisfies ResponseCacheEntry } @@ -2848,7 +2854,7 @@ export default abstract class Server< kind: CachedRouteKind.REDIRECT, props: metadata.pageData ?? metadata.flightData, } satisfies CachedRedirectValue, - revalidate: metadata.revalidate, + cacheControl, isFallback: false, } satisfies ResponseCacheEntry } @@ -2870,7 +2876,7 @@ export default abstract class Server< status: res.statusCode, segmentData: metadata.segmentData, } satisfies CachedAppPageValue, - revalidate: metadata.revalidate, + cacheControl, isFallback: !!fallbackRouteParams, } satisfies ResponseCacheEntry } @@ -2883,7 +2889,7 @@ export default abstract class Server< headers, status: isAppPath ? res.statusCode : undefined, } satisfies CachedPageValue, - revalidate: metadata.revalidate, + cacheControl, isFallback: pagesFallback, } } @@ -3088,9 +3094,9 @@ export default abstract class Server< // Otherwise, if we did get a fallback response, we should return it. if (fallbackResponse) { - // Remove the revalidate from the response to prevent it from being + // Remove the cache control from the response to prevent it from being // used in the surrounding cache. - delete fallbackResponse.revalidate + delete fallbackResponse.cacheControl return fallbackResponse } @@ -3110,7 +3116,7 @@ export default abstract class Server< typeof postponed !== 'undefined' ) { return { - revalidate: 1, + cacheControl: { revalidate: 1, expire: undefined }, isFallback: false, value: { kind: CachedRouteKind.PAGES, @@ -3134,17 +3140,11 @@ export default abstract class Server< : null // Perform the render. - const result = await doRender({ + return doRender({ postponed, pagesFallback: undefined, fallbackRouteParams, }) - if (!result) return null - - return { - ...result, - revalidate: result.revalidate, - } } const cacheEntry = await this.responseCache.get( @@ -3278,9 +3278,9 @@ export default abstract class Server< return { type: 'rsc', body: RenderResult.fromStatic(matchedSegment), - // TODO: Eventually this should use revalidate time of the - // individual segment, not the whole page. - revalidate: cacheEntry.revalidate, + // TODO: Eventually this should use cache control of the individual + // segment, not the whole page. + cacheControl: cacheEntry.cacheControl, } } @@ -3294,22 +3294,22 @@ export default abstract class Server< return { type: 'rsc', body: RenderResult.fromStatic(''), - revalidate: cacheEntry?.revalidate, + cacheControl: cacheEntry?.cacheControl, } } // If the cache value is an image, we should error early. if (cachedData?.kind === CachedRouteKind.IMAGE) { - throw new Error('invariant SSG should not return an image cache value') + throw new InvariantError('SSG should not return an image cache value') } - // Coerce the revalidate parameter from the render. - let revalidate: Revalidate | undefined + // Coerce the cache control parameter from the render. + let cacheControl: CacheControl | undefined // If this is a resume request in minimal mode it is streamed with dynamic // content and should not be cached. if (minimalPostponed) { - revalidate = 0 + cacheControl = { revalidate: 0, expire: undefined } } // If this is in minimal mode and this is a flight request that isn't a @@ -3321,18 +3321,18 @@ export default abstract class Server< !isPrefetchRSCRequest && isRoutePPREnabled ) { - revalidate = 0 + cacheControl = { revalidate: 0, expire: undefined } } else if (!this.renderOpts.dev || (hasServerProps && !isNextDataRequest)) { // If this is a preview mode request, we shouldn't cache it if (isPreviewMode) { - revalidate = 0 + cacheControl = { revalidate: 0, expire: undefined } } // If this isn't SSG, then we should set change the header only if it is // not set already. else if (!isSSG) { if (!res.getHeader('Cache-Control')) { - revalidate = 0 + cacheControl = { revalidate: 0, expire: undefined } } } @@ -3344,30 +3344,39 @@ export default abstract class Server< // period of 0 so that it doesn't get cached unexpectedly by a CDN else if (is404Page) { const notFoundRevalidate = getRequestMeta(req, 'notFoundRevalidate') - revalidate = - typeof notFoundRevalidate === 'undefined' ? 0 : notFoundRevalidate - } else if (is500Page) { - revalidate = 0 - } - // If the cache entry has a revalidate value that's a number, use it. - else if (typeof cacheEntry.revalidate === 'number') { - if (cacheEntry.revalidate < 1) { - throw new Error( - `Invalid revalidate configuration provided: ${cacheEntry.revalidate} < 1` - ) + cacheControl = { + revalidate: + typeof notFoundRevalidate === 'undefined' ? 0 : notFoundRevalidate, + expire: undefined, } + } else if (is500Page) { + cacheControl = { revalidate: 0, expire: undefined } + } else if (cacheEntry.cacheControl) { + // If the cache entry has a cache control with a revalidate value that's + // a number, use it. + if (typeof cacheEntry.cacheControl.revalidate === 'number') { + if (cacheEntry.cacheControl.revalidate < 1) { + throw new Error( + `Invalid revalidate configuration provided: ${cacheEntry.cacheControl.revalidate} < 1` + ) + } - revalidate = cacheEntry.revalidate - } - // Otherwise if the revalidate value is false, then we should use the cache - // time of one year. - else if (cacheEntry.revalidate === false) { - revalidate = CACHE_ONE_YEAR + cacheControl = { + revalidate: cacheEntry.cacheControl.revalidate, + expire: + cacheEntry.cacheControl?.expire ?? this.nextConfig.expireTime, + } + } + // Otherwise if the revalidate value is false, then we should use the + // cache time of one year. + else { + cacheControl = { revalidate: CACHE_ONE_YEAR, expire: undefined } + } } } - cacheEntry.revalidate = revalidate + cacheEntry.cacheControl = cacheControl // If there's a callback for `onCacheEntry`, call it with the cache entry // and the revalidate options. @@ -3401,20 +3410,18 @@ export default abstract class Server< // so that we can use this as source of truth for the // cache-control header instead of what the 404 page returns // for the revalidate value - addRequestMeta(req, 'notFoundRevalidate', cacheEntry.revalidate) + addRequestMeta( + req, + 'notFoundRevalidate', + cacheEntry.cacheControl?.revalidate + ) // If cache control is already set on the response we don't // override it to allow users to customize it via next.config - if ( - typeof cacheEntry.revalidate !== 'undefined' && - !res.getHeader('Cache-Control') - ) { + if (cacheEntry.cacheControl && !res.getHeader('Cache-Control')) { res.setHeader( 'Cache-Control', - formatRevalidate({ - revalidate: cacheEntry.revalidate, - expireTime: this.nextConfig.expireTime, - }) + getCacheControlHeader(cacheEntry.cacheControl) ) } if (isNextDataRequest) { @@ -3431,16 +3438,10 @@ export default abstract class Server< } else if (cachedData.kind === CachedRouteKind.REDIRECT) { // If cache control is already set on the response we don't // override it to allow users to customize it via next.config - if ( - typeof cacheEntry.revalidate !== 'undefined' && - !res.getHeader('Cache-Control') - ) { + if (cacheEntry.cacheControl && !res.getHeader('Cache-Control')) { res.setHeader( 'Cache-Control', - formatRevalidate({ - revalidate: cacheEntry.revalidate, - expireTime: this.nextConfig.expireTime, - }) + getCacheControlHeader(cacheEntry.cacheControl) ) } @@ -3451,7 +3452,7 @@ export default abstract class Server< // @TODO: Handle flight data. JSON.stringify(cachedData.props) ), - revalidate: cacheEntry.revalidate, + cacheControl: cacheEntry.cacheControl, } } else { await handleRedirect(cachedData.props) @@ -3467,16 +3468,13 @@ export default abstract class Server< // If cache control is already set on the response we don't // override it to allow users to customize it via next.config if ( - typeof cacheEntry.revalidate !== 'undefined' && + cacheEntry.cacheControl && !res.getHeader('Cache-Control') && !headers.get('Cache-Control') ) { headers.set( 'Cache-Control', - formatRevalidate({ - revalidate: cacheEntry.revalidate, - expireTime: this.nextConfig.expireTime, - }) + getCacheControlHeader(cacheEntry.cacheControl) ) } @@ -3563,7 +3561,9 @@ export default abstract class Server< // distinguishing between `force-static` and pages that have no // postponed state. // TODO: distinguish `force-static` from pages with no postponed state (static) - revalidate: isDynamicRSCRequest ? 0 : cacheEntry.revalidate, + cacheControl: isDynamicRSCRequest + ? { revalidate: 0, expire: undefined } + : cacheEntry.cacheControl, } } @@ -3572,7 +3572,7 @@ export default abstract class Server< return { type: 'rsc', body: RenderResult.fromStatic(cachedData.rscData), - revalidate: cacheEntry.revalidate, + cacheControl: cacheEntry.cacheControl, } } @@ -3586,7 +3586,7 @@ export default abstract class Server< return { type: 'html', body, - revalidate: cacheEntry.revalidate, + cacheControl: cacheEntry.cacheControl, } } @@ -3606,7 +3606,11 @@ export default abstract class Server< }) ) - return { type: 'html', body, revalidate: 0 } + return { + type: 'html', + body, + cacheControl: { revalidate: 0, expire: undefined }, + } } // This request has postponed, so let's create a new transformer that the @@ -3653,19 +3657,19 @@ export default abstract class Server< // We don't want to cache the response if it has postponed data because // the response being sent to the client it's dynamic parts are streamed // to the client on the same request. - revalidate: 0, + cacheControl: { revalidate: 0, expire: undefined }, } } else if (isNextDataRequest) { return { type: 'json', body: RenderResult.fromStatic(JSON.stringify(cachedData.pageData)), - revalidate: cacheEntry.revalidate, + cacheControl: cacheEntry.cacheControl, } } else { return { type: 'html', body: cachedData.html, - revalidate: cacheEntry.revalidate, + cacheControl: cacheEntry.cacheControl, } } } diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index d677e0aaa99e2..972b4707f029f 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -10,7 +10,6 @@ import type { SubresourceIntegrityAlgorithm } from '../build/webpack/plugins/sub import type { WEB_VITALS } from '../shared/lib/utils' import type { NextParsedUrlQuery } from './request-meta' import type { SizeLimit } from '../types' -import type { ExpireTime } from './lib/revalidate' import type { SupportedTestRunners } from '../cli/next-test' import type { ExperimentalPPRConfig } from './lib/experimental/ppr' import { INFINITE_CACHE } from '../lib/constants' @@ -316,7 +315,7 @@ export interface ExperimentalConfig { /** * @deprecated use config.expireTime instead */ - expireTime?: ExpireTime + expireTime?: number middlewarePrefetch?: 'strict' | 'flexible' manualClientBasePath?: boolean /** @@ -1027,7 +1026,7 @@ export interface NextConfig extends Record { /** * period (in seconds) where the server allow to serve stale cache */ - expireTime?: ExpireTime + expireTime?: number /** * Enable experimental features. Note that all experimental features are subject to breaking changes in the future. @@ -1127,7 +1126,9 @@ export const defaultConfig: NextConfig = { keepAlive: true, }, logging: {}, - expireTime: process.env.__NEXT_TEST_MODE ? undefined : 31536000, + expireTime: process.env.NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL + ? undefined + : 31536000, // one year staticPageGenerationTimeout: 60, output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, modularizeImports: undefined, diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index 568a8b02ce07e..7346301b47b37 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -27,6 +27,8 @@ import { getContentType, getExtension } from './serve-static' import * as Log from '../build/output/log' import isError from '../lib/is-error' import { parseUrl } from '../lib/url' +import type { CacheControl } from './lib/cache-control' +import { InvariantError } from '../shared/lib/invariant-error' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' @@ -414,7 +416,7 @@ export class ImageOptimizerCache { revalidateAfter: Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(), - revalidate: maxAge, + cacheControl: { revalidate: maxAge, expire: undefined }, isStale: now > expireAt, isFallback: false, } @@ -428,18 +430,21 @@ export class ImageOptimizerCache { cacheKey: string, value: IncrementalCacheValue | null, { - revalidate, + cacheControl, }: { - revalidate?: number | false + cacheControl?: CacheControl } ) { if (value?.kind !== CachedRouteKind.IMAGE) { throw new Error('invariant attempted to set non-image to image-cache') } + const revalidate = cacheControl?.revalidate + if (typeof revalidate !== 'number') { - throw new Error('invariant revalidate must be a number for image-cache') + throw new InvariantError('revalidate must be a number for image-cache') } + const expireAt = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now() @@ -772,7 +777,7 @@ export async function imageOptimizer( return { buffer: previouslyCachedImage.buffer, contentType, - maxAge: opts?.previousCacheEntry?.revalidate || maxAge, + maxAge: opts?.previousCacheEntry?.cacheControl?.revalidate || maxAge, etag: previouslyCachedImage.etag, upstreamEtag: previouslyCachedImage.upstreamEtag, } diff --git a/packages/next/src/server/lib/revalidate.ts b/packages/next/src/server/lib/cache-control.ts similarity index 60% rename from packages/next/src/server/lib/revalidate.ts rename to packages/next/src/server/lib/cache-control.ts index de3652e22dd85..93a8b08736909 100644 --- a/packages/next/src/server/lib/revalidate.ts +++ b/packages/next/src/server/lib/cache-control.ts @@ -8,27 +8,28 @@ import { CACHE_ONE_YEAR } from '../../lib/constants' * value for this option. */ export type Revalidate = number | false -export type ExpireTime = number -export function formatRevalidate({ - revalidate, - expireTime, -}: { +export interface CacheControl { revalidate: Revalidate - expireTime?: ExpireTime -}): string { + expire: number | undefined +} + +export function getCacheControlHeader({ + revalidate, + expire, +}: CacheControl): string { const swrHeader = - typeof revalidate === 'number' && expireTime !== undefined - ? revalidate >= expireTime - ? '' - : `stale-while-revalidate=${expireTime - revalidate}` - : 'stale-while-revalidate' + typeof revalidate === 'number' && + expire !== undefined && + revalidate < expire + ? `, stale-while-revalidate=${expire - revalidate}` + : '' if (revalidate === 0) { return 'private, no-cache, no-store, max-age=0, must-revalidate' } else if (typeof revalidate === 'number') { - return `s-maxage=${revalidate}, ${swrHeader}` + return `s-maxage=${revalidate}${swrHeader}` } - return `s-maxage=${CACHE_ONE_YEAR}, ${swrHeader}` + return `s-maxage=${CACHE_ONE_YEAR}${swrHeader}` } diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index e897f3eece725..54e97349aba6d 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -14,7 +14,6 @@ import { type SetIncrementalFetchCacheContext, type SetIncrementalResponseCacheContext, } from '../../response-cache' -import type { Revalidate } from '../revalidate' import type { DeepReadonly } from '../../../shared/lib/deep-readonly' import FileSystemCache from './file-system-cache' @@ -27,7 +26,7 @@ import { PRERENDER_REVALIDATE_HEADER, } from '../../../lib/constants' import { toRoute } from '../to-route' -import { SharedRevalidateTimings } from './shared-revalidate-timings' +import { SharedCacheControls } from './shared-cache-controls' import { workUnitAsyncStorageInstance } from '../../app-render/work-unit-async-storage-instance' import { getPrerenderResumeDataCache, @@ -35,6 +34,7 @@ import { } from '../../app-render/work-unit-async-storage.external' import { getCacheHandlers } from '../../use-cache/handlers' import { InvariantError } from '../../../shared/lib/invariant-error' +import type { Revalidate } from '../cache-control' export interface CacheHandlerContext { fs?: CacheFs @@ -96,10 +96,10 @@ export class IncrementalCache implements IncrementalCacheType { private readonly locks = new Map>() /** - * The revalidate timings for routes. This will source the timings from the - * prerender manifest until the in-memory cache is updated with new timings. + * The cache controls for routes. This will source the values from the + * prerender manifest until the in-memory cache is updated with new values. */ - private readonly revalidateTimings: SharedRevalidateTimings + private readonly cacheControls: SharedCacheControls constructor({ fs, @@ -170,7 +170,7 @@ export class IncrementalCache implements IncrementalCacheType { this.requestProtocol = requestProtocol this.allowedRevalidateHeaderKeys = allowedRevalidateHeaderKeys this.prerenderManifest = getPrerenderManifest() - this.revalidateTimings = new SharedRevalidateTimings(this.prerenderManifest) + this.cacheControls = new SharedCacheControls(this.prerenderManifest) this.fetchCacheKeyPrefix = fetchCacheKeyPrefix let revalidatedTags: string[] = [] @@ -216,10 +216,15 @@ export class IncrementalCache implements IncrementalCacheType { if (dev) return Math.floor(performance.timeOrigin + performance.now() - 1000) + const cacheControl = this.cacheControls.get(toRoute(pathname)) + // if an entry isn't present in routes we fallback to a default // of revalidating after 1 second unless it's a fallback request. - const initialRevalidateSeconds = - this.revalidateTimings.get(toRoute(pathname)) ?? (isFallback ? false : 1) + const initialRevalidateSeconds = cacheControl + ? cacheControl.revalidate + : isFallback + ? false + : 1 const revalidateAfter = typeof initialRevalidateSeconds === 'number' @@ -488,8 +493,7 @@ export class IncrementalCache implements IncrementalCacheType { let entry: IncrementalResponseCacheEntry | null = null const { isFallback } = ctx - - const revalidate = this.revalidateTimings.get(toRoute(cacheKey)) + const cacheControl = this.cacheControls.get(toRoute(cacheKey)) let isStale: boolean | -1 | undefined let revalidateAfter: Revalidate @@ -514,7 +518,7 @@ export class IncrementalCache implements IncrementalCacheType { if (cacheData) { entry = { isStale, - revalidate, + cacheControl, revalidateAfter, value: cacheData.value, isFallback, @@ -533,11 +537,11 @@ export class IncrementalCache implements IncrementalCacheType { entry = { isStale, value: null, - revalidate, + cacheControl, revalidateAfter, isFallback, } - this.set(cacheKey, entry.value, { ...ctx, revalidate }) + this.set(cacheKey, entry.value, { ...ctx, cacheControl }) } return entry } @@ -594,10 +598,8 @@ export class IncrementalCache implements IncrementalCacheType { } try { - // Set the value for the revalidate seconds so if it changes we can - // update the cache with the new value. - if (!ctx.fetchCache && typeof ctx.revalidate !== 'undefined') { - this.revalidateTimings.set(toRoute(pathname), ctx.revalidate) + if (!ctx.fetchCache && ctx.cacheControl) { + this.cacheControls.set(toRoute(pathname), ctx.cacheControl) } await this.cacheHandler?.set(pathname, data, ctx) diff --git a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts new file mode 100644 index 0000000000000..e6935165c61c2 --- /dev/null +++ b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts @@ -0,0 +1,94 @@ +import type { + DynamicPrerenderManifestRoute, + PrerenderManifestRoute, +} from '../../../build' +import { RenderingMode } from '../../../build/rendering-mode' +import { SharedCacheControls } from './shared-cache-controls' + +describe('SharedCacheControls', () => { + let sharedCacheControls: SharedCacheControls + let prerenderManifest + + beforeEach(() => { + prerenderManifest = { + routes: { + '/route1': { + initialRevalidateSeconds: 10, + initialExpireSeconds: undefined, + dataRoute: null, + srcRoute: null, + prefetchDataRoute: null, + experimentalPPR: undefined, + renderingMode: RenderingMode.STATIC, + allowHeader: [], + } satisfies PrerenderManifestRoute, + '/route2': { + initialRevalidateSeconds: 20, + initialExpireSeconds: 40, + dataRoute: null, + srcRoute: null, + prefetchDataRoute: null, + experimentalPPR: undefined, + renderingMode: RenderingMode.STATIC, + allowHeader: [], + } satisfies PrerenderManifestRoute, + }, + dynamicRoutes: { + '/route4': { + fallbackRevalidate: 30, + fallbackExpire: 50, + fallback: true, + fallbackRootParams: undefined, + fallbackSourceRoute: undefined, + dataRoute: null, + dataRouteRegex: null, + prefetchDataRoute: null, + prefetchDataRouteRegex: null, + routeRegex: '', + experimentalPPR: undefined, + renderingMode: RenderingMode.PARTIALLY_STATIC, + allowHeader: [], + } satisfies DynamicPrerenderManifestRoute, + }, + } + sharedCacheControls = new SharedCacheControls(prerenderManifest) + }) + + afterEach(() => { + sharedCacheControls.clear() + }) + + it('should get cache control from in-memory cache', () => { + sharedCacheControls.set('/route1', { revalidate: 15, expire: undefined }) + const cacheControl = sharedCacheControls.get('/route1') + expect(cacheControl).toEqual({ revalidate: 15 }) + }) + + it('should get cache control from prerender manifest if not in cache', () => { + const cacheControl = sharedCacheControls.get('/route2') + expect(cacheControl).toEqual({ revalidate: 20, expire: 40 }) + }) + + it('should return undefined if cache control not found', () => { + const cacheControl = sharedCacheControls.get('/route3') + expect(cacheControl).toBeUndefined() + }) + + it('should set cache control in cache', () => { + sharedCacheControls.set('/route3', { revalidate: 30, expire: undefined }) + const cacheControl = sharedCacheControls.get('/route3') + expect(cacheControl).toEqual({ revalidate: 30 }) + }) + + it('should clear the in-memory cache', () => { + sharedCacheControls.set('/route3', { revalidate: 30, expire: undefined }) + sharedCacheControls.clear() + const cacheControl = sharedCacheControls.get('/route3') + expect(cacheControl).toBeUndefined() + }) + + it('should get cache control from prerender manifest for dynamic route with fallback', () => { + const cacheControl = sharedCacheControls.get('/route4') + expect(cacheControl).toEqual({ revalidate: 30, expire: 50 }) + }) +}) diff --git a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.ts b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.ts new file mode 100644 index 0000000000000..52394678c378b --- /dev/null +++ b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.ts @@ -0,0 +1,85 @@ +import type { PrerenderManifest } from '../../../build' +import type { DeepReadonly } from '../../../shared/lib/deep-readonly' +import type { CacheControl } from '../cache-control' + +/** + * A shared cache of cache controls for routes. This cache is used so we don't + * have to modify the prerender manifest when we want to update the cache + * control for a route. + */ +export class SharedCacheControls { + /** + * The in-memory cache of cache lives for routes. This cache is populated when + * the cache is updated with new cache lives. + */ + private static readonly cacheControls = new Map() + + constructor( + /** + * The prerender manifest that contains the initial cache controls for + * routes. + */ + private readonly prerenderManifest: DeepReadonly< + Pick + > + ) {} + + /** + * Try to get the cache control value for a route. This will first try to get + * the value from the in-memory cache. If the value is not present in the + * in-memory cache, it will be sourced from the prerender manifest. + * + * @param route the route to get the cache control for + * @returns the cache control for the route, or undefined if the values + * are not present in the in-memory cache or the prerender manifest + */ + public get(route: string): CacheControl | undefined { + // This is a copy on write cache that is updated when the cache is updated. + // If the cache is never written to, then the values will be sourced from + // the prerender manifest. + let cacheControl = SharedCacheControls.cacheControls.get(route) + if (cacheControl) return cacheControl + + let prerenderData = this.prerenderManifest.routes[route] + + if (prerenderData) { + const { initialRevalidateSeconds, initialExpireSeconds } = prerenderData + + if (typeof initialRevalidateSeconds !== 'undefined') { + return { + revalidate: initialRevalidateSeconds, + expire: initialExpireSeconds, + } + } + } + + const dynamicPrerenderData = this.prerenderManifest.dynamicRoutes[route] + + if (dynamicPrerenderData) { + const { fallbackRevalidate, fallbackExpire } = dynamicPrerenderData + + if (typeof fallbackRevalidate !== 'undefined') { + return { revalidate: fallbackRevalidate, expire: fallbackExpire } + } + } + + return undefined + } + + /** + * Set the cache control for a route. + * + * @param route the route to set the cache control for + * @param cacheControl the cache control for the route + */ + public set(route: string, cacheControl: CacheControl) { + SharedCacheControls.cacheControls.set(route, cacheControl) + } + + /** + * Clear the in-memory cache of cache controls for routes. + */ + public clear() { + SharedCacheControls.cacheControls.clear() + } +} diff --git a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts deleted file mode 100644 index eb6e0dbbc797b..0000000000000 --- a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { RenderingMode } from '../../../build/rendering-mode' -import { SharedRevalidateTimings } from './shared-revalidate-timings' - -describe('SharedRevalidateTimings', () => { - let sharedRevalidateTimings: SharedRevalidateTimings - let prerenderManifest - - beforeEach(() => { - prerenderManifest = { - routes: { - '/route1': { - initialRevalidateSeconds: 10, - dataRoute: null, - srcRoute: null, - prefetchDataRoute: null, - experimentalPPR: undefined, - renderingMode: RenderingMode.STATIC, - allowHeader: [], - }, - '/route2': { - initialRevalidateSeconds: 20, - dataRoute: null, - srcRoute: null, - prefetchDataRoute: null, - experimentalPPR: undefined, - renderingMode: RenderingMode.STATIC, - allowHeader: [], - }, - }, - dynamicRoutes: {}, - } - sharedRevalidateTimings = new SharedRevalidateTimings(prerenderManifest) - }) - - afterEach(() => { - sharedRevalidateTimings.clear() - }) - - it('should get revalidate timing from in-memory cache', () => { - sharedRevalidateTimings.set('/route1', 15) - const revalidate = sharedRevalidateTimings.get('/route1') - expect(revalidate).toBe(15) - }) - - it('should get revalidate timing from prerender manifest if not in cache', () => { - const revalidate = sharedRevalidateTimings.get('/route2') - expect(revalidate).toBe(20) - }) - - it('should return undefined if revalidate timing not found', () => { - const revalidate = sharedRevalidateTimings.get('/route3') - expect(revalidate).toBeUndefined() - }) - - it('should set revalidate timing in cache', () => { - sharedRevalidateTimings.set('/route3', 30) - const revalidate = sharedRevalidateTimings.get('/route3') - expect(revalidate).toBe(30) - }) - - it('should clear the in-memory cache', () => { - sharedRevalidateTimings.set('/route3', 30) - sharedRevalidateTimings.clear() - const revalidate = sharedRevalidateTimings.get('/route3') - expect(revalidate).toBeUndefined() - }) -}) diff --git a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts b/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts deleted file mode 100644 index 1a474f0931170..0000000000000 --- a/packages/next/src/server/lib/incremental-cache/shared-revalidate-timings.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { PrerenderManifest } from '../../../build' -import type { DeepReadonly } from '../../../shared/lib/deep-readonly' -import type { Revalidate } from '../revalidate' - -/** - * A shared cache of revalidate timings for routes. This cache is used so we - * don't have to modify the prerender manifest when we want to update the - * revalidate timings for a route. - */ -export class SharedRevalidateTimings { - /** - * The in-memory cache of revalidate timings for routes. This cache is - * populated when the cache is updated with new timings. - */ - private static readonly timings = new Map() - - constructor( - /** - * The prerender manifest that contains the initial revalidate timings for - * routes. - */ - private readonly prerenderManifest: DeepReadonly< - Pick - > - ) {} - - /** - * Try to get the revalidate timings for a route. This will first try to get - * the timings from the in-memory cache. If the timings are not present in the - * in-memory cache, then the timings will be sourced from the prerender - * manifest. - * - * @param route the route to get the revalidate timings for - * @returns the revalidate timings for the route, or undefined if the timings - * are not present in the in-memory cache or the prerender manifest - */ - public get(route: string): Revalidate | undefined { - // This is a copy on write cache that is updated when the cache is updated. - // If the cache is never written to, then the timings will be sourced from - // the prerender manifest. - let revalidate = SharedRevalidateTimings.timings.get(route) - if (typeof revalidate !== 'undefined') return revalidate - - revalidate = this.prerenderManifest.routes[route]?.initialRevalidateSeconds - if (typeof revalidate !== 'undefined') return revalidate - - revalidate = this.prerenderManifest.dynamicRoutes[route]?.fallbackRevalidate - if (typeof revalidate !== 'undefined') return revalidate - - return undefined - } - - /** - * Set the revalidate timings for a route. - * - * @param route the route to set the revalidate timings for - * @param revalidate the revalidate timings for the route - */ - public set(route: string, revalidate: Revalidate) { - SharedRevalidateTimings.timings.set(route, revalidate) - } - - /** - * Clear the in-memory cache of revalidate timings for routes. - */ - public clear() { - SharedRevalidateTimings.timings.clear() - } -} diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 8e3d10cb26477..6a3bcad54a808 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -22,7 +22,7 @@ import type { PagesAPIRouteModule } from './route-modules/pages-api/module' import type { UrlWithParsedQuery } from 'url' import type { ParsedUrlQuery } from 'querystring' import type { ParsedUrl } from '../shared/lib/router/utils/parse-url' -import type { Revalidate, ExpireTime } from './lib/revalidate' +import type { CacheControl } from './lib/cache-control' import type { WaitUntil } from './after/builtin-request-context' import fs from 'fs' @@ -520,8 +520,7 @@ export default class NextNodeServer extends BaseServer< type: 'html' | 'json' | 'rsc' generateEtags: boolean poweredByHeader: boolean - revalidate: Revalidate | undefined - expireTime: ExpireTime | undefined + cacheControl: CacheControl | undefined } ): Promise { return sendRenderResult({ @@ -531,8 +530,7 @@ export default class NextNodeServer extends BaseServer< type: options.type, generateEtags: options.generateEtags, poweredByHeader: options.poweredByHeader, - revalidate: options.revalidate, - expireTime: options.expireTime, + cacheControl: options.cacheControl, }) } @@ -959,7 +957,7 @@ export default class NextNodeServer extends BaseServer< upstreamEtag, }, isFallback: false, - revalidate: maxAge, + cacheControl: { revalidate: maxAge, expire: undefined }, } }, { @@ -985,7 +983,7 @@ export default class NextNodeServer extends BaseServer< paramsResult.isStatic, cacheEntry.isMiss ? 'MISS' : cacheEntry.isStale ? 'STALE' : 'HIT', imagesConfig, - cacheEntry.revalidate || 0, + cacheEntry.cacheControl?.revalidate || 0, Boolean(this.renderOpts.dev) ) return true diff --git a/packages/next/src/server/render-result.ts b/packages/next/src/server/render-result.ts index 1ff8b07a150eb..6c541f4cab24e 100644 --- a/packages/next/src/server/render-result.ts +++ b/packages/next/src/server/render-result.ts @@ -1,5 +1,5 @@ import type { OutgoingHttpHeaders, ServerResponse } from 'http' -import type { Revalidate } from './lib/revalidate' +import type { CacheControl } from './lib/cache-control' import type { FetchMetrics } from './base-http' import { @@ -16,7 +16,7 @@ type ContentTypeOption = string | undefined export type AppPageRenderResultMetadata = { flightData?: Buffer - revalidate?: Revalidate + cacheControl?: CacheControl staticBailoutInfo?: { stack?: string description?: string @@ -45,7 +45,7 @@ export type AppPageRenderResultMetadata = { export type PagesRenderResultMetadata = { pageData?: any - revalidate?: Revalidate + cacheControl?: CacheControl assetQueryString?: string isNotFound?: boolean isRedirect?: boolean diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index c7a458496c054..bf642a7216d10 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -36,7 +36,7 @@ import type { NextFontManifest } from '../build/webpack/plugins/next-font-manife import type { PagesModule } from './route-modules/pages/module' import type { ComponentsEnhancer } from '../shared/lib/utils' import type { NextParsedUrlQuery } from './request-meta' -import type { Revalidate, ExpireTime } from './lib/revalidate' +import type { Revalidate } from './lib/cache-control' import type { COMPILER_NAMES } from '../shared/lib/constants' import React, { type JSX } from 'react' @@ -100,7 +100,7 @@ import { import { getTracer } from './lib/trace/tracer' import { RenderSpan } from './lib/trace/constants' import { ReflectAdapter } from './web/spec-extension/adapters/reflect' -import { formatRevalidate } from './lib/revalidate' +import { getCacheControlHeader } from './lib/cache-control' import { getErrorSource } from '../shared/lib/error-source' import type { DeepReadonly } from '../shared/lib/deep-readonly' import type { PagesDevOverlayType } from '../client/components/react-dev-overlay/pages/pages-dev-overlay' @@ -283,7 +283,7 @@ export type RenderOptsPartial = { isServerAction?: boolean isExperimentalCompile?: boolean isPrefetch?: boolean - expireTime?: ExpireTime + expireTime?: number experimental: { clientTraceMetadata?: string[] } @@ -557,10 +557,7 @@ export async function renderToHTMLImpl( if (isAutoExport && !dev && isExperimentalCompile) { res.setHeader( 'Cache-Control', - formatRevalidate({ - revalidate: false, - expireTime, - }) + getCacheControlHeader({ revalidate: false, expire: expireTime }) ) isAutoExport = false } @@ -1052,8 +1049,8 @@ export async function renderToHTMLImpl( 'props' in data ? data.props : undefined ) - // pass up revalidate and props for export - metadata.revalidate = revalidate + // pass up cache control and props for export + metadata.cacheControl = { revalidate, expire: undefined } metadata.pageData = props // this must come after revalidate is added to renderResultMeta @@ -1126,7 +1123,7 @@ export async function renderToHTMLImpl( }) ) canAccessRes = false - metadata.revalidate = 0 + metadata.cacheControl = { revalidate: 0, expire: undefined } } catch (serverSidePropsError: any) { // remove not found error code to prevent triggering legacy // 404 rendering diff --git a/packages/next/src/server/response-cache/index.ts b/packages/next/src/server/response-cache/index.ts index d2feba2f2a892..7c48b0361ef66 100644 --- a/packages/next/src/server/response-cache/index.ts +++ b/packages/next/src/server/response-cache/index.ts @@ -142,9 +142,9 @@ export default class ResponseCache implements ResponseCacheBase { resolved = true } - // We want to persist the result only if it has a revalidate value + // We want to persist the result only if it has a cache control value // defined. - if (typeof resolveValue.revalidate !== 'undefined') { + if (resolveValue.cacheControl) { if (this.minimalMode) { this.previousCacheItem = { key: cacheKey, @@ -153,7 +153,7 @@ export default class ResponseCache implements ResponseCacheBase { } } else { await incrementalCache.set(key, resolveValue.value, { - revalidate: resolveValue.revalidate, + cacheControl: resolveValue.cacheControl, isRoutePPREnabled, isFallback, }) @@ -162,14 +162,24 @@ export default class ResponseCache implements ResponseCacheBase { return resolveValue } catch (err) { - // When a getStaticProps path is erroring we automatically re-set the - // existing cache under a new expiration to prevent non-stop retrying. - if (cachedResponse) { + // When a path is erroring we automatically re-set the existing cache + // with new revalidate and expire times to prevent non-stop retrying. + if (cachedResponse?.cacheControl) { + const newRevalidate = Math.min( + Math.max(cachedResponse.cacheControl.revalidate || 3, 3), + 30 + ) + + const newExpire = + cachedResponse.cacheControl.expire === undefined + ? undefined + : Math.max( + newRevalidate + 3, + cachedResponse.cacheControl.expire + ) + await incrementalCache.set(key, cachedResponse.value, { - revalidate: Math.min( - Math.max(cachedResponse.revalidate || 3, 3), - 30 - ), + cacheControl: { revalidate: newRevalidate, expire: newExpire }, isRoutePPREnabled, isFallback, }) diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index a2c8110c4047c..e0265eb710bee 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -1,6 +1,6 @@ import type { OutgoingHttpHeaders } from 'http' import type RenderResult from '../render-result' -import type { Revalidate } from '../lib/revalidate' +import type { CacheControl, Revalidate } from '../lib/cache-control' import type { RouteKind } from '../route-kind' export interface ResponseCacheBase { @@ -132,7 +132,7 @@ export interface IncrementalCachedPageValue { } export interface IncrementalResponseCacheEntry { - revalidate?: Revalidate + cacheControl?: CacheControl /** * timestamp in milliseconds to revalidate after */ @@ -174,7 +174,7 @@ export type ResponseCacheValue = | CachedRouteValue export type ResponseCacheEntry = { - revalidate?: Revalidate + cacheControl?: CacheControl value: ResponseCacheValue | null isStale?: boolean | -1 isMiss?: boolean @@ -210,6 +210,7 @@ export interface GetIncrementalFetchCacheContext { export interface GetIncrementalResponseCacheContext { kind: Exclude + /** * True if the route is enabled for PPR. */ @@ -230,7 +231,8 @@ export interface SetIncrementalFetchCacheContext { export interface SetIncrementalResponseCacheContext { fetchCache?: false - revalidate?: Revalidate + cacheControl?: CacheControl + /** * True if the route is enabled for PPR. */ diff --git a/packages/next/src/server/response-cache/utils.ts b/packages/next/src/server/response-cache/utils.ts index e43c2abdd8397..1d19a08691c1a 100644 --- a/packages/next/src/server/response-cache/utils.ts +++ b/packages/next/src/server/response-cache/utils.ts @@ -46,7 +46,7 @@ export async function toResponseCacheEntry( return { isMiss: response.isMiss, isStale: response.isStale, - revalidate: response.revalidate, + cacheControl: response.cacheControl, isFallback: response.isFallback, value: response.value?.kind === CachedRouteKind.PAGES diff --git a/packages/next/src/server/response-cache/web.ts b/packages/next/src/server/response-cache/web.ts index d9334a7ab5f56..c86b69811a778 100644 --- a/packages/next/src/server/response-cache/web.ts +++ b/packages/next/src/server/response-cache/web.ts @@ -98,7 +98,7 @@ export default class WebResponseCache { resolve(resolveValue) } - if (key && cacheEntry && typeof cacheEntry.revalidate !== 'undefined') { + if (key && cacheEntry && cacheEntry.cacheControl) { this.previousCacheItem = { key: pendingResponseKey || key, entry: cacheEntry, diff --git a/packages/next/src/server/send-payload.ts b/packages/next/src/server/send-payload.ts index ddf5d5b409508..17ee1aea354b1 100644 --- a/packages/next/src/server/send-payload.ts +++ b/packages/next/src/server/send-payload.ts @@ -1,11 +1,11 @@ import type { IncomingMessage, ServerResponse } from 'http' import type RenderResult from './render-result' -import type { Revalidate, ExpireTime } from './lib/revalidate' +import type { CacheControl } from './lib/cache-control' import { isResSent } from '../shared/lib/utils' import { generateETag } from './lib/etag' import fresh from 'next/dist/compiled/fresh' -import { formatRevalidate } from './lib/revalidate' +import { getCacheControlHeader } from './lib/cache-control' import { RSC_CONTENT_TYPE_HEADER } from '../client/components/app-router-headers' export function sendEtagResponse( @@ -39,8 +39,7 @@ export async function sendRenderResult({ type, generateEtags, poweredByHeader, - revalidate, - expireTime, + cacheControl, }: { req: IncomingMessage res: ServerResponse @@ -48,8 +47,7 @@ export async function sendRenderResult({ type: 'html' | 'json' | 'rsc' generateEtags: boolean poweredByHeader: boolean - revalidate: Revalidate | undefined - expireTime: ExpireTime | undefined + cacheControl: CacheControl | undefined }): Promise { if (isResSent(res)) { return @@ -61,15 +59,10 @@ export async function sendRenderResult({ // If cache control is already set on the response we don't // override it to allow users to customize it via next.config - if (typeof revalidate !== 'undefined' && !res.getHeader('Cache-Control')) { - res.setHeader( - 'Cache-Control', - formatRevalidate({ - revalidate, - expireTime, - }) - ) + if (cacheControl && !res.getHeader('Cache-Control')) { + res.setHeader('Cache-Control', getCacheControlHeader(cacheControl)) } + const payload = result.isDynamic ? null : result.toUnchunkedString() if (generateEtags && payload !== null) { diff --git a/packages/next/src/server/use-cache/cache-life.ts b/packages/next/src/server/use-cache/cache-life.ts index 11e2cec95f41b..ef1987f467358 100644 --- a/packages/next/src/server/use-cache/cache-life.ts +++ b/packages/next/src/server/use-cache/cache-life.ts @@ -53,8 +53,8 @@ function validateCacheLife(profile: CacheLife) { if (profile.expire !== undefined) { if ((profile.expire as any) === false) { throw new Error( - 'Pass `Infinity` instead of `false` if you want to cache on the client forever ' + - 'without checking with the server.' + 'Pass `Infinity` instead of `false` if you want to cache on the server forever ' + + 'without checking with the origin.' ) } else if (typeof profile.expire !== 'number') { throw new Error('The expire option must be a number of seconds.') @@ -65,7 +65,7 @@ function validateCacheLife(profile: CacheLife) { if (profile.revalidate > profile.expire) { throw new Error( 'If providing both the revalidate and expire options, ' + - 'the expire option must be greater than the revalidate option.' + + 'the expire option must be greater than the revalidate option. ' + 'The expire option indicates how many seconds from the start ' + 'until it can no longer be used.' ) @@ -76,7 +76,7 @@ function validateCacheLife(profile: CacheLife) { if (profile.stale > profile.expire) { throw new Error( 'If providing both the stale and expire options, ' + - 'the expire option must be greater than the stale option.' + + 'the expire option must be greater than the stale option. ' + 'The expire option indicates how many seconds from the start ' + 'until it can no longer be used.' ) diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 8a5be7e3c2dee..a925d91675fed 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -10,7 +10,7 @@ import type { Options, RouteHandler, } from './base-server' -import type { Revalidate, ExpireTime } from './lib/revalidate' +import type { CacheControl } from './lib/cache-control' import { byteLength } from './api-utils/web' import BaseServer, { NoFallbackError } from './base-server' @@ -261,8 +261,7 @@ export default class NextWebServer extends BaseServer< type: 'html' | 'json' generateEtags: boolean poweredByHeader: boolean - revalidate: Revalidate | undefined - expireTime: ExpireTime | undefined + cacheControl: CacheControl | undefined } ): Promise { res.setHeader('X-Edge-Runtime', '1') 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 c12efbe9903ac..afadd42143fa3 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -1018,6 +1018,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 1, "srcRoute": "/articles/[slug]", }, @@ -1042,6 +1043,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/blog/[author]", }, @@ -1090,6 +1092,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/blog/[author]", }, @@ -1162,6 +1165,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/blog/[author]", }, @@ -1234,6 +1238,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/force-cache", }, @@ -1330,6 +1335,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/gen-params-dynamic-revalidate/[slug]", }, @@ -1426,6 +1432,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/isr-error-handling", }, @@ -1834,6 +1841,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/prerendered-not-found/segment-revalidate", }, @@ -1858,6 +1866,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialHeaders": { "content-type": "application/json", "x-next-cache-tags": "_N_T_/layout,_N_T_/route-handler/layout,_N_T_/route-handler/no-store-force-static/layout,_N_T_/route-handler/no-store-force-static/route,_N_T_/route-handler/no-store-force-static", @@ -1886,6 +1895,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialHeaders": { "content-type": "application/json", "x-next-cache-tags": "_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,thankyounext", @@ -2014,6 +2024,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 50, "srcRoute": "/strip-w3c-trace-context-headers", }, @@ -2086,6 +2097,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/variable-config-revalidate/revalidate-3", }, @@ -2110,6 +2122,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/variable-revalidate-stable/revalidate-3", }, @@ -2134,6 +2147,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/variable-revalidate/authorization", }, @@ -2158,6 +2172,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/variable-revalidate/cookie", }, @@ -2182,6 +2197,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/variable-revalidate/encoding", }, @@ -2206,6 +2222,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/variable-revalidate/headers-instance", }, @@ -2230,6 +2247,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/variable-revalidate/post-method", }, @@ -2254,6 +2272,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 3, "srcRoute": "/variable-revalidate/revalidate-3", }, @@ -2278,6 +2297,7 @@ describe('app-dir static/dynamic handling', () => { "value": "multipart/form-data;.*", }, ], + "initialExpireSeconds": 31536000, "initialRevalidateSeconds": 10, "srcRoute": "/variable-revalidate/revalidate-360-isr", }, diff --git a/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts index dce7b40eb0b80..641e156b701a2 100644 --- a/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts +++ b/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts @@ -33,7 +33,7 @@ describe('custom-cache-control', () => { expect(res.headers.get('cache-control')).toBe( isNextDev ? 'no-store, must-revalidate' - : 's-maxage=120, stale-while-revalidate' + : 's-maxage=120, stale-while-revalidate=31535880' ) } ) @@ -71,7 +71,7 @@ describe('custom-cache-control', () => { expect(res.headers.get('cache-control')).toBe( isNextDev ? 'no-store, must-revalidate' - : 's-maxage=120, stale-while-revalidate' + : 's-maxage=120, stale-while-revalidate=31535880' ) }) diff --git a/test/e2e/app-dir/ppr-full/ppr-full.test.ts b/test/e2e/app-dir/ppr-full/ppr-full.test.ts index 3b9aeb9b91980..701a1cd0bbbcd 100644 --- a/test/e2e/app-dir/ppr-full/ppr-full.test.ts +++ b/test/e2e/app-dir/ppr-full/ppr-full.test.ts @@ -132,7 +132,9 @@ describe('ppr-full', () => { expect(cacheControl).toEqual('no-store, must-revalidate') } else if (dynamic === false || dynamic === 'force-static') { expect(cacheControl).toEqual( - `s-maxage=${revalidate || '31536000'}, stale-while-revalidate` + revalidate === undefined + ? `s-maxage=31536000` + : `s-maxage=${revalidate}, stale-while-revalidate=${31536000 - revalidate}` ) } else { expect(cacheControl).toEqual( @@ -596,7 +598,7 @@ describe('ppr-full', () => { if (isNextStart) { expect(res.headers.get('cache-control')).toEqual( - 's-maxage=31536000, stale-while-revalidate' + 's-maxage=31536000' ) } @@ -665,7 +667,9 @@ describe('ppr-full', () => { ) } else { expect(res.headers.get('cache-control')).toEqual( - `s-maxage=${revalidate || '31536000'}, stale-while-revalidate` + revalidate === undefined + ? `s-maxage=31536000` + : `s-maxage=${revalidate}, stale-while-revalidate=${31536000 - revalidate}` ) } diff --git a/test/e2e/app-dir/use-cache/app/[id]/page.tsx b/test/e2e/app-dir/use-cache/app/[id]/page.tsx index 74f4ed6d08dc3..7ba0b38ddee35 100644 --- a/test/e2e/app-dir/use-cache/app/[id]/page.tsx +++ b/test/e2e/app-dir/use-cache/app/[id]/page.tsx @@ -1,5 +1,8 @@ +import { unstable_cacheLife } from 'next/cache' + async function getCachedRandom(n: number) { 'use cache' + unstable_cacheLife('weeks') return String(Math.ceil(Math.random() * n)) } @@ -11,5 +14,7 @@ export async function generateStaticParams() { } export default async function Page() { - return 'hit' + const value = getCachedRandom(1) + + return

{value}

} diff --git a/test/e2e/app-dir/use-cache/next.config.js b/test/e2e/app-dir/use-cache/next.config.js index 4364cab19bb93..fda5b419e8d43 100644 --- a/test/e2e/app-dir/use-cache/next.config.js +++ b/test/e2e/app-dir/use-cache/next.config.js @@ -9,6 +9,7 @@ const nextConfig = { frequent: { stale: 19, revalidate: 100, + expire: 250, }, }, cacheHandlers: { diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index 077e26c0be778..95299bfae9239 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -7,6 +7,7 @@ import { createRenderResumeDataCache, RenderResumeDataCache, } from 'next/dist/server/resume-data-cache/resume-data-cache' +import { PrerenderManifest } from 'next/dist/build' const GENERIC_RSC_ERROR = 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' @@ -370,21 +371,29 @@ describe('use-cache', () => { ]) }) - it('should match the expected revalidate config on the prerender manifest', async () => { - const prerenderManifest = JSON.parse( + it('should match the expected revalidate and expire configs on the prerender manifest', async () => { + const { version, routes, dynamicRoutes } = JSON.parse( await next.readFile('.next/prerender-manifest.json') - ) + ) as PrerenderManifest + + expect(version).toBe(4) + + // custom cache life profile "frequent" + expect(routes['/cache-life'].initialRevalidateSeconds).toBe(100) + expect(routes['/cache-life'].initialExpireSeconds).toBe(250) - expect(prerenderManifest.version).toBe(4) - expect( - prerenderManifest.routes['/cache-life'].initialRevalidateSeconds - ).toBe(100) + // default expireTime + expect(routes['/cache-fetch'].initialExpireSeconds).toBe(31536000) // The revalidate config from the fetch call should lower the revalidate // config for the page. - expect( - prerenderManifest.routes['/cache-tag'].initialRevalidateSeconds - ).toBe(42) + expect(routes['/cache-tag'].initialRevalidateSeconds).toBe(42) + + if (process.env.__NEXT_EXPERIMENTAL_PPR === 'true') { + // cache life profile "weeks" + expect(dynamicRoutes['/[id]'].fallbackRevalidate).toBe(604800) + expect(dynamicRoutes['/[id]'].fallbackExpire).toBe(2592000) + } }) it('should match the expected stale config in the page header', async () => { @@ -394,6 +403,23 @@ describe('use-cache', () => { expect(meta.headers['x-nextjs-stale-time']).toBe('19') }) + it('should send an SWR cache-control header based on the revalidate and expire values', async () => { + let response = await next.fetch('/cache-life') + + expect(response.headers.get('cache-control')).toBe( + // revalidate is set to 100, expire is set to 250 => SWR 150 + 's-maxage=100, stale-while-revalidate=150' + ) + + response = await next.fetch('/cache-fetch') + + expect(response.headers.get('cache-control')).toBe( + // revalidate is set to 900, expire is one year (31536000, default + // expireTime) => SWR 31535100 + 's-maxage=900, stale-while-revalidate=31535100' + ) + }) + it('should propagate unstable_cache tags correctly', async () => { const meta = JSON.parse( await next.readFile('.next/server/app/cache-tag.meta') diff --git a/test/e2e/prerender.test.ts b/test/e2e/prerender.test.ts index e48c8601df26f..c53b91e7f637e 100644 --- a/test/e2e/prerender.test.ts +++ b/test/e2e/prerender.test.ts @@ -106,84 +106,98 @@ describe('Prerender', () => { '/': { allowHeader, dataRoute: `/_next/data/${next.buildId}/index.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 2, srcRoute: null, }, '/blog/[post3]': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/[post3].json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: '/blog/[post]', }, '/blog/post-1': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post-1.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: '/blog/[post]', }, '/blog/post-2': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post-2.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: '/blog/[post]', }, '/blog/post-4': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post-4.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: '/blog/[post]', }, '/blog/post-1/comment-1': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post-1/comment-1.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 2, srcRoute: '/blog/[post]/[comment]', }, '/blog/post-2/comment-2': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post-2/comment-2.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 2, srcRoute: '/blog/[post]/[comment]', }, '/blog/post.1': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog/post.1.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: '/blog/[post]', }, '/catchall-explicit/another/value': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/another/value.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, '/catchall-explicit/first': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/first.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, '/catchall-explicit/hello/another': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/hello/another.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, '/catchall-explicit/second': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/second.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, '/catchall-explicit/[first]/[second]': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/[first]/[second].json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, '/catchall-explicit/[third]/[fourth]': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall-explicit/[third]/[fourth].json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall-explicit/[...slug]', }, @@ -208,6 +222,7 @@ describe('Prerender', () => { '/another': { allowHeader, dataRoute: `/_next/data/${next.buildId}/another.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: null, }, @@ -232,12 +247,14 @@ describe('Prerender', () => { '/blocking-fallback-some/a': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blocking-fallback-some/a.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/blocking-fallback-some/[slug]', }, '/blocking-fallback-some/b': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blocking-fallback-some/b.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/blocking-fallback-some/[slug]', }, @@ -250,12 +267,14 @@ describe('Prerender', () => { '/blocking-fallback/test-errors-1': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blocking-fallback/test-errors-1.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/blocking-fallback/[slug]', }, '/blog': { allowHeader, dataRoute: `/_next/data/${next.buildId}/blog.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 10, srcRoute: null, }, @@ -316,24 +335,28 @@ describe('Prerender', () => { '/catchall/another/value': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall/another/value.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall/[...slug]', }, '/catchall/first': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall/first.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall/[...slug]', }, '/catchall/second': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall/second.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall/[...slug]', }, '/catchall/hello/another': { allowHeader, dataRoute: `/_next/data/${next.buildId}/catchall/hello/another.json`, + initialExpireSeconds: 31536000, initialRevalidateSeconds: 1, srcRoute: '/catchall/[...slug]', }, @@ -650,7 +673,7 @@ describe('Prerender', () => { expect(initialRes.headers.get('cache-control')).toBe( isDeploy ? 'public, max-age=0, must-revalidate' - : 's-maxage=2, stale-while-revalidate' + : 's-maxage=2, stale-while-revalidate=31535998' ) }) } @@ -1301,9 +1324,7 @@ describe('Prerender', () => { it('should use correct caching headers for a no-revalidate page', async () => { const initialRes = await fetchViaHTTP(next.url, '/something') expect(initialRes.headers.get('cache-control')).toBe( - isDeploy - ? 'public, max-age=0, must-revalidate' - : 's-maxage=31536000, stale-while-revalidate' + isDeploy ? 'public, max-age=0, must-revalidate' : 's-maxage=31536000' ) const initialHtml = await initialRes.text() expect(initialHtml).toMatch(/hello.*?world/) diff --git a/test/integration/not-found-revalidate/test/index.test.js b/test/integration/not-found-revalidate/test/index.test.js index 98ff9ea8be548..57ddb31d205e4 100644 --- a/test/integration/not-found-revalidate/test/index.test.js +++ b/test/integration/not-found-revalidate/test/index.test.js @@ -82,7 +82,7 @@ const runTests = () => { let $ = cheerio.load(await res.text()) expect(res.headers.get('cache-control')).toBe( - `s-maxage=1, stale-while-revalidate` + `s-maxage=1, stale-while-revalidate=31535999` ) expect(res.status).toBe(404) expect(JSON.parse($('#props').text()).notFound).toBe(true) @@ -92,7 +92,7 @@ const runTests = () => { $ = cheerio.load(await res.text()) expect(res.headers.get('cache-control')).toBe( - `s-maxage=1, stale-while-revalidate` + `s-maxage=1, stale-while-revalidate=31535999` ) expect(res.status).toBe(404) expect(JSON.parse($('#props').text()).notFound).toBe(true) @@ -103,7 +103,7 @@ const runTests = () => { const props = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props.found).toBe(true) @@ -116,7 +116,7 @@ const runTests = () => { const props2 = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props2.found).toBe(true) @@ -129,7 +129,7 @@ const runTests = () => { const props3 = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props3.found).toBe(true) @@ -148,7 +148,7 @@ const runTests = () => { let $ = cheerio.load(await res.text()) expect(res.headers.get('cache-control')).toBe( - `s-maxage=1, stale-while-revalidate` + `s-maxage=1, stale-while-revalidate=31535999` ) expect(res.status).toBe(404) expect(JSON.parse($('#props').text()).notFound).toBe(true) @@ -159,7 +159,7 @@ const runTests = () => { const props = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props.found).toBe(true) @@ -172,7 +172,7 @@ const runTests = () => { const props2 = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props2.found).toBe(true) @@ -185,7 +185,7 @@ const runTests = () => { const props3 = JSON.parse($('#props').text()) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) expect(res.status).toBe(200) expect(props3.found).toBe(true) diff --git a/test/production/prerender-prefetch/index.test.ts b/test/production/prerender-prefetch/index.test.ts index 4317ce74e8d0a..0518d9b98b46b 100644 --- a/test/production/prerender-prefetch/index.test.ts +++ b/test/production/prerender-prefetch/index.test.ts @@ -300,6 +300,13 @@ describe('Prerender prefetch', () => { pages: new FileRef(join(__dirname, 'app/pages')), }, dependencies: {}, + env: { + // Simulate that a CDN has consumed the SWR cache-control header, + // otherwise the browser will cache responses and which messes with + // the expectations in this test. + // See https://github.com/vercel/next.js/pull/70674 for context. + NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL: '1', + }, }) }) afterAll(() => next.destroy()) @@ -319,6 +326,13 @@ describe('Prerender prefetch', () => { }, }, dependencies: {}, + env: { + // Simulate that a CDN has consumed the SWR cache-control header, + // otherwise the browser will cache responses and which messes with + // the expectations in this test. + // See https://github.com/vercel/next.js/pull/70674 for context. + NEXT_PRIVATE_CDN_CONSUMED_SWR_CACHE_CONTROL: '1', + }, }) }) afterAll(() => next.destroy()) diff --git a/test/production/standalone-mode/required-server-files/pages/gssp.js b/test/production/standalone-mode/required-server-files/pages/gssp.js index 6b8b69c22521f..bd2019da5181b 100644 --- a/test/production/standalone-mode/required-server-files/pages/gssp.js +++ b/test/production/standalone-mode/required-server-files/pages/gssp.js @@ -5,7 +5,7 @@ import next from 'next' // force a warning during `next build` import { useRouter } from 'next/router' export async function getServerSideProps({ res }) { - res.setHeader('cache-control', 's-maxage=1, stale-while-revalidate') + res.setHeader('cache-control', 's-maxage=1, stale-while-revalidate=31535999') const data = await fs.promises.readFile( path.join(process.cwd(), 'data.txt'), 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 59af01b6d93e1..9dd142dcfc1ee 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 @@ -114,9 +114,7 @@ describe('required server files app router', () => { }, }) expect(res.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=31536000, stale-while-revalidate' - ) + expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') }) it('should handle optional catchall', async () => { @@ -176,7 +174,7 @@ describe('required server files app router', () => { }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( - 's-maxage=3600, stale-while-revalidate' + 's-maxage=3600, stale-while-revalidate=31532400' ) }) diff --git a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts index 12cd22fbb3fbb..582b760a549d7 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files-i18n.test.ts @@ -178,7 +178,7 @@ describe('required server files i18n', () => { }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) await waitFor(2000) @@ -189,7 +189,7 @@ describe('required server files i18n', () => { }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) }) @@ -201,7 +201,7 @@ describe('required server files i18n', () => { }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) await next.patchFile('standalone/data.txt', 'hide') @@ -213,7 +213,7 @@ describe('required server files i18n', () => { expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) }) diff --git a/test/production/standalone-mode/required-server-files/required-server-files.test.ts b/test/production/standalone-mode/required-server-files/required-server-files.test.ts index f8bf81f087eca..8d49d1dc91e04 100644 --- a/test/production/standalone-mode/required-server-files/required-server-files.test.ts +++ b/test/production/standalone-mode/required-server-files/required-server-files.test.ts @@ -218,13 +218,13 @@ describe('required server files', () => { case: 'redirect no revalidate', path: '/optional-ssg/redirect-1', dest: '/somewhere', - cacheControl: 's-maxage=31536000, stale-while-revalidate', + cacheControl: 's-maxage=31536000', }, { case: 'redirect with revalidate', path: '/optional-ssg/redirect-2', dest: '/somewhere-else', - cacheControl: 's-maxage=5, stale-while-revalidate', + cacheControl: 's-maxage=5, stale-while-revalidate=31535995', }, ])( `should have correct cache-control for $case`, @@ -295,13 +295,13 @@ describe('required server files', () => { case: 'notFound no revalidate', path: '/optional-ssg/not-found-1', dest: '/somewhere', - cacheControl: 's-maxage=31536000, stale-while-revalidate', + cacheControl: 's-maxage=31536000', }, { case: 'notFound with revalidate', path: '/optional-ssg/not-found-2', dest: '/somewhere-else', - cacheControl: 's-maxage=5, stale-while-revalidate', + cacheControl: 's-maxage=5, stale-while-revalidate=31535995', }, ])( `should have correct cache-control for $case`, @@ -327,9 +327,7 @@ describe('required server files', () => { it('should have the correct cache-control for props with no revalidate', async () => { const res = await fetchViaHTTP(appPort, '/optional-ssg/props-no-revalidate') expect(res.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=31536000, stale-while-revalidate' - ) + expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') const $ = cheerio.load(await res.text()) expect(JSON.parse($('#props').text()).params).toEqual({ rest: ['props-no-revalidate'], @@ -341,9 +339,7 @@ describe('required server files', () => { undefined ) expect(dataRes.status).toBe(200) - expect(res.headers.get('cache-control')).toBe( - 's-maxage=31536000, stale-while-revalidate' - ) + expect(res.headers.get('cache-control')).toBe('s-maxage=31536000') expect((await dataRes.json()).pageProps.params).toEqual({ rest: ['props-no-revalidate'], }) @@ -525,7 +521,7 @@ describe('required server files', () => { }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) await waitFor(2000) @@ -536,7 +532,7 @@ describe('required server files', () => { }) expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) }) @@ -548,7 +544,7 @@ describe('required server files', () => { }) expect(res.status).toBe(200) expect(res.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) await next.patchFile('standalone/data.txt', 'hide') @@ -560,7 +556,7 @@ describe('required server files', () => { expect(res2.status).toBe(404) expect(res2.headers.get('cache-control')).toBe( - 's-maxage=1, stale-while-revalidate' + 's-maxage=1, stale-while-revalidate=31535999' ) })