diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f9e6770c3..e6eb24e6ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,13 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
### [Unreleased]
Changes since the last non-beta release.
+### Added
+- Added streaming server rendering support:
+ - New `stream_react_component` helper for adding streamed components to views
+ - New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
+ - Enables progressive page loading and improved performance for server-rendered React components
+ [PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
+
#### Changed
- Console replay script generation now awaits the render request promise before generating, allowing it to capture console logs from asynchronous operations. This requires using a version of the Node renderer that supports replaying async console logs. [PR #1649](https://github.com/shakacode/react_on_rails/pull/1649) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
diff --git a/SUMMARY.md b/SUMMARY.md
index 7196a50e52..98320165b6 100644
--- a/SUMMARY.md
+++ b/SUMMARY.md
@@ -17,6 +17,7 @@ Here is the new link:
+ [How React on Rails Works](docs/outdated/how-react-on-rails-works.md)
+ [Client vs. Server Rendering](./docs/guides/client-vs-server-rendering.md)
+ [React Server Rendering](./docs/guides/react-server-rendering.md)
+ + [🚀 Next-Gen Server Rendering: Streaming with React 18's Latest APIs](./docs/guides/streaming-server-rendering.md)
+ [Render-Functions and the RailsContext](docs/guides/render-functions-and-railscontext.md)
+ [Caching and Performance: React on Rails Pro](https://github.com/shakacode/react_on_rails/wiki).
+ [Deployment](docs/guides/deployment.md).
diff --git a/docs/guides/streaming-server-rendering.md b/docs/guides/streaming-server-rendering.md
new file mode 100644
index 0000000000..3586f9f0dc
--- /dev/null
+++ b/docs/guides/streaming-server-rendering.md
@@ -0,0 +1,212 @@
+# 🚀 Streaming Server Rendering with React 18
+
+React on Rails Pro supports streaming server rendering using React 18's latest APIs, including `renderToPipeableStream` and Suspense. This guide explains how to implement and optimize streaming server rendering in your React on Rails application.
+
+## Prerequisites
+
+- React on Rails Pro subscription
+- React 18 or higher (experimental version)
+- React on Rails v15.0.0-alpha.0 or higher
+- React on Rails Pro v4.0.0.rc.5 or higher
+
+## Benefits of Streaming Server Rendering
+
+- Faster Time to First Byte (TTFB)
+- Progressive page loading
+- Improved user experience
+- Better SEO performance
+- Optimal handling of data fetching
+
+## Implementation Steps
+
+1. **Use Experimental React 18 Version**
+
+First, ensure you're using React 18's experimental version in your package.json:
+
+```json
+"dependencies": {
+ "react": "18.3.0-canary-670811593-20240322",
+ "react-dom": "18.3.0-canary-670811593-20240322"
+}
+```
+
+> Note: Check the React documentation for the latest release that supports streaming.
+
+2. **Prepare Your React Components**
+
+You can create async React components that return a promise. Then, you can use the `Suspense` component to render a fallback UI while the component is loading.
+
+```jsx
+// app/javascript/components/MyStreamingComponent.jsx
+import React, { Suspense } from 'react';
+
+const fetchData = async () => {
+ // Simulate API call
+ const response = await fetch('api/endpoint');
+ return response.json();
+};
+
+const MyStreamingComponent = () => {
+ return (
+ <>
+
+ Streaming Server Rendering
+
+ Loading...}>
+
+
+ >
+ );
+};
+
+const SlowDataComponent = async () => {
+ const data = await fetchData();
+ return
{data}
;
+};
+
+export default MyStreamingComponent;
+```
+
+```jsx
+// app/javascript/packs/registration.jsx
+import MyStreamingComponent from '../components/MyStreamingComponent';
+
+ReactOnRails.register({ MyStreamingComponent });
+```
+
+3. **Add The Component To Your Rails View**
+
+```erb
+
+
+<%=
+ stream_react_component(
+ 'MyStreamingComponent',
+ props: { greeting: 'Hello, Streaming World!' },
+ prerender: true
+ )
+%>
+
+
+```
+
+4. **Render The View Using The `stream_view_containing_react_components` Helper**
+
+Ensure you have a controller that renders the view containing the React components. The controller must include the `ReactOnRails::Controller`, `ReactOnRailsPro::Stream` and `ActionController::Live` modules.
+
+```ruby
+# app/controllers/example_controller.rb
+
+class ExampleController < ApplicationController
+ include ActionController::Live
+ include ReactOnRails::Controller
+ include ReactOnRailsPro::Stream
+
+ def show
+ stream_view_containing_react_components(template: 'example/show')
+ end
+end
+```
+
+5. **Test Your Application**
+
+You can test your application by running `rails server` and navigating to the appropriate route.
+
+
+6. **What Happens During Streaming**
+
+When a user visits the page, they'll experience the following sequence:
+
+1. The initial HTML shell is sent immediately, including:
+ - The page layout
+ - Any static content (like the `` and footer)
+ - Placeholder content for the React component (typically a loading state)
+
+2. As the React component processes and suspense boundaries resolve:
+ - HTML chunks are streamed to the browser progressively
+ - Each chunk updates a specific part of the page
+ - The browser renders these updates without a full page reload
+
+For example, with our `MyStreamingComponent`, the sequence might be:
+
+1. The initial HTML includes the header, footer, and loading state.
+
+```html
+
+ Streaming Server Rendering
+
+
+ Loading...
+
+
+```
+
+2. As the component resolves, HTML chunks are streamed to the browser:
+
+```html
+
+ [Fetched data]
+
+
+
+```
+
+## When to Use Streaming
+
+Streaming SSR is particularly valuable in specific scenarios. Here's when to consider it:
+
+### Ideal Use Cases
+
+1. **Data-Heavy Pages**
+ - Pages that fetch data from multiple sources
+ - Dashboard-style layouts where different sections can load independently
+ - Content that requires heavy processing or computation
+
+2. **Progressive Enhancement**
+ - When you want users to see and interact with parts of the page while others load
+ - For improving perceived performance on slower connections
+ - When different parts of your page have different priority levels
+
+3. **Large, Complex Applications**
+ - Applications with multiple independent widgets or components
+ - Pages where some content is critical and other content is supplementary
+ - When you need to optimize Time to First Byte (TTFB)
+
+### Best Practices for Streaming
+
+1. **Component Structure**
+ ```jsx
+ // Good: Independent sections that can stream separately
+
+ }>
+
+
+ }>
+
+
+ }>
+
+
+
+
+ // Bad: Everything wrapped in a single Suspense boundary
+ }>
+
+
+
+
+ ```
+
+2. **Data Loading Strategy**
+ - Prioritize critical data that should be included in the initial HTML
+ - Use streaming for supplementary data that can load progressively
+ - Consider implementing a waterfall strategy for dependent data
diff --git a/jest.config.js b/jest.config.js
index ee6fa2d665..09319b8666 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,4 +1,5 @@
module.exports = {
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'jsdom',
+ setupFiles: ['/node_package/tests/jest.setup.js'],
};
diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb
index 97e0953b2b..d16e776092 100644
--- a/lib/react_on_rails/helper.rb
+++ b/lib/react_on_rails/helper.rb
@@ -91,6 +91,64 @@ def react_component(component_name, options = {})
end
end
+ # Streams a server-side rendered React component using React's `renderToPipeableStream`.
+ # Supports React 18 features like Suspense, concurrent rendering, and selective hydration.
+ # Enables progressive rendering and improved performance for large components.
+ #
+ # Note: This function can only be used with React on Rails Pro.
+ # The view that uses this function must be rendered using the
+ # `stream_view_containing_react_components` method from the React on Rails Pro gem.
+ #
+ # Example of an async React component that can benefit from streaming:
+ #
+ # const AsyncComponent = async () => {
+ # const data = await fetchData();
+ # return {data}
;
+ # };
+ #
+ # function App() {
+ # return (
+ # Loading...}>
+ #
+ #
+ # );
+ # }
+ #
+ # @param [String] component_name Name of your registered component
+ # @param [Hash] options Options for rendering
+ # @option options [Hash] :props Props to pass to the react component
+ # @option options [String] :dom_id DOM ID of the component container
+ # @option options [Hash] :html_options Options passed to content_tag
+ # @option options [Boolean] :prerender Set to false to disable server-side rendering
+ # @option options [Boolean] :trace Set to true to add extra debugging information to the HTML
+ # @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering
+ # Any other options are passed to the content tag, including the id.
+ def stream_react_component(component_name, options = {})
+ unless ReactOnRails::Utils.react_on_rails_pro?
+ raise ReactOnRails::Error,
+ "You must use React on Rails Pro to use the stream_react_component method."
+ end
+
+ if @rorp_rendering_fibers.nil?
+ raise ReactOnRails::Error,
+ "You must call stream_view_containing_react_components to render the view containing the react component"
+ end
+
+ rendering_fiber = Fiber.new do
+ stream = internal_stream_react_component(component_name, options)
+ stream.each_chunk do |chunk|
+ Fiber.yield chunk
+ end
+ end
+
+ @rorp_rendering_fibers << rendering_fiber
+
+ # return the first chunk of the fiber
+ # It contains the initial html of the component
+ # all updates will be appended to the stream sent to browser
+ rendering_fiber.resume
+ end
+
# react_component_hash is used to return multiple HTML strings for server rendering, such as for
# adding meta-tags to a page.
# It is exactly like react_component except for the following:
@@ -330,6 +388,16 @@ def load_pack_for_generated_component(react_component_name, render_options)
private
+ def internal_stream_react_component(component_name, options = {})
+ options = options.merge(stream?: true)
+ result = internal_react_component(component_name, options)
+ build_react_component_result_for_server_streamed_content(
+ rendered_html_stream: result[:result],
+ component_specification_tag: result[:tag],
+ render_options: result[:render_options]
+ )
+ end
+
def generated_components_pack_path(component_name)
"#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js"
end
@@ -361,6 +429,33 @@ def build_react_component_result_for_server_rendered_string(
prepend_render_rails_context(result)
end
+ def build_react_component_result_for_server_streamed_content(
+ rendered_html_stream: required("rendered_html_stream"),
+ component_specification_tag: required("component_specification_tag"),
+ render_options: required("render_options")
+ )
+ content_tag_options_html_tag = render_options.html_options[:tag] || "div"
+ # The component_specification_tag is appended to the first chunk
+ # We need to pass it early with the first chunk because it's needed in hydration
+ # We need to make sure that client can hydrate the app early even before all components are streamed
+ is_first_chunk = true
+ rendered_html_stream = rendered_html_stream.transform do |chunk|
+ if is_first_chunk
+ is_first_chunk = false
+ html_content = <<-HTML
+ #{rails_context_if_not_already_rendered}
+ #{component_specification_tag}
+ <#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk}#{content_tag_options_html_tag}>
+ HTML
+ next html_content.strip
+ end
+ chunk
+ end
+
+ rendered_html_stream.transform(&:html_safe)
+ # TODO: handle console logs
+ end
+
def build_react_component_result_for_server_rendered_hash(
server_rendered_html: required("server_rendered_html"),
component_specification_tag: required("component_specification_tag"),
@@ -404,20 +499,22 @@ def compose_react_component_html_with_spec_and_console(component_specification_t
HTML
end
- # prepend the rails_context if not yet applied
- def prepend_render_rails_context(render_value)
- return render_value if @rendered_rails_context
+ def rails_context_if_not_already_rendered
+ return "" if @rendered_rails_context
data = rails_context(server_side: false)
@rendered_rails_context = true
- rails_context_content = content_tag(:script,
- json_safe_and_pretty(data).html_safe,
- type: "application/json",
- id: "js-react-on-rails-context")
+ content_tag(:script,
+ json_safe_and_pretty(data).html_safe,
+ type: "application/json",
+ id: "js-react-on-rails-context")
+ end
- "#{rails_context_content}\n#{render_value}".html_safe
+ # prepend the rails_context if not yet applied
+ def prepend_render_rails_context(render_value)
+ "#{rails_context_if_not_already_rendered}\n#{render_value}".strip.html_safe
end
def internal_react_component(react_component_name, options = {})
@@ -520,6 +617,9 @@ def server_rendered_react_component(render_options)
js_code: js_code)
end
+ # TODO: handle errors for streams
+ return result if render_options.stream?
+
if result["hasErrors"] && render_options.raise_on_prerender_error
# We caught this exception on our backtrace handler
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,
diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb
index f73415bc8e..8bb8536ed7 100644
--- a/lib/react_on_rails/react_component/render_options.rb
+++ b/lib/react_on_rails/react_component/render_options.rb
@@ -107,6 +107,10 @@ def set_option(key, value)
options[key] = value
end
+ def stream?
+ options[:stream?]
+ end
+
private
attr_reader :options
diff --git a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb
index d1e7212d82..2dcd3eb80b 100644
--- a/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb
+++ b/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb
@@ -92,6 +92,12 @@ def exec_server_render_js(js_code, render_options, js_evaluator = nil)
end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/PerceivedComplexity
+ # TODO: merge with exec_server_render_js
+ def exec_server_render_streaming_js(js_code, render_options, js_evaluator = nil)
+ js_evaluator ||= self
+ js_evaluator.eval_streaming_js(js_code, render_options)
+ end
+
def trace_js_code_used(msg, js_code, file_name = "tmp/server-generated.js", force: false)
return unless ReactOnRails.configuration.trace || force
diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts
index cc8006ea7c..0ee89ad872 100644
--- a/node_package/src/ReactOnRails.ts
+++ b/node_package/src/ReactOnRails.ts
@@ -1,10 +1,11 @@
import type { ReactElement } from 'react';
+import type { Readable } from 'stream';
import * as ClientStartup from './clientStartup';
import handleError from './handleError';
import ComponentRegistry from './ComponentRegistry';
import StoreRegistry from './StoreRegistry';
-import serverRenderReactComponent from './serverRenderReactComponent';
+import serverRenderReactComponent, { streamServerRenderedReactComponent } from './serverRenderReactComponent';
import buildConsoleReplay from './buildConsoleReplay';
import createReactOutput from './createReactOutput';
import Authenticity from './Authenticity';
@@ -247,6 +248,14 @@ ctx.ReactOnRails = {
return serverRenderReactComponent(options);
},
+ /**
+ * Used by server rendering by Rails
+ * @param options
+ */
+ streamServerRenderedReactComponent(options: RenderParams): Readable {
+ return streamServerRenderedReactComponent(options);
+ },
+
/**
* Used by Rails to catch errors in rendering
* @param options
diff --git a/node_package/src/serverRenderReactComponent.ts b/node_package/src/serverRenderReactComponent.ts
index bbc1b53ef8..ed392793a1 100644
--- a/node_package/src/serverRenderReactComponent.ts
+++ b/node_package/src/serverRenderReactComponent.ts
@@ -1,4 +1,5 @@
import ReactDOMServer from 'react-dom/server';
+import { PassThrough, Readable } from 'stream';
import type { ReactElement } from 'react';
import ComponentRegistry from './ComponentRegistry';
@@ -192,4 +193,53 @@ const serverRenderReactComponent: typeof serverRenderReactComponentInternal = (o
}
};
+const stringToStream = (str: string): Readable => {
+ const stream = new PassThrough();
+ stream.push(str);
+ stream.push(null);
+ return stream;
+};
+
+export const streamServerRenderedReactComponent = (options: RenderParams): Readable => {
+ const { name: componentName, domNodeId, trace, props, railsContext, throwJsErrors } = options;
+
+ let renderResult: null | Readable = null;
+
+ try {
+ const componentObj = ComponentRegistry.get(componentName);
+ validateComponent(componentObj, componentName);
+
+ const reactRenderingResult = createReactOutput({
+ componentObj,
+ domNodeId,
+ trace,
+ props,
+ railsContext,
+ });
+
+ if (isServerRenderHash(reactRenderingResult) || isPromise(reactRenderingResult)) {
+ throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
+ }
+
+ const renderStream = new PassThrough();
+ ReactDOMServer.renderToPipeableStream(reactRenderingResult).pipe(renderStream);
+ renderResult = renderStream;
+
+ // TODO: Add console replay script to the stream
+ } catch (e) {
+ if (throwJsErrors) {
+ throw e;
+ }
+
+ const error = e instanceof Error ? e : new Error(String(e));
+ renderResult = stringToStream(handleError({
+ e: error,
+ name: componentName,
+ serverSide: true,
+ }));
+ }
+
+ return renderResult;
+};
+
export default serverRenderReactComponent;
diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts
index d2e129db5d..2f808dc067 100644
--- a/node_package/src/types/index.ts
+++ b/node_package/src/types/index.ts
@@ -1,4 +1,5 @@
import type { ReactElement, ReactNode, Component, ComponentType } from 'react';
+import type { Readable } from 'stream';
// Don't import redux just for the type definitions
// See https://github.com/shakacode/react_on_rails/issues/1321
@@ -168,6 +169,7 @@ export interface ReactOnRails {
): RenderReturnType;
getComponent(name: string): RegisteredComponent;
serverRenderReactComponent(options: RenderParams): null | string | Promise;
+ streamServerRenderedReactComponent(options: RenderParams): Readable;
handleError(options: ErrorOptions): string | undefined;
buildConsoleReplay(): string;
registeredComponents(): Map;
diff --git a/node_package/tests/ReactOnRails.test.js b/node_package/tests/ReactOnRails.test.js
index da3a56adc5..afa6f4c271 100644
--- a/node_package/tests/ReactOnRails.test.js
+++ b/node_package/tests/ReactOnRails.test.js
@@ -19,10 +19,15 @@ describe('ReactOnRails', () => {
});
ReactOnRails.register({ R1 });
- document.body.innerHTML = '';
- // eslint-disable-next-line no-underscore-dangle
- const actual = ReactOnRails.render('R1', {}, 'root')._reactInternals.type;
- expect(actual).toEqual(R1);
+ const root = document.createElement('div');
+ root.id = 'root';
+ root.textContent = ' WORLD ';
+
+ document.body.innerHTML = '';
+ document.body.appendChild(root);
+ ReactOnRails.render('R1', {}, 'root');
+
+ expect(document.getElementById('root').textContent).toEqual(' WORLD ');
});
it('accepts traceTurbolinks as an option true', () => {
diff --git a/node_package/tests/jest.setup.js b/node_package/tests/jest.setup.js
new file mode 100644
index 0000000000..454efc9cb3
--- /dev/null
+++ b/node_package/tests/jest.setup.js
@@ -0,0 +1,13 @@
+// If jsdom environment is set and TextEncoder is not defined, then define TextEncoder and TextDecoder
+// The current version of jsdom does not support TextEncoder and TextDecoder
+// The following code will tell us when jsdom supports TextEncoder and TextDecoder
+if (typeof window !== 'undefined' && typeof window.TextEncoder !== 'undefined') {
+ throw new Error('TextEncoder is already defined, remove the polyfill');
+}
+
+if (typeof window !== 'undefined') {
+ // eslint-disable-next-line global-require
+ const { TextEncoder, TextDecoder } = require('util');
+ global.TextEncoder = TextEncoder;
+ global.TextDecoder = TextDecoder;
+}
diff --git a/package.json b/package.json
index ad565cfb64..fc29157fe9 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,8 @@
"@babel/preset-react": "^7.18.6",
"@babel/types": "^7.20.7",
"@types/jest": "^29.0.0",
- "@types/react": "^17.0.0",
- "@types/react-dom": "^17.0.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
"@types/turbolinks": "^5.2.2",
"@types/webpack-env": "^1.18.4",
"@typescript-eslint/eslint-plugin": "^6.18.1",
@@ -39,8 +39,8 @@
"prettier": "^2.8.8",
"prettier-eslint-cli": "^5.0.0",
"prop-types": "^15.8.1",
- "react": "^17.0.0",
- "react-dom": "^17.0.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
"react-transform-hmr": "^1.0.4",
"redux": "^4.2.1",
"ts-jest": "^29.1.0",
diff --git a/spec/dummy/config/webpack/alias.js b/spec/dummy/config/webpack/alias.js
index 5645c184ad..3dd27b0462 100644
--- a/spec/dummy/config/webpack/alias.js
+++ b/spec/dummy/config/webpack/alias.js
@@ -4,6 +4,7 @@ module.exports = {
resolve: {
alias: {
Assets: resolve(__dirname, '..', '..', 'client', 'app', 'assets'),
+ stream: 'stream-browserify',
},
},
};
diff --git a/spec/dummy/config/webpack/commonWebpackConfig.js b/spec/dummy/config/webpack/commonWebpackConfig.js
index 998c0d0238..c268f81f8e 100644
--- a/spec/dummy/config/webpack/commonWebpackConfig.js
+++ b/spec/dummy/config/webpack/commonWebpackConfig.js
@@ -41,6 +41,7 @@ baseClientWebpackConfig.plugins.push(
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
+ process: 'process/browser',
}),
);
diff --git a/spec/dummy/config/webpack/webpackConfig.js b/spec/dummy/config/webpack/webpackConfig.js
index 75747b4556..3f99331fea 100644
--- a/spec/dummy/config/webpack/webpackConfig.js
+++ b/spec/dummy/config/webpack/webpackConfig.js
@@ -4,6 +4,7 @@ const serverWebpackConfig = require('./serverWebpackConfig');
const webpackConfig = (envSpecific) => {
const clientConfig = clientWebpackConfig();
const serverConfig = serverWebpackConfig();
+ clientConfig.resolve.fallback = { stream: require.resolve('stream-browserify') };
if (envSpecific) {
envSpecific(clientConfig, serverConfig);
diff --git a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
index fc05f73aba..de0d01e7b0 100644
--- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
+++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb
@@ -7,6 +7,7 @@
class PlainReactOnRailsHelper
include ReactOnRailsHelper
+ include ActionView::Helpers::TagHelper
end
# rubocop:disable Metrics/BlockLength
@@ -365,5 +366,30 @@ class PlainReactOnRailsHelper
expect { ob.send(:rails_context, server_side: false) }.not_to raise_error
end
end
+
+ describe "#rails_context_if_not_already_rendered" do
+ let(:helper) { PlainReactOnRailsHelper.new }
+
+ before do
+ allow(helper).to receive(:rails_context).and_return({ some: "context" })
+ end
+
+ it "returns a script tag with rails context when not already rendered" do
+ result = helper.send(:rails_context_if_not_already_rendered)
+ expect(result).to include('