cloudflare / cloudflare/vinext
Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries (phantom-miss fix)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Next.js Change
**Commit:** [`073bb78`](https://github.com/vercel/next.js/commit/073bb783ea757572e1294f64c916f5b7619fb33e)
**PR:** [#94645](https://github.com/vercel/next.js/pull/94645)
## What changed
A dev-only correctness fix for how short-lived `'use cache'` entries interact with the staged streaming dev render under Cache Components.
### The bug
When a `'use cache'` entry is short-lived (`revalidate: 0`, a short `expire`, or a short `stale`), the streaming dev render **defers** it to a later staged-rendering boundary — the entry is dynamic data, not part of the static shell. Previously the dev render left the underlying `cacheSignal` **read open** when deferring. At the next staged-rendering boundary that open read looked like a pending cache read, so even a warm handler hit was classified as a cache miss. Two visible consequences:
1. The cold-cache dev indicator lit up on every load, even with a fully warm cache.
2. Validation was routed through the background warm render rather than serving the streamed response directly.
### The fix
Mirror what the prerender path already does: when a short-lived entry is deferred, end the `cacheSignal` read immediately, and serve the buffered value through a **plain** stream rather than one wrapped in `createTrackedReadableStream` (which would re-couple it to the signal). A new `cacheSignalReadEnded` local tracks this so the same read isn't ended twice, and the regenerate path re-begins the read (`cacheSignal.beginRead()`) before generating, so the trailing `endRead` issued by the generation stays balanced.
### The runtime-prefetch 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: a fresh entry with a short stale time should stay in the static shell on initial loads, plain navigations, and HMR refreshes, and only be excluded on client navigations into runtime-prefetch routes.
To express this, the work-unit store now carries a `shellStage` (`Static` vs `Runtime`), the warm validation render mirrors it, and the short-stale dev deferral is gated on `workUnitStore.shellStage === RenderStage.Runtime`.
### Files changed (non-test)
- `packages/next/src/server/app-render/app-render.tsx` (+11/-4) — thread `shellStage` through the request store and the warm validation render.
- `packages/next/src/server/app-render/work-unit-async-storage.external.ts` (+7/-0) — add `shellStage` to the work-unit store and `RenderStage` enum surface.
- `packages/next/src/server/use-cache/use-cache-wrapper.ts` (+117/-35) — core of the fix: end `cacheSignal` read on deferral, gate short-stale deferral on `shellStage === Runtime`, re-begin the read on regenerate, and serve via plain stream when the signal read has already been ended.
### Test coverage
- New `short-stale-cache` fixture (with and without `prefetch-config`) exercises the static-vs-runtime asymmetry: a long-revalidate / short-stale / long-expire entry must resolve in the static stage on initial load + plain navigation, and only resolve dynamically on a navigation into a runtime-prefetch route.
- Previously-skipped short-lived warmup case re-enabled.
- Cache-indicator short-lived test now asserts cold badge on cold load, **not** on warm reload.
## Impact on vinext
vinext does not yet have a streaming staged dev render that mirrors Next.js's static-shell / runtime-prefetch-shell / dynamic distinction for `'use cache'` (see #1126, #1194). When it does, this is a correctness gotcha worth porting:
1. **Cache-signal accounting:** any `cacheSignal`-equivalent must be balanced across deferred reads. If vinext's dev render defers a short-lived `'use cache'` entry to a later stage, it must end the read at the deferral point, and re-begin it if it later regenerates. Otherwise a warm handler hit looks like a miss at the next stage boundary.
2. **Tracked vs plain streams:** once the signal read has been ended for a deferred entry, the served stream must be a plain `ReadableStream`, not one tracked by the cache signal — otherwise the signal goes negative when the stream finishes.
3. **`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.
4. **Warm-validation mirror:** if vinext runs a background warm validation render (the moral equivalent of Next.js's), it must inherit the same `shellStage`, or the validation render disagrees with the streaming render about which entries to include in the shell.
The core lesson: vinext's `'use cache'` dev semantics need to distinguish "deferred to runtime stage" from "cache miss," and the cache-signal-style bookkeeping has to be balanced across the deferral.
## Related
- #1126 — Detect `'use cache'` module-scope deadlocks early in dev
- #1194 — Attach inner `'use cache'` call site as cause of nested-dynamic prerender error
- #1696 — Cache Components error recovery RSC payload
Contributor guide
Assessment
This issue has not been assessed yet.