Skip to content

Commit 093247d

Browse files
Add support for streaming server side rendering (#1633)
* tmp * add support for streaming rendered component using renderToPipeableStream * put ROR scripts after the first rendered chunk * remove log statements * add stream_react_component_async helper function * add helper function to render a whole view * fix failing jest tests * linting * linting * remove redundant new line when context is not prepended * rename stream_react_component_async to stream_react_component * fix error caused by process on browser * remove new line appended to the page when has no rails context * fix the problem of not updating the first streaming chunk * rename stream_react_component_internal to internal_stream_react_component * add unit tests for rails_context_if_not_already_rendered * remove :focus tag from rails_context_if_not_already_rendered spec * make render function returns Readable stream instead of PassThrough * use validateComponent function instead of manually validating it * linting * add some comments * don't return extra null at he end of the stream * update CHANGELOG.md * update docs * update docs
1 parent eb7639b commit 093247d

File tree

18 files changed

+478
-48
lines changed

18 files changed

+478
-48
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ Please follow the recommendations outlined at [keepachangelog.com](http://keepac
1818
### [Unreleased]
1919
Changes since the last non-beta release.
2020

21+
### Added
22+
- Added streaming server rendering support:
23+
- New `stream_react_component` helper for adding streamed components to views
24+
- New `streamServerRenderedReactComponent` function in the react-on-rails package that uses React 18's `renderToPipeableStream` API
25+
- Enables progressive page loading and improved performance for server-rendered React components
26+
[PR #1633](https://github.com/shakacode/react_on_rails/pull/1633) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
27+
2128
#### Changed
2229
- 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).
2330

SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Here is the new link:
1717
+ [How React on Rails Works](docs/outdated/how-react-on-rails-works.md)
1818
+ [Client vs. Server Rendering](./docs/guides/client-vs-server-rendering.md)
1919
+ [React Server Rendering](./docs/guides/react-server-rendering.md)
20+
+ [🚀 Next-Gen Server Rendering: Streaming with React 18's Latest APIs](./docs/guides/streaming-server-rendering.md)
2021
+ [Render-Functions and the RailsContext](docs/guides/render-functions-and-railscontext.md)
2122
+ [Caching and Performance: React on Rails Pro](https://github.com/shakacode/react_on_rails/wiki).
2223
+ [Deployment](docs/guides/deployment.md).
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# 🚀 Streaming Server Rendering with React 18
2+
3+
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.
4+
5+
## Prerequisites
6+
7+
- React on Rails Pro subscription
8+
- React 18 or higher (experimental version)
9+
- React on Rails v15.0.0-alpha.0 or higher
10+
- React on Rails Pro v4.0.0.rc.5 or higher
11+
12+
## Benefits of Streaming Server Rendering
13+
14+
- Faster Time to First Byte (TTFB)
15+
- Progressive page loading
16+
- Improved user experience
17+
- Better SEO performance
18+
- Optimal handling of data fetching
19+
20+
## Implementation Steps
21+
22+
1. **Use Experimental React 18 Version**
23+
24+
First, ensure you're using React 18's experimental version in your package.json:
25+
26+
```json
27+
"dependencies": {
28+
"react": "18.3.0-canary-670811593-20240322",
29+
"react-dom": "18.3.0-canary-670811593-20240322"
30+
}
31+
```
32+
33+
> Note: Check the React documentation for the latest release that supports streaming.
34+
35+
2. **Prepare Your React Components**
36+
37+
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.
38+
39+
```jsx
40+
// app/javascript/components/MyStreamingComponent.jsx
41+
import React, { Suspense } from 'react';
42+
43+
const fetchData = async () => {
44+
// Simulate API call
45+
const response = await fetch('api/endpoint');
46+
return response.json();
47+
};
48+
49+
const MyStreamingComponent = () => {
50+
return (
51+
<>
52+
<header>
53+
<h1>Streaming Server Rendering</h1>
54+
</header>
55+
<Suspense fallback={<div>Loading...</div>}>
56+
<SlowDataComponent />
57+
</Suspense>
58+
</>
59+
);
60+
};
61+
62+
const SlowDataComponent = async () => {
63+
const data = await fetchData();
64+
return <div>{data}</div>;
65+
};
66+
67+
export default MyStreamingComponent;
68+
```
69+
70+
```jsx
71+
// app/javascript/packs/registration.jsx
72+
import MyStreamingComponent from '../components/MyStreamingComponent';
73+
74+
ReactOnRails.register({ MyStreamingComponent });
75+
```
76+
77+
3. **Add The Component To Your Rails View**
78+
79+
```erb
80+
<!-- app/views/example/show.html.erb -->
81+
82+
<%=
83+
stream_react_component(
84+
'MyStreamingComponent',
85+
props: { greeting: 'Hello, Streaming World!' },
86+
prerender: true
87+
)
88+
%>
89+
90+
<footer>
91+
<p>Footer content</p>
92+
</footer>
93+
```
94+
95+
4. **Render The View Using The `stream_view_containing_react_components` Helper**
96+
97+
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.
98+
99+
```ruby
100+
# app/controllers/example_controller.rb
101+
102+
class ExampleController < ApplicationController
103+
include ActionController::Live
104+
include ReactOnRails::Controller
105+
include ReactOnRailsPro::Stream
106+
107+
def show
108+
stream_view_containing_react_components(template: 'example/show')
109+
end
110+
end
111+
```
112+
113+
5. **Test Your Application**
114+
115+
You can test your application by running `rails server` and navigating to the appropriate route.
116+
117+
118+
6. **What Happens During Streaming**
119+
120+
When a user visits the page, they'll experience the following sequence:
121+
122+
1. The initial HTML shell is sent immediately, including:
123+
- The page layout
124+
- Any static content (like the `<h1>` and footer)
125+
- Placeholder content for the React component (typically a loading state)
126+
127+
2. As the React component processes and suspense boundaries resolve:
128+
- HTML chunks are streamed to the browser progressively
129+
- Each chunk updates a specific part of the page
130+
- The browser renders these updates without a full page reload
131+
132+
For example, with our `MyStreamingComponent`, the sequence might be:
133+
134+
1. The initial HTML includes the header, footer, and loading state.
135+
136+
```html
137+
<header>
138+
<h1>Streaming Server Rendering</h1>
139+
</header>
140+
<template id="s0">
141+
<div>Loading...</div>
142+
</template>
143+
<footer>
144+
<p>Footer content</p>
145+
</footer>
146+
```
147+
148+
2. As the component resolves, HTML chunks are streamed to the browser:
149+
150+
```html
151+
<template hidden id="b0">
152+
<div>[Fetched data]</div>
153+
</template>
154+
155+
<script>
156+
// This implementation is slightly simplified
157+
document.getElementById('s0').replaceChildren(
158+
document.getElementById('b0')
159+
);
160+
</script>
161+
```
162+
163+
## When to Use Streaming
164+
165+
Streaming SSR is particularly valuable in specific scenarios. Here's when to consider it:
166+
167+
### Ideal Use Cases
168+
169+
1. **Data-Heavy Pages**
170+
- Pages that fetch data from multiple sources
171+
- Dashboard-style layouts where different sections can load independently
172+
- Content that requires heavy processing or computation
173+
174+
2. **Progressive Enhancement**
175+
- When you want users to see and interact with parts of the page while others load
176+
- For improving perceived performance on slower connections
177+
- When different parts of your page have different priority levels
178+
179+
3. **Large, Complex Applications**
180+
- Applications with multiple independent widgets or components
181+
- Pages where some content is critical and other content is supplementary
182+
- When you need to optimize Time to First Byte (TTFB)
183+
184+
### Best Practices for Streaming
185+
186+
1. **Component Structure**
187+
```jsx
188+
// Good: Independent sections that can stream separately
189+
<Layout>
190+
<Suspense fallback={<HeaderSkeleton />}>
191+
<Header />
192+
</Suspense>
193+
<Suspense fallback={<MainContentSkeleton />}>
194+
<MainContent />
195+
</Suspense>
196+
<Suspense fallback={<SidebarSkeleton />}>
197+
<Sidebar />
198+
</Suspense>
199+
</Layout>
200+
201+
// Bad: Everything wrapped in a single Suspense boundary
202+
<Suspense fallback={<FullPageSkeleton />}>
203+
<Header />
204+
<MainContent />
205+
<Sidebar />
206+
</Suspense>
207+
```
208+
209+
2. **Data Loading Strategy**
210+
- Prioritize critical data that should be included in the initial HTML
211+
- Use streaming for supplementary data that can load progressively
212+
- Consider implementing a waterfall strategy for dependent data

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
module.exports = {
22
preset: 'ts-jest/presets/js-with-ts',
33
testEnvironment: 'jsdom',
4+
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
45
};

lib/react_on_rails/helper.rb

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,64 @@ def react_component(component_name, options = {})
9191
end
9292
end
9393

94+
# Streams a server-side rendered React component using React's `renderToPipeableStream`.
95+
# Supports React 18 features like Suspense, concurrent rendering, and selective hydration.
96+
# Enables progressive rendering and improved performance for large components.
97+
#
98+
# Note: This function can only be used with React on Rails Pro.
99+
# The view that uses this function must be rendered using the
100+
# `stream_view_containing_react_components` method from the React on Rails Pro gem.
101+
#
102+
# Example of an async React component that can benefit from streaming:
103+
#
104+
# const AsyncComponent = async () => {
105+
# const data = await fetchData();
106+
# return <div>{data}</div>;
107+
# };
108+
#
109+
# function App() {
110+
# return (
111+
# <Suspense fallback={<div>Loading...</div>}>
112+
# <AsyncComponent />
113+
# </Suspense>
114+
# );
115+
# }
116+
#
117+
# @param [String] component_name Name of your registered component
118+
# @param [Hash] options Options for rendering
119+
# @option options [Hash] :props Props to pass to the react component
120+
# @option options [String] :dom_id DOM ID of the component container
121+
# @option options [Hash] :html_options Options passed to content_tag
122+
# @option options [Boolean] :prerender Set to false to disable server-side rendering
123+
# @option options [Boolean] :trace Set to true to add extra debugging information to the HTML
124+
# @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering
125+
# Any other options are passed to the content tag, including the id.
126+
def stream_react_component(component_name, options = {})
127+
unless ReactOnRails::Utils.react_on_rails_pro?
128+
raise ReactOnRails::Error,
129+
"You must use React on Rails Pro to use the stream_react_component method."
130+
end
131+
132+
if @rorp_rendering_fibers.nil?
133+
raise ReactOnRails::Error,
134+
"You must call stream_view_containing_react_components to render the view containing the react component"
135+
end
136+
137+
rendering_fiber = Fiber.new do
138+
stream = internal_stream_react_component(component_name, options)
139+
stream.each_chunk do |chunk|
140+
Fiber.yield chunk
141+
end
142+
end
143+
144+
@rorp_rendering_fibers << rendering_fiber
145+
146+
# return the first chunk of the fiber
147+
# It contains the initial html of the component
148+
# all updates will be appended to the stream sent to browser
149+
rendering_fiber.resume
150+
end
151+
94152
# react_component_hash is used to return multiple HTML strings for server rendering, such as for
95153
# adding meta-tags to a page.
96154
# 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)
330388

331389
private
332390

391+
def internal_stream_react_component(component_name, options = {})
392+
options = options.merge(stream?: true)
393+
result = internal_react_component(component_name, options)
394+
build_react_component_result_for_server_streamed_content(
395+
rendered_html_stream: result[:result],
396+
component_specification_tag: result[:tag],
397+
render_options: result[:render_options]
398+
)
399+
end
400+
333401
def generated_components_pack_path(component_name)
334402
"#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js"
335403
end
@@ -361,6 +429,33 @@ def build_react_component_result_for_server_rendered_string(
361429
prepend_render_rails_context(result)
362430
end
363431

432+
def build_react_component_result_for_server_streamed_content(
433+
rendered_html_stream: required("rendered_html_stream"),
434+
component_specification_tag: required("component_specification_tag"),
435+
render_options: required("render_options")
436+
)
437+
content_tag_options_html_tag = render_options.html_options[:tag] || "div"
438+
# The component_specification_tag is appended to the first chunk
439+
# We need to pass it early with the first chunk because it's needed in hydration
440+
# We need to make sure that client can hydrate the app early even before all components are streamed
441+
is_first_chunk = true
442+
rendered_html_stream = rendered_html_stream.transform do |chunk|
443+
if is_first_chunk
444+
is_first_chunk = false
445+
html_content = <<-HTML
446+
#{rails_context_if_not_already_rendered}
447+
#{component_specification_tag}
448+
<#{content_tag_options_html_tag} id="#{render_options.dom_id}">#{chunk}</#{content_tag_options_html_tag}>
449+
HTML
450+
next html_content.strip
451+
end
452+
chunk
453+
end
454+
455+
rendered_html_stream.transform(&:html_safe)
456+
# TODO: handle console logs
457+
end
458+
364459
def build_react_component_result_for_server_rendered_hash(
365460
server_rendered_html: required("server_rendered_html"),
366461
component_specification_tag: required("component_specification_tag"),
@@ -404,20 +499,22 @@ def compose_react_component_html_with_spec_and_console(component_specification_t
404499
HTML
405500
end
406501

407-
# prepend the rails_context if not yet applied
408-
def prepend_render_rails_context(render_value)
409-
return render_value if @rendered_rails_context
502+
def rails_context_if_not_already_rendered
503+
return "" if @rendered_rails_context
410504

411505
data = rails_context(server_side: false)
412506

413507
@rendered_rails_context = true
414508

415-
rails_context_content = content_tag(:script,
416-
json_safe_and_pretty(data).html_safe,
417-
type: "application/json",
418-
id: "js-react-on-rails-context")
509+
content_tag(:script,
510+
json_safe_and_pretty(data).html_safe,
511+
type: "application/json",
512+
id: "js-react-on-rails-context")
513+
end
419514

420-
"#{rails_context_content}\n#{render_value}".html_safe
515+
# prepend the rails_context if not yet applied
516+
def prepend_render_rails_context(render_value)
517+
"#{rails_context_if_not_already_rendered}\n#{render_value}".strip.html_safe
421518
end
422519

423520
def internal_react_component(react_component_name, options = {})
@@ -520,6 +617,9 @@ def server_rendered_react_component(render_options)
520617
js_code: js_code)
521618
end
522619

620+
# TODO: handle errors for streams
621+
return result if render_options.stream?
622+
523623
if result["hasErrors"] && render_options.raise_on_prerender_error
524624
# We caught this exception on our backtrace handler
525625
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,

0 commit comments

Comments
 (0)