cloudflare / cloudflare/vinext

Cache Components: exclude short-stale `'use cache'` entries from BOTH static and runtime prerenders (drop static/runtime shell asymmetry)

Open
#2,002 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.