mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user