Honor the route-level expire value with blocking revalidation (#93211)

A prerendered route's `expire` — set via `cacheLife({ expire })` inside
`'use cache'` or via the `expireTime` config fallback — lands in the
prerender manifest as `initialExpireSeconds` / `fallbackExpire`
(#76207), but the runtime never read it: `IncrementalCache.get` only
considered `revalidate`. So past expire, Next.js served stale with a
background refresh instead of the blocking regeneration the `cacheLife`
`expire` docs describe.

The fix is three coordinated changes. The render-time
`responseGenerator` in `app-page.ts`, `app-route.ts`, and
`pages-handler.ts` now applies the `expireTime` fallback as soon as it
has the render's `cacheControl`, so every downstream consumer (the cache
stored via `IncrementalCache.set`, the response `Cache-Control` header,
the entry returned to `handleResponse`) sees a finalized `cacheControl`
with a populated `expire` — mirroring the build-time fallback.
`IncrementalCache.get` then returns `isStale = -1` when `lastModified +
expire * 1000 < now`, and `response-cache.handleGet` skips its early
`resolve(previousEntry)` for `isStale === -1` so the blocking
revalidation inside `responseGenerator` (which already picks
`BLOCKING_STATIC_RENDER` on that signal) can return its fresh output to
the user. Previously the early resolve committed the stale value to the
response first, so even though `responseGenerator` still ran a fresh
render its output only warmed the cache for the next request. As a side
effect this also closes the same early-resolve hole on the existing
tag-expired `isStale = -1` path.

On Vercel, ISR cache decisions live at the Proxy and the Proxy currently
ignores `staleExpiration` (using a hard-coded one-year value instead).
It is also expected, once it starts honoring `staleExpiration`, to pick
up updated values from the `stale-while-revalidate` response header.
Until that lands this change is only observable on `next start` —
deploy-mode behavior is tracked independently of Next.js.

Two test suites cover the new behavior.
`test/production/app-dir/use-cache-expire` uses `cacheComponents` +
`cacheLife({ expire: 300 })` with a custom cache handler that shifts
`lastModified` via an `x-test-cache-age-offset-ms` header, exercising
the fully-static shell, the partially-static route shell for a known
param, and the partially-static fallback shell for unknown params.
`test/e2e/app-dir/expire-time` covers classic ISR (`revalidate = 1`,
`expireTime: 2`) with a real three-second wait and is `it.failing` on
deploy, so it will flip the moment the Proxy honors the expire value.

fixes #78269
This commit is contained in:
Hendrik Liebau
2026-04-29 08:20:37 +00:00
committed by GitHub
parent e1bb911c14
commit 8e4cfc5062
16 changed files with 328 additions and 22 deletions
+18 -1
View File
@@ -943,6 +943,23 @@ export async function handler(
fetchMetrics,
} = metadata
// Apply the `expireTime` fallback as soon as we have the render's
// `cacheControl`, so every downstream consumer (the cache stored via
// `incrementalCache.set`, the response Cache-Control header, the outgoing
// entry returned to `handleResponse`) sees a finalized `cacheControl`
// with a populated `expire`. This mirrors the build-time fallback in
// `build/index.ts` so we don't apply an expire to routes that opt out of
// revalidation entirely (`revalidate: false`) or that are dynamic
// (`revalidate: 0`).
if (
cacheControl &&
cacheControl.revalidate !== false &&
cacheControl.revalidate > 0 &&
cacheControl.expire === undefined
) {
cacheControl.expire = nextConfig.expireTime
}
if (cacheTags) {
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
}
@@ -1605,7 +1622,7 @@ export async function handler(
cacheControl = {
revalidate: cacheEntry.cacheControl.revalidate,
expire: cacheEntry.cacheControl?.expire ?? nextConfig.expireTime,
expire: cacheEntry.cacheControl.expire,
}
}
// Otherwise if the revalidate value is false, then we should use the
+11 -1
View File
@@ -387,7 +387,17 @@ export async function handler(
const expire =
typeof context.renderOpts.collectedExpire === 'undefined' ||
context.renderOpts.collectedExpire >= INFINITE_CACHE
? undefined
? // Fall back to the global `expireTime` config when the
// route has a numeric `revalidate` but didn't declare an
// explicit `expire` (e.g. via `cacheLife`). This mirrors the
// build-time fallback in `build/index.ts` so cache entries
// and the response Cache-Control header agree on the route's
// effective expire. Routes that opt out of revalidation
// (`revalidate: false`) or that are dynamic (`revalidate: 0`)
// keep `expire: undefined`.
revalidate !== false && revalidate > 0
? nextConfig.expireTime
: undefined
: context.renderOpts.collectedExpire
// Create the cache entry for the response.
@@ -590,26 +590,39 @@ export class IncrementalCache implements IncrementalCacheType {
ctx.isFallback
)
isStale =
revalidateAfter !== false && revalidateAfter < now ? true : undefined
// If the route's `expire` time has passed, force a blocking revalidation
// by signalling `isStale = -1`. The response cache treats `-1` as "skip
// the early SWR resolve" and awaits a fresh render before the user sees a
// response.
const expireAfter =
typeof cacheControl?.expire === 'number'
? cacheControl.expire * 1000 + lastModified
: undefined
// If the stale time couldn't be determined based on the revalidation
// time, we check if the tags are expired or stale.
if (
isStale === undefined &&
(cacheData?.value?.kind === CachedRouteKind.APP_PAGE ||
cacheData?.value?.kind === CachedRouteKind.APP_ROUTE)
) {
const tagsHeader = cacheData.value.headers?.[NEXT_CACHE_TAGS_HEADER]
if (expireAfter !== undefined && expireAfter < now) {
isStale = -1
} else {
isStale =
revalidateAfter !== false && revalidateAfter < now ? true : undefined
if (typeof tagsHeader === 'string') {
const cacheTags = tagsHeader.split(',')
// If the stale time couldn't be determined based on the revalidation
// time, we check if the tags are expired or stale.
if (
isStale === undefined &&
(cacheData?.value?.kind === CachedRouteKind.APP_PAGE ||
cacheData?.value?.kind === CachedRouteKind.APP_ROUTE)
) {
const tagsHeader = cacheData.value.headers?.[NEXT_CACHE_TAGS_HEADER]
if (cacheTags.length > 0) {
if (areTagsExpired(cacheTags, lastModified)) {
isStale = -1
} else if (areTagsStale(cacheTags, lastModified)) {
isStale = true
if (typeof tagsHeader === 'string') {
const cacheTags = tagsHeader.split(',')
if (cacheTags.length > 0) {
if (areTagsExpired(cacheTags, lastModified)) {
isStale = -1
} else if (areTagsStale(cacheTags, lastModified)) {
isStale = true
}
}
}
}
@@ -335,7 +335,16 @@ export default class ResponseCache implements ResponseCacheBase {
})
: null
if (previousIncrementalCacheEntry && !context.isOnDemandRevalidate) {
// `isStale === -1` signals that the entry is past its `expire` (either
// via an expired tag or, with `cacheLife({ expire })`, past the route's
// expire time in the prerender manifest). In that case we must NOT
// early-resolve with the stale value — instead we fall through to a
// blocking revalidation so the response returned to the user is fresh.
if (
previousIncrementalCacheEntry &&
!context.isOnDemandRevalidate &&
previousIncrementalCacheEntry.isStale !== -1
) {
resolve(previousIncrementalCacheEntry)
resolved = true
@@ -353,7 +362,7 @@ export default class ResponseCache implements ResponseCacheBase {
context.isFallback,
responseGenerator,
previousIncrementalCacheEntry,
previousIncrementalCacheEntry !== null && !context.isOnDemandRevalidate,
resolved,
undefined,
context.invocationID
)
@@ -361,6 +361,24 @@ export const getHandler = ({
let cacheControl: CacheControl | undefined =
metadata.cacheControl
// Apply the `expireTime` fallback as soon as we have the
// render's `cacheControl`, so every downstream consumer (the
// cache stored via `incrementalCache.set`, the response
// Cache-Control header, the outgoing entry returned from this
// responseGenerator) sees a finalized `cacheControl` with a
// populated `expire`. This mirrors the build-time fallback in
// `build/index.ts` so we don't apply an expire to routes that
// opt out of revalidation entirely (`revalidate: false`) or
// that are dynamic (`revalidate: 0`).
if (
cacheControl &&
cacheControl.revalidate !== false &&
cacheControl.revalidate > 0 &&
cacheControl.expire === undefined
) {
cacheControl.expire = nextConfig.expireTime
}
if ('isNotFound' in metadata && metadata.isNotFound) {
return {
value: null,
@@ -609,7 +627,7 @@ export const getHandler = ({
}
cacheControl = {
revalidate: result.cacheControl.revalidate,
expire: result.cacheControl?.expire ?? nextConfig.expireTime,
expire: result.cacheControl.expire,
}
} else {
// revalidate: false
@@ -169,6 +169,7 @@
"test/e2e/app-dir/error-boundary-navigation/index.test.ts",
"test/e2e/app-dir/error-boundary-navigation/override-node-env.test.ts",
"test/e2e/app-dir/errors/index.test.ts",
"test/e2e/app-dir/expire-time/**/*",
"test/e2e/app-dir/fallback-prefetch/fallback-prefetch.test.ts",
"test/e2e/app-dir/fallback-shells/**/*",
"test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts",
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,5 @@
export const revalidate = 2
export default async function Page() {
return <p id="value">{new Date().toISOString()}</p>
}
@@ -0,0 +1,63 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('expire-time', () => {
const { next, isNextDeploy } = nextTestSetup({
files: __dirname,
})
// On Vercel, ISR cache decisions happen at the Proxy layer. The Vercel
// builder reads the route's `expire` value (Next.js's `initialExpireSeconds`
// in the prerender manifest, which the build derives from `expireTime` when
// no explicit `cacheLife` expire is set) and passes it to the Proxy as
// `staleExpiration`. The Proxy is also expected, once implemented, to read
// updated values from Next.js's `stale-while-revalidate` response header on
// subsequent revalidations. Today the Proxy ignores the expire value entirely
// and treats it as one year. Past `expireTime` it serves stale with a
// background revalidation instead of a blocking prerender. When Proxy is
// updated to honor the expire value, this test will start passing in deploy
// mode and `it.failing` will itself fail. That's the signal to flip it back
// to `it`.
const itFailsWhenDeployed = isNextDeploy ? it.failing : it
/* eslint-disable jest/no-standalone-expect */
itFailsWhenDeployed(
'should do a blocking revalidation when the cache entry has expired',
async () => {
const $first = await next.render$('/')
const v1 = $first('#value').text()
expect(v1).toBeDateString()
// The first request might trigger a background revalidation if the
// prerender document is already older than the configured revalidate
// time. So we refetch until we get a different value than the first one.
let v2: string
await retry(
async () => {
const $second = await next.render$('/')
v2 = $second('#value').text()
expect(v2).toBeDateString()
expect(v2).not.toBe(v1)
},
4_000,
200
)
// Wait past the `expireTime` (10 s). The next request must trigger a
// blocking prerender, not stale-while-revalidate — so the response
// returned right here carries a freshly-computed value.
await new Promise((resolve) => setTimeout(resolve, 10_000))
const $third = await next.render$('/')
const v3 = $third('#value').text()
expect(v3).toBeDateString()
// This should be a new value, not the expired previous one, and
// especially not the expired prerendered one.
expect(v3).not.toBe(v2)
expect(v3).not.toBe(v1)
console.log({ v1, v2, v3 })
}
)
/* eslint-enable jest/no-standalone-expect */
})
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// When a route has `export const revalidate = N` but no explicit `expire`
// (e.g. no `cacheLife`), the build falls back to `expireTime` for the route's
// `initialExpireSeconds` in the prerender manifest. Keep this low so the test
// can wait past it without slowing CI down.
expireTime: 10,
}
module.exports = nextConfig
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,38 @@
import { cacheLife } from 'next/cache'
import { connection } from 'next/server'
import { Suspense } from 'react'
async function getValue() {
'use cache'
cacheLife({ revalidate: 60, expire: 300 })
return new Date().toISOString()
}
export function generateStaticParams() {
return [{ id: 'known' }]
}
export default async function Page({
params,
}: {
params: Promise<{ id: string }>
}) {
return (
<>
<p id="value">{await getValue()}</p>
<Suspense fallback={<p id="dynamic">loading</p>}>
<DynamicPart params={params} />
</Suspense>
</>
)
}
async function DynamicPart({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await connection()
return (
<p id="dynamic">
{id} - {new Date().toISOString()}
</p>
)
}
@@ -0,0 +1,11 @@
import { cacheLife } from 'next/cache'
async function getValue() {
'use cache'
cacheLife({ revalidate: 60, expire: 300 })
return new Date().toISOString()
}
export default async function Page() {
return <p id="value">{await getValue()}</p>
}
@@ -0,0 +1,30 @@
const {
default: FileSystemCache,
} = require('next/dist/server/lib/incremental-cache/file-system-cache')
/**
* A FileSystemCache variant that lets a test simulate the passage of time by
* shifting the `lastModified` timestamp of a cached entry into the past. The
* offset is controlled per-request via the `x-test-cache-age-offset-ms` header
* so tests can skip over `expire` windows (e.g. 5 minutes) without actually
* waiting.
*/
module.exports = class IncrementalCacheHandler extends FileSystemCache {
constructor(ctx) {
super(ctx)
this.requestHeaders = ctx._requestHeaders
}
async get(key, ctx) {
const result = await super.get(key, ctx)
const offsetHeader = this.requestHeaders?.['x-test-cache-age-offset-ms']
const offsetMs = typeof offsetHeader === 'string' ? Number(offsetHeader) : 0
if (result && offsetMs > 0 && !ctx.fetchCache) {
return { ...result, lastModified: result.lastModified - offsetMs }
}
return result
}
}
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
cacheHandler: require.resolve('./incremental-cache-handler'),
}
module.exports = nextConfig
@@ -0,0 +1,52 @@
import { nextTestSetup } from 'e2e-utils'
// Lives under `test/production` so it only runs in `next start` mode. In dev
// mode the default in-memory `'use cache'` handler would return the cached
// function value on re-render (we don't fake time for the function-level
// cache), so the observable wouldn't change even when the ISR layer does a
// blocking revalidation. Deploy mode is also out of scope: on Vercel the ISR
// cache decision is made at the Proxy layer before the lambda runs, and
// Proxy-side behavior is covered by Proxy's own tests. The companion suite
// `test/e2e/app-dir/expire-time` covers the same `IncrementalCache` /
// response-cache change via classic ISR (no `use cache`, no custom handler,
// short `expireTime`) and runs in deploy mode.
describe('use-cache-expire', () => {
const { next } = nextTestSetup({
files: __dirname,
})
async function expectBlockingRevalidation(path: string) {
const $first = await next.render$(path)
const v0 = $first('#value').text()
expect(v0).toBeTruthy()
const $second = await next.render$(path, undefined, {
// Shift the ISR entry's `lastModified` past `expire` (301 s). Once past
// `expire`, the next request must trigger a blocking prerender — not
// stale-while-revalidate — so the response carries a freshly-computed
// value rather than the previous cached one.
headers: { 'x-test-cache-age-offset-ms': String(301 * 1000) },
})
const v1 = $second('#value').text()
expect(v1).not.toBe(v0)
}
it('should blocking-revalidate a fully static shell past expire', async () => {
await expectBlockingRevalidation('/static')
})
it('should blocking-revalidate a partially-static route shell past expire', async () => {
// Hits the prerendered route shell for the known `generateStaticParams`
// entry, whose static portion contains the cached `getValue()`.
await expectBlockingRevalidation('/partially-static/known')
})
it('should blocking-revalidate a partially-static fallback shell past expire', async () => {
// Hits the fallback shell for an unknown param (not in
// `generateStaticParams`). The cached `getValue()` lives above the Suspense
// boundary in the fallback shell itself, so the same past-expire blocking
// behavior must apply there too.
await expectBlockingRevalidation('/partially-static/unknown')
})
})