diff --git a/.claude/docs/ppr/design-v1.md b/.claude/docs/ppr/design-v1.md
new file mode 100644
index 0000000000..eff1a7f7a8
--- /dev/null
+++ b/.claude/docs/ppr/design-v1.md
@@ -0,0 +1,203 @@
+# PPR Design Proposal — v1 (for Codex review)
+
+**Goal**: Implement a credible MVP of React 19.2 Partial Prerendering (PPR) for React on Rails
+Pro that the dummy app can demo end-to-end. Stay within RFC #3244 directions but trim Phase 3
+(node-renderer protocol bump) since we can dispatch through the existing render endpoint via a
+new `render_mode`. Phases 1, 2, 4 are in-scope; docs/changelog are minimal.
+
+## Non-goals (deliberately deferred)
+
+- RSC + PPR composition. PPR works on the SSR HTML layer. Pages using `` keep working
+ with `stream_react_component`; PPR is offered as a sibling helper.
+- A new `/ppr/prerender` `/ppr/resume` endpoint pair on the node renderer. We reuse the existing
+ `/bundles//render/` endpoint and dispatch by `render_mode` inside the JS bundle.
+ No `protocolVersion` bump.
+- `prerender_only` build-time prerender CLI. First request lazily prerenders + caches.
+
+## High level flow
+
+```
+ppr_react_component('Page', props: {...})
+ ├── compute cache key (component + JSON-stable props + bundle digest + RoR-Pro version)
+ ├── Rails.cache.fetch(key):
+ │ MISS → invoke "ppr_prerender" render mode on the SSR bundle
+ │ → returns Hash { shellHtml, postponedState (JSON string or null), hasErrors, isShellReady }
+ │ → cache the Hash
+ │ HIT → load Hash from cache
+ ├── if postponedState is null (fully static) → just emit shellHtml as a single chunk and finish
+ └── else → invoke "ppr_resume" render mode on the SSR bundle, passing shell + postponedState via railsContext
+ → JS path streams shellHtml first, then resumeToPipeableStream() output
+ → flowed through existing Pro stream_view_containing_react_components pipeline
+```
+
+## File-level plan
+
+### JS — packages/react-on-rails-pro/src
+
+#### `capabilities/proPPR.ts` (new)
+Exports `createProPPRCapability()` returning two methods registered on the Pro ReactOnRails
+instance:
+
+```ts
+{
+ isPPRCapable: true as const,
+ // Non-streaming: returns a Hash via the existing serverRenderReactComponent contract,
+ // with two extra fields: pprShellHtml, pprPostponedState (JSON string or null).
+ // Reuses createReactOutput + ComponentRegistry like serverRenderReactComponent does.
+ prerenderReactComponentForPPR(options: PPRPrerenderParams): Promise<{
+ html: string; // shell HTML — duplicated under `html` so existing helpers pick it up cleanly
+ pprShellHtml: string;
+ pprPostponedState: string | null;
+ consoleReplayScript: string;
+ hasErrors: boolean;
+ isShellReady: boolean;
+ }>;
+ // Streaming: shell first, then resume chunks via existing transform stream pipeline.
+ resumeReactComponentForPPR(options: PPRResumeParams): Readable;
+}
+```
+
+##### prerender phase
+
+1. Set up an `AbortController`. The user app can opt to call `controller.abort()` themselves
+ via a callback we expose, but default behavior is a configurable timeout
+ (`pprPrerenderTimeoutMs`, default 30s).
+2. Resolve the registered component with a `railsContext` extended with:
+ `{ isPrerendering: true, ppr: { reason: 'prerender' } }`.
+3. Wrap the resolved element in ``.
+4. Call `prerenderToNodeStream(element, { signal, identifierPrefix: domNodeId, bootstrapScripts, onError })`.
+5. Wait for the abort timer to fire OR an external "static work done" signal (TBD); call `controller.abort()`.
+6. Drain the prelude into a string. Return `{ pprShellHtml, pprPostponedState: postponed ? JSON.stringify(postponed) : null }`.
+7. Errors during static rendering → emit error HTML and set `hasErrors: true`.
+
+##### resume phase
+
+1. Parse `postponedState` from `railsContext.pprPostponedState` (JSON string).
+2. Resolve the component again with `{ isPrerendering: false }` and the FULL railsContext (cookies, headers, query).
+3. Wrap in ``.
+4. Use existing `transformRenderStreamChunksToResultObject` so the output passes through the same
+ chunk-format pipeline that `streamServerRenderedReactComponent` uses.
+5. Write `shellHtml` as the FIRST chunk via `writeChunk(shellHtml)`, then pipe
+ `resumeToPipeableStream(element, postponed, { onError })` through `pipeToTransform`.
+6. Run `injectRSCPayload` if the page contains RSC routes (re-uses RSCRequestTracker).
+
+#### `postpone.ts` (new)
+Exposes:
+
+```ts
+export const PPRPhaseContext: React.Context<{ phase: 'prerender' | 'resume' | null }>;
+// Throws never-resolving promise during prerender, no-op during resume.
+export function usePostpone(reason?: string): void;
+```
+
+`usePostpone` reads the phase from React context. The shared context provider is added by the
+Pro PPR capability around the registered component. Server components don't use React context, but
+any boundary that calls `usePostpone` is by definition rendering inside the SSR HTML pass — so
+SSR React context is available.
+
+The shared sentinel is allocated once:
+```ts
+const NEVER_RESOLVES: Promise = new Promise(() => {});
+```
+
+#### `ReactOnRailsRSC.ts`, `ReactOnRails.node.ts`
+Register `createProPPRCapability()` alongside existing capabilities.
+
+### Ruby
+
+#### `react_on_rails_pro/lib/react_on_rails_pro/ppr.rb` (new)
+```ruby
+module ReactOnRailsPro::PPR
+ module_function
+
+ CACHE_NAMESPACE = 'ror_pro_ppr'
+
+ def cache_key(component_name, props:, **)
+ [
+ *ReactOnRailsPro::Cache.base_cache_key(CACHE_NAMESPACE, prerender: true),
+ component_name,
+ Digest::SHA1.hexdigest(props.to_json) # JSON-stable enough for cache keying
+ ]
+ end
+
+ def fetch(component_name, props:, cache_options: {}, &block)
+ Rails.cache.fetch(cache_key(component_name, props: props), cache_options) { yield }
+ end
+end
+```
+
+#### `react_on_rails_pro/lib/react_on_rails_pro/configuration.rb`
+Add:
+- `enable_ppr_support` (default false)
+- `ppr_prerender_timeout_ms` (default 30_000)
+
+#### `app/helpers/react_on_rails_pro_helper.rb`
+Add `ppr_react_component(component_name, options = {})`:
+
+```ruby
+def ppr_react_component(component_name, options = {})
+ raise ReactOnRailsPro::Error, 'PPR support is not enabled' unless ReactOnRailsPro.configuration.enable_ppr_support
+
+ options[:prerender] = true
+ on_complete = options.delete(:on_complete)
+
+ cached = ReactOnRailsPro::PPR.fetch(
+ component_name,
+ props: options[:props] || {},
+ cache_options: options[:cache_options] || {}
+ ) do
+ internal_ppr_prerender(component_name, options)
+ end
+
+ consumer_stream_async(on_complete: on_complete) do
+ if cached[:postponed_state].nil?
+ static_shell_only_stream(cached, component_name, options)
+ else
+ internal_ppr_resume(component_name, options.merge(
+ ppr_shell_html: cached[:shell_html],
+ ppr_postponed_state: cached[:postponed_state]
+ ))
+ end
+ end
+end
+```
+
+#### dispatch via render_mode
+`server_rendering_js_code.rb` picks the JS function name based on render mode:
+- `:ppr_prerender` → `'prerenderReactComponentForPPR'`
+- `:ppr_resume` → `'resumeReactComponentForPPR'`
+
+The Ruby `internal_ppr_prerender` runs the existing non-streaming `eval_js` path; the Ruby
+`internal_ppr_resume` runs the existing streaming `eval_streaming_js` path. railsContext gets
+extra fields injected at JS level for both phases (`isPrerendering`, `pprPostponedState`,
+`pprShellHtml`).
+
+## Open questions for codex
+
+1. **Timeout vs. signal-driven abort.** Fixed timeout is robust but pessimistic. Should we expose
+ a per-render hook so user code can tell us when it's done with static work? Tradeoff:
+ simplicity vs. flexibility.
+2. **Tree-shape stability.** Bundle digest is part of the cache key — so a code change invalidates
+ the shell. Is that enough, or do we also fingerprint the rendered tree? React itself logs a
+ warning if resume sees a different tree shape; should we surface that as a Rails error?
+3. **Shell-as-cached-Hash vs streaming the cached shell.** First version writes the shell as a
+ single chunk. Pro: simplicity. Con: long shells have high TTFB. OK to defer streaming the shell?
+4. **Where does `usePostpone` read its phase?** A React context wrapped around the registered
+ component is clean but only works if the user's component tree is rendered through the Pro
+ wrapper (it always is for `ppr_react_component`). Confirm this is acceptable, or do we want a
+ `globalThis` fallback for code paths outside the wrapper?
+5. **RSC interaction.** Pages using `` re-fetch their Flight payload on resume. Confirm
+ we just document this and don't try to cache the Flight payload in v1.
+6. **Postponed state size.** The `postponed` JSON can be large. Is Rails.cache (default
+ memory_store) the right default? Or should we mandate `:file_store` / Redis with a size cap?
+
+## Acceptance for v1
+
+- `ppr_react_component('PPRDemo')` works on a Rails view in the Pro dummy app.
+- First request: shell is built, cached, streamed; postponed state cached alongside.
+- Subsequent request: cache HIT skips React; shell is streamed immediately, then resume fills holes.
+- A component using `usePostpone()` becomes a hole; siblings appear in the cached shell.
+- Multiple Suspense boundaries with different latencies all settle into the shell unless they call
+ `usePostpone()`.
+- Errors during prerender → fall back to `stream_react_component`-style error HTML.
+- Chrome DevTools MCP test: shell appears in first chunk, `$RC` instructions arrive later.
diff --git a/.claude/docs/ppr/design-v2.md b/.claude/docs/ppr/design-v2.md
new file mode 100644
index 0000000000..6a9a30ecd4
--- /dev/null
+++ b/.claude/docs/ppr/design-v2.md
@@ -0,0 +1,296 @@
+# PPR Design Proposal — v2 (post-codex-v1 review)
+
+Revisions in v2 are tagged `[v2]` and grouped by codex's numbered findings.
+
+## Scope (unchanged)
+
+MVP of React 19.2 PPR for React on Rails Pro. Helper `ppr_react_component`. Cache `(shell, postponed)`
+in Rails.cache. Lazy first-request prerender. SSR HTML layer only — RSC composition deferred.
+
+## Rendering pipeline (revised)
+
+### `[v2 #1]` — explicit render modes
+
+Add to `react_on_rails/lib/react_on_rails/react_component/render_options.rb`:
+
+```ruby
+def ppr_prerender? = render_mode == :ppr_prerender
+def ppr_resume? = render_mode == :ppr_resume
+def ppr? = ppr_prerender? || ppr_resume?
+
+# streaming? returns true for :ppr_resume so the existing streaming path is used.
+def streaming?
+ %i[html_streaming rsc_payload_streaming ppr_resume].include?(render_mode)
+end
+```
+
+Update `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb` to pick the JS
+function name explicitly:
+
+```ruby
+render_function_name =
+ if render_options.ppr_prerender?
+ "'prerenderReactComponentForPPR'"
+ elsif render_options.ppr_resume?
+ "'resumeReactComponentForPPR'"
+ elsif ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
+ "ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
+ else
+ "'serverRenderReactComponent'"
+ end
+```
+
+The PPR functions are registered on the Pro `ReactOnRails` global, so the existing
+`ReactOnRails[#{render_function_name}]({...})` invocation at `server_rendering_js_code.rb:91`
+calls them directly without further changes.
+
+### `[v2 #2]` — prerender returns Promise
+
+`prerenderReactComponentForPPR` returns a `Promise<{ html, consoleReplayScript, hasErrors,
+isShellReady, pprPostponedState }>`. The node renderer VM at `vm.ts:161` already awaits and
+JSON-stringifies non-string results, so no protocol changes needed.
+
+ExecJS does NOT support async, Node streams, or AbortController. PPR fails fast when the active
+render pool is `RubyEmbeddedJavaScript`:
+
+```ruby
+def ppr_react_component(component_name, options = {})
+ unless ReactOnRailsPro.configuration.node_renderer?
+ raise ReactOnRailsPro::Error,
+ 'PPR requires the Pro node renderer (ExecJS does not support AbortController/async).'
+ end
+ ...
+end
+```
+
+### `[v2 #3]` — shell HTML is a component fragment, not a full document
+
+The user's PPR root component returns a React fragment, not ``. Fizz produces fragment HTML.
+The cached `shell_html` is exactly what existing `serverRenderReactComponent` would produce for
+the same component, modulo postponed boundaries. `build_react_component_result_for_server_rendered_string`
+will wrap it inside `
…
` exactly as today.
+
+Note: PPR-emitted `` placeholders work even when the surrounding HTML is a
+fragment. The `$RC` instructions emitted during resume are JS that look up nodes by id, so
+position is irrelevant.
+
+The `bootstrapScripts` option of `prerenderToNodeStream` is **omitted** for PPR. ROR's existing
+client-bundle injection is responsible for booting React on the page. We do NOT pass bootstrap
+scripts to Fizz from JS (this also avoids duplicating React's `$RC` runtime in the shell).
+
+### `[v2 #4]` — PPR cache is its own contract
+
+The cache stores a hash:
+
+```ruby
+{
+ shell_html: String, # component-fragment HTML, ready to feed into existing wrapper
+ postponed_state: String|nil, # JSON.stringify(postponed) or nil for fully-static pages
+ console_replay_script: String, # captured during prerender
+ ppr_version: 1 # bump if cache shape changes
+}
+```
+
+We do NOT reuse `cached_stream_react_component`'s "cache the final Rails-side chunks" pattern.
+PPR's cache is the React-side artifact (shell + postponed). We rebuild Rails-side wrapping
+on every request.
+
+### `[v2 #5]` — `isShellReady = true` before writing shell
+
+In `resumeReactComponentForPPR`:
+
+```ts
+const renderState = { result: null, hasErrors: false, isShellReady: true }; // ← true from start
+const { readableStream, pipeToTransform, writeChunk } = transformRenderStreamChunksToResultObject(renderState);
+writeChunk(shellHtml); // first JSON chunk has isShellReady: true
+const stream = resumeToPipeableStream(element, JSON.parse(postponedState), { onError });
+pipeToTransform(stream); // resume chunks follow
+```
+
+Also: the resume side returns the stream synchronously (do not await renderToPipeableStream's
+async boundary chain). The first chunk is immediately available.
+
+### `[v2 #6]` — phase tracking via AsyncLocalStorage; PPR not registered in RSC bundle
+
+PPR capability is registered ONLY in `packages/react-on-rails-pro/src/ReactOnRails.node.ts`, NOT
+in `ReactOnRailsRSC.ts`. The `usePostpone` helper:
+
+```ts
+import { AsyncLocalStorage } from 'node:async_hooks';
+const phaseStore = new AsyncLocalStorage<{ phase: 'prerender' | 'resume' }>();
+
+export function usePostpone(reason?: string): void {
+ // Hook runs inside React's render — the AsyncLocalStorage propagates if we wrap our
+ // render() calls with phaseStore.run(...). Module-level fallback is acceptable for v1
+ // because each worker request is serial.
+ const phase = phaseStore.getStore()?.phase;
+ if (phase === 'prerender') throw NEVER_RESOLVES;
+ // resume → no-op
+}
+```
+
+`prerenderReactComponentForPPR` calls `phaseStore.run({phase:'prerender'}, () => prerenderToNodeStream(...))`.
+`resumeReactComponentForPPR` calls `phaseStore.run({phase:'resume'}, () => resumeToPipeableStream(...))`.
+
+If `node:async_hooks` is unavailable (ExecJS path), `usePostpone` falls back to a module-level
+flag set/cleared by the same wrappers. Since the ExecJS path is guarded out at the helper level
+[v2 #2], the fallback is dead code on production paths but covers test scaffolding.
+
+`ppr_react_component` does NOT support RSC components for v1. It is documented explicitly:
+"Use `ppr_react_component` only for client/SSR React trees. For RSC use `stream_react_component`
+(or `cached_stream_react_component`) — the two helpers compose differently and we'll address
+PPR+RSC in a follow-up."
+
+### `[v2 #7]` — AbortController in VM context
+
+The Pro node renderer's worker VM at `vm.ts:221` uses `additionalContext` to inject globals.
+We add `AbortController, AbortSignal` to the *default* set of VM globals when
+`enable_ppr_support` is true, and we hard-fail if `globalThis.AbortController` is missing in the
+PPR functions:
+
+```ts
+function ensurePPRRuntime() {
+ if (typeof AbortController === 'undefined') {
+ throw new Error(
+ 'React on Rails Pro PPR requires AbortController on the JS runtime. ' +
+ 'Add it to the node renderer VM globals (additionalContext.AbortController) ' +
+ 'or upgrade your node renderer to a version that includes it by default.'
+ );
+ }
+}
+```
+
+### `[v2 #8]` — abort cleanup is precise
+
+Pseudocode for `prerenderReactComponentForPPR`:
+
+```ts
+let timer: NodeJS.Timeout | undefined;
+const controller = new AbortController();
+
+function cleanup() {
+ if (timer) { clearTimeout(timer); timer = undefined; }
+ if (!controller.signal.aborted) controller.abort();
+}
+
+try {
+ timer = setTimeout(() => controller.abort(new Error('ppr-prerender-timeout')), timeoutMs);
+ const { prelude, postponed } = await prerenderToNodeStream(element, {
+ signal: controller.signal,
+ identifierPrefix: domNodeId,
+ onError(err) { /* swallow expected AbortError; report others */ },
+ });
+
+ // Drain prelude. AbortController fired? prelude still emits up to the abort point + then ends.
+ const shellHtml = await streamToString(prelude);
+ return {
+ html: shellHtml, // existing field — reused for non-streaming consumer
+ pprShellHtml: shellHtml,
+ pprPostponedState: postponed ? JSON.stringify(postponed) : null,
+ consoleReplayScript,
+ hasErrors: false,
+ isShellReady: true,
+ };
+} catch (e) {
+ // Do NOT cache. Ruby side will fall back to error HTML rendering.
+ return { html: '', hasErrors: true, errorMessage: String(e), pprShellHtml: '', pprPostponedState: null, ... };
+} finally {
+ cleanup();
+}
+```
+
+Specifically:
+- Always `clearTimeout(timer)` in finally.
+- `controller.abort()` is idempotent so calling it in finally is safe.
+- We do NOT call `prelude.destroy()` once we've consumed it; `streamToString` exits via `'end'`.
+- If the prelude errors before any chunk: `prerenderToNodeStream` rejects → we go to `catch` → no cache.
+- We never partially cache. `Rails.cache.fetch(key) { yield_block_or_raise }` only writes the
+ cache when the block returns normally. If the JS returns a hash with `hasErrors: true`, the
+ Ruby caller raises before caching.
+
+### `[v2 #9]` — explicit cache_key required
+
+```ruby
+def ppr_react_component(component_name, options = {})
+ raise ReactOnRailsPro::Error, "ppr_react_component requires :cache_key" unless options[:cache_key]
+ ...
+end
+```
+
+The cache key composition mirrors `cached_stream_react_component`:
+
+```ruby
+ReactOnRailsPro::Cache.react_component_cache_key(component_name, options.merge(prerender: true))
+# Already includes server bundle digest; we add a 'ppr-v1' namespace and the React major version.
+```
+
+### `[v2 #10]` — strict prerender/resume tree separation
+
+Document and enforce: **all per-request reads must be inside Suspense boundaries that postpone**.
+
+We provide a runtime check in `resumeReactComponentForPPR` — if React's `onError` fires with a
+"resume tree mismatch" error during resume, we surface it as a hard error in the Rails stream
+(`hasErrors: true`, `error: ...`), so devs notice immediately rather than silently falling back
+to client rendering.
+
+For prerender, we pass a sanitized railsContext:
+
+```ts
+const prerenderRailsContext = {
+ // Only static-safe fields. No cookies, no headers, no current_user, no locale.
+ serverSide: true,
+ rorPro: railsContext.rorPro,
+ reactClientManifestFileName: railsContext.reactClientManifestFileName, // for RSC bundle ref
+ __isPrerendering: true,
+};
+```
+
+The resume side gets the FULL railsContext but the user's component contract is: "do not read
+request-varying values outside a postponed boundary."
+
+## Phase boundaries / files
+
+### JS — packages/react-on-rails-pro/src
+
+- `capabilities/proPPR.ts` — new
+- `postpone.ts` — new (`usePostpone`, `phaseStore`)
+- `ReactOnRails.node.ts` — register PPR capability
+- (NOT modified) `ReactOnRailsRSC.ts`
+
+### Ruby — react_on_rails_pro
+
+- `lib/react_on_rails_pro/ppr.rb` — cache helpers
+- `lib/react_on_rails_pro/configuration.rb` — `enable_ppr_support`, `ppr_prerender_timeout_ms`
+- `app/helpers/react_on_rails_pro_helper.rb` — `ppr_react_component`
+
+### Open-source — react_on_rails
+
+- `lib/react_on_rails/react_component/render_options.rb` — add `ppr_prerender?`, `ppr_resume?`,
+ update `streaming?`. (This is the only OSS-side change required.)
+
+### Tests
+
+- `packages/react-on-rails-pro/tests/proPPR.test.tsx` — unit tests for prerender/resume capability
+- `react_on_rails_pro/spec/react_on_rails_pro/ppr_spec.rb` — Ruby unit tests
+- `react_on_rails_pro/spec/dummy/client/app/components/PPRDemo/` — multi-Suspense demo
+- `react_on_rails_pro/spec/dummy/app/views/pages/ppr_demo.html.erb`
+- `react_on_rails_pro/spec/dummy/e2e-tests/ppr.spec.ts` — Playwright (for CI; chrome-devtools MCP for interactive verification)
+
+## Acceptance for v1 (reaffirmed)
+
+- [ ] `ppr_react_component('PPRDemo', cache_key: 'demo')` works in Pro dummy.
+- [ ] First request: shell built, cached, streamed; postponed state cached alongside.
+- [ ] Subsequent request: cache HIT skips React; shell streamed first, resume fills holes.
+- [ ] Component using `usePostpone()` becomes a hole; siblings appear in cached shell.
+- [ ] Error during prerender → graceful failure HTML; cache not written.
+- [ ] Resume tree mismatch → hard error visible to dev (not silent client-rendering fallback).
+- [ ] Chrome DevTools MCP test: shell first, `$RC` later, second request faster than first.
+
+## Open questions remaining
+
+a) `streaming?` change in OSS `render_options.rb` — does this risk breaking other consumers? Need
+ to grep.
+b) `ReactOnRailsPro::Cache.react_component_cache_key` already includes bundle digest, but we need
+ to verify it also varies on `cache_key`. Need to read.
+c) Should `ppr_prerender_timeout_ms` be per-call instead of global? Per-call wins, set default
+ from config.
diff --git a/.claude/docs/ppr/design-v3.md b/.claude/docs/ppr/design-v3.md
new file mode 100644
index 0000000000..0578a22c6f
--- /dev/null
+++ b/.claude/docs/ppr/design-v3.md
@@ -0,0 +1,215 @@
+# PPR Design Proposal — v3 (post-codex-v2 review)
+
+Resolves codex's v2 critique. New revisions tagged `[v3]`. v2 items still stand unless overridden.
+
+---
+
+## `[v3 R-2]` — pushback on "resume is async/callback-driven"
+
+**Codex claimed**: `resumeToPipeableStream` is documented as `await resumeToPipeableStream(..., { onShellReady })`.
+
+**Counterevidence**: React 19.2.3 source (`react-dom/cjs/react-dom-server.node.development.js:10766`):
+```js
+exports.resumeToPipeableStream = function (children, postponedState, options) {
+ var request = resumeRequestImpl(children, postponedState, options), hasStartedFlowing = !1;
+ startWork(request);
+ return {
+ pipe: function (destination) { ... startFlowing(request, destination); ... },
+ abort: ...
+ };
+};
+```
+
+The function is **synchronous** and returns `{pipe, abort}`. There is no `onShellReady`. This matches
+the working `ppr-demo.mjs` in the repo root. **Closing this point as non-issue.**
+
+(Codex's confusion was likely with `renderToPipeableStream` — which DOES have `onShellReady` —
+and/or with `prerenderToNodeStream` which is async.)
+
+---
+
+## `[v3 R-1]` — `streaming?` is correctly OVERLOADED today; fix the consumers, not the predicate
+
+**Codex's correct point**: Adding `:ppr_resume` to `streaming?` would route through:
+1. `ProRendering#render_with_cache` → wraps stream with `StreamCache` (wrong for PPR — we manage our own cache)
+2. `server_rendering_js_code.rb` → injects `generateRSCPayload` definition + RSC railsContext fields (irrelevant for PPR)
+3. (and we want it to keep going through `eval_streaming_js`, which is the only correct effect)
+
+**Fix**: We update the consumers, not the predicate.
+
+### `[v3]` change to OSS `render_options.rb`
+
+```ruby
+def streaming?
+ %i[html_streaming rsc_payload_streaming ppr_resume].include?(render_mode)
+end
+
+# New helpers — opt-in for behavior that should NOT apply to PPR
+def html_streaming? = render_mode == :html_streaming
+def rsc_payload_streaming? = render_mode == :rsc_payload_streaming
+def ppr_prerender? = render_mode == :ppr_prerender
+def ppr_resume? = render_mode == :ppr_resume
+def ppr? = ppr_prerender? || ppr_resume?
+def html_or_rsc_streaming? = html_streaming? || rsc_payload_streaming?
+```
+
+### `[v3]` Pro consumers updated
+
+`react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb` —
+`render_with_cache` should NOT engage for PPR resume. Replace the existing `render_options.streaming?`
+guard for stream-cache with:
+
+```ruby
+result = if render_options.html_streaming? # was: streaming?
+ render_streaming_with_cache(prerender_cache_key, js_code, render_options)
+ elsif render_options.rsc_payload_streaming?
+ render_on_pool(js_code, render_options) # RSC path: no Pro stream cache
+ elsif render_options.ppr_resume?
+ render_on_pool(js_code, render_options) # PPR resume: own cache contract
+ else
+ Rails.cache.fetch(prerender_cache_key) do
+ prerender_cache_hit = false
+ render_on_pool(js_code, render_options)
+ end
+ end
+```
+
+Also: `ppr_react_component` always sets `skip_prerender_cache: true` so the JS-side prerender
+output is not double-cached at the Pro Rendering level.
+
+`react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb` — the
+`enable_rsc_support && render_options.streaming?` guard becomes
+`enable_rsc_support && render_options.html_or_rsc_streaming?`. PPR has its own railsContext
+augmentation (no `generateRSCPayload`).
+
+This is a localized, low-risk change. Audit script:
+
+```bash
+grep -RIn "\.streaming?" react_on_rails react_on_rails_pro
+# Expected hits to update: pro_rendering.rb, server_rendering_js_code.rb. All other hits
+# (e.g. inside ruby_embedded_java_script.rb that dispatches to streaming HTTP) are exactly
+# what we want for PPR resume.
+```
+
+---
+
+## `[v3 R-3]` — `AsyncLocalStorage` availability
+
+**Codex's correct point**: `import { AsyncLocalStorage } from 'node:async_hooks'` may fail at bundle
+parse time if the bundle doesn't have node externals; relying on a try/catch fallback inside the
+import won't help.
+
+**Fix**: Inject `AsyncLocalStorage` into the VM context as a global, alongside `AbortController`
+and `AbortSignal`. Update node renderer:
+
+```ts
+// packages/react-on-rails-pro-node-renderer/src/worker/vm.ts (extendContext block)
+extendContext(contextObject, {
+ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream,
+ process, performance,
+ setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask,
+ AbortController, AbortSignal, // [v3] for PPR
+ AsyncLocalStorage, // [v3] for PPR phase tracking
+});
+```
+
+`AsyncLocalStorage` is imported at the top of vm.ts: `import { AsyncLocalStorage } from 'node:async_hooks';`
+That works at the renderer process level (not inside the sandbox) — no bundle externals needed.
+
+The PPR JS module reads `AsyncLocalStorage` from `globalThis`:
+
+```ts
+// packages/react-on-rails-pro/src/postpone.ts
+const ALS = (globalThis as any).AsyncLocalStorage;
+if (typeof ALS !== 'function') {
+ // Hard error at module load if PPR is reachable in this build context.
+ throw new Error('PPR requires AsyncLocalStorage as a VM global. Update the Pro node renderer to >= protocol Y.Y.');
+}
+const phaseStore = new ALS();
+```
+
+The Pro Ruby side surfaces a clearer error at the helper level (so devs see it before JS runs):
+
+```ruby
+def ppr_react_component(component_name, options = {})
+ unless ReactOnRailsPro.configuration.enable_ppr_support
+ raise ReactOnRailsPro::Error, "PPR support is not enabled (set config.enable_ppr_support = true)"
+ end
+ unless ReactOnRailsPro.configuration.node_renderer?
+ raise ReactOnRailsPro::Error, "PPR requires the Pro node renderer (ExecJS lacks AbortController/streams)"
+ end
+ ...
+end
+```
+
+---
+
+## `[v3 R-4]` — AbortController injection: unconditional default
+
+**Codex's correct point**: `enable_ppr_support` is a Ruby-side flag and the node renderer doesn't
+know about it.
+
+**Fix**: Add `AbortController, AbortSignal, AsyncLocalStorage` to the default `supportModules`
+context unconditionally. They're standard Node.js globals (Node ≥ 16). No node-renderer config
+flag needed. Documentation update:
+
+- `docs/oss/building-features/node-renderer/js-configuration.md` — list new globals
+- `packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts` JSDoc
+- `docs/oss/migrating/rsc-troubleshooting.md` — list new globals
+- `react_on_rails_pro/packages/node-renderer/package.json` — bump `protocolVersion` to indicate
+ the runtime contract change (PPR-capable). Note: bumping protocolVersion is unrelated to the
+ request/response wire protocol — it's just a contract tag. We document the bump and the gem
+ side already accepts protocolVersion mismatches with a warning.
+
+A vm.test.ts test asserts these new globals are present in the VM context.
+
+---
+
+## `[v3 R-5]` — Lazy prerender blocks the request thread
+
+**Codex's correct point**: 30s timeout means up to 30s blocked on the first request.
+
+**Fix**: Document explicitly as MVP tradeoff. Lower default to **8 seconds**. Make it a
+per-component option (`prerender_timeout_ms:`) so devs can tune. Add a future-work note for
+background warmup. Concretely:
+
+```ruby
+# react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
+add_attr :ppr_default_prerender_timeout_ms, default: 8_000
+
+# usage
+ppr_react_component('Page', cache_key: 'page-v1', props: {...},
+ prerender_timeout_ms: 5_000)
+```
+
+Documentation note in `docs/api/ppr.md`:
+
+> **Cold-cache UX**. The first request to a PPR-cached page blocks while the static shell is
+> built (default 8s). Subsequent requests skip prerender and serve the cached shell immediately.
+> If your shell is expensive to build, prefer a build-time warmup task (planned, see
+> `react_on_rails_pro:ppr_warmup` rake) over lazy first-hit prerendering.
+
+We accept this constraint for v1.
+
+---
+
+## Updated final check
+
+After the v3 changes, all five of codex's v2 issues are addressed:
+- [✓] R-1: PPR-resume streaming routing surgically updated (specific consumers, not the predicate)
+- [✓] R-2: kept synchronous resume API (codex was wrong; React 19.2 source confirms)
+- [✓] R-3: AsyncLocalStorage injected into VM globals
+- [✓] R-4: AbortController/AbortSignal/AsyncLocalStorage added unconditionally to supportModules
+- [✓] R-5: blocking first-hit explicitly documented; default timeout lowered to 8s; per-call override
+
+## Summary diff vs v2
+
+- New OSS predicate `html_or_rsc_streaming?` and PPR predicates.
+- Pro `pro_rendering.rb` and `server_rendering_js_code.rb` switched from `streaming?` to specific
+ predicates where the existing behavior is RSC-aware streaming or stream caching.
+- `ppr_react_component` always sets `skip_prerender_cache: true`.
+- VM globals for the node renderer extended with `AbortController`, `AbortSignal`, `AsyncLocalStorage`.
+- `protocolVersion` bumped (informational tag, not wire-protocol bump).
+- Default prerender timeout lowered to 8s; tunable per-call.
+- The shell is a component fragment; resume is synchronous; phase tracking via injected
+ AsyncLocalStorage; PPR registered only in `ReactOnRails.node.ts`. (All as v2.)
diff --git a/.claude/docs/ppr/design-v4.md b/.claude/docs/ppr/design-v4.md
new file mode 100644
index 0000000000..b569235f49
--- /dev/null
+++ b/.claude/docs/ppr/design-v4.md
@@ -0,0 +1,224 @@
+# PPR Design Proposal — v4 (post-codex-v3 review)
+
+Codex concurred on R-2 (resume is sync — APPROVED). 4 new findings; addressed below as `[v4]`.
+
+---
+
+## `[v4 R-1]` — JsCodeBuilder also reads `streaming?`
+
+**Codex right**. `react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb:107`:
+```ruby
+def rsc_streaming?(render_request)
+ ReactOnRailsPro.configuration.enable_rsc_support && render_request.streaming?
+end
+```
+
+**Fix**: Update to use `html_or_rsc_streaming?` (or equivalently, exclude PPR resume explicitly).
+Add a delegated predicate to `RenderRequest`:
+
+```ruby
+# react_on_rails_pro/lib/react_on_rails_pro/request.rb (or wherever RenderRequest lives)
+delegate :html_or_rsc_streaming?, :ppr_prerender?, :ppr_resume?, to: :@render_options
+
+# js_code_builder.rb:107
+def rsc_streaming?(render_request)
+ ReactOnRailsPro.configuration.enable_rsc_support && render_request.html_or_rsc_streaming?
+end
+```
+
+Audit script (broader): `grep -RIn 'streaming?' react_on_rails_pro/ react_on_rails/`.
+Confirmed consumers requiring update:
+- `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb:64`
+- `react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb` (RSC params/bundle dispatch)
+- `react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb:107`
+- `react_on_rails/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb:61, :82`
+ → KEEP (we want PPR resume to dispatch through the streaming HTTP path)
+
+---
+
+## `[v4 R-2]` — `configuration.rb` syntax
+
+**Codex right**. The repo uses `attr_accessor` + `DEFAULT_*` constants + explicit `initialize`
+keyword args. Concretely:
+
+```ruby
+# Add to constants block:
+DEFAULT_ENABLE_PPR_SUPPORT = false
+DEFAULT_PPR_PRERENDER_TIMEOUT_MS = 8_000
+
+# Append to attr_accessor list:
+attr_accessor ..., :enable_ppr_support, :ppr_prerender_timeout_ms
+
+# Update self.configuration block:
+@configuration ||= Configuration.new(
+ ...
+ enable_ppr_support: Configuration::DEFAULT_ENABLE_PPR_SUPPORT,
+ ppr_prerender_timeout_ms: Configuration::DEFAULT_PPR_PRERENDER_TIMEOUT_MS,
+)
+
+# Update initialize signature, body assigns, etc.
+```
+
+We will follow the existing pattern verbatim.
+
+---
+
+## `[v4 R-3]` — `protocolVersion` bump is unsafe
+
+**Codex right**. `protocolVersion` mismatch returns `412` and Ruby raises hard. Bumping it would
+break older renderers immediately upon gem upgrade.
+
+**Fix**: **Do NOT bump `protocolVersion`**. Use runtime capability detection instead.
+
+```ts
+// packages/react-on-rails-pro/src/postpone.ts (module top)
+function pprRuntimeMissing(): string | null {
+ const missing: string[] = [];
+ if (typeof globalThis.AbortController !== 'function') missing.push('AbortController');
+ if (typeof globalThis.AbortSignal !== 'function') missing.push('AbortSignal');
+ if (typeof (globalThis as any).AsyncLocalStorage !== 'function') missing.push('AsyncLocalStorage');
+ return missing.length ? missing.join(', ') : null;
+}
+
+// In capabilities/proPPR.ts entry (lazy-checked at first call):
+const missing = pprRuntimeMissing();
+if (missing) {
+ throw new Error(
+ `React on Rails Pro PPR requires runtime globals not available in this VM: ${missing}. ` +
+ `Upgrade your Pro node renderer to a version that injects these globals (>= the version that ships PPR support).`
+ );
+}
+```
+
+Add a Ruby-side companion check that prints a helpful message at boot if `enable_ppr_support` is
+true but the renderer doesn't have these globals. We add a tiny capability probe sent to the
+renderer at startup:
+
+```ruby
+# Lazy: only checked when first PPR helper is invoked, to avoid extra startup cost.
+def ppr_react_component(component_name, options = {})
+ ReactOnRailsPro::PPR.ensure_runtime_supported!
+ ...
+end
+```
+
+`ensure_runtime_supported!` issues a `runInVM` that returns `typeof AbortController === 'function' && typeof AsyncLocalStorage === 'function'`. Cached after first success per process.
+
+This avoids the protocol-version compatibility break entirely.
+
+---
+
+## `[v4 R-4]` — ALS context propagation across delayed callbacks
+
+**Codex right** — ALS is fragile when callbacks fire from external async resources. While Node's
+ALS does propagate across `await`, timers, and most stream events that were registered inside
+`als.run`, codex is correct that it's risky to assume — and stream `_read` invoked by an
+external pull may run with the consumer's ALS context, not the producer's.
+
+**Fix**: Defense-in-depth — wrap every callback with `phaseStore.run` explicitly. Also,
+inject PPR globals OUTSIDE the `supportModules` gate so PPR-without-supportModules either works
+or fails fast.
+
+```ts
+// packages/react-on-rails-pro/src/postpone.ts
+const phaseStore = new (globalThis as any).AsyncLocalStorage<{ phase: 'prerender' | 'resume' }>();
+type Phase = 'prerender' | 'resume';
+
+export function withPhase(phase: Phase, fn: () => T): T {
+ return phaseStore.run({ phase }, fn);
+}
+
+export function getCurrentPhase(): Phase | null {
+ return phaseStore.getStore()?.phase ?? null;
+}
+
+export function usePostpone(reason?: string): void {
+ if (getCurrentPhase() === 'prerender') throw NEVER_RESOLVES;
+}
+```
+
+In `proPPR.ts`:
+
+```ts
+async function prerenderReactComponentForPPR(options) {
+ return withPhase('prerender', async () => {
+ const controller = new AbortController();
+ const timer = setTimeout(
+ () => withPhase('prerender', () => controller.abort()), // re-bind in callback
+ options.prerenderTimeoutMs,
+ );
+ try {
+ const { prelude, postponed } = await prerenderToNodeStream(element, {
+ signal: controller.signal,
+ identifierPrefix: domNodeId,
+ onError: (err) => withPhase('prerender', () => reportError(err)),
+ });
+ const shellHtml = await streamToString(prelude); // streamToString listens to 'data'/'end'
+ ...
+ } finally {
+ clearTimeout(timer);
+ }
+ });
+}
+```
+
+Likewise for resume — wrap `resumeToPipeableStream(...)`, `pipe(destination)`, all event listeners
+in `withPhase('resume', ...)`. Cheap, robust, no reliance on subtle async_hooks behavior.
+
+For inject-outside-supportModules: make a separate `ppr-globals` block in vm.ts that runs
+unconditionally (or at least independently of `supportModules`). PPR-only globals are tiny and
+have no side effects.
+
+```ts
+// vm.ts (always-on PPR globals, independent of supportModules)
+const PPR_GLOBALS = { AbortController, AbortSignal, AsyncLocalStorage };
+extendContext(contextObject, PPR_GLOBALS);
+```
+
+This means PPR works even when users have `supportModules: false`, AND it preserves the
+existing semantic that PPR-using codepaths fail loud (via runtime detection) if for some
+reason these globals are missing.
+
+VM tests:
+```ts
+test('AbortController is available unconditionally', async () => {
+ const config = getConfig();
+ config.supportModules = false; // explicit
+ await createUploadedBundleForTest();
+ await buildVM(uploadedBundlePathForTest());
+ const result = await runInVM(`typeof AbortController === 'function'`, uploadedBundlePathForTest());
+ expect(result).toBe('true');
+});
+test('AsyncLocalStorage is available unconditionally', ...);
+test('phase persists across await/setTimeout/abort within ALS run', ...);
+```
+
+---
+
+## Open question answered (codex v3 final asks)
+
+a) **Cache shape blessed**: `{ shell_html: String, postponed_state: String|nil, console_replay_script: String, ppr_version: 1 }`.
+b) **`cache_key:` required**, `:if`/`:unless` supported (mirrors `cached_react_component`). The
+ `use_cache?` check from `ReactOnRailsPro::Cache` already handles `:if`/`:unless`.
+c) **Failure mode**: prerender JS returns `{ hasErrors: true, errorMessage }`; Ruby helper raises
+ `ReactOnRailsPro::Error` (or falls back to error HTML if `raise_on_prerender_error: false`),
+ never writes the cache (Rails.cache.fetch's block must return normally to write).
+
+## Diff vs v3
+
+- `js_code_builder.rb` updated; `RenderRequest` gets new delegated predicates.
+- `configuration.rb` follows existing `attr_accessor` + `DEFAULT_*` + `initialize` pattern.
+- No `protocolVersion` bump. Runtime capability detection in JS + Ruby probe.
+- Defense-in-depth ALS wrapping (`withPhase`) on every callback boundary.
+- PPR globals injected unconditionally (independent of `supportModules`).
+
+## Verdict request
+
+If this is APPROVE, I'll start implementing in this order:
+1. OSS render_options.rb predicates (single small commit)
+2. Pro consumer surgical updates (pro_rendering.rb, server_rendering_js_code.rb, js_code_builder.rb)
+3. Node renderer VM globals (AbortController, AbortSignal, AsyncLocalStorage)
+4. JS PPR capability + postpone helper + register in node entry only
+5. Ruby ppr_react_component + PPR cache module + configuration knobs
+6. Dummy app demo pages (multi-Suspense, edge cases)
+7. Tests: unit (JS+Ruby) + Playwright + Chrome DevTools MCP verification
diff --git a/.claude/docs/ppr/design-v5.md b/.claude/docs/ppr/design-v5.md
new file mode 100644
index 0000000000..863256d05f
--- /dev/null
+++ b/.claude/docs/ppr/design-v5.md
@@ -0,0 +1,173 @@
+# PPR Design Proposal — v5 (post-codex-v4 review)
+
+Codex blessed v4's four contract points; blocked on 2 import/registration concerns. v5 addresses both.
+
+---
+
+## `[v5 R-1]` — Lazy React PPR API imports
+
+**Codex right**. Pro's `package.json` declares `react >= 16`, and a static import of
+`react-dom/static.prerenderToNodeStream` would crash module loading in apps on React 16/17/18/19.0/19.1.
+
+**Fix**: lazy-import React PPR APIs inside the first PPR call. Two-step approach:
+
+### Step 1 — `proPPR.ts` does NOT import React PPR APIs at module top
+
+```ts
+// packages/react-on-rails-pro/src/capabilities/proPPR.ts (top — note: no direct react-dom imports)
+import type { Readable } from 'stream';
+// imports for our internal helpers, types, etc. — but NOT for prerenderToNodeStream / resumeToPipeableStream
+```
+
+### Step 2 — first call lazily resolves both React entry points
+
+```ts
+type PPRReactAPIs = {
+ prerenderToNodeStream: typeof import('react-dom/static').prerenderToNodeStream;
+ resumeToPipeableStream: typeof import('react-dom/server').resumeToPipeableStream;
+};
+
+let cachedPPRReactAPIs: PPRReactAPIs | null = null;
+let cachedPPRError: Error | null = null;
+
+async function loadPPRReactAPIs(): Promise {
+ if (cachedPPRReactAPIs) return cachedPPRReactAPIs;
+ if (cachedPPRError) throw cachedPPRError;
+ try {
+ const [staticMod, serverMod] = await Promise.all([
+ import('react-dom/static'),
+ import('react-dom/server'),
+ ]);
+ if (typeof staticMod.prerenderToNodeStream !== 'function' ||
+ typeof serverMod.resumeToPipeableStream !== 'function') {
+ throw new Error(
+ 'React on Rails Pro PPR requires React 19.2+ (react-dom/static.prerenderToNodeStream and react-dom/server.resumeToPipeableStream). ' +
+ `Current React is ${require('react').version}. Upgrade react and react-dom to >= 19.2.`
+ );
+ }
+ cachedPPRReactAPIs = {
+ prerenderToNodeStream: staticMod.prerenderToNodeStream,
+ resumeToPipeableStream: serverMod.resumeToPipeableStream,
+ };
+ return cachedPPRReactAPIs;
+ } catch (e) {
+ cachedPPRError = e as Error;
+ throw e;
+ }
+}
+```
+
+### Step 3 — capability check expanded
+
+`pprRuntimeMissing()` (in `postpone.ts`) checks both VM globals AND React APIs:
+
+```ts
+async function checkPPRRuntime(): Promise {
+ const missing: string[] = [];
+ if (typeof globalThis.AbortController !== 'function') missing.push('AbortController');
+ if (typeof globalThis.AbortSignal !== 'function') missing.push('AbortSignal');
+ if (typeof (globalThis as any).AsyncLocalStorage !== 'function') missing.push('AsyncLocalStorage');
+ if (missing.length) throw new Error(`PPR missing VM globals: ${missing.join(', ')}`);
+ await loadPPRReactAPIs(); // throws on missing React APIs
+}
+```
+
+### Step 4 — registration is cheap
+
+Registering the capability does NOT trigger the lazy import. The capability methods just call
+`await loadPPRReactAPIs()` on entry. So `ReactOnRails.node.ts` can safely include
+`createProPPRCapability()` without breaking older React apps. The error only surfaces if the
+user actually invokes a PPR helper.
+
+---
+
+## `[v5 R-2]` — RSC component detection
+
+**Codex right**. `registerServerComponent` wraps RSCRoute and calls plain `ReactOnRails.register`,
+losing the "this is RSC" tag in the registry.
+
+**Fix**: Tag RSC wrappers with a shared symbol; carry through `RegisteredComponent`.
+
+### Step 1 — shared symbol
+
+```ts
+// packages/react-on-rails/src/types/rscMarker.ts (or a small new file in shared types)
+// New OSS export so it's available to both Pro registry and Pro RSC wrapper.
+export const RSC_COMPONENT_MARKER = Symbol.for('react_on_rails_pro.rsc_component');
+
+export function markAsRSCComponent(fn: T): T {
+ Object.defineProperty(fn, RSC_COMPONENT_MARKER, { value: true, enumerable: false, configurable: false });
+ return fn;
+}
+
+export function isRSCComponent(value: unknown): boolean {
+ // typeof a function is 'function', not 'object' — accept both.
+ return !!value &&
+ (typeof value === 'function' || typeof value === 'object') &&
+ (value as any)[RSC_COMPONENT_MARKER] === true;
+}
+```
+
+### Step 2 — wrap server component registrations
+
+```ts
+// packages/react-on-rails-pro/src/registerServerComponent/server.tsx
+const componentsWrappedInRSCRoute: Record = {};
+for (const [componentName] of Object.entries(components)) {
+ componentsWrappedInRSCRoute[componentName] = markAsRSCComponent(
+ wrapServerComponentRenderer(
+ (props: unknown) => ,
+ componentName,
+ ),
+ );
+}
+```
+
+### Step 3 — proPPR refuses RSC components
+
+```ts
+// in prerenderReactComponentForPPR
+const componentObj = ComponentRegistry.get(name);
+if (isRSCComponent(componentObj.component)) {
+ throw new Error(
+ `ppr_react_component does not support RSC components in v1. ` +
+ `Use stream_react_component for "${name}" or wait for PPR+RSC support.`
+ );
+}
+```
+
+The Ruby helper relays this error to the Rails view via the standard error path
+(`hasErrors: true`).
+
+---
+
+## v5 implementation contract (final)
+
+1. **Cache shape**: `{ shell_html: String, postponed_state: String|nil, console_replay_script: String, ppr_version: 1 }`. ✅
+2. **Helper signature**: `ppr_react_component(name, cache_key:, props:, prerender_timeout_ms:, cache_options:, if:, unless:, on_complete:)`. ✅
+3. **Failure semantics**: prerender JS returns `hasErrors: true`; Ruby raises (or falls back per `raise_on_prerender_error`); cache never written. ✅
+4. **Registration scope**: PPR registered in `ReactOnRails.node.ts` only. RSC components rejected at runtime. ✅
+5. **Lazy React imports**: PPR APIs loaded on first call, not at module top. ✅
+6. **VM globals**: `AbortController`, `AbortSignal`, `AsyncLocalStorage` injected unconditionally. ✅
+7. **Phase tracking**: `withPhase(phase, fn)` defense-in-depth wrap on every callback boundary. ✅
+8. **Predicate audit**: `streaming?` updated where needed, surgically; `html_or_rsc_streaming?`
+ added; `js_code_builder.rb`, `pro_rendering.rb`, `server_rendering_js_code.rb` updated. ✅
+
+## Implementation order
+
+1. `react_on_rails/lib/react_on_rails/react_component/render_options.rb` — predicates.
+2. `react_on_rails_pro/lib/react_on_rails_pro/{server_rendering_pool/pro_rendering.rb, server_rendering_js_code.rb, js_code_builder.rb, request.rb}` — predicate consumers.
+3. `packages/react-on-rails-pro-node-renderer/src/worker/vm.ts` — VM globals (AbortController, AbortSignal, AsyncLocalStorage), unconditional.
+4. `packages/react-on-rails/src/types/rscMarker.ts` — RSC marker symbol (OSS so it's shared).
+5. `packages/react-on-rails-pro/src/registerServerComponent/server.tsx` — tag RSC wrappers.
+6. `packages/react-on-rails-pro/src/postpone.ts` — `withPhase`, `usePostpone`.
+7. `packages/react-on-rails-pro/src/capabilities/proPPR.ts` — capability impl, lazy React imports.
+8. `packages/react-on-rails-pro/src/ReactOnRails.node.ts` — register PPR capability.
+9. `react_on_rails_pro/lib/react_on_rails_pro/{configuration.rb, ppr.rb}` — config + cache module.
+10. `react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb` — `ppr_react_component`.
+11. JS unit tests (`packages/react-on-rails-pro/tests/proPPR.test.tsx`).
+12. Ruby unit tests (`react_on_rails_pro/spec/react_on_rails_pro/ppr_spec.rb`).
+13. VM tests (`packages/react-on-rails-pro-node-renderer/tests/vm.test.ts`).
+14. Dummy app demo (`react_on_rails_pro/spec/dummy/client/app/components/PPRDemo/`, `app/views/pages/ppr_demo.html.erb`, route).
+15. Chrome DevTools MCP verification + Playwright E2E (`react_on_rails_pro/spec/dummy/e2e-tests/ppr.spec.ts`).
+16. CHANGELOG entry.
diff --git a/.claude/docs/ppr/screenshot.png b/.claude/docs/ppr/screenshot.png
new file mode 100644
index 0000000000..873d437ac3
Binary files /dev/null and b/.claude/docs/ppr/screenshot.png differ
diff --git a/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts b/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
index 4b31d34b75..662d8f1613 100644
--- a/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
+++ b/packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
@@ -11,6 +11,7 @@ import cluster from 'cluster';
import type { Readable } from 'stream';
import { ReadableStream } from 'stream/web';
import { promisify, TextEncoder } from 'util';
+import { AsyncLocalStorage } from 'async_hooks';
import type { ReactOnRails as ROR } from 'react-on-rails' with { 'resolution-mode': 'import' };
import type { Context } from 'vm';
@@ -213,6 +214,16 @@ export async function buildVM(filePath: string) {
const contextObject = { sharedConsoleHistory, runOnOtherBundle };
+ // PPR globals — injected unconditionally, independent of `supportModules`.
+ // PPR (Partial Prerendering) requires AbortController/AbortSignal for prerender
+ // abort timing and AsyncLocalStorage for phase tracking across async boundaries.
+ // Code that doesn't use PPR is unaffected by these globals being present.
+ extendContext(contextObject, {
+ AbortController,
+ AbortSignal,
+ AsyncLocalStorage,
+ });
+
if (supportModules) {
// IMPORTANT: When adding anything to this object, update:
// 1. docs/oss/building-features/node-renderer/js-configuration.md
diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json
index 95b9996056..d74df743a6 100644
--- a/packages/react-on-rails-pro/package.json
+++ b/packages/react-on-rails-pro/package.json
@@ -56,7 +56,8 @@
},
"./RSCRoute": "./lib/RSCRoute.js",
"./RSCProvider": "./lib/RSCProvider.js",
- "./ServerComponentFetchError": "./lib/ServerComponentFetchError.js"
+ "./ServerComponentFetchError": "./lib/ServerComponentFetchError.js",
+ "./postpone": "./lib/postpone.js"
},
"dependencies": {
"react-on-rails": "workspace:*"
diff --git a/packages/react-on-rails-pro/src/ReactOnRails.node.ts b/packages/react-on-rails-pro/src/ReactOnRails.node.ts
index f2eae43a78..74cacba57c 100644
--- a/packages/react-on-rails-pro/src/ReactOnRails.node.ts
+++ b/packages/react-on-rails-pro/src/ReactOnRails.node.ts
@@ -14,11 +14,24 @@
import { createSSRCapability } from 'react-on-rails/@internal/capabilities/ssr';
import { createProStreamingCapability } from './capabilities/proStreaming.ts';
+import { createProPPRCapability } from './capabilities/proPPR.ts';
import createReactOnRailsPro from './createReactOnRailsPro.ts';
const currentGlobal = globalThis.ReactOnRails || null;
+// PPR capability is registered on the Node SSR entry only — NOT on the RSC bundle entry.
+// PPR + RSC composition is intentionally deferred (see RFC #3244). Registering the capability
+// is cheap: the React PPR APIs are loaded lazily on first prerenderReactComponentForPPR /
+// resumeReactComponentForPPR call, so apps using older React versions are unaffected unless
+// they actually invoke a PPR helper.
const ReactOnRails = createReactOnRailsPro(
- [createSSRCapability(), createProStreamingCapability()],
+ // The PPR capability adds two new render functions that the Ruby side dispatches by
+ // render_mode (`:ppr_prerender`, `:ppr_resume`). They aren't part of `ReactOnRailsInternal`
+ // (the OSS interface), so the cast widens to allow capability-specific extensions.
+ [
+ createSSRCapability(),
+ createProStreamingCapability(),
+ createProPPRCapability() as Record,
+ ],
currentGlobal,
);
diff --git a/packages/react-on-rails-pro/src/capabilities/proPPR.ts b/packages/react-on-rails-pro/src/capabilities/proPPR.ts
new file mode 100644
index 0000000000..5f5246a41a
--- /dev/null
+++ b/packages/react-on-rails-pro/src/capabilities/proPPR.ts
@@ -0,0 +1,392 @@
+/* eslint-disable import/prefer-default-export -- named export for consistency with capability API */
+
+/*
+ * Copyright (c) 2025 Shakacode LLC
+ *
+ * This file is NOT licensed under the MIT (open source) license.
+ * It is part of the React on Rails Pro offering and is licensed separately.
+ *
+ * Unauthorized copying, modification, distribution, or use of this file,
+ * via any medium, is strictly prohibited without a valid license agreement
+ * from Shakacode LLC.
+ *
+ * For licensing terms, please see:
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
+ */
+
+import { PassThrough, Readable } from 'stream';
+import * as React from 'react';
+
+import createReactOutput from 'react-on-rails/createReactOutput';
+import { isPromise } from 'react-on-rails/isServerRenderResult';
+import buildConsoleReplay, { consoleReplay } from 'react-on-rails/buildConsoleReplay';
+import { convertToError } from 'react-on-rails/serverRenderUtils';
+import { isRSCComponent } from 'react-on-rails/rscMarker';
+import type { RenderParams, RailsContext } from 'react-on-rails/types';
+
+import * as ComponentRegistry from '../ComponentRegistry.ts';
+import { withPhase } from '../postpone.ts';
+import { transformRenderStreamChunksToResultObject } from '../streamingUtils.ts';
+import handleError from '../handleError.ts';
+
+// ─── Lazy React PPR API loader ──────────────────────────────────────────────────────────────
+// Pro declares peer-dep `react >= 16`. The PPR APIs (prerenderToNodeStream,
+// resumeToPipeableStream) only exist in React 19.2+. We resolve them lazily so apps on older
+// React versions can still load the Pro Node entry without crashing.
+
+type PrerenderToNodeStreamFn = (
+ children: React.ReactElement,
+ options?: {
+ signal?: AbortSignal;
+ identifierPrefix?: string;
+ bootstrapScripts?: string[];
+ onError?: (error: unknown) => void;
+ },
+) => Promise<{ prelude: Readable; postponed: unknown | null }>;
+
+type ResumeToPipeableStreamFn = (
+ children: React.ReactElement,
+ postponed: unknown,
+ options?: {
+ onError?: (error: unknown) => void;
+ },
+) => {
+ pipe: (destination: NodeJS.WritableStream) => NodeJS.WritableStream;
+ abort: (reason?: unknown) => void;
+};
+
+let cachedAPIs: {
+ prerenderToNodeStream: PrerenderToNodeStreamFn;
+ resumeToPipeableStream: ResumeToPipeableStreamFn;
+} | null = null;
+let cachedAPIError: Error | null = null;
+
+async function loadPPRReactAPIs() {
+ if (cachedAPIs) return cachedAPIs;
+ if (cachedAPIError) throw cachedAPIError;
+ try {
+ const [staticMod, serverMod] = await Promise.all([
+ // The dynamic import is intentional: it postpones resolving these specifiers until the
+ // first PPR call. Apps on React 16-19.1 can register the PPR capability without crashing.
+ import('react-dom/static'),
+ import('react-dom/server'),
+ ]);
+ const prerenderToNodeStream = (
+ staticMod as unknown as { prerenderToNodeStream?: PrerenderToNodeStreamFn }
+ ).prerenderToNodeStream;
+ const resumeToPipeableStream = (
+ serverMod as unknown as { resumeToPipeableStream?: ResumeToPipeableStreamFn }
+ ).resumeToPipeableStream;
+ if (typeof prerenderToNodeStream !== 'function' || typeof resumeToPipeableStream !== 'function') {
+ const reactVersion = (React as unknown as { version: string }).version;
+ throw new Error(
+ `React on Rails Pro PPR requires React 19.2+ (prerenderToNodeStream and resumeToPipeableStream). ` +
+ `Current React is ${reactVersion}. Upgrade react and react-dom to >= 19.2.`,
+ );
+ }
+ cachedAPIs = { prerenderToNodeStream, resumeToPipeableStream };
+ return cachedAPIs;
+ } catch (e) {
+ cachedAPIError = convertToError(e);
+ throw cachedAPIError;
+ }
+}
+
+function checkPPRRuntimeOrThrow(): void {
+ const missing: string[] = [];
+ if (typeof globalThis.AbortController !== 'function') missing.push('AbortController');
+ if (typeof globalThis.AbortSignal !== 'function') missing.push('AbortSignal');
+ if (typeof (globalThis as unknown as { AsyncLocalStorage?: unknown }).AsyncLocalStorage !== 'function')
+ missing.push('AsyncLocalStorage');
+ // Real timers are required by the prerender abort path. The node renderer's `stubTimers`
+ // option (default true) replaces setTimeout/clearTimeout with no-ops, which would make the
+ // abort timer never fire and PPR would hang until external timeouts. We probe by scheduling
+ // a no-op and checking the returned handle is truthy and clearTimeout is callable.
+ const setT = (globalThis as unknown as { setTimeout?: unknown }).setTimeout;
+ const clearT = (globalThis as unknown as { clearTimeout?: unknown }).clearTimeout;
+ if (typeof setT !== 'function' || typeof clearT !== 'function') {
+ missing.push('setTimeout/clearTimeout');
+ } else {
+ const handle = (setT as (cb: () => void, ms: number) => unknown)(() => {}, 0);
+ // Stubbed timers in the node renderer return undefined.
+ if (handle === undefined || handle === null) missing.push('setTimeout (real, not stubbed)');
+ else (clearT as (h: unknown) => void)(handle);
+ }
+ if (missing.length) {
+ throw new Error(
+ `React on Rails Pro PPR requires runtime globals not available in this VM: ${missing.join(', ')}. ` +
+ `Upgrade your Pro node renderer to a version that injects these globals (>= the version that ` +
+ `ships PPR support), and ensure stubTimers is disabled (set RENDERER_STUB_TIMERS=false or ` +
+ `stubTimers: false in the renderer config) so the prerender abort timer can fire.`,
+ );
+ }
+}
+
+// ─── helpers ────────────────────────────────────────────────────────────────────────────────
+
+function streamToString(readable: Readable): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ readable.on('data', (chunk: Buffer | string) => {
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
+ });
+ readable.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
+ readable.on('error', reject);
+ });
+}
+
+async function resolveComponentElement(
+ options: PPRRenderOptions,
+ // We accept a loose RailsContext shape here because PPR adds its own fields and the strict
+ // `RailsContext` requires `getRSCPayloadStream` for streaming flows we don't engage.
+ railsContext: Record & { serverSide?: boolean },
+): Promise {
+ const componentObj = ComponentRegistry.get(options.name);
+ if (!componentObj) throw new Error(`PPR: component "${options.name}" is not registered`);
+ if (isRSCComponent(componentObj.component)) {
+ throw new Error(
+ `ppr_react_component does not support RSC components in v1. Use stream_react_component for "${options.name}".`,
+ );
+ }
+ const result = createReactOutput({
+ componentObj,
+ props: options.props,
+ // The local railsContext is a permissive Record; createReactOutput accepts the strict
+ // RailsContext type. We cast at the boundary — the Pro PPR helper synthesizes the context
+ // from rails_context the JS side received, augmented with PPR-specific keys.
+ railsContext: railsContext as unknown as RailsContext,
+ domNodeId: options.domNodeId,
+ trace: options.trace,
+ }) as unknown;
+ const resolved = isPromise(result) ? await (result as Promise) : result;
+ if (!React.isValidElement(resolved)) {
+ throw new Error(
+ `PPR: render function for "${options.name}" did not return a React element (got ${typeof resolved})`,
+ );
+ }
+ return resolved as React.ReactElement;
+}
+
+// ─── types ──────────────────────────────────────────────────────────────────────────────────
+
+type PPRRenderOptions = RenderParams & {
+ // Injected by server_rendering_js_code.rb on the railsContext side; we don't read these from
+ // the top-level options, but the caller may pass them too. Kept here for type completeness.
+};
+
+type PPRPrerenderResult = {
+ html: string;
+ pprShellHtml: string;
+ pprPostponedState: string | null;
+ consoleReplayScript: string;
+ hasErrors: boolean;
+ errorMessage?: string;
+ isShellReady: boolean;
+};
+
+// ─── Phase A: prerender ────────────────────────────────────────────────────────────────────
+
+async function prerenderReactComponentForPPR(options: PPRRenderOptions): Promise {
+ checkPPRRuntimeOrThrow();
+ const { prerenderToNodeStream } = await loadPPRReactAPIs();
+
+ // The Ruby side passes the timeout via railsContext.pprPrerenderTimeoutMs. Default 8s.
+ const railsContext = (options.railsContext ?? {}) as RailsContext & {
+ pprPrerenderTimeoutMs?: number;
+ pprPhase?: string;
+ };
+ const prerenderTimeoutMs = Number(railsContext.pprPrerenderTimeoutMs) || 8_000;
+
+ return withPhase('prerender', async () => {
+ let timer: NodeJS.Timeout | undefined;
+ const controller = new AbortController();
+
+ const cleanup = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ if (!controller.signal.aborted) controller.abort();
+ };
+
+ try {
+ // Build a sanitized railsContext for prerender — request-varying fields are omitted so
+ // user code is forced to read them inside postponed boundaries. The Ruby side also strips
+ // sensitive context, but we belt-and-suspenders here.
+ const prerenderRailsContext = {
+ ...railsContext,
+ serverSide: true as const,
+ };
+ const reactElement = await resolveComponentElement(options, prerenderRailsContext);
+
+ timer = setTimeout(() => {
+ // re-bind the phase in the timer callback (defense-in-depth — withPhase is cheap).
+ withPhase('prerender', () => controller.abort(new Error('ppr-prerender-timeout')));
+ }, prerenderTimeoutMs);
+
+ const onError = (err: unknown) => {
+ // Expected AbortError during normal abort flow — swallow.
+ const msg = err instanceof Error ? err.message : String(err);
+ if (msg.includes('aborted') || msg.includes('AbortError')) return;
+ // eslint-disable-next-line no-console
+ console.error('[PPR prerender] error:', err);
+ };
+
+ const { prelude, postponed } = await prerenderToNodeStream(reactElement, {
+ signal: controller.signal,
+ identifierPrefix: options.domNodeId,
+ onError: (err) => withPhase('prerender', () => onError(err)),
+ });
+
+ const shellHtml = await streamToString(prelude);
+ const consoleReplayScript = buildConsoleReplay();
+
+ return {
+ html: shellHtml,
+ pprShellHtml: shellHtml,
+ pprPostponedState: postponed ? JSON.stringify(postponed) : null,
+ consoleReplayScript,
+ hasErrors: false,
+ isShellReady: true,
+ };
+ } catch (e) {
+ const error = convertToError(e);
+ return {
+ html: '',
+ pprShellHtml: '',
+ pprPostponedState: null,
+ consoleReplayScript: consoleReplay(console.history ?? [], 0),
+ hasErrors: true,
+ errorMessage: `${error.message}\n${error.stack ?? ''}`,
+ isShellReady: false,
+ };
+ } finally {
+ cleanup();
+ }
+ });
+}
+
+// ─── Phase B: resume ───────────────────────────────────────────────────────────────────────
+
+function resumeReactComponentForPPR(options: PPRRenderOptions): Readable {
+ // Set up the Pro chunk-format pipeline first so we can return the stream synchronously.
+ // We mark isShellReady: true from the start because the cached shell IS the shell —
+ // no React shell handshake needs to happen.
+ const renderState: import('react-on-rails/types').StreamRenderState = {
+ result: null,
+ hasErrors: false,
+ isShellReady: true,
+ };
+
+ const { readableStream, pipeToTransform, writeChunk, emitError, endStream } =
+ transformRenderStreamChunksToResultObject(renderState);
+
+ const railsContext = (options.railsContext ?? {}) as RailsContext & {
+ pprShellHtml?: string;
+ pprPostponedState?: string;
+ };
+ const shellHtml = railsContext.pprShellHtml ?? '';
+ const postponedStateJson = railsContext.pprPostponedState ?? null;
+
+ const failBeforeShell = (error: Error): Readable => {
+ renderState.hasErrors = true;
+ renderState.error = error;
+ if (options.throwJsErrors) {
+ emitError(error);
+ } else {
+ const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true });
+ pipeToTransform(errorHtmlStream);
+ }
+ return readableStream;
+ };
+
+ // VALIDATE the postponed state BEFORE writing the shell chunk. If parsing fails, we want to
+ // surface the error to Rails through the normal error path (and let the helper invalidate the
+ // cache entry), not commit a half-broken response with the shell already on the wire.
+ let parsedPostponedState: unknown = null;
+ if (postponedStateJson) {
+ try {
+ parsedPostponedState = JSON.parse(postponedStateJson);
+ } catch (e) {
+ return failBeforeShell(
+ new Error(
+ `PPR resume: cached postponed state is not valid JSON for "${options.name}". ` +
+ 'The cache entry is likely corrupted; clear the PPR cache to recover. ' +
+ `(parse error: ${(e as Error).message})`,
+ ),
+ );
+ }
+ }
+
+ // Stream the cached shell as the first chunk immediately. (The transform wraps it in a JSON
+ // envelope; Rails-side build_react_component_result_for_server_streamed_content unpacks the
+ // first chunk into the component wrapper as usual.)
+ writeChunk(shellHtml);
+
+ // If there's no postponed state (fully-static page) just end the stream.
+ if (parsedPostponedState === null) {
+ endStream();
+ return readableStream;
+ }
+
+ // Run resume inside withPhase('resume') — defense-in-depth. The postpone helper would also
+ // be a no-op without the phase, but other Pro libs may key off it.
+ const runResume = async () => {
+ try {
+ checkPPRRuntimeOrThrow();
+ const { resumeToPipeableStream } = await loadPPRReactAPIs();
+ const reactElement = await resolveComponentElement(options, railsContext);
+
+ const passThrough = new PassThrough();
+ const resumeStream = resumeToPipeableStream(reactElement, parsedPostponedState, {
+ onError: (err) =>
+ withPhase('resume', () => {
+ renderState.hasErrors = true;
+ renderState.error = convertToError(err);
+ if (options.throwJsErrors) emitError(err);
+ }),
+ });
+ // resumeToPipeableStream returns synchronously with .pipe(destination). We pipe into
+ // a PassThrough so we can hand a Readable to the existing transform pipeline.
+ resumeStream.pipe(passThrough);
+ pipeToTransform(passThrough);
+ } catch (e) {
+ // POST-shell error (after writeChunk): the shell is already on the wire so we can't
+ // redirect to a fresh error page. Surface via the chunk pipeline. Honor throwJsErrors so
+ // tests / strict consumers see the failure rather than a partial render.
+ const error = convertToError(e);
+ renderState.hasErrors = true;
+ renderState.error = error;
+ if (options.throwJsErrors) {
+ emitError(error);
+ } else {
+ const errorHtmlStream = handleError({ e: error, name: options.name, serverSide: true });
+ pipeToTransform(errorHtmlStream);
+ }
+ }
+ };
+
+ withPhase('resume', () => {
+ runResume().catch((e: unknown) => {
+ const error = convertToError(e);
+ renderState.hasErrors = true;
+ renderState.error = error;
+ emitError(error);
+ });
+ });
+
+ return readableStream;
+}
+
+/**
+ * Pro PPR capability — registers `prerenderReactComponentForPPR` and `resumeReactComponentForPPR`
+ * on the Pro ReactOnRails instance. Both are dispatched by the Ruby side via render_mode
+ * `:ppr_prerender` and `:ppr_resume`.
+ */
+export function createProPPRCapability() {
+ return {
+ isPPRCapable: true as const,
+ prerenderReactComponentForPPR,
+ resumeReactComponentForPPR,
+ };
+}
diff --git a/packages/react-on-rails-pro/src/postpone.ts b/packages/react-on-rails-pro/src/postpone.ts
new file mode 100644
index 0000000000..d1b98febec
--- /dev/null
+++ b/packages/react-on-rails-pro/src/postpone.ts
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2025 Shakacode LLC
+ *
+ * This file is NOT licensed under the MIT (open source) license.
+ * It is part of the React on Rails Pro offering and is licensed separately.
+ *
+ * Unauthorized copying, modification, distribution, or use of this file,
+ * via any medium, is strictly prohibited without a valid license agreement
+ * from Shakacode LLC.
+ *
+ * For licensing terms, please see:
+ * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
+ */
+
+/**
+ * PPR (Partial Prerendering) phase tracking.
+ *
+ * Two phases:
+ * - 'prerender': building the static shell. usePostpone() throws a never-resolving promise,
+ * so the surrounding boundary is captured as a hole.
+ * - 'resume': request-time fill. usePostpone() is a no-op so the boundary renders normally.
+ *
+ * AsyncLocalStorage propagates the phase across awaits, timers, and stream events as long as
+ * the related callback is registered inside withPhase(...). Pro's PPR capability defensively
+ * wraps every callback boundary (timeouts, abort listeners, onError, prerender Promise chain)
+ * with `withPhase` to avoid relying on subtle async_hooks behavior.
+ */
+
+type PPRPhase = 'prerender' | 'resume';
+
+// AsyncLocalStorage is injected by the Pro node renderer as a VM global. Pull it from globalThis
+// rather than `import 'node:async_hooks'` so this module loads cleanly even when run inside a
+// constrained sandbox without Node externals (e.g. ExecJS — though PPR is gated to the node
+// renderer at the Ruby helper level).
+const ALSCtor = (globalThis as unknown as { AsyncLocalStorage?: new () => AsyncLocalStorageLike })
+ .AsyncLocalStorage;
+
+interface AsyncLocalStorageLike {
+ run(store: T, fn: () => R): R;
+ getStore(): T | undefined;
+}
+
+let phaseStore: AsyncLocalStorageLike | null = null;
+
+/**
+ * Lazily allocate the phase store on first PPR use. If AsyncLocalStorage is missing entirely,
+ * we fall back to a module-level slot — safe because the Pro node renderer worker is single-
+ * threaded per request and PPR is gated to that runtime by the Ruby helper.
+ */
+function getPhaseStore(): AsyncLocalStorageLike {
+ if (phaseStore) return phaseStore;
+ if (ALSCtor) {
+ phaseStore = new ALSCtor();
+ return phaseStore;
+ }
+ // Fallback (single-process, single-thread): one slot.
+ let slot: { phase: PPRPhase } | undefined;
+ const fallback: AsyncLocalStorageLike = {
+ run(store: { phase: PPRPhase }, fn: () => R): R {
+ const prev = slot;
+ slot = store;
+ try {
+ return fn();
+ } finally {
+ slot = prev;
+ }
+ },
+ getStore(): { phase: PPRPhase } | undefined {
+ return slot;
+ },
+ };
+ phaseStore = fallback;
+ return phaseStore;
+}
+
+/** Run `fn` with the active PPR phase set. Use defensively at every callback boundary. */
+export function withPhase(phase: PPRPhase, fn: () => R): R {
+ return getPhaseStore().run({ phase }, fn);
+}
+
+/** Returns the current active PPR phase, or null if no PPR call is on the stack. */
+export function getCurrentPhase(): PPRPhase | null {
+ return getPhaseStore().getStore()?.phase ?? null;
+}
+
+// Single shared sentinel — allocated once. Throwing a never-resolving promise is the React 19.2
+// idiom for declaring "I am dynamic; postpone my Suspense boundary". (React.unstable_postpone
+// was removed before any stable release.)
+const NEVER_RESOLVES: Promise = new Promise(() => {
+ /* never resolves */
+});
+
+/**
+ * Mark the surrounding boundary as a postponed hole during PPR's prerender phase.
+ *
+ * Behaviour:
+ * - Inside prerender → throws a never-resolving promise. The boundary becomes POSTPONED and
+ * its placeholder is emitted as `` in the prelude.
+ * - Inside resume → no-op. The component renders normally with full request data.
+ * - Outside any PPR phase → no-op. usePostpone is safe to call from non-PPR render paths.
+ *
+ * @param _reason developer-friendly note. Unused at runtime but kept for ergonomics + parity
+ * with Next.js' equivalent helper.
+ */
+export function usePostpone(_reason?: string): void {
+ if (getCurrentPhase() === 'prerender') {
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal -- React PPR contract is to
+ // throw a (never-resolving) promise, not an Error. Throwing a thenable is how Suspense
+ // detects "this is async" — the engine awaits it and never gets a result, so the boundary
+ // is captured as a postponed task when AbortController fires.
+ throw NEVER_RESOLVES;
+ }
+}
diff --git a/packages/react-on-rails-pro/src/registerServerComponent/server.tsx b/packages/react-on-rails-pro/src/registerServerComponent/server.tsx
index 4bcf31c67f..675f662e7e 100644
--- a/packages/react-on-rails-pro/src/registerServerComponent/server.tsx
+++ b/packages/react-on-rails-pro/src/registerServerComponent/server.tsx
@@ -14,6 +14,7 @@
import * as React from 'react';
import { ReactComponent, RenderFunction } from 'react-on-rails/types';
+import { markAsRSCComponent } from 'react-on-rails/rscMarker';
import ReactOnRails from '../ReactOnRails.client.ts';
import RSCRoute from '../RSCRoute.tsx';
import wrapServerComponentRenderer from '../wrapServerComponentRenderer/server.tsx';
@@ -39,9 +40,12 @@ import wrapServerComponentRenderer from '../wrapServerComponentRenderer/server.t
const registerServerComponent = (components: Record) => {
const componentsWrappedInRSCRoute: Record = {};
for (const [componentName] of Object.entries(components)) {
- componentsWrappedInRSCRoute[componentName] = wrapServerComponentRenderer(
- (props: unknown) => ,
- componentName,
+ // Tag the wrapper so non-RSC paths (e.g. PPR) can refuse it cleanly.
+ componentsWrappedInRSCRoute[componentName] = markAsRSCComponent(
+ wrapServerComponentRenderer(
+ (props: unknown) => ,
+ componentName,
+ ),
);
}
diff --git a/packages/react-on-rails-pro/tests/proPPR.test.tsx b/packages/react-on-rails-pro/tests/proPPR.test.tsx
new file mode 100644
index 0000000000..f1836183de
--- /dev/null
+++ b/packages/react-on-rails-pro/tests/proPPR.test.tsx
@@ -0,0 +1,156 @@
+/**
+ * @jest-environment node
+ *
+ * End-to-end unit test for the PPR capability. Exercises:
+ * - Phase A (prerender) with a mix of static + postponed boundaries.
+ * - Phase B (resume) producing $RC instructions for postponed boundaries.
+ * - Fully-static page (postponedState === null) takes the early-exit path on resume.
+ * - usePostpone returns a no-op on resume so the same component tree renders normally.
+ *
+ * NOTE: testEnvironment override to `node` is required so dynamic `import('react-dom/static')`
+ * resolves to `static.node.js` (which exports prerenderToNodeStream) rather than
+ * `static.browser.js` (which does not).
+ */
+import * as React from 'react';
+import { Suspense } from 'react';
+import { Readable } from 'stream';
+
+// Register globals the capability checks for. Real node renderer injects these in vm.ts.
+import { AsyncLocalStorage } from 'async_hooks';
+(globalThis as unknown as { AsyncLocalStorage: typeof AsyncLocalStorage }).AsyncLocalStorage =
+ AsyncLocalStorage;
+
+import { createProPPRCapability } from '../src/capabilities/proPPR';
+import { usePostpone } from '../src/postpone';
+import { register } from '../src/ComponentRegistry';
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+const FastStatic: React.FC = (() => {
+ // Async function components are valid in React 19; simulate "resolves quickly".
+ const Component = async () => {
+ await sleep(5);
+ return
+ loading}>
+
+
+
+);
+
+beforeAll(() => {
+ register({
+ PPRTestRoot: PPRRoot as unknown as React.ComponentType,
+ PPRTestStaticOnly: StaticOnly as unknown as React.ComponentType,
+ });
+});
+
+function streamToString(s: Readable): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ s.on('data', (c: Buffer) => chunks.push(c));
+ s.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
+ s.on('error', reject);
+ });
+}
+
+describe('PPR capability', () => {
+ const capability = createProPPRCapability();
+
+ test('prerender + resume produces shell with hole, then fills hole on resume', async () => {
+ const prerenderResult = await capability.prerenderReactComponentForPPR({
+ name: 'PPRTestRoot',
+ props: {},
+ domNodeId: 'PPRTestRoot-0',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ railsContext: { pprPrerenderTimeoutMs: 500 } as never,
+ } as never);
+
+ expect(prerenderResult.hasErrors).toBe(false);
+ expect(prerenderResult.pprShellHtml).toContain('Synchronous banner');
+ expect(prerenderResult.pprShellHtml).toContain('FastStatic content');
+ expect(prerenderResult.pprShellHtml).toContain('SlowStatic content');
+ expect(prerenderResult.pprShellHtml).not.toContain('hello-from-resume');
+ // Hole exists when one or more boundaries postponed.
+ expect(prerenderResult.pprPostponedState).toBeTruthy();
+
+ const resumeStream = capability.resumeReactComponentForPPR({
+ name: 'PPRTestRoot',
+ props: { resumeMessage: 'hello-from-resume' },
+ domNodeId: 'PPRTestRoot-0',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ railsContext: {
+ pprShellHtml: prerenderResult.pprShellHtml,
+ pprPostponedState: prerenderResult.pprPostponedState,
+ } as never,
+ } as never);
+
+ const resumed = await streamToString(resumeStream);
+ // First chunk: shell wrapped in JSON envelope. The resume HTML should arrive in subsequent chunks.
+ const lines = resumed.trim().split('\n').filter(Boolean);
+ expect(lines.length).toBeGreaterThanOrEqual(1);
+ expect(resumed).toContain('Synchronous banner');
+ expect(resumed).toContain('hello-from-resume');
+ }, 30_000);
+
+ test('fully-static page returns null postponedState and short-circuits on resume', async () => {
+ const prerenderResult = await capability.prerenderReactComponentForPPR({
+ name: 'PPRTestStaticOnly',
+ props: {},
+ domNodeId: 'PPRTestStaticOnly-0',
+ trace: false,
+ throwJsErrors: false,
+ renderingReturnsPromises: false,
+ railsContext: { pprPrerenderTimeoutMs: 500 } as never,
+ } as never);
+
+ expect(prerenderResult.hasErrors).toBe(false);
+ expect(prerenderResult.pprShellHtml).toContain('Static-only sync');
+ expect(prerenderResult.pprPostponedState).toBeNull();
+ }, 30_000);
+});
diff --git a/packages/react-on-rails/package.json b/packages/react-on-rails/package.json
index f1bd627701..a2921db488 100644
--- a/packages/react-on-rails/package.json
+++ b/packages/react-on-rails/package.json
@@ -42,6 +42,7 @@
"./reactHydrateOrRender": "./lib/reactHydrateOrRender.js",
"./turbolinksUtils": "./lib/turbolinksUtils.js",
"./isRenderFunction": "./lib/isRenderFunction.js",
+ "./rscMarker": "./lib/rscMarker.js",
"./ReactOnRails.client": "./lib/ReactOnRails.client.js",
"./ReactOnRails.full": "./lib/ReactOnRails.full.js",
"./handleError": "./lib/handleError.js",
diff --git a/packages/react-on-rails/src/rscMarker.ts b/packages/react-on-rails/src/rscMarker.ts
new file mode 100644
index 0000000000..612d15ed61
--- /dev/null
+++ b/packages/react-on-rails/src/rscMarker.ts
@@ -0,0 +1,30 @@
+/**
+ * Marks a registered component as an RSC (React Server Components) wrapper so callers can
+ * decide whether to support it. Used by:
+ * - Pro `registerServerComponent` to tag wrapped functions.
+ * - Pro `ppr_react_component` (PPR) to refuse RSC components in v1 (RSC + PPR composition
+ * is intentionally deferred).
+ *
+ * Symbol.for is used so the marker is stable across module boundaries / multiple package
+ * instances (e.g. when the OSS package is loaded both from a host and a yalc-linked copy).
+ */
+export const RSC_COMPONENT_MARKER = Symbol.for('react_on_rails_pro.rsc_component');
+
+export function markAsRSCComponent unknown)>(value: T): T {
+ Object.defineProperty(value, RSC_COMPONENT_MARKER, {
+ value: true,
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ });
+ return value;
+}
+
+export function isRSCComponent(value: unknown): boolean {
+ // typeof a function is 'function', not 'object' — accept both.
+ return (
+ !!value &&
+ (typeof value === 'function' || typeof value === 'object') &&
+ (value as { [k: symbol]: unknown })[RSC_COMPONENT_MARKER] === true
+ );
+}
diff --git a/react_on_rails/lib/react_on_rails/react_component/render_options.rb b/react_on_rails/lib/react_on_rails/react_component/render_options.rb
index 83d7744d61..f4521f2af3 100644
--- a/react_on_rails/lib/react_on_rails/react_component/render_options.rb
+++ b/react_on_rails/lib/react_on_rails/react_component/render_options.rb
@@ -155,11 +155,24 @@ def render_mode
# - :html_streaming: Progressive SSR using renderToPipeableStream (non-blocking and rendering incrementally)
# - :rsc_payload_streaming: Server Components serialized in React flight format
# (non-blocking and rendering incrementally).
+ # - :ppr_prerender: Partial Prerendering — build the static shell once, returning a Hash
+ # with shellHtml + postponedState. Non-streaming over the wire (one Promise).
+ # - :ppr_resume: Partial Prerendering — stream the cached shell first, then fill postponed
+ # boundaries via React's resumeToPipeableStream. Streaming over the wire.
options.fetch(:render_mode, :sync)
end
def streaming?
- # Returns true if the component should be rendered incrementally
+ # Returns true if the component should be rendered incrementally over the wire.
+ # NOTE: callers that need "the legacy streaming behaviors" (RSC payload injection, the
+ # Pro stream cache wrap, etc.) must use html_or_rsc_streaming? instead — PPR resume
+ # streams but does not want those legacy behaviors.
+ %i[html_streaming rsc_payload_streaming ppr_resume].include?(render_mode)
+ end
+
+ def html_or_rsc_streaming?
+ # Returns true for the legacy streaming modes (HTML streaming + RSC payload streaming).
+ # Use this instead of streaming? in code paths that should NOT engage for PPR.
%i[html_streaming rsc_payload_streaming].include?(render_mode)
end
@@ -173,6 +186,18 @@ def html_streaming?
render_mode == :html_streaming
end
+ def ppr_prerender?
+ render_mode == :ppr_prerender
+ end
+
+ def ppr_resume?
+ render_mode == :ppr_resume
+ end
+
+ def ppr?
+ ppr_prerender? || ppr_resume?
+ end
+
def store_dependencies
options[:store_dependencies]
end
diff --git a/react_on_rails/lib/react_on_rails/render_request.rb b/react_on_rails/lib/react_on_rails/render_request.rb
index 1d5444b196..121dcacc96 100644
--- a/react_on_rails/lib/react_on_rails/render_request.rb
+++ b/react_on_rails/lib/react_on_rails/render_request.rb
@@ -52,10 +52,26 @@ def streaming?
render_options.streaming?
end
+ def html_or_rsc_streaming?
+ render_options.html_or_rsc_streaming?
+ end
+
def rsc_payload_streaming?
render_options.rsc_payload_streaming?
end
+ def ppr_prerender?
+ render_options.ppr_prerender?
+ end
+
+ def ppr_resume?
+ render_options.ppr_resume?
+ end
+
+ def ppr?
+ render_options.ppr?
+ end
+
private
def validate_server_bundle_configured!
diff --git a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
index 7bf365dc85..32705fc1a2 100644
--- a/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
+++ b/react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
@@ -278,11 +278,68 @@ def cached_async_react_component(component_name, raw_options = {}, &block)
end
end
+ # Renders a React component using Partial Prerendering (PPR).
+ #
+ # Phase A (lazy on first hit): React renders everything it can; an AbortController fires
+ # after `prerender_timeout_ms` (default 8s) so any pending Suspense boundary becomes a
+ # postponed hole. The shell HTML + serialized postponed state are cached together.
+ #
+ # Phase B (every hit after): The cached shell streams immediately as the first chunk; React's
+ # `resumeToPipeableStream` then fills only the postponed boundaries with per-request data.
+ #
+ # Components that need request-varying data (cookies, session, headers, current_user) MUST be
+ # placed inside a `` boundary that calls `usePostpone()` from
+ # `react-on-rails-pro/postpone`. Sibling boundaries that resolve before the abort fires are
+ # baked into the cached shell.
+ #
+ # @param component_name [String] Name of the registered React component (must NOT be an RSC
+ # component — PPR + RSC composition is intentionally deferred in v1).
+ # @param raw_options [Hash] Options forwarded to the rendering pipeline. Required keys:
+ # - :cache_key — String/Array/Proc. Must be present (mirrors `cached_stream_react_component`).
+ # @option raw_options [Hash] :props Component props (passed as a block recommended? No — props
+ # are part of the cache key, so they must be evaluated. Pass via :props directly.)
+ # @option raw_options [Integer] :prerender_timeout_ms Per-call override of the abort timer.
+ # @option raw_options [Hash] :cache_options Forwarded to Rails.cache.fetch (`:expires_in`, etc.)
+ # @option raw_options [Boolean] :if / :unless Conditional caching (mirrors other Pro helpers).
+ # @option raw_options [Proc] :on_complete Forwarded to the streaming pipeline.
+ # @raise [ReactOnRailsPro::Error] when PPR is not enabled or :cache_key is missing.
+ def ppr_react_component(component_name, raw_options = {})
+ ReactOnRailsPro::PPR.ensure_supported!
+ unless raw_options[:cache_key]
+ raise ReactOnRailsPro::Error,
+ "Option 'cache_key' is required for ppr_react_component"
+ end
+
+ on_complete = raw_options.delete(:on_complete)
+ timeout_ms = raw_options.delete(:prerender_timeout_ms) ||
+ ReactOnRailsPro.configuration.ppr_prerender_timeout_ms
+
+ # Step 1: cache lookup (or build the shell on miss). The effective timeout is part of the
+ # cache key — different timeouts lead to different sets of resolved-vs-postponed boundaries
+ # in the cached shell, so they MUST invalidate independently.
+ shell_data = if ReactOnRailsPro::Cache.use_cache?(raw_options)
+ key_options = raw_options.merge(ppr_prerender_timeout_ms: timeout_ms)
+ cache_key = ReactOnRailsPro::PPR.cache_key(component_name, key_options)
+ Rails.logger.debug { "[ReactOnRailsPro] PPR cache_key=#{cache_key.inspect}" }
+ Rails.cache.fetch(cache_key, raw_options[:cache_options] || {}) do
+ internal_ppr_prerender(component_name, raw_options.merge(prerender_timeout_ms: timeout_ms))
+ end
+ else
+ internal_ppr_prerender(component_name, raw_options.merge(prerender_timeout_ms: timeout_ms))
+ end
+
+ # Step 2: stream the shell + resume any postponed boundaries
+ consumer_stream_async(on_complete: on_complete) do
+ internal_ppr_resume(component_name, raw_options, shell_data: shell_data)
+ end
+ end
+
if defined?(ScoutApm)
include ScoutApm::Tracer
instrument_method :cached_react_component, type: "ReactOnRails", name: "cached_react_component"
instrument_method :cached_react_component_hash, type: "ReactOnRails", name: "cached_react_component_hash"
instrument_method :cached_stream_react_component, type: "ReactOnRails", name: "cached_stream_react_component"
+ instrument_method :ppr_react_component, type: "ReactOnRails", name: "ppr_react_component"
end
private
@@ -520,6 +577,71 @@ def internal_rsc_payload_react_component(react_component_name, options = {})
end
end
+ # Phase A — non-streaming prerender. Returns a Hash describing the cached shell:
+ # { shell_html:, postponed_state:, console_replay_script:, has_errors:, error_message: }
+ # Cache writes only happen when has_errors is false (Rails.cache.fetch's block must return
+ # a value to be written; if has_errors is true we raise, so the cache stays clean).
+ def internal_ppr_prerender(component_name, options)
+ timeout_ms = options.delete(:prerender_timeout_ms) ||
+ ReactOnRailsPro.configuration.ppr_prerender_timeout_ms
+ options = options.merge(
+ prerender: true,
+ render_mode: :ppr_prerender,
+ skip_prerender_cache: true,
+ ppr_prerender_timeout_ms: timeout_ms
+ )
+
+ result = internal_react_component(component_name, options)[:result]
+
+ # Hard-validate the JS-side response BEFORE returning. We must NOT let a malformed
+ # response (e.g. a string, an unexpected Hash shape, a missing pprShellHtml key) through —
+ # if Rails.cache.fetch's block returns a value the cache stores it, even if that value
+ # represents a broken shell. Raise instead so the cache write is skipped.
+ unless result.is_a?(Hash)
+ raise ReactOnRailsPro::Error,
+ "PPR prerender for #{component_name} returned a #{result.class} instead of a Hash. " \
+ "Expected { pprShellHtml:, pprPostponedState:, hasErrors:, ... }."
+ end
+
+ if result["hasErrors"]
+ raise ReactOnRailsPro::Error,
+ "PPR prerender failed for #{component_name}: #{result['errorMessage'] || result['html']}"
+ end
+
+ unless result.key?("pprShellHtml")
+ raise ReactOnRailsPro::Error,
+ "PPR prerender for #{component_name} returned a Hash without `pprShellHtml`. " \
+ "This usually means the JS bundle does not have the PPR capability registered. " \
+ "Result keys: #{result.keys.inspect}"
+ end
+
+ {
+ shell_html: result["pprShellHtml"].to_s,
+ postponed_state: result["pprPostponedState"],
+ console_replay_script: result["consoleReplayScript"].to_s,
+ has_errors: false,
+ ppr_version: ReactOnRailsPro::PPR::CACHE_VERSION
+ }
+ end
+
+ # Phase B — streaming resume. Streams the cached shell as the first chunk and then pipes the
+ # resume output (which fills postponed boundaries with per-request data).
+ def internal_ppr_resume(component_name, raw_options, shell_data:)
+ options = raw_options.merge(
+ prerender: true,
+ render_mode: :ppr_resume,
+ skip_prerender_cache: true,
+ ppr_shell_html: shell_data[:shell_html],
+ ppr_postponed_state: shell_data[:postponed_state]
+ )
+ result = internal_react_component(component_name, options)
+ build_react_component_result_for_server_streamed_content(
+ rendered_html_stream: result[:result],
+ component_specification_tag: result[:tag],
+ render_options: result[:render_options]
+ )
+ end
+
def build_react_component_result_for_server_streamed_content(
rendered_html_stream:,
component_specification_tag:,
diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb
index 9e3f1ef30e..9b15d40b01 100644
--- a/react_on_rails_pro/lib/react_on_rails_pro.rb
+++ b/react_on_rails_pro/lib/react_on_rails_pro.rb
@@ -15,6 +15,7 @@
require "react_on_rails_pro/license_validator"
require "react_on_rails_pro/cache"
require "react_on_rails_pro/stream_cache"
+require "react_on_rails_pro/ppr"
require "react_on_rails_pro/server_rendering_pool/pro_rendering"
require "react_on_rails_pro/server_rendering_pool/node_rendering_pool"
require "react_on_rails_pro/server_rendering_js_code"
diff --git a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
index 80004dda2b..1644fa3922 100644
--- a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
+++ b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
@@ -34,7 +34,9 @@ def self.configuration
rsc_bundle_js_file: Configuration::DEFAULT_RSC_BUNDLE_JS_FILE,
react_client_manifest_file: Configuration::DEFAULT_REACT_CLIENT_MANIFEST_FILE,
react_server_client_manifest_file: Configuration::DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE,
- concurrent_component_streaming_buffer_size: Configuration::DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE
+ concurrent_component_streaming_buffer_size: Configuration::DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE,
+ enable_ppr_support: Configuration::DEFAULT_ENABLE_PPR_SUPPORT,
+ ppr_prerender_timeout_ms: Configuration::DEFAULT_PPR_PRERENDER_TIMEOUT_MS
)
end
@@ -63,6 +65,13 @@ class Configuration # rubocop:disable Metrics/ClassLength
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json"
DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE = 64
+ DEFAULT_ENABLE_PPR_SUPPORT = false
+ # Default abort timeout (in milliseconds) for the PPR prerender phase. The first request to
+ # a `ppr_react_component` page blocks while React renders the static shell; the AbortController
+ # fires this many ms after the prerender starts, capturing all completed Suspense siblings
+ # into the shell and marking still-pending boundaries as postponed holes. Tunable per-call
+ # via `ppr_react_component(..., prerender_timeout_ms:)`.
+ DEFAULT_PPR_PRERENDER_TIMEOUT_MS = 8_000
attr_accessor :renderer_url, :renderer_password, :tracing,
:server_renderer, :renderer_use_fallback_exec_js, :prerender_caching,
@@ -72,7 +81,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
:renderer_request_retry_limit, :throw_js_errors, :ssr_timeout,
:profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support,
:rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file,
- :react_server_client_manifest_file
+ :react_server_client_manifest_file, :enable_ppr_support, :ppr_prerender_timeout_ms
attr_reader :concurrent_component_streaming_buffer_size, :renderer_http_keep_alive_timeout
@@ -122,7 +131,9 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
enable_rsc_support: nil, rsc_payload_generation_url_path: nil,
rsc_bundle_js_file: nil, react_client_manifest_file: nil,
react_server_client_manifest_file: nil,
- concurrent_component_streaming_buffer_size: DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE)
+ concurrent_component_streaming_buffer_size: DEFAULT_CONCURRENT_COMPONENT_STREAMING_BUFFER_SIZE,
+ enable_ppr_support: DEFAULT_ENABLE_PPR_SUPPORT,
+ ppr_prerender_timeout_ms: DEFAULT_PPR_PRERENDER_TIMEOUT_MS)
self.renderer_url = renderer_url
self.renderer_password = renderer_password
self.server_renderer = server_renderer
@@ -150,6 +161,8 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
self.react_client_manifest_file = react_client_manifest_file
self.react_server_client_manifest_file = react_server_client_manifest_file
self.concurrent_component_streaming_buffer_size = concurrent_component_streaming_buffer_size
+ self.enable_ppr_support = enable_ppr_support
+ self.ppr_prerender_timeout_ms = ppr_prerender_timeout_ms
end
def setup_config_values
diff --git a/react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb b/react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb
index 1d75affe86..d2da99e1c7 100644
--- a/react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb
+++ b/react_on_rails_pro/lib/react_on_rails_pro/js_code_builder.rb
@@ -105,13 +105,20 @@ def wrap_in_iife(body, render_request)
private
def rsc_streaming?(render_request)
- ReactOnRailsPro.configuration.enable_rsc_support && render_request.streaming?
+ # NOTE: PPR resume is technically streaming over the wire but we do NOT want to engage
+ # the RSC payload injection / generateRSCPayload manifest plumbing here. Use
+ # html_or_rsc_streaming? rather than streaming? so PPR routes around this branch.
+ ReactOnRailsPro.configuration.enable_rsc_support && render_request.html_or_rsc_streaming?
end
# Returns a JavaScript expression for the render function name.
# For RSC streaming, this is a ternary expression (not a simple string literal).
def resolve_render_function_js_expr(render_request)
- if rsc_streaming?(render_request)
+ if render_request.ppr_prerender?
+ "'prerenderReactComponentForPPR'"
+ elsif render_request.ppr_resume?
+ "'resumeReactComponentForPPR'"
+ elsif rsc_streaming?(render_request)
"ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
else
"'serverRenderReactComponent'"
diff --git a/react_on_rails_pro/lib/react_on_rails_pro/ppr.rb b/react_on_rails_pro/lib/react_on_rails_pro/ppr.rb
new file mode 100644
index 0000000000..b69d5a59db
--- /dev/null
+++ b/react_on_rails_pro/lib/react_on_rails_pro/ppr.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module ReactOnRailsPro
+ # Partial Prerendering (PPR) helpers — cache key composition and runtime support checks.
+ #
+ # PPR caches a Hash of `(shell_html, postponed_state, console_replay_script)` keyed by
+ # component + props + bundle digest. On a cache hit the shell streams immediately and only
+ # postponed boundaries execute on the server. See `ppr_react_component` in
+ # `react_on_rails_pro_helper.rb` for usage.
+ module PPR
+ # Bump this when the cached shape changes (renames, new fields, semantic changes). The cache
+ # key includes this so old cached values are invalidated cleanly on upgrade.
+ CACHE_VERSION = 1
+
+ module_function
+
+ # Compose the PPR cache key. Mirrors `ReactOnRailsPro::Cache.react_component_cache_key`
+ # (component + bundle digest + dep digest + user cache_key) and adds a 'ror_pro_ppr-vN'
+ # namespace so PPR cache entries don't collide with other Pro caches.
+ #
+ # The effective `ppr_prerender_timeout_ms` is included so changing the timeout invalidates
+ # the cache: a different timeout produces a different set of resolved-vs-postponed
+ # boundaries in the shell.
+ def cache_key(component_name, options)
+ timeout = options[:ppr_prerender_timeout_ms] ||
+ ReactOnRailsPro.configuration.ppr_prerender_timeout_ms
+ [
+ "ror_pro_ppr-v#{CACHE_VERSION}",
+ "timeout-#{timeout}",
+ *ReactOnRailsPro::Cache.react_component_cache_key(component_name, options.merge(prerender: true))
+ ]
+ end
+
+ # Returns true when PPR helpers can be used in this process. Currently requires:
+ # - `enable_ppr_support` flag in configuration
+ # - the Pro node renderer (ExecJS lacks AbortController/streams)
+ def supported?
+ ReactOnRailsPro.configuration.enable_ppr_support && ReactOnRailsPro.configuration.node_renderer?
+ end
+
+ # Throws a clear error if the runtime can't run PPR.
+ def ensure_supported!
+ return if supported?
+
+ msg = []
+ unless ReactOnRailsPro.configuration.enable_ppr_support
+ msg << "Enable it with `config.enable_ppr_support = true` in config/initializers/react_on_rails_pro.rb."
+ end
+ unless ReactOnRailsPro.configuration.node_renderer?
+ msg << "PPR requires the Pro node renderer (ExecJS lacks AbortController/streams). " \
+ "Set `config.server_renderer = 'NodeRenderer'`."
+ end
+ raise ReactOnRailsPro::Error, "PPR is not available: #{msg.join(' ')}"
+ end
+ end
+end
diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb
index 576cfb7baf..ca7dd82ac6 100644
--- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb
+++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb
@@ -59,14 +59,18 @@ def generate_rsc_payload_js_function(render_options)
# @return [String] JavaScript code that will render the React component on the server
def render(props_string, rails_context, redux_stores, react_component_name, render_options)
render_function_name =
- if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
+ if render_options.ppr_prerender?
+ "'prerenderReactComponentForPPR'"
+ elsif render_options.ppr_resume?
+ "'resumeReactComponentForPPR'"
+ elsif ReactOnRailsPro.configuration.enable_rsc_support && render_options.html_or_rsc_streaming?
# Select appropriate function based on whether the rendering request is running on server or rsc bundle
# As the same rendering request is used to generate the rsc payload and SSR the component.
"ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'"
else
"'serverRenderReactComponent'"
end
- rsc_params = if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming?
+ rsc_params = if ReactOnRailsPro.configuration.enable_rsc_support && render_options.html_or_rsc_streaming?
config = ReactOnRailsPro.configuration
react_client_manifest_file = config.react_client_manifest_file
react_server_client_manifest_file = config.react_server_client_manifest_file
@@ -77,6 +81,16 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
else
""
end
+ ppr_params = if render_options.ppr?
+ <<-JS
+ railsContext.pprPhase = #{render_options.ppr_prerender? ? '"prerender"' : '"resume"'};
+ railsContext.pprPrerenderTimeoutMs = #{render_options.internal_option(:ppr_prerender_timeout_ms).to_json};
+ railsContext.pprShellHtml = #{render_options.internal_option(:ppr_shell_html).to_json};
+ railsContext.pprPostponedState = #{render_options.internal_option(:ppr_postponed_state).to_json};
+ JS
+ else
+ ""
+ end
# This function is called with specific componentName and props when generateRSCPayload is invoked
# In that case, it replaces the empty () with ('componentName', props) in the rendering request
@@ -84,6 +98,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
(function(componentName = #{react_component_name.to_json}, props = undefined) {
var railsContext = #{rails_context};
#{rsc_params}
+ #{ppr_params}
#{generate_rsc_payload_js_function(render_options)}
#{ssr_pre_hook_js}
#{redux_stores}
diff --git a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb
index 6d787a01eb..b650fa1c08 100644
--- a/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb
+++ b/react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/pro_rendering.rb
@@ -61,8 +61,13 @@ def render_with_cache(js_code, render_options)
prerender_cache_key = cache_key(js_code, render_options)
prerender_cache_hit = true
- result = if render_options.streaming?
+ result = if render_options.html_or_rsc_streaming?
render_streaming_with_cache(prerender_cache_key, js_code, render_options)
+ elsif render_options.ppr_resume?
+ # PPR resume manages its own cache (the Pro PPR helper caches the shell
+ # + postponedState as a Hash). Skip the Pro stream cache layer here so we
+ # don't double-cache or capture per-request dynamic content.
+ render_on_pool(js_code, render_options)
else
Rails.cache.fetch(prerender_cache_key) do
prerender_cache_hit = false
diff --git a/react_on_rails_pro/spec/dummy/Gemfile.lock b/react_on_rails_pro/spec/dummy/Gemfile.lock
index e56a14c54f..76e87d1671 100644
--- a/react_on_rails_pro/spec/dummy/Gemfile.lock
+++ b/react_on_rails_pro/spec/dummy/Gemfile.lock
@@ -492,7 +492,7 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.6.0)
- uri (1.0.3)
+ uri (1.1.1)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
index 481a635848..7140275298 100644
--- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
+++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
@@ -96,6 +96,26 @@ def stream_async_components
stream_view_containing_react_components(template: "/pages/stream_async_components")
end
+ # PPR demo pages — rendered through the streaming pipeline because the resume phase streams.
+ # The first request lazily prerenders + caches the shell; subsequent requests stream from cache.
+ def ppr_demo
+ stream_view_containing_react_components(template: "/pages/ppr_demo")
+ end
+
+ def ppr_static_only_demo
+ stream_view_containing_react_components(template: "/pages/ppr_static_only_demo")
+ end
+
+ def ppr_all_dynamic_demo
+ stream_view_containing_react_components(template: "/pages/ppr_all_dynamic_demo")
+ end
+
+ # Test convenience: clear all PPR cache entries so the next visit cold-starts again.
+ def ppr_demo_clear_cache
+ Rails.cache.clear
+ head :no_content
+ end
+
def stream_async_components_for_testing
stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing")
end
diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/ppr_all_dynamic_demo.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/ppr_all_dynamic_demo.html.erb
new file mode 100644
index 0000000000..d56a76065c
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/app/views/pages/ppr_all_dynamic_demo.html.erb
@@ -0,0 +1,18 @@
+
PPR — All Dynamic
+
+ Every Suspense boundary calls usePostpone(). The cached shell consists only of the
+ surrounding markup; every section is filled per-request via resumeToPipeableStream.
+
+ This page is rendered with ppr_react_component. The first hit prerenders + caches
+ the static shell; subsequent hits stream the cached shell immediately and fill the postponed
+ boundaries via React's resumeToPipeableStream.
+
Reload this page once. The first request blocks while the shell is built (default 8s).
+
Reload again. The shell streams immediately; only the dynamic sections wait for resume.
+
Compare timestamps inside dynamic sections vs the synchronous banner — dynamic timestamps
+ change on every request, the banner / static products stay the same.
+
diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/ppr_static_only_demo.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/ppr_static_only_demo.html.erb
new file mode 100644
index 0000000000..ba1287557a
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/app/views/pages/ppr_static_only_demo.html.erb
@@ -0,0 +1,15 @@
+
PPR — Fully Static
+
+ No usePostpone() calls anywhere. After the first hit, the cached shell is the entire page;
+ resume is skipped because postponedState came back null from prerender.
+
+ This page exercises every PPR edge case: fast/slow/slower static boundaries, multiple dynamic
+ boundaries, and a nested Suspense tree where the outer boundary postpones.
+
+
+ {/* Synchronous content — always in the shell, no Suspense needed. */}
+
+
Synchronous banner
+
This text is rendered synchronously and is always in the shell.
+
+
+ Loading header…}>
+
+
+
+ Loading products…}>
+
+
+
+ Loading cart…}>
+
+
+
+ Loading reviews…}>
+
+
+
+ Loading greeting…}>
+
+
+
+ Loading timestamp…}>
+
+
+
+ Loading nested boundary…}>
+
+
+
+
+
+);
+
+export default PPRDemo;
diff --git a/react_on_rails_pro/spec/dummy/client/app/components/PPRDemo/PPRStaticOnly.tsx b/react_on_rails_pro/spec/dummy/client/app/components/PPRDemo/PPRStaticOnly.tsx
new file mode 100644
index 0000000000..44cc76b6d8
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/client/app/components/PPRDemo/PPRStaticOnly.tsx
@@ -0,0 +1,35 @@
+/* PPR demo — fully static page. usePostpone is never called → postponedState comes back null
+ * from prerender, the cache stores only the shell, and resume is skipped at the helper level.
+ * This is the optimal PPR case: every request after the first reads the shell directly. */
+import React, { Suspense } from 'react';
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+const FastSection = async () => {
+ await sleep(50);
+ return