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 10 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 0 improved · 0 regressed · 6 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". |
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, never in streaming headerscacheLife("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.dynamicbelow 30s is not enforced on this one response (see risk)cacheLife({ 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 (pending marker paired with the dynamic bound) and the done-script metadata getter; the negative guard test explains why streaming headers can never carry the 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).segment-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, and the vinext-internalX-Vinext-Stale-Time-Pending).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.staleTimes.dynamicis lower. Bounding every pending response bystaleTimes.dynamicinstead was tried and reverted: it destroys prefetch reuse of statically-prefetchable dynamic-param routes (thesegment-cache-client-paramscompat test), because a cold stream cannot distinguish the two outcomes. Eliminating the window entirely requires blocking cold renders, rejected in fix(isr): honor route expire ceilings #961.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): it exists only in cacheComponents staged renders, where cache scopes settle before the render task queue drains. In vinext's lazy streaming model the iterable could never be closed (the stream cannot end while it is open, and completion is observable only via stream end).MAX_TRAVERSAL_CACHE_TTLbfcache semantics.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.