-
Notifications
You must be signed in to change notification settings - Fork 0
Add support for streaming rendered components using renderToPipeableStream
#407
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
Changes from all commits
a054023
84a9e16
06d40a7
804d96d
f438b7d
6e3485d
7096e28
63ca4cd
16e371e
da1470e
f8eac5c
20b8f28
404a787
0954622
aceaf8c
029186f
85c8140
963bced
846105a
593ea1d
9aa4330
9d9ddd3
771eee9
a9fea71
6dcd38e
3026a37
974eb57
7e11777
60d1391
6cc2b0c
e105c7a
c03e1d2
099e87f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
| command: cd spec/dummy && RAILS_ENV=test rails server | ||||||
| background: true | ||||||
| - run: | ||||||
| name: wait for rails server to start | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| command: | | ||||||
| while ! curl -s http://localhost:3000 > /dev/null; do sleep 1; done | ||||||
| - run: | ||||||
| name: Run rspec tests | ||||||
| command: | | ||||||
|
|
||||||
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @justin808 You added this comment in 1bb654c for |
||
| @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}" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.