cloudflare / cloudflare/vinext
Cache Components: exclude short-stale `'use cache'` entries from BOTH static and runtime prerenders (drop static/runtime shell asymmetry)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`d056208`](https://github.com/vercel/next.js/commit/d0562082e9a24db6a0d421d41a625889a1a56ef1)
**PR:** [#94678](https://github.com/vercel/next.js/pull/94678)
## What changed
Brings static prerenders into line with runtime prerenders for short-stale `'use cache'` entries. Previously, an entry with `stale < 30s` was excluded from the **runtime** prefetch shell only — it was still included in the **static** shell. As of #94645 (#1919), the matching dev-render path also gated this exclusion on `workUnitStore.shellStage === RenderStage.Runtime`. This commit removes the asymmetry: short-stale entries are excluded from **both** kinds of prerender, and the `shellStage` distinction is deleted from the work-unit store entirely.
The rationale: a static prerender shouldn't contain data that the corresponding runtime prerender of the same page would omit. Both have the same motivation — if data goes stale that quickly, it's not worth prefetching.
### Mechanism (from the diff)
The renamed constant captures the new semantics:
```ts
// packages/next/src/server/use-cache/constants.ts
// Was: export const RUNTIME_PREFETCH_DYNAMIC_STALE = 30
export const DYNAMIC_STALE = 30 // 30 seconds
```
Both short-stale gates in `use-cache-wrapper.ts` now treat `prerender` (static) the same as `prerender-runtime`:
```ts
// packages/next/src/server/use-cache/use-cache-wrapper.ts
if (rdcResult.entry.stale < DYNAMIC_STALE) {
switch (workUnitStore.type) {
case 'prerender': // <-- newly included
case 'prerender-runtime':
// If the cache entry will become stale in less than 30 seconds, we
// consider this cache entry dynamic as it's not worth prefetching.
// ... throw new DynamicError(...)
case 'request': {
// A short stale time excludes the entry from prerenders.
// We delay it here to match that.
if (process.env.NODE_ENV === 'development') {
// (defer to runtime, end cache-signal read, etc.)
}
break
}
// prerender-ppr / prerender-legacy / cache / etc. unchanged
}
}
```
The dev-render request-path branch is **no longer gated on `shellStage === Runtime`** — it fires for every dev request when the entry is short-stale.
### Surface deletions
The `shellStage` field is dropped entirely from the work-unit store:
```ts
// packages/next/src/server/app-render/work-unit-async-storage.external.ts
export interface RequestStore extends CommonWorkUnitStore {
// ...
// (deleted) shellStage?: RenderStage.Static | RenderStage.Runtime
}
```
`setUpStagedDevRender`, `runDevValidationInBackground`, `renderWithWarmCachesForValidationInDev`, and `stagedRenderWithCachesInDev` all lose the `shellStage` parameter. The warm-validation render no longer mirrors a stage onto the request store.
### Test changes
- New `cache-life-short-stale` fixture (long expire, long revalidate, `stale: 18 < 30`) — disable JS, hit the page, and assert the cache resolves to a Suspense fallback in the static shell (omitted), then re-enable JS and assert it resolves to the actual cached value.
- Existing `use-cache` test fixture's `cacheLife.frequent.stale` bumped from `19` to `30`, with a comment that `>= DYNAMIC_STALE` is required to stay eligible for the static shell.
- The cache-life route is added to the static prerender list **only when cache components is off**; with cache components on it becomes a dynamic hole.
- Inline meta-file assertions updated: `x-nextjs-stale-time: 19` → `30`.
### Net effect
`cacheLife({ stale: 18, revalidate: 100, expire: 1000 })` under Cache Components used to render as a fully-resolved value in the static HTML and stream as a Suspense fallback on a runtime prefetch. It now renders as a Suspense fallback on both — the runtime fills it in via dynamic data.
## Impact on vinext
Two related impacts:
### 1. Reclassify the recommendation in #1919
#1919 ("Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries") describes the dev-render cache-signal accounting fix from #94645, and explicitly recommends honoring a static-vs-runtime asymmetry:
> A short `stale` excludes an entry from the **runtime prefetch shell** but not from the **static shell**. The asymmetry must be honored in dev too…
>
> **`shellStage` parity:** the static-shell vs runtime-prefetch-shell asymmetry for short `stale` is a request-level property, not an entry-level one. Whatever vinext uses to represent the current render's shell stage must be threaded into the same place `'use cache'` decides whether to defer on short stale.
This commit reverses that direction. The `shellStage` distinction is gone, and the deferral fires uniformly. When vinext implements the dev cache-signal fix, it should **not** thread a `shellStage` through the request store. Update or close out the `shellStage` portion of #1919 to match the new direction; the cache-signal balance / plain-stream-on-defer guidance is still correct.
### 2. Wire short-stale exclusion into static prerenders (when CC lands)
When vinext implements Cache Components and the equivalent of Next.js's static prerender (`prerender` work-unit type), the short-stale gate must include `prerender` alongside `prerender-runtime`. The threshold (`DYNAMIC_STALE = 30`) is a single shared constant; both kinds of prerender throw a `DynamicError` (or its vinext equivalent) when reading a `'use cache'` entry whose `stale < 30`. In dev requests, both also defer the entry to a later staged-rendering boundary.
Concretely, when adding `'use cache'` support:
1. **One short-stale constant for all prerender flavors.** Don't keep a separate `RUNTIME_PREFETCH_DYNAMIC_STALE` for runtime renders; use `DYNAMIC_STALE` for both static and runtime.
2. **`prerender` and `prerender-runtime` share the exclusion path.** Whatever vinext uses to represent "static prerender" must take the same branch as the runtime prefetch render: dynamic-error / abort the cache read, omit the entry from the prerendered output.
3. **Dev deferral is unconditional.** When `process.env.NODE_ENV === 'development'` and the dev render encounters a short-stale entry on a `request` work-unit, defer it to a runtime stage regardless of which shell is being produced. No need to plumb a stage flag into the request store.
4. **Routes-manifest / dynamic-route classification.** Without Cache Components a `cache-life`-style page is fully static; with Cache Components and a short-stale entry it becomes a dynamic hole. Whatever vinext does to classify routes as static vs partial vs dynamic needs to flag short-stale `'use cache'` entries as dynamic holes when CC is on.
### Notes
- This does **not** change behavior for fully-static routes that don't go through Cache Components. Apps that don't opt into CC won't see a difference.
- The 30-second threshold is the same; only the set of work-unit types that exclude on it has changed.
- The `prerender-ppr`, `prerender-legacy`, and `cache` work-unit types continue not to short-stale-exclude. Only the new-shape `prerender` and `prerender-runtime` apply the rule.
## Related
- #1919 — Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries (this commit removes the `shellStage` portion of that fix)
- #1936 — Dev cache handler: serve stale `'use cache'` entries until `expire` (not `revalidate`) in dev
- #1937 — Persist `'use cache: private'` entries in dev (request-scoped handler)
- #1696 — App Router cacheComponents: skip metadata/viewport in error recovery RSC payload
- #1126 — Detect `'use cache'` module-scope deadlocks early in dev
Contributor guide
Assessment
This issue has not been assessed yet.