Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Propagate expire time to cache-control header and prerender manifest #76207

Merged
merged 17 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
129 changes: 93 additions & 36 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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[]

Expand All @@ -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.
Expand Down Expand Up @@ -258,7 +268,7 @@ export interface SsgRoute {
allowHeader: string[]
}

export interface DynamicSsgRoute {
export interface DynamicPrerenderManifestRoute {
dataRoute: string | null
dataRouteRegex: string | null
experimentalBypassFor?: RouteHas[]
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2128,7 +2143,7 @@ export default async function build(
isRoutePPREnabled,
isHybridAmp,
ssgPageRoutes,
initialRevalidateSeconds: false,
initialCacheControl: undefined,
runtime: pageRuntime,
pageDuration: undefined,
ssgPageDurations: undefined,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2855,26 +2895,40 @@ 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
pageInfos.set(page, {
...(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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -3372,9 +3429,8 @@ export default async function build(
allowHeader: ALLOWED_HEADERS,
}

// Set route Revalidation Interval
if (pageInfo) {
pageInfo.initialRevalidateSeconds = initialRevalidateSeconds
pageInfo.initialCacheControl = cacheControl
}
}
}
Expand Down Expand Up @@ -3476,6 +3532,7 @@ export default async function build(
? `${normalizedRoute}.html`
: false,
fallbackRevalidate: undefined,
fallbackExpire: undefined,
fallbackSourceRoute: undefined,
fallbackRootParams: undefined,
dataRouteRegex: normalizeRouteRegex(
Expand Down
12 changes: 8 additions & 4 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading