Contents

Vite RSC · Partial Prerendering

Cached frame, dynamic hole, resumed page

One component tree is rendered three times by the RSC runtime. The useful distinction is which branches resolve, which remain suspended, and what each pass emits.

root.tsx @ 3ec1aa8 createCachedComponent markDynamic RSC static API Companion: result / ready race

The component tree

cached layout cached async work request-time RSC work
cached frame

CachedLayout

Produces the document frame, layout timestamp, and navigation. Its cached output is reusable, but its children remain a temporary-reference slot filled by the surrounding render.

children slot JSX children travel as temporary references, so the layout cache stores a reusable frame with placeholders instead of baking these child elements into the cache entry.

dynamic child

DynamicContent

markDynamic() leaves this Suspense branch pending during prerender. Its fallback becomes part of the static HTML shell.

cached async

CachedAsyncContent

The 100 ms miss is useful static work. Warmup waits for and stores it, so the final shell contains its resolved output.

Cache serialization is an existing primitive, not the PPR mechanism. The dashed child slot is the cache component temporary-reference hole: non-serializable JSX children are passed through as references rather than becoming the cached layout value. PPR adds readiness tracking for cache fills and uses markDynamic to identify the request-time hole inside that slot.
The PPR roles in root.tsx
const CachedLayout = createCachedComponent(Layout)
const CachedAsyncContent = createCachedComponent(AsyncContent)

async function DynamicContent({ url }) {
  await markDynamic() // hangs only while prerender context is active
  await delay(300)     // request-time work
  return <pre>Requested URL: {url.pathname}</pre>
}

The two React runtime layers

The similarly named APIs operate on different stream types. The RSC layer produces Flight; the React DOM layer turns decoded Flight into prerendered or resumed HTML.

RSC · Flight

react-server-dom family

Use the Vite-aware public wrappers so client references are resolved through the Vite manifest.

Build time@vitejs/plugin-rsc/rsc/static
prerender()
→ partial Flight prelude
Request time@vitejs/plugin-rsc/rsc/server
renderToReadableStream()
→ fresh Flight
React DOM · HTML

react-dom family

React DOM owns serializable postponed state and the operation that resumes it.

Build timereact-dom/static.edge
prerender()
→ HTML prelude + postponed
Request timereact-dom/server.edge
resume()
→ HTML continuation
The bridge: @vitejs/plugin-rsc/ssr createFromReadableStream() decodes either Flight stream into the React tree consumed by React DOM. RSC prerender() and React DOM prerender() share a name, but only the latter returns postponed state for resume().

What resolves in each RSC render

Warmup RSC prerender

@vitejs/plugin-rsc/rsc/static · prerender()

Cut off after cache fills settle; discard this Flight output.
CachedLayoutcold cache
Cache miss → fill → resolvedThe layout frame becomes reusable cache output.
DynamicContentrequest-time branch
markDynamic() → never settles before cutoffAn intentional prerender-only hanging suspension under Suspense.
CachedAsyncContentcold async cache
Suspend → 100 ms fill → resolvedReadiness waits for the fill and React retries it before cutoff.

Final RSC prerender

@vitejs/plugin-rsc/rsc/static · prerender()

Cut off with one branch pending; retain partial Flight.
CachedLayoutwarm cache
Cache hit → resolvedThe warmup frame is available immediately.
DynamicContentrequest-time branch
markDynamic() → never settles before cutoffThe intentional hanging hole remains in the Flight stream.
CachedAsyncContentwarm async cache
Cache hit → resolvedNo 100 ms suspension is repeated.

Request RSC render

@vitejs/plugin-rsc/rsc/server · renderToReadableStream()

All three branches resolve; emit complete fresh Flight.
CachedLayoutrestored cache
Cache hit → resolvedThe persisted frame is replayed.
DynamicContentlive request
Temporary 300 ms suspension → resolvedNo prerender context means markDynamic() does not create the hanging suspension.
CachedAsyncContentrestored async cache
Cache hit → resolvedThe warmup value and timestamp are reused.
The second pass is demo scaffolding, not a requirement. Because the readiness gate waits for every discovered cache fill to settle and gives React a retry turn before cutoff, the warmup already emits a valid partial Flight prelude that could feed React DOM directly, so the restart is not strictly necessary here. The demo still discards it and renders again so that cache discovery and shell capture read as separate stages, which mirrors production frameworks whose discovery pass genuinely lacks final-render semantics.
The choice of prerender() is incidental too. The warmup could instead drain a plain renderToReadableStream(), and a partial prelude can also come from a controlled renderToReadableStream() cutoff. Next.js and vinext even pick opposite arrangements, so Next.js produces its final RSC with renderToReadableStream() under staged cutoff, while vinext warms with renderToReadableStream() and captures its final Flight with prerender(). What is fundamental is the phased control (readiness, an intentional dynamic hole, and a cutoff), not the number of passes or the specific stream API.

How Flight becomes resumable HTML

React DOM receives the component states emitted by the corresponding RSC render. The repeated blocks below connect each RSC output directly to its HTML operation.

Final RSC prerender output · partial Flight
CachedLayoutresolved
DynamicContentpending until cutoff
CachedAsyncContentresolved
react-dom/static.edgeprerender()
receives the decoded partial Flight tree
HTML build resultprelude + serializable postponed state
Request RSC render output · fresh Flight
CachedLayoutresolved
DynamicContentsuspends, then resolves
CachedAsyncContentresolved
react-dom/server.edgeresume()
receives the decoded request tree + persisted postponed
HTML continuationThe request-time content that completes the persisted shell.
The two partial HTML streams become one response

postponed tells resume() where to continue. It is not itself written into the response.

Build-time preludepersisted static HTML: layout, fallback, cached async content
Request-time continuationresumed HTML for dynamic content
response begins immediately → continuation bytes follow and complete the postponed hole

@vitejs/plugin-rsc/ssr createFromReadableStream() is the bridge in both rows: it decodes Flight into the React tree consumed by React DOM.

Local investigation artifact. Source claims verified against packages/plugin-rsc/examples/ppr at 3ec1aa8f7d165150d4ec80d8b0cce365a41293b8. No external assets or runtime JavaScript.