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 `