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
53 lines
2.4 KiB
TypeScript
53 lines
2.4 KiB
TypeScript
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')
|
|
})
|
|
})
|