Skip to content

Commit 3ae9d38

Browse files
authored
Propagate expire time to cache-control header and prerender manifest (#76207)
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 applies to all routes, and 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` (and `fallbackExpire`), analogous to `initialRevalidateSeconds` (or `fallbackRevalidate`). closes NAR-100
1 parent 9483a37 commit 3ae9d38

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+660
-440
lines changed

packages/next/errors.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -652,5 +652,10 @@
652652
"651": "Unexpected module type %s",
653653
"652": "Expected cached value for cache key %s not to be a %s kind, got \"FETCH\" instead.",
654654
"653": "Expected cached value for cache key %s to be a \"FETCH\" kind, got %s instead.",
655-
"654": "Segment Cache experiment is not enabled. This is a bug in Next.js."
655+
"654": "Segment Cache experiment is not enabled. This is a bug in Next.js.",
656+
"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.",
657+
"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.",
658+
"657": "revalidate must be a number for image-cache",
659+
"658": "Pass `Infinity` instead of `false` if you want to cache on the server forever without checking with the origin.",
660+
"659": "SSG should not return an image cache value"
656661
}

packages/next/src/build/index.ts

+93-36
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

@@ -209,7 +209,7 @@ import { turbopackBuild } from './turbopack-build'
209209

210210
type Fallback = null | boolean | string
211211

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

@@ -224,10 +224,20 @@ export interface SsgRoute {
224224
initialStatus?: number
225225

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

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

261-
export interface DynamicSsgRoute {
271+
export interface DynamicPrerenderManifestRoute {
262272
dataRoute: string | null
263273
dataRouteRegex: string | null
264274
experimentalBypassFor?: RouteHas[]
@@ -270,6 +280,11 @@ export interface DynamicSsgRoute {
270280
*/
271281
fallbackRevalidate: Revalidate | undefined
272282

283+
/**
284+
* When defined, it describes the expire configuration for the fallback route.
285+
*/
286+
fallbackExpire: number | undefined
287+
273288
/**
274289
* The headers that should used when serving the fallback.
275290
*/
@@ -328,8 +343,8 @@ const ALLOWED_HEADERS: string[] = [
328343

329344
export type PrerenderManifest = {
330345
version: 4
331-
routes: { [route: string]: SsgRoute }
332-
dynamicRoutes: { [route: string]: DynamicSsgRoute }
346+
routes: { [route: string]: PrerenderManifestRoute }
347+
dynamicRoutes: { [route: string]: DynamicPrerenderManifestRoute }
333348
notFoundRoutes: string[]
334349
preview: __ApiPreviewProps
335350
}
@@ -2128,7 +2143,7 @@ export default async function build(
21282143
isRoutePPREnabled,
21292144
isHybridAmp,
21302145
ssgPageRoutes,
2131-
initialRevalidateSeconds: false,
2146+
initialCacheControl: undefined,
21322147
runtime: pageRuntime,
21332148
pageDuration: undefined,
21342149
ssgPageDurations: undefined,
@@ -2708,6 +2723,31 @@ export default async function build(
27082723
// If there was no result, there's nothing more to do.
27092724
if (!exportResult) return
27102725

2726+
const getCacheControl = (
2727+
exportPath: string,
2728+
defaultRevalidate: Revalidate = false
2729+
): CacheControl => {
2730+
const cacheControl =
2731+
exportResult.byPath.get(exportPath)?.cacheControl
2732+
2733+
if (!cacheControl) {
2734+
return { revalidate: defaultRevalidate, expire: undefined }
2735+
}
2736+
2737+
if (
2738+
cacheControl.revalidate !== false &&
2739+
cacheControl.revalidate > 0 &&
2740+
cacheControl.expire === undefined
2741+
) {
2742+
return {
2743+
revalidate: cacheControl.revalidate,
2744+
expire: config.expireTime,
2745+
}
2746+
}
2747+
2748+
return cacheControl
2749+
}
2750+
27112751
if (debugOutput || process.env.NEXT_SSG_FETCH_METRICS === '1') {
27122752
recordFetchMetrics(exportResult)
27132753
}
@@ -2739,7 +2779,7 @@ export default async function build(
27392779

27402780
let hasRevalidateZero =
27412781
appConfig.revalidate === 0 ||
2742-
exportResult.byPath.get(page)?.revalidate === 0
2782+
getCacheControl(page).revalidate === 0
27432783

27442784
if (hasRevalidateZero && pageInfos.get(page)?.isStatic) {
27452785
// if the page was marked as being static, but it contains dynamic data
@@ -2855,26 +2895,40 @@ export default async function build(
28552895
if (route.pathname === UNDERSCORE_NOT_FOUND_ROUTE) continue
28562896

28572897
const {
2858-
revalidate = appConfig.revalidate ?? false,
28592898
metadata = {},
28602899
hasEmptyPrelude,
28612900
hasPostponed,
28622901
} = exportResult.byPath.get(route.pathname) ?? {}
28632902

2903+
const cacheControl = getCacheControl(
2904+
route.pathname,
2905+
appConfig.revalidate
2906+
)
2907+
28642908
pageInfos.set(route.pathname, {
28652909
...(pageInfos.get(route.pathname) as PageInfo),
28662910
hasPostponed,
28672911
hasEmptyPrelude,
2912+
// TODO: Enable the following line to show "ISR" status in build
2913+
// output. Requires different presentation to also work for app
2914+
// router routes.
2915+
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2916+
// initialCacheControl: cacheControl,
28682917
})
28692918

28702919
// update the page (eg /blog/[slug]) to also have the postpone metadata
28712920
pageInfos.set(page, {
28722921
...(pageInfos.get(page) as PageInfo),
28732922
hasPostponed,
28742923
hasEmptyPrelude,
2924+
// TODO: Enable the following line to show "ISR" status in build
2925+
// output. Requires different presentation to also work for app
2926+
// router routes.
2927+
// See https://vercel.slack.com/archives/C02CDC2ALJH/p1739552318644119?thread_ts=1739550179.439319&cid=C02CDC2ALJH
2928+
// initialCacheControl: cacheControl,
28752929
})
28762930

2877-
if (revalidate !== 0) {
2931+
if (cacheControl.revalidate !== 0) {
28782932
const normalizedRoute = normalizePagePath(route.pathname)
28792933

28802934
let dataRoute: string | null
@@ -2907,7 +2961,8 @@ export default async function build(
29072961
: undefined,
29082962
experimentalPPR: isRoutePPREnabled,
29092963
experimentalBypassFor: bypassFor,
2910-
initialRevalidateSeconds: revalidate,
2964+
initialRevalidateSeconds: cacheControl.revalidate,
2965+
initialExpireSeconds: cacheControl.expire,
29112966
srcRoute: page,
29122967
dataRoute,
29132968
prefetchDataRoute,
@@ -2944,8 +2999,11 @@ export default async function build(
29442999
for (const route of dynamicRoutes) {
29453000
const normalizedRoute = normalizePagePath(route.pathname)
29463001

2947-
const { metadata, revalidate } =
2948-
exportResult.byPath.get(route.pathname) ?? {}
3002+
const metadata = exportResult.byPath.get(
3003+
route.pathname
3004+
)?.metadata
3005+
3006+
const cacheControl = getCacheControl(route.pathname)
29493007

29503008
let dataRoute: string | null = null
29513009
if (!isAppRouteHandler) {
@@ -2988,12 +3046,13 @@ export default async function build(
29883046
const fallbackMode =
29893047
route.fallbackMode ?? FallbackMode.NOT_FOUND
29903048

2991-
// When we're configured to serve a prerender, we should use the
2992-
// fallback revalidate from the export result. If it can't be
2993-
// found, mark that we should keep the shell forever (`false`).
2994-
let fallbackRevalidate: Revalidate | undefined =
3049+
// When the route is configured to serve a prerender, we should
3050+
// use the cache control from the export result. If it can't be
3051+
// found, mark that we should keep the shell forever
3052+
// (revalidate: `false` via `getCacheControl()`).
3053+
const fallbackCacheControl =
29953054
isRoutePPREnabled && fallbackMode === FallbackMode.PRERENDER
2996-
? revalidate ?? false
3055+
? cacheControl
29973056
: undefined
29983057

29993058
const fallback: Fallback = fallbackModeToFallbackField(
@@ -3023,7 +3082,8 @@ export default async function build(
30233082
),
30243083
dataRoute,
30253084
fallback,
3026-
fallbackRevalidate,
3085+
fallbackRevalidate: fallbackCacheControl?.revalidate,
3086+
fallbackExpire: fallbackCacheControl?.expire,
30273087
fallbackStatus: meta.status,
30283088
fallbackHeaders: meta.headers,
30293089
fallbackRootParams: route.fallbackRootParams,
@@ -3268,10 +3328,11 @@ export default async function build(
32683328
for (const locale of i18n.locales) {
32693329
const localePage = `/${locale}${page === '/' ? '' : page}`
32703330

3331+
const cacheControl = getCacheControl(localePage)
3332+
32713333
prerenderManifest.routes[localePage] = {
3272-
initialRevalidateSeconds:
3273-
exportResult.byPath.get(localePage)?.revalidate ??
3274-
false,
3334+
initialRevalidateSeconds: cacheControl.revalidate,
3335+
initialExpireSeconds: cacheControl.expire,
32753336
experimentalPPR: undefined,
32763337
renderingMode: undefined,
32773338
srcRoute: null,
@@ -3285,9 +3346,11 @@ export default async function build(
32853346
}
32863347
}
32873348
} else {
3349+
const cacheControl = getCacheControl(page)
3350+
32883351
prerenderManifest.routes[page] = {
3289-
initialRevalidateSeconds:
3290-
exportResult.byPath.get(page)?.revalidate ?? false,
3352+
initialRevalidateSeconds: cacheControl.revalidate,
3353+
initialExpireSeconds: cacheControl.expire,
32913354
experimentalPPR: undefined,
32923355
renderingMode: undefined,
32933356
srcRoute: null,
@@ -3301,10 +3364,8 @@ export default async function build(
33013364
allowHeader: ALLOWED_HEADERS,
33023365
}
33033366
}
3304-
// Set Page Revalidation Interval
33053367
if (pageInfo) {
3306-
pageInfo.initialRevalidateSeconds =
3307-
exportResult.byPath.get(page)?.revalidate ?? false
3368+
pageInfo.initialCacheControl = getCacheControl(page)
33083369
}
33093370
} else {
33103371
// For a dynamic SSG page, we did not copy its data exports and only
@@ -3350,15 +3411,11 @@ export default async function build(
33503411
)
33513412
}
33523413

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

33603416
prerenderManifest.routes[route.pathname] = {
3361-
initialRevalidateSeconds,
3417+
initialRevalidateSeconds: cacheControl.revalidate,
3418+
initialExpireSeconds: cacheControl.expire,
33623419
experimentalPPR: undefined,
33633420
renderingMode: undefined,
33643421
srcRoute: page,
@@ -3372,9 +3429,8 @@ export default async function build(
33723429
allowHeader: ALLOWED_HEADERS,
33733430
}
33743431

3375-
// Set route Revalidation Interval
33763432
if (pageInfo) {
3377-
pageInfo.initialRevalidateSeconds = initialRevalidateSeconds
3433+
pageInfo.initialCacheControl = cacheControl
33783434
}
33793435
}
33803436
}
@@ -3476,6 +3532,7 @@ export default async function build(
34763532
? `${normalizedRoute}.html`
34773533
: false,
34783534
fallbackRevalidate: undefined,
3535+
fallbackExpire: undefined,
34793536
fallbackSourceRoute: undefined,
34803537
fallbackRootParams: undefined,
34813538
dataRouteRegex: normalizeRouteRegex(

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
@@ -520,12 +522,14 @@ export async function printTreeView(
520522

521523
usedSymbols.add(symbol)
522524

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

525529
messages.push([
526530
`${border} ${symbol} ${
527-
pageInfo?.initialRevalidateSeconds
528-
? `${item} (ISR: ${pageInfo?.initialRevalidateSeconds} Seconds)`
531+
pageInfo?.initialCacheControl?.revalidate
532+
? `${item} (ISR: ${pageInfo?.initialCacheControl.revalidate} Seconds)`
529533
: item
530534
}${
531535
totalDuration > MIN_DURATION

packages/next/src/export/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -646,8 +646,8 @@ async function exportAppImpl(
646646
if (options.buildExport) {
647647
// Update path info by path.
648648
const info = collector.byPath.get(path) ?? {}
649-
if (typeof result.revalidate !== 'undefined') {
650-
info.revalidate = result.revalidate
649+
if (result.cacheControl) {
650+
info.cacheControl = result.cacheControl
651651
}
652652
if (typeof result.metadata !== 'undefined') {
653653
info.metadata = result.metadata

0 commit comments

Comments
 (0)