Contents

PPR prerender context: readiness inside a completion race

The readiness gate is only one branch of the decision. Natural completion proves a route is fully static; readiness proves an unfinished render has reached an intentional dynamic hole after all discovered cache work settled.

PPR example @ 3ec1aa8 prerender-context.ts Companion: component flow Issue #948 Next.js CacheSignal Next.js prior art vinext final abort gate

Mental model

The complete decision

result completes || ready(dynamic hole + cache idle + retry)

ready is not expected to resolve for every render. It certifies when an unfinished render can be cut off as intentionally partial; result independently handles natural static completion.

Two valid outcomes

Result wins: the render closed naturally and is fully static.

Ready wins: a dynamic hole was observed, cache fills settled, and React received a retry turn.

Then: abort the pass and preserve its partial output.

React / RSC scheduling tracked cache work dynamic frontier readiness and cutoff

Event timeline

This timeline follows the partial branch where ready wins. On a fully static route, result completes instead and the pass is never aborted by this gate.

Partial prerender branch event timeline On the ready branch, React starts in async local storage, cache work is tracked, the dynamic boundary is reached, cache work settles, one stabilization task runs, and the prerender is aborted. time enter ALS start prerender track work pendingWork +1 cache starts markDynamic dynamicReached = true await forever cache settles pendingWork -1 notify waiters stabilize setTimeout(0) recheck gate ready race wins abort pass Why the demo needs this flag It proves unfinished output is an intentional dynamic hole, not merely incomplete static work.

1. Explicit tracking only

trackPrerenderWork adds the cache promise to a request-local set and removes it on either fulfillment or rejection. Rejection must remove it too, or readiness deadlocks.

2. Intentional partiality

markDynamic creates the hanging request-time hole. dynamicReached records the corresponding evidence that eventual cutoff is intentional.

3. Stabilize, then cut

One task turn gives React a chance to observe cache completion and reveal more work. The gate is checked again before readiness resolves.

The full decision, including natural completion
result = startPrerender()

trackPrerenderWork(cachePromise)

await markDynamic() // never resolves during prerender

dynamicReached && pendingWork.size === 0
  -> wait one task
  -> check again
  -> ready

Promise.race([result, ready])
  result -> fully static
  ready  -> abort and retain partial output

Concrete outcome contrasts

Render shapeWhat happensRace outcomeMeaning
Fully static tree Cache fills settle and the RSC stream closes. dynamicReached stays false, so ready remains pending. result wins The pass completed naturally and must not be aborted.
Static shell with a dynamic child markDynamic records the hole and hangs. Independent cache fills settle, React receives one retry turn, and the gate remains true. ready wins The unfinished stream is intentionally partial, so abort preserves the shell.
Dynamic child reached before a sibling cache fill completes dynamicReached is true, but pendingWork.size > 0 keeps readiness blocked until the sibling's static output is available. ready wins later The dynamic frontier does not truncate useful independent static work.
Dynamic access inside tracked cache work The cache fill itself waits forever on markDynamic, so pendingWork cannot become empty. Neither branch wins Invalid cache usage; a production framework should detect the cache scope and report an error.
Unmarked arbitrary hanging promise The stream does not close and no framework dynamic boundary certifies why it is pending. Neither branch wins Unsupported user suspension; framework-owned dynamic APIs are part of the contract.

How production elaborates the same concepts

1. Warmup quiescence

Next.js: CacheSignal.beginRead/endRead, pending module tracking, and a scheduler-aware settle window. Initial count zero still waits long enough for React to discover reads.

Demo: pendingWork tracks cache fills, and one setTimeout(0) provides an approximate settle window before rechecking.

2. Dynamic-hole semantics

Demo and vinext: dynamicReached / hasDynamicBoundary explicitly witness an intentional dynamic hole.

Next.js: staged dynamic inputs remain unresolved; a stream still pending after the permitted static work provides equivalent evidence.

3. Final cutoff scheduling

Next.js: uses controlled React tasks before inspecting and aborting a still-pending stream.

Demo: one task turn approximates that scheduling detail; the outer race still lets a fully static render finish naturally.

The transferable conceptual split
completion: render closes naturally -> fully static

cache readiness: discovered useful static work is quiescent

dynamic witness: unfinished output is an intentional request-time hole

cutoff: after a retry window, abort only when readiness wins
Is dynamicReached necessary?
Some equivalent evidence is necessary before intentionally cutting off unfinished output. The demo and vinext record that evidence explicitly with a boolean. Next.js derives it by observing that the stream remains pending after all work permitted in its static scheduling window. The representation differs, but the conceptual condition remains.
The missing nested-cache case
If tracked cache work itself calls markDynamic, its fill remains pending forever and deadlocks readiness. This is invalid cache usage rather than another PPR phase. A production framework can detect the active cache scope and report a usage error; the demo documents the invariant instead.

Why two RSC passes

Pass 1: warmup

The cache starts cold. This pass exists to discover and finish cache fills, not to produce the durable shell.

render tree
discover and materialize cache entries
demo: reach flag + cache settle, then abort
cancel and discard prelude

Pass 2: final

The same payload renders again against a warm cache. This yields a clean partial Flight stream with cacheable shell work already available.

restart render
replay warm cache entries
reuse the same readiness gate and cutoff decision
send partial Flight into HTML prerender
Why not keep the warmup output?
The prospective pass may contain transient suspension and fallback behavior from filling the cache. Restarting makes the persisted shell describe the warm-cache state, not the process used to reach it.

Deliberate runtime approximations

No implementation can infer that arbitrary application async work is globally finished. Each framework defines which work it tracks and a scheduler window after which remaining suspension is treated as dynamic. The demo uses the smallest versions of those same approximations.

ConcernDemo approximationProduction variantsContract boundary
Dynamic witness dynamicReached explicitly records that markDynamic created the hole. vinext uses hasDynamicBoundary; Next.js observes a stream still pending after its permitted static scheduling window. Intentional suspension must use a framework-owned dynamic API.
Scheduler settling One setTimeout(0) lets React process cache pings before the gate is rechecked. vinext waits across delayed tasks; Next.js coordinates sequential tasks and React's immediate queue. Each is a framework-specific cutoff window, not proof that arbitrary async work is exhausted.
Cache readiness pendingWork tracks promises created by the demo's cache wrapper. Next.js uses cache and module signals; vinext counts framework-owned cache tasks. Only registered cache work participates; unrelated application promises are not readiness signals.
Cache/dynamic composition The demo documents dynamic access inside tracked cache work as invalid. Production frameworks can detect cache scope and report the usage error; broader runtimes may also guard readiness defensively. Cache fills must not directly depend on request-time data.

The one sentence to retain

Race natural completion against readiness: completion proves fully static output, while readiness proves an intentional dynamic hole remains after discovered cache work settles and React receives a retry turn.

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