mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +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
31 lines
988 B
JavaScript
31 lines
988 B
JavaScript
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
|
|
}
|
|
}
|