Files
Hendrik LiebauandGitHub 8e4cfc5062 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
2026-04-29 08:20:37 +00:00

64 lines
2.5 KiB
TypeScript

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 */
})