Skip to content

Add RSCRoute ssr={false} to skip initial RSC payload generation#3318

Open
ihabadham wants to merge 10 commits into
mainfrom
ihabadham/feature/rsc-route-ssr-false
Open

Add RSCRoute ssr={false} to skip initial RSC payload generation#3318
ihabadham wants to merge 10 commits into
mainfrom
ihabadham/feature/rsc-route-ssr-false

Conversation

@ihabadham
Copy link
Copy Markdown
Collaborator

@ihabadham ihabadham commented May 19, 2026

Summary

Adds ssr={false} support to RSCRoute.

By default, RSCRoute keeps the existing ssr={true} behavior: the server renders the route, generates its RSC payload, embeds that payload in the HTML, and the client hydrates from the embedded payload.

When ssr={false}, that route instance skips initial server-side RSC payload generation. In streaming SSR, the route throws a classified server-only bailout before provider work. If it is inside a scoped Suspense boundary, React streams that fallback HTML and retries the route on the client.

The client retry uses the existing RSC provider path, including cache lookup, /rsc_payload/:componentName fetching, ServerComponentFetchError, and useRSC().refetchComponent(...) retry behavior.

Part of #3101. Follow-up #3394 adds default-provider support for deferred roots that do not manually use wrapServerComponentRenderer.

Behavior

API

<RSCRoute
  componentName="HeavyWidget"
  componentProps={{ userId }}
  ssr={false}
/>

ssr defaults to true.

  • ssr={false} does not call the server-side RSC payload path during initial SSR.
  • Deferred routes do not get embedded window.REACT_ON_RAILS_RSC_PAYLOADS[...] entries.
  • Suspense owns loading UI; no new fallback prop is added.
  • Mixed wrapped pages work: ssr={true} routes can SSR while ssr={false} routes defer.
  • Error boundary and retry behavior continue through the existing RSCProvider path.
  • Omitting ssr or passing ssr={true} preserves the existing server-rendered path.

Implementation notes

Rendering a user-provided Suspense fallback on the server requires React's server-error bailout path. Returning null would skip RSC payload work, but it would not activate the surrounding Suspense fallback. To get both behaviors, RSCRoute ssr={false} throws a classified server-only bailout before useRSC, provider cache-key generation, or RSC payload generation.

React treats that throw as recoverable when it is caught by Suspense: the server streams the nearest fallback and the client retries the route. React still reports that recovery path through the server stream onError callback and the client hydrateRoot recoverable-error callback, so React on Rails Pro distinguishes this intentional bailout from real render failures.

This PR:

  • Adds ssr?: boolean to RSCRouteProps.
  • Adds RSCRouteSSRFalseBailoutError to mark the intentional server-only bailout.
  • Returns the bailout digest from the stream renderer so React can encode the expected retry path.
  • Suppresses only the matching recoverable bailout during hydration; other recoverable errors still report normally.
  • Keeps the client retry on the existing provider fetch/cache/error/retry path.

Tests

Covered with package tests, streaming tests, docs, and dummy-app E2E for:

  • skipped server payload generation;
  • default ssr={true} behavior;
  • Suspense fallback streaming;
  • mixed ssr={true} / ssr={false} routes in wrapped streaming roots;
  • ServerComponentFetchError and useRSC().refetchComponent(...) retry;
  • no embedded payload for deferred routes;
  • no broad recoverable-error suppression.

Test plan

  • pnpm --filter react-on-rails-pro run type-check
  • pnpm --filter react-on-rails-pro run test
  • pnpm --filter react-on-rails run type-check
  • pnpm --filter react-on-rails run test
  • pnpm --filter react-on-rails run build
  • pnpm --filter react-on-rails-pro run build
  • ENABLE_JEST_CONSOLE=true pnpm --filter react-on-rails-pro exec jest tests/deferredRouteSsr.test.tsx --runInBand
  • ENABLE_JEST_CONSOLE=true pnpm --filter react-on-rails-pro exec jest tests/streamServerRenderedReactComponent.test.jsx --runInBand -t 'RSCRoute ssr=false|post-SSR'
  • cd react_on_rails_pro/spec/dummy && pnpm exec playwright test e2e-tests/rsc_route_ssr_false.spec.ts --project=chromium --config=playwright.config.ts
  • pnpm exec prettier --check <changed files>
  • pnpm exec eslint --no-ignore <changed source and focused test files>
  • git diff --check

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an optional ssr?: boolean prop to RSCRoute (default true). When ssr={false} on the server RSCRoute throws a classified bailout so the nearest Suspense fallback HTML can stream; the client then retries via the existing provider/cache/fetch path. Includes error class, streaming/client handling, tests, docs, and integration demo.

Changes

RSCRoute ssr prop implementation

Layer / File(s) Summary
Bailout error classification mechanism
packages/react-on-rails-pro/src/RSCRouteSSRFalseBailoutError.ts
Exports a fixed bailout digest, RSCRouteSSRFalseBailoutError subclass, and isRSCRouteSSRFalseBailoutError type guard to identify intentional server-side bailouts.
RSCRoute component and ssr prop
packages/react-on-rails-pro/src/RSCRoute.tsx
Adds ssr?: boolean (default true) to props, extracts prior render logic into RSCRouteContent, and makes RSCRoute throw the classified bailout on server when ssr={false}, otherwise renders RSCRouteContent.
Streaming error handling and SSR end notification
packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts
Detects bailout errors in onError via the type guard and returns the bailout digest; adds didNotifySSREnd guard and notifySSREndOnce() to prevent duplicate end notifications.
Client-side hydration error recovery
packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
Adds onRecoverableError to hydrateRoot options that filters out classified bailout errors (checking error and cause) and reports other recoverable errors.
Deferred SSR unit tests
packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx
New test suite with TestErrorBoundary and RetryFallback covering server bailout, bailout classification without provider init, bailout before loader call, default provider path, client Suspense retry, ServerComponentFetchError wrapping, and refetch recovery.
Streaming tests and Jest setup
packages/react-on-rails-pro/tests/jest.setup.js, packages/react-on-rails-pro/tests/streamServerRenderedReactComponent.test.jsx, packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx
Sets IS_REACT_ACT_ENVIRONMENT for tests; streaming tests add helpers to collect chunks and assert Suspense fallback HTML is emitted without RSC payload generation, bailout digest presence, no stream error with throwJsErrors, and single post-SSR hook invocation. Test registry cleanup updated.
Implementation planning documentation
internal/planning/3101-rsc-route-ssr-false.md
Updates Phase 1/Phase 2 semantics, payload-skipping invariant, Suspense ownership rules, and Phase 2 streaming checklist and test strategy.
Integration and E2E test validation
react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx, react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts
Adds mixed SSR/deferred demo route and Playwright spec that verifies initial HTML omits deferred payload, client fetch occurs after navigation, final UI includes deferred content, and SSR-related console warnings are absent.
User-facing documentation
docs/pro/react-server-components/inside-client-components.md
Documents ssr?: boolean with examples and guidance: new ssr={false} subsection, Suspense guidance, updated performance/caching notes, updated example, API table entry, and troubleshooting for missing nearby Suspense boundaries.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Browser
  participant Hydrate as hydrateRoot
  participant Server as React Server
  participant Stream as Streaming Handler
  participant Provider as RSC Provider

  rect rgba(200, 100, 100, 0.5)
  Note over Client,Provider: SSR Phase - ssr={false}
  Server->>Server: RSCRoute render
  Server->>Server: Check ssr=false & window undefined
  Server->>Stream: throw RSCRouteSSRFalseBailoutError
  Stream->>Stream: Detect bailout by digest
  Stream->>Client: Emit Suspense fallback HTML + digest
  end

  rect rgba(100, 150, 200, 0.5)
  Note over Client,Provider: Hydration Phase
  Client->>Hydrate: hydrateRoot with onRecoverableError
  Hydrate->>Hydrate: Bailout appears in recoverable errors
  Hydrate->>Hydrate: isRSCRouteSSRFalseBailoutError check
  Hydrate->>Hydrate: Skip error reporting (filtered)
  end

  rect rgba(100, 200, 100, 0.5)
  Note over Client,Provider: Client Retry Phase - After Mount/Suspense
  Client->>Provider: Suspense triggers retry
  Provider->>Provider: getServerComponent(componentName)
  Provider->>Client: HTTP /rsc_payload/componentName
  Client->>Client: Render received payload and remove fallback
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • shakacode/react_on_rails#3317: Updates the same planning document (internal/planning/3101-rsc-route-ssr-false.md) to refine Phase 1/Phase 2 semantics and the Suspense bailout contract.

Suggested reviewers

  • justin808

Poem

A rabbit hops with glee today,
For deferred routes now find their way —
Suspense shows placeholders light,
The server skips the heavy write,
The client fetches when it's right. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing an ssr={false} prop to RSCRoute to skip initial RSC payload generation during SSR.
Linked Issues check ✅ Passed The PR comprehensively implements all acceptance criteria from issue #3101: adds ssr prop (default true), skips server payload generation when false, reuses client fetch/provider paths, supports mixed pages, includes error boundary compatibility, and updates documentation. Tests cover server payload skipping, HTTP fetching on mount, fetch errors, mixed behavior, and initial HTML verification.
Out of Scope Changes check ✅ Passed All changes directly support the ssr={false} implementation: new error class for classified bailout, stream renderer handling of bailout, client hydration error suppression, test coverage, documentation updates, and dummy app E2E examples. No unrelated modifications detected.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ihabadham/feature/rsc-route-ssr-false

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the P2 Backlog priority label May 19, 2026
@ihabadham ihabadham changed the title Add ssr=false support to RSCRoute Add RSCRoute ssr={false} to skip initial RSC payload generation May 19, 2026
@ihabadham ihabadham changed the title Add RSCRoute ssr={false} to skip initial RSC payload generation Add RSCRoute ssr={false} to skip initial RSC payload generation May 19, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 19, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.83 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.83 KB (0%)
react-on-rails/client bundled (brotli) 53.9 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.9 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.77 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.77 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.81 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.81 KB (0%)
registerServerComponent/client bundled (gzip) 128.17 KB (+0.18% 🔺)
registerServerComponent/client bundled (gzip) (time) 128.17 KB (+0.18% 🔺)
registerServerComponent/client bundled (brotli) 62.22 KB (+0.26% 🔺)
registerServerComponent/client bundled (brotli) (time) 62.22 KB (+0.26% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 122.58 KB (+0.17% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.58 KB (+0.17% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 57.2 KB (+0.2% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.2 KB (+0.2% 🔺)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 19, 2026

Code Review

Overview

This PR implements Phase 2 of the RSCRoute ssr=false feature, upgrading from the Phase 1 mount-gated approach to a proper React Suspense retry path via a classified server-side bailout error.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 19, 2026

Code Review

Overview

This PR implements Phase 2 of the RSCRoute ssr=false feature, upgrading from Phase 1 (mount-gated approach) to a proper React Suspense retry path via a classified server-side bailout error. RSCRouteSSRFalseBailoutError is thrown before any provider-dependent work, React Suspense streams the nearest fallback, and the client retries through the existing RSCProvider path unchanged.

What works well

  • Classification approach: The digest field on RSCRouteSSRFalseBailoutError is the correct way to propagate error identity through React streaming. Returning error.digest from onError is exactly what renderToPipeableStream expects to embed data-dgst in the streamed HTML.
  • Cross-realm guard: isRSCRouteSSRFalseBailoutError does both an instanceof check and a structural digest check. Good practice - protects against module boundary serialization, different module copies in test environments, and bundler deduplication.
  • notifySSREndOnce guard: Prevents double-invocation of the post-SSR hook when onAllReady races with another notification path in streamServerRenderedComponent. The accompanying test proves it.
  • Test coverage: deferredRouteSsr.test.tsx is thorough - server bailout, no-context-needed invariant, no cache-key/loader call before bailout, client Suspense retry, error wrapping, and error-boundary retry all covered.
  • Backward compatibility: ssr defaults to true, existing code is unaffected.

Issues

Documentation: the Suspense for loading states example now implies ssr=false is required for loading states

The section was originally about showing a loading indicator during client-side navigation using a default ssr=true route. The updated example adds ssr=false to SlowServerComponent, conflating two concerns: (1) client-side navigation loading state - wrap any RSCRoute in Suspense, no ssr=false needed; and (2) deferred SSR - ssr=false specifically to skip initial payload generation. A developer reading this section to add a loading spinner during navigation will copy the example and accidentally opt out of SSR. See inline comment.

Minor: redundant cause branch in onRecoverableError

In wrapServerComponentRenderer/client.tsx, when error is not an Error instance, cause is assigned error itself, so the second isRSCRouteSSRFalseBailoutError(cause) check is a no-op duplicate of the first. Harmless since React always passes an Error to onRecoverableError. See inline comment.

Summary

The core implementation is solid and the test suite is comprehensive. Two items: (1) a documentation issue that could lead users to accidentally apply ssr=false when they only want a client-navigation loading indicator; (2) a minor redundant code path.

Comment thread docs/pro/react-server-components/inside-client-components.md Outdated
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR implements Phase 2 of the RSCRoute ssr={false} feature: instead of the Phase 1 mount-gated return null, the server now throws a classified RSCRouteSSRFalseBailoutError before any provider/RSC work, letting React stream the nearest Suspense fallback as real HTML and retry the route on the client through the existing RSCProvider path.

  • RSCRoute throws the bailout error when ssr=false and typeof window === 'undefined'; RSCRouteSSRFalseBailoutError carries a stable digest constant so the stream renderer and hydration handler can identify it as non-fatal.
  • streamServerRenderedReactComponent returns error.digest from onError (the correct React API to embed data-dgst in the HTML) and guards notifySSREnd with a once-flag to prevent double-invocation across multiple bailouts in the same stream.
  • wrapServerComponentRenderer/client adds onRecoverableError to hydrateRoot that silently drops only the classified bailout error while forwarding all other recoverable errors via globalThis.reportError.

Confidence Score: 3/5

The core implementation is solid, but the new E2E test will fail at runtime due to a non-existent Playwright API call.

The streaming SSR bailout, digest propagation, notifySSREndOnce guard, and onRecoverableError suppression are all correctly wired. The single defect is in the E2E test: page.requests() is not a method on Playwright's Page object, so that assertion block will throw a TypeError and the test will never pass.

react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts — the page.requests() call on lines 1137-1145 needs to be replaced with a page.on('request', ...) listener registered before navigation.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/RSCRoute.tsx Adds ssr prop; throws RSCRouteSSRFalseBailoutError before provider work on the server when ssr=false. Logic is clean and correct.
packages/react-on-rails-pro/src/RSCRouteSSRFalseBailoutError.ts New classified bailout error class with digest constant and a dual-check predicate (instance check + structural digest check for cross-realm robustness).
packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts Returns error.digest from onError for bailout errors (correct use of React's digest API), adds notifySSREndOnce idempotency guard, and calls notifySSREndOnce from onAllReady.
packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Adds onRecoverableError to hydrateRoot that suppresses only classified bailout errors while forwarding all other recoverable errors via globalThis.reportError.
packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx New unit tests covering server bailout, client Suspense retry, rejected payload wrapping, and error-boundary retry. Coverage is comprehensive and patterns are correct.
packages/react-on-rails-pro/tests/streamServerRenderedReactComponent.test.jsx New streaming tests verify fallback HTML, no RSC payload generation, no stream error on classified bailout, and single post-SSR hook invocation.
react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts E2E test uses page.requests(), which is not a Playwright API method and will throw at runtime, preventing this test from ever passing.

Comments Outside Diff (1)

  1. react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts, line 1137-1145 (link)

    P1 page.requests() is not a Playwright API

    page.requests() does not exist on Playwright's Page object — calling it will throw TypeError: page.requests is not a function at runtime and the E2E test will never pass. The standard pattern for collecting all requests is to register a page.on('request', ...) listener before navigation.

Reviews (1): Last reviewed commit: "Tidy RSCRoute ssr=false PR diff" | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/planning/3101-rsc-route-ssr-false.md`:
- Around line 264-266: Phase 2's design that depends on React's
onRecoverableError.errorInfo.digest must be updated because React 19 removed
digest; change the plan to identify the intentional bailout via an alternate
channel: either (A) emit a distinct, well-known bailout error type/message from
the server (e.g., Error subclass or unique message prefix) and have the client
match that pattern in onRecoverableError, or (B) include the bailout identifier
in server response metadata/headers and surface that to the client so it can
suppress only that bailout; update the Phase 2 text around onRecoverableError,
the "client suppresses only recoverable errors carrying that digest" wording,
and any downstream sections referencing digest to describe the chosen
alternative mechanism (refer to Phase 2, onRecoverableError, and the
"intentional bailout" handling).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bbac9882-3b75-4697-b6ca-62432b22628e

📥 Commits

Reviewing files that changed from the base of the PR and between 3a1f5c0 and 9cefc62.

📒 Files selected for processing (22)
  • docs/pro/react-server-components/inside-client-components.md
  • internal/planning/3101-rsc-route-ssr-false.md
  • packages/react-on-rails-pro/package.json
  • packages/react-on-rails-pro/src/ClientSideRenderer.ts
  • packages/react-on-rails-pro/src/defaultRSCProviderRegistry.ts
  • packages/react-on-rails-pro/src/handleRecoverableError.client.ts
  • packages/react-on-rails-pro/src/registerDefaultRSCProvider.client.tsx
  • packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
  • packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts
  • packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx
  • packages/react-on-rails/src/reactApis.cts
  • packages/react-on-rails/src/reactHydrateOrRender.ts
  • react_on_rails/lib/react_on_rails/packs_generator.rb
  • react_on_rails/spec/dummy/spec/packs_generator_spec.rb
  • react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb
  • react_on_rails_pro/spec/dummy/app/views/pages/unwrapped_rsc_route_client_render.html.erb
  • react_on_rails_pro/spec/dummy/app/views/pages/unwrapped_rsc_route_stream_render.html.erb
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/LazyUnwrappedRSCRouteChild.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/UnwrappedRSCRouteDemo.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/UnwrappedStreamRSCRouteDemo.tsx
  • react_on_rails_pro/spec/dummy/config/routes.rb
  • react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts
✅ Files skipped from review due to trivial changes (5)
  • react_on_rails_pro/spec/dummy/app/views/pages/unwrapped_rsc_route_stream_render.html.erb
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/UnwrappedStreamRSCRouteDemo.tsx
  • react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/UnwrappedRSCRouteDemo.tsx
  • react_on_rails_pro/spec/dummy/config/routes.rb
  • react_on_rails_pro/spec/dummy/app/views/pages/unwrapped_rsc_route_client_render.html.erb

Comment thread internal/planning/3101-rsc-route-ssr-false.md Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 20, 2026

Code review for RSCRoute ssr=false. See inline comments for specific issues. Overall: the implementation is correct. The classified-error approach for intentional server bailouts is the right pattern for React streaming SSR. Lazy-import of the RSC fetch runtime is a good performance choice. Error boundary retry behavior is preserved. Key items before merge: (1) consider a DEV-mode warning when ssr=false fires without a nearby user-placed Suspense boundary to prevent silent empty-content issues; (2) simplify getRecoverableErrorCause in handleRecoverableError.client.ts by dropping the redundant 'cause' in error check since Error.prototype.cause is typed in ES2022; (3) consider a test for the no-Suspense edge case to document the failure mode explicitly.

Comment thread packages/react-on-rails-pro/src/handleRecoverableError.client.ts Outdated
Comment thread packages/react-on-rails-pro/src/defaultRSCProviderRegistry.ts Outdated
Comment thread packages/react-on-rails-pro/src/RSCRoute.tsx
Comment thread packages/react-on-rails-pro/src/RSCRouteSSRFalseBailoutError.ts
Comment thread packages/react-on-rails-pro/src/registerDefaultRSCProvider.client.tsx Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 20, 2026

Code Review

Overview

This PR implements Phases 2 and 3 of the RSCRoute ssr={false} feature:

  • Phase 2: Instead of Phase 1's return null / mount-gate approach, the server now throws a classified RSCRouteSSRFalseBailoutError under the relevant Suspense boundary. React streams the nearest fallback HTML, and the client retries through the existing RSC provider path.
  • Phase 3: Auto-bundled roots (without a manual wrapServerComponentRenderer) get a default RSC provider automatically registered from the generated client pack, so deferred-only roots work without any manual wrapper.

The design is well-thought-out, the PR description and planning doc are thorough, and the test coverage is solid.


Positives

  • Clean error classification. RSCRouteSSRFalseBailoutError carrying a fixed digest string neatly separates intentional bailouts from real render failures. Returning that digest from the streaming onError hook is exactly how React embeds it into the data-dgst attribute in the streamed HTML — the client can then match and suppress it in onRecoverableError.
  • Good lazy-loading hygiene. getReactServerComponent.client.ts is dynamically imported inside the provider factory, so the RSC fetch runtime is only bundled/loaded when a deferred route actually needs to fetch a payload.
  • Correct provider placement. The default provider wraps the root outside error boundaries, so fallback UIs can still call useRSC().refetchComponent(...). The tests validate this explicitly.
  • notifySSREndOnce guard. React streaming can call both onError (for the Suspense-caught bailout) and onAllReady (when the shell finishes). The idempotency guard prevents double-firing post-SSR hooks. Worth keeping.
  • Test depth. The new deferredRouteSsr.test.tsx covers server bailout, client Suspense retry, ServerComponentFetchError wrapping, error-boundary refetch, and default-provider placement. The E2E tests cover the real browser flows including the absence of console errors.

Concerns

1. setDefaultRSCProviderFactory silently replaces an existing factory

In defaultRSCProviderRegistry.ts, calling setDefaultRSCProviderFactory a second time silently overwrites the previous factory. In production this is fine — it's called once at module init — but in dev/test double-registration is hard to notice. A dev-mode warning (process.env.NODE_ENV !== 'production') on overwrite would help catch accidental double-imports.

2. handleRecoverableError cause extraction is over-specified

getRecoverableErrorCause checks both error instanceof Error and 'cause' in error. Since ES2022, cause is a standard Error property, so the second guard always passes for any Error instance. The type cast to Error & { cause?: unknown } is also redundant. The intent (checking whether React wrapped the original error with the original as a cause) is correct — the guard could be simplified to error instanceof Error ? error.cause : undefined.

3. typeof window === 'undefined' as server sentinel in RSCRoute

This is the conventional React check and works correctly here. One edge case worth documenting: if RSCRoute ssr={false} is ever used inside a true React Server Component (where window is also undefined but for unrelated reasons), the bailout fires but the Suspense client-retry path may not behave as expected. Probably out of scope for this PR, but a short note in the troubleshooting section of the docs would pre-empt confusion.

4. E2E test fixture: opaque cache-key strings

In rsc_route_ssr_false.spec.ts, payload key constants like DEFAULT_PAYLOAD_KEY = 'MyServerComponent-{}-ServerComponentRouter-react-component-0' encode the internal cache-key format inline. If the format changes, the not.toContain assertions pass silently. Consider deriving these from a shared helper or at minimum documenting their format so a future change to the key scheme doesn't leave stale constants.

5. reactHydrateOrRender fourth-argument convention

The new optional hydrateOptions parameter is used only from ClientSideRenderer via the default-provider path. All other call sites pass 3 args. This is fine but a brief comment at the parameter declaration explaining which path uses it would make the OSS-side change easier to understand in isolation.


Minor Nits

  • The streamBackpressure.e2e.test.tsx change (ComponentRegistry.components().clear()ComponentRegistry.clear()) looks like an incidental cleanup unrelated to the feature — fine to include but worth noting in the PR description.
  • RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST is duplicated as a string constant in rsc_route_ssr_false.spec.ts (line 28) rather than imported from the source module. A test import would make the spec fail fast if the constant is ever renamed.

Summary

The implementation is correct and the design decisions are well-justified. The classified bailout + digest approach is the right way to integrate with React's streaming SSR error handling without broadly suppressing recoverable errors. The lazy-import strategy for the RSC fetch runtime is a solid performance optimization. The concerns above are mostly about defensive coding and test maintainability rather than correctness. Approve with minor suggestions.

Comment thread packages/react-on-rails-pro/src/defaultRSCProviderRegistry.ts Outdated
Comment thread packages/react-on-rails-pro/src/handleRecoverableError.client.ts Outdated
Comment thread packages/react-on-rails-pro/src/registerDefaultRSCProvider.client.tsx Outdated
Comment thread react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b74724db1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/react-on-rails-pro/src/ClientSideRenderer.ts Outdated
Comment thread packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts Outdated
@ihabadham ihabadham force-pushed the ihabadham/feature/rsc-route-ssr-false branch from e77ed62 to cf606f1 Compare May 25, 2026 06:12
Comment thread packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts Outdated
Comment thread packages/react-on-rails-pro/src/RSCRoute.tsx
Comment thread docs/pro/react-server-components/inside-client-components.md Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 25, 2026

Code Review

Overview

This PR implements Phase 2 of RSCRoute ssr={false} — skipping initial server-side RSC payload generation by throwing a classified Suspense bailout error on the server. React streams the nearest Suspense fallback HTML and retries the route on the client through the existing RSCProvider path. The implementation is clean, well-tested, and the use of React's onError/data-dgst mechanism for error classification is the right approach.


What works well

  • Correct use of React's streaming error protocol. Returning error.digest from onError in renderToPipeableStream causes React to embed data-dgst="..." in the HTML, enabling the client to identify and suppress only the intentional recoverable error in onRecoverableError. This is exactly what the API is designed for.

  • Tight scope in onRecoverableError. Only the classified bailout digest is suppressed; all other recoverable errors fall through to globalThis.reportError / console.error. This avoids silently swallowing real hydration issues.

  • RSCRouteSSRFalseBailoutError's duck-typing fallback (checking .digest in addition to instanceof) correctly handles cases where React wraps the original error before passing it to onRecoverableError.

  • Test coverage is comprehensive — unit tests, streaming integration tests, and a Playwright E2E spec all target the key behavioral contracts (no server payload generated, Suspense fallback in HTML, client retry path, ServerComponentFetchError + retry).

  • ComponentRegistry.clear() cleanup in test beforeEach hooks is correct — the method exists and this is a cleaner API than the previous .components().clear() call.


Issues to address

1. notifySSREndOnce guard — missing explanation (inline on streamServerRenderedReactComponent.ts:64)

React's renderToPipeableStream guarantees onAllReady fires exactly once. The guard is presumably needed for a Pro-package lifecycle reason not visible in this diff. A brief comment explaining the scenario that makes double-notification possible (and proves the test is non-trivially checking something) would significantly help future maintainers.

2. onRecoverableError cause depth — unspoken assumption (inline on wrapServerComponentRenderer/client.tsx:108)

The one-level error.cause unwrap matches React's current wrapping behavior. If this assumption ever breaks, the recoverable error won't be suppressed and users will see unexpected console noise. Worth a comment, and worth verifying that the instanceof path, the digest path, and the cause path each have a covering test case.

Also: adding the onRecoverableError handler is a behavioral change for all hydration errors, not just the bailout. Previously React used its own internal console.error default. Now non-bailout recoverable errors go to globalThis.reportError first. Apps with global error monitoring (Sentry, Datadog) that hook window.reportError will start receiving these. This is probably the right behavior, but it is worth calling out in the changelog.

3. typeof window === 'undefined' — undocumented constraint (inline on RSCRoute.tsx:109)

Functionally correct for the React on Rails Node renderer environment. A short comment would guard against confusion if this code is ever tested in an environment with a server-side window shim.

4. Documentation: Suspense example now only shows ssr={false} (inline on inside-client-components.md:404)

The "Suspense for loading states" section previously documented using Suspense with the default ssr={true} during client-side navigation. Changing the code example to ssr={false} removes that case without replacing it. Readers may incorrectly conclude that Suspense is only relevant for deferred routes. The fix is small: keep both examples (or at least a note that Suspense is equally important for ssr={true} on client navigation).


Minor notes

  • The E2E spec uses await page.requests() to assert which RSC payload endpoints were called after navigation. This is reliable given the waitForRequest call earlier in the test has already flushed the fetch — the ordering looks correct.
  • The runWithoutWindow helper in the unit tests cleanly restores the original descriptor, including the case where window was not on globalThis at all. Good defensive teardown.
  • Planning doc updates (Phase 1/2 language) are appropriately reflected in the acceptance criteria table.

Summary

The core implementation is solid. The four items above are the only things worth addressing before merge: the two missing comments, the changelog note about the onRecoverableError behavior change, and the documentation example regression. None are blocking on their own, but together they represent a meaningful clarity improvement for both maintainers and users.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts (1)

59-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Notify postSSRHookTracker on error HTML exits too.

notifySSREndOnce() is only reached from onAllReady(). If reactRenderingResult rejects, or onShellError falls back to handleError, the stream still completes without the matching SSR-end notification.

Suggested fix
   const sendErrorHtml = (error: Error) => {
     const errorHtmlStream = handleError({ e: error, name: componentName, serverSide: true });
     pipeToTransform(errorHtmlStream);
+    notifySSREndOnce();
   };

Also applies to: 117-120

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts` around
lines 59 - 69, The SSR-end notification is missed when error HTML is sent;
update sendErrorHtml (and any places that call handleError fallback such as
onShellError and the reactRenderingResult rejection path) to ensure
notifySSREndOnce() is invoked when the error stream finishes or errors.
Concretely, after creating errorHtmlStream in sendErrorHtml, attach handlers
(e.g., 'end'/'close' and 'error') or await the stream completion so that
notifySSREndOnce() is called once the error output completes; keep
notifySSREndOnce idempotent (it already is) so calling it from onShellError and
the rejection catch is safe. Ensure the change references sendErrorHtml,
notifySSREndOnce, onShellError and the reactRenderingResult rejection path.
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts (1)

26-28: ⚡ Quick win

Consider validating the deferred payload request completes successfully.

The test waits for the request to occur but doesn't verify it completed with a successful status. This could mask fetch failures.

📡 Proposed enhancement to validate request success
-    const deferredPayloadRequest = page.waitForRequest(/\/rsc_payload\/SimpleComponent/);
+    const deferredPayloadRequestPromise = page.waitForRequest(/\/rsc_payload\/SimpleComponent/);
     await page.goto(MIXED_ROUTE_PATH);
-    await deferredPayloadRequest;
+    const deferredPayloadRequest = await deferredPayloadRequestPromise;
+    const response = await deferredPayloadRequest.response();
+    expect(response?.status()).toBe(200);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts` around
lines 26 - 28, The test currently awaits the request captured in
deferredPayloadRequest (from
page.waitForRequest(/\/rsc_payload\/SimpleComponent/)) but doesn't verify it
succeeded; update the test to capture the Request object
(deferredPayloadRequest), await its Response (via request.response() or switch
to page.waitForResponse), and assert the response is non-null and has a
successful status (e.g., status === 200) after navigating to MIXED_ROUTE_PATH so
fetch failures are surfaced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts`:
- Around line 59-69: The SSR-end notification is missed when error HTML is sent;
update sendErrorHtml (and any places that call handleError fallback such as
onShellError and the reactRenderingResult rejection path) to ensure
notifySSREndOnce() is invoked when the error stream finishes or errors.
Concretely, after creating errorHtmlStream in sendErrorHtml, attach handlers
(e.g., 'end'/'close' and 'error') or await the stream completion so that
notifySSREndOnce() is called once the error output completes; keep
notifySSREndOnce idempotent (it already is) so calling it from onShellError and
the rejection catch is safe. Ensure the change references sendErrorHtml,
notifySSREndOnce, onShellError and the reactRenderingResult rejection path.

---

Nitpick comments:
In `@react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts`:
- Around line 26-28: The test currently awaits the request captured in
deferredPayloadRequest (from
page.waitForRequest(/\/rsc_payload\/SimpleComponent/)) but doesn't verify it
succeeded; update the test to capture the Request object
(deferredPayloadRequest), await its Response (via request.response() or switch
to page.waitForResponse), and assert the response is non-null and has a
successful status (e.g., status === 200) after navigating to MIXED_ROUTE_PATH so
fetch failures are surfaced.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 935170a4-0949-40b6-8ce9-ef3d82084d91

📥 Commits

Reviewing files that changed from the base of the PR and between e77ed62 and cf606f1.

📒 Files selected for processing (12)
  • docs/pro/react-server-components/inside-client-components.md
  • internal/planning/3101-rsc-route-ssr-false.md
  • packages/react-on-rails-pro/src/RSCRoute.tsx
  • packages/react-on-rails-pro/src/RSCRouteSSRFalseBailoutError.ts
  • packages/react-on-rails-pro/src/streamServerRenderedReactComponent.ts
  • packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
  • packages/react-on-rails-pro/tests/deferredRouteSsr.test.tsx
  • packages/react-on-rails-pro/tests/jest.setup.js
  • packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx
  • packages/react-on-rails-pro/tests/streamServerRenderedReactComponent.test.jsx
  • react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx
  • react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts

ihabadham added 6 commits May 26, 2026 07:56
Use a classified server bailout so scoped Suspense boundaries can stream fallback HTML without generating an RSC payload for ssr=false routes.

Filter that bailout from stream error reporting and suppress only the matching recoverable hydration error by digest, while preserving real render errors.

Add package and dummy-app coverage for fallback rendering, payload skipping, mixed route behavior, and client retry through the existing provider path.
@ihabadham ihabadham force-pushed the ihabadham/feature/rsc-route-ssr-false branch from f07fcba to 9da8bc5 Compare May 26, 2026 04:56
@ihabadham
Copy link
Copy Markdown
Collaborator Author

Address-review follow-up after 213776466:

  • Addressed the docs Suspense example by showing the default RSCRoute case first and documenting ssr={false} separately.
  • Marked the bailout digest as readonly and documented the digest-based classifier fallback.
  • Simplified recoverable-error cause handling so missing causes resolve to undefined instead of rechecking the original error.
  • Investigated the broader post-SSR hook lifecycle comment. Shell/component error behavior does not reproduce as a PR1-created regression, while render-function rejection after hook registration exposes a broader pre-existing lifecycle decision. Deferring that to follow-up rather than expanding this RSCRoute ssr={false} PR.
  • Verified page.requests() is available in the installed Playwright types and already used by the existing Pro dummy E2E fixture.

Resolved the review threads that were addressed, outdated after the split, or explicitly declined with the rationale above.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

Code Review: Add RSCRoute ssr={false} to skip initial RSC payload generation

Overall: This is a well-designed implementation of Phase 2 for the ssr={false} feature. The classified-bailout pattern (throwing a typed error with a digest that React embeds as data-dgst) is the correct approach for intentional Suspense streaming bailouts, and the PR correctly distinguishes it from real render failures throughout the error-handling pipeline. Tests are thorough.


Correctness ✓

  • RSCRouteSSRFalseBailoutError.digest — using a stable string digest rather than instanceof checks across the serialization boundary (server → HTML → client) is the right call. isRSCRouteSSRFalseBailoutError's fallback to digest comparison handles React's recoverable-error wrapping correctly.
  • onError returning error.digest — this is the correct renderToPipeableStream API to encode the digest as data-dgst in streamed HTML; React requires this for client-side identification.
  • sawRSCRouteSSRFalseBailout && !sawUnexpectedRenderError logic in onAllReady — the conditional suppression is correct: only silence the duplicate warning when the classified bailout was the only reason React called onAllReady twice.
  • ComponentRegistry.clear() cleanup in test beforeEach — good fix; components().clear() reached into internals whereas clear() uses the proper exported API.

Issues

1. onRecoverableError cause-chain depth is one level (wrapServerComponentRenderer/client.tsx)

The check only inspects error.cause; it does not recurse. React currently wraps with a single cause, but if React ever nests errors more deeply (or a third-party error boundary rewraps them), the classified bailout could slip through and be reported as a real error. A small recursive helper would future-proof this.

2. No development-mode warning for missing scoped Suspense (RSCRoute.tsx)

When ssr={false} is used without a user-defined <Suspense> wrapper, the root-level <Suspense fallback={null}> silently catches the bailout and renders nothing until the client fills it in. This is a common footgun: the developer gets a blank area with no diagnostic. Even appending "Wrap this route in a boundary with a fallback for loading UI." to the bailout error message would improve DX in development.

3. Minor: wrapServerComponentRenderer.client.test.jsx bypasses project conventions

The test file uses jest.doMock + require() (CommonJS module-reset pattern) with no top-level import statements, relying on Jest globals instead of @jest/globals. This is necessary for the hydrateRoot-options isolation approach, but if the project ever enforces @jest/globals imports project-wide this file will need updating.


Test coverage gap

No test covers ssr={false} without any user-defined Suspense boundary in the streaming path (the root-fallback={null} scenario). That path is documented but untested; a streaming test asserting the shell still renders and the deferred slot renders as empty (not errored) would pin the contract.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

Code Review — Add RSCRoute ssr={false} to skip initial RSC payload generation

Overview

This is a well-structured Phase 2 implementation. The classified-error approach for Suspense bailout is the right React 19 pattern: throwing a digest-bearing error so React embeds the nearest Suspense fallback in the server HTML while the client retries through the existing provider path. Test coverage is thorough across unit, streaming, and E2E layers.

Two things worth addressing before merge:


Bug: page.requests() is not a Playwright API

page.requests() does not exist on Playwright's Page object. This would throw a TypeError at runtime, failing the E2E test unconditionally. The correct pattern is to collect requests via the 'request' event before navigation:

const rscRequestUrls: string[] = [];
page.on('request', (req) => {
  if (req.url().includes('/rsc_payload/')) {
    rscRequestUrls.push(req.url());
  }
});
const deferredPayloadRequest = page.waitForRequest(/\/rsc_payload\/SimpleComponent/);
await page.goto(MIXED_ROUTE_PATH);
await deferredPayloadRequest;

// ... visibility assertions ...

expect(rscRequestUrls.some((url) => url.includes('/rsc_payload/SimpleComponent'))).toBe(true);
expect(rscRequestUrls.some((url) => url.includes('/rsc_payload/MyServerComponent'))).toBe(false);

(See inline comment below.)


Minor: onRecoverableError silently changes behavior for non-bailout errors

Previously hydrateRoot had no onRecoverableError callback, so React used its built-in default: calling console.error with both the error and component-stack context. The new handler replaces that default for all recoverable errors, including non-bailout ones. Calling console.error(error) directly loses the component stack that React would normally append. This is a small regression in error-reporting fidelity for real hydration mismatches.

Consider logging through React's default first and only suppressing the classified bailout, or at minimum noting the intentional trade-off in a comment. (See inline comment below.)


Minor observations (no action required)

  • The isRSCRouteSSRFalseBailoutError type guard annotates its return as error is RSCRouteSSRFalseBailoutError even when matching only by digest — the object may not satisfy all structural properties of the class. This is an acceptable narrowing trade-off given how the guard is used, but worth knowing.
  • The sawRSCRouteSSRFalseBailout && !sawUnexpectedRenderError logic in onAllReady correctly handles the mixed-error scenario covered by the test. The approach is clear.
  • The ComponentRegistry.clear() change in the two test files is a clean improvement; no concerns.

Comment thread react_on_rails_pro/spec/dummy/e2e-tests/rsc_route_ssr_false.spec.ts
Comment thread packages/react-on-rails-pro/src/RSCRoute.tsx
@ihabadham
Copy link
Copy Markdown
Collaborator Author

Address-review summary

Scan scope: full PR history; no prior summary marker.

Mattered

  • None. No correctness/blocking issues were accepted from the latest review pass.

Optional

  • Declined recursive recoverable-error cause-chain matching after verifying the current React 19.2.3 bailout path carries the digest directly on the recoverable error and does not use nested causes.
  • Declined changing non-bailout recoverable-error reporting after verifying React 19.2.3's default recoverable-error handler calls reportGlobalError(error) and does not include component-stack context.
  • Declined adding a no-user-Suspense streaming test; the documented supported/recommended path remains scoped Suspense around RSCRoute ssr={false}.

Skipped

  • Skipped the page.requests() change request after verifying Playwright 1.58.2 includes Page.requests() and this repo already uses it in the E2E fixture.
  • Skipped a runtime warning/dev hint for missing scoped Suspense because RSCRoute cannot reliably distinguish a user-scoped Suspense boundary from the wrapper root fallback={null} boundary; docs already describe the behavior.
  • Skipped test-style feedback around jest.doMock/require() because that shape is intentional for module-isolation around the client wrapper side-effect import.

Follow-up issue: None.

Next default scan starts after this comment. Say check all reviews to rescan the full PR.

@ihabadham ihabadham requested a review from AbanoubGhadban May 26, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant