Skip to content

fix(isr): honor route expire ceilings#961

Open
NathanDrake2406 wants to merge 6 commits intocloudflare:mainfrom
NathanDrake2406:nathan/fix-isr-expire
Open

fix(isr): honor route expire ceilings#961
NathanDrake2406 wants to merge 6 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: eae6e81

@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
Comment thread packages/vinext/src/entries/app-rsc-entry.ts
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
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
Comment thread packages/vinext/src/build/prerender.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review submitted successfully. Here's a summary:

Verdict: APPROVE

The PR correctly implements Next.js route expire ceilings across the full ISR pipeline. All 21 CI checks pass green. The key strengths:

  • Consistent threading: expireSeconds flows through all five ISR write paths and both cache handlers
  • Hard expiry enforcement: Both Memory and KV handlers check expireAt and return null (hard miss) for expired entries
  • Clean architecture: cache-control.ts centralizes header construction, cache-control-metadata.ts deduplicates validation logic, backward compatibility preserved for entries without expire metadata
  • Previous review concerns resolved: Background regen uses route-specific cacheControl from render results, utilities are deduplicated, cacheLife() inline configs inherit default profile values
  • Good test coverage: New cache-control.test.ts, ISR expire ceiling tests, stored-metadata-wins tests, cacheLife-only prerender fixtures

I posted 5 non-blocking observations as inline comments covering: the defensive "expired" cacheState check in isrGet, the STATIC_CACHE_CONTROL constant remaining unbounded, the backward-compatible fallback in readCacheControlNumberField, the cacheControl return gate in the generated entry, and the clean prerender restructuring.

github run

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.
@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 thorough, well-structured 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 enforce hard expiry, and Cache-Control headers now emit finite stale-while-revalidate windows matching Next.js getCacheControlHeader semantics.

What I verified:

  • Expire flows end-to-end from next.config.ts → resolved config → generated entries → render lifecycle → cache set → cache get → Cache-Control response headers.
  • Both MemoryCacheHandler and KVCacheHandler enforce hard expiry (delete + return null past expireAt) and return cacheControl metadata on stale/fresh reads.
  • Background regeneration in app-page-cache.ts correctly propagates route-specific cacheControl from the render result, so cacheLife() policy survives across regens.
  • The settleCapturedRscRenderForCacheMetadata helper ensures cacheLife() side effects from the RSC render are available before response headers are finalized.
  • Backward compatibility is clean: old cache entries without expireAt/cacheControl continue to work (no expire enforcement, unbounded SWR headers). The readCacheControlNumberField utility handles both the new { cacheControl: { revalidate, expire } } shape and the legacy { revalidate } shape.
  • The cacheLife() inline config normalization ({ ...cacheLifeProfiles.default, ...profile }) matches Next.js behavior where omitted fields inherit the default profile.
  • All 21 CI checks are green.

Architecture highlights:

  • cache-control.ts is a clean centralization with well-documented branching between STALE-with-expire (route policy) and STALE-without-expire (legacy fallback).
  • cache-control-metadata.ts deduplicates ctx field reading between both handlers.
  • The prerender path correctly derives expire from requestCacheLife (authoritative) with fallback to the rendered Cache-Control header, then to the global config.
  • The isPrerender short-circuit in the HTML render path avoids double-writing (prerender results are seeded separately).

I have a few minor non-blocking observations below. None should block merge.

function parseCacheControlSeconds(cacheControl: string, directive: string): number | undefined {
for (const part of cacheControl.split(",")) {
const [rawName, rawValue] = part.trim().split("=", 2);
if (rawName.toLowerCase() !== directive) continue;
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: rawName is the result of split("=", 2)[0] after trim() on the whole part, but it isn't trimmed itself. A header like s-maxage =60 (space before =) would produce rawName = "s-maxage ", failing the comparison. In practice this never happens because this function only parses headers generated by vinext itself (which never insert spaces around =), but a defensive .trim() on rawName would be more robust.

Suggested change
if (rawName.toLowerCase() !== directive) continue;
if (rawName.trim().toLowerCase() !== directive) continue;

}

// Eagerly read values that must be captured before the stream is consumed.
await settleCapturedRscRenderForCacheMetadata(isrRscDataPromise);
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.

Worth noting for future readers: settleCapturedRscRenderForCacheMetadata buffers the entire RSC stream before headers are finalized. This is a necessary tradeoff for correctness (cacheLife metadata must be available before response headers are set), but it changes the HTML path from streaming-first to buffer-first semantics for ISR-eligible routes. If TTFB regressions are reported on routes with large RSC payloads, this is the place to look.

data: IncrementalCacheValue,
revalidateSeconds: number,
tags?: string[],
expireSeconds?: number,
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: the trailing expireSeconds parameter after tags breaks the established pattern where optional params come last with defaults. Since tags already has a default (?? []), having expireSeconds after it means callers must always pass tags explicitly (or undefined) to specify expire. All current callers do pass tags, so this is fine in practice, but if the function signature is ever extended again, consider using an options object for the optional params.

// Check time-based expiry — return stale entry with cacheState="stale"
// Check hard expiry first. Past `expire`, Next.js blocks on fresh
// regeneration instead of serving stale with background work.
if (entry.expireAt !== null && Date.now() > entry.expireAt) {
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.

Good: the expire check uses entry.expireAt !== null (not !== undefined), which means old MemoryEntry objects that don't have expireAt at all get undefined here, which fails the !== null check, correctly skipping expire enforcement for legacy entries. The KV handler uses !== undefined && !== null for the same reason (JSON deserialization never produces undefined). Clean backward-compat story.

const staleWhileRevalidate =
expireSeconds === undefined
? ", stale-while-revalidate"
: 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.

Edge case observation: when revalidateSeconds === expireSeconds, the ternary revalidateSeconds < expireSeconds is false, so staleWhileRevalidate is "" and the header is s-maxage=N with no SWR directive at all. This means downstream caches won't serve stale — they'll always refetch after s-maxage. This is correct (zero-width stale window = no stale serving), and the test at line 13 of cache-control.test.ts (buildRevalidateCacheControl(300, 300)s-maxage=300) verifies it. Just noting the behavior is intentional.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 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, both cache handlers (memory + KV), and response header construction. Key strengths:

  • Consistent expire enforcement: Both handlers hard-delete past expireAt and return cacheControl metadata for correct header construction
  • Clean backward compatibility: Old entries without expireAt/cacheControl gracefully degrade (no expire enforcement, unbounded SWR)
  • Background regen correctness: Route-specific cacheControl from render results survives across regenerations
  • Good architecture: cache-control.ts centralizes header logic, cache-control-metadata.ts deduplicates ctx reading, prerender derives expire from requestCacheLife with proper fallback chain

5 non-blocking inline comments posted covering: defensive rawName.trim() in parseCacheControlSeconds, the TTFB implications of settleCapturedRscRenderForCacheMetadata, the positional expireSeconds parameter in isrSet, the intentional backward-compat difference between !== null (memory) and !== undefined && !== null (KV), and documenting the revalidate === expire edge case behavior.

github run

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