cloudflare / cloudflare/vinext

App Router PPR resume: treat empty postponed bodies as dynamic render (cold RDC resume)

Open
#1,935 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:** [`dee4137`](https://github.com/vercel/next.js/commit/dee4137fa690c9a7fbb399014b12955b1e579686)
**PR:** [#94729](https://github.com/vercel/next.js/pull/94729)

## What changed

A correctness fix for PPR resume requests where the postponed state is **present but empty**. The cold RDC (Resumable Dynamic Component) resume path needs to distinguish an absent postponed state from an empty one so cold resume requests perform a full dynamic RSC render instead of being rejected or treated as revalidation.

Previously the code used truthiness checks (`!minimalPostponed`, `!getRequestMeta(req, 'postponed')`), which collapsed two distinct cases:

- `undefined` — no postponed state attached, request is not a resume.
- `''` — empty postponed state attached, request **is** a resume but has no postponed data to splice in (cold RDC resume).

The fix introduces `hasPostponedState = typeof minimalPostponed === 'string'` and replaces every truthiness check with an explicit string-type check.

### Affected code paths

In `packages/next/src/build/templates/app-page.ts`:

1. **NEXT_RESUME_HEADER POST path** — the early branch that handles `req.headers[NEXT_RESUME_HEADER] === '1' && req.method === 'POST'` is now entered when the postponed metadata is anything other than a string, including the empty-resume case.
2. **`isDynamicRSCRequest` in minimal mode** — `isDynamicRSCRequest = isDynamicRSCRequest && hasPostponedState`. An empty postponed state now counts as having postponed state, so the request takes the dynamic path rather than being demoted to revalidation.
3. **`supportsDynamicResponse`** — the `typeof minimalPostponed === 'string'` clause stays, now via `hasPostponedState`.
4. **The "should serve from SSG cache" guard** — `!hasPostponedState` replaces `!minimalPostponed`, so a cold resume is not served from cache.
5. **Cache-control override on resume** — `if (hasPostponedState) { cacheControl = { revalidate: 0, expire: undefined } }` so an empty-resume response is not cached.

In `packages/next/src/server/base-server.ts`:

1. The "isNextDataReq && resume" rejection branch only fires when `postponed` is a string. An empty string still counts (and is rejected as before), but an absent value is no longer misclassified.
2. The same `hasPostponedState` change is applied to the GET/HEAD method guard, so resume can use non-GET/HEAD methods only when there's actual postponed-state metadata (string), not just any truthy value.

### Why this matters

PPR allows the client to request the dynamic portion of a partially-prerendered page by sending a resume request with the postponed state from the static shell. A **cold** resume happens when the dynamic portion needs to be rendered fresh without any prior postponed state — for example, an RDC ("Resumable Dynamic Component") that's resumed with empty state. The empty-string sentinel says "this is a resume request, but there's no postponed data to splice — do a full dynamic render and stream the result."

Before the fix, `''` was falsy, so the resume request fell through to either being rejected (in the isNextDataReq branch) or being treated as a revalidation (in the minimal-mode `isDynamicRSCRequest` branch). The visible symptom is RDC resume requests serving the wrong response or failing.

## Impact on vinext

vinext does not yet ship PPR resume handling, but the existing tracking issues (#1614, #1747, #1426, #1696, #1918) cover the App Shell / PPR direction. When vinext implements PPR resume, this empty-vs-absent distinction needs to be in place from the start:

1. **Use a typed sentinel, not truthiness.** Whatever vinext uses to represent postponed state on the request (request meta, request store, etc.), the "is this a resume request" predicate must be `typeof state === 'string'`, not `!!state`. The empty-resume case is legitimate.
2. **`isDynamicRSCRequest`-equivalent in minimal/edge mode** must include the empty-resume case as dynamic. Otherwise cold RDC resumes get served from the prerender cache instead of being rendered fresh.
3. **Cache-control override on resume** — any branch that overrides `revalidate`/`expire` to `0` on resume must fire for the empty case too, so the cold-resume response isn't cached.
4. **Method allow-list for resume** — POST resume requests need to be admitted when the resume metadata is present (string), not just truthy.

If vinext represents postponed state with an object rather than a string, the same logical distinction still applies: define the predicate over presence, and admit the "present but empty" case to the dynamic path.

## Related

- #1614 — App Router: client-side App Shell prefetching
- #1747 — App Router App Shell: extract shell from static prerender response via server-sent byte offset
- #1918 — App Router: propagate shell errors when resuming Node FizzStream
- #1696 — App Router cacheComponents: skip metadata/viewport in error recovery RSC payload

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.