Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a054023
add support for streaming react components using renderToPipeableStream
AbanoubGhadban Jun 11, 2024
84a9e16
fix typescript error
AbanoubGhadban Oct 15, 2024
06d40a7
linting
AbanoubGhadban Oct 15, 2024
804d96d
add some comments and other tiny changes
AbanoubGhadban Oct 15, 2024
f438b7d
update react version to 19.0.0-beta-26f2496093-20240514
AbanoubGhadban Oct 15, 2024
6e3485d
Revert "update react version to 19.0.0-beta-26f2496093-20240514"
AbanoubGhadban Oct 15, 2024
7096e28
improve some function names
AbanoubGhadban Oct 20, 2024
63ca4cd
linting
AbanoubGhadban Oct 20, 2024
16e371e
extract a code to a function and add comments
AbanoubGhadban Oct 20, 2024
da1470e
merge some conditions in code
AbanoubGhadban Oct 20, 2024
f8eac5c
make comment clearer
AbanoubGhadban Oct 20, 2024
20b8f28
improve a comment
AbanoubGhadban Oct 20, 2024
404a787
fix: await sending stream in request
AbanoubGhadban Oct 21, 2024
0954622
add unit tests for ruby helper functions
AbanoubGhadban Oct 23, 2024
aceaf8c
linting
AbanoubGhadban Oct 23, 2024
029186f
add integration tests to test the hydration and hydration failure beh…
AbanoubGhadban Oct 23, 2024
85c8140
add tests to test node renderer html streaming api
AbanoubGhadban Oct 24, 2024
963bced
make integration tests use the testing react component
AbanoubGhadban Oct 24, 2024
846105a
linting
AbanoubGhadban Oct 24, 2024
593ea1d
add e2e tests for streaming html feature
AbanoubGhadban Oct 26, 2024
9aa4330
run rails server in testing env on CI
AbanoubGhadban Oct 26, 2024
9d9ddd3
linting
AbanoubGhadban Oct 26, 2024
771eee9
update rails_context tests to work when Capybara server is not running
AbanoubGhadban Oct 26, 2024
a9fea71
update comment
AbanoubGhadban Oct 27, 2024
6dcd38e
update comment
AbanoubGhadban Oct 27, 2024
3026a37
update test description
AbanoubGhadban Oct 27, 2024
974eb57
update comment
AbanoubGhadban Oct 27, 2024
7e11777
update comment
AbanoubGhadban Oct 27, 2024
60d1391
update chunks_read variable name
AbanoubGhadban Oct 27, 2024
6cc2b0c
update streaming docs
AbanoubGhadban Oct 29, 2024
e105c7a
update server bundle used for testing
AbanoubGhadban Nov 1, 2024
c03e1d2
update CHANGELOG.md
AbanoubGhadban Nov 1, 2024
099e87f
update react on rails version in tests
AbanoubGhadban Nov 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ jobs:
name: Run Node renderer in a background
command: cd spec/dummy && yarn run node-renderer
background: true
- run:
name: run rails server in background
Copy link
Contributor

@alexeyr-ci alexeyr-ci Oct 27, 2024

Choose a reason for hiding this comment

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

Suggested change
name: run rails server in background
name: Run Rails server in background

command: cd spec/dummy && RAILS_ENV=test rails server
background: true
- run:
name: wait for rails server to start
Copy link
Contributor

@alexeyr-ci alexeyr-ci Oct 27, 2024

Choose a reason for hiding this comment

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

Suggested change
name: wait for rails server to start
name: Wait for the Rails server to start

command: |
while ! curl -s http://localhost:3000 > /dev/null; do sleep 1; done
- run:
name: Run rspec tests
command: |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ You can find the **package** version numbers from this repo's tags and below in
*Add changes in master not yet tagged.*

### Added
- Added streaming server rendering support: [PR 407](https://github.com/shakacode/react_on_rails_pro/pull/407) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
- New `stream_view_containing_react_components` helper method that can be used with `stream_react_component` helper method in react_on_rails gem.
- Enables progressive page loading and improved performance for server-rendered React components.
- Added support for replaying console logs from asynchronous operations:
- New `replayServerAsyncOperationLogs` configuration option to enable/disable this feature
- When enabled, captures and replays console output from async operations during server-side rendering
Expand Down
6 changes: 6 additions & 0 deletions docs/home-pro.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ See the README.md in those sample apps for more details.

## Features

### 🚀 Next-Gen Server Rendering: Streaming with React 18's Latest APIs
React on Rails Pro supports React 18's Streaming Server-Side Rendering, allowing you to progressively render and stream HTML content to the client. This enables faster page loads and better user experience.

See [docs/streaming-server-rendering](./streaming-server-rendering.md) for more details.

### Caching
Caching of SSR is critical for achieving optimum performance.

Expand All @@ -44,6 +49,7 @@ See the [Ruby API](docs/ruby-api.md).
## References

* [Installation](./installation.md)
* [Streaming Server Rendering](./streaming-server-rendering.md)
* [Caching](./caching.md)
* [Rails Configuration](./configuration.md)
* [Node Renderer Docs](./node-renderer/basics.md)
Expand Down
212 changes: 212 additions & 0 deletions docs/streaming-server-rendering.md
Original file line number Diff line number Diff line change
@@ -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 (
<>
<header>
<h1>Streaming Server Rendering</h1>
</header>
<Suspense fallback={<div>Loading...</div>}>
<SlowDataComponent />
</Suspense>
</>
);
};

const SlowDataComponent = async () => {
const data = await fetchData();
return <div>{data}</div>;
};

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
<!-- app/views/example/show.html.erb -->

<%=
stream_react_component(
'MyStreamingComponent',
props: { greeting: 'Hello, Streaming World!' },
prerender: true
)
%>

<footer>
<p>Footer content</p>
</footer>
```

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 `<h1>` 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
<header>
<h1>Streaming Server Rendering</h1>
</header>
<template id="s0">
<div>Loading...</div>
</template>
<footer>
<p>Footer content</p>
</footer>
```

2. As the component resolves, HTML chunks are streamed to the browser:

```html
<template hidden id="b0">
<div>[Fetched data]</div>
</template>

<script>
// This implementation is slightly simplified
document.getElementById('s0').replaceChildren(
document.getElementById('b0')
);
</script>
```

## 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
<Layout>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<MainContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Layout>

// Bad: Everything wrapped in a single Suspense boundary
<Suspense fallback={<FullPageSkeleton />}>
<Header />
<MainContent />
<Sidebar />
</Suspense>
```

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
1 change: 1 addition & 0 deletions lib/react_on_rails_pro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
require "react_on_rails_pro/server_rendering_js_code"
require "react_on_rails_pro/assets_precompile"
require "react_on_rails_pro/prepare_node_renderer_bundles"
require "react_on_rails_pro/stream"
14 changes: 12 additions & 2 deletions lib/react_on_rails_pro/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "net/http/post/multipart"
require "uri"
require "persistent_http"
require_relative "stream_request"

module ReactOnRailsPro
class Request
Expand All @@ -17,6 +18,15 @@ def render_code(path, js_code, send_bundle)
perform_request(path, form_with_code(js_code, send_bundle))
end

def render_code_as_stream(path, js_code)
Rails.logger.info { "[ReactOnRailsPro] Perform rendering request as a stream #{path}" }
# The block here and at perform_request is passed to connection.request,
# which allows us to read each chunk received in the HTTP stream as soon as it's received.
ReactOnRailsPro::StreamRequest.create do |send_bundle, &block|
perform_request(path, form_with_code(js_code, send_bundle), &block)
end
end

def upload_assets
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
perform_request("/upload-assets", form_with_assets_and_bundle)
Expand All @@ -34,12 +44,12 @@ def connection
@connection ||= create_connection
end

def perform_request(path, form)
def perform_request(path, form, &block)
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
retry_request = true
while retry_request
begin
response = connection.request(Net::HTTP::Post::Multipart.new(path, form))
response = connection.request(Net::HTTP::Post::Multipart.new(path, form), &block)
retry_request = false
rescue Timeout::Error => e
# Testing timeout catching:
Expand Down
7 changes: 6 additions & 1 deletion lib/react_on_rails_pro/server_rendering_js_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ def ssr_pre_hook_js
end

def render(props_string, rails_context, redux_stores, react_component_name, render_options)
render_function_name = if render_options.stream?
"streamServerRenderedReactComponent"
else
"serverRenderReactComponent"
end
<<-JS
(function() {
var railsContext = #{rails_context};
#{ssr_pre_hook_js}
#{redux_stores}
var props = #{props_string};
return ReactOnRails.serverRenderReactComponent({
return ReactOnRails.#{render_function_name}({
name: '#{react_component_name}',
domNodeId: '#{render_options.dom_id}',
props: props,
Expand Down
38 changes: 27 additions & 11 deletions lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@ def exec_server_render_js(js_code, render_options)
.exec_server_render_js(js_code, render_options, self)
end

def eval_js(js_code, render_options, send_bundle: false)
ReactOnRailsPro::ServerRenderingPool::ProRendering
.set_request_digest_on_render_options(js_code, render_options)
def exec_server_render_streaming_js(js_code, render_options)
render_options.set_option(:throw_js_errors, ReactOnRailsPro.configuration.throw_js_errors)
# The secret sauce is passing self as the 3rd param, the js_evaluator
ReactOnRails::ServerRenderingPool::RubyEmbeddedJavaScript
.exec_server_render_streaming_js(js_code, render_options, self)
end

# In case this method is called with simple, raw JS, not depending on the bundle, next line
# is needed.
@bundle_hash ||= ReactOnRailsPro::Utils.bundle_hash
def eval_streaming_js(js_code, render_options)
path = prepare_render_path(js_code, render_options)
ReactOnRailsPro::Request.render_code_as_stream(path, js_code)
end

# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
# From the request path
# path = "/bundles/#{@bundle_hash}/render"
path = "/bundles/#{@bundle_hash}/render/#{render_options.request_digest}"
def eval_js(js_code, render_options, send_bundle: false)
path = prepare_render_path(js_code, render_options)

response = ReactOnRailsPro::Request.render_code(path, js_code, send_bundle)

Expand All @@ -66,14 +68,28 @@ def eval_js(js_code, render_options, send_bundle: false)
raise ReactOnRailsPro::Error,
"Renderer unhandled error at the VM level: #{response.code}:\n#{response.body}"
else
raise ReactOnRailsPro::Error, "Unknown response code from renderer: #{response.code}:\n#{response.body}"
raise ReactOnRailsPro::Error, "Unexpected response code from renderer: #{response.code}:\n#{response.body}"
end
rescue StandardError => e
raise e unless ReactOnRailsPro.configuration.renderer_use_fallback_exec_js

fallback_exec_js(js_code, render_options, e)
end

def prepare_render_path(js_code, render_options)
ReactOnRailsPro::ServerRenderingPool::ProRendering
.set_request_digest_on_render_options(js_code, render_options)

# In case this method is called with simple, raw JS, not depending on the bundle, next line
# is needed.
Comment on lines +83 to +84
Copy link
Contributor

Choose a reason for hiding this comment

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

@justin808 You added this comment in 1bb654c for bundle_utc_timestamp, does it still apply? I don't see why it would be necessary in this case only, @bundle_hash is only set in reset_pool_if_server_bundle_was_modified otherwise.

@bundle_hash ||= ReactOnRailsPro::Utils.bundle_hash

# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
# From the request path
# path = "/bundles/#{@bundle_hash}/render"
"/bundles/#{@bundle_hash}/render/#{render_options.request_digest}"
end

def fallback_exec_js(js_code, render_options, error)
Rails.logger.warn do
"[ReactOnRailsPro] Falling back to ExecJS because of #{error}"
Expand Down
Loading