Skip to content

[WIP] Add ability to render server components inside client components (add support for react-router) #1736

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server
Choose a base branch
from

Conversation

AbanoubGhadban
Copy link
Collaborator

@AbanoubGhadban AbanoubGhadban commented Apr 27, 2025

Rendering React Server Components Inside Client Components

This PR introduces a powerful new capability to the React on Rails framework: the ability to render React Server Components (RSC) directly inside React Client Components. This bypasses a fundamental limitation of React's component model while maintaining performance and preventing hydration issues.

📋 Overview

Important

This feature should be used judiciously. It's best suited for server components whose props change very rarely, such as router routes. Do not use this with components whose props change frequently as it triggers HTTP requests to the server on each re-render.

Before this PR

Server Components could only be embedded inside Client Components if passed as a prop from a parent Server Component:

// Parent Server Component
export default function Parent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

After this PR

Server Components can be directly rendered inside Client Components using the new RSCRoute component:

'use client';

import RSCRoute from 'react-on-rails/RSCRoute';

export default function ClientComponent() {
  return (
    <div>
      <RSCRoute componentName="ServerComponent" componentProps={{ user }} />
    </div>
  );
}

🔄 Implementation Details

🔄 RSC Payload Lifecycle

The RSC payload lifecycle follows two distinct paths depending on whether the component is being server-side rendered or client-side rendered.

Server-Side Rendering Flow

  1. Client Component Renders RSCRoute

    • A client component includes <RSCRoute componentName="ServerComponent" componentProps={props} />
    • During SSR, this triggers a request for the server component
  2. RSC Payload Generation

    • RSCRoute communicates with the RSC bundle via getRSCPayloadStream
    • The RSC bundle generates the RSC payload and returns it as a stream
  3. Server-Side HTML Generation

    • The server uses the RSC payload to render HTML for the server component
    • injectRSCPayload function collects and tracks all RSC payloads
  4. Payload Embedding

    • RSC payloads are embedded directly into the HTML as script tags
    • Each payload is stored in a global array using the pattern:
      <script>(self.REACT_ON_RAILS_RSC_PAYLOADS||={})["ComponentName-props-renderRequestId"]||=[]</script>
      <script>(self.REACT_ON_RAILS_RSC_PAYLOADS["ComponentName-props-renderRequestId"]).push(chunk)</script>

Client-Side Rendering Flow

  1. Initial Hydration

    • When RSCRoute renders during hydration, it checks for embedded payloads
    • If found, it uses the embedded payload without making a network request
  2. Post-Navigation Rendering

    • For components rendered after client-side navigation or state changes:
      • No embedded payload exists for the new component/props combination
      • RSCRoute makes an HTTP request to fetch the RSC payload
      • Request goes to /${rscPayloadGenerationUrl}/${componentName}?props=${propsString}
  3. Component Rendering

    • The fetched or embedded payload is used to render the server component
    • Client components referenced in the payload are hydrated with their props
flowchart TD
    A[Client Component<br>renders RSCRoute] --> B{Is SSR?}

    %% Server-side path
    B -->|Yes| C[RSCRoute communicates<br>with RSC bundle]
    C --> D[RSC payload generated]
    D --> E[Server renders HTML<br>using RSC payload]
    E --> F[RSC payload embedded<br>in HTML response]
    F --> G[HTML sent to browser]

    %% Client-side paths
    G --> H[Browser renders HTML]
    H --> I{Component<br>previously SSRed?}

    %% Hydration path
    I -->|Yes| J[Use embedded<br>RSC payload]
    J --> K[Hydrate components<br>without network request]

    %% Client navigation path
    B -->|No| L[Make HTTP request<br>for RSC payload]
    I -->|No| L
    L --> M[Process RSC payload]
    M --> N[Render server component<br>with client components]

    %% Styling
    classDef serverProcess fill:#f9f,stroke:#333,stroke-width:2px
    classDef clientProcess fill:#bbf,stroke:#333,stroke-width:2px
    classDef dataFlow fill:#bfb,stroke:#333,stroke-width:2px

    class C,D,E,F serverProcess
    class A,H,I,J,K,L,M,N clientProcess
    class G dataFlow
Loading

The key innovation in this architecture is how it handles the two rendering scenarios differently:

  1. For SSR + Hydration: Embeds payloads directly in the HTML to eliminate network requests
  2. For Client Navigation: Makes on-demand HTTP requests for new server components

This dual approach provides the best of both worlds: fast initial page loads with embedded data and dynamic component loading for subsequent interactions.

🧩 Key Components

RSCProvider

At the foundation of this architecture is the RSCProvider, which provides the core functionality for fetching and rendering server components.

// Interface
type RSCContextType = {
  getCachedComponent: (componentName: string, componentProps: unknown) => React.ReactNode;
  getComponent: (componentName: string, componentProps: unknown) => Promise<React.ReactNode>;
};

// Usage via hook
const { getCachedComponent, getComponent } = useRSC();

The RSCProvider handles the complex logic of:

  • Determining whether to use embedded payloads or fetch from the server
  • Caching components to prevent duplicate requests
  • Managing request promises to prevent race conditions
  • Converting RSC payloads into renderable React elements

This component is environment-aware and behaves differently when:

  • On the server: It communicates directly with the RSC bundle
  • On the client: It either uses embedded payloads or makes HTTP requests

While you could use the useRSC hook directly in client components, it's generally better to use the RSCRoute component which provides a simpler interface.

wrapServerComponentRenderer

Because the RSCProvider has different implementations for client and server environments, you shouldn't create it manually. Instead, the wrapServerComponentRenderer function handles this complexity:

// Client usage
import wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client';

// Server usage
import wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/server';

// Basic usage pattern
const WrappedComponent = wrapServerComponentRenderer(YourClientComponent);

This higher-order function:

  • Takes a client component or render function
  • Wraps it with the appropriate RSCProvider implementation
  • Properly handles hydration on the client or rendering on the server
  • Ensures Suspense boundaries are set up correctly

The wrapped component is ready to be registered with React on Rails and will properly handle server component rendering in any environment.

RSCRoute

Building on the foundation provided by RSCProvider and wrapServerComponentRenderer, the RSCRoute component provides the simplest way to render server components:

// Interface
type RSCRouteProps = {
  componentName: string;
  componentProps: unknown;
};

// Usage
<RSCRoute componentName="MyServerComponent" componentProps={{ user }} />;

RSCRoute is the primary component you'll use in your application to:

  • Render server components inside client components
  • Automatically handle server/client transitions
  • Manage caching and fetching of server component data
  • Integrate with Suspense for loading states

It uses React's use hook to integrate with Suspense boundaries and provide a consistent loading experience across your application.

These three components form a cohesive system that makes server components usable in client components while maintaining performance and preventing hydration mismatches.

🔍 Use Cases and Examples

❌ Bad Example - Frequently Changing Props

'use client';
import { useState } from 'react';
import RSCRoute from 'react-on-rails/RSCRoute';

export default function ClientComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <label>Count: {count}</label>
      {/* BAD EXAMPLE: Server Component props change with each button click */}
      <RSCRoute componentName="ServerComponent" componentProps={{ count }} />
    </div>
  );
}

Warning

This implementation will make a server request on every state change, significantly impacting performance.

✅ Good Example - Router Integration

'use client';
import { Routes, Route, Link } from 'react-router-dom';
import RSCRoute from 'react-on-rails/RSCRoute';
import AnotherClientComponent from './AnotherClientComponent';

export default function AppRouter({ user }: { user: any }) {
  return (
    <>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/client-component">Client Component</Link>
      </nav>
      <Routes>
        {/* Mix client and server components in your router */}
        <Route path="/client-component" element={<AnotherClientComponent />} />
        {/* GOOD EXAMPLE: Server Component props rarely change */}
        <Route path="/about" element={<RSCRoute componentName="About" componentProps={{ user }} />} />
        <Route path="/" element={<RSCRoute componentName="Home" componentProps={{ user }} />} />
      </Routes>
    </>
  );
}

🧩 Nested Route Example

The framework supports nesting client and server components to arbitrary depth:

'use client';
import { Routes, Route } from 'react-router-dom';
import RSCRoute from 'react-on-rails/RSCRoute';
import ServerRouteLayout from './ServerRouteLayout';
import ClientRouteLayout from './ClientRouteLayout';
import AnotherClientComponent from './AnotherClientComponent';

export default function AppRouter() {
  return (
    <Routes>
      <Route path="/main-server-route" element={<ServerRouteLayout />}>
        <Route path="/server-subroute" element={<RSCRoute componentName="MyServerComponent" />} />
        <Route path="/client-subroute" element={<AnotherClientComponent />} />
      </Route>
      <Route path="/main-client-route" element={<ClientRouteLayout />}>
        <Route path="/client-subroute" element={<AnotherClientComponent />} />
        <Route path="/server-subroute" element={<RSCRoute componentName="MyServerComponent" />} />
      </Route>
    </Routes>
  );
}

To use Outlet in server components, create a client version:

// ./components/Outlet.tsx
'use client';
export { Outlet as default } from 'react-router-dom';

Then use in server components:

// ./components/ServerRouteLayout.tsx
import Outlet from './Outlet';

export default function ServerRouteLayout() {
  return (
    <div>
      <h1>Server Route Layout</h1>
      <Outlet />
    </div>
  );
}

🔧 Internal Implementation

This section details the inner workings of the React Server Components integration in React on Rails, explaining how server components are generated, transported, and rendered.

getReactServerComponent

At the core of the RSC implementation is the getReactServerComponent function, which has environment-specific implementations.

Server Implementation

// Simplified interface
type RSCServerRootProps = {
  componentName: string;
  componentProps: unknown;
  railsContext: RailsContext;
};

const getReactServerComponent = async ({
  componentName,
  componentProps,
  railsContext,
}: RSCServerRootProps) => {
  // Implementation details...
};

On the server, this function:

  1. Validates that required context properties exist
  2. Creates an SSR manifest by loading client and server manifests
    const ssrManifest = await createSSRManifest(
      railsContext.reactServerClientManifestFileName,
      railsContext.reactClientManifestFileName,
    );
  3. Gets the RSC payload stream via ReactOnRails.getRSCPayloadStream (Explained in RSCPayloadGenerator)
  4. Processes the stream with React's runtime to create React elements
    return createFromReactOnRailsNodeStream(rscPayloadStream, ssrManifest);

The manifest creation process maps module IDs between server and client bundles, allowing React to correctly associate server components with their client counterparts.

Client Implementation

// Simplified interface
type ClientGetReactServerComponentProps = {
  componentName: string;
  componentProps: unknown;
  railsContext: RailsContext;
};

const getReactServerComponent = ({
  componentName,
  componentProps,
  railsContext,
}: ClientGetReactServerComponentProps) => {
  // Implementation details...
};

On the client, this function:

  1. Generates a unique cache key based on component name, props, and request ID
  2. Checks if an embedded payload exists in window.REACT_ON_RAILS_RSC_PAYLOADS[key]
  3. If found, creates a stream from the array and processes it
    if (payloads) {
      return createFromPreloadedPayloads(payloads);
    }
  4. If not found, makes an HTTP request to fetch the payload
    return fetchRSC({ componentName, componentProps, railsContext });

Both paths ultimately use React's createFromReadableStream to convert the RSC payload into React elements.

flowchart TD
    A[getReactServerComponent] --> B{Environment?}
    
    B -->|Server| C[Validate railsContext]
    C --> D[Load client & server manifests]
    D --> E[Get RSC payload stream]
    E --> F[Process with createFromNodeStream]
    F --> G[Return React Elements]
    
    B -->|Client| H[Generate cache key]
    H --> I{Embedded payload<br>exists?}
    I -->|Yes| J[Create stream from<br>embedded payload]
    I -->|No| K[Fetch payload via HTTP]
    J --> L[Process with createFromReadableStream]
    K --> L
    L --> G
    
    classDef serverProcess fill:#f9f,stroke:#333,stroke-width:2px
    classDef clientProcess fill:#bbf,stroke:#333,stroke-width:2px
    classDef common fill:#fff,stroke:#333,stroke-width:2px
    
    class C,D,E,F serverProcess
    class H,I,J,K,L clientProcess
    class A,B,G common
Loading

RSCProvider

The RSCProvider creates a React context for server component operations. The provider accepts a getServerComponent function, making it environment-agnostic. It can work with either the server or client implementation of getReactServerComponent:

// Simplified interface
type RSCContextType = {
  getCachedComponent: (componentName: string, componentProps: unknown) => React.ReactNode;
  getComponent: (componentName: string, componentProps: unknown) => Promise<React.ReactNode>;
};

// Factory function
export const createRSCProvider = ({
  railsContext,
  getServerComponent,
}: {
  railsContext: RailsContext;
  getServerComponent: typeof getReactServerComponent;
}) => {
  // Implementation details...
  return ({ children }: { children: React.ReactNode }) => (
    <RSCContext.Provider value={{ getCachedComponent, getComponent }}>
      {children}
    </RSCContext.Provider>
  );
};

Key aspects of the implementation:

  1. Component Caching: Uses in-memory cache to store already rendered components

    const cachedComponents: Record<string, React.ReactNode> = {};
  2. Promise Tracking: Prevents duplicate requests for the same component

    const fetchRSCPromises: Record<string, Promise<React.ReactNode>> = {};
  3. Cache Key Generation: Creates unique keys based on component name, props, and request ID

    const generateCacheKey = (componentName: string, componentProps: unknown) => {
      return `${componentName}-${JSON.stringify(componentProps)}-${railsContext.componentSpecificMetadata?.renderRequestId}`;
    };

wrapServerComponentRenderer

This higher-order function wraps components with RSC capability. It creates the proper RSCProvider for each environment, passing the environment-specific implementation of getServerComponent to the provider. On the server, it passes the server implementation of getReactServerComponent, while on the client, it passes the client implementation.

Server Implementation

// node_package/src/wrapServerComponentRenderer/server.tsx
const WrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOrRenderFunction) => {
  // Validation...
  
  const wrapper: RenderFunction = async (props, railsContext) => {
    // Get component...
    
    const RSCProvider = createRSCProvider({
      railsContext,
      getServerComponent: getReactServerComponent,
    });

    return () => (
      <RSCProvider>
        <React.Suspense fallback={null}>
          <Component {...props} />
        </React.Suspense>
      </RSCProvider>
    );
  };

  return wrapper;
};

Client Implementation

// node_package/src/wrapServerComponentRenderer/client.tsx
const WrapServerComponentRenderer = (componentOrRenderFunction: ReactComponentOrRenderFunction) => {
  // Validation...
  
  const wrapper: RenderFunction = async (props, railsContext, domNodeId) => {
    // Get component...
    
    const RSCProvider = createRSCProvider({
      railsContext,
      getServerComponent: getReactServerComponent,
    });

    const root = (
      <RSCProvider>
        <React.Suspense fallback={null}>
          <Component {...props} />
        </React.Suspense>
      </RSCProvider>
    );

    // DOM operations
    if (domNode.innerHTML) {
      ReactDOMClient.hydrateRoot(domNode, root, { identifierPrefix: domNodeId });
    } else {
      ReactDOMClient.createRoot(domNode, { identifierPrefix: domNodeId }).render(root);
    }
    
    return '';
  };

  return wrapper;
};

Key differences:

  1. Server: Returns a render function that produces React elements
  2. Client: Returns a renderer function that performs DOM operations (hydration/rendering)
  3. Both: Wrap the component in RSCProvider and Suspense

This function replaces the previous RSCServerRoot and RSCClientRoot components, providing a more flexible approach that works with any client component or render function containing server components. The wrapped component can be either a React component or a render function that returns a React component on the server, or a renderer function on the client.

sequenceDiagram
    participant App as Application Code
    participant WSCR as WrapServerComponentRenderer
    participant RSCP as RSCProvider
    participant RSC as RSCRoute
    participant React as React Runtime
    
    App->>WSCR: Pass client component
    WSCR->>WSCR: Create render function on server and renderer function on client
    App->>WSCR: Call render or renderer with props and railsContext
    WSCR->>RSCP: Create RSCProvider for server or client
    WSCR->>React: Wrap component in Suspense
    
    alt Server Environment
        WSCR->>App: Return react component that will be rendered and streamed to the client
    else Client Environment
        WSCR->>React: Hydrate or render the component after wrapping in RSCProvider
    end
    
    RSC->>RSCP: useRSC()
    RSCP->>RSC: Return context methods
Loading

RSCPayloadGenerator

The RSCPayloadGenerator coordinates the generation and tracking of RSC payloads:

// Simplified interface
export const getRSCPayloadStream = async (
  componentName: string,
  props: unknown,
  railsContext: RailsContext,
): Promise<NodeJS.ReadableStream> => {
  // Implementation details...
};

export const onRSCPayloadGenerated = (
  railsContext: RailsContext, 
  callback: RSCPayloadCallback
) => {
  // Implementation details...
};

export const getRSCPayloadStreams = (
  railsContext: RailsContext
): RSCPayloadStreamInfo[] => {
  // Implementation details...
};

export const clearRSCPayloadStreams = (
  railsContext: RailsContext
) => {
  // Implementation details...
};

The implementation:

  1. Payload Generation: Calls the global generateRSCPayload function to get the RSC stream

    const stream = await generateRSCPayload(componentName, props, railsContext);
  2. Stream Duplication: Creates duplicate streams to serve different consumers: one will be used to SSR the server component and one will be used to inject the payload into the HTML

    const stream1 = new PassThrough();
    stream.pipe(stream1);
    const stream2 = new PassThrough();
    stream1.pipe(stream2);
  3. Payload Tracking: Stores streams by railsContext for later injection

    const streamInfo: RSCPayloadStreamInfo = {
      componentName,
      props,
      stream: stream2,
    };
    streams.push(streamInfo);
    mapRailsContextToRSCPayloadStreams.set(railsContext, streams);
  4. Callback Notification: Notifies registered callbacks about new streams

    const callbacks = rscPayloadCallbacks.get(railsContext) || [];
    callbacks.forEach((callback) => callback(streamInfo));

These functions are exposed globally via the ReactOnRails object, making them accessible anywhere in the application.

injectRSCPayload

The injectRSCPayload function embeds RSC payloads into the HTML stream:

// Simplified interface
export default function injectRSCPayload(
  pipeableHtmlStream: NodeJS.ReadableStream | PipeableStream,
  railsContext: RailsContext,
) {
  // Implementation details...
  return resultStream;
}

Key aspects:

  1. Stream Initialization: Creates a result stream to hold HTML + RSC payloads

    const resultStream = new PassThrough();
  2. RSC Payload Monitoring: Listens for newly generated payloads

    ReactOnRails.onRSCPayloadGenerated?.(railsContext, (streamInfo) => {
      // Process each RSC payload stream
    });
  3. Array Initialization: Creates a global array to store payload chunks

    // Generates JavaScript like:
    // (self.REACT_ON_RAILS_RSC_PAYLOADS||={})["ComponentName-props-id"]||=[]
    initializeCacheKeyJSArray(cacheKey, resultStream);
  4. Chunk Embedding: Writes each chunk to the result stream as a script tag

    // Generates JavaScript like:
    // (self.REACT_ON_RAILS_RSC_PAYLOADS["ComponentName-props-id"]).push(chunk)
    writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
  5. HTML Processing: Passes through HTML while ensuring payloads are injected

    htmlStream.on('data', (chunk: Buffer) => {
      htmlBuffer.push(chunk);
      // Process HTML chunks
    });

The timing of these operations is critical for proper hydration:

sequenceDiagram
    participant App as Server Rendering
    participant HTML as HTML Stream
    participant RSCGen as RSCPayloadGenerator
    participant Inject as injectRSCPayload
    participant Result as Combined HTML+RSC Stream
    
    App->>HTML: Begin HTML rendering
    App->>RSCGen: Generate RSC payload
    RSCGen-->>Inject: Notify via callback
    Inject->>Result: Write array initialization
    
    loop For each RSC chunk
        RSCGen-->>Inject: Stream chunk
        Inject->>Result: Write push statement
    end
    
    HTML-->>Inject: Stream HTML chunks
    Inject->>Result: Pass through HTML
    
    Result-->>App: Complete response
Loading

The entire system ensures that:

  • RSC payloads are generated on demand
  • Payloads are efficiently tracked and embedded in responses
  • Client-side code can access payloads without additional network requests
  • Hydration happens correctly and in the right order

This complex orchestration allows server components to be rendered inside client components while maintaining performance and preventing hydration mismatches.

🔄 Preventing Race Conditions During Hydration

A critical aspect of the RSC implementation is preventing race conditions during component hydration. Specifically, we need to ensure that:

The Race Condition: Without careful implementation, client components might be hydrated before their corresponding RSC payload arrays are initialized in the page, causing unnecessary network requests.

How We Prevent This Race Condition

Our approach relies on a fundamental behavior of React's hydration process (You can check it out in SuspenseHydration.test.tsx and at the /async_on_server_sync_on_client page in the RORP dummy app):

  1. React's Hydration Sequence: Client components only hydrate after their HTML content has been streamed to the client and is present in the DOM.

  2. Early Payload Initialization: When a server component is rendered during SSR, getReactServerComponent calls ReactOnRails.getRSCPayloadStream which:

    • Immediately initializes the global payload array in the HTML stream
    • Does this synchronously, before the component finishes rendering
    // This script is injected into the HTML stream first
    <script>(self.REACT_ON_RAILS_RSC_PAYLOADS||={})["ComponentName-props-id"]||=[]</script>
  3. Parallel Streaming: The HTML stream processes payload chunks and component HTML in parallel after initialization of the payload array:

    1. Payload array initialization script
    2. Payload chunks and component HTML streamed simultaneously
sequenceDiagram
    participant Server as Server Rendering
    participant HTML as HTML Stream
    participant Client as Client Hydration
    
    Server->>HTML: Initialize RSC payload array
    Note right of HTML: <script>(self.REACT_ON_RAILS_RSC_PAYLOADS||={})[key]||=[]</script>
    
    par Parallel Streaming
        Server->>HTML: Stream RSC payload chunks
        Note right of HTML: <script>(self.REACT_ON_RAILS_RSC_PAYLOADS[key]).push(chunk)</script>
        Server->>HTML: Stream component HTML
        Note right of HTML: <div id="component">Component HTML</div>
    end
    
    HTML->>Client: Browser processes HTML & scripts as they arrive
    Client->>Client: Hydration begins only after component HTML is in DOM
    Client->>Client: getReactServerComponent checks for embedded payload
    Note right of Client: RSC payload array exists before hydration starts
Loading

Why This Works

This implementation guarantees that:

  1. The global REACT_ON_RAILS_RSC_PAYLOADS object and the component-specific array are initialized before the component's HTML appears in the document

  2. When React begins hydration of the component, it triggers getReactServerComponent, which finds the pre-initialized array and uses it instead of making an HTTP request

  3. Even in cases where the full payload hasn't finished streaming, the client code can read from the array as new chunks arrive, ensuring progressive rendering

  4. If during component hydration, no RSC payload array is found in the global REACT_ON_RAILS_RSC_PAYLOADS object, it indicates that the component was not server-side rendered. In this case, getReactServerComponent falls back to making an HTTP request to fetch the RSC payload. This scenario occurs in two common cases:

    • Client Navigation: When a user navigates to a new route client-side, the component wasn't part of the initial SSR, so no payload array exists
    • Dynamic Rendering: When a server component is conditionally rendered based on client-side state changes. For example:
      function ParentComponent() {
        const [showChild, setShowChild] = useState(false);
        
        return (
          <div>
            <button onClick={() => setShowChild(true)}>Show Child</button>
            {showChild && <RSCRoute componentName="MyServerComponent" />}
          </div>
        );
      }
      In this case, when showChild becomes true, MyServerComponent needs to be fetched via HTTP since it wasn't part of the initial SSR.

This approach has been verified through tests like SuspenseHydration.test.tsx which confirm that React's hydration behavior consistently follows this pattern, effectively preventing the race condition.

📝 Registration Process

Here's a streamlined guide to integrating server components within client components:

1. Register the server component in your RSC bundle

First, register any server components you'll need to reference:

// packs/server_bundle.ts
import { registerServerComponent } from 'react-on-rails/registerServerComponent/server';
import MyServerComponent from './components/MyServerComponent';

registerServerComponent({ MyServerComponent });

2. Create your client component with RSCRoute

Build a client component that references server components using RSCRoute:

// components/AppRouter.tsx
export default function AppRouter() {
  return (
    <Routes>
      <Route path="/my-server-component" element={<RSCRoute componentName="MyServerComponent" />} />
      <Route path="/" element={<Home />} />
    </Routes>
  )
}

3. Create environment-specific entry points

Client entry point:

// components/AppRouter.client.tsx
"use client";

import ReactOnRails from 'react-on-rails';
import { wrapServerComponentRenderer } from 'react-on-rails/wrapServerComponentRenderer/client';
import AppRouter from './components/AppRouter';

const WrappedAppRouter = wrapServerComponentRenderer(AppRouter);

ReactOnRails.register({ AppRouter: WrappedAppRouter });

Server entry point:

// components/AppRouter.server.tsx
import ReactOnRails from 'react-on-rails';
import { wrapServerComponentRenderer } from 'react-on-rails/wrapServerComponentRenderer/server';
import AppRouter from './components/AppRouter';

const WrappedAppRouter = wrapServerComponentRenderer(AppRouter);

ReactOnRails.register({ AppRouter: WrappedAppRouter });

4. Use in your Rails view

<%= stream_react_component('AppRouter', props: { some: 'props' }, prerender: true) %>

Tip

With auto_load_bundle: true in your configuration, you can skip the manual ReactOnRails.register and registerServerComponent calls, but you still need to create both client and server wrappers with the appropriate wrapServerComponentRenderer imports.

Pull Request checklist

Remove this line after checking all the items here. If the item is not applicable to the PR, both check it out and wrap it by ~.

  • Add/update test to cover these changes
  • Update documentation
  • Update CHANGELOG file

Add the CHANGELOG entry at the top of the file.

Other Information

Remove this paragraph and mention any other important and relevant information such as benchmarks.


This change is Reviewable

Summary by CodeRabbit

  • New Features

    • Introduced comprehensive support for React Server Components (RSC), including new configuration options, context management, and streaming capabilities.
    • Added new components and providers for RSC server-side and client-side rendering.
    • Enabled injection of RSC payloads into HTML streams for more efficient hydration.
    • Exposed new API methods for managing RSC payload streams.
  • Bug Fixes

    • Improved rendering logic to prevent double rendering and reduce unnecessary HTTP requests.
    • Enhanced error handling and validation for configuration and rendering processes.
  • Documentation

    • Expanded configuration and changelog documentation to cover new RSC features and integration steps.
  • Tests

    • Added extensive test coverage for new RSC features, configuration options, and payload injection logic.
  • Chores

    • Updated dependencies to support new RSC features and maintain compatibility.
    • Improved ESLint configuration and code style enforcement.

…uest tracking

- Introduced renderRequestId in render options to uniquely identify each render request.
- Updated server rendering code to include renderRequestId in the context.
- Enhanced client-side rendering to handle and log renderRequestId.
- Modified type definitions to accommodate the new renderRequestId field in RailsContext.
- Improved debugging output in RailsContext component for better visibility of context data.
Copy link
Contributor

coderabbitai bot commented Apr 27, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

This update introduces comprehensive support for React Server Components (RSC) within the React on Rails framework. It adds new configuration options, helper methods, and internal APIs to manage RSC payload generation, streaming, and client/server integration. Several new TypeScript modules and React components are implemented to handle RSC rendering, context management, and payload injection into HTML streams. The configuration, documentation, and test suites are updated to cover these new features. The package exports are expanded to facilitate conditional server/client module resolution. Legacy or redundant files are removed, and several files are refactored for improved clarity and maintainability.

Changes

Files / Groups Change Summary
CHANGELOG.md, docs/guides/configuration.md Updated documentation to describe new RSC support, configuration options, and enhancements.
lib/react_on_rails/configuration.rb, lib/react_on_rails/helper.rb, lib/react_on_rails/react_component/render_options.rb, lib/react_on_rails/server_rendering_js_code.rb, lib/react_on_rails/test_helper/webpack_assets_status_checker.rb, lib/react_on_rails/utils.rb Added/updated configuration and helper methods for RSC manifest files, payload URLs, and render request IDs; extended context and asset checking logic.
node_package/src/ClientSideRenderer.ts, node_package/src/ReactOnRails.client.ts, node_package/src/ReactOnRails.node.ts, node_package/src/ReactOnRailsRSC.ts, node_package/src/serverRenderReactComponent.ts, node_package/src/streamServerRenderedReactComponent.ts Integrated RSC-specific logic, context propagation, error handling, and payload injection into client and server rendering flows.
node_package/src/RSCClientRoot.tsx, node_package/src/RSCProvider.tsx, node_package/src/RSCRoute.ts, node_package/src/RSCServerRoot.tsx, node_package/src/getReactServerComponent.client.ts, node_package/src/getReactServerComponent.server.ts, node_package/src/injectRSCPayload.ts, node_package/src/transformRSCNodeStream.ts, node_package/src/wrapServerComponentRenderer/client.tsx, node_package/src/wrapServerComponentRenderer/server.rsc.tsx, node_package/src/wrapServerComponentRenderer/server.tsx Introduced new React components, providers, and utility modules for RSC context, streaming, and rendering on both client and server.
node_package/src/RSCPayloadGenerator.ts, node_package/src/loadJsonFile.ts Added modules for generating, managing, and caching RSC payload streams and loading JSON manifests.
node_package/src/registerServerComponent/client.tsx, node_package/src/registerServerComponent/server.rsc.ts, node_package/src/registerServerComponent/server.tsx Refactored and added registration logic for server components in both client and server contexts; removed legacy/placeholder server registration file.
node_package/src/buildConsoleReplay.ts, node_package/src/transformRSCStreamAndReplayConsoleLogs.ts Improved stream transformation and console replay logic, including handling of empty scripts and support for string/Uint8Array streams.
node_package/src/types/index.ts Updated type definitions for Rails context, RSC payload streams, and ReactOnRails internal API.
node_package/src/reactApis.cts Added runtime check for React 19+ use API availability.
node_package/src/loadReactClientManifest.ts, node_package/src/RSCClientRoot.ts, node_package/src/registerServerComponent/server.ts Removed deprecated or replaced modules.
node_package/tests/injectRSCPayload.test.ts, spec/react_on_rails/configuration_spec.rb, spec/react_on_rails/utils_spec.rb, node_package/tests/RSCClientRoot.test.jsx Added and updated tests for RSC payload injection, configuration, and utility methods.
eslint.config.ts, knip.ts Updated ESLint and Knip configurations for new file patterns and extensions.
package.json Expanded exports for conditional server/client modules; updated dependencies and peerDependencies for RSC support.
spec/dummy/client/app/components/RailsContext.jsx Improved rendering of object values as JSON in the RailsContext component.

Sequence Diagram(s)

sequenceDiagram
  participant Rails as Rails (Server)
  participant NodeRenderer as Node Renderer
  participant ReactOnRails as ReactOnRails (Node)
  participant Client as Browser/Client

  Rails->>NodeRenderer: Request server-side render with RSC
  NodeRenderer->>ReactOnRails: getRSCPayloadStream(component, props, context)
  ReactOnRails->>ReactOnRails: Generate RSC payload stream
  ReactOnRails->>NodeRenderer: Return RSC payload stream
  NodeRenderer->>Rails: Stream rendered HTML + inject RSC payload scripts
  Rails->>Client: Serve HTML with embedded RSC payload
  Client->>Client: Hydrate using RSCClientRoot & context
  Client->>Rails: Optionally fetch more RSC payloads for hydration
Loading

Possibly related PRs

  • shakacode/react_on_rails#1644: Introduces and enhances React Server Components (RSC) support, modifying helpers and streaming logic closely related to the features in this PR.

Suggested reviewers

  • Judahmeek
  • alexeyr-ci

Poem

In the warren of code, where the RSCs stream,
A bundle of carrots—our new server dream!
With context and payloads, we hop and we play,
Streaming components in a bright, clever way.
Now Rails and React, together they run,
Bringing fresh features for everyone!
🥕✨


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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@AbanoubGhadban AbanoubGhadban changed the base branch from master to abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server-original April 27, 2025 16:14
@AbanoubGhadban AbanoubGhadban changed the base branch from abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server-original to abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server April 27, 2025 16:14
Copy link
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

Inline review comments failed to post. This is likely due to GitHub's limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 17

🧹 Nitpick comments (22)
node_package/src/reactApis.cts (1)

55-61: Good runtime check for React compatibility.

The function properly verifies React 19+ compatibility by checking for the use hook, which is required for React Server Components.

Consider enhancing the error message to be more actionable:

-      'React.use is not defined. Please ensure you are using React 19 to use server components.',
+      'React.use is not defined. Please ensure you are using React 19+ to use server components. Update your package.json dependencies and run npm/yarn install.',
node_package/src/ReactOnRailsRSC.ts (2)

27-33: Error handling improved, but message could be more specific.

The validation of required properties in railsContext is good, but the error message could be more descriptive about what's missing.

-    throw new Error('Rails context is not available');
+    throw new Error('Rails context or reactClientManifestFileName is not available. Both are required for RSC rendering.');

75-75: Missing documentation for new flag.

The isRSCBundle flag is added without any comments explaining its purpose or usage.

+// Flag to indicate that this is an RSC bundle, used to distinguish between client and RSC bundles
 ReactOnRails.isRSCBundle = true;
node_package/src/registerServerComponent/server.rsc.ts (1)

1-25: Clean implementation for RSC component registration

The function provides a clear interface for registering React Server Components directly without additional wrapping, with good documentation explaining how this differs from server bundle registration.

One improvement would be to add explicit return type annotation for the function to enhance API clarity:

-const registerServerComponent = (components: { [id: string]: ReactComponent | RenderFunction }) =>
+const registerServerComponent = (components: { [id: string]: ReactComponent | RenderFunction }): void =>
  ReactOnRails.register(components);
node_package/src/loadJsonFile.ts (1)

1-25: Efficient JSON file loading implementation with proper caching

The implementation correctly resolves paths, handles errors, and caches results to avoid redundant file operations. The comments clearly explain the file location assumptions.

Consider adding these improvements:

  1. Add more specific error typing for better error handling:
-  } catch (error) {
+  } catch (error: unknown) {
  1. Consider adding an option to bypass the cache in development environments for easier debugging:
-export default async function loadJsonFile(fileName: string) {
+export default async function loadJsonFile(fileName: string, options?: { bypassCache?: boolean }) {
   // Asset JSON files are uploaded to node renderer.
   // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js.
   // Thus, the __dirname of this code is where we can find the manifest file.
   const filePath = path.resolve(__dirname, fileName);
   const loadedJsonFile = loadedJsonFiles.get(filePath);
-  if (loadedJsonFile) {
+  if (loadedJsonFile && !options?.bypassCache) {
     return loadedJsonFile;
   }
node_package/src/registerServerComponent/client.tsx (1)

40-46: Clean implementation of the new RSC rendering approach

The refactored implementation properly wraps each component with the WrapServerComponentRenderer and RSCRoute components, maintaining the same function signature for backward compatibility.

While the JSDoc comment (lines 7-38) explains the purpose and usage of this function, it should be updated to reflect the new implementation that uses RSCRoute instead of RSCClientRoot.

 /**
  * Registers React Server Components (RSC) with React on Rails.
- * This function wraps server components with RSCClientRoot to handle client-side rendering
+ * This function wraps server components with RSCRoute through WrapServerComponentRenderer to handle client-side rendering
  * and hydration of RSC payloads.
  *
node_package/src/RSCServerRoot.tsx (1)

27-29: Consider memoizing the root element.

Since this is a server-side component, memoization could improve performance when the same component is rendered multiple times.

 const root = <RSCProvider>{suspensableServerComponent}</RSCProvider>;

-  return () => root;
+  // Memoize the render function to avoid recreating the element on each render
+  const renderFunction = React.useMemo(() => () => root, [root]);
+  return renderFunction;
lib/react_on_rails/helper.rb (1)

654-655: Improve boolean condition syntax and add render request ID

The data-render-request-id attribute is important for tracking RSC render requests, but there's a style improvement opportunity in the boolean condition.

-                                                "data-force-load" => (render_options.force_load ? true : nil),
+                                                "data-force-load" => render_options.force_load || nil,
                                                "data-render-request-id" => render_options.render_request_id)
🧰 Tools
🪛 RuboCop (1.73)

[convention] 654-654: Use double pipes || instead.

(Style/RedundantCondition)

node_package/src/wrapServerComponentRenderer/server.tsx (2)

12-15: Fix error message to reference correct component

The error message incorrectly references "RSCClientRoot" instead of "WrapServerComponentRenderer".

  const wrapper: RenderFunction = async (props, railsContext) => {
    if (!railsContext) {
-      throw new Error('RSCClientRoot: No railsContext provided');
+      throw new Error('WrapServerComponentRenderer: No railsContext provided');
    }

30-34: Consider a more meaningful fallback for Suspense

Using null as a fallback might lead to layout shifts when the component is loading.

  const suspensableServerComponent = (
-      <React.Suspense fallback={null}>
+      <React.Suspense fallback={<div aria-busy="true" aria-live="polite">Loading...</div>}>
        <Component {...props} />
      </React.Suspense>
  );
node_package/src/RSCClientRoot.tsx (1)

41-45: Consider a more meaningful fallback for Suspense

Using null as a fallback might lead to layout shifts during loading. A simple loading indicator would improve user experience.

  const SuspensableRSCRoute = (
-    <React.Suspense fallback={null}>
+    <React.Suspense fallback={<div aria-busy="true" aria-live="polite">Loading...</div>}>
      <ServerComponentContainer />
    </React.Suspense>
  );
node_package/src/RSCProvider.tsx (2)

23-30: Stable cache keys – JSON.stringify is brittle

JSON.stringify orders object keys alphabetically only by chance; two prop objects with identical content but different key order serialise differently, producing duplicate cache entries.

Consider hashing after a stable-stringify (e.g. fast-json-stable-stringify) or a deep-hash function.


50-53: Memoise context value to prevent needless re-renders

Because a new object literal is passed on every render, all descendants that call useContext will re-render even when nothing changed.

-return <RSCContext.Provider value={{ getCachedComponent, getComponent }}>{children}</RSCContext.Provider>;
+const ctxValue = React.useMemo(
+  () => ({ getCachedComponent, getComponent }),
+  [], // safe because both functions are stable
+);
+return <RSCContext.Provider value={ctxValue}>{children}</RSCContext.Provider>;
node_package/src/streamServerRenderedReactComponent.ts (1)

187-190: Compile-time guarantee for railsContext

A runtime guard is good, but adding railsContext: RailsContext to the RenderParams type would catch violations earlier and eliminate a potential unchecked cast downstream.

If changing the public type isn’t feasible, at least mark the property as required in the internal variant used by streaming helpers.

node_package/src/getReactServerComponent.client.ts (1)

80-85: Cache-key may explode for large props

Using JSON.stringify directly in the cache key can create multi-KB keys and is brittle with respect to property-order differences.
Consider hashing the props (e.g. SHA-256) or serialising with a stable stringifier to keep the key short and collision-safe.

node_package/src/wrapServerComponentRenderer/client.tsx (3)

25-28: Error message references outdated component

The thrown error still mentions “RSCClientRoot”, which was removed. Update for clarity:

-      throw new Error('RSCClientRoot: No railsContext provided');
+      throw new Error('WrapServerComponentRenderer: No railsContext provided');

42-48: DOM-node validation message is stale

Same naming issue as above and the second message omits the wrapper’s name:

-      throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`);
+      throw new Error(
+        `WrapServerComponentRenderer: No DOM node found for id "${domNodeId}"`,
+      );

54-57: Return type workaround leaks into production code

Returning an empty string merely to satisfy the RenderFunction contract is fragile and may mislead future maintainers.
Prefer widening the RenderFunction return type to void | string (or similar) and returning undefined here.

docs/guides/configuration.md (1)

107-131: Cross-reference prerequisite flag and add initializer snippet

Docs mention config.enable_rsc_support = true, but no example is shown. A short snippet prevents mis-configuration:

# In `config/initializers/react_on_rails_pro.rb`
ReactOnRailsPro.configure do |config|
  # Enable React Server Components support
  config.enable_rsc_support = true
end

Also consider linking to the section that explains how to generate the three manifest files to give newcomers the full picture.

node_package/src/injectRSCPayload.ts (1)

17-22: Escape only known dangerous sequences – consider </script> split edge

escapeScript handles <!-- and </script>, but a payload chunk could contain
</scr + ipt> split across two pushes, bypassing the regex.
While unlikely, using a robust HTML-escaping lib (or Base64-encoding the payload and decoding in JS)
removes this class of issues altogether.

node_package/src/getReactServerComponent.server.ts (1)

66-70: Typo in error message can mislead users.

The thrown text references serverClientManifestFileName, while the actual property name is reactServerClientManifestFileName.

-'serverClientManifestFileName and reactServerClientManifestFileName are required. ' +
+'reactClientManifestFileName and reactServerClientManifestFileName are required. ' +
node_package/src/types/index.ts (1)

431-433: Duplicate JSDoc opener introduces syntax noise.

There are two consecutive /** lines, which may confuse TS-server and linters.

-  /**
-   /**
+  /**
🛑 Comments failed to post (17)
node_package/tests/RSCClientRoot.test.jsx (1)

14-14: ⚠️ Potential issue

Fix import path inconsistency

The import path has been updated to use the new .tsx extension, but there's an inconsistency at line 46 where jest.requireActual('../src/RSCClientRoot.ts') still references the old .ts extension. This could cause test failures.

-  jest.requireActual('../src/RSCClientRoot.ts');
+  jest.requireActual('../src/RSCClientRoot.tsx');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

-  jest.requireActual('../src/RSCClientRoot.ts');
+  jest.requireActual('../src/RSCClientRoot.tsx');
node_package/src/serverRenderReactComponent.ts (1)

118-118: ⚠️ Potential issue

Fixed error handling by passing the correct error state.

This change fixes a bug in error handling by passing errorRenderState instead of renderState to createResultObject. Now when an error occurs during rendering, the final result object will correctly include the full error details and flags instead of the original render state.

This ensures that error information is properly propagated to the client for better debugging and error reporting.

node_package/src/RSCServerRoot.tsx (1)

12-14: ⚠️ Potential issue

Fix error message to reference the correct component name.

The error message mentions "RSCClientRoot" but this is the "RSCServerRoot" component.

-    throw new Error('RSCClientRoot: No railsContext provided');
+    throw new Error('RSCServerRoot: No railsContext provided');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  if (!railsContext) {
    throw new Error('RSCServerRoot: No railsContext provided');
  }
node_package/src/RSCRoute.ts (1)

1-18: 💡 Verification agent

🧩 Analysis chain

Clean implementation of the RSC Route component.

The implementation is well-structured and handles both cached and non-cached component scenarios properly. The use of React hooks and the component lifecycle is appropriate for this use case.

However, note that React.use() is an experimental feature in React. I recommend ensuring that there's proper error handling if this component is used with React versions that don't support this feature.


🌐 Web query:

What version of React is required for React.use() hook?

💡 Result:

React Version Required for the React.use() Hook

The use() hook is a new addition introduced in React 19. To use React.use(), your project must be running React version 19.0.0 or higher[5][6][2].

Details

  • The use() API allows you to read the value of a promise or context directly in your component’s render logic. This hook enables you to write code that "reads" async resources and context more flexibly than previous hooks such as useContext and without the boilerplate needed for useEffect and useState for async data fetching[2][5][6].
  • This hook can only be used inside components or other hooks, like standard React hooks[2][6].
  • Unlike existing hooks, use() can be called conditionally or inside loops, not just at the top level of a component[2].

Version History

  • React Hooks (such as useState, useEffect) were introduced in React 16.8[3][4].
  • The use() hook was first introduced in React 19[5][6]. It is not available in any earlier version of React.

Summary Table

Hook Introduced In Required React Version
Basic Hooks (useState, useEffect, etc.) React 16.8 16.8+
use() React 19 19.0.0+

To use the React.use() hook, ensure your project uses React 19 or later[5][6][2]. Older versions of React (including 16.x, 17.x, and 18.x) do not support this API.

Citations:


Ensure React 19+ Compatibility for React.use Hook

The React.use() hook was officially added in React 19.0.0, so this component will fail on earlier React versions. Please update your compatibility requirements and/or add a fallback:

• Update package.json peerDependencies to "react": ">=19.0.0"
• Or wrap the call to React.use() in a version check and fall back to useEffect/useState for async loading on older React versions
• Document in your README that this component requires React 19+

node_package/src/RSCProvider.tsx (2)

2-3: ⚠️ Potential issue

Remove .ts extensions from import specifiers

Including the source–file extension (.ts) in a TypeScript import causes tsc to emit the same extension to the compiled JavaScript, which then fails at runtime (Cannot find module './types/index.ts').
Simply omit the extension; the compiler will resolve the module and transform the emitted JS import to "./types/index.js" (or whatever is appropriate for your build).

-import { RailsContext } from './types/index.ts';
-import { getReactServerComponent } from './getReactServerComponent.client.ts';
+import { RailsContext } from './types';
+import { getReactServerComponent } from './getReactServerComponent.client';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

import { RailsContext } from './types';
import { getReactServerComponent } from './getReactServerComponent.client';

5-9: 🛠️ Refactor suggestion

Allow getCachedComponent to return undefined

getCachedComponent legitimately returns undefined when the cache-misses, but the type signature hides that fact.
Surfacing the possibility avoids unsound narrowing for callers.

-type RSCContextType = {
-  getCachedComponent: (componentName: string, componentProps: unknown) => React.ReactNode;
+type RSCContextType = {
+  getCachedComponent: (
+    componentName: string,
+    componentProps: unknown,
+  ) => React.ReactNode | undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

type RSCContextType = {
  getCachedComponent: (
    componentName: string,
    componentProps: unknown,
  ) => React.ReactNode | undefined;

  getComponent: (componentName: string, componentProps: unknown) => Promise<React.ReactNode>;
};
package.json (1)

68-68: 🛠️ Refactor suggestion

Pin Git dependency to a commit or tag

Depending on a moving branch hampers reproducibility: every install can yield a different tree, breaking deterministic builds and cacheability.

Prefer:

"react-on-rails-rsc": "github:shakacode/react_on_rails_rsc#<commit-sha>"

or wait until the branch is merged and published.

node_package/src/streamServerRenderedReactComponent.ts (2)

11-13: ⚠️ Potential issue

Strip .ts extension from import

Same issue as earlier: keep imports extension-less so the compiled JS can resolve.

-import injectRSCPayload from './injectRSCPayload.ts';
+import injectRSCPayload from './injectRSCPayload';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

import type { RenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts';
import injectRSCPayload from './injectRSCPayload';

91-101: 🛠️ Refactor suggestion

consoleReplayTimeoutId may be used before assignment

let consoleReplayTimeoutId: NodeJS.Timeout; is undefined until the first setTimeout, yet clearTimeout(consoleReplayTimeoutId) is called immediately.
TypeScript flags this as “variable used before assignment” and, at runtime, clearTimeout(undefined) is harmless but the type error blocks compilation.

-let consoleReplayTimeoutId: NodeJS.Timeout;
+let consoleReplayTimeoutId: NodeJS.Timeout | undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  let consoleReplayTimeoutId: NodeJS.Timeout | undefined;
  const buildConsoleReplayChunk = () => {
    const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages);
    previouslyReplayedConsoleMessages = consoleHistory?.length || 0;
    if (consoleReplayScript.length === 0) {
      return null;
    }
    const consoleReplayJsonChunk = JSON.stringify(createResultObject('', consoleReplayScript, renderState));
    return consoleReplayJsonChunk;
  };
node_package/src/getReactServerComponent.client.ts (2)

19-27: ⚠️ Potential issue

Add basic fetch-error handling before streaming the response

If the Rails endpoint responds with a non-200 status the current logic happily proceeds, resulting in a cryptic “No stream found” or a runtime read error later.
A quick response.ok guard will surface the real problem early.

   const response = await fetchPromise;
-  const stream = response.body;
+  if (!response.ok) {
+    throw new Error(
+      `Failed to fetch RSC payload (status ${response.status}): ${await response
+        .clone()
+        .text()}`,
+    );
+  }
+  const stream = response.body;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

const createFromFetch = async (fetchPromise: Promise<Response>) => {
  const response = await fetchPromise;
  if (!response.ok) {
    throw new Error(
      `Failed to fetch RSC payload (status ${response.status}): ${await response
        .clone()
        .text()}`,
    );
  }
  const stream = response.body;
  if (!stream) {
    throw new Error('No stream found in response');
  }
  const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
  return createFromReadableStream<React.ReactNode>(transformedStream);
};

29-34: 🛠️ Refactor suggestion

⚠️ Potential issue

Prevent “/undefined/” URLs and URL-encode the props

rscPayloadGenerationUrl is optional, so calling .replace or string-interpolating it can yield
/undefined/<component> and break the request.
Additionally, raw JSON in the query string is not URL-safe (contains {, }, ", etc.).

-  const strippedUrlPath = rscPayloadGenerationUrl?.replace(/^\/|\/$/g, '');
-  return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`));
+  const basePath = (rscPayloadGenerationUrl ?? '').replace(/^\/|\/$/g, '');
+  const encodedProps = encodeURIComponent(propsString);
+  return createFromFetch(fetch(`/${basePath}/${componentName}?props=${encodedProps}`));

Consider falling back to a sensible default path or throwing when the URL is missing, depending on expected behaviour.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

const fetchRSC = ({ componentName, componentProps, railsContext }: ClientGetReactServerComponentProps) => {
  const propsString = JSON.stringify(componentProps);
  const { rscPayloadGenerationUrl } = railsContext;
  const basePath = (rscPayloadGenerationUrl ?? '').replace(/^\/|\/$/g, '');
  const encodedProps = encodeURIComponent(propsString);
  return createFromFetch(fetch(`/${basePath}/${componentName}?props=${encodedProps}`));
};
node_package/src/injectRSCPayload.ts (1)

46-53: ⚠️ Potential issue

Avoid async Promise executors (lint error & micro-task confusion)

Lint correctly flags the async executor. Wrap the async work in an IIFE instead:

-          new Promise(async (resolve) => {
-            for await (const chunk of stream ?? []) {
-              const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk);
-              writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
-            }
-            resolve();
-          }),
+          (async () => {
+            for await (const chunk of stream ?? []) {
+              const decodedChunk =
+                typeof chunk === 'string' ? chunk : decoder.decode(chunk);
+              writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
+            }
+          })(),

This satisfies the same requirement without violating best practices.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

          (async () => {
            for await (const chunk of stream ?? []) {
              const decodedChunk =
                typeof chunk === 'string' ? chunk : decoder.decode(chunk);
              writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey);
            }
          })(),
🧰 Tools
🪛 Biome (1.9.4)

[error] 46-52: Promise executor functions should not be async.

(lint/suspicious/noAsyncPromiseExecutor)

node_package/src/getReactServerComponent.server.ts (3)

46-48: 🛠️ Refactor suggestion

Avoid hard-coding the public assets prefix.

'/webpack/development/' will break in production or when the asset host changes.
Surface this as a parameter (env var or railsContext) with a sane default.


73-77: ⚠️ Potential issue

ReactOnRails is used without being in scope – compilation failure risk.

ReactOnRails is referenced but never imported or declared. Import it (preferred) or reference via globalThis.

+import type { ReactOnRailsInternal } from './types/index.ts';
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+import ReactOnRails from 'react-on-rails';

Also cast if you need the internal helpers:

const ROR = ReactOnRails as unknown as ReactOnRailsInternal;
if (typeof ROR.getRSCPayloadStream !== 'function') {
  ...
}

30-33: ⚠️ Potential issue

Fix misspelling & add defensive check when building moduleMap.

aboluteFileUrl is miss-spelled (should be absoluteFileUrl).
More importantly, the corresponding entry may be absent in reactServerManifest, which will raise a runtime error when destructuring. Guard against undefined and surface an explicit error.

-Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => {
-  const { id, chunks } = reactServerManifest[aboluteFileUrl];
+Object.entries(reactClientManifest).forEach(([absoluteFileUrl, clientFileBundlingInfo]) => {
+  const serverEntry = reactServerManifest[absoluteFileUrl];
+  if (!serverEntry) {
+    throw new Error(
+      `Missing server-side manifest entry for "${absoluteFileUrl}". ` +
+        'Ensure both client & server RSC manifests are generated with identical keys.',
+    );
+  }
+  const { id, chunks } = serverEntry;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  Object.entries(reactClientManifest).forEach(([absoluteFileUrl, clientFileBundlingInfo]) => {
    const serverEntry = reactServerManifest[absoluteFileUrl];
    if (!serverEntry) {
      throw new Error(
        `Missing server-side manifest entry for "${absoluteFileUrl}". ` +
        'Ensure both client & server RSC manifests are generated with identical keys.',
      );
    }
    const { id, chunks } = serverEntry;
    moduleMap[clientFileBundlingInfo.id] = {
      '*': {
node_package/src/RSCPayloadGenerator.ts (2)

16-24: 🛠️ Refactor suggestion

Provide unsubscribe handle to avoid callback build-up.

onRSCPayloadGenerated adds callbacks but never removes them, producing unbounded growth for long-lived processes.

 export const onRSCPayloadGenerated = (railsContext: RailsContext, callback: RSCPayloadCallback) => {
   const callbacks = rscPayloadCallbacks.get(railsContext) || [];
   callbacks.push(callback);
   rscPayloadCallbacks.set(railsContext, callbacks);

   // Call callback for any existing streams for this context
   const existingStreams = mapRailsContextToRSCPayloadStreams.get(railsContext) || [];
   existingStreams.forEach((streamInfo) => callback(streamInfo));
+
+  // Return disposer
+  return () => {
+    const cbs = rscPayloadCallbacks.get(railsContext) ?? [];
+    rscPayloadCallbacks.set(
+      railsContext,
+      cbs.filter((cb) => cb !== callback),
+    );
+  };
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

export const onRSCPayloadGenerated = (railsContext: RailsContext, callback: RSCPayloadCallback) => {
  const callbacks = rscPayloadCallbacks.get(railsContext) || [];
  callbacks.push(callback);
  rscPayloadCallbacks.set(railsContext, callbacks);

  // Call callback for any existing streams for this context
  const existingStreams = mapRailsContextToRSCPayloadStreams.get(railsContext) || [];
  existingStreams.forEach((streamInfo) => callback(streamInfo));

  // Return disposer
  return () => {
    const cbs = rscPayloadCallbacks.get(railsContext) ?? [];
    rscPayloadCallbacks.set(
      railsContext,
      cbs.filter((cb) => cb !== callback),
    );
  };
};

12-15: 🛠️ Refactor suggestion

Potential memory-leak: use WeakMap for context keys.

RailsContext objects are unique per request. Storing them in a Map prevents GC even after the request completes.
Switch to WeakMap so entries are released automatically.

-const mapRailsContextToRSCPayloadStreams = new Map<RailsContext, RSCPayloadStreamInfo[]>();
-const rscPayloadCallbacks = new Map<RailsContext, Array<RSCPayloadCallback>>();
+const mapRailsContextToRSCPayloadStreams = new WeakMap<RailsContext, RSCPayloadStreamInfo[]>();
+const rscPayloadCallbacks = new WeakMap<RailsContext, Array<RSCPayloadCallback>>();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

const mapRailsContextToRSCPayloadStreams = new WeakMap<RailsContext, RSCPayloadStreamInfo[]>();
const rscPayloadCallbacks = new WeakMap<RailsContext, Array<RSCPayloadCallback>>();

@AbanoubGhadban AbanoubGhadban changed the title [WIP] Make rsc compatible with react router [WIP] Add ability to render server components inside client components (add support for react-router) May 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant