cloudflare / cloudflare/vinext

Cache Components: `'use cache'` called after prerender aborts must error, not fill an empty entry (cache-poisoning fix)

Open
#2,812 0 comments 0 reactions 0 assignees View on GitHub
nextjs-tracking
Dominant language
TypeScript
Stars
8.8k
Forks
406
Avg merge
2d 11h
Merged PRs (30d)
122

Description

## Next.js Change

**Commit:** [`3317392`](https://github.com/vercel/next.js/commit/331739243f768934efc895f5881a407bb979950a)
**PR:** [#96426](https://github.com/vercel/next.js/pull/96426)

## What changed

Fixes cache poisoning where a `'use cache'` entry that starts filling **after** a prerender has already been aborted would save an empty stream, poisoning the cache. This happens when uncached ("tasky") IO runs before a cache read:

```js
await setTimeout(100) // uncached IO we can't abort...
await cachedData() // ...so we still reach the cache read even though the prerender aborted
```

Because the aborted `renderSignal` was included in the `AbortSignal.any([...])` passed into `prerender()` inside `use-cache-wrapper`, the signal was aborted immediately, producing an empty entry that then got saved.

Closes vercel/next.js#96339.

### Mechanism (from the diff)

Two changes in `packages/next/src/server/use-cache/use-cache-wrapper.ts`:

**1. Remove `renderSignal` from the abort composition** used inside cache generation, so a already-aborted prerender doesn't immediately abort the cache fill into an empty stream:

```ts
const abortSignal = dynamicAccessAbortSignal
? AbortSignal.any([
dynamicAccessAbortSignal,
// outerWorkUnitStore.renderSignal, <-- removed
timeoutAbortController.signal,
])
: timeoutAbortController.signal
```

**2. Short-circuit `cache()` when the prerender is already aborted**, returning an erroring/untracked hanging promise instead of proceeding to fill (and save) the cache:

```ts
const workUnitStore = workUnitAsyncStorage.getStore()
if (workUnitStore === undefined) {
throw new InvariantError('"use cache" cannot be used outside of App Router. ...')
}

switch (workUnitStore.type) {
case 'prerender':
case 'prerender-runtime': {
if (workUnitStore.renderSignal.aborted) {
// The prerender is over, so return an erroring promise and DON'T fill
// the cache (it's behind uncached IO, so it's not part of the prerender).
return makeUntrackedHangingPromise(
workUnitStore.renderSignal,
workStore.route,
'"use cache" called after prerender ended'
)
}
break
}
case 'prerender-ppr':
case 'prerender-legacy':
case 'prerender-client':
case 'validation-client':
case 'request':
case 'cache':
case 'private-cache':
case 'unstable-cache':
case 'generate-static-params':
break
default:
workUnitStore satisfies never
}
```

A new `makeUntrackedHangingPromise` helper is added in `dynamic-rendering-utils.ts`. The existing `workUnitStore === undefined` invariant check was moved earlier so the new switch can read it. The rejected promise is returned before the cache-filling codepath, and the PR notes this should not be observable to userspace because the prerender is already aborted.

## Impact on vinext

vinext is building `'use cache'` support (see the cache cluster below). Any implementation that fills a cache entry during a prerender must handle the case where uncached IO delays a cache read until **after** the prerender's render signal has aborted. Otherwise vinext can persist an empty/partial cache entry and serve it on subsequent requests — a correctness/cache-poisoning bug.

What to check/do:

1. **Don't compose the prerender's render signal into the cache-fill abort signal** such that an already-aborted prerender immediately produces an empty entry. Keep the timeout / dynamic-access signals, but drop the render signal from the fill path.
2. **Short-circuit cache reads after prerender abort.** When `'use cache'` is invoked during a `prerender` / `prerender-runtime` work unit whose render signal is already aborted, return an erroring/hanging promise and **do not fill or save** the entry. It sits behind uncached IO, so it is semantically not part of the prerender.
3. **Keep the untracked semantics.** The rejected promise should not participate in runtime-data tracking (the prerender is over). Model this in whatever vinext uses for hanging/aborted promises.
4. **Port the test.** Next.js added `test/e2e/app-dir/use-cache-after-uncached-io/` — a page/route that awaits uncached IO (e.g. `setTimeout`) then reads a `'use cache'` value, asserting the entry is not poisoned with an empty stream.

### Notes

- Relevant only once vinext implements `'use cache'` / Cache Components prerenders. It is a distinct correctness fix (cache-fill-after-abort) not covered by the existing short-stale / dev-signal cache issues below.
- The `request`, `cache`, `unstable-cache`, and legacy/ppr prerender work-unit types are intentionally unaffected — only the new-shape `prerender` / `prerender-runtime` paths short-circuit on abort.

## Related

- #2002 — Cache Components: exclude short-stale `'use cache'` entries from BOTH static and runtime prerenders
- #1919 — Cache Components dev: end cache-signal read for deferred short-lived `'use cache'` entries (phantom-miss 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)
- #1126 — Detect `'use cache'` module-scope deadlocks early in dev

Contributor guide

Open the contributing guide

Research direction

Start with vinext's cache-fill path; the payload does not name its implementation file. Compare it with packages/next/src/server/use-cache/use-cache-wrapper.ts and dynamic-rendering-utils.ts, then port test/e2e/app-dir/use-cache-after-uncached-io/. Done means an aborted prerender neither fills nor saves an empty cache entry, with the delayed read covered by a test.

Written by the indexing model from the issue text.

Assessment

Tech stack
next.js, typescript
Domain
backend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.