Skip to content

fix(isr): honor route expire ceilings#961

Merged
james-elicx merged 12 commits intocloudflare:mainfrom
NathanDrake2406:nathan/fix-isr-expire
May 2, 2026
Merged

fix(isr): honor route expire ceilings#961
james-elicx merged 12 commits intocloudflare:mainfrom
NathanDrake2406:nathan/fix-isr-expire

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

Closes #957.

Next.js now treats a route-level expire ceiling as the boundary between stale-while-revalidate and blocking regeneration. Vinext was only tracking revalidate, so every time-expired ISR entry stayed eligible for stale serving forever. This PR adds the missing expire dimension and wires it through App Router pages, App Route handlers, Pages Router routes, prerender seeding, and cached function writes.

Next.js references

What changed

  • Memory and KV cache entries now store both revalidateAt and expireAt.
  • ISR reads return a hard miss once an entry is beyond expire, while preserving SWR for entries that are stale but still within expire.
  • next.config expireTime is resolved with the Next.js one-year default and passed through generated App and Pages entry wiring.
  • App pages, App route handlers, Pages routes, prerender manifests, seeded prerenders, and cacheLife-backed cached functions now pass expire alongside revalidate.
  • Cache-Control response helpers now emit finite stale-while-revalidate windows when expire is known, matching Next.js getCacheControlHeader.
  • The App Router generated entry only describes app shape and delegates ISR mechanics to server/isr-cache.ts.

Validation

  • vp check
  • vp test run tests/cache-control.test.ts tests/isr-cache.test.ts tests/kv-cache-handler.test.ts tests/app-page-cache.test.ts tests/app-page-response.test.ts tests/app-page-render.test.ts tests/app-route-handler-response.test.ts tests/app-route-handler-cache.test.ts tests/app-route-handler-execution.test.ts tests/pages-page-data.test.ts tests/pages-page-response.test.ts tests/next-config.test.ts tests/prerender.test.ts tests/deploy.test.ts tests/entry-templates.test.ts

Copilot AI review requested due to automatic review settings April 29, 2026 09:54
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 29, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@961

commit: 89d2a14

@NathanDrake2406 NathanDrake2406 marked this pull request as draft April 29, 2026 10:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates vinext’s ISR implementation to honor Next.js-style route expire ceilings by tracking and enforcing both revalidate and expire, and by aligning Cache-Control header semantics with Next.js.

Changes:

  • Extend cache entries (memory + KV) to include expireAt and treat beyond-expire entries as hard misses (blocking regeneration).
  • Plumb expireTime (with Next.js 1-year default) through config resolution, entry generation, prerender manifests/seeding, and ISR writes.
  • Centralize Cache-Control header construction to emit finite stale-while-revalidate windows when expire is known.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/pages-page-response.test.ts Updates expected Pages Router response Cache-Control with finite SWR window.
tests/pages-page-data.test.ts Adds expireSeconds plumbing + updated ISR set call expectations.
tests/next-config.test.ts Adds coverage for expireTime defaulting and config override.
tests/kv-cache-handler.test.ts Adds expire validation + runtime tests for stale-vs-expired behavior.
tests/isr-cache.test.ts Adds expire ceiling behavior tests at ISR layer.
tests/cache-control.test.ts New unit tests for buildRevalidateCacheControl behavior.
tests/app-route-handler-response.test.ts Updates Route Handler Cache-Control expectations to finite SWR.
tests/app-route-handler-execution.test.ts Threads expireSeconds into ISR writes and header expectations.
tests/app-route-handler-cache.test.ts Threads expireSeconds into ISR writes from cache helper path.
tests/app-page-response.test.ts Updates App Page response policy Cache-Control to finite SWR.
tests/app-page-render.test.ts Updates ISR set call signatures to account for new expireSeconds arg.
tests/app-page-cache.test.ts Adds HIT Cache-Control assertion and threads expireSeconds into writes.
tests/snapshots/entry-templates.test.ts.snap Snapshot updates for generated entries (expireTime + shared isr-cache imports).
packages/vinext/src/shims/cache.ts Adds expireAt to memory entries; reads cacheControl.{revalidate,expire} consistently.
packages/vinext/src/shims/cache-runtime.ts Writes cached function results with { cacheControl: { revalidate, expire } }.
packages/vinext/src/server/seed-cache.ts Seeds prerenders with { cacheControl: { revalidate, expire } } when present.
packages/vinext/src/server/pages-page-response.ts Uses shared Cache-Control helper and passes expireSeconds to ISR writes.
packages/vinext/src/server/pages-page-data.ts Uses shared Cache-Control helper and threads expireSeconds into ISR writes/headers.
packages/vinext/src/server/isr-cache.ts Adds expireSeconds to ISR set and treats handler-reported expired as hard miss.
packages/vinext/src/server/cache-control.ts New shared constants + buildRevalidateCacheControl helper aligned with Next.js.
packages/vinext/src/server/app-route-handler-response.ts Replaces inline Cache-Control strings with shared helper/constants + expireSeconds.
packages/vinext/src/server/app-route-handler-execution.ts Threads expireSeconds into response headers and ISR writes.
packages/vinext/src/server/app-route-handler-cache.ts Threads expireSeconds into cached responses and regeneration ISR writes.
packages/vinext/src/server/app-page-response.ts Uses shared Cache-Control helper/constants and accepts expireSeconds.
packages/vinext/src/server/app-page-render.ts Consumes request-scoped cacheLife expire and propagates to policy/cache writes.
packages/vinext/src/server/app-page-cache.ts Threads expireSeconds into cached responses and regeneration ISR writes.
packages/vinext/src/index.ts Plumbs expireTime into App Router config passed to entry generation.
packages/vinext/src/entries/pages-server-entry.ts Embeds expireTime into generated config and passes expireSeconds downstream.
packages/vinext/src/entries/app-rsc-entry.ts Adds expireTime defaulting + passes expireSeconds into helpers and ISR wiring.
packages/vinext/src/config/next-config.ts Adds expireTime to config types and resolves default (1 year).
packages/vinext/src/cloudflare/kv-cache-handler.ts Persists expireAt, enforces hard expiry, and validates stored entry shape.
packages/vinext/src/build/prerender.ts Adds expire to prerender manifest route records and writes it into index.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/vinext/src/build/prerender.ts
Comment thread packages/vinext/src/build/prerender.ts Outdated
Comment thread packages/vinext/src/server/app-page-cache.ts
Comment thread packages/vinext/src/server/pages-page-data.ts
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 29, 2026 15:43
@NathanDrake2406 NathanDrake2406 marked this pull request as draft April 29, 2026 15:48
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 29, 2026 15:58
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/fix-isr-expire branch 3 times, most recently from 672c634 to 97c154a Compare April 29, 2026 16:23
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

Solid work. The expire ceiling is threaded consistently through all five ISR write paths (App page, App route handler, Pages, prerender seed, cached functions), both cache handlers (memory + KV) enforce hard expiry, and Cache-Control headers now emit finite stale-while-revalidate windows. The cache-control.ts centralization is clean, the backward compatibility path (unbounded SWR when expire is unknown) is correct, and the prerender manifest now records route-level expire derived from the rendered Cache-Control header rather than blindly using the global config.

Test coverage is good — the new cache-control.test.ts, ISR expire ceiling tests, KV expiry tests, and the prerender fixture with cacheLife({ revalidate: 1, expire: 3 }) all exercise the right boundaries.

I have a few comments — one medium-priority issue around background regeneration using the config-global expire instead of the route's effective expire, and a few smaller items.

Comment thread packages/vinext/src/server/app-page-cache.ts Outdated
Comment thread packages/vinext/src/server/cache-control.ts
Comment thread packages/vinext/src/build/prerender.ts
Comment thread packages/vinext/src/cloudflare/kv-cache-handler.ts Outdated
Comment thread packages/vinext/src/server/app-page-render.ts
Comment thread tests/cache-control.test.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 29, 2026

Review submitted successfully. Here's a summary of my findings:

Overall: The PR is well-structured and correctly implements the expire ceiling feature across all ISR paths. The cache-control.ts centralization, backward compatibility for unknown expire, and prerender manifest integration are all clean.

Key issue raised (medium priority):

  • Background regeneration uses global expire: In app-page-cache.ts, when stale entries trigger background regen, the isrSet calls use options.expireSeconds (the global 1-year config default) rather than the route's effective expire from cacheLife(). This means the first fresh render is correct, but every subsequent background regen can overwrite route-specific policy with the global config.

Other comments:

  • STALE cache-control behavioral change needs documenting (now emits s-maxage=<revalidate> instead of s-maxage=0 when expire is known)
  • Pages Router prerender always uses global expireTime — correct but worth a comment explaining why
  • Helper functions (isUnknownRecord, readRecordField, readCacheControlNumberField) duplicated between KV handler and memory handler
  • RSC capture condition skips when revalidateSeconds is null, which could miss cacheLife()-only routes during prerender
  • buildCachedRevalidateCacheControl lacks direct test coverage for STALE+known-expire path

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Review follow-up: the Pages Router prerender expire: config.expireTime comment is not valid as a cacheLife() bug. cacheLife() is App Router/use-cache scoped in Next.js (packages/next/src/server/use-cache/cache-life.ts), while Pages Router getStaticProps metadata records expire: undefined (packages/next/src/server/render.tsx) and Next fills the missing expire from config.expireTime (packages/next/src/build/index.ts, packages/next/src/server/base-server.ts). I kept the Pages path on the global expireTime fallback and added an inline code comment to make that intentional difference explicit.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

This is solid, well-structured work. The expire ceiling is threaded consistently through all ISR write paths, both cache handlers enforce hard expiry, Cache-Control headers emit finite stale-while-revalidate windows, and the prerender manifest now records route-level expire derived from both cacheLife() and the rendered Cache-Control header.

The previous round's key concern about background regeneration overwriting route-specific expire with the global config has been addressed — renderFreshPageForCache now returns cacheControl from _consumeRequestScopedCacheLife(), and app-page-cache.ts uses the regen result's cacheControl with fallback to options.expireSeconds. The utility extraction (cache-control-metadata.ts) also resolved the prior duplication concern.

I have a few remaining comments — nothing blocking.

Comment thread packages/vinext/src/server/cache-control.ts
Comment thread packages/vinext/src/entries/app-rsc-entry.ts Outdated
Comment thread packages/vinext/src/entries/app-rsc-entry.ts Outdated
Comment thread packages/vinext/src/server/app-page-render.ts
Comment thread packages/vinext/src/build/prerender.ts
Comment thread tests/cache-control.test.ts
Comment thread packages/vinext/src/server/app-page-cache.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 29, 2026

Review submitted successfully. Here's a summary of my findings:

Verdict: APPROVE — The PR is well-structured and correctly implements the expire ceiling feature.

Key observations:

  1. Previous concern resolved: The main issue from the prior review (background regen overwriting route-specific expire with global config) has been fixed. renderFreshPageForCache now returns cacheControl from _consumeRequestScopedCacheLife(), and the regen path in app-page-cache.ts uses those values with fallback to the global config.

  2. Utility deduplication resolved: isUnknownRecord, readRecordField, and readCacheControlNumberField are now in a shared cache-control-metadata.ts utility, imported by both the memory and KV cache handlers.

  3. Clean architecture: The new cache-control.ts centralizes Cache-Control header construction. The buildCachedRevalidateCacheControl function correctly handles the behavioral change for STALE responses (using route policy when expire is known, preserving legacy s-maxage=0 when expire is unknown).

  4. Prerender path: resolveRenderedCacheControl correctly extracts expire from both request-scoped cacheLife() metadata and the rendered Cache-Control header, with proper fallback chain.

Comments posted (non-blocking):

  • Suggest a doc comment on buildCachedRevalidateCacheControl explaining the STALE/HIT branching logic
  • Note the revalidateSeconds ?? 0 fallback at L2092 in the entry should have a clarifying comment
  • Flag a minor edge case: cacheLife({ expire: 60 }) without revalidate won't propagate expire through the background regen path
  • Suggest additional test cases for buildCachedRevalidateCacheControl HIT path and revalidate=0 edge case

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

This is a well-executed feature addition. The expire ceiling is threaded consistently through all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions), both cache handlers enforce hard expiry, and Cache-Control headers now emit finite stale-while-revalidate windows matching Next.js semantics. The architecture is clean: cache-control.ts centralizes header construction, cache-control-metadata.ts extracts shared validation logic, and backward compatibility for entries without expire metadata is preserved throughout.

The previous review rounds' key concerns have been addressed — background regeneration now propagates route-specific cacheControl from the render result, utilities have been deduplicated, and the cacheLife() inline config normalization ensures omitted fields inherit defaults.

A few non-blocking observations below.

Comment thread packages/vinext/src/server/app-page-render.ts Outdated
Comment thread packages/vinext/src/build/prerender.ts
Comment thread packages/vinext/src/server/pages-page-data.ts
Comment thread packages/vinext/src/shims/cache.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 29, 2026

Review submitted successfully. Here's a summary:

Verdict: APPROVE

The PR correctly implements Next.js route expire ceilings across the full ISR pipeline. The key strengths:

  • Consistent threading: expireSeconds flows through all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions) and both cache handlers (memory + KV).
  • Hard expiry enforcement: Both cache handlers now check expireAt before returning stale entries, returning null (hard miss) for entries beyond the expire ceiling.
  • Clean architecture: cache-control.ts centralizes header construction with proper backward compatibility for entries without expire metadata.
  • Previous concerns resolved: Background regeneration now uses route-specific cacheControl from render results, utility functions are deduplicated in cache-control-metadata.ts, and cacheLife() inline configs inherit default profile values.

Four non-blocking comments posted:

  1. The revalidateSeconds (min-wins) vs expireSeconds (last-wins) asymmetry in app-page-render.ts could use a clarifying comment
  2. Minor double-parse of s-maxage in resolveRenderedCacheControl in prerender.ts
  3. The ?? 60 fallback in pages-page-data.ts buildPagesCacheResponse should document it's a legacy migration path
  4. Redundant per-call _setRequestScopedCacheLife writes inside cacheLife() when in a "use cache" context

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

This is a well-executed, thorough implementation. The expire ceiling is threaded consistently through all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions), both cache handlers (memory + KV) enforce hard expiry, and Cache-Control headers now emit finite stale-while-revalidate windows matching Next.js semantics.

Architecture highlights:

  • cache-control.ts is a clean centralization of header construction with correct backward compatibility for entries without expire metadata.
  • cache-control-metadata.ts deduplicates the ctx.cacheControl field reading between Memory and KV handlers.
  • The stored cacheControl metadata on cache entries takes priority over caller-level fallbacks in buildAppPageCachedResponse and buildRouteHandlerCachedResponse, so route-specific policy survives across deploys even if the global config changes.
  • Background regeneration correctly propagates cacheControl from the render result, so route-specific cacheLife() policy is preserved across regens.
  • The settleCapturedRscRenderForCacheMetadata helper ensures cacheLife() metadata is available before response headers are finalized.
  • The prerender path derives expire from requestCacheLife with fallback to the rendered Cache-Control header, matching Next.js behavior.

Test coverage is good: The new cache-control.test.ts, ISR expire ceiling tests, KV expiry tests, stored-metadata-wins tests, cacheLife-only prerender fixtures, and the inline-config-inherits-defaults shim test all exercise the right boundaries.

CI is fully green across all 21 checks (unit, integration, E2E, create-next-app).

I have a few non-blocking observations below — nothing that should block merge.

Comment thread packages/vinext/src/server/isr-cache.ts
Comment thread packages/vinext/src/server/cache-control.ts
Comment thread packages/vinext/src/utils/cache-control-metadata.ts
Comment thread packages/vinext/src/entries/app-rsc-entry.ts Outdated
Comment thread packages/vinext/src/build/prerender.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de72991bcf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-page-render.ts Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b37e39517

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-page-cache.ts Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62696bd65b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/seed-cache.ts Outdated
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

This is a well-executed, thorough implementation. I verified the expire ceiling threading end-to-end across all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions), both cache handlers (Memory + KV), the centralized cache-control.ts header construction, the speculative cacheLife write path, and the prerender metadata flow.

Architecture

The data flow is clean: next.config.expireTime → resolved config → __expireTime in generated entries → expireSeconds threaded through render/cache/response helpers → expireAt in cache entries → hard-delete enforcement in handlers → finite stale-while-revalidate in Cache-Control headers. The layering follows the repo's stated preference for thin generated entries delegating to typed helpers.

Key design decisions are sound:

  • Stored metadata wins over config: buildAppPageCachedResponse prefers cacheControl from the cache entry over caller-provided defaults. This means route-specific policy survives across deploys even if the global config changes.
  • Replace, not min-merge, for expire: cacheLife() replaces the config-level expire (vs min-merge for revalidate). This matches Next.js semantics where cacheLife() is the authoritative source of expire.
  • Backward-compatible degradation: Old entries without expireAt/cacheControl skip expire enforcement and get unbounded SWR headers. Both handlers handle this cleanly via different null/undefined checks appropriate to their storage format.
  • _peekRequestScopedCacheLife: The non-destructive peek for prerender response shaping, leaving _consumeRequestScopedCacheLife for the manifest writer, is a correct fix for the destructive-read ordering issue.

Test coverage

Good: cache-control.test.ts covers finite SWR, equal revalidate/expire, unknown expire, STALE+known-expire, HIT+known-expire, and revalidate=0 edge cases. ISR tests cover hard-expire enforcement, prerender fixtures exercise cacheLife(), KV tests validate stored metadata shape, and shim tests verify inline config normalization. The cacheLife-only prerender fixtures are a valuable addition.

Remaining observations (non-blocking)

A few minor items that are fine to address later or leave as-is.

Comment thread packages/vinext/src/server/app-page-render.ts
Comment thread packages/vinext/src/server/app-page-cache.ts Outdated
Comment thread packages/vinext/src/server/cache-control.ts Outdated
Comment thread packages/vinext/src/server/isr-cache.ts
Comment thread packages/vinext/src/server/app-page-render.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 1, 2026

Review submitted successfully. Here's a summary:

Verdict: APPROVE

The PR correctly implements Next.js route expire ceilings across the full ISR pipeline. I verified expire threading end-to-end from config resolution through all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions), both cache handlers (Memory + KV), and response header construction.

Key strengths:

  • Clean data flow from next.config.expireTime through generated entries to cache entries to Cache-Control headers
  • Stored cacheControl metadata on cache entries takes priority over config defaults, so route-specific policy survives across deploys
  • Correct backward compatibility: old entries without expireAt/cacheControl gracefully degrade (no expire enforcement, unbounded SWR)
  • The _peekRequestScopedCacheLife / _consumeRequestScopedCacheLife split correctly solves the destructive-read ordering issue for prerender
  • Background regeneration propagates route-specific cacheControl from render results

5 non-blocking comments posted:

  1. Speculative RSC stream tee memory overhead for non-cacheLife routes (optimization target)
  2. Defensive NaN guard suggestion for resolveAppPageCacheWritePolicy
  3. Documenting the revalidateSeconds >= expireSeconds edge case in buildRevalidateCacheControl
  4. Noting the asymmetry between legacy revalidate (preserved top-level) vs expire (only in cacheControl) for custom handlers
  5. Doc comment suggestion for readRequestCacheLifeForPrerender's consume-on-fallback behavior

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/fix-isr-expire branch 2 times, most recently from e546ba0 to 75c823e Compare May 2, 2026 11:28
@NathanDrake2406 NathanDrake2406 requested a review from Copilot May 2, 2026 11:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses.

Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules.

Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/fix-isr-expire branch from 75c823e to 89d2a14 Compare May 2, 2026 17:02
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(isr): honor route expire ceilings

This is a well-executed, thorough implementation. The expire ceiling is correctly threaded end-to-end across all five ISR write paths (App page, App route handler, Pages Router, prerender seed, cached functions), both cache handlers (Memory + KV), and the centralized Cache-Control header construction.

Key strengths

  • Complete data flow: next.config.expireTime → resolved config → __expireTime in generated entries → expireSeconds through render/cache/response helpers → expireAt in cache entries → hard-delete enforcement in handlers → finite stale-while-revalidate in Cache-Control headers.
  • Clean backward compatibility: Old entries without expireAt/cacheControl skip expire enforcement and get unbounded SWR headers. Both handlers use appropriate null/undefined checks for their storage formats (memory: !== null; KV: !== undefined && !== null).
  • Stored metadata wins: buildAppPageCachedResponse and buildRouteHandlerCachedResponse both prefer cacheControl from the cache entry over caller-provided defaults. Route-specific policy survives across deploys.
  • Background regen correctness: The dispatch layer consumes _consumeRequestScopedCacheLife() from the regen render, and the cached cacheControl is used for subsequent isrSet writes with proper fallback to the global config.
  • _peekRequestScopedCacheLife / _consumeRequestScopedCacheLife split: Correctly solves the destructive-read ordering issue where prerender response shaping needs metadata before the manifest writer consumes it.
  • cache-control.ts centralization: Clean module with well-documented branching between STALE-with-expire (route policy) and STALE-without-expire (legacy fallback).
  • Speculative cacheLife writes: resolveAppPageCacheWritePolicy correctly returns null when no valid cache policy exists, and preserveClientResponseHeaders prevents no-store from being stamped on responses that may not be ISR-eligible.

CI is fully green across all 21 checks. I have a few non-blocking observations below.

Non-blocking observations

  1. expireSeconds: 0 ambiguity: Both cache handlers compute expireAt = effectiveExpire > 0 ? now + ... : null, so expire: 0 means "no expire enforcement" (same as undefined). But buildRevalidateCacheControl(60, 0) produces s-maxage=60 (no SWR) because revalidateSeconds >= expireSeconds. The header says "don't serve stale" but the stored entry has no hard-expire timestamp. Not a practical issue since cacheLife() validation warns when expire < revalidate, but the semantic gap is worth being aware of.

  2. Speculative tee memory overhead: Every production App Router request with revalidateSeconds === null creates a tee'd RSC stream that gets captured and buffered even when the route never calls cacheLife(). The write correctly bails, but the buffer is allocated. This is acknowledged as a future optimization target.

  3. DEFAULT_EXPIRE_TIME duplication: The constant 31_536_000 appears in both next-config.ts and app-rsc-entry.ts. A single export would be slightly cleaner, though the current approach is defensible since the entry generator needs a static fallback.

  4. No expireTime config validation: resolveNextConfig accepts any number for expireTime without checking for negative values or NaN. The downstream Number.isFinite() guard on resolveAppPageCacheWritePolicy would catch NaN for cache writes, but negative values would still flow through to buildRevalidateCacheControl and produce a valid-looking but semantically wrong header.

LGTM — this is a solid, well-tested implementation that correctly matches Next.js expire ceiling semantics.

serverActionsAllowedOrigins,
optimizePackageImports,
serverActionsBodySizeLimit,
expireTime: typeof config.expireTime === "number" ? config.expireTime : DEFAULT_EXPIRE_TIME,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: no validation that config.expireTime is a positive finite number. Negative values or NaN would pass the typeof === "number" check and flow through to cache handlers and buildRevalidateCacheControl.

Not blocking since NaN would be caught by the Number.isFinite() guard in resolveAppPageCacheWritePolicy and negative values are unlikely in practice, but a clamp or warning here would be more defensive:

Suggested change
expireTime: typeof config.expireTime === "number" ? config.expireTime : DEFAULT_EXPIRE_TIME,
expireTime: typeof config.expireTime === "number" && config.expireTime > 0 ? config.expireTime : DEFAULT_EXPIRE_TIME,


// `expire <= revalidate` is a zero-width stale window: downstream caches
// should refetch after s-maxage instead of serving stale.
if (revalidateSeconds >= expireSeconds) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When expireSeconds === 0, this condition is true for any non-negative revalidateSeconds, producing s-maxage=N with no SWR directive. But both cache handlers treat expire: 0 as "no expire" (since effectiveExpire > 0 is false, expireAt is null). So the header says "don't serve stale" but the stored entry has no hard-expire enforcement.

This asymmetry is unlikely to occur in practice (cacheLife validation warns when expire < revalidate), but documenting it here would help future readers:

Suggested change
if (revalidateSeconds >= expireSeconds) {
// `expire <= revalidate` is a zero-width stale window: downstream caches
// should refetch after s-maxage instead of serving stale.
// Note: expire === 0 also lands here; cache handlers treat 0 as "no expire"
// (no expireAt is stored), but the header correctly omits the SWR directive.
if (revalidateSeconds >= expireSeconds) {

return entry?.value.value && entry.value.value.kind === "APP_PAGE" ? entry.value.value : null;
}

function resolveAppPageCacheWritePolicy(options: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation: resolveAppPageCacheWritePolicy and applyRequestCacheLife (in app-page-render.ts:138) share nearly identical merge logic — Math.min for revalidate, direct replacement for expire, same null handling. The different return types justify separate functions, but extracting the shared merge step into a tiny helper (e.g., mergeRequestCacheLifeValues) would eliminate the duplication and make it easier to keep them in sync if the merge semantics evolve.

Not blocking — the current split is clear and well-commented.

const headers = config?.headers ?? [];
const allowedOrigins = config?.allowedOrigins ?? [];
const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
const expireTime = config?.expireTime ?? DEFAULT_EXPIRE_TIME;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: DEFAULT_EXPIRE_TIME is duplicated here and in next-config.ts. If the default ever changes, both must be updated in lockstep. Consider exporting from next-config.ts (or a shared constants module) and importing here, since the entry generator already imports other types from that module.

if (htmlResponsePolicy.shouldWriteToCache) {
const shouldSpeculativelyWriteCache =
options.isProduction &&
shouldCaptureRscForCacheMetadata &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The speculative tee allocates a captured RSC buffer for every production App Router request with revalidateSeconds === null, even when the route never calls cacheLife(). The write is correctly gated by resolveAppPageCacheWritePolicy, so this is a memory overhead issue, not a correctness one.

Worth noting in a comment that this is a known optimization target — if profiling shows elevated memory on non-ISR routes, this is the place to look.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 2, 2026

Review submitted successfully. Here's a summary:

Verdict: APPROVE

The PR correctly implements Next.js route expire ceilings across the full ISR pipeline. I verified the expire threading end-to-end from config resolution through all five ISR write paths, both cache handlers, and response header construction. All 21 CI checks are green.

5 non-blocking inline comments posted:

  1. next-config.ts:702 — Suggest adding a positive-number guard on config.expireTime to prevent negative values or NaN from flowing through
  2. cache-control.ts:27 — Document the expire === 0 edge case where the header omits SWR but no expireAt is stored
  3. app-page-cache.ts:120 — Note the duplicated merge logic between resolveAppPageCacheWritePolicy and applyRequestCacheLife; a shared helper could reduce drift risk
  4. app-rsc-entry.ts:145DEFAULT_EXPIRE_TIME is duplicated across two files; a single export would be cleaner
  5. app-page-render.ts:461 — The speculative tee allocates a captured RSC buffer even for non-cacheLife routes; worth a comment marking it as an optimization target

github run

@james-elicx james-elicx merged commit f45fce0 into cloudflare:main May 2, 2026
27 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/fix-isr-expire branch May 6, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Honor route-level expire value with blocking revalidation in ISR

3 participants