Skip to content

Commit a3a6cf7

Browse files
hi-ogawaOpenCode
andauthored
docs(rsc): clarify PPR render passes (#1305)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent f4b5498 commit a3a6cf7

2 files changed

Lines changed: 37 additions & 12 deletions

File tree

packages/plugin-rsc/examples/ppr/README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The PPR-relevant shape in [`src/root.tsx`](./src/root.tsx) places cached and dyn
3232
| Component | Behavior |
3333
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3434
| `CachedLayout` | Caches the document frame, navigation, and its render timestamp. Its cached bytes contain temporary-reference placeholders rather than concrete `children`, so each invocation can supply fresh values through those slots. |
35-
| `CachedAsyncContent` | Waits 100 ms on a cache miss. The warmup pass tracks that fill, materializes its Flight result, and reuses it in later renders. |
35+
| `CachedAsyncContent` | Waits 100 ms on a cache miss. The RSC prerender tracks that fill, materializes its Flight result, and reuses it in later renders. |
3636
| `DynamicContent` | Calls `markDynamic()`. It returns a pending promise during prerender and `undefined` during a request render, so the request continues immediately into its request-time work. |
3737

3838
`createCachedComponent` gives this example the semantics of a small `"use cache"` runtime without requiring a compiler transform. Its argument and result serialization follows [`examples/basic/src/framework/use-cache-runtime.tsx`](../basic/src/framework/use-cache-runtime.tsx). Temporary references are important for `CachedLayout`: its cache key and bytes retain reference markers while the matching reference set supplies the concrete dynamic `children` on each invocation.
@@ -52,7 +52,7 @@ dynamic boundary reached
5252
-> ready -> abort and retain partial RSC output
5353
```
5454

55-
The two branches are both necessary. A fully static render does not call `markDynamic()`, so its `ready` promise remains pending and `result` wins naturally. A partial render may be cut off only after `dynamicReached` proves that its suspension is intentional and `pendingWork` proves that no tracked cache fill can add more static output.
55+
Both branches of this race are necessary. A fully static render does not call `markDynamic()`, so its `ready` promise remains pending and `result` wins naturally. A partial render may be cut off only after `dynamicReached` proves that its suspension is intentional and `pendingWork` proves that no tracked cache fill can add more static output.
5656

5757
[`src/framework/prerender-context.ts`](./src/framework/prerender-context.ts) implements this compact model with `AsyncLocalStorage`, a set of cache promises, and one `setTimeout(0)` retry window. Production frameworks make the same kind of framework-defined approximation with more complete cache, module, and scheduler tracking.
5858

@@ -82,19 +82,32 @@ Applications should use the `@vitejs/plugin-rsc` exports rather than importing i
8282

8383
## Build flow
8484

85-
Each static path goes through three render steps:
85+
This example sends each static path through three render steps:
8686

87-
1. **Warm the RSC cache.** `@vitejs/plugin-rsc/rsc/static` `prerender()` discovers cache misses and waits for their useful static work to finish. Its output is discarded because it records the process of filling the cache rather than the final warm-cache shell.
87+
1. **Warm the RSC cache.** `@vitejs/plugin-rsc/rsc/static` `prerender()` discovers cache misses and waits for their useful static work to finish. The demo intentionally discards this result to keep cache discovery and shell capture as separate stages.
8888
2. **Capture partial Flight.** The same RSC `prerender()` renders a clean pass against the warm cache. Cached content is immediately available, while `DynamicContent` remains pending at `markDynamic()`. Cutting off here produces the static Flight prefix around that intentional hole.
8989
3. **Capture resumable HTML.** `react-dom/static.edge` `prerender()` consumes the decoded partial Flight tree. `preventStreamClose` keeps the missing segment pending, so React DOM emits an HTML `prelude` for the shell and serializable `postponed` state describing where request-time rendering must continue.
9090

91+
The restart is illustrative rather than required by this compact runtime. Its readiness gate waits for every tracked cache fill to settle and gives React a retry turn before cutoff, so the first RSC prerender already returns a valid partial Flight prelude that could be passed directly to React DOM. Keeping a second pass makes cache discovery and final shell capture explicit and mirrors production architectures where those phases have different contracts.
92+
93+
A useful observation falls out of comparing real frameworks. The specific API that generates each RSC stream does not actually matter, because neither the cache-discovery pass nor the final Flight fed to SSR is bound to the static `prerender()` prelude rather than a plain `renderToReadableStream()`. What matters is that whichever call generates the stream is wrapped in precise control over readiness, staging, and cutoff. Next.js and vinext even reach for the two APIs in opposite arrangements. Next.js lets an initial `prerender()` prelude drive a prospective React DOM render, while its final RSC pass runs `renderToReadableStream()` under framework-controlled staging and cutoff. vinext does the reverse, warming its cache by draining an ordinary `renderToReadableStream()`, then producing its final Flight with the static `prerender()` prelude.
94+
95+
So separate phases are demanded by that control layer rather than by the API or by pending cache fills. Cache discovery runs without the tracking, metadata, staging, and cutoff that the authoritative artifact requires, so it cannot double as the final pass. Separate phases are not required merely because cache fills were initially pending.
96+
97+
- [Next.js initial phase omits final-render semantics](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L7957-L7984)
98+
- [Next.js initial RSC prelude drives prospective React DOM work](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L8056-L8147)
99+
- [Next.js final RSC staging and React DOM prerender](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L8230-L8607)
100+
- [vinext warmup stream drain and abort](https://github.com/cloudflare/vinext/blob/fd1cc3d3ddaaec8c130d5e4bcae3a6f761089756/packages/vinext/src/server/app-ppr-fallback-shell-render.ts#L28-L55)
101+
- [vinext final RSC prelude](https://github.com/cloudflare/vinext/blob/fd1cc3d3ddaaec8c130d5e4bcae3a6f761089756/packages/vinext/src/server/app-page-render.ts#L731-L752)
102+
- [vinext final React DOM prelude](https://github.com/cloudflare/vinext/blob/fd1cc3d3ddaaec8c130d5e4bcae3a6f761089756/packages/vinext/src/server/app-ssr-entry.ts#L615-L624)
103+
91104
The build persists the shared RSC cache and each route's `{ prelude, postponed }` result. Repeating the RSC render is safe because React rendering must already be pure and restartable; the cache ensures expensive static work is performed on the miss and replayed by the final pass.
92105

93106
The demo intentionally requires a dynamic HTML hole so every generated route exercises `resume`. A complete framework would also persist and serve fully static results and other valid prerender outcomes.
94107

95108
### Development flow
96109

97-
Development runs the same warmup, final RSC prerender, and React DOM prerender on demand for each document request. It passes the live `{ prelude, postponed }` result directly into request-time resume instead of loading persisted build output.
110+
Development runs the same chosen warmup, final RSC prerender, and React DOM prerender pipeline on demand for each document request. It passes the live `{ prelude, postponed }` result directly into request-time resume instead of loading persisted build output.
98111

99112
Adding `?__ppr` exercises the persistence boundary without a production build. The dev server prerenders all static paths, serializes the shared cache and route results, then revives the selected route before serving it. The rendering model stays the same; only the handoff changes from live objects to their persisted representation.
100113

@@ -153,7 +166,7 @@ These are deliberate runtime boundaries, not attempts to detect arbitrary applic
153166

154167
## Prior art
155168

156-
- [Next.js cache warmup and final prerender](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L7905-L8490)
169+
- [Next.js prospective and final prerender implementation](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L7905-L8490)
157170
- [Next.js `CacheSignal`](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/cache-signal.ts)
158171
- [vinext fallback-shell prerender state](https://github.com/cloudflare/vinext/blob/fd1cc3d3ddaaec8c130d5e4bcae3a6f761089756/packages/vinext/src/shims/ppr-fallback-shell.ts)
159172
- [Next.js adapter PPR request flow](https://nextjs.org/docs/app/api-reference/adapters/implementing-ppr-in-an-adapter#2-runtime-flow-serve-cached-shell-and-resume-in-background)

packages/plugin-rsc/examples/ppr/src/framework/entry.rsc.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,30 @@ function stitchPprStreams(
119119
}
120120

121121
/**
122-
* Produces one route's live React DOM prerender result. The warmup pass fills
123-
* the shared RSC cache before the final RSC and HTML prerenders capture a shell.
122+
* Produces one route's live React DOM prerender result. This demo separates the
123+
* RSC cache-discovery and shell-capture stages to mirror larger frameworks.
124124
*/
125125
async function prerenderPprRoute(request: Request): Promise<PrerenderResult> {
126126
const rscPayload: RscPayload = {
127127
root: <Root url={new URL(request.url)} />,
128128
}
129-
// The discarded warmup pass discovers and fills cache entries while
130-
// request-time work remains suspended. The final pass starts from a clean
131-
// React render, reuses those entries, and captures only the resulting shell.
132-
// https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L7905-L7917
129+
// This compact runtime's first pass already returns a valid partial Flight
130+
// prelude: readiness waits for cache fills to settle and gives React a retry
131+
// turn before cutoff. We nevertheless discard it and restart so cache
132+
// discovery and authoritative shell capture remain visibly separate, as
133+
// they must in frameworks whose discovery stage lacks final-render semantics.
134+
// https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L7905-L7993
133135
// https://github.com/cloudflare/vinext/blob/fd1cc3d3ddaaec8c130d5e4bcae3a6f761089756/packages/vinext/src/server/app-ppr-fallback-shell-render.ts#L28-L55
136+
//
137+
// The API is incidental here. Only the warmup's cache fills matter, so this
138+
// first pass could instead drain a plain renderToReadableStream() and would
139+
// likely work, which is the arrangement vinext uses. Flipping the final pass
140+
// the same way is the harder direction, because a plain render errors its
141+
// pending holes on abort rather than halting them, so recovering a resumable
142+
// prelude needs the extra post-abort chunk discarding and non-closing-stream
143+
// replay that Next.js somehow manages. Keeping prerender() on both passes
144+
// keeps this runtime compact.
145+
// https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/server/app-render/app-render.tsx#L8230-L8607
134146
const warmup = await prerenderRsc(rscPayload, 'warmup')
135147
await warmup.prelude.cancel()
136148
const { prelude } = await prerenderRsc(rscPayload, 'final')

0 commit comments

Comments
 (0)