Pass 1: warmup
The cache starts cold. This pass exists to discover and finish cache fills, not to produce the durable shell.
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.
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.
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.
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.
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.
markDynamic creates the hanging request-time hole. dynamicReached records the corresponding evidence that eventual cutoff is intentional.
One task turn gives React a chance to observe cache completion and reveal more work. The gate is checked again before readiness resolves.
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
| Render shape | What happens | Race outcome | Meaning |
|---|---|---|---|
| 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. |
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.
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.
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.
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
dynamicReached necessary?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.The cache starts cold. This pass exists to discover and finish cache fills, not to produce the durable shell.
The same payload renders again against a warm cache. This yields a clean partial Flight stream with cacheable shell work already available.
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.
| Concern | Demo approximation | Production variants | Contract 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. |
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.