internal/planning/3101-rsc-route-ssr-false.md
RSCRoute ssr={false}Status: implementation plan for issue #3101. Phase 1 is implemented. This plan targets the full implementation for the issue. The phases describe implementation modes and review boundaries, not optional roadmap items.
Add an optional ssr prop to RSCRoute:
<RSCRoute componentName="HeavyWidget" componentProps={{ userId }} ssr={false} />
ssr defaults to true, preserving current behavior. When ssr={false}, the route does not generate
or embed that instance's RSC payload during the initial Rails server render. Phase 1 resolves the same
server component after the React tree has mounted. Phase 2 resolves through React's Suspense retry path
while preserving the same payload-skipping invariant.
The feature gives applications a per-route way to defer lower-priority server component content. It reduces initial server work and initial HTML size for those routes without changing server component registration, error handling, retry behavior, or the existing provider cache model.
RSCRoute currently enters the RSC provider path whenever it renders. During server rendering, that path
uses the server-side RSC implementation, which calls railsContext.getRSCPayloadStream(...), renders the
server component into HTML, and tracks the payload so React on Rails Pro can embed it into the streamed
response as window.REACT_ON_RAILS_RSC_PAYLOADS chunks.
That behavior is correct for content needed in the initial HTML. It is wasteful for content that does not contribute to the initial view or first interaction. This feature is for server component routes whose work can move out of the critical render path: content hidden behind user interaction, content outside the initial viewport, or secondary data that can load after the page becomes interactive.
ssr={false} lets the application opt a single RSCRoute out of that initial server work while preserving
the rest of the RSC runtime behavior once the browser takes over.
ssr?: boolean to RSCRoute, with true as the default.ssr is omitted or set to true.ssr={false}, avoid calling the server-side RSC payload path during the initial server render.RSCRoute instances with deferred instances.fallback prop to RSCRoute; users use React Suspense for loading UI.RSCRoute-specific fetch/cache/error runtime.The prop name is ssr.
ssr is concise, uses common React terminology, and describes the behavior at the React component boundary:
whether this route participates in server-side rendering for the initial request. It also avoids overloading
the Rails helper term prerender, which already has broader meaning in React on Rails.
The default is true so existing applications keep the same behavior without code changes.
No new loading prop is needed. Suspense owns loading UI. RSCRoute owns payload timing. When users want
loading UI, they place the deferred route under a scoped Suspense boundary:
<Suspense fallback={<Spinner />}>
<RSCRoute componentName="HeavyWidget" componentProps={{ userId }} ssr={false} />
</Suspense>
Phase 1 preserves client-side loading UI after mount. Phase 2 renders that scoped Suspense fallback in the server HTML.
The table below describes the Phase 1 contract. Later phases preserve the same payload-skipping invariant and add narrower behavior on top of it.
| Scenario | Server render | First browser hydration render | After mount | Embedded RSC payload |
|---|---|---|---|---|
ssr omitted | Current behavior | Current behavior | Current behavior | Current behavior |
ssr={true} | Current behavior | Current behavior | Current behavior | Current behavior |
ssr={false} | Render no content for that route | Render no content for that route | Resolve through existing RSC provider path | No payload generated by that route during SSR |
The key invariant is narrow and intentional:
An
ssr={false}instance must not initiate server-side RSC payload generation during the initial server render.
That invariant does not require a duplicate browser HTTP request when an equivalent result already exists in the provider cache. Phase 2 preserves this invariant while changing the server HTML when a scoped Suspense boundary covers the deferred route: the route content is still skipped, but React can emit the boundary fallback.
The implementation reuses the current RSC runtime paths:
packages/react-on-rails-pro/src/RSCRoute.tsx currently calls
useRSC().getComponent(componentName, componentProps) during render and passes the returned promise to
PromiseWrapper.packages/react-on-rails-pro/src/RSCProvider.tsx owns the provider-level promise cache. It exposes
getComponent and refetchComponent, and its cache key is based on componentName and componentProps.packages/react-on-rails-pro/src/getReactServerComponent.server.ts is the server implementation that calls
railsContext.getRSCPayloadStream(...).packages/react-on-rails-pro/src/RSCRequestTracker.ts tracks generated payload streams for embedding.packages/react-on-rails-pro/src/injectRSCPayload.ts emits payload initialization and payload chunks into
the HTML stream.packages/react-on-rails-pro/src/getReactServerComponent.client.ts first checks
window.REACT_ON_RAILS_RSC_PAYLOADS; if there is no embedded payload, it fetches from the configured RSC
payload endpoint.RSCRouteErrorBoundary converts errors surfaced from PromiseWrapper into ServerComponentFetchError,
which preserves the retry pattern around useRSC().refetchComponent(...).The feature extends RSCRoute's timing and keeps the existing provider/client/server implementations as the
main data path.
Phase 1 uses a mount-gated timing model. The route needs a server-and-hydration phase where
ssr={false} returns no content before provider-dependent work runs. It also needs a browser-mounted phase where
the existing RSC provider path is allowed to run.
React's hydrateRoot documentation expects the
initial client output during hydration to match the server-rendered HTML. React also documents a two-pass
pattern for intentional client/server differences using state set in an
Effect. That pattern fits this feature:
ssr={false}.ssr={false}.This avoids a hydration mismatch while still allowing the browser to fetch the deferred payload immediately after mount.
Phase 2 keeps the same payload-skipping invariant but changes the timing model: server rendering uses a classified Suspense bailout, and client rendering enters the provider path through React's Suspense retry path.
RSCRoute makes the skip decision before calling useRSC() or generating a provider cache key. The component
shape is:
function RSCRoute(props) {
// Track whether the browser has mounted.
// If ssr={false} and the route has not mounted, return null.
// Otherwise render the provider-dependent content component.
return <RSCRouteContent {...props} />;
}
function RSCRouteContent(props) {
// Use the existing RSC provider path.
// getComponent(...), PromiseWrapper, and RSCRouteErrorBoundary stay here.
}
This shape matters for three reasons:
useRSC() before a skipped server render returns.The error boundary continues to own conversion to ServerComponentFetchError. Place the boundary so it covers
the provider-dependent content render, not only the promise result, while preserving the documented retry
behavior.
Provider cache reuse remains valid.
The provider cache currently deduplicates by componentName and componentProps. It does not include ssr,
and it does not include the DOM node id. This means two identical routes inside the same provider can share a
promise/result.
That is acceptable for this feature. The rule is not "ssr={false} always performs a unique HTTP request." The
rule is "ssr={false} does not cause server payload generation during SSR."
Important examples:
Tests assert the invariant and avoid over-specifying duplicate network requests.
The delayed route uses the same RSC provider path after mount. That preserves the existing failure shape:
PromiseWrapper.RSCRouteErrorBoundary converts those failures into ServerComponentFetchError.useRSC().refetchComponent(...).The implementation avoids custom fetch/catch/retry logic inside RSCRoute. A custom route-local fetcher would
duplicate provider caching and make retry behavior diverge from existing client-side RSC navigation failures.
Mixed pages work naturally when each route makes its own render-timing decision.
Example:
<>
<RSCRoute componentName="HeaderStats" componentProps={{ userId }} />
<RSCRoute componentName="Recommendations" componentProps={{ userId }} ssr={false} />
</>
Phase 1 expected behavior:
HeaderStats follows the current server-rendered path and can embed its payload.Recommendations returns no content during server render and first hydration render.Recommendations resolves through the existing provider path.In Phase 2, a scoped Suspense boundary around Recommendations can render fallback HTML during server
rendering while preserving the same sibling shell and payload-skipping invariant.
Phase 1 focuses on the core behavior: skip server payload generation, keep hydration stable, and use the existing client RSC path after mount.
React supports server-rendering Suspense fallback by treating a
server-side throw under Suspense as an intentional server bailout and retrying on the client. Phase 2 uses that
model for RSCRoute ssr={false} during streaming SSR.
In Phase 2, a deferred route throws a classified intentional bailout on the server before provider-dependent RSC work runs. React emits the nearest Suspense fallback into the server HTML and retries the route on the client. React on Rails Pro classifies only that intentional bailout as non-fatal in stream error handling; real render failures keep the existing error behavior.
RSCRoute does not add a fallback prop and does not wrap itself in an internal Suspense boundary. The nearest
Suspense boundary controls loading UI. A scoped user boundary preserves the surrounding shell and renders the
intended fallback:
<Header />
<Suspense fallback={<RecommendationsSkeleton />}>
<RSCRoute componentName="Recommendations" componentProps={{ userId }} ssr={false} />
</Suspense>
<Footer />
If no nearer boundary exists in the supported wrapped streaming path, the existing root-level framework boundary
with fallback={null} is the nearest boundary and catches the bailout. That preserves payload skipping, but it
can hide the rendered root and provides no meaningful loading UI. The documented pattern is to place Suspense
close to each deferred route.
React reports this documented retry path through client onRecoverableError. Phase 2 does not broadly suppress
recoverable errors. The server stream path returns the intentional bailout digest, and the client suppresses only
recoverable errors carrying that digest.
Phase 3 adds support for auto-bundled roots that only defer RSCRoute payloads with ssr={false} without
explicitly calling wrapServerComponentRenderer. This includes roots rendered through react_component(..., prerender: false) and deferred-only roots rendered through stream_react_component.
The implementation reuses the existing provider machinery rather than adding a second route-local fetch runtime.
The provider must sit above the application subtree, not inside RSCRoute, so parent error-boundary fallback UIs
can still call useRSC().refetchComponent(...). A provider nested inside RSCRoute disappears when a parent error
boundary replaces the failing route with its fallback UI.
The provider registration must also happen before the application root renders. Registering it as an RSCRoute
module side effect is too late for lazy-loaded routes, because the root can render before the chunk that imports
RSCRoute is evaluated. Phase 3 therefore registers the default provider from auto-bundled generated client packs
when RSC support is enabled. The generated pack imports the registration module before importing
react-on-rails-pro/client and registering the root component.
The client renderer then wraps ordinary non-renderer roots with the registered provider when the current
railsContext includes the RSC payload endpoint. Existing renderer roots still delegate to their renderer first,
so roots that already use wrapServerComponentRenderer are not double-wrapped.
Manual client entrypoints that bypass generated packs are outside this automatic path. They can import the same registration module explicitly before registering their root. Making that path fully automatic would require a broader safe client-entrypoint change, because importing client provider code from the wrong Pro entrypoint can pollute the RSC build.
Issue #3101 also calls out components rendered through react_component rather than stream_react_component.
There are two separate cases.
When react_component is used with prerender: false, the root is already client-rendered. In that mode, no
server RSC payload is generated during the initial response. Phase 3 adds the automatic provider path for
auto-bundled roots that only defer RSCRoute payloads, so RSCRoute ssr={false} can resolve through the existing
provider runtime without a manual renderer wrapper.
When react_component is used with prerender: true, the current server-side RSC renderer helper validates
streaming capabilities before the child component tree renders. That means an RSCRoute ssr={false} cannot by
itself make this path work, because the outer renderer fails before RSCRoute gets a chance to return no
content.
react_component(..., prerender: true)Question: can deferred RSCRoute ssr={false} work cleanly inside the non-streaming
react_component(..., prerender: true) helper, not only inside stream_react_component or client-rendered
react_component(..., prerender: false) roots?
Phase 4 investigated this mode and is deferred.
The important distinction is functional support versus clean support. An unwrapped
react_component(..., prerender: true) root with only RSCRoute ssr={false} can already render the surrounding
HTML, show the nearest Suspense fallback, hydrate, fetch the RSC payload in the browser, and eventually show the
server component. A wrapped root can also reach that behavior if the early streaming-capability assertion is
delayed. The unresolved problem is not basic rendering; it is safely identifying the intentional ssr={false}
server bailout during hydration so React's expected recoverable error can be suppressed without hiding real server
render errors.
Key findings:
react_component(..., prerender: true) uses React's renderToString path.renderToString as having limited Suspense support. When a component suspends, the output
contains fallback HTML immediately rather than waiting for the content.renderToString has no public onError option and internally uses a no-op error handler.onError digest. For example, a renderer using static prerender
could return the RSCRoute ssr={false} bailout digest from onError, React would write that digest into the
Suspense boundary marker, and the browser could suppress only that known bailout. That is the clean signal
renderToString does not provide.A naive fix that only delays the wrapServerComponentRenderer/server streaming-capability check is not safe. It
lets deferred-only ssr={false} render fallback HTML, but if an ssr={true} route attempts real server RSC
payload generation under Suspense, React can catch the thrown unsupported-helper error and emit fallback/client
retry markers instead of preserving the existing descriptive failure. That would weaken the current clear error
for unsupported server RSC rendering through react_component(..., prerender: true).
Decision: do not implement non-streaming react_component(..., prerender: true) support in this PR stack. Clean
support requires one of:
wrapServerComponentRenderer/server, use React static prerender only when
the normal streaming capabilities are missing and the tree is deferred-only. That would clean up wrapped
react_component(..., prerender: true) roots, but unwrapped roots would still render with renderToString and
keep the noisy recoverable error; orreact_component(..., prerender: true)
cases can get the same classified bailout signal.Both are larger than the contained ssr={false} route behavior and default-provider work here. The supported
paths remain:
stream_react_component for server-rendered RSC payloads and streamed Suspense fallback HTML;react_component(..., prerender: false) for client-deferred RSC roots that fetch after the browser renders.The production work is split into phases so each behavior can be built, reviewed, and tested independently. All phases are part of the target implementation for issue #3101. The phases identify implementation modes and review boundaries; they do not mean every later phase is a small additive patch on the previous phase.
RSCRoute API and timingssr?: boolean to RSCRouteProps.ssr to true.ssr={false} and the route has not mounted.useRSC().getComponent(componentName, componentProps) after the route is allowed to render.PromiseWrapper to consume the RSC promise.RSCRouteErrorBoundary to preserve ServerComponentFetchError behavior.RSCRoute.RSCRoute timing and provider behavior.ssr={false} during streaming SSR.useRSC(), provider cache key generation, or server RSC payload generation.fallback prop or an internal Suspense boundary to RSCRoute.RSCRoute ssr={false} work in auto-bundled deferred-only roots that do not explicitly call
wrapServerComponentRenderer.RSCProvider machinery.ClientSideRenderer only after renderer delegation fails, so manually wrapped
renderer roots keep their current path and are not double-wrapped.react-on-rails-pro/client registers or renders the root.react-on-rails-pro/registerDefaultRSCProvider/client so generated packs and
manual entrypoints can use the same setup path.getReactServerComponent.client from the provider's getServerComponent function, so the browser
RSC runtime is loaded only when a deferred route actually fetches payload data.useRSC().refetchComponent(...).react_component(..., prerender: true) and deferred-only RSC
usage requires more than delaying server-capability checks.renderToString, because production output does not carry the
classified bailout digest.react_component(..., prerender: false) on the client-rendered path, where no server RSC payload is
generated during the initial response.The tests prove behavior rather than exact implementation shape.
Phase 1 coverage:
ssr={false} renders no server output for that route and does not call the injected server component loader.ssr={false} can return before RSC context is required during the skipped server render.ssr={false} uses the existing provider path.ServerComponentFetchError so user error boundaries and retry flows
continue to work.ssr={false} route does not.ssr preserves current SSR behavior.Phase 2 adds focused streaming coverage:
RSCRoute ssr={false} renders its fallback in server HTML while preserving
sibling shell content.throwJsErrors path.Phase 3 adds focused root-provider coverage:
react-on-rails-pro/client
when RSC support is enabled.railsContext includes the RSC payload endpoint.react_component(..., prerender: false) root can render a lazy-loaded RSCRoute ssr={false}
without a manual wrapServerComponentRenderer call.stream_react_component root can stream the scoped Suspense fallback, avoid
the deferred payload during SSR, and hydrate without logging the classified bailout as a recoverable error.useRSC().refetchComponent(...) retry./rsc_payload/... or load the client RSC fetch
runtime.Avoid tests that require a separate HTTP request when the provider cache already has an equivalent component result. Cache reuse is part of the current provider behavior.
| Issue requirement | Plan response |
|---|---|
New ssr prop | Add ssr?: boolean to RSCRouteProps, defaulting to true. |
ssr={false} skips SSR payload generation | Return before server-side provider work and assert no server loader call. |
| Phase 1 client resolution | Use mounted state to enter the existing provider path after hydration. |
| Phase 2 client resolution | Use React's Suspense retry path to enter the existing provider path. |
| No embedded payload for deferred route | Avoid calling railsContext.getRSCPayloadStream(...) for that route during SSR. |
| Default behavior unchanged | Treat omitted ssr and ssr={true} as current behavior. |
| Mixed pages | Make the decision per route instance and preserve provider cache semantics. |
| Error boundary compatibility | Keep the existing PromiseWrapper and RSCRouteErrorBoundary path. |
| Deferred-only auto-bundled roots | Register a default provider from generated client packs and wrap non-renderer roots. |
| Docs update | Update docs/pro/react-server-components/inside-client-components.md. |
| Suspense loading UI | Keep loading UI owned by scoped React Suspense boundaries; add no fallback prop. |
Update docs/pro/react-server-components/inside-client-components.md with:
ssr prop API and default value.ssr={false}: below-the-fold, collapsed, or lower-priority RSC content.Suspense example for loading UI.Suspense close to the deferred route; broad boundaries produce broad fallback regions.Phase 1 establishes the core contract: RSCRoute ssr={false} skips server payload generation, keeps hydration
stable, resolves through the existing provider path after mount, preserves error/retry behavior, and documents
the prop. Phase 2 keeps the provider path and changes the timing through React's Suspense retry path.
Phases 2-4 preserve the payload-skipping goal while changing or expanding specific mechanics:
RSCRoute ssr={false} and do not explicitly call wrapServerComponentRenderer.react_component(..., prerender: true) support for RSC/Suspense
roots needs a separate render-pipeline design, not just delayed server-capability checks.The implemented phases are separated to keep the review and test surface understandable while preserving a single end-to-end design.