[GH-ISSUE #957] Honor route-level expire value with blocking revalidation in ISR #202

Closed
opened 2026-05-06 12:38:08 +02:00 by BreizhHardware · 0 comments

Originally created by @github-actions[bot] on GitHub (Apr 29, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/957

Next.js Change

A prerendered route's expire value — set via cacheLife({ expire }) inside 'use cache' or via the expireTime config fallback — was previously not honored at runtime. Past expire, Next.js was serving stale with a background refresh instead of the blocking regeneration that the cacheLife expire docs describe. This commit fixes it so that past expire, the runtime performs blocking revalidation and returns the freshly regenerated output to the user.

Commit: 8e4cfc5
PR: #93211
Fixes: #78269

What Changed

Three coordinated changes:

  1. responseGenerator in app-page.ts, app-route.ts, pages-handler.ts: 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.
  2. IncrementalCache.get: Returns isStale = -1 when lastModified + expire * 1000 < now.
  3. response-cache.handleGet: Skips its early resolve(previousEntry) when 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.

Impact on vinext

vinext has its own ISR implementation (isr-cache.ts, server/ modules) that distinguishes time-expired stale entries (returned with stale-while-revalidate semantics) from tag-invalidated entries (returned as null/MISS for blocking regen). The current architecture, per AGENTS.md, treats every time-based expiration as SWR.

vinext should match Next.js behavior:

  • For an entry past its revalidate window but within expire: serve stale, regenerate in background (current behavior, OK).
  • For an entry past its expire window: block the response on a fresh render, just like a tag-invalidated entry.

This requires:

  1. Tracking both revalidate and expire durations per cache key (vinext currently tracks only revalidate).
  2. In the ISR cache layer, distinguish three states: fresh, stale-within-expire (SWR), and beyond-expire (treat like a hard miss / blocking regen).
  3. Honor the expireTime config fallback so routes without an explicit cacheLife({ expire }) still get a finite expire window.
  4. Apply the same logic to App Router pages, App Router route handlers, and Pages Router routes (Next.js touched all three handler templates).

Suggested Action

  • Audit isr-cache.ts and the cache write path to confirm whether expire is currently captured; if not, plumb it through alongside revalidate.
  • Add a third "beyond expire" branch to the SWR logic that treats the entry as a miss and blocks on regeneration.
  • Wire the expireTime config fallback so routes without cacheLife still pick up the configured ceiling.
  • Add tests mirroring test/production/app-dir/use-cache-expire (custom cache handler + x-test-cache-age-offset-ms header) and test/e2e/app-dir/expire-time (classic revalidate + expireTime pair).
Originally created by @github-actions[bot] on GitHub (Apr 29, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/957 ## Next.js Change A prerendered route's `expire` value — set via `cacheLife({ expire })` inside `'use cache'` or via the `expireTime` config fallback — was previously not honored at runtime. Past expire, Next.js was serving stale with a background refresh instead of the blocking regeneration that the `cacheLife` `expire` docs describe. This commit fixes it so that past `expire`, the runtime performs blocking revalidation and returns the freshly regenerated output to the user. **Commit:** [`8e4cfc5`](https://github.com/vercel/next.js/commit/8e4cfc50627cd3e321ef2db4f798e46698a2fbb8) **PR:** [#93211](https://github.com/vercel/next.js/pull/93211) **Fixes:** [#78269](https://github.com/vercel/next.js/issues/78269) ## What Changed Three coordinated changes: 1. **`responseGenerator` in `app-page.ts`, `app-route.ts`, `pages-handler.ts`**: 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. 2. **`IncrementalCache.get`**: Returns `isStale = -1` when `lastModified + expire * 1000 < now`. 3. **`response-cache.handleGet`**: Skips its early `resolve(previousEntry)` when `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. ## Impact on vinext vinext has its own ISR implementation (`isr-cache.ts`, `server/` modules) that distinguishes time-expired stale entries (returned with stale-while-revalidate semantics) from tag-invalidated entries (returned as null/MISS for blocking regen). The current architecture, per `AGENTS.md`, treats every time-based expiration as SWR. vinext should match Next.js behavior: - For an entry past its `revalidate` window but within `expire`: serve stale, regenerate in background (current behavior, OK). - For an entry past its `expire` window: **block** the response on a fresh render, just like a tag-invalidated entry. This requires: 1. Tracking both `revalidate` and `expire` durations per cache key (vinext currently tracks only `revalidate`). 2. In the ISR cache layer, distinguish three states: fresh, stale-within-expire (SWR), and beyond-expire (treat like a hard miss / blocking regen). 3. Honor the `expireTime` config fallback so routes without an explicit `cacheLife({ expire })` still get a finite expire window. 4. Apply the same logic to App Router pages, App Router route handlers, and Pages Router routes (Next.js touched all three handler templates). ## Suggested Action - Audit `isr-cache.ts` and the cache write path to confirm whether `expire` is currently captured; if not, plumb it through alongside `revalidate`. - Add a third "beyond expire" branch to the SWR logic that treats the entry as a miss and blocks on regeneration. - Wire the `expireTime` config fallback so routes without `cacheLife` still pick up the configured ceiling. - Add tests mirroring `test/production/app-dir/use-cache-expire` (custom cache handler + `x-test-cache-age-offset-ms` header) and `test/e2e/app-dir/expire-time` (classic `revalidate` + `expireTime` pair).
BreizhHardware 2026-05-06 12:38:08 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#202
No description provided.