Skip to content

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

Open
ihabadham wants to merge 5 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 5 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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.18 KB (+0.2% 🔺)
registerServerComponent/client bundled (brotli) (time) 62.18 KB (+0.2% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 122.58 KB (+0.17% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.58 KB (+0.17% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 57.23 KB (+0.26% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.23 KB (+0.26% 🔺)

@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.

return (
<Suspense fallback={<div>Loading…</div>}>
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} />
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} ssr={false} />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This example now ties ssr={false} to the general "Suspense for loading states" use case, but the two are independent:

  • Loading state during client navigation works with any RSCRoute, regardless of ssr. A Suspense boundary around <RSCRoute componentName="SlowServerComponent" ... /> (no ssr={false}) already shows the fallback during client navigation.
  • ssr={false} is specifically for deferring the initial SSR payload. Using it here causes SlowServerComponent to skip SSR entirely, which may not be what a reader wants.

Suggestion: restore the example without ssr={false} so the client-navigation loading case is shown in isolation, or add a comment in the snippet that makes it explicit this is a deferred-SSR example rather than a general loading-state pattern:

Suggested change
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} ssr={false} />
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} />

Comment on lines +95 to +98
const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : error;

if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When error instanceof Error is false, cause is set to error itself, so isRSCRouteSSRFalseBailoutError(cause) on line 98 is identical to isRSCRouteSSRFalseBailoutError(error) — a no-op duplicate.

In practice this branch is unreachable because React always passes an Error to onRecoverableError, but the else-path is still dead weight. If you want to guard against a future change, the cleaner form is:

Suggested change
const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : error;
if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
const cause = error instanceof Error && error.cause !== undefined ? error.cause : undefined;
if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {

This makes it clear that cause is only checked when it is genuinely distinct from error, and avoids the redundant second check when cause would just alias error.

@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.

const handleRecoverableError = (error: unknown) => {
const cause = getRecoverableErrorCause(error);

if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 'cause' in error check and subsequent cast are unnecessary — Error.prototype.cause is standard ES2022 and already typed on Error in modern TypeScript targets.

Suggested change
if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
const getRecoverableErrorCause = (error: unknown): unknown =>
error instanceof Error ? error.cause : undefined;


import type { ReactElement } from 'react';
import type { RailsContext } from 'react-on-rails/types';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This module-level singleton has no 'use client' guard and no server-side runtime check. The import chain currently keeps it client-only (via ClientSideRenderer.ts), but if this file is accidentally imported server-side in a future change, the singleton state would be shared across all SSR requests in the process.

Consider adding a comment at the top of the file documenting the client-only invariant, or a runtime guard:

// This module is client-only. Its mutable singleton state must not be shared across SSR requests.
// It is imported exclusively from client-side modules (ClientSideRenderer.ts).

Comment on lines +109 to +110
if (!ssr && typeof window === 'undefined') {
throw new RSCRouteSSRFalseBailoutError(componentName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two notes on this bailout guard:

  1. Edge-runtime portability: typeof window === 'undefined' is the established pattern in this codebase and correct for Node/Rails streaming. Worth a comment that some edge runtimes (Deno, Cloudflare Workers) define window, so this would behave differently there — good to document before RSC support expands to edge targets.

  2. Silent failure without a scoped Suspense boundary: When this throw is caught by the root Suspense fallback={null} (because the user forgot to add a scoped boundary), the section renders empty with no feedback. A DEV-only console.warn before throwing would give developers an actionable message rather than a silent blank area.

Comment on lines +29 to +34
this.name = 'RSCRouteSSRFalseBailoutError';
}
}

export function isRSCRouteSSRFalseBailoutError(error: unknown): error is RSCRouteSSRFalseBailoutError {
return (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The digest-based duck-typing path here is the correct React pattern: when React serializes an error across the server/client streaming boundary, the instanceof check will fail (the class prototype is lost), so matching on the digest string is the right fallback. Worth a short comment explaining why both checks exist:

Suggested change
this.name = 'RSCRouteSSRFalseBailoutError';
}
}
export function isRSCRouteSSRFalseBailoutError(error: unknown): error is RSCRouteSSRFalseBailoutError {
return (
export function isRSCRouteSSRFalseBailoutError(error: unknown): error is RSCRouteSSRFalseBailoutError {
// instanceof works on the server and in the browser before React serializes the error.
// The digest check handles the hydration boundary case where React wraps the original error
// and the prototype chain is no longer intact.
return (
error instanceof RSCRouteSSRFalseBailoutError ||
(typeof error === 'object' &&
error !== null &&
'digest' in error &&
(error as { digest?: unknown }).digest === RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST)
);
}

Comment on lines +104 to +107
if (isRSCRouteSSRFalseBailoutError(error)) {
return error.digest;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good pattern. Returning error.digest tells React's streaming renderer to embed the digest string in the data-dgst attribute of the Suspense boundary, which the client hydration handler then uses to identify the intentional bailout and suppress it from onRecoverableError. The flow is: server throws → onError returns digest → React encodes digest in HTML → client hydrateRoot calls onRecoverableError with the wrapped error → handleRecoverableError suppresses it.

One small note: for the real-error path, reportError is called but undefined is returned, which is correct — React uses the absence of a return value to mean "no digest, treat this as a real error."

Comment on lines +24 to +28
getServerComponent: async (args) => {
const { default: getReactServerComponent } = await import('./getReactServerComponent.client.ts');
return getReactServerComponent(domNodeId, railsContext)(args);
},
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The if (typeof window !== 'undefined') guard is redundant here since the 'use client' directive at the top already marks this as a client-only module and the file is only imported from client-side code paths. The guard is harmless, but the comment above it is more valuable than the guard itself — explaining why this registration happens at module load time (before any lazy chunk containing RSCRoute is evaluated) would help future maintainers understand the timing constraint.

Also: each call to the factory creates a new createRSCProvider class instance. Since the factory is called once per root mount this is acceptable, but worth noting in case the factory pattern is later reused in a high-frequency context.

@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.

export const setDefaultRSCProviderFactory = (factory: DefaultRSCProviderFactory) => {
defaultRSCProviderFactory = factory;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent overwrite: if this is called a second time (e.g. a generated pack imported twice via barrel re-exports or a chunking edge case), the previous factory is lost with no warning. Since the side-effect import pattern makes double-registration hard to spot, consider guarding in dev:

Suggested change
export const setDefaultRSCProviderFactory = (factory: DefaultRSCProviderFactory) => {
if (process.env.NODE_ENV !== 'production' && defaultRSCProviderFactory) {
console.warn(
'[react-on-rails-pro] setDefaultRSCProviderFactory called more than once. ' +
'The previous factory will be replaced. Check for duplicate imports of registerDefaultRSCProvider/client.',
);
}
defaultRSCProviderFactory = factory;
};

import { isRSCRouteSSRFalseBailoutError } from './RSCRouteSSRFalseBailoutError.ts';

const getRecoverableErrorCause = (error: unknown): unknown =>
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

'cause' in error is always true for any Error instance in ES2022+ (where cause is a standard property), so the second condition never filters anything out. The cast to Error & { cause?: unknown } is also redundant since Error.cause is already typed as unknown in the latest TS lib. This can be simplified:

Suggested change
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
const getRecoverableErrorCause = (error: unknown): unknown =>
error instanceof Error ? error.cause : undefined;

onAllReady() {
streamingTrackers.postSSRHookTracker.notifySSREnd();
notifySSREndOnce();
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Returning a non-undefined string from onError is the hook by which React embeds the value into the data-dgst attribute of the <template data-msg> element in the streamed HTML. The client's onRecoverableError receives an error with that same digest, which is how handleRecoverableError can match and suppress it. This is intentional and correct React streaming SSR behavior — worth a brief inline comment since it's non-obvious:

Suggested change
},
onError(e) {
const error = convertToError(e);
if (isRSCRouteSSRFalseBailoutError(error)) {
// Returning the digest embeds it in the streamed HTML data-dgst attribute so
// the client onRecoverableError handler can identify and suppress this expected retry.
return error.digest;
}
reportError(error);
return undefined;
},

getServerComponent: async (args) => {
const { default: getReactServerComponent } = await import('./getReactServerComponent.client.ts');
return getReactServerComponent(domNodeId, railsContext)(args);
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The dynamic import() here is the intentional tree-shaking boundary — the RSC fetch runtime is only loaded when a deferred route actually initiates a payload fetch, not at root mount. The module system caches the import after the first resolution, so repeated calls to getServerComponent don't re-evaluate the module. This is the right approach, but a short comment would make the intent clear to future readers:

Suggested change
},
getServerComponent: async (args) => {
// Lazy import keeps the RSC fetch runtime out of the initial bundle; the
// module cache ensures this import resolves instantly after the first fetch.
const { default: getReactServerComponent } = await import('./getReactServerComponent.client.ts');
return getReactServerComponent(domNodeId, railsContext)(args);
},

expect(html).not.toContain('Post 1');

const consoleMessages: string[] = [];
page.on('console', (message) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This duplicates the constant from RSCRouteSSRFalseBailoutError.ts as a string literal. If the digest value is ever changed, this spec will silently keep passing (the not.toContain assertions succeed vacuously and the toContain assertion on line 2136 would fail with an unhelpful message). Importing from source makes renames fail fast:

Suggested change
page.on('console', (message) => {
import { RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST } from '../../../packages/react-on-rails-pro/src/RSCRouteSSRFalseBailoutError.ts';

(Adjust the relative path as needed for the E2E test's tsconfig or use a package alias if one exists.)

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".

reactElement,
shouldHydrate,
shouldHydrate && wrappedByDefaultRSCProvider
? { identifierPrefix: domNodeId, onRecoverableError: handleRecoverableError }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid forcing hydration identifierPrefix on default provider roots

Passing identifierPrefix: domNodeId for every hydrated root that gets auto-wrapped by the default RSC provider can create server/client ID divergence on non-stream SSR pages. In this commit, generated client packs now register the default provider broadly, but the non-stream server path (serverRenderReactComponent using renderToString) does not set a matching identifier prefix, so components using useId can hit hydration mismatches and fall back to client rendering when rsc_support_enabled is on and prerendered roots hydrate through this branch.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 25, 2026

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

Overview

This PR implements Phase 2 of the RSCRoute ssr={false} feature. The core mechanism is clean: when ssr={false} and typeof window === 'undefined', RSCRoute throws a classified RSCRouteSSRFalseBailoutError before any provider work runs. React's streaming SSR catches that throw under the nearest Suspense boundary, emits the fallback HTML, and the client retries through the existing RSC provider path. The stream renderer distinguishes the intentional bailout from real failures via the error's digest, and hydrateRoot suppresses the matching recoverable error.

The architecture is sound. A few specific issues below.


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

rsc_route_ssr_false.spec.ts calls await page.requests() at line 37. This method does not exist on Playwright's Page class — the standard approach is to collect requests with page.on('request', ...) before navigation. At runtime this will throw a TypeError, so the assertions that verify which payload URLs were fetched (and that MyServerComponent was not re-fetched) never run. TypeScript should catch this at compile time unless the E2E tsconfig skips type-checking.

Suggested fix:

const rscRequests: string[] = [];
page.on('request', (req) => {
  if (req.url().includes('/rsc_payload/')) rscRequests.push(req.url());
});
const deferredPayloadRequest = page.waitForRequest(/\/rsc_payload\/SimpleComponent/);
await page.goto(MIXED_ROUTE_PATH);
await deferredPayloadRequest;
// ... assertions ...
expect(rscRequests.some((url) => url.includes('/rsc_payload/SimpleComponent'))).toBe(true);
expect(rscRequests.some((url) => url.includes('/rsc_payload/MyServerComponent'))).toBe(false);

Minor: digest field should be readonly

RSCRouteSSRFalseBailoutError.digest is used as an identity token for classification. Declaring it readonly prevents accidental mutation and makes the intent clearer:

readonly digest = RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST;

Minor: Redundant check in onRecoverableError

In wrapServerComponentRenderer/client.tsx, the cause extraction reads:

const cause =
  error instanceof Error && 'cause' in error
    ? (error as Error & { cause?: unknown }).cause
    : error;  // falls back to error itself

When error is not an Error instance, cause is assigned error, so the subsequent isRSCRouteSSRFalseBailoutError(cause) check duplicates the isRSCRouteSSRFalseBailoutError(error) check already evaluated. The fallback should be undefined:

const cause = error instanceof Error && 'cause' in error
  ? (error as Error & { cause?: unknown }).cause
  : undefined;

Observation: notifySSREndOnce guard — consider a brief comment

The didNotifySSREnd flag guards against onAllReady being invoked more than once. This is non-obvious (React's docs describe onAllReady as firing once). A short comment explaining the specific scenario where double-fire can occur on the bailout path would help future maintainers understand why the guard exists.


Looks Good

  • The isRSCRouteSSRFalseBailoutError dual-check (instanceof + digest string fallback) correctly handles error re-wrapping across module boundaries.
  • Returning error.digest from onError in renderToPipeableStream is the correct React 18.3+ mechanism for embedding data-dgst in streamed HTML; the test asserting data-dgst presence confirms the round-trip works.
  • ComponentRegistry.clear() is confirmed to exist at ComponentRegistry.ts:72; the test migration from .components().clear() is correct.
  • The runWithoutWindow helper in deferredRouteSsr.test.tsx cleanly simulates SSR conditions in jsdom without permanently monkeypatching globals.
  • Test coverage is thorough: classified bailout, Suspense fallback, provider path retry, ServerComponentFetchError wrapping, and the refetch/recover cycle are all exercised.

Comment on lines +37 to +42
const rscRequests = (await page.requests()).filter((payloadRequest) =>
payloadRequest.url().includes('/rsc_payload/'),
);
expect(
rscRequests.some((payloadRequest) => payloadRequest.url().includes('/rsc_payload/SimpleComponent')),
).toBe(true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

page.requests() is not a Playwright API — this will throw a TypeError at runtime, causing the assertions below to never execute. Collect requests via page.on('request', ...) before navigating instead:

Suggested change
const rscRequests = (await page.requests()).filter((payloadRequest) =>
payloadRequest.url().includes('/rsc_payload/'),
);
expect(
rscRequests.some((payloadRequest) => payloadRequest.url().includes('/rsc_payload/SimpleComponent')),
).toBe(true);
const rscRequests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/rsc_payload/')) rscRequests.push(req.url());
});
expect(
rscRequests.some((url) => url.includes('/rsc_payload/SimpleComponent')),
).toBe(true);
expect(
rscRequests.some((url) => url.includes('/rsc_payload/MyServerComponent')),
).toBe(false);

Note: page.on('request', ...) must be set up before page.goto(...) (which it already is in this test since the listener registration just needs to move before goto). Also remove the await — event listeners are synchronous.

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-265: Replace the unhyphenated phrase "client recoverable
errors" with the hyphenated compound adjective "client-recoverable errors" in
the document text (referencing the sentence that mentions the client
onRecoverableError retry path and the Phase 2 suppression wording) so the phrase
reads clearly as "client-recoverable errors."
🪄 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: 6fc2775c-bdeb-4f23-b986-21fe9c82fe3a

📥 Commits

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

📒 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
💤 Files with no reviewable changes (2)
  • 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
✅ Files skipped from review due to trivial changes (1)
  • packages/react-on-rails-pro/tests/jest.setup.js

Comment on lines +264 to +265
React still reports this documented retry path through client `onRecoverableError`. Phase 2 should not broadly
suppress client recoverable errors; the intentional-bailout classification belongs on the server stream path.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use hyphenated compound adjective for clarity.

At Line 265, “client recoverable errors” reads better as “client-recoverable errors.”

🧰 Tools
🪛 LanguageTool

[grammar] ~265-~265: Use a hyphen to join words.
Context: ...ase 2 should not broadly suppress client recoverable errors; the intentional-bail...

(QB_NEW_EN_HYPHEN)

🤖 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 `@internal/planning/3101-rsc-route-ssr-false.md` around lines 264 - 265,
Replace the unhyphenated phrase "client recoverable errors" with the hyphenated
compound adjective "client-recoverable errors" in the document text (referencing
the sentence that mentions the client onRecoverableError retry path and the
Phase 2 suppression wording) so the phrase reads clearly as "client-recoverable
errors."

* path as a real server-rendering failure.
*/
export class RSCRouteSSRFalseBailoutError extends Error {
digest = RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The digest field is used as an identity token for classification and should not be mutable. Mark it readonly:

Suggested change
digest = RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST;
readonly digest = RSC_ROUTE_SSR_FALSE_BAILOUT_DIGEST;

Comment on lines +105 to +108
const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : error;

if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When error is not an Error instance, cause falls back to error itself, making isRSCRouteSSRFalseBailoutError(cause) a duplicate of the isRSCRouteSSRFalseBailoutError(error) check already on the same line. The fallback should be undefined:

Suggested change
const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : error;
if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : undefined;
if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {

Comment on lines +64 to +68
const notifySSREndOnce = () => {
if (didNotifySSREnd) return;

didNotifySSREnd = true;
streamingTrackers.postSSRHookTracker.notifySSREnd();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The once-guard is correct and the test proves it's needed, but the reason onAllReady can fire more than once on the bailout path is non-obvious. A short comment would help future maintainers:

Suggested change
const notifySSREndOnce = () => {
if (didNotifySSREnd) return;
didNotifySSREnd = true;
streamingTrackers.postSSRHookTracker.notifySSREnd();
// Guard against onAllReady firing multiple times. React's streaming renderer can
// invoke it an additional time after a Suspense boundary resolves a classified
// bailout (e.g. RSCRoute ssr={false}), resulting in duplicate postSSRHook calls.
const notifySSREndOnce = () => {
if (didNotifySSREnd) return;
didNotifySSREnd = true;
streamingTrackers.postSSRHookTracker.notifySSREnd();
};

ihabadham added 5 commits May 25, 2026 09:08
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 e77ed62 to cf606f1 Compare May 25, 2026 06:12
pipeToTransform(errorHtmlStream);
};

const notifySSREndOnce = () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The notifySSREndOnce guard is a useful safety measure, but the reason onAllReady could fire more than once isn't documented. React's renderToPipeableStream guarantees onAllReady is called exactly once per render, so this guard is defensive against something in the surrounding machinery (e.g., a Pro-package extension point calling notifySSREnd elsewhere). A brief comment explaining the specific scenario that prompted this guard would help future maintainers understand whether it can ever be removed.

Suggested change
const notifySSREndOnce = () => {
// Guard against double-notification: with the Suspense bailout path, the Pro-package
// streaming lifecycle may produce conditions where this callback fires more than once.
const notifySSREndOnce = () => {
if (didNotifySSREnd) return;
didNotifySSREnd = true;
streamingTrackers.postSSRHookTracker.notifySSREnd();
};

const cause =
error instanceof Error && 'cause' in error ? (error as Error & { cause?: unknown }).cause : error;

if (isRSCRouteSSRFalseBailoutError(error) || isRSCRouteSSRFalseBailoutError(cause)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The cause unwrapping is correct for React's current error wrapping behavior (one level of cause), but it may silently fail to suppress the recoverable error if React changes how it wraps errors in a future version. Consider adding a comment noting this assumption, and/or checking whether isRSCRouteSSRFalseBailoutError matching on digest (the duck-typing branch in RSCRouteSSRFalseBailoutError.ts) covers the real-world React wrapping cases seen in testing.

Also worth noting: when neither branch matches, the fallback uses globalThis.reportError before console.error. This is correct modern behaviour, but it's a silent change from the previous default (which was React's own console.error call). Any Pro-level global error monitoring that hooks reportError will now receive these recoverable errors, which may or may not be desired. Worth a mention in the PR changelog/docs.

};

const RSCRoute = ({ componentName, componentProps, ssr = true }: RSCRouteProps): ReactNode => {
if (!ssr && typeof window === 'undefined') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

typeof window === 'undefined' is the right check for the React on Rails Node renderer context. One edge case worth documenting: if this component ever runs in an environment where window is polyfilled/defined on the server (some SSG toolchains or specific test harnesses), the bailout will silently not fire and the component will fall through to RSCRouteContent/useRSC() instead. It won't crash (it matches the ssr={true} path), but the ssr={false} intent is lost without any warning. Given React on Rails Pro's target environment this is unlikely, but a brief comment pinning the assumption would help future readers.

return (
<Suspense fallback={<div>Loading…</div>}>
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} />
<RSCRoute componentName="SlowServerComponent" componentProps={{ user }} ssr={false} />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This code example was changed from the original implicit ssr={true} to ssr={false}. The section is titled "Suspense for loading states", and the original prose explained that Suspense is useful during client-side navigation — i.e., even with the default ssr={true} a route can suspend while the client fetches a payload on navigation.

By changing the example to ssr={false} only, the section no longer documents the more common case. Readers of this section may incorrectly conclude that Suspense is only needed with ssr={false}. Consider keeping both: a short ssr={true} (or no prop) example for client-navigation loading, then the ssr={false} example for the deferred initial render case.

@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

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