Skip to content

Commit e7b195c

Browse files
committed
Propagate expire time to cache-control header and prerender manifest
For the `stale-while-revalidate` value in the `Cache-Control` response header, Next.js currently uses the configured [`expireTime`](https://nextjs.org/docs/app/api-reference/config/next-config-js/expireTime), which defaults to one year. With the introduction of `"use cache"` and granular [cache lifetimes](https://nextjs.org/docs/app/api-reference/functions/cacheLife), users can now set expire times per route — based on the minimum expire time of all cached functions used by that route. This PR updates the `stale-while-revalidate` value to reflect the route's cache lifetime, using the difference between the minimum expire time and the minimum revalidate time. If no explicit expire time is defined in cache profiles, the globally configured `expireTime` is used. Additionally, the collected expire time is added to the prerender manifest as `initialExpireSeconds`, analogous to `initialRevalidateSeconds`.
1 parent f984d1f commit e7b195c

34 files changed

+494
-386
lines changed

packages/next/errors.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -644,5 +644,10 @@
644644
"643": "Invalid \"devIndicator.position\" provided, expected one of %s, received %s",
645645
"644": "@rspack/core is not available. Please make sure the appropriate Next.js plugin is installed.",
646646
"645": "@rspack/plugin-react-refresh is not available. Please make sure the appropriate Next.js plugin is installed.",
647-
"646": "No span found for compilation"
647+
"646": "No span found for compilation",
648+
"647": "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.",
649+
"648": "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.",
650+
"649": "revalidate must be a number for image-cache",
651+
"650": "Pass `Infinity` instead of `false` if you want to cache on the server forever without checking with the origin.",
652+
"651": "SSG should not return an image cache value"
648653
}

packages/next/src/build/index.ts

+77-31
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { PagesManifest } from './webpack/plugins/pages-manifest-plugin'
33
import type { ExportPathMap, NextConfigComplete } from '../server/config-shared'
44
import type { MiddlewareManifest } from './webpack/plugins/middleware-plugin'
55
import type { ActionManifest } from './webpack/plugins/flight-client-entry-plugin'
6-
import type { Revalidate } from '../server/lib/revalidate'
6+
import type { CacheControl, Revalidate } from '../server/lib/cache-control'
77

88
import '../lib/setup-exception-listeners'
99

@@ -208,7 +208,7 @@ import { turbopackBuild } from './turbopack-build'
208208

209209
type Fallback = null | boolean | string
210210

211-
export interface SsgRoute {
211+
export interface PrerenderManifestRoute {
212212
dataRoute: string | null
213213
experimentalBypassFor?: RouteHas[]
214214

@@ -223,10 +223,20 @@ export interface SsgRoute {
223223
initialStatus?: number
224224

225225
/**
226-
* The revalidation configuration for this route.
226+
* The revalidate value for this route. This might be inferred from:
227+
* - route segment configs
228+
* - fetch calls
229+
* - unstable_cache
230+
* - "use cache"
227231
*/
228232
initialRevalidateSeconds: Revalidate
229233

234+
/**
235+
* The expire value for this route, which is inferred from the "use cache"
236+
* functions that are used by the route, or the expireTime config.
237+
*/
238+
initialExpireSeconds: number | undefined
239+
230240
/**
231241
* The prefetch data route associated with this page. If not defined, this
232242
* page does not support prefetching.
@@ -257,7 +267,7 @@ export interface SsgRoute {
257267
allowHeader: string[]
258268
}
259269

260-
export interface DynamicSsgRoute {
270+
export interface DynamicPrerenderManifestRoute {
261271
dataRoute: string | null
262272
dataRouteRegex: string | null
263273
experimentalBypassFor?: RouteHas[]
@@ -327,8 +337,8 @@ const ALLOWED_HEADERS: string[] = [
327337

328338
export type PrerenderManifest = {
329339
version: 4
330-
routes: { [route: string]: SsgRoute }
331-
dynamicRoutes: { [route: string]: DynamicSsgRoute }
340+
routes: { [route: string]: PrerenderManifestRoute }
341+
dynamicRoutes: { [route: string]: DynamicPrerenderManifestRoute }
332342
notFoundRoutes: string[]
333343
preview: __ApiPreviewProps
334344
}
@@ -2135,7 +2145,7 @@ export default async function build(
21352145
isRoutePPREnabled,
21362146
isHybridAmp,
21372147
ssgPageRoutes,
2138-
initialRevalidateSeconds: false,
2148+
initialCacheControl: undefined,
21392149
runtime: pageRuntime,
21402150
pageDuration: undefined,
21412151
ssgPageDurations: undefined,
@@ -2703,6 +2713,28 @@ export default async function build(
27032713
// If there was no result, there's nothing more to do.
27042714
if (!exportResult) return
27052715

2716+
const getCacheControl = (
2717+
exportPath: string,
2718+
defaultRevalidate: Revalidate = false
2719+
): CacheControl => {
2720+
const cacheControl =
2721+
exportResult.byPath.get(exportPath)?.cacheControl
2722+
2723+
if (!cacheControl) {
2724+
return { revalidate: defaultRevalidate }
2725+
}
2726+
2727+
if (
2728+
cacheControl.revalidate !== false &&
2729+
cacheControl.revalidate > 0 &&
2730+
cacheControl.expire === undefined
2731+
) {
2732+
cacheControl.expire = config.expireTime
2733+
}
2734+
2735+
return cacheControl
2736+
}
2737+
27062738
if (debugOutput || process.env.NEXT_SSG_FETCH_METRICS === '1') {
27072739
recordFetchMetrics(exportResult)
27082740
}
@@ -2734,7 +2766,7 @@ export default async function build(
27342766

27352767
let hasRevalidateZero =
27362768
appConfig.revalidate === 0 ||
2737-
exportResult.byPath.get(page)?.revalidate === 0
2769+
getCacheControl(page).revalidate === 0
27382770

27392771
if (hasRevalidateZero && pageInfos.get(page)?.isStatic) {
27402772
// if the page was marked as being static, but it contains dynamic data
@@ -2854,26 +2886,40 @@ export default async function build(
28542886
if (route.pathname === UNDERSCORE_NOT_FOUND_ROUTE) continue
28552887

28562888
const {
2857-
revalidate = appConfig.revalidate ?? false,
28582889
metadata = {},
28592890
hasEmptyPrelude,
28602891
hasPostponed,
28612892
} = exportResult.byPath.get(route.pathname) ?? {}
28622893

2894+
const cacheControl = getCacheControl(
2895+
route.pathname,
2896+
appConfig.revalidate
2897+
)
2898+
28632899
pageInfos.set(route.pathname, {
28642900
...(pageInfos.get(route.pathname) as PageInfo),
28652901
hasPostponed,
28662902
hasEmptyPrelude,
2903+
// TODO: Enable the following line to show "ISR" status in build
2904+
// output. Requires different presentation to also work for app
2905+
// router routes.
2906+
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2907+
// initialCacheControl: cacheControl,
28672908
})
28682909

28692910
// update the page (eg /blog/[slug]) to also have the postpone metadata
28702911
pageInfos.set(page, {
28712912
...(pageInfos.get(page) as PageInfo),
28722913
hasPostponed,
28732914
hasEmptyPrelude,
2915+
// TODO: Enable the following line to show "ISR" status in build
2916+
// output. Requires different presentation to also work for app
2917+
// router routes.
2918+
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2919+
// initialCacheControl: cacheControl,
28742920
})
28752921

2876-
if (revalidate !== 0) {
2922+
if (cacheControl.revalidate !== 0) {
28772923
const normalizedRoute = normalizePagePath(route.pathname)
28782924

28792925
let dataRoute: string | null
@@ -2906,7 +2952,8 @@ export default async function build(
29062952
: undefined,
29072953
experimentalPPR: isRoutePPREnabled,
29082954
experimentalBypassFor: bypassFor,
2909-
initialRevalidateSeconds: revalidate,
2955+
initialRevalidateSeconds: cacheControl.revalidate,
2956+
initialExpireSeconds: cacheControl.expire,
29102957
srcRoute: page,
29112958
dataRoute,
29122959
prefetchDataRoute,
@@ -2943,8 +2990,11 @@ export default async function build(
29432990
for (const route of dynamicRoutes) {
29442991
const normalizedRoute = normalizePagePath(route.pathname)
29452992

2946-
const { metadata, revalidate } =
2947-
exportResult.byPath.get(route.pathname) ?? {}
2993+
const metadata = exportResult.byPath.get(
2994+
route.pathname
2995+
)?.metadata
2996+
2997+
const cacheControl = getCacheControl(route.pathname)
29482998

29492999
let dataRoute: string | null = null
29503000
if (!isAppRouteHandler) {
@@ -2992,7 +3042,7 @@ export default async function build(
29923042
// found, mark that we should keep the shell forever (`false`).
29933043
let fallbackRevalidate: Revalidate | undefined =
29943044
isRoutePPREnabled && fallbackMode === FallbackMode.PRERENDER
2995-
? revalidate ?? false
3045+
? cacheControl.revalidate
29963046
: undefined
29973047

29983048
const fallback: Fallback = fallbackModeToFallbackField(
@@ -3267,10 +3317,11 @@ export default async function build(
32673317
for (const locale of i18n.locales) {
32683318
const localePage = `/${locale}${page === '/' ? '' : page}`
32693319

3320+
const cacheControl = getCacheControl(localePage)
3321+
32703322
prerenderManifest.routes[localePage] = {
3271-
initialRevalidateSeconds:
3272-
exportResult.byPath.get(localePage)?.revalidate ??
3273-
false,
3323+
initialRevalidateSeconds: cacheControl.revalidate,
3324+
initialExpireSeconds: cacheControl.expire,
32743325
experimentalPPR: undefined,
32753326
renderingMode: undefined,
32763327
srcRoute: null,
@@ -3284,9 +3335,11 @@ export default async function build(
32843335
}
32853336
}
32863337
} else {
3338+
const cacheControl = getCacheControl(page)
3339+
32873340
prerenderManifest.routes[page] = {
3288-
initialRevalidateSeconds:
3289-
exportResult.byPath.get(page)?.revalidate ?? false,
3341+
initialRevalidateSeconds: cacheControl.revalidate,
3342+
initialExpireSeconds: cacheControl.expire,
32903343
experimentalPPR: undefined,
32913344
renderingMode: undefined,
32923345
srcRoute: null,
@@ -3300,10 +3353,8 @@ export default async function build(
33003353
allowHeader: ALLOWED_HEADERS,
33013354
}
33023355
}
3303-
// Set Page Revalidation Interval
33043356
if (pageInfo) {
3305-
pageInfo.initialRevalidateSeconds =
3306-
exportResult.byPath.get(page)?.revalidate ?? false
3357+
pageInfo.initialCacheControl = getCacheControl(page)
33073358
}
33083359
} else {
33093360
// For a dynamic SSG page, we did not copy its data exports and only
@@ -3349,15 +3400,11 @@ export default async function build(
33493400
)
33503401
}
33513402

3352-
const initialRevalidateSeconds =
3353-
exportResult.byPath.get(route.pathname)?.revalidate ?? false
3354-
3355-
if (typeof initialRevalidateSeconds === 'undefined') {
3356-
throw new Error("Invariant: page wasn't built")
3357-
}
3403+
const cacheControl = getCacheControl(route.pathname)
33583404

33593405
prerenderManifest.routes[route.pathname] = {
3360-
initialRevalidateSeconds,
3406+
initialRevalidateSeconds: cacheControl.revalidate,
3407+
initialExpireSeconds: cacheControl.expire,
33613408
experimentalPPR: undefined,
33623409
renderingMode: undefined,
33633410
srcRoute: page,
@@ -3371,9 +3418,8 @@ export default async function build(
33713418
allowHeader: ALLOWED_HEADERS,
33723419
}
33733420

3374-
// Set route Revalidation Interval
33753421
if (pageInfo) {
3376-
pageInfo.initialRevalidateSeconds = initialRevalidateSeconds
3422+
pageInfo.initialCacheControl = cacheControl
33773423
}
33783424
}
33793425
}

packages/next/src/build/utils.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { collectRootParamKeys } from './segment-config/app/collect-root-param-ke
8181
import { buildAppStaticPaths } from './static-paths/app'
8282
import { buildPagesStaticPaths } from './static-paths/pages'
8383
import type { PrerenderedRoute } from './static-paths/types'
84+
import type { CacheControl } from '../server/lib/cache-control'
8485

8586
export type ROUTER_TYPE = 'pages' | 'app'
8687

@@ -346,7 +347,8 @@ export interface PageInfo {
346347
*/
347348
isRoutePPREnabled: boolean
348349
ssgPageRoutes: string[] | null
349-
initialRevalidateSeconds: number | false
350+
// TODO: initialCacheControl should be set per prerendered route.
351+
initialCacheControl: CacheControl | undefined
350352
pageDuration: number | undefined
351353
ssgPageDurations: number[] | undefined
352354
runtime: ServerRuntime
@@ -510,12 +512,14 @@ export async function printTreeView(
510512

511513
usedSymbols.add(symbol)
512514

513-
if (pageInfo?.initialRevalidateSeconds) usedSymbols.add('ISR')
515+
// TODO: Rework this to be usable for app router routes.
516+
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
517+
if (pageInfo?.initialCacheControl?.revalidate) usedSymbols.add('ISR')
514518

515519
messages.push([
516520
`${border} ${symbol} ${
517-
pageInfo?.initialRevalidateSeconds
518-
? `${item} (ISR: ${pageInfo?.initialRevalidateSeconds} Seconds)`
521+
pageInfo?.initialCacheControl
522+
? `${item} (ISR: ${pageInfo?.initialCacheControl.revalidate} Seconds)`
519523
: item
520524
}${
521525
totalDuration > MIN_DURATION

packages/next/src/export/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -636,8 +636,8 @@ async function exportAppImpl(
636636
if (options.buildExport) {
637637
// Update path info by path.
638638
const info = collector.byPath.get(path) ?? {}
639-
if (typeof result.revalidate !== 'undefined') {
640-
info.revalidate = result.revalidate
639+
if (result.cacheControl) {
640+
info.cacheControl = result.cacheControl
641641
}
642642
if (typeof result.metadata !== 'undefined') {
643643
info.metadata = result.metadata

packages/next/src/export/routes/app-page.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export async function exportAppPage(
139139
const { metadata } = result
140140
const {
141141
flightData,
142-
revalidate = false,
142+
cacheControl = { revalidate: false },
143143
postponed,
144144
fetchTags,
145145
fetchMetrics,
@@ -151,23 +151,23 @@ export async function exportAppPage(
151151
throw new Error('Invariant: page postponed without PPR being enabled')
152152
}
153153

154-
if (revalidate === 0) {
154+
if (cacheControl.revalidate === 0) {
155155
if (isDynamicError) {
156156
throw new Error(
157157
`Page with dynamic = "error" encountered dynamic data method on ${path}.`
158158
)
159159
}
160160
const { staticBailoutInfo = {} } = metadata
161161

162-
if (revalidate === 0 && debugOutput && staticBailoutInfo?.description) {
162+
if (debugOutput && staticBailoutInfo?.description) {
163163
logDynamicUsageWarning({
164164
path,
165165
description: staticBailoutInfo.description,
166166
stack: staticBailoutInfo.stack,
167167
})
168168
}
169169

170-
return { revalidate: 0, fetchMetrics }
170+
return { cacheControl, fetchMetrics }
171171
}
172172

173173
// If page data isn't available, it means that the page couldn't be rendered
@@ -270,7 +270,7 @@ export async function exportAppPage(
270270
metadata: hasNextSupport ? meta : undefined,
271271
hasEmptyPrelude: Boolean(postponed) && html === '',
272272
hasPostponed: Boolean(postponed),
273-
revalidate,
273+
cacheControl,
274274
fetchMetrics,
275275
}
276276
} catch (err) {
@@ -298,7 +298,7 @@ export async function exportAppPage(
298298
})
299299
}
300300

301-
return { revalidate: 0, fetchMetrics }
301+
return { cacheControl: { revalidate: 0 }, fetchMetrics }
302302
}
303303
}
304304

0 commit comments

Comments
 (0)