mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
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
64 lines
2.5 KiB
TypeScript
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 */
|
|
})
|