fix(app-router): honor cacheLife stale on the client router - #2708
fix(app-router): honor cacheLife stale on the client router#2708NathanDrake2406 wants to merge 25 commits into
Conversation
`cacheLife` profiles carry three independent numbers, and `stale` is the
client-router dimension: how long the browser may reuse cached route output
without asking the server. vinext aggregated it correctly (`resolveCacheLife`
min-reduces all three; the request scope accumulates min-wins) and then
projected it away at every consumer, so the only staleness a browser ever saw
was `dynamicStaleTimeSeconds` from `experimental.staleTimes` — a build-time
constant unrelated to the cached subtrees that produced the render. A subtree
declaring `cacheLife("seconds")` (stale: 30) was held for the full 5-minute
visited-response TTL.
Carry the resolved `stale` to the client on `x-nextjs-stale-time` (matching
Next.js's `NEXT_ROUTER_STALE_TIME_HEADER`) and combine it with the config
value by taking the minimum, so neither min-wins lattice overrides the other.
The 30s prefetch floor applies to the new value too.
Two normalization rules, both deliberate:
- An absent `stale` is never synthesized from `revalidate`/`expire`. The
`default` profile has no `stale` and an `expire` of ~136 years, so deriving
one would license session-long reuse without a refresh.
- The three numbers are not assumed to be ordered — `seconds` is
`{ stale: 30, revalidate: 1, expire: 60 }` — so `revalidate` never
constrains `stale`. Only the hard `expire` ceiling does.
The previous commit derived `x-nextjs-stale-time` from `peekRequestCacheLife()` at response-construction time. That read happens after `probeAppPageBeforeRender` but before the RSC stream is consumed, and the probe only awaits the page component's own async result — it does not render the returned tree. Any `use cache` scope in a child Server Component registers later, during stream consumption, so the header described the probe rather than the output. Because `cacheLife` aggregation is minimum-wins, missing one late scope is enough to make the value wrong, and "it can only shorten" did not hold: with no `dynamicStaleTimeSeconds` present (the usual case for `use cache` pages, which are not dynamic renders), a peeked `stale: 300` widened the prefetch window from `PREFETCH_CACHE_TTL` (30s) to 300s — 10x longer than today, in the direction the change set out to fix. Carry the value on the cache entry instead. The ISR write path reads the request-scoped accumulation via the consuming `getRequestCacheLife()` inside the cache-write closure, which runs after the captured stream has drained, so it observes the completed render's minimum. `CacheControlMetadata` gains `stale`, `isrSet` persists it, and `buildAppPageCachedResponse` re-advertises it on hits. Prerender seeds carry it through `VINEXT_PRERENDER_CACHE_LIFE_HEADER` and the prerender manifest so a seeded entry makes the same claim a runtime render would. Two links that would otherwise silently drop the claim are closed with it: the `use cache` entry write now persists `stale`, and `recordRequestScopedCacheControl` re-registers it on a data-cache hit — without both, a page's advertised freshness would depend on data-cache temperature rather than on what it declared. Streaming responses now advertise nothing and leave the client on its configured `experimental.staleTimes`, which is the honest answer for a value that is not yet known when headers are committed. Covering fresh streaming renders needs a render-completion signal the streaming RSC path does not have today; Next.js solves it by streaming an `AsyncIterable<number>` in the RSC payload (`app-render.tsx` `baseResponse.s`, closed after the render settles), which is the natural follow-up. The client-side combination logic is unchanged: both stale signals are still min-reduced, absent `stale` is never synthesized from `revalidate`/`expire`, `revalidate` never clamps `stale`, and `expire` still caps it.
The previous round persisted the completed render's cacheLife stale onto
the ISR entry and replayed it on cache hits, which left the value missing
or drifting wherever the entry's lifetime diverged from the render's:
- Fresh streaming renders advertised nothing, so the first visitor of any
route (and every dev render, since dev writes no ISR entry) stayed on
the configured staleTimes despite a declared cacheLife. The done-script
emitted by the RSC embed transform's finalize() runs only after the
full RSC stream has drained, so it can carry the completed render's
minimum where streaming headers cannot. Emit the peeked request-scoped
cacheLife there and seed the hydration visited-response entry from it.
This also makes the client's min-combination of the config and
cacheLife signals reachable: a dynamic render with use cache subtrees
now legitimately carries both.
- The expire clamp bounded the value but not the elapsed window: an
entry of { stale: 30, expire: 60 } hit at age 59s replayed a 30s reuse
window reaching 29s past expire. Age the serve-time clamp with the
entry's lastModified so only the remaining window is advertised.
- The two client caches applied different floors: prefetch entries
floored a cacheLife stale at 30s while cold navigations honored it
verbatim, so the same declaration produced two behaviors keyed on
whether a prefetch fired first. Floor the cacheLife signal once in the
shared resolver, mirroring Next.js getStaleTimeMs, before the min so
it can never raise the config-derived bound.
Mechanically, the app-page cache setter's six-position signature
(declared identically in four modules) collapses into one exported
AppPageCacheSetter taking an AppPageCacheWritePolicy object, with
isrSetAppPage adapting to the shared positional isrSet, which stays
unchanged for the Pages Router and route handlers.
The cold RSC fetch gap is a design decision, not a pending follow-up: Next.js's AsyncIterable stale transport only works under staged rendering (cacheComponents), where cache scopes settle before the render task queue drains — in vinext's lazy streaming model the iterable could never close. Next.js's plain-mode mechanism is a blocking cold render, which cloudflare#961 deliberately rejected to keep ISR page streams unblocked. Assert the resulting no-header contract on the streaming-response test. Also reword the write-policy expire comment: a cacheLife-declared expire replaces the config expireTime fallback (Next.js precedence), it is not min-merged with a route-level ceiling — no such ceiling exists outside cacheLife.
Three fixes from review round 3:
Background regeneration dropped the regenerating render's cacheLife stale:
renderAppPageCacheArtifacts returned only { revalidate, expire }, so
resolveRegeneratedAppPageCachePolicy could never receive the stale it was
built to preserve and the first regen silently widened client reuse back
to the configured fallback. The producer now carries it, with a
real-producer regression test the mocked-cacheControl tests could not
provide.
The age-aware expire clamp is removed. Composed with the client's 30s
floor it delivered neither contract (a clamped 1 re-floored to 30), and
cached HTML replayed the unclamped done-script value regardless. Next.js
stores the stale header at generation and replays it verbatim on every
hit; vinext now does the same, keeping expire a serve-side ceiling.
Cold cacheable RSC responses stream before their cacheLife resolves
(cloudflare#961), which left them on the 300s client fallback — reproducing the
headline bug for the first request of every entry epoch. They now carry
X-Vinext-Stale-Time-Pending, and both client caches bound such responses
at the 30s floor: the unresolved claim, once floored, could never
license less.
The pending marker meant 'capture was attempted', but the client read it as 'a cacheLife claim exists'. Capture eligibility is decided before the lazy stream runs, so a late request-API read can make the completed render dynamic — the finalizer skips the ISR write and no claim ever exists, yet the marker granted 30 seconds of reuse even under staleTimes.dynamic: 0. Pending responses now carry the configured dynamic stale time (including 0) and the client takes the minimum, so an unresolved response never receives a wider window than the dynamic bound.
commit: |
Performance benchmarksCompared 1 improved · 0 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
Pairing the pending marker with the configured dynamic stale time broke the segment-cache-client-params compat test: dynamic-param routes prefetch with no minimum TTL, so the paired 0 default made every cold prefetch entry expire instantly and navigations refetched routes that Next.js serves entirely from a static prefetch. A cold stream cannot distinguish a render that will resolve static from one that turns dynamic mid-stream, and bounding both by staleTimes.dynamic sacrifices the guaranteed-correct case for the ambiguous one. Pending responses go back to the 30s floor cap; the late-dynamic exposure (one epoch-cold response, at most 30s) is documented as the price of non-blocking cold renders (cloudflare#961).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 437f640819
ℹ️ 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".
…d nested cache hits - writePrerenderIndex now copies `stale` into vinext-prerender.json so seedMemoryCacheFromPrerender actually receives it - KVCacheHandler.set() and buildPrerenderKVPairs persist cacheControl.stale so warm hits on the Cloudflare KV backend replay the producing render's claim; validateCacheEntry accepts the field - a nested use cache HIT pushes its stored lifetime into the enclosing cache context's lifeConfigs, mirroring the MISS path, so the outer entry keeps the child's stale claim once the outer goes warm
The added modules ride in every consumer build environment; the review-grade rationale lives in the PR body, the code keeps one-line constraints.
|
@codex review |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
CachedRscResponse carried staleTimePending and staleTimeSeconds as two independent optionals, so the cached form admitted a state the wire never produces (pending and resolved at once) and every consumer had to encode the precedence rule. Replace both with a discriminated serverStaleTime (pending | resolved), collapsed once at the header-parse boundary.
The generic setter had grown to six positional arguments, the last an
App-Router-only `stale` value, with isrSetAppPage as a policy-object
wrapper that unpacked straight back into it. Take { cacheControl, tags }
instead: routers construct the metadata they actually claim, the wrapper
and its type disappear, and the shared isrCacheControl builder replaces
the cache-control literal duplicated across writers.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2363b944da
ℹ️ 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".
The done-script metadata reused the RSC header's rule of dropping the config-derived dynamic stale time while the speculative ISR capture was armed. That rule only holds on the header path, which substitutes the pending marker and its 30s floor; the done script emits the resolved cacheLife instead, so a production render that turned dynamic shipped the cacheLife claim as its only bound and let the hydration-seeded entry reuse dynamic output for its full duration. Dev never took the capture path, so the two diverged. Also migrates the pages-basic seed fixture, the last positional isrSet caller, which typecheck does not cover.
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff at head, tracing every emitter and consumer of the new stale / serverStaleTime dimension and re-checking each prior review finding against the current code (not the commit the earlier reviews ran against). No blocking issues.
Architecture
Sound. resolveClientStaleTimeSeconds (utils/cache-control-metadata.ts:40) is a single projection routed through every write/replay/seed path; the two normalization rules (never synthesize stale from revalidate/expire, never clamp) are documented at the resolver and hold. stale never enters streaming headers — it travels only on completion-time channels (ISR write, HIT/STALE replay, done-script, prerender seed, or the post-Flight footer). The byte-stuffed footer framing (0xff 0xff for reserved bytes, 0xff 0x00 as the terminal footer tag) is clean and the encode/decode/extract paths share one validator.
Prior findings — all resolved at head
- KV
staleround-trip (Codex P1):kv-data-adapter.runtime.ts:369,391,583persists and validatescacheControl.stale;prerender-kv-populate.ts:130-170carriesroute.staleinto bulk-import entries. ✅ - Prerender index projection (Codex P1):
PrerenderRouteResult.staledeclared and copied bywritePrerenderIndex;seedMemoryCacheFromPrerenderthreads it through. ✅ - Nested
use cacheHIT propagation (Codex P1):recordRequestScopedCacheControl(cache-runtime.ts:698) pushes the stored lifetime into the enclosinglifeConfigs, mirroring the MISS path, and only when a parent scope exists. ✅ - Done-script dynamic bound on captured dynamic renders (Codex P1):
app-page-render.ts:1047-1051emitsdynamicStaleTimeSecondswhenever the completedkindisdynamic, regardless of the speculative-capture decision. ✅ - Late-dynamic completion dropping the pending bound (Codex/last-bonk P2): resolved and covered. The completion footer for a dynamic render always includes the
serverStaleTimeSecondskey (app-page-render.ts:917-920emitsnullor a number), sohasCompletedServerStaleTime(navigation.ts:1002) is always true for a completed dynamic render and the provisional pending state is dropped.tests/prefetch-cache.test.ts:456-482pins exactly the scenario the last review flagged:dynamicStaleTimeSeconds: 60+ pending header →resolveCachedRscResponseTtlMsreturns60_000, not30_000. The concern in the most recent bonk review does not reproduce against this head. ✅ - Footer stripping withholding a 256-byte tail (Codex/last-bonk P2): the streaming decoder was rewritten to byte-stuffing (
stripRscCompletionMetadata,rsc-completion-metadata.ts:167). It now holds at most one ambiguous escape byte and passes ordinary chunks through without copying — no fixed 256-byte tail is retained on the live-navigation path. ✅
Independent checks
x-nextjs-stale-timematches Next.jsNEXT_ROUTER_STALE_TIME_HEADER.- The positional→policy
isrSetrefactor updated every caller, including thepages-basicfixture endpoint (seed-revalidate-cache-headers.ts:19-24). escapeFlightChunkescapes per-chunk with both bytes emitted together, so a chunk boundary can never split an escape pair; the decoder carriesescapePendingacross chunks correctly.- Snapshot seeding (
app-browser-entry.ts:2269-2296) correctly refuses to publish a completed-dynamic response as a reusable static snapshot and drops the provisional prefetch expiry in favor of the resolved bound. - Ran
tests/rsc-completion-metadata.test.ts,tests/shims.test.ts,tests/prefetch-cache.test.tslocally — all green (1320 + 16 + prefetch suite).
The two documented known limits (edge-CDN no-op set() capping RSC reuse at the 30s floor; late-dynamic streaming not enforcing staleTimes.dynamic below 30s at header time) are deliberate trade-offs tied to #961 and reasonable given the streaming model.
One minor non-blocking note inline.
| cacheLife: { stale?: number } | null | undefined, | ||
| ): number | undefined { | ||
| const stale = cacheLife?.stale; | ||
| return isFiniteNonNegative(stale) ? stale : undefined; |
There was a problem hiding this comment.
Minor / non-blocking: resolveClientStaleTimeSeconds returns 0 for cacheLife({ stale: 0 }) (0 is finite and non-negative), which flows through as a real x-nextjs-stale-time: 0 claim; the client floors it back to 30s via Math.max(seconds, 30), matching Next.js getStaleTimeMs. That behavior is correct, but the undefined-vs-0 boundary is load-bearing for the "absent = no claim" invariant this resolver enforces. Worth a one-line note that stale: 0 is intentionally carried as a real claim (not dropped as "no claim") so a future reader doesn't "simplify" the check to stale ? stale : undefined and silently change the semantics.
|
Review posted successfully. Here's a summary of what I did: Review Summary for PR #2708I reviewed the full diff at the current head ( Key finding: the two most-recent P2s are already resolved at headThe last
Also verified resolvedThe three earlier Codex P1s (KV round-trip, prerender index projection, nested Independent checks
One minor non-blocking note: |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff at head (cbbb4de), tracing every emitter and consumer of the new stale / serverStaleTime dimension and re-checking each prior review finding against the current code. No blocking issues.
Architecture
Sound. resolveClientStaleTimeSeconds (utils/cache-control-metadata.ts:40) is a single projection routed through every write/replay/seed path; the two documented normalization rules (never synthesize stale from revalidate/expire, never clamp) hold. stale never enters streaming Cache-Control — it travels only on completion-time channels: ISR write, HIT/STALE replay (app-page-cache.ts:247), done-script, prerender seed, or the post-Flight footer. The byte-stuffed footer framing (0xff 0xff for reserved bytes, 0xff 0x00 as the terminal tag) is clean and fail-closed; the encode/decode/extract paths share one validator.
Prior findings — all resolved at head
- KV
staleround-trip (P1):kv-data-adapter.runtime.ts:369,391,583persists and validatescacheControl.stale;prerender-kv-populate.ts:130-170carriesroute.staleinto bulk-import entries. ✅ - Prerender index projection (P1):
PrerenderRouteResult.staledeclared and copied bywritePrerenderIndex;seedMemoryCacheFromPrerenderthreads it through. ✅ - Nested
use cacheHIT propagation (P1):recordRequestScopedCacheControl(cache-runtime.ts:698) pushes the stored lifetime into the enclosinglifeConfigs, mirroring the MISS path, and only when a parent scope exists. ✅ - Done-script dynamic bound on captured dynamic renders (P1):
app-page-render.ts:1047-1051emitsdynamicStaleTimeSecondswhenever the completedkindisdynamic, regardless of the speculative-capture decision. ✅ - Late-dynamic completion dropping the pending bound (P2): resolved and covered. The completion footer for a dynamic render always includes
serverStaleTimeSeconds(app-page-render.ts:917-920emitsnullor a number), sohasCompletedServerStaleTime(navigation.ts:1002-1003) is always true for a completed dynamic render and the provisional pending state is dropped.tests/prefetch-cache.test.ts:456-482pins the exact scenario a prior review flagged:dynamicStaleTimeSeconds: 60+ pending header → TTL resolves to60_000, not30_000. The bug does not reproduce against this head. ✅ - Footer stripping withholding a 256-byte tail (P2):
stripRscCompletionMetadata(rsc-completion-metadata.ts:167) was rewritten to byte-stuffing. It holds at most one ambiguous escape byte and passes ordinary chunks through without copying — no fixed 256-byte tail is retained on the live-navigation path.tests/rsc-completion-metadata.test.ts:51pins early delivery of a small Flight shell. ✅
Independent checks
x-nextjs-stale-timematches Next.js canaryNEXT_ROUTER_STALE_TIME_HEADERverbatim (verified againstapp-router-headers.ts).- The positional→policy
isrSetrefactor updated every caller, including the flaggedpages-basicfixture endpoint (seed-revalidate-cache-headers.ts:19-24). escapeFlightChunkescapes per-chunk with both bytes emitted together, so a chunk boundary can never split an escape pair; the streaming decoder carriesescapePendingacross chunks correctly.- Malformed-frame rejection is exhaustive: invalid escapes, truncated footers, oversized frames, multiple frames, cancellation, and error propagation are all tested and fail closed.
- Snapshot seeding (
app-browser-entry.ts:2269-2296) correctly refuses to publish a completed-dynamic response as a reusable static snapshot and drops the provisional prefetch expiry in favor of the resolved bound. - Ran ~10 relevant test files locally (
rsc-completion-metadata,prefetch-cache,app-page-cache,app-page-render,shims,kv-cache-handler,seed-cache,prerender,app-visited-response-cache,app-page-cache-render) — all green (1646+ tests).
The two documented known limits (edge-CDN no-op set() capping RSC reuse at the 30s floor; late-dynamic streaming not enforcing staleTimes.dynamic below 30s at header time) are deliberate trade-offs tied to #961 and reasonable given the streaming model.
One minor non-blocking note inline.
| cacheLife: { stale?: number } | null | undefined, | ||
| ): number | undefined { | ||
| const stale = cacheLife?.stale; | ||
| return isFiniteNonNegative(stale) ? stale : undefined; |
There was a problem hiding this comment.
Minor / non-blocking: resolveClientStaleTimeSeconds returns 0 for cacheLife({ stale: 0 }) (0 is finite and non-negative), which flows through applyClientStaleTimeHeader (app-page-response.ts:139, which correctly gates on === undefined rather than falsy) and emits a real x-nextjs-stale-time: 0 claim. The client floors it to 30s via Math.max(seconds, 30), matching Next.js getStaleTimeMs, so behavior is correct.
The undefined-vs-0 boundary is load-bearing for the "absent = no claim" invariant this resolver enforces. Worth a one-line note that stale: 0 is intentionally carried as a real claim (not dropped as "no claim"), so a future reader doesn't "simplify" isFiniteNonNegative(stale) ? stale : undefined to a stale ? stale : undefined check and silently change the semantics.
|
Review posted successfully. Here's a summary of what I found: Review Summary for PR #2708I reviewed the full diff at head ( Key verification: both most-recent P2s are resolved at head
Also confirmed resolvedThe three earlier Codex P1s (KV round-trip, prerender index projection, nested Independent checks
The two documented known limits (edge-CDN no-op One minor non-blocking inline note: the |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff at head (35175ac), tracing every emitter and consumer of the new stale / serverStaleTime dimension and re-verifying each prior review finding against the current code. No blocking issues.
Architecture
Sound. resolveClientStaleTimeSeconds (utils/cache-control-metadata.ts:40) is a single projection routed through every write/replay/seed path, and the two documented normalization rules (never synthesize stale from revalidate/expire, never clamp) hold. stale never enters streaming Cache-Control — it travels only on completion-time channels: ISR write, HIT/STALE replay (app-page-cache.ts:247), done-script, prerender seed, or the post-Flight footer.
Prior findings — all resolved at head
- KV
staleround-trip (P1):kv-data-adapter.runtime.ts:369,391,583persists and validatescacheControl.stale;prerender-kv-populate.ts:130-170carriesroute.staleinto bulk-import entries. ✅ - Prerender index projection (P1):
PrerenderRouteResult.staledeclared and copied bywritePrerenderIndex;seedMemoryCacheFromPrerenderthreads it through. ✅ - Nested
use cacheHIT propagation (P1):recordRequestScopedCacheControl(cache-runtime.ts:698) pushes the stored lifetime into the enclosinglifeConfigsat line 712 (mirroring the MISS path) and to the request scope at line 732, and only when a parent scope exists. It fires only on the HIT path (cache-runtime.ts:584,589); the MISS path's push lives inrunCachedFunctionWithContext, so there is no double-count. ✅ - Done-script dynamic bound on captured dynamic renders (P1):
app-page-render.ts:1047-1051emitsdynamicStaleTimeSecondswhenever the completedkindisdynamic, regardless of the speculative-capture decision. ✅ - Late-dynamic completion dropping the pending bound (P2): resolved. The completion footer for a dynamic render always includes
serverStaleTimeSeconds(app-page-render.ts:917-920emitsnullor a number), sohasCompletedServerStaleTime(navigation.ts:1002-1003) is true for a completed dynamic render and the provisional pending state is replaced by the resolved bound (navigation.ts:1008-1012).tests/prefetch-cache.test.tspinsdynamicStaleTimeSeconds: 60+ pending header → 60s, not 30s. ✅ - Footer stripping withholding a 256-byte tail (P2):
stripRscCompletionMetadata(rsc-completion-metadata.ts:167) uses byte-stuffing, holds at most one ambiguous escape byte, and passes ordinary chunks through without copying (:212,217) — no fixed 256-byte tail retained on the live path.tests/rsc-completion-metadata.test.ts:51pins early delivery of a small Flight shell. ✅
Independent checks
- Byte-stuffed framing is unambiguous:
escapeFlightChunkdoubles every0xff, so a real0xff 0x00in Flight data decodes as0xff+0x00and can never be mistaken for the0xff 0x00footer tag. Encode/decode is correct across all chunk boundaries (:98-124) and fail-closed on invalid escapes, truncated, oversized, and multiple frames. extractRscCompletionMetadatapasses the entire remaining buffer toparseFooterCandidate, whose length check rejects any trailing bytes after the footer (multiple-frame guard).- The
undefined-vs-0boundary is guarded with=== undefinedat every emitter (app-page-response.ts:139,:161,:167), sostale: 0correctly flows as a real claim. - The positional→policy
isrSetrefactor updated every caller including thepages-basicfixture endpoint (seed-revalidate-cache-headers.ts:19-24). - Snapshot seeding (
app-browser-entry.ts:2269-2296) refuses to publish a completed-dynamic response as a reusable static snapshot. - Ran
tests/rsc-completion-metadata.test.ts,tests/shims.test.ts,tests/prefetch-cache.test.tslocally — all green (1336 tests).
The two documented known limits (edge-CDN no-op set() capping RSC reuse at the 30s floor; late-dynamic streaming not enforcing staleTimes.dynamic below 30s at header time) are deliberate trade-offs tied to #961 and reasonable given the streaming model.
One minor non-blocking note inline.
| cacheLife: { stale?: number } | null | undefined, | ||
| ): number | undefined { | ||
| const stale = cacheLife?.stale; | ||
| return isFiniteNonNegative(stale) ? stale : undefined; |
There was a problem hiding this comment.
Minor / non-blocking (flagged by prior reviews, still uncommented in code): resolveClientStaleTimeSeconds returns 0 for cacheLife({ stale: 0 }) because 0 is finite and non-negative, so it flows through applyClientStaleTimeHeader (which correctly gates on === undefined, not falsy) and emits a real x-nextjs-stale-time: 0. The client then floors it to 30s via Math.max(seconds, 30), matching Next.js getStaleTimeMs, so behavior is correct.
The undefined-vs-0 boundary is load-bearing for the "absent = no claim" invariant this resolver enforces. Worth a one-line note so a future reader doesn't "simplify" isFiniteNonNegative(stale) ? stale : undefined to a stale ? stale : undefined check and silently change the semantics — that rewrite would drop stale: 0 as "no claim" and let the configured fallback license far more reuse.
|
Review posted successfully. Here's a summary of what I found: Review Summary for PR #2708I reviewed the full diff at head ( Verified correct
One minor non-blocking noteThe The two documented known limits (edge-CDN no-op |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff at head (3b7beb08), tracing every emitter and consumer of the new stale / serverStaleTime / completion-footer machinery independently rather than trusting the prior review chain. No blocking issues.
Architecture
Sound. resolveClientStaleTimeSeconds (utils/cache-control-metadata.ts:40) is a single projection routed through every write/replay/seed path, and at head it correctly does not clamp stale by expire (line 44 is isFiniteNonNegative(stale) ? stale : undefined) — matching the PR's stated "never clamp" rule. (Note: gh pr diff still surfaces an older revision of this function that lived in app-page-response.ts and did Math.min(stale, expire); that clamping revision is not present in the final tree, so the diff view is misleading here but the shipped code is correct.)
stale never enters streaming Cache-Control — it travels only on completion-time channels: ISR write, HIT/STALE replay (app-page-cache.ts:247), the done-script, the prerender seed, or the post-Flight footer.
Prior findings — all resolved at head
- KV
staleround-trip (P1):kv-data-adapter.runtime.tsreadseffectiveStaleand persists/validatescacheControl.stale;prerender-kv-populate.tscarriesroute.staleinto bulk-import entries. ✅ - Prerender index projection (P1):
PrerenderRouteResult.staledeclared and copied bywritePrerenderIndex;seedMemoryCacheFromPrerenderthreads it through. ✅ - Nested
use cacheHIT propagation (P1):recordRequestScopedCacheControl(cache-runtime.ts:698) pushes the stored lifetime into the enclosinglifeConfigs(mirroring the MISS path) and the request scope, only when a parent scope exists — no double-count. ✅ - Done-script dynamic bound on captured dynamic renders (P1):
app-page-render.ts:1047-1051emitsdynamicStaleTimeSecondswhenever the completedkindisdynamic, regardless of the speculative-capture decision. ✅ - Late-dynamic completion dropping the pending bound (P2): resolved. The footer always carries
serverStaleTimeSeconds(app-page-render.ts:915-921,nullor a number), sohasCompletedServerStaleTime(navigation.ts:1002) is true for a completed dynamic render and the provisional pending state is replaced (navigation.ts:1008-1012). ✅ - Footer stripping withholding a 256-byte tail (P2):
stripRscCompletionMetadatauses byte-stuffing, holds at most one ambiguous escape byte, and passes ordinary chunks through without copying (rsc-completion-metadata.ts:210-217).tests/rsc-completion-metadata.test.ts:51pins early delivery of a small Flight shell. ✅
Independent checks I ran
- Round-trip escaping discipline is airtight. The
"1"completion header means the body was byte-stuffed (needs strip/extract);"resolved"means clean body + header-only metadata (no strip). Verified every RSC-response consumer honors this: live nav strips (app-browser-entry.ts:2185,2542), snapshot buffers extract (navigation.ts:995), andpreparePrefetchResponse(link.tsx:901→app-browser-entry.ts:2404) only ever receives already-decoded snapshot buffers viarestoreRscResponse, which sets"resolved"(never"1") and never re-escapes. No consumer feeds escaped bytes to React. - Byte-stuffing is unambiguous:
escapeFlightChunkdoubles every0xff, so a real0xff 0x00in Flight data decodes as two bytes and can never collide with the0xff 0x00footer tag. The every-byte-split test (:98-124) confirms escape pairs never split across chunk boundaries. - Fail-closed framing: invalid escapes, truncated footers, oversized (>256B) frames, and multiple frames all reject in both the buffered and streaming decoders; trailing bytes after a footer are caught by the exact-length check in
parseFooterCandidate(:58). isrSetpositional→policy refactor updated every caller, including thepages-basicfixture endpoint and the Pages RouterwritePagesIsrCache(revalidate: falsestill round-trips viaisrCacheControl(false)).- Snapshot seeding (
app-browser-entry.ts:2269-2296) refuses to publish a completed-dynamic response as a reusable static snapshot. - Ran
tests/rsc-completion-metadata,tests/prefetch-cache,tests/app-page-cache,tests/shims,tests/kv-cache-handlerlocally — all green (1447 tests).
The two documented known limits (edge-CDN no-op set() capping RSC reuse at the 30s floor; late-dynamic streaming not enforcing staleTimes.dynamic below 30s at header time) are deliberate trade-offs tied to #961 and reasonable given the streaming model.
One minor non-blocking note inline — the only thing still worth touching.
| cacheLife: { stale?: number } | null | undefined, | ||
| ): number | undefined { | ||
| const stale = cacheLife?.stale; | ||
| return isFiniteNonNegative(stale) ? stale : undefined; |
There was a problem hiding this comment.
Minor / non-blocking (raised by several prior reviews, still uncommented in code): resolveClientStaleTimeSeconds returns 0 for cacheLife({ stale: 0 }) because 0 is finite and non-negative, so it flows through applyClientStaleTimeHeader (which correctly gates on === undefined, not falsy) and emits a real x-nextjs-stale-time: 0. The client then floors it to 30s via Math.max(seconds, 30), matching Next.js getStaleTimeMs — so behavior is correct.
The undefined-vs-0 boundary is load-bearing for the "absent = no claim" invariant this resolver enforces. Worth a one-line note so a future reader doesn't "simplify" the finite check to a truthy check and silently reclassify stale: 0 as "no claim" (which would then let the configured staleTimes fallback license far more reuse than the render declared):
| return isFiniteNonNegative(stale) ? stale : undefined; | |
| export function resolveClientStaleTimeSeconds( | |
| cacheLife: { stale?: number } | null | undefined, | |
| ): number | undefined { | |
| // `stale: 0` is a real claim (floored to 30s client-side), NOT "no claim". | |
| // The undefined-vs-0 boundary is load-bearing: only an absent `stale` means | |
| // "no client-freshness claim" and lets the configured fallback apply. Do not | |
| // collapse this to a truthy check. | |
| const stale = cacheLife?.stale; | |
| return isFiniteNonNegative(stale) ? stale : undefined; | |
| } |
|
Review posted successfully. Here's a summary of what I found: Review Summary for PR #2708I reviewed the full diff at head ( Notable finding while verifyingThe Verified correct
One minor non-blocking noteThe |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff at head (0d4532a), tracing every emitter and consumer of the new stale / serverStaleTime / completion-footer machinery independently. No blocking issues.
Architecture
Sound. resolveClientStaleTimeSeconds (utils/cache-control-metadata.ts:41) is a single projection routed through every write/replay/seed path; the two documented normalization rules (never synthesize stale from revalidate/expire, never clamp) hold — line 45 is isFiniteNonNegative(stale) ? stale : undefined. stale never enters streaming Cache-Control; it travels only on completion-time channels: ISR write, HIT/STALE replay, done-script, prerender seed, or the post-Flight footer.
The minor note raised across every prior review — the undefined-vs-0 boundary being load-bearing — is now documented in the resolver JSDoc (cache-control-metadata.ts:38-39, added in the docs(cache) commit at head). Nothing left to flag there.
Prior findings — all resolved at head
- KV
staleround-trip (P1):kv-data-adapter.runtime.ts:369,391persistscacheControl.stale;validateCacheEntry:583accepts it on read;prerender-kv-populate.tscarriesroute.staleinto bulk-import entries. ✅ - Prerender index projection (P1):
PrerenderRouteResult.staledeclared and copied bywritePrerenderIndex;seedMemoryCacheFromPrerenderthreads it throughstaleSeconds. ✅ - Nested
use cacheHIT propagation (P1):recordRequestScopedCacheControl(cache-runtime.ts:698) pushes the stored lifetime into the enclosinglifeConfigs(:712, mirroring the MISS path) and only when a parent scope exists. ✅ - Done-script dynamic bound on captured dynamic renders (P1):
app-page-render.ts:1047-1051emitsdynamicStaleTimeSecondswhenever the completedkindisdynamic, regardless of the speculative-capture decision. ✅ - Late-dynamic completion dropping the pending bound (P2): resolved. The completion footer for a dynamic render always includes
serverStaleTimeSeconds(app-page-render.ts:917-920emitsnullor a number), sohasCompletedServerStaleTime(navigation.ts:1002-1003) is true for a completed dynamic render and the provisional pending state is replaced (navigation.ts:1008-1012). ✅ - Footer stripping withholding a 256-byte tail (P2):
stripRscCompletionMetadata(rsc-completion-metadata.ts:167) uses byte-stuffing, holds at most one ambiguous escape byte, and passes ordinary chunks through without copying (:210-217).tests/rsc-completion-metadata.test.ts:51pins early delivery of a small Flight shell. ✅
Independent checks
- Byte-stuffing is unambiguous and fail-closed.
escapeFlightChunkdoubles every0xff, so a real0xff 0x00in Flight data decodes as two bytes and can never collide with the0xff 0x00footer tag. The every-byte-split test (:98-124) confirms escape pairs never split across chunk boundaries. Multiple frames are caught by the exact-length check inparseFooterCandidate:58(payloadLength !== lengthOffset - FOOTER_PREFIX_BYTES); invalid escapes, truncation, and oversized frames all reject in both decoders. - Escaped-vs-clean body contract is honored. The completion header
"1"means the body was byte-stuffed (strip/extract required);restoreRscResponse(navigation.ts:1089) sets"resolved"on a snapshot buffer that was already extracted, so no consumer ever re-strips or feeds escaped bytes to React. isrSetpositional→policy refactor updated every caller, including thepages-basicfixture endpoint (seed-revalidate-cache-headers.ts:19-24, now passing{ cacheControl: { revalidate: 3600 } }).- Hydration seeding (
app-browser-entry.ts:1528-1568) carries the done-scriptstaleTimeSecondsonto the visited-response snapshot as aresolvedserver stale time. - Ran
tests/rsc-completion-metadata.test.ts(16) andtests/prefetch-cache.test.ts(51) locally at head — all green.
The two documented known limits (edge-CDN no-op set() capping RSC reuse at the 30s floor; late-dynamic streaming not enforcing staleTimes.dynamic below 30s at header time) are deliberate trade-offs tied to #961 and reasonable given the streaming model. LGTM.
|
Review posted successfully. Here's a summary of what I found: Review Summary for PR #2708I reviewed the full diff at head ( Key findingsArchitecture is sound. All prior review findings are resolved at head, verified against the actual code:
The one recurring minor note (the Independent verification:
The two documented known limits are deliberate trade-offs tied to #961 and reasonable. |
# Conflicts: # packages/vinext/src/shims/navigation.ts
Overview
cacheLife.stale, the client-reuse dimension of a cache profile that vinext computed and then discardedresolveClientStaleTimeSeconds, carries the completed render's minimumstaleonto every artifact that outlives the render: the ISR entry, the prerender seed, the initial-HTML done-script, and the regenerated entry. Cache hits replay it asx-nextjs-stale-time; both client caches bound reuse by itstaleis a property of a completed render (aggregation is minimum-wins, anduse cachescopes keep registering while the stream is consumed), so it travels only on completion-time channels: durable cache metadata, the HTML done-script, or an internal trailer after the Flight payloadcacheLife("seconds")(stale: 30) is reused by the browser for at most 30s instead of the 5-minute client cache TTLWhy
cacheLifeprofiles carry three independent numbers.revalidateandexpiregovern server and shared caches;staleis the contract with the client router: how long the browser may reuse route output without asking the server. vinext min-reducesstaleacross alluse cachescopes of a request and then projected it out of every consumer, so the only freshness signal a browser ever saw was the build-timeexperimental.staleTimesconstant. Request-scoped declarations had no observable effect on navigation freshness.The constraint that shapes the design: a streaming response's headers cannot carry its own resolved
cacheLife. Headers commit when the Flight stream is created; child Server Components registeruse cachescopes while the stream is consumed. Because aggregation is minimum-wins, a value read at header time is not approximately right, it is unbounded too large. The value is therefore read only at completion time and attached to durable artifacts:app-page-cache-finalizer.ts)CacheControlMetadatagainsstale?; the write policy persists it via the newAppPageCacheSetterpolicy object (isrSetAppPage)app-page-cache.ts)x-nextjs-stale-timefrom entry metadata, unaged and unclamped;expirestays a serve-side ceiling (expired entries are blocking misses)app-page-cache-render.ts)staleon itscacheControlinto the refreshed entrybuild/prerender.ts,seed-cache.ts)VINEXT_PRERENDER_CACHE_LIFE_HEADERcarriesstaleinto the route results;PrerenderRouteResultdeclares it andwritePrerenderIndexcopies it intovinext-prerender.json, where seeding reads itkv-data-adapter.runtime.ts,prerender-kv-populate.ts)set()persistscacheControl.stale(validated on read); deploy-time KV population carriesroute.staleinto bulk-import entriesapp-ssr-stream.ts,app-page-render.ts)finalize()after the full RSC drain, so it is a completion-time channelcacheLifeand shipsstaleTimeSeconds; hydration seeds the visited-response entry from it, identically in dev and prodshims/cache-runtime.ts)use cacheentry write persistsstale; a warm hit re-registers it on the request scope and, when nested inside anotheruse cache, pushes it into the enclosing scope'slifeConfigsexactly like the MISS path — so the outer entry keeps the child's claim once both go warmapp-page-render.ts,shims/navigation.ts)X-Vinext-Stale-Time-Pending: 1; both client caches cap such a response at the 30s floor instead of their fallback TTLsTwo normalization rules, documented at the resolver: an absent
staleis never synthesized fromrevalidateorexpire(thedefaultprofile would license ~136 years of reuse), and no other field constrains it (secondsis{ stale: 30, revalidate: 1, expire: 60 }on purpose; Next.js floors the replayed value at 30s client-side, so client reuse may overshootexpireby design).What changed
staleTimes/ 5-minute fallbackstalereplayed verbatim on both artifacts;Cache-Controlunchangedstale; a dynamic render carries both signals and the client takes the minimumstalestaleTimes.dynamic; the dynamic response is not published as a reusable static snapshotcacheLife({ stale: 5 })getStaleTimeMs), whether the route arrived via prefetch or cold navigationstaledropped at serialization; fallbackcacheControl.staleround-trips through KV and deploy-time bulk importuse cache, inner entry warmstaleanywherestalenever entersCache-Control, and cold responses stay non-blocking (#961)Maintainer review path
packages/vinext/src/utils/cache-control-metadata.ts: the shared projection and its two normalization rules; every emitter goes through it.packages/vinext/src/server/app-page-cache-finalizer.ts: why the write-time read is sound (post-drain) and howstaleenters the entry.packages/vinext/src/server/app-page-cache.ts: verbatim replay on HIT/STALE and the regeneration policy.packages/vinext/src/server/app-page-render.ts: the RSC branch, completion metadata appended after Flight, and the done-script metadata getter; streaming headers remain provisional because they cannot carry the completed value.packages/vinext/src/shims/navigation.ts: the client resolver (min of both lattices, uniform 30s floor, pending handling) and snapshot/replay round-trip.packages/vinext/src/server/app-browser-entry.ts: hydration seeding and the visited-response store.packages/vinext/src/shims/cache-runtime.ts,build/prerender.ts,server/seed-cache.ts: the propagation links that would otherwise silently drop the value.Validation
{ stale: 30, revalidate: 1, expire: 60 }persists 30 (revalidatedoes not clamp);{ stale: 300, expire: 45 }persists 300 (expirenever clamps); a streaming render's headers advertise nothing even with a page-level claim (regression guard for the header-time approach).default-shaped entries emit no header.cacheControl(tests/app-page-cache-render.test.ts; fails without the producer fix).staleTimes.dynamic, skipped ISR publication, and no stale prefetch snapshot; thesegment-cache-client-paramscompat E2E pins full prefetch reuse of statically-prefetchable dynamic-param routes.cacheControl.staleround-trip throughKVCacheHandlerand deploy-time bulk-import entries;writePrerenderIndexregression test pinsstaleinvinext-prerender.json; the nested-HIT test fails without thelifeConfigspropagation fix (verified red).vp checkand the affected suites locally; CI runs the full suite and all E2E projects.Risk / compatibility
CacheControlMetadata.staleis optional; entries written before this change simply emit no header and clients keep their configured fallback.x-nextjs-stale-time, matching Next.js, plus vinext-internal pending/completion markers). When dynamic usage is discovered after headers commit, a bounded internal footer follows the Flight payload; vinext strips it before React consumes the stream and records the resolved dynamic bound in the client snapshot.set()is a no-op, the streamed response is the cached artifact, so edge HITs replay the pending marker and RSC navigations stay capped at the conservative 30s floor instead of a wider completed claim. Attaching the completed claim would require blocking the stream (rejected in fix(isr): honor route expire ceilings #961); dropping the marker would widen reuse beyond the declaration. The initial-HTML path is unaffected — the done-script travels in the body.cacheLifesignals by minimum instead of header-wins (vinext keeps the config signal as a separate carrier), and the done-script advertises the claim in dev where Next.js emits nothing.stalenever entersCache-Control;expirehandling is unchanged.Non-goals
waitUntilwrite. The pending marker bounds the residual window instead.StaleTimeIterablepayload-field transport: it depends on cacheComponents staged rendering. Vinext instead uses a small internal footer appended only after the lazy Flight stream completes, keeping the React payload and completion signal separately decodable.MAX_TRAVERSAL_CACHE_TTLbfcache semantics.Review hardening
The completion transport now carries both client-freshness lattices after the Flight stream drains:
dynamicStaleTimeSecondsplus the completedcacheLife.staleresult. A numeric cacheLife result replaces the provisional pending marker with a resolved minimum; explicitnullproves that the completed dynamic render had no cacheLife claim; an absent field conservatively retains the 30s pending floor.The footer uses byte-stuffed framing rather than a magic string: ordinary Flight
0xffbytes encode as0xff 0xff, leaving0xff 0x00as an unambiguous footer tag. Buffered prefetch extraction and streaming HMR/React normalization share the same strict footer validator and fail closed on invalid escapes, truncated, oversized, malformed, or multiple frames. The streaming decoder holds at most one ambiguous escape byte, passes ordinary chunks through without copying, and no longer retains an arbitrary 256-byte Flight tail until close.Review regressions cover every split of encoded framing, ordinary reserved bytes in buffered and streaming paths, short footerless payloads, malformed-frame rejection, cancellation/error propagation,
dynamic=300combined with completedcacheLife.stale=30, and the no-cacheLife dynamic 60s production navigation boundary.References
app-render.tsx(applyMetadataFromPrerenderResult)app-page-runtime.tscached-header replayserver/app-render/stale-time.tsStaleTimeIterabletransport this PR deliberately does not port (see non-goals)No changeset: this repo generates them from merged SHAs via
scripts/create-changeset.mts.