diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf7d7a57..c7e320593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ You can find the **package** version numbers from this repo's tags and below in ## [Unreleased] *Add changes in master not yet tagged.* +## [4.0.0-rc.14] - 2025-06-22 + +### Improved +- Improved RSC rendering flow by eliminating double rendering of server components and reducing the number of HTTP requests. +- Updated communication protocol between Node Renderer and Rails to version 2.0.0 which supports the ability to upload multiple bundles at once. +- Added the ability to communicate between different bundles on the renderer by using the `runOnOtherBundle` function which is globally available for the rendering request. + +[PR 515](https://github.com/shakacode/react_on_rails_pro/pull/515) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + + +## [4.0.0-rc.13] - 2025-03-07 + ### Added - 🚀 **Introducing React Server Components Support!** 🎉 - Experience the future of React with full RSC integration diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index 2f9d386cd..a4140f5cd 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.3.7' -gem "react_on_rails", "15.0.0.alpha.2" # keep in sync with package.json files +gem "react_on_rails", "15.0.0.rc.1" # keep in sync with package.json files # For local development # Add the following line to the Gemfile.local file diff --git a/Gemfile.lock b/Gemfile.lock index b01d546de..30d27f0ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -284,7 +284,7 @@ GEM ffi (~> 1.0) rdoc (6.12.0) psych (>= 4.0.0) - react_on_rails (15.0.0.alpha.2) + react_on_rails (15.0.0.rc.1) addressable connection_pool execjs (~> 2.5) @@ -462,7 +462,7 @@ DEPENDENCIES pry-theme puma (~> 6) rails (~> 7.1) - react_on_rails (= 15.0.0.alpha.2) + react_on_rails (= 15.0.0.rc.1) react_on_rails_pro! rspec-rails rspec-retry diff --git a/babel.config.js b/babel.config.js index e6ffbd417..89c9b74d6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], + plugins: ['@babel/plugin-syntax-import-attributes'], }; diff --git a/docs/node-renderer/js-configuration.md b/docs/node-renderer/js-configuration.md index 11460a9cb..ce9641768 100644 --- a/docs/node-renderer/js-configuration.md +++ b/docs/node-renderer/js-configuration.md @@ -17,7 +17,7 @@ Here are the options available for the JavaScript renderer configuration object, 1. **logHttpLevel** (default: `process.env.RENDERER_LOG_HTTP_LEVEL || 'error'`) - The HTTP server log level (same allowed values as `logLevel`). 1. **fastifyServerOptions** (default: `{}`) - Additional options to pass to the Fastify server factory. See [Fastify documentation](https://fastify.dev/docs/latest/Reference/Server/#factory). 1. **bundlePath** (default: `process.env.RENDERER_BUNDLE_PATH || '/tmp/react-on-rails-pro-node-renderer-bundles'` ) - path to a temp directory where uploaded bundle files will be stored. For example you can set it to `path.resolve(__dirname, './.node-renderer-bundles')` if you configured renderer from the `/` directory of your app. -1. **workersCount** (default: `env.RENDERER_WORKERS_COUNT || defaultWorkersCount()` where default is your CPUs count - 1) - Number of workers that will be forked to serve rendering requests. If you set this manually make sure that value is a **Number** and is `>= 1`. +1. **workersCount** (default: `process.env.RENDERER_WORKERS_COUNT || defaultWorkersCount()` where default is your CPUs count - 1) - Number of workers that will be forked to serve rendering requests. If you set this manually make sure that value is a **Number** and is `>= 0`. Setting this to `0` will run the renderer in a single process mode without forking any workers, which is useful for debugging purposes. For production use, the value should be `>= 1`. 1. **password** (default: `env.RENDERER_PASSWORD`) - The password expected to receive from the **Rails client** to authenticate rendering requests. If no password is set, no authentication will be required. 1. **allWorkersRestartInterval** (default: `env.RENDERER_ALL_WORKERS_RESTART_INTERVAL`) - Interval in minutes between scheduled restarts of all workers. By default restarts are not enabled. If restarts are enabled, `delayBetweenIndividualWorkerRestarts` should also be set. @@ -63,10 +63,14 @@ const config = { // All other values are the defaults, as described above }; +// For debugging, run in single process mode +if (process.env.NODE_ENV === 'development') { + config.workersCount = 0; +} // Renderer detects a total number of CPUs on virtual hostings like Heroku or CircleCI instead // of CPUs number allocated for current container. This results in spawning many workers while // only 1-2 of them really needed. -if (process.env.CI) { +else if (process.env.CI) { config.workersCount = 2; } diff --git a/docs/react-server-components-inside-client-components.md b/docs/react-server-components-inside-client-components.md new file mode 100644 index 000000000..62184e53c --- /dev/null +++ b/docs/react-server-components-inside-client-components.md @@ -0,0 +1,332 @@ + +# Using React Server Components Inside Client Components + +React on Rails now supports rendering React Server Components (RSC) directly inside React Client Components. This guide explains how to use this feature effectively in your applications. + +## Overview + +React Server Components provide several benefits.However, React traditionally doesn't allow server components to be directly rendered inside client components. This feature bypasses that limitation. + +> [!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. + +## Basic Usage + +### Before + +Previously, server components could only be embedded inside client components if passed as a prop from a parent server component: + +```tsx +// Parent Server Component +export default function Parent() { + return ( + + + + ); +} +``` + +### After + +Now, you can render server components directly inside client components using the `RSCRoute` component: + +```tsx +'use client'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +export default function ClientComponent() { + return ( +
+ +
+ ); +} +``` + +## Setup Steps + +### 1. Register your server components + +Register your server components in your Server and RSC bundles: + +```tsx +// packs/server_bundle.tsx +import registerServerComponent from 'react-on-rails/registerServerComponent/server.rsc'; +import MyServerComponent from './components/MyServerComponent'; +import AnotherServerComponent from './components/AnotherServerComponent'; + +registerServerComponent({ + MyServerComponent, + AnotherServerComponent +}); +``` + +> [!NOTE] +> Server components only need to be registered in the client bundle if they will be rendered directly in Rails views using the `stream_react_component` helper. If you're only using server components inside client components via `RSCRoute`, you can skip client bundle registration entirely. In this case, it's enough to register the server component in the server and RSC bundles. + +### 2. Create your client component + +Create a client component that uses `RSCRoute` to render server components: + +```tsx +// components/MyClientComponent.tsx +'use client'; +import { useState } from 'react'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +export default function MyClientComponent({ user }) { + return ( +
+

Hello from Client Component

+ +
+ ); +} +``` + +### 3. Wrap your client component + +Create client and server versions of your component wrapped with `wrapServerComponentRenderer`: + +#### Client version: +```tsx +// components/MyClientComponent.client.tsx +'use client'; +import ReactOnRails from 'react-on-rails'; +import wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client'; +import MyClientComponent from './MyClientComponent'; + +const WrappedComponent = wrapServerComponentRenderer(MyClientComponent); + +ReactOnRails.register({ + MyClientComponent: WrappedComponent +}); +``` + +#### Server version: +```tsx +// components/MyClientComponent.server.tsx +import ReactOnRails from 'react-on-rails'; +import wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/server'; +import MyClientComponent from './MyClientComponent'; + +const WrappedComponent = wrapServerComponentRenderer(MyClientComponent); + +ReactOnRails.register({ + MyClientComponent: WrappedComponent +}); +``` + +### 4. Use in Rails view + +```erb +<%= stream_react_component('MyClientComponent', props: { user: current_user.as_json }, prerender: true) %> +``` + +> [!NOTE] +> You must use `stream_react_component` rather than `react_component` for server components or client components that use server components. + +## Use Cases and Examples + +### ❌ Bad Example - Frequently Changing Props + +```tsx +'use client'; +import { useState } from 'react'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +export default function ClientComponent() { + const [count, setCount] = useState(0); + + return ( +
+ + + {/* BAD EXAMPLE: Server Component props change with each button click */} + +
+ ); +} +``` + +> [!WARNING] +> This implementation will make a server request on every state change, significantly impacting performance. + +### ✅ Good Example - Router Integration + +```tsx +'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 }) { + return ( + <> + + + {/* Mix client and server components in your router */} + } /> + {/* GOOD EXAMPLE: Server Component props rarely change */} + } /> + } /> + + + ); +} +``` + +## Advanced Usage + +### Nested Routes with Server Components + +The framework supports nesting client and server components to arbitrary depth: + +```tsx +'use client'; +import { Routes, Route } from 'react-router-dom'; +import RSCRoute from 'react-on-rails/RSCRoute'; +import ServerRouteLayout from './ServerRouteLayout'; +import ClientRouteLayout from './ClientRouteLayout'; + +export default function AppRouter() { + return ( + + }> + } /> + } /> + + }> + } /> + } /> + + + ); +} +``` + +### Using `Outlet` in Server Components + +To use React Router's `Outlet` in server components, create a client version: + +```tsx +// ./components/Outlet.tsx +'use client'; +export { Outlet as default } from 'react-router-dom'; +``` + +Then use it in your server components: + +```tsx +// ./components/ServerRouteLayout.tsx +import Outlet from './Outlet'; + +export default function ServerRouteLayout() { + return ( +
+

Server Route Layout

+ +
+ ); +} +``` + +## Auto-Loading Bundles + +If you're using the `auto_load_bundle: true` option in your React on Rails configuration, you don't need to manually register components using `ReactOnRails.register`. However, you still need to: + +1. Create both client and server wrappers for your components +2. Properly import the environment-specific implementations of `wrapServerComponentRenderer` + +## Component Lifecycle + +When using server components inside client components: + +1. **During Initial SSR**: + - The server component is rendered on the server + - Its payload is embedded directly in the HTML response + - No additional HTTP requests are needed for hydration + +2. **During Client Navigation**: + - When a user navigates to a new route client-side + - The client makes an HTTP request to fetch the server component payload + - The route is rendered with the fetched server component + +3. **During State Changes**: + - If a server component's props change, a new HTTP request is made + - The component is re-rendered with the new props + - This is why you should avoid frequently changing props + +## Performance Considerations + +- Page responsiveness is improved because RSC payloads are embedded in the HTML and no additional HTTP requests are needed for hydration +- Client navigation to new routes with server components requires an HTTP request +- Avoid changing server component props frequently +- Consider using suspense boundaries for loading states during navigation + +## Common Patterns + +### Using a Loading State + +```tsx +'use client'; +import { Suspense } from 'react'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +export default function ClientComponent({ user }) { + return ( +
+ Loading server component...
}> + + + + ); +} +``` + +### Conditional Rendering + +```tsx +'use client'; +import { useState } from 'react'; +import { Suspense } from 'react'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +export default function ClientComponent({ user }) { + const [showServerComponent, setShowServerComponent] = useState(false); + + return ( +
+ + + {showServerComponent && ( + Loading...
}> + + + )} + + ); +} +``` + +> [!NOTE] +> When conditionally rendering server components, an HTTP request will be made when the component becomes visible. + +## Best Practices + +1. **Use for rarely changing components**: Server components are ideal for routes, layouts, and content that doesn't change frequently. + +2. **Always wrap in Suspense**: Server components may load asynchronously, especially after client navigation. + +3. **Pass stable props**: Avoid passing state variables that change frequently as props to server components. + +4. **Use for data-heavy components**: Components that need to fetch data from databases or APIs are good candidates for server components. + +By following these guidelines, you can effectively leverage React Server Components while maintaining optimal performance. diff --git a/docs/react-server-components-rendering-flow.md b/docs/react-server-components-rendering-flow.md index 4048171d3..d3877d3c4 100644 --- a/docs/react-server-components-rendering-flow.md +++ b/docs/react-server-components-rendering-flow.md @@ -24,92 +24,35 @@ In a React Server Components project, there are three distinct types of bundles: - Code splitting occurs automatically for client components - Chunks are loaded on-demand during client component hydration -## Current React Server Component Rendering Flow +## React Server Component Rendering Flow -When a request is made to a page using React Server Components, the following sequence occurs: +When a request is made to a page using React Server Components, the following optimized sequence occurs: -1. Initial HTML Generation: +1. Initial Request Processing: - The `stream_react_component` helper is called in the view - Makes a request to the node renderer - - Renderer uses the **Server Bundle** to generate HTML for all components - - HTML is streamed to the client - -2. RSC Payload Generation: - - Browser shows the initial html - - Browser makes a separate fetch request to the RSC payload URL - - Calls `rsc_payload_react_component` on the server - - Node renderer uses the **RSC Bundle** to generate the RSC payload - - Server components are rendered and serialized - - Client components are included as references + - Server bundle's rendering function calls `generateRSCPayload` with the component name and props + - This executes the component rendering in the RSC bundle + - RSC bundle generates the payload containing server component data and client component references + - The payload is returned to the server bundle + +2. Server-Side Rendering with RSC Payload: + - The server bundle uses the RSC payload to generate HTML for server components using `RSCServerRoot` + - `RSCServerRoot` splits the RSC payload stream into two parts: + - One stream for rendering server components as HTML + - Another stream for embedding the RSC payload in the response + - `RSCPayloadContainer` component embeds the RSC payload within the HTML response + - HTML and embedded RSC payload are streamed together to the client 3. Client Hydration: - - RSC payload is processed by React runtime - - Client component chunks are fetched based on references - - Components are hydrated progressively - -## Current React Server Component Rendering Limitations -_See open PRs. Active development will soon solve these limitations_ - -This approach has two main inefficiencies: - -1. **Double Rendering**: Server components are rendered twice: - - Once for HTML generation using the server bundle - - Again, for RSC payload generation using the RSC bundle - -2. **Multiple Requests**: Requires two separate HTTP requests: - - Initial request for HTML - - Secondary request for RSC payload - -```mermaid -sequenceDiagram - participant Browser - participant RailsView - participant NodeRenderer - participant ServerBundle - participant RSCBundle - - Note over Browser,RSCBundle: 1. Initial HTML Generation - Browser->>RailsView: Request page - RailsView->>NodeRenderer: stream_react_component - NodeRenderer->>ServerBundle: Generate HTML - ServerBundle-->>NodeRenderer: HTML for all components - NodeRenderer-->>Browser: Stream HTML - - Note over Browser,RSCBundle: 2. RSC Payload Generation - Browser->>NodeRenderer: Fetch RSC payload - NodeRenderer->>RSCBundle: rsc_payload_react_component - RSCBundle-->>NodeRenderer: RSC payload with:
- Server components
- Client component refs - NodeRenderer-->>Browser: Stream RSC payload - - Note over Browser: 3. Client Hydration - Browser->>Browser: Process RSC payload - loop For each client component - Browser->>Browser: Fetch component chunk - Browser->>Browser: Hydrate component - end -``` - -> [!NOTE] -> For simplicity, this diagram shows the RSC payload being fetched after the HTML is fully streamed to the client. In reality, the browser begins fetching the RSC payload and starts hydration immediately as soon as it receives the necessary HTML, without waiting for the complete page to be streamed. This parallel processing enables faster page interactivity and better performance. - -## Future Improvements - -These inefficiencies will be addressed in the upcoming ["Use RSC payload to render server components on server"](https://github.com/shakacode/react_on_rails_pro/pull/515) PR. The new flow will be: - -1. Initial Request: - - `stream_react_component` triggers node renderer - - Renderer uses **RSC Bundle** to generate RSC payload - - Payload is passed to the rendering function in **Server Bundle** - - HTML of server components is generated using RSC payload - - Client component references are filled with HTML of the client components - -2. Single Response: - - HTML and RSC payload are streamed together, with the RSC payload embedded inside the HTML page - Browser displays HTML immediately - - React runtime uses embedded RSC payload for hydration - - Client components are hydrated progressively + - React runtime uses the embedded RSC payload for hydration + - Client components are hydrated progressively without requiring a separate HTTP request -This improved approach eliminates double rendering and reduces HTTP requests, resulting in better performance and resource utilization. +This approach offers significant advantages: +- Eliminates double rendering of server components +- Reduces HTTP requests by embedding the RSC payload within the initial HTML response +- Provides faster interactivity through streamlined rendering and hydration ```mermaid sequenceDiagram @@ -122,9 +65,9 @@ sequenceDiagram Note over Browser,ServerBundle: 1. Initial Request Browser->>RailsView: Request page RailsView->>NodeRenderer: stream_react_component - NodeRenderer->>RSCBundle: Generate RSC payload - RSCBundle-->>NodeRenderer: RSC payload - NodeRenderer->>ServerBundle: Pass RSC payload + NodeRenderer->>ServerBundle: Execute rendering request + ServerBundle->>RSCBundle: generateRSCPayload(component, props) + RSCBundle-->>ServerBundle: RSC payload with:
- Server components
- Client component refs ServerBundle-->>NodeRenderer: Generate HTML using RSC payload Note over Browser,ServerBundle: 2. Single Response @@ -137,3 +80,7 @@ sequenceDiagram Browser->>Browser: Hydrate component end ``` + +## Next Steps + +To learn more about how to render React Server Components inside client components, see [React Server Components Inside Client Components](react-server-components-inside-client-components.md). diff --git a/docs/react-server-components-tutorial.md b/docs/react-server-components-tutorial.md index 838d8b48b..777a96ab7 100644 --- a/docs/react-server-components-tutorial.md +++ b/docs/react-server-components-tutorial.md @@ -14,4 +14,6 @@ This tutorial will guide you through learning [React Server Components (RSC)](ht 6. [React Server Components Rendering Flow](react-server-components-rendering-flow.md) - Understand the detailed rendering flow of RSC, including bundle types, current limitations, and future improvements. +7. [React Server Components Inside Client Components](react-server-components-inside-client-components.md) - Learn how to render server components inside client components. + Each part of the tutorial builds on the concepts from previous sections, so it's recommended to follow them in order. Let's begin with creating your first React Server Component! diff --git a/eslint.config.mjs b/eslint.config.mjs index b01d4a43a..7dc0c1515 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,7 @@ export default defineConfig([ '**/vendor/', '**/dist/', '**/.yalc/', + '**/*.chunk.js', ]), { files: ['**/*.[jt]s', '**/*.[cm][jt]s', '**/*.[jt]sx'], diff --git a/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb b/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb index 0a3691cd8..7444187f4 100644 --- a/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb +++ b/lib/react_on_rails_pro/concerns/rsc_payload_renderer.rb @@ -22,6 +22,8 @@ def rsc_payload private def rsc_payload_component_props + return {} if params[:props].blank? + JSON.parse(params[:props]) end diff --git a/lib/react_on_rails_pro/configuration.rb b/lib/react_on_rails_pro/configuration.rb index dbed6c040..3f4b541fe 100644 --- a/lib/react_on_rails_pro/configuration.rb +++ b/lib/react_on_rails_pro/configuration.rb @@ -33,7 +33,7 @@ def self.configuration ) end - class Configuration + class Configuration # rubocop:disable Metrics/ClassLength DEFAULT_RENDERER_URL = "http://localhost:3800" DEFAULT_RENDERER_METHOD = "ExecJS" DEFAULT_RENDERER_FALLBACK_EXEC_JS = true @@ -103,6 +103,18 @@ def setup_config_values setup_renderer_password setup_assets_to_copy setup_execjs_profiler_if_needed + check_react_on_rails_support_for_rsc + end + + def check_react_on_rails_support_for_rsc + return unless enable_rsc_support + + return if ReactOnRails::Utils.respond_to?(:rsc_support_enabled?) + + raise ReactOnRailsPro::Error, <<~MSG + React Server Components (RSC) support requires react_on_rails version 15.0.0 or higher. + Please upgrade your react_on_rails gem to enable this feature. + MSG end def setup_execjs_profiler_if_needed diff --git a/lib/react_on_rails_pro/error.rb b/lib/react_on_rails_pro/error.rb index ece625f81..81b1fca81 100644 --- a/lib/react_on_rails_pro/error.rb +++ b/lib/react_on_rails_pro/error.rb @@ -4,5 +4,11 @@ module ReactOnRailsPro class Error < ::ReactOnRails::Error + def self.raise_duplicate_bundle_upload_error + raise ReactOnRailsPro::Error, + "The bundle has already been uploaded, " \ + "but the server is still sending the send_bundle status code. " \ + "This is unexpected behavior." + end end end diff --git a/lib/react_on_rails_pro/request.rb b/lib/react_on_rails_pro/request.rb index 7cf24fbf6..e4883f4fa 100644 --- a/lib/react_on_rails_pro/request.rb +++ b/lib/react_on_rails_pro/request.rb @@ -14,7 +14,7 @@ def reset_connection def render_code(path, js_code, send_bundle) Rails.logger.info { "[ReactOnRailsPro] Perform rendering request #{path}" } - form = form_with_code(js_code, send_bundle, is_rsc_payload: false) + form = form_with_code(js_code, send_bundle) perform_request(path, form: form) end @@ -28,27 +28,55 @@ def render_code_as_stream(path, js_code, is_rsc_payload:) end ReactOnRailsPro::StreamRequest.create do |send_bundle| - form = form_with_code(js_code, send_bundle, is_rsc_payload: is_rsc_payload) + form = form_with_code(js_code, send_bundle) perform_request(path, form: form, stream: true) end end def upload_assets Rails.logger.info { "[ReactOnRailsPro] Uploading assets" } - perform_request("/upload-assets", form: form_with_assets_and_bundle) - return unless ReactOnRailsPro.configuration.enable_rsc_support + # Check if server bundle exists before trying to upload assets + server_bundle_path = ReactOnRails::Utils.server_bundle_js_file_path + unless File.exist?(server_bundle_path) + raise ReactOnRailsPro::Error, "Server bundle not found at #{server_bundle_path}. " \ + "Please build your bundles before uploading assets." + end - perform_request("/upload-assets", form: form_with_assets_and_bundle(is_rsc_payload: true)) - # Explicitly return nil to ensure consistent return value regardless of whether - # enable_rsc_support is true or false. Without this, the method would return nil - # when RSC is disabled but return the response object when RSC is enabled. - nil + # Create a list of bundle timestamps to send to the node renderer + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + target_bundles = [pool.server_bundle_hash] + + # Add RSC bundle if enabled + if ReactOnRailsPro.configuration.enable_rsc_support + rsc_bundle_path = ReactOnRails::Utils.rsc_bundle_js_file_path + unless File.exist?(rsc_bundle_path) + raise ReactOnRailsPro::Error, "RSC bundle not found at #{rsc_bundle_path}. " \ + "Please build your bundles before uploading assets." + end + target_bundles << pool.rsc_bundle_hash + end + + form = form_with_assets_and_bundle + form["targetBundles"] = target_bundles + + perform_request("/upload-assets", form: form) end def asset_exists_on_vm_renderer?(filename) Rails.logger.info { "[ReactOnRailsPro] Sending request to check if file exist on node-renderer: #{filename}" } - response = perform_request("/asset-exists?filename=#{filename}", json: common_form_data) + + form_data = common_form_data + + # Add targetBundles from the current bundle hash and RSC bundle hash + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + target_bundles = [pool.server_bundle_hash] + + target_bundles << pool.rsc_bundle_hash if ReactOnRailsPro.configuration.enable_rsc_support + + form_data["targetBundles"] = target_bundles + + response = perform_request("/asset-exists?filename=#{filename}", json: form_data) JSON.parse(response.body)["exists"] == true end @@ -58,19 +86,6 @@ def connection @connection ||= create_connection end - def rsc_connection - @rsc_connection ||= begin - unless ReactOnRailsPro.configuration.enable_rsc_support - raise ReactOnRailsPro::Error, - "RSC support is not enabled. Please set enable_rsc_support to true in your " \ - "config/initializers/react_on_rails_pro.rb file before " \ - "rendering any RSC payload." - end - - create_connection - end - end - def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit retry_request = true @@ -118,42 +133,54 @@ def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metr response end - def form_with_code(js_code, send_bundle, is_rsc_payload:) + def form_with_code(js_code, send_bundle) form = common_form_data form["renderingRequest"] = js_code - populate_form_with_bundle_and_assets(form, is_rsc_payload: is_rsc_payload, check_bundle: false) if send_bundle + populate_form_with_bundle_and_assets(form, check_bundle: false) if send_bundle form end - def populate_form_with_bundle_and_assets(form, is_rsc_payload:, check_bundle:) - server_bundle_path = if is_rsc_payload - ReactOnRails::Utils.rsc_bundle_js_file_path - else - ReactOnRails::Utils.server_bundle_js_file_path - end - if check_bundle && !File.exist?(server_bundle_path) - raise ReactOnRailsPro::Error, "Bundle not found #{server_bundle_path}" + def populate_form_with_bundle_and_assets(form, check_bundle:) + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + + add_bundle_to_form( + form, + bundle_path: ReactOnRails::Utils.server_bundle_js_file_path, + bundle_file_name: pool.renderer_bundle_file_name, + bundle_hash: pool.server_bundle_hash, + check_bundle: check_bundle + ) + + if ReactOnRailsPro.configuration.enable_rsc_support + add_bundle_to_form( + form, + bundle_path: ReactOnRails::Utils.rsc_bundle_js_file_path, + bundle_file_name: pool.rsc_renderer_bundle_file_name, + bundle_hash: pool.rsc_bundle_hash, + check_bundle: check_bundle + ) end - pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool - renderer_bundle_file_name = if is_rsc_payload - pool.rsc_renderer_bundle_file_name - else - pool.renderer_bundle_file_name - end - form["bundle"] = { - body: get_form_body_for_file(server_bundle_path), + add_assets_to_form(form) + end + + def add_bundle_to_form(form, bundle_path:, bundle_file_name:, bundle_hash:, check_bundle:) + raise ReactOnRailsPro::Error, "Bundle not found #{bundle_path}" if check_bundle && !File.exist?(bundle_path) + + form["bundle_#{bundle_hash}"] = { + body: get_form_body_for_file(bundle_path), content_type: "text/javascript", - filename: renderer_bundle_file_name + filename: bundle_file_name } - - add_assets_to_form(form, is_rsc_payload: is_rsc_payload) end - def add_assets_to_form(form, is_rsc_payload:) - assets_to_copy = ReactOnRailsPro.configuration.assets_to_copy || [] - # react_client_manifest file is needed to generate react server components payload - assets_to_copy << ReactOnRails::Utils.react_client_manifest_file_path if is_rsc_payload + def add_assets_to_form(form) + assets_to_copy = (ReactOnRailsPro.configuration.assets_to_copy || []).dup + # react_client_manifest and react_server_manifest files are needed to generate react server components payload + if ReactOnRailsPro.configuration.enable_rsc_support + assets_to_copy << ReactOnRails::Utils.react_client_manifest_file_path + assets_to_copy << ReactOnRails::Utils.react_server_client_manifest_file_path + end return form unless assets_to_copy.present? @@ -180,18 +207,14 @@ def add_assets_to_form(form, is_rsc_payload:) form end - def form_with_assets_and_bundle(is_rsc_payload: false) + def form_with_assets_and_bundle form = common_form_data - populate_form_with_bundle_and_assets(form, is_rsc_payload: is_rsc_payload, check_bundle: true) + populate_form_with_bundle_and_assets(form, check_bundle: true) form end def common_form_data - { - "gemVersion" => ReactOnRailsPro::VERSION, - "protocolVersion" => "1.0.0", - "password" => ReactOnRailsPro.configuration.renderer_password - } + ReactOnRailsPro::Utils.common_form_data end def create_connection diff --git a/lib/react_on_rails_pro/server_rendering_js_code.rb b/lib/react_on_rails_pro/server_rendering_js_code.rb index f12a84650..a9fb08c06 100644 --- a/lib/react_on_rails_pro/server_rendering_js_code.rb +++ b/lib/react_on_rails_pro/server_rendering_js_code.rb @@ -7,35 +7,101 @@ def ssr_pre_hook_js ReactOnRailsPro.configuration.ssr_pre_hook_js || "" end + # Generates the JavaScript function used for React Server Components payload generation + # Returns the JavaScript code that defines the generateRSCPayload function. + # It also adds necessary information to the railsContext to generate the RSC payload for any component in the app. + # @return [String] JavaScript code for RSC payload generation + def generate_rsc_payload_js_function(render_options) + return "" unless ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming? + + if render_options.rsc_payload_streaming? + # When already on RSC bundle, we prevent further RSC payload generation + # by throwing an error if generateRSCPayload is called + return <<-JS + if (typeof generateRSCPayload !== 'function') { + globalThis.generateRSCPayload = function generateRSCPayload() { + throw new Error('The rendering request is already running on the RSC bundle. Please ensure that generateRSCPayload is only called from any React Server Component.') + } + } + JS + end + + # To minimize the size of the HTTP request body sent to the node renderer, + # we reuse the existing rendering request string within the generateRSCPayload function. + # This approach allows us to simply replace the component name and props, + # rather than rewriting the entire rendering request. + # This regex finds the empty function call pattern `()` and replaces it with the component and props + <<-JS + railsContext.serverSideRSCPayloadParameters = { + renderingRequest, + rscBundleHash: '#{ReactOnRailsPro::Utils.rsc_bundle_hash}', + } + if (typeof generateRSCPayload !== 'function') { + globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { + const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; + const propsString = JSON.stringify(props); + const newRenderingRequest = renderingRequest.replace(/\\(\\s*\\)\\s*$/, `('${componentName}', ${propsString})`); + return runOnOtherBundle(rscBundleHash, newRenderingRequest); + } + } + JS + end + + def add_component_specific_metadata(render_options) + # If RSC support is not enabled, no render request id is available + return "" unless ReactOnRailsPro.configuration.enable_rsc_support && render_options.render_request_id + + <<-JS + railsContext.componentSpecificMetadata = {renderRequestId: '#{render_options.render_request_id}'}; + JS + end + + # Main rendering function that generates JavaScript code for server-side rendering + # @param props_string [String] JSON string of props to pass to the React component + # @param rails_context [String] JSON string of Rails context data + # @param redux_stores [String] JavaScript code for Redux stores initialization + # @param react_component_name [String] Name of the React component to render + # @param render_options [Object] Options that control the rendering behavior + # @return [String] JavaScript code that will render the React component on the server def render(props_string, rails_context, redux_stores, react_component_name, render_options) - render_function_name = if render_options.rsc_payload_streaming? - "serverRenderRSCReactComponent" - elsif render_options.html_streaming? - "streamServerRenderedReactComponent" - else - "serverRenderReactComponent" - end - rsc_props_if_rsc_request = if render_options.rsc_payload_streaming? - manifest_file = ReactOnRails.configuration.react_client_manifest_file - "reactClientManifestFileName: '#{manifest_file}'," - else - "" - end + render_function_name = + if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming? + # Select appropriate function based on whether the rendering request is running on server or rsc bundle + # As the same rendering request is used to generate the rsc payload and SSR the component. + "ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent'" + else + "'serverRenderReactComponent'" + end + rsc_params = if ReactOnRailsPro.configuration.enable_rsc_support && render_options.streaming? + react_client_manifest_file = ReactOnRails.configuration.react_client_manifest_file + react_server_client_manifest_file = ReactOnRails.configuration.react_server_client_manifest_file + <<-JS + railsContext.reactClientManifestFileName = '#{react_client_manifest_file}'; + railsContext.reactServerClientManifestFileName = '#{react_server_client_manifest_file}'; + JS + else + "" + end + + # This function is called with specific componentName and props when generateRSCPayload is invoked + # In that case, it replaces the empty () with ('componentName', props) in the rendering request <<-JS - (function() { + (function(componentName = '#{react_component_name}', props = undefined) { var railsContext = #{rails_context}; - #{ssr_pre_hook_js} - #{redux_stores} - var props = #{props_string}; - return ReactOnRails.#{render_function_name}({ - name: '#{react_component_name}', + #{add_component_specific_metadata(render_options)} + #{rsc_params} + #{generate_rsc_payload_js_function(render_options)} + #{ssr_pre_hook_js} + #{redux_stores} + var usedProps = typeof props === 'undefined' ? #{props_string} : props; + return ReactOnRails[#{render_function_name}]({ + name: componentName, domNodeId: '#{render_options.dom_id}', - props: props, + props: usedProps, trace: #{render_options.trace}, railsContext: railsContext, throwJsErrors: #{ReactOnRailsPro.configuration.throw_js_errors}, renderingReturnsPromises: #{ReactOnRailsPro.configuration.rendering_returns_promises}, - #{rsc_props_if_rsc_request} }); })() JS diff --git a/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb b/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb index e7afea819..c2f7a9949 100644 --- a/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb +++ b/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb @@ -57,7 +57,7 @@ def eval_streaming_js(js_code, render_options) ReactOnRailsPro::Request.render_code_as_stream( path, js_code, - is_rsc_payload: render_options.rsc_payload_streaming? + is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming? ) end @@ -70,6 +70,9 @@ def eval_js(js_code, render_options, send_bundle: false) when 200 response.body when ReactOnRailsPro::STATUS_SEND_BUNDLE + # To prevent infinite loop + ReactOnRailsPro::Error.raise_duplicate_bundle_upload_error if send_bundle + eval_js(js_code, render_options, send_bundle: true) when 400 raise ReactOnRailsPro::Error, @@ -96,7 +99,8 @@ def prepare_render_path(js_code, render_options) ReactOnRailsPro::ServerRenderingPool::ProRendering .set_request_digest_on_render_options(js_code, render_options) - is_rendering_rsc_payload = render_options.rsc_payload_streaming? + rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support + is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming? bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash # TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119 # From the request path diff --git a/lib/react_on_rails_pro/stream_request.rb b/lib/react_on_rails_pro/stream_request.rb index 453dd61b4..f70105fae 100644 --- a/lib/react_on_rails_pro/stream_request.rb +++ b/lib/react_on_rails_pro/stream_request.rb @@ -70,8 +70,8 @@ def initialize(&request_block) private_class_method :new - def each_chunk - return enum_for(:each_chunk) unless block_given? + def each_chunk(&block) + return enum_for(:each_chunk) unless block send_bundle = false error_body = +"" @@ -82,27 +82,37 @@ def each_chunk # Also, we check the status code inside the loop block because calling `status` outside the loop block # is blocking, it will wait for the response to be fully received # Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details - loop_response_lines(stream_response) do |chunk| - if stream_response.status >= 400 - error_body << chunk - next - end - - processed_chunk = chunk.strip - yield processed_chunk unless processed_chunk.empty? - end + process_response_chunks(stream_response, error_body, &block) break rescue HTTPX::HTTPError => e - response = e.response - case response.status - when ReactOnRailsPro::STATUS_SEND_BUNDLE - send_bundle = true + send_bundle = handle_http_error(e, error_body, send_bundle) + end + end + + def process_response_chunks(stream_response, error_body) + loop_response_lines(stream_response) do |chunk| + if !stream_response.respond_to?(:status) || stream_response.status >= 400 + error_body << chunk next - when ReactOnRailsPro::STATUS_INCOMPATIBLE - raise ReactOnRailsPro::Error, error_body - else - raise ReactOnRailsPro::Error, "Unexpected response code from renderer: #{response.status}:\n#{error_body}" end + + processed_chunk = chunk.strip + yield processed_chunk unless processed_chunk.empty? + end + end + + def handle_http_error(error, error_body, send_bundle) + response = error.response + case response.status + when ReactOnRailsPro::STATUS_SEND_BUNDLE + # To prevent infinite loop + ReactOnRailsPro::Error.raise_duplicate_bundle_upload_error if send_bundle + + true + when ReactOnRailsPro::STATUS_INCOMPATIBLE + raise ReactOnRailsPro::Error, error_body + else + raise ReactOnRailsPro::Error, "Unexpected response code from renderer: #{response.status}:\n#{error_body}" end end diff --git a/lib/react_on_rails_pro/utils.rb b/lib/react_on_rails_pro/utils.rb index 6f80818ce..e1c7440cb 100644 --- a/lib/react_on_rails_pro/utils.rb +++ b/lib/react_on_rails_pro/utils.rb @@ -130,10 +130,16 @@ def self.with_trace(message = nil) end def self.common_form_data + dependencies = if ReactOnRailsPro.configuration.enable_rsc_support + pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool + [pool.rsc_bundle_hash, pool.server_bundle_hash] + end + { "gemVersion" => ReactOnRailsPro::VERSION, - "protocolVersion" => "1.0.0", - "password" => ReactOnRailsPro.configuration.renderer_password + "protocolVersion" => ReactOnRailsPro::PROTOCOL_VERSION, + "password" => ReactOnRailsPro.configuration.renderer_password, + "dependencyBundleTimestamps" => dependencies } end diff --git a/lib/react_on_rails_pro/version.rb b/lib/react_on_rails_pro/version.rb index ead7e15a4..bb6e4da9a 100644 --- a/lib/react_on_rails_pro/version.rb +++ b/lib/react_on_rails_pro/version.rb @@ -2,5 +2,5 @@ module ReactOnRailsPro VERSION = "4.0.0.rc.13" - PROTOCOL_VERSION = "1.0.0" + PROTOCOL_VERSION = "2.0.0" end diff --git a/package.json b/package.json index 1280afaec..d6f47da45 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@shakacode-tools/react-on-rails-pro-node-renderer", "version": "4.0.0-rc.13", - "protocolVersion": "1.0.0", + "protocolVersion": "2.0.0", "description": "react-on-rails-pro JavaScript for react_on_rails_pro Ruby gem", "exports": { ".": { @@ -49,6 +49,7 @@ "devDependencies": { "@babel/core": "^7.26.10", "@babel/eslint-parser": "^7.27.0", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.27.0", @@ -84,7 +85,8 @@ "nps": "^5.9.12", "pino-pretty": "^13.0.0", "prettier": "^3.2.5", - "react-on-rails": "15.0.0-alpha.2", + "react-on-rails": "15.0.0-rc.1", + "redis": "^5.0.1", "release-it": "^17.6.0", "sentry-testkit": "^5.0.6", "touch": "^3.1.0", diff --git a/packages/node-renderer/src/ReactOnRailsProNodeRenderer.ts b/packages/node-renderer/src/ReactOnRailsProNodeRenderer.ts index 4ae166ec9..a5b27b048 100644 --- a/packages/node-renderer/src/ReactOnRailsProNodeRenderer.ts +++ b/packages/node-renderer/src/ReactOnRailsProNodeRenderer.ts @@ -1,6 +1,6 @@ import cluster from 'cluster'; import { version as fastifyVersion } from 'fastify/package.json'; -import { Config } from './shared/configBuilder'; +import { Config, buildConfig } from './shared/configBuilder'; import log from './shared/log'; import { majorVersion } from './shared/utils'; @@ -26,13 +26,21 @@ and for "@fastify/..." dependencies in your package.json. Consider removing them ); } + const { workersCount } = buildConfig(config); /* eslint-disable global-require,@typescript-eslint/no-require-imports -- * Using normal `import` fails before the check above. */ - if (cluster.isPrimary) { - (require('./master') as typeof import('./master'))(config); + const isSingleProcessMode = workersCount === 0; + if (isSingleProcessMode || cluster.isWorker) { + if (isSingleProcessMode) { + log.info('Running renderer in single process mode (workersCount: 0)'); + } + + const worker = require('./worker') as typeof import('./worker'); + await worker.default(config).ready(); } else { - await (require('./worker') as typeof import('./worker')).default(config).ready(); + const master = require('./master') as typeof import('./master'); + master(config); } /* eslint-enable global-require,@typescript-eslint/no-require-imports */ } diff --git a/packages/node-renderer/src/shared/configBuilder.ts b/packages/node-renderer/src/shared/configBuilder.ts index 0b6499435..30c7bef8d 100644 --- a/packages/node-renderer/src/shared/configBuilder.ts +++ b/packages/node-renderer/src/shared/configBuilder.ts @@ -152,8 +152,7 @@ const defaultConfig: Config = { additionalContext: null, // Workers count defaults to number of CPUs minus 1 - workersCount: - (env.RENDERER_WORKERS_COUNT && parseInt(env.RENDERER_WORKERS_COUNT, 10)) || defaultWorkersCount(), + workersCount: env.RENDERER_WORKERS_COUNT ? parseInt(env.RENDERER_WORKERS_COUNT, 10) : defaultWorkersCount(), // No default for password, means no auth password: env.RENDERER_PASSWORD, diff --git a/packages/node-renderer/src/shared/utils.ts b/packages/node-renderer/src/shared/utils.ts index 3ee24d212..4f6babc05 100644 --- a/packages/node-renderer/src/shared/utils.ts +++ b/packages/node-renderer/src/shared/utils.ts @@ -1,7 +1,7 @@ import cluster from 'cluster'; import path from 'path'; import { MultipartFile } from '@fastify/multipart'; -import { createWriteStream, ensureDir, move, MoveOptions } from 'fs-extra'; +import { createWriteStream, ensureDir, move, MoveOptions, copy, CopyOptions, unlink } from 'fs-extra'; import { Readable, pipeline, PassThrough } from 'stream'; import { promisify } from 'util'; import * as errorReporter from './errorReporter'; @@ -98,15 +98,31 @@ export function moveUploadedAsset( return move(asset.savedFilePath, destinationPath, options); } -export async function moveUploadedAssets(uploadedAssets: Asset[]): Promise { - const { bundlePath } = getConfig(); +export function copyUploadedAsset( + asset: Asset, + destinationPath: string, + options: CopyOptions = {}, +): Promise { + return copy(asset.savedFilePath, destinationPath, options); +} - const moveMultipleAssets = uploadedAssets.map((asset) => { - const destinationAssetFilePath = path.join(bundlePath, asset.filename); - return moveUploadedAsset(asset, destinationAssetFilePath, { overwrite: true }); +export async function copyUploadedAssets(uploadedAssets: Asset[], targetDirectory: string): Promise { + const copyMultipleAssets = uploadedAssets.map((asset) => { + const destinationAssetFilePath = path.join(targetDirectory, asset.filename); + return copyUploadedAsset(asset, destinationAssetFilePath, { overwrite: true }); }); - await Promise.all(moveMultipleAssets); - log.info(`Moved assets ${JSON.stringify(uploadedAssets.map((fileDescriptor) => fileDescriptor.filename))}`); + await Promise.all(copyMultipleAssets); + log.info( + `Copied assets ${JSON.stringify(uploadedAssets.map((fileDescriptor) => fileDescriptor.filename))}`, + ); +} + +export async function deleteUploadedAssets(uploadedAssets: Asset[]): Promise { + const deleteMultipleAssets = uploadedAssets.map((asset) => unlink(asset.savedFilePath)); + await Promise.all(deleteMultipleAssets); + log.info( + `Deleted assets ${JSON.stringify(uploadedAssets.map((fileDescriptor) => fileDescriptor.filename))}`, + ); } export function isPromise(value: T | Promise): value is Promise { @@ -137,3 +153,18 @@ export const delay = (milliseconds: number) => new Promise((resolve) => { setTimeout(resolve, milliseconds); }); + +export function getBundleDirectory(bundleTimestamp: string | number) { + const { bundlePath } = getConfig(); + return path.join(bundlePath, `${bundleTimestamp}`); +} + +export function getRequestBundleFilePath(bundleTimestamp: string | number) { + const bundleDirectory = getBundleDirectory(bundleTimestamp); + return path.join(bundleDirectory, `${bundleTimestamp}.js`); +} + +export function getAssetPath(bundleTimestamp: string | number, filename: string) { + const bundleDirectory = getBundleDirectory(bundleTimestamp); + return path.join(bundleDirectory, filename); +} diff --git a/packages/node-renderer/src/worker.ts b/packages/node-renderer/src/worker.ts index 83dd4c337..942720165 100644 --- a/packages/node-renderer/src/worker.ts +++ b/packages/node-renderer/src/worker.ts @@ -5,6 +5,7 @@ import path from 'path'; import cluster from 'cluster'; +import { mkdir } from 'fs/promises'; import fastify from 'fastify'; import fastifyFormbody from '@fastify/formbody'; import fastifyMultipart from '@fastify/multipart'; @@ -15,15 +16,18 @@ import fileExistsAsync from './shared/fileExistsAsync'; import type { FastifyInstance, FastifyReply, FastifyRequest } from './worker/types'; import checkProtocolVersion from './worker/checkProtocolVersionHandler'; import authenticate from './worker/authHandler'; -import handleRenderRequest from './worker/handleRenderRequest'; +import { handleRenderRequest, type ProvidedNewBundle } from './worker/handleRenderRequest'; import { errorResponseResult, formatExceptionMessage, - moveUploadedAssets, + copyUploadedAssets, ResponseResult, workerIdLabel, saveMultipartFile, Asset, + getAssetPath, + getBundleDirectory, + deleteUploadedAssets, } from './shared/utils'; import * as errorReporter from './shared/errorReporter'; import { lock, unlock } from './shared/locks'; @@ -78,6 +82,12 @@ const setResponse = async (result: ResponseResult, res: FastifyReply) => { const isAsset = (value: unknown): value is Asset => (value as { type?: string }).type === 'asset'; +function assertAsset(value: unknown, key: string): asserts value is Asset { + if (!isAsset(value)) { + throw new Error(`React On Rails Error: Expected an asset for key: ${key}`); + } +} + // Remove after this issue is resolved: https://github.com/fastify/light-my-request/issues/315 let useHttp2 = true; @@ -86,12 +96,28 @@ export const disableHttp2 = () => { useHttp2 = false; }; +type WithBodyArrayField = T & { [P in K | `${K}[]`]?: string | string[] }; + +const extractBodyArrayField = ( + body: WithBodyArrayField, Key>, + key: Key, +): string[] | undefined => { + const value = body[key] ?? body[`${key}[]`]; + if (Array.isArray(value)) { + return value; + } + if (typeof value === 'string') { + return [value]; + } + return undefined; +}; + export default function run(config: Partial) { // Store config in app state. From now it can be loaded by any module using // getConfig(): buildConfig(config); - const { bundlePath, logHttpLevel, port, fastifyServerOptions } = getConfig(); + const { bundlePath, logHttpLevel, port, fastifyServerOptions, workersCount } = getConfig(); const app = fastify({ http2: useHttp2 as true, @@ -174,7 +200,12 @@ export default function run(config: Partial) { // the digest is part of the request URL. Yes, it's not used here, but the // server logs might show it to distinguish different requests. app.post<{ - Body: { renderingRequest: string } & Record; + Body: WithBodyArrayField< + { + renderingRequest: string; + }, + 'dependencyBundleTimestamps' + >; // Can't infer from the route like Express can Params: { bundleTimestamp: string; renderRequestDigest: string }; }>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => { @@ -195,23 +226,29 @@ export default function run(config: Partial) { const { renderingRequest } = req.body; const { bundleTimestamp } = req.params; - let providedNewBundle: Asset | undefined; + const providedNewBundles: ProvidedNewBundle[] = []; const assetsToCopy: Asset[] = []; Object.entries(req.body).forEach(([key, value]) => { if (key === 'bundle') { - providedNewBundle = value as Asset; + assertAsset(value, key); + providedNewBundles.push({ timestamp: bundleTimestamp, bundle: value }); + } else if (key.startsWith('bundle_')) { + assertAsset(value, key); + providedNewBundles.push({ timestamp: key.replace('bundle_', ''), bundle: value }); } else if (isAsset(value)) { assetsToCopy.push(value); } }); try { + const dependencyBundleTimestamps = extractBodyArrayField(req.body, 'dependencyBundleTimestamps'); await trace(async (context) => { try { const result = await handleRenderRequest({ renderingRequest, bundleTimestamp, - providedNewBundle, + dependencyBundleTimestamps, + providedNewBundles, assetsToCopy, }); await setResponse(result, res); @@ -235,7 +272,7 @@ export default function run(config: Partial) { // There can be additional files that might be required at the runtime. // Since the remote renderer doesn't contain any assets, they must be uploaded manually. app.post<{ - Body: Record; + Body: WithBodyArrayField, 'targetBundles'>; }>('/upload-assets', async (req, res) => { if (!(await requestPrechecks(req, res))) { return; @@ -243,8 +280,19 @@ export default function run(config: Partial) { let lockAcquired = false; let lockfileName: string | undefined; const assets: Asset[] = Object.values(req.body).filter(isAsset); + + // Handle targetBundles as either a string or an array + const targetBundles = extractBodyArrayField(req.body, 'targetBundles'); + if (!targetBundles || targetBundles.length === 0) { + const errorMsg = 'No targetBundles provided. As of protocol version 2.0.0, targetBundles is required.'; + log.error(errorMsg); + await setResponse(errorResponseResult(errorMsg), res); + return; + } + const assetsDescription = JSON.stringify(assets.map((asset) => asset.filename)); - const taskDescription = `Uploading files ${assetsDescription} to ${bundlePath}`; + const taskDescription = `Uploading files ${assetsDescription} to bundle directories: ${targetBundles.join(', ')}`; + try { const { lockfileName: name, wasLockAcquired, errorMessage } = await lock('transferring-assets'); lockfileName = name; @@ -260,7 +308,31 @@ export default function run(config: Partial) { } else { log.info(taskDescription); try { - await moveUploadedAssets(assets); + // Prepare all directories first + const directoryPromises = targetBundles.map(async (bundleTimestamp) => { + const bundleDirectory = getBundleDirectory(bundleTimestamp); + + // Check if bundle directory exists, create if not + if (!(await fileExistsAsync(bundleDirectory))) { + log.info(`Creating bundle directory: ${bundleDirectory}`); + await mkdir(bundleDirectory, { recursive: true }); + } + return bundleDirectory; + }); + + const bundleDirectories = await Promise.all(directoryPromises); + + // Copy assets to each bundle directory + const assetCopyPromises = bundleDirectories.map(async (bundleDirectory) => { + await copyUploadedAssets(assets, bundleDirectory); + log.info(`Copied assets to bundle directory: ${bundleDirectory}`); + }); + + await Promise.all(assetCopyPromises); + + // Delete assets from uploads directory + await deleteUploadedAssets(assets); + await setResponse( { status: 200, @@ -299,6 +371,7 @@ export default function run(config: Partial) { // Checks if file exist app.post<{ Querystring: { filename: string }; + Body: WithBodyArrayField, 'targetBundles'>; }>('/asset-exists', async (req, res) => { if (!(await isAuthenticated(req, res))) { return; @@ -313,17 +386,35 @@ export default function run(config: Partial) { return; } - const assetPath = path.join(bundlePath, filename); + // Handle targetBundles as either a string or an array + const targetBundles = extractBodyArrayField(req.body, 'targetBundles'); + if (!targetBundles || targetBundles.length === 0) { + const errorMsg = 'No targetBundles provided. As of protocol version 2.0.0, targetBundles is required.'; + log.error(errorMsg); + await setResponse(errorResponseResult(errorMsg), res); + return; + } - const fileExists = await fileExistsAsync(assetPath); + // Check if the asset exists in each of the target bundles + const results = await Promise.all( + targetBundles.map(async (bundleHash) => { + const assetPath = getAssetPath(bundleHash, filename); + const exists = await fileExistsAsync(assetPath); - if (fileExists) { - log.info(`/asset-exists Uploaded asset DOES exist: ${assetPath}`); - await setResponse({ status: 200, data: { exists: true }, headers: {} }, res); - } else { - log.info(`/asset-exists Uploaded asset DOES NOT exist: ${assetPath}`); - await setResponse({ status: 200, data: { exists: false }, headers: {} }, res); - } + if (exists) { + log.info(`/asset-exists Uploaded asset DOES exist in bundle ${bundleHash}: ${assetPath}`); + } else { + log.info(`/asset-exists Uploaded asset DOES NOT exist in bundle ${bundleHash}: ${assetPath}`); + } + + return { bundleHash, exists }; + }), + ); + + // Asset exists if it exists in all target bundles + const allExist = results.every((result) => result.exists); + + await setResponse({ status: 200, data: { exists: allExist, results }, headers: {} }, res); }); app.get('/info', (_req, res) => { @@ -337,9 +428,10 @@ export default function run(config: Partial) { // will not listen: // we are extracting worker from cluster to avoid false TS error const { worker } = cluster; - if (cluster.isWorker && worker !== undefined) { + if (workersCount === 0 || cluster.isWorker) { app.listen({ port }, () => { - log.info(`Node renderer worker #${worker.id} listening on port ${port}!`); + const workerName = worker ? `worker #${worker.id}` : 'master (single-process)'; + log.info(`Node renderer ${workerName} listening on port ${port}!`); }); } diff --git a/packages/node-renderer/src/worker/checkProtocolVersionHandler.ts b/packages/node-renderer/src/worker/checkProtocolVersionHandler.ts index 1acb9147f..b1f0f3b3c 100644 --- a/packages/node-renderer/src/worker/checkProtocolVersionHandler.ts +++ b/packages/node-renderer/src/worker/checkProtocolVersionHandler.ts @@ -15,8 +15,7 @@ export = function checkProtocolVersion(req: FastifyRequest) { reqProtocolVersion ? `request protocol ${reqProtocolVersion}` : `MISSING with body ${JSON.stringify(req.body)}` - } does not -match installed renderer protocol ${packageJson.protocolVersion} for version ${packageJson.version}. + } does not match installed renderer protocol ${packageJson.protocolVersion} for version ${packageJson.version}. Update either the renderer or the Rails server`, }; } diff --git a/packages/node-renderer/src/worker/handleRenderRequest.ts b/packages/node-renderer/src/worker/handleRenderRequest.ts index a997ef4b8..82dbebfe2 100644 --- a/packages/node-renderer/src/worker/handleRenderRequest.ts +++ b/packages/node-renderer/src/worker/handleRenderRequest.ts @@ -7,6 +7,7 @@ import cluster from 'cluster'; import path from 'path'; +import { mkdir } from 'fs/promises'; import { lock, unlock } from '../shared/locks'; import fileExistsAsync from '../shared/fileExistsAsync'; import log from '../shared/log'; @@ -15,17 +16,23 @@ import { formatExceptionMessage, errorResponseResult, workerIdLabel, - moveUploadedAssets, + copyUploadedAssets, ResponseResult, moveUploadedAsset, isReadableStream, isErrorRenderResult, - handleStreamError, + getRequestBundleFilePath, + deleteUploadedAssets, } from '../shared/utils'; import { getConfig } from '../shared/configBuilder'; import * as errorReporter from '../shared/errorReporter'; import { buildVM, hasVMContextForBundle, runInVM } from './vm'; +export type ProvidedNewBundle = { + timestamp: string | number; + bundle: Asset; +}; + async function prepareResult( renderingRequest: string, bundleFilePathPerTimestamp: string, @@ -46,14 +53,10 @@ async function prepareResult( } if (isReadableStream(result)) { - const newStreamAfterHandlingError = handleStreamError(result, (error) => { - const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream'); - errorReporter.message(msg); - }); return { headers: { 'Cache-Control': 'public, max-age=31536000' }, status: 200, - stream: newStreamAfterHandlingError, + stream: result, }; } @@ -68,11 +71,6 @@ async function prepareResult( } } -function getRequestBundleFilePath(bundleTimestamp: string | number) { - const { bundlePath } = getConfig(); - return path.join(bundlePath, `${bundleTimestamp}.js`); -} - /** * @param bundleFilePathPerTimestamp * @param providedNewBundle @@ -80,11 +78,13 @@ function getRequestBundleFilePath(bundleTimestamp: string | number) { * @param assetsToCopy might be null */ async function handleNewBundleProvided( - bundleFilePathPerTimestamp: string, - providedNewBundle: Asset, renderingRequest: string, + providedNewBundle: ProvidedNewBundle, assetsToCopy: Asset[] | null | undefined, -): Promise { +): Promise { + const bundleFilePathPerTimestamp = getRequestBundleFilePath(providedNewBundle.timestamp); + const bundleDirectory = path.dirname(bundleFilePathPerTimestamp); + await mkdir(bundleDirectory, { recursive: true }); log.info('Worker received new bundle: %s', bundleFilePathPerTimestamp); let lockAcquired = false; @@ -104,14 +104,16 @@ async function handleNewBundleProvided( } try { - log.info(`Moving uploaded file ${providedNewBundle.savedFilePath} to ${bundleFilePathPerTimestamp}`); - await moveUploadedAsset(providedNewBundle, bundleFilePathPerTimestamp); + log.info( + `Moving uploaded file ${providedNewBundle.bundle.savedFilePath} to ${bundleFilePathPerTimestamp}`, + ); + await moveUploadedAsset(providedNewBundle.bundle, bundleFilePathPerTimestamp); if (assetsToCopy) { - await moveUploadedAssets(assetsToCopy); + await copyUploadedAssets(assetsToCopy, bundleDirectory); } log.info( - `Completed moving uploaded file ${providedNewBundle.savedFilePath} to ${bundleFilePathPerTimestamp}`, + `Completed moving uploaded file ${providedNewBundle.bundle.savedFilePath} to ${bundleFilePathPerTimestamp}`, ); } catch (error) { const fileExists = await fileExistsAsync(bundleFilePathPerTimestamp); @@ -119,7 +121,7 @@ async function handleNewBundleProvided( const msg = formatExceptionMessage( renderingRequest, error, - `Unexpected error when moving the bundle from ${providedNewBundle.savedFilePath} \ + `Unexpected error when moving the bundle from ${providedNewBundle.bundle.savedFilePath} \ to ${bundleFilePathPerTimestamp})`, ); log.error(msg); @@ -131,20 +133,7 @@ to ${bundleFilePathPerTimestamp})`, ); } - try { - // Either this process or another process placed the file. Because the lock is acquired, the - // file must be fully written - log.info('buildVM, bundleFilePathPerTimestamp', bundleFilePathPerTimestamp); - await buildVM(bundleFilePathPerTimestamp); - return await prepareResult(renderingRequest, bundleFilePathPerTimestamp); - } catch (error) { - const msg = formatExceptionMessage( - renderingRequest, - error, - `Unexpected error when building the VM ${bundleFilePathPerTimestamp}`, - ); - return errorResponseResult(msg); - } + return undefined; } finally { if (lockAcquired) { log.info('About to unlock %s from worker %i', lockfileName, workerIdLabel()); @@ -164,44 +153,88 @@ to ${bundleFilePathPerTimestamp})`, } } +async function handleNewBundlesProvided( + renderingRequest: string, + providedNewBundles: ProvidedNewBundle[], + assetsToCopy: Asset[] | null | undefined, +): Promise { + log.info('Worker received new bundles: %s', providedNewBundles); + + const handlingPromises = providedNewBundles.map((providedNewBundle) => + handleNewBundleProvided(renderingRequest, providedNewBundle, assetsToCopy), + ); + const results = await Promise.all(handlingPromises); + + if (assetsToCopy) { + await deleteUploadedAssets(assetsToCopy); + } + + const errorResult = results.find((result) => result !== undefined); + return errorResult; +} + /** * Creates the result for the Fastify server to use. * @returns Promise where the result contains { status, data, headers } to * send back to the browser. */ -export = async function handleRenderRequest({ +export async function handleRenderRequest({ renderingRequest, bundleTimestamp, - providedNewBundle, + dependencyBundleTimestamps, + providedNewBundles, assetsToCopy, }: { renderingRequest: string; bundleTimestamp: string | number; - providedNewBundle?: Asset | null; + dependencyBundleTimestamps?: string[] | number[]; + providedNewBundles?: ProvidedNewBundle[] | null; assetsToCopy?: Asset[] | null; }): Promise { try { - const bundleFilePathPerTimestamp = getRequestBundleFilePath(bundleTimestamp); + // const bundleFilePathPerTimestamp = getRequestBundleFilePath(bundleTimestamp); + const allBundleFilePaths = Array.from( + new Set([...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(getRequestBundleFilePath)), + ); + const entryBundleFilePath = getRequestBundleFilePath(bundleTimestamp); + + const { maxVMPoolSize } = getConfig(); + + if (allBundleFilePaths.length > maxVMPoolSize) { + return { + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + status: 410, + data: `Too many bundles uploaded. The maximum allowed is ${maxVMPoolSize}. Please reduce the number of bundles or increase maxVMPoolSize in your configuration.`, + }; + } // If the current VM has the correct bundle and is ready - if (hasVMContextForBundle(bundleFilePathPerTimestamp)) { - return await prepareResult(renderingRequest, bundleFilePathPerTimestamp); + if (allBundleFilePaths.every((bundleFilePath) => hasVMContextForBundle(bundleFilePath))) { + return await prepareResult(renderingRequest, entryBundleFilePath); } // If gem has posted updated bundle: - if (providedNewBundle) { - return await handleNewBundleProvided( - bundleFilePathPerTimestamp, - providedNewBundle, - renderingRequest, - assetsToCopy, - ); + if (providedNewBundles) { + const result = await handleNewBundlesProvided(renderingRequest, providedNewBundles, assetsToCopy); + if (result) { + return result; + } } // Check if the bundle exists: - const fileExists = await fileExistsAsync(bundleFilePathPerTimestamp); - if (!fileExists) { - log.info(`No saved bundle ${bundleFilePathPerTimestamp}. Requesting a new bundle.`); + const missingBundles = ( + await Promise.all( + [...(dependencyBundleTimestamps ?? []), bundleTimestamp].map(async (timestamp) => { + const bundleFilePath = getRequestBundleFilePath(timestamp); + const fileExists = await fileExistsAsync(bundleFilePath); + return fileExists ? null : timestamp; + }), + ) + ).filter((timestamp) => timestamp !== null); + + if (missingBundles.length > 0) { + const missingBundlesText = missingBundles.length > 1 ? 'bundles' : 'bundle'; + log.info(`No saved ${missingBundlesText}: ${missingBundles.join(', ')}`); return { headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, status: 410, @@ -211,10 +244,10 @@ export = async function handleRenderRequest({ // The bundle exists, but the VM has not yet been created. // Another worker must have written it or it was saved during deployment. - log.info('Bundle %s exists. Building VM for worker %s.', bundleFilePathPerTimestamp, workerIdLabel()); - await buildVM(bundleFilePathPerTimestamp); + log.info('Bundle %s exists. Building VM for worker %s.', entryBundleFilePath, workerIdLabel()); + await Promise.all(allBundleFilePaths.map((bundleFilePath) => buildVM(bundleFilePath))); - return await prepareResult(renderingRequest, bundleFilePathPerTimestamp); + return await prepareResult(renderingRequest, entryBundleFilePath); } catch (error) { const msg = formatExceptionMessage( renderingRequest, @@ -224,4 +257,4 @@ export = async function handleRenderRequest({ errorReporter.message(msg); return Promise.reject(error as Error); } -}; +} diff --git a/packages/node-renderer/src/worker/vm.ts b/packages/node-renderer/src/worker/vm.ts index 6194fbec9..2f751512a 100644 --- a/packages/node-renderer/src/worker/vm.ts +++ b/packages/node-renderer/src/worker/vm.ts @@ -11,13 +11,19 @@ import cluster from 'cluster'; import type { Readable } from 'stream'; import { ReadableStream } from 'stream/web'; import { promisify, TextEncoder } from 'util'; -import type { ReactOnRails as ROR } from 'react-on-rails'; +import type { ReactOnRails as ROR } from 'react-on-rails' with { 'resolution-mode': 'import' }; import type { Context } from 'vm'; import SharedConsoleHistory from '../shared/sharedConsoleHistory'; import log from '../shared/log'; import { getConfig } from '../shared/configBuilder'; -import { formatExceptionMessage, smartTrim, isReadableStream } from '../shared/utils'; +import { + formatExceptionMessage, + smartTrim, + isReadableStream, + getRequestBundleFilePath, + handleStreamError, +} from '../shared/utils'; import * as errorReporter from '../shared/errorReporter'; const readFileAsync = promisify(fs.readFile); @@ -95,6 +101,83 @@ function manageVMPoolSize() { } } +/** + * + * @param renderingRequest JS Code to execute for SSR + * @param filePath + * @param vmCluster + */ +export async function runInVM( + renderingRequest: string, + filePath: string, + vmCluster?: typeof cluster, +): Promise { + const { bundlePath } = getConfig(); + + try { + // Wait for VM creation if it's in progress + if (vmCreationPromises.has(filePath)) { + await vmCreationPromises.get(filePath); + } + + // Get the correct VM context based on the provided bundle path + const vmContext = getVMContext(filePath); + + if (!vmContext) { + throw new Error(`No VM context found for bundle ${filePath}`); + } + + // Update last used timestamp + vmContext.lastUsed = Date.now(); + + const { context, sharedConsoleHistory } = vmContext; + + if (log.level === 'debug') { + // worker is nullable in the primary process + const workerId = vmCluster?.worker?.id; + log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${filePath} with code +${smartTrim(renderingRequest)}`); + const debugOutputPathCode = path.join(bundlePath, 'code.js'); + log.debug(`Full code executed written to: ${debugOutputPathCode}`); + await writeFileAsync(debugOutputPathCode, renderingRequest); + } + + let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest(() => { + context.renderingRequest = renderingRequest; + try { + return vm.runInContext(renderingRequest, context) as RenderCodeResult; + } finally { + context.renderingRequest = undefined; + } + }); + + if (isReadableStream(result)) { + const newStreamAfterHandlingError = handleStreamError(result, (error) => { + const msg = formatExceptionMessage(renderingRequest, error, 'Error in a rendering stream'); + errorReporter.message(msg); + }); + return newStreamAfterHandlingError; + } + if (typeof result !== 'string') { + const objectResult = await result; + result = JSON.stringify(objectResult); + } + if (log.level === 'debug') { + log.debug(`result from JS: +${smartTrim(result)}`); + const debugOutputPathResult = path.join(bundlePath, 'result.json'); + log.debug(`Wrote result to file: ${debugOutputPathResult}`); + await writeFileAsync(debugOutputPathResult, result); + } + + return result; + } catch (exception) { + const exceptionMessage = formatExceptionMessage(renderingRequest, exception); + log.debug('Caught exception in rendering request', exceptionMessage); + return Promise.resolve({ exceptionMessage }); + } +} + export async function buildVM(filePath: string) { // Return existing promise if VM is already being created if (vmCreationPromises.has(filePath)) { @@ -116,7 +199,13 @@ export async function buildVM(filePath: string) { const additionalContextIsObject = additionalContext !== null && additionalContext.constructor === Object; const sharedConsoleHistory = new SharedConsoleHistory(); - const contextObject = { sharedConsoleHistory }; + + const runOnOtherBundle = async (bundleTimestamp: string | number, renderingRequest: string) => { + const bundlePath = getRequestBundleFilePath(bundleTimestamp); + return runInVM(renderingRequest, bundlePath, cluster); + }; + + const contextObject = { sharedConsoleHistory, runOnOtherBundle }; if (supportModules) { // IMPORTANT: When adding anything to this object, update: @@ -259,74 +348,6 @@ export async function buildVM(filePath: string) { return vmCreationPromise; } -/** - * - * @param renderingRequest JS Code to execute for SSR - * @param filePath - * @param vmCluster - */ -export async function runInVM( - renderingRequest: string, - filePath: string, - vmCluster?: typeof cluster, -): Promise { - const { bundlePath } = getConfig(); - - try { - // Wait for VM creation if it's in progress - if (vmCreationPromises.has(filePath)) { - await vmCreationPromises.get(filePath); - } - - // Get the correct VM context based on the provided bundle path - const vmContext = getVMContext(filePath); - - if (!vmContext) { - throw new Error(`No VM context found for bundle ${filePath}`); - } - - // Update last used timestamp - vmContext.lastUsed = Date.now(); - - const { context, sharedConsoleHistory } = vmContext; - - if (log.level === 'debug') { - // worker is nullable in the primary process - const workerId = vmCluster?.worker?.id; - log.debug(`worker ${workerId ? `${workerId} ` : ''}received render request for bundle ${filePath} with code -${smartTrim(renderingRequest)}`); - const debugOutputPathCode = path.join(bundlePath, 'code.js'); - log.debug(`Full code executed written to: ${debugOutputPathCode}`); - await writeFileAsync(debugOutputPathCode, renderingRequest); - } - - let result = sharedConsoleHistory.trackConsoleHistoryInRenderRequest( - () => vm.runInContext(renderingRequest, context) as RenderCodeResult, - ); - - if (isReadableStream(result)) { - return result; - } - if (typeof result !== 'string') { - const objectResult = await result; - result = JSON.stringify(objectResult); - } - if (log.level === 'debug') { - log.debug(`result from JS: -${smartTrim(result)}`); - const debugOutputPathResult = path.join(bundlePath, 'result.json'); - log.debug(`Wrote result to file: ${debugOutputPathResult}`); - await writeFileAsync(debugOutputPathResult, result); - } - - return result; - } catch (exception) { - const exceptionMessage = formatExceptionMessage(renderingRequest, exception); - log.debug('Caught exception in rendering request', exceptionMessage); - return Promise.resolve({ exceptionMessage }); - } -} - export function resetVM() { // Clear all VM contexts vmContexts.clear(); diff --git a/packages/node-renderer/tests/fixtures/projects/spec-dummy/220f7a3/asyncComponentsTreeForTestingRenderingRequest.js b/packages/node-renderer/tests/fixtures/projects/spec-dummy/220f7a3/asyncComponentsTreeForTestingRenderingRequest.js deleted file mode 100644 index c57bcf85b..000000000 --- a/packages/node-renderer/tests/fixtures/projects/spec-dummy/220f7a3/asyncComponentsTreeForTestingRenderingRequest.js +++ /dev/null @@ -1,16 +0,0 @@ - (function() { - var railsContext = {"railsEnv":"development","inMailer":false,"i18nLocale":"en","i18nDefaultLocale":"en","rorVersion":"14.0.5","rorPro":true,"rorProVersion":"4.0.0.rc.5","href":"http://localhost:3000/stream_async_components","location":"/stream_async_components","scheme":"http","host":"localhost","port":3000,"pathname":"/stream_async_components","search":null,"httpAcceptLanguage":"en-US,en-GB;q=0.9,en;q=0.8,ar;q=0.7","somethingUseful":"REALLY USEFUL","serverSide":true}; - - ReactOnRails.clearHydratedStores(); - - var props = {"helloWorldData":{"name":"Mr. Server Side Rendering","\u003cscript\u003ewindow.alert('xss1');\u003c/script\u003e":"\u003cscript\u003ewindow.alert(\"xss2\");\u003c/script\u003e"}}; - return ReactOnRails.streamServerRenderedReactComponent({ - name: 'AsyncComponentsTreeForTesting', - domNodeId: 'AsyncComponentsTreeForTesting-react-component-0', - props: props, - trace: true, - railsContext: railsContext, - throwJsErrors: false, - renderingReturnsPromises: true - }); - })() diff --git a/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js b/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js new file mode 100644 index 000000000..03957919d --- /dev/null +++ b/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js @@ -0,0 +1,32 @@ +(function(componentName = 'AsyncComponentsTreeForTesting', props = undefined) { + var railsContext = {"componentRegistryTimeout":5000,"railsEnv":"development","inMailer":false,"i18nLocale":"en","i18nDefaultLocale":"en","rorVersion":"15.0.0.alpha.2","rorPro":true,"rscPayloadGenerationUrl":"rsc_payload/","rorProVersion":"4.0.0.rc.13","href":"http://localhost:3000/stream_async_components_for_testing","location":"/stream_async_components_for_testing","scheme":"http","host":"localhost","port":3000,"pathname":"/stream_async_components_for_testing","search":null,"httpAcceptLanguage":"en-US,en-GB;q=0.9,en;q=0.8,ar;q=0.7","somethingUseful":"REALLY USEFUL","serverSide":true}; + railsContext.componentSpecificMetadata = {renderRequestId: '123'}; + railsContext.reactClientManifestFileName = 'react-client-manifest.json'; + railsContext.reactServerClientManifestFileName = 'react-server-client-manifest.json'; + + railsContext.serverSideRSCPayloadParameters = { + renderingRequest, + rscBundleHash: '88888-test', + } + + if (typeof generateRSCPayload !== 'function') { + globalThis.generateRSCPayload = function generateRSCPayload(componentName, props, railsContext) { + const { renderingRequest, rscBundleHash } = railsContext.serverSideRSCPayloadParameters; + const propsString = JSON.stringify(props); + const newRenderingRequest = renderingRequest.replace(/\(\s*\)\s*$/, `('${componentName}', ${propsString})`); + return runOnOtherBundle(rscBundleHash, newRenderingRequest); + } + } + + ReactOnRails.clearHydratedStores(); + var usedProps = typeof props === 'undefined' ? {"helloWorldData":{"name":"Mr. Server Side Rendering","\u003cscript\u003ewindow.alert('xss1');\u003c/script\u003e":"\u003cscript\u003ewindow.alert(\"xss2\");\u003c/script\u003e"}} : props; + return ReactOnRails[ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent']({ + name: componentName, + domNodeId: 'AsyncComponentsTreeForTesting-react-component-0', + props: usedProps, + trace: true, + railsContext: railsContext, + throwJsErrors: false, + renderingReturnsPromises: true, + }); +})() diff --git a/packages/node-renderer/tests/fixtures/secondary-bundle.js b/packages/node-renderer/tests/fixtures/secondary-bundle.js new file mode 100644 index 000000000..d901dd052 --- /dev/null +++ b/packages/node-renderer/tests/fixtures/secondary-bundle.js @@ -0,0 +1,3 @@ +global.ReactOnRails = { + dummy: { html: 'Dummy Object from secondary bundle' }, +}; diff --git a/packages/node-renderer/tests/handleRenderRequest.test.ts b/packages/node-renderer/tests/handleRenderRequest.test.ts index 1b6981f3f..2557fa78d 100644 --- a/packages/node-renderer/tests/handleRenderRequest.test.ts +++ b/packages/node-renderer/tests/handleRenderRequest.test.ts @@ -3,14 +3,26 @@ import touch from 'touch'; import lockfile from 'lockfile'; import { createVmBundle, + createSecondaryVmBundle, uploadedBundlePath, + uploadedSecondaryBundlePath, createUploadedBundle, + createUploadedSecondaryBundle, + createUploadedAsset, + uploadedAssetPath, + uploadedAssetOtherPath, resetForTest, BUNDLE_TIMESTAMP, + SECONDARY_BUNDLE_TIMESTAMP, lockfilePath, + mkdirAsync, + vmBundlePath, + vmSecondaryBundlePath, + ASSET_UPLOAD_FILE, + ASSET_UPLOAD_OTHER_FILE, } from './helper'; import { hasVMContextForBundle } from '../src/worker/vm'; -import handleRenderRequest from '../src/worker/handleRenderRequest'; +import { handleRenderRequest } from '../src/worker/handleRenderRequest'; import { delay, Asset } from '../src/shared/utils'; const testName = 'handleRenderRequest'; @@ -28,10 +40,23 @@ const renderResult = { data: JSON.stringify({ html: 'Dummy Object' }), }; +const renderResultFromBothBundles = { + status: 200, + headers: { 'Cache-Control': 'public, max-age=31536000' }, + data: JSON.stringify({ + mainBundleResult: { html: 'Dummy Object' }, + secondaryBundleResult: { html: 'Dummy Object from secondary bundle' }, + }), +}; + // eslint-disable-next-line jest/valid-title describe(testName, () => { beforeEach(async () => { await resetForTest(testName); + const bundleDirectory = path.dirname(vmBundlePath(testName)); + await mkdirAsync(bundleDirectory, { recursive: true }); + const secondaryBundleDirectory = path.dirname(vmSecondaryBundlePath(testName)); + await mkdirAsync(secondaryBundleDirectory, { recursive: true }); }); afterAll(async () => { @@ -45,11 +70,18 @@ describe(testName, () => { const result = await handleRenderRequest({ renderingRequest: 'ReactOnRails.dummy', bundleTimestamp: BUNDLE_TIMESTAMP, - providedNewBundle: uploadedBundleForTest(), + providedNewBundles: [ + { + bundle: uploadedBundleForTest(), + timestamp: BUNDLE_TIMESTAMP, + }, + ], }); expect(result).toEqual(renderResult); - expect(hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898.js`))).toBeTruthy(); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); }); test('If bundle was not uploaded yet and not provided', async () => { @@ -93,11 +125,18 @@ describe(testName, () => { const result = await handleRenderRequest({ renderingRequest: 'ReactOnRails.dummy', bundleTimestamp: BUNDLE_TIMESTAMP, - providedNewBundle: uploadedBundleForTest(), + providedNewBundles: [ + { + bundle: uploadedBundleForTest(), + timestamp: BUNDLE_TIMESTAMP, + }, + ], }); expect(result).toEqual(renderResult); - expect(hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898.js`))).toBeTruthy(); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); }); test('If lockfile exists from another thread and bundle provided.', async () => { @@ -118,10 +157,273 @@ describe(testName, () => { const result = await handleRenderRequest({ renderingRequest: 'ReactOnRails.dummy', bundleTimestamp: BUNDLE_TIMESTAMP, - providedNewBundle: uploadedBundleForTest(), + providedNewBundles: [ + { + bundle: uploadedBundleForTest(), + timestamp: BUNDLE_TIMESTAMP, + }, + ], + }); + + expect(result).toEqual(renderResult); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); + }); + + test('If multiple bundles are provided', async () => { + expect.assertions(3); + await createUploadedBundle(testName); + await createUploadedSecondaryBundle(testName); + + const result = await handleRenderRequest({ + renderingRequest: 'ReactOnRails.dummy', + bundleTimestamp: BUNDLE_TIMESTAMP, + providedNewBundles: [ + { + bundle: { + filename: '', + savedFilePath: uploadedBundlePath(testName), + type: 'asset', + }, + timestamp: BUNDLE_TIMESTAMP, + }, + { + bundle: { + filename: '', + savedFilePath: uploadedSecondaryBundlePath(testName), + type: 'asset', + }, + timestamp: SECONDARY_BUNDLE_TIMESTAMP, + }, + ], + }); + + expect(result).toEqual(renderResult); + // only the primary bundle should be in the VM context + // The secondary bundle will be processed only if the rendering request requests it + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024899/1495063024899.js`)), + ).toBeFalsy(); + }); + + test('If multiple bundles are provided and multiple assets are provided as well', async () => { + await createUploadedBundle(testName); + await createUploadedSecondaryBundle(testName); + + // Create additional uploaded assets using helper functions + await createUploadedAsset(testName); + + const additionalAssets = [ + { + filename: ASSET_UPLOAD_FILE, + savedFilePath: uploadedAssetPath(testName), + type: 'asset' as const, + }, + { + filename: ASSET_UPLOAD_OTHER_FILE, + savedFilePath: uploadedAssetOtherPath(testName), + type: 'asset' as const, + }, + ]; + + const result = await handleRenderRequest({ + renderingRequest: 'ReactOnRails.dummy', + bundleTimestamp: BUNDLE_TIMESTAMP, + providedNewBundles: [ + { + bundle: { + filename: '', + savedFilePath: uploadedBundlePath(testName), + type: 'asset', + }, + timestamp: BUNDLE_TIMESTAMP, + }, + { + bundle: { + filename: '', + savedFilePath: uploadedSecondaryBundlePath(testName), + type: 'asset', + }, + timestamp: SECONDARY_BUNDLE_TIMESTAMP, + }, + ], + assetsToCopy: additionalAssets, + }); + + expect(result).toEqual(renderResult); + + // Only the primary bundle should be in the VM context + // The secondary bundle will be processed only if the rendering request requests it + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024899/1495063024899.js`)), + ).toBeFalsy(); + + // Verify that the additional assets were copied to both bundle directories + const mainBundleDir = path.dirname( + path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`), + ); + const secondaryBundleDir = path.dirname( + path.resolve(__dirname, `./tmp/${testName}/1495063024899/1495063024899.js`), + ); + const mainAsset1Path = path.join(mainBundleDir, ASSET_UPLOAD_FILE); + const mainAsset2Path = path.join(mainBundleDir, ASSET_UPLOAD_OTHER_FILE); + const secondaryAsset1Path = path.join(secondaryBundleDir, ASSET_UPLOAD_FILE); + const secondaryAsset2Path = path.join(secondaryBundleDir, ASSET_UPLOAD_OTHER_FILE); + + const fsModule = await import('fs/promises'); + const mainAsset1Exists = await fsModule + .access(mainAsset1Path) + .then(() => true) + .catch(() => false); + const mainAsset2Exists = await fsModule + .access(mainAsset2Path) + .then(() => true) + .catch(() => false); + const secondaryAsset1Exists = await fsModule + .access(secondaryAsset1Path) + .then(() => true) + .catch(() => false); + const secondaryAsset2Exists = await fsModule + .access(secondaryAsset2Path) + .then(() => true) + .catch(() => false); + + expect(mainAsset1Exists).toBeTruthy(); + expect(mainAsset2Exists).toBeTruthy(); + expect(secondaryAsset1Exists).toBeTruthy(); + expect(secondaryAsset2Exists).toBeTruthy(); + }); + + test('If dependency bundle timestamps are provided but not uploaded yet', async () => { + expect.assertions(1); + + const result = await handleRenderRequest({ + renderingRequest: 'ReactOnRails.dummy', + bundleTimestamp: BUNDLE_TIMESTAMP, + dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], + }); + + expect(result).toEqual({ + status: 410, + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + data: 'No bundle uploaded', + }); + }); + + test('If dependency bundle timestamps are provided and already uploaded', async () => { + expect.assertions(1); + await createVmBundle(testName); + await createSecondaryVmBundle(testName); + + const result = await handleRenderRequest({ + renderingRequest: 'ReactOnRails.dummy', + bundleTimestamp: BUNDLE_TIMESTAMP, + dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], }); expect(result).toEqual(renderResult); - expect(hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898.js`))).toBeTruthy(); + }); + + test('rendering request can call runOnOtherBundle', async () => { + await createVmBundle(testName); + await createSecondaryVmBundle(testName); + + const renderingRequest = ` + runOnOtherBundle(${SECONDARY_BUNDLE_TIMESTAMP}, 'ReactOnRails.dummy').then((secondaryBundleResult) => ({ + mainBundleResult: ReactOnRails.dummy, + secondaryBundleResult: JSON.parse(secondaryBundleResult), + })); + `; + + const result = await handleRenderRequest({ + renderingRequest, + bundleTimestamp: BUNDLE_TIMESTAMP, + dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], + }); + + expect(result).toEqual(renderResultFromBothBundles); + // Both bundles should be in the VM context + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898/1495063024898.js`)), + ).toBeTruthy(); + expect( + hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024899/1495063024899.js`)), + ).toBeTruthy(); + }); + + test('renderingRequest is globally accessible inside the VM', async () => { + await createVmBundle(testName); + + const renderingRequest = ` + renderingRequest; + `; + + const result = await handleRenderRequest({ + renderingRequest, + bundleTimestamp: BUNDLE_TIMESTAMP, + }); + + expect(result).toEqual({ + status: 200, + headers: { 'Cache-Control': 'public, max-age=31536000' }, + data: renderingRequest, + }); + }); + + // The renderingRequest variable is automatically reset after synchronous execution to prevent data leakage + // between requests in the shared VM context. This means it will be undefined in any async callbacks. + // + // If you need to access renderingRequest in an async context, save it to a local variable first: + // + // const renderingRequest = ` + // const savedRequest = renderingRequest; // Save synchronously + // Promise.resolve().then(() => { + // return savedRequest; // Access async + // }); + // `; + test('renderingRequest is reset after the sync execution (not accessible from async functions)', async () => { + await createVmBundle(testName); + + // Since renderingRequest is undefined in async callbacks, we return the string 'undefined' + // to demonstrate this behavior (as undefined cannot be returned from the VM) + const renderingRequest = ` + Promise.resolve().then(() => renderingRequest ?? 'undefined'); + `; + + const result = await handleRenderRequest({ + renderingRequest, + bundleTimestamp: BUNDLE_TIMESTAMP, + }); + + expect(result).toEqual({ + status: 200, + headers: { 'Cache-Control': 'public, max-age=31536000' }, + data: JSON.stringify('undefined'), + }); + }); + + test('If main bundle exists but dependency bundle does not exist', async () => { + expect.assertions(1); + // Only create the main bundle, not the secondary/dependency bundle + await createVmBundle(testName); + + const result = await handleRenderRequest({ + renderingRequest: 'ReactOnRails.dummy', + bundleTimestamp: BUNDLE_TIMESTAMP, + dependencyBundleTimestamps: [SECONDARY_BUNDLE_TIMESTAMP], + }); + + expect(result).toEqual({ + status: 410, + headers: { 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' }, + data: 'No bundle uploaded', + }); }); }); diff --git a/packages/node-renderer/tests/helper.ts b/packages/node-renderer/tests/helper.ts index e5feefa6d..0f078ba7e 100644 --- a/packages/node-renderer/tests/helper.ts +++ b/packages/node-renderer/tests/helper.ts @@ -1,15 +1,21 @@ // NOTE: The tmp bundle directory for each test file must be different due to the fact that // jest will run multiple test files synchronously. import path from 'path'; +import fsPromises from 'fs/promises'; import fs from 'fs'; -import { promisify } from 'util'; import fsExtra from 'fs-extra'; import { buildVM, resetVM } from '../src/worker/vm'; import { buildConfig } from '../src/shared/configBuilder'; -const fsCopyFileAsync = promisify(fs.copyFile); +export const mkdirAsync = fsPromises.mkdir; +const safeCopyFileAsync = async (src: string, dest: string) => { + const parentDir = path.dirname(dest); + await mkdirAsync(parentDir, { recursive: true }); + await fsPromises.copyFile(src, dest); +}; export const BUNDLE_TIMESTAMP = 1495063024898; +export const SECONDARY_BUNDLE_TIMESTAMP = 1495063024899; export const ASSET_UPLOAD_FILE = 'loadable-stats.json'; export const ASSET_UPLOAD_OTHER_FILE = 'loadable-stats-other.json'; @@ -17,6 +23,10 @@ export function getFixtureBundle() { return path.resolve(__dirname, './fixtures/bundle.js'); } +export function getFixtureSecondaryBundle() { + return path.resolve(__dirname, './fixtures/secondary-bundle.js'); +} + export function getFixtureAsset() { return path.resolve(__dirname, `./fixtures/${ASSET_UPLOAD_FILE}`); } @@ -36,18 +46,35 @@ export function setConfig(testName: string) { } export function vmBundlePath(testName: string) { - return path.resolve(bundlePath(testName), `${BUNDLE_TIMESTAMP}.js`); + return path.resolve(bundlePath(testName), `${BUNDLE_TIMESTAMP}`, `${BUNDLE_TIMESTAMP}.js`); +} + +export function vmSecondaryBundlePath(testName: string) { + return path.resolve( + bundlePath(testName), + `${SECONDARY_BUNDLE_TIMESTAMP}`, + `${SECONDARY_BUNDLE_TIMESTAMP}.js`, + ); } export async function createVmBundle(testName: string) { - await fsCopyFileAsync(getFixtureBundle(), vmBundlePath(testName)); + await safeCopyFileAsync(getFixtureBundle(), vmBundlePath(testName)); return buildVM(vmBundlePath(testName)); } +export async function createSecondaryVmBundle(testName: string) { + await safeCopyFileAsync(getFixtureSecondaryBundle(), vmSecondaryBundlePath(testName)); + return buildVM(vmSecondaryBundlePath(testName)); +} + export function lockfilePath(testName: string) { return `${vmBundlePath(testName)}.lock`; } +export function secondaryLockfilePath(testName: string) { + return `${vmSecondaryBundlePath(testName)}.lock`; +} + export function uploadedBundleDir(testName: string) { return path.resolve(bundlePath(testName), 'uploads'); } @@ -56,22 +83,49 @@ export function uploadedBundlePath(testName: string) { return path.resolve(uploadedBundleDir(testName), `${BUNDLE_TIMESTAMP}.js`); } -export function assetPath(testName: string) { - return path.resolve(bundlePath(testName), ASSET_UPLOAD_FILE); +export function uploadedSecondaryBundlePath(testName: string) { + return path.resolve(uploadedBundleDir(testName), `${SECONDARY_BUNDLE_TIMESTAMP}.js`); +} + +export function uploadedAssetPath(testName: string) { + return path.resolve(uploadedBundleDir(testName), ASSET_UPLOAD_FILE); } -export function assetPathOther(testName: string) { - return path.resolve(bundlePath(testName), ASSET_UPLOAD_OTHER_FILE); +export function uploadedAssetOtherPath(testName: string) { + return path.resolve(uploadedBundleDir(testName), ASSET_UPLOAD_OTHER_FILE); +} + +export function assetPath(testName: string, bundleTimestamp: string) { + return path.resolve(bundlePath(testName), bundleTimestamp, ASSET_UPLOAD_FILE); +} + +export function assetPathOther(testName: string, bundleTimestamp: string) { + return path.resolve(bundlePath(testName), bundleTimestamp, ASSET_UPLOAD_OTHER_FILE); } export async function createUploadedBundle(testName: string) { - const mkdirAsync = promisify(fs.mkdir); await mkdirAsync(uploadedBundleDir(testName), { recursive: true }); - return fsCopyFileAsync(getFixtureBundle(), uploadedBundlePath(testName)); + return safeCopyFileAsync(getFixtureBundle(), uploadedBundlePath(testName)); +} + +export async function createUploadedSecondaryBundle(testName: string) { + await mkdirAsync(uploadedBundleDir(testName), { recursive: true }); + return safeCopyFileAsync(getFixtureSecondaryBundle(), uploadedSecondaryBundlePath(testName)); +} + +export async function createUploadedAsset(testName: string) { + await mkdirAsync(uploadedBundleDir(testName), { recursive: true }); + return Promise.all([ + safeCopyFileAsync(getFixtureAsset(), uploadedAssetPath(testName)), + safeCopyFileAsync(getOtherFixtureAsset(), uploadedAssetOtherPath(testName)), + ]); } -export async function createAsset(testName: string) { - return fsCopyFileAsync(getFixtureAsset(), assetPath(testName)); +export async function createAsset(testName: string, bundleTimestamp: string) { + return Promise.all([ + safeCopyFileAsync(getFixtureAsset(), assetPath(testName, bundleTimestamp)), + safeCopyFileAsync(getOtherFixtureAsset(), assetPathOther(testName, bundleTimestamp)), + ]); } export async function resetForTest(testName: string) { diff --git a/packages/node-renderer/tests/htmlStreaming.test.js b/packages/node-renderer/tests/htmlStreaming.test.js index 236cb6304..3f043c00d 100644 --- a/packages/node-renderer/tests/htmlStreaming.test.js +++ b/packages/node-renderer/tests/htmlStreaming.test.js @@ -6,6 +6,7 @@ import buildApp from '../src/worker'; import config from './testingNodeRendererConfigs'; import { readRenderingRequest } from './helper'; import * as errorReporter from '../src/shared/errorReporter'; +import packageJson from '../src/shared/packageJson'; const app = buildApp(config); @@ -20,55 +21,61 @@ afterAll(async () => { jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn()); -const createForm = ({ - project = 'spec-dummy', - commit = '220f7a3', - useTestBundle = true, - props = {}, - throwJsErrors = false, -} = {}) => { +const SERVER_BUNDLE_TIMESTAMP = '77777-test'; +// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture +const RSC_BUNDLE_TIMESTAMP = '88888-test'; + +const createForm = ({ project = 'spec-dummy', commit = '', props = {}, throwJsErrors = false } = {}) => { const form = new FormData(); - form.append('gemVersion', '4.0.0.rc.5'); - form.append('protocolVersion', '1.0.0'); + form.append('gemVersion', packageJson.version); + form.append('protocolVersion', packageJson.protocolVersion); form.append('password', 'myPassword1'); + form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); let renderingRequestCode = readRenderingRequest( project, commit, 'asyncComponentsTreeForTestingRenderingRequest.js', ); - renderingRequestCode = renderingRequestCode.replace( - 'props: props,', - `props: { ...props, ...{${Object.entries(props) - .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) - .join(', ')}} },`, - ); + renderingRequestCode = renderingRequestCode.replace(/\(\s*\)\s*$/, `(undefined, ${JSON.stringify(props)})`); if (throwJsErrors) { renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); } form.append('renderingRequest', renderingRequestCode); - const bundlePath = useTestBundle - ? '../../../spec/dummy/public/webpack/test/server-bundle.js' - : `./fixtures/projects/${project}/${commit}/server-bundle-web-target.js`; - form.append('bundle', fs.createReadStream(path.join(__dirname, bundlePath)), { + const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test'); + const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js'); + form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), { contentType: 'text/javascript', filename: 'server-bundle.js', }); + const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js'); + form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), { + contentType: 'text/javascript', + filename: 'rsc-bundle.js', + }); + const clientManifestPath = path.join(testBundlesDirectory, 'react-client-manifest.json'); + form.append('asset1', fs.createReadStream(clientManifestPath), { + contentType: 'application/json', + filename: 'react-client-manifest.json', + }); + const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json'); + form.append('asset2', fs.createReadStream(reactServerClientManifestPath), { + contentType: 'application/json', + filename: 'react-server-client-manifest.json', + }); return form; }; const makeRequest = async (options = {}) => { - const useTestBundle = options.useTestBundle ?? true; const startTime = Date.now(); const form = createForm(options); - const bundleHash = useTestBundle ? '77777' : '88888'; const { address, port } = app.server.address(); const client = http2.connect(`http://${address}:${port}`); const request = client.request({ ':method': 'POST', - ':path': `/bundles/${bundleHash}/render/454a82526211afdb215352755d36032c`, + ':path': `/bundles/${SERVER_BUNDLE_TIMESTAMP}/render/454a82526211afdb215352755d36032c`, 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, }); request.setEncoding('utf8'); @@ -178,17 +185,22 @@ describe('html streaming', () => { }, 10000); it.each([true, false])( - 'should stop rendering when an error happen in the shell and renders the error (throwJsErrors: %s)', + 'sever components are not rendered when a sync error happens, but the error is not considered at the shell (throwJsErrors: %s)', async (throwJsErrors) => { - const { status, chunks } = await makeRequest({ + const { status, jsonChunks } = await makeRequest({ props: { throwSyncError: true }, - useTestBundle: true, throwJsErrors, }); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toMatch( - /
Exception in rendering[\s\S.]*Sync error from AsyncComponentsTreeForTesting[\s\S.]*<\/pre>/,
+      expect(jsonChunks.length).toBeGreaterThanOrEqual(1);
+      expect(jsonChunks.length).toBeLessThanOrEqual(4);
+
+      const chunksWithError = jsonChunks.filter((chunk) => chunk.hasErrors);
+      expect(chunksWithError).toHaveLength(1);
+      expect(chunksWithError[0].renderingError.message).toMatch(
+        /Sync error from AsyncComponentsTreeForTesting/,
       );
+      expect(chunksWithError[0].html).toMatch(/Sync error from AsyncComponentsTreeForTesting/);
+      expect(chunksWithError[0].isShellReady).toBeTruthy();
       expect(status).toBe(200);
     },
     10000,
@@ -197,7 +209,6 @@ describe('html streaming', () => {
   it("shouldn't notify error reporter when throwJsErrors is false and shell error happens", async () => {
     await makeRequest({
       props: { throwSyncError: true },
-      useTestBundle: true,
       // throwJsErrors is false by default
     });
     expect(errorReporter.message).not.toHaveBeenCalled();
@@ -206,10 +217,10 @@ describe('html streaming', () => {
   it('should notify error reporter when throwJsErrors is true and shell error happens', async () => {
     await makeRequest({
       props: { throwSyncError: true },
-      useTestBundle: true,
       throwJsErrors: true,
     });
-    expect(errorReporter.message).toHaveBeenCalledTimes(1);
+    // Reporter is called twice: once for the error occured at RSC vm and the other while rendering the errornous rsc payload
+    expect(errorReporter.message).toHaveBeenCalledTimes(2);
     expect(errorReporter.message).toHaveBeenCalledWith(
       expect.stringMatching(
         /Error in a rendering stream[\s\S.]*Sync error from AsyncComponentsTreeForTesting/,
@@ -222,7 +233,6 @@ describe('html streaming', () => {
     async (throwJsErrors) => {
       const { status, chunks, fullBody, jsonChunks } = await makeRequest({
         props: { throwAsyncError: true },
-        useTestBundle: true,
         throwJsErrors,
       });
       expect(chunks.length).toBeGreaterThan(5);
@@ -237,9 +247,16 @@ describe('html streaming', () => {
       expect(fullBody).toContain('branch2 (level 1)');
       expect(fullBody).toContain('branch2 (level 0)');
 
-      expect(jsonChunks[0].hasErrors).toBeFalsy();
-      // All chunks after the first one should have errors
-      expect(jsonChunks.slice(1).every((chunk) => chunk.hasErrors)).toBeTruthy();
+      expect(jsonChunks[0].isShellReady).toBeTruthy();
+      expect(jsonChunks[0].hasErrors).toBeTruthy();
+      expect(jsonChunks[0].renderingError).toMatchObject({
+        message: 'Async error from AsyncHelloWorldHooks',
+        stack: expect.stringMatching(
+          /Error: Async error from AsyncHelloWorldHooks\s*at AsyncHelloWorldHooks/,
+        ),
+      });
+      expect(jsonChunks.slice(1).some((chunk) => chunk.hasErrors)).toBeFalsy();
+      expect(jsonChunks.slice(1).some((chunk) => chunk.renderingError)).toBeFalsy();
     },
     10000,
   );
@@ -247,7 +264,6 @@ describe('html streaming', () => {
   it('should not notify error reporter when throwJsErrors is false and async error happens', async () => {
     await makeRequest({
       props: { throwAsyncError: true },
-      useTestBundle: true,
       throwJsErrors: false,
     });
     expect(errorReporter.message).not.toHaveBeenCalled();
@@ -256,10 +272,10 @@ describe('html streaming', () => {
   it('should notify error reporter when throwJsErrors is true and async error happens', async () => {
     await makeRequest({
       props: { throwAsyncError: true },
-      useTestBundle: true,
       throwJsErrors: true,
     });
-    expect(errorReporter.message).toHaveBeenCalledTimes(1);
+    // Reporter is called twice: once for the error occured at RSC vm and the other while rendering the errornous rsc payload
+    expect(errorReporter.message).toHaveBeenCalledTimes(2);
     expect(errorReporter.message).toHaveBeenCalledWith(
       expect.stringMatching(/Error in a rendering stream[\s\S.]*Async error from AsyncHelloWorldHooks/),
     );
diff --git a/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js b/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js
new file mode 100644
index 000000000..8b7a791b7
--- /dev/null
+++ b/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js
@@ -0,0 +1,156 @@
+import path from 'path';
+import { Readable } from 'stream';
+import { buildVM, getVMContext, resetVM } from '../src/worker/vm';
+import { getConfig } from '../src/shared/configBuilder';
+
+const SimpleWorkingComponent = () => 'hello';
+
+const ComponentWithSyncError = () => {
+  throw new Error('Sync error');
+};
+
+const ComponentWithAsyncError = async () => {
+  await new Promise((resolve) => {
+    setTimeout(resolve, 0);
+  });
+  throw new Error('Async error');
+};
+
+describe('serverRenderRSCReactComponent', () => {
+  beforeEach(async () => {
+    const config = getConfig();
+    config.supportModules = true;
+    config.maxVMPoolSize = 2; // Set a small pool size for testing
+    config.stubTimers = false;
+  });
+
+  afterEach(async () => {
+    resetVM();
+  });
+
+  // The serverRenderRSCReactComponent function should only be called when the bundle is compiled with the `react-server` condition.
+  // Therefore, we cannot call it directly in the test files. Instead, we run the RSC bundle through the VM and call the method from there.
+  const getReactOnRailsRSCObject = async () => {
+    const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test');
+    const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js');
+    await buildVM(rscBundlePath);
+    const vmContext = getVMContext(rscBundlePath);
+    const { ReactOnRails, React } = vmContext.context;
+
+    function SuspensedComponentWithAsyncError() {
+      return React.createElement('div', null, [
+        React.createElement('div', null, 'Hello'),
+        React.createElement(
+          React.Suspense,
+          {
+            fallback: React.createElement('div', null, 'Loading Async Component...'),
+          },
+          React.createElement(ComponentWithAsyncError),
+        ),
+      ]);
+    }
+
+    ReactOnRails.register({
+      SimpleWorkingComponent,
+      ComponentWithSyncError,
+      ComponentWithAsyncError,
+      SuspensedComponentWithAsyncError,
+    });
+
+    return ReactOnRails;
+  };
+
+  const renderComponent = async (componentName, throwJsErrors = false) => {
+    const ReactOnRails = await getReactOnRailsRSCObject();
+    return ReactOnRails.serverRenderRSCReactComponent({
+      name: componentName,
+      props: {},
+      throwJsErrors,
+      railsContext: {
+        serverSide: true,
+        reactClientManifestFileName: 'react-client-manifest.json',
+        reactServerClientManifestFileName: 'react-server-client-manifest.json',
+        componentSpecificMetadata: { renderRequestId: '123' },
+        renderingReturnsPromises: true,
+      },
+    });
+  };
+
+  it('ReactOnRails should be defined and have serverRenderRSCReactComponent method', async () => {
+    const result = await getReactOnRailsRSCObject();
+    expect(result).toBeDefined();
+    expect(typeof result.serverRenderRSCReactComponent).toBe('function');
+  });
+
+  // Add these helper functions at the top of the describe block
+  const getStreamContent = async (stream) => {
+    let content = '';
+    stream.on('data', (chunk) => {
+      content += chunk.toString();
+    });
+
+    await new Promise((resolve) => {
+      stream.on('end', resolve);
+    });
+
+    return content;
+  };
+
+  const expectStreamContent = async (stream, expectedContents, options = {}) => {
+    const { throwJsErrors, expectedError } = options;
+    expect(stream).toBeDefined();
+    expect(stream).toBeInstanceOf(Readable);
+
+    const onError = throwJsErrors ? jest.fn() : null;
+    if (onError) {
+      stream.on('error', onError);
+    }
+
+    const content = await getStreamContent(stream);
+
+    if (expectedError) {
+      expect(onError).toHaveBeenCalled();
+      expect(onError).toHaveBeenCalledWith(new Error(expectedError));
+    }
+
+    expectedContents.forEach((text) => {
+      expect(content).toContain(text);
+    });
+  };
+
+  it('should returns stream with content when the component renders successfully', async () => {
+    const result = await renderComponent('SimpleWorkingComponent');
+    await expectStreamContent(result, ['hello']);
+  });
+
+  it('should returns stream with error when the component throws a sync error', async () => {
+    const result = await renderComponent('ComponentWithSyncError');
+    await expectStreamContent(result, ['Sync error']);
+  });
+
+  it('should emit an error when the component throws a sync error and throwJsErrors is true', async () => {
+    const result = await renderComponent('ComponentWithSyncError', true);
+    await expectStreamContent(result, ['Sync error'], {
+      throwJsErrors: true,
+      expectedError: 'Sync error',
+    });
+  });
+
+  it('should emit an error when the component throws an async error and throwJsErrors is true', async () => {
+    const result = await renderComponent('ComponentWithAsyncError', true);
+    await expectStreamContent(result, ['Async error'], { throwJsErrors: true, expectedError: 'Async error' });
+  });
+
+  it('should render a suspense component with an async error', async () => {
+    const result = await renderComponent('SuspensedComponentWithAsyncError');
+    await expectStreamContent(result, ['Loading Async Component...', 'Hello', 'Async error']);
+  });
+
+  it('emits an error when the suspense component throws an async error and throwJsErrors is true', async () => {
+    const result = await renderComponent('SuspensedComponentWithAsyncError', true);
+    await expectStreamContent(result, ['Loading Async Component...', 'Hello', 'Async error'], {
+      throwJsErrors: true,
+      expectedError: 'Async error',
+    });
+  });
+});
diff --git a/packages/node-renderer/tests/vm.test.ts b/packages/node-renderer/tests/vm.test.ts
index 96ca0bd00..051e5d4d9 100644
--- a/packages/node-renderer/tests/vm.test.ts
+++ b/packages/node-renderer/tests/vm.test.ts
@@ -5,6 +5,7 @@ import {
   readRenderingRequest,
   createVmBundle,
   resetForTest,
+  BUNDLE_TIMESTAMP,
 } from './helper';
 import { buildVM, hasVMContextForBundle, resetVM, runInVM, getVMContext } from '../src/worker/vm';
 import { getConfig } from '../src/shared/configBuilder';
@@ -184,7 +185,11 @@ describe('buildVM and runInVM', () => {
     expect.assertions(1);
     await createVmBundleForTest();
 
-    expect(hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898.js`))).toBeTruthy();
+    expect(
+      hasVMContextForBundle(
+        path.resolve(__dirname, `./tmp/${testName}/${BUNDLE_TIMESTAMP}/${BUNDLE_TIMESTAMP}.js`),
+      ),
+    ).toBeTruthy();
   });
 
   test('FriendsAndGuests bundle for commit 1a7fe417 requires supportModules false', async () => {
@@ -564,4 +569,142 @@ describe('buildVM and runInVM', () => {
       expect(runCodeInVM3).toBe('function');
     });
   });
+
+  describe('VM Pool Management', () => {
+    beforeEach(async () => {
+      await resetForTest(testName);
+      const config = getConfig();
+      config.supportModules = true;
+      config.maxVMPoolSize = 2; // Set a small pool size for testing
+    });
+
+    afterEach(async () => {
+      await resetForTest(testName);
+      resetVM();
+    });
+
+    test('respects maxVMPoolSize limit', async () => {
+      const bundle1 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+      );
+      const bundle2 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+      );
+      const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+      // Build VMs up to and beyond the pool limit
+      await buildVM(bundle1);
+      await buildVM(bundle2);
+      await buildVM(bundle3);
+
+      // Only the two most recently used bundles should have contexts
+      expect(hasVMContextForBundle(bundle1)).toBeFalsy();
+      expect(hasVMContextForBundle(bundle2)).toBeTruthy();
+      expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+    });
+
+    test('calling buildVM with the same bundle path does not create a new VM', async () => {
+      const bundle1 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+      );
+      const bundle2 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+      );
+      await buildVM(bundle1);
+      await buildVM(bundle2);
+      await buildVM(bundle2);
+      await buildVM(bundle2);
+
+      expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+      expect(hasVMContextForBundle(bundle2)).toBeTruthy();
+    });
+
+    test('updates lastUsed timestamp when accessing existing VM', async () => {
+      const bundle1 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+      );
+      const bundle2 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+      );
+      const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+      // Create initial VMs
+      await buildVM(bundle1);
+      await buildVM(bundle2);
+
+      // Wait a bit to ensure timestamp difference
+      await new Promise((resolve) => {
+        setTimeout(resolve, 100);
+      });
+
+      // Access bundle1 again to update its timestamp
+      await buildVM(bundle1);
+
+      // Add a new VM - should remove bundle2 as it's the oldest
+      await buildVM(bundle3);
+
+      // Bundle1 should still exist as it was accessed more recently
+      expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+      expect(hasVMContextForBundle(bundle2)).toBeFalsy();
+      expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+    });
+
+    test('updates lastUsed timestamp when running code in VM', async () => {
+      const bundle1 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+      );
+      const bundle2 = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+      );
+      const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+      // Create initial VMs
+      await buildVM(bundle1);
+      await buildVM(bundle2);
+
+      // Wait a bit to ensure timestamp difference
+      await new Promise((resolve) => {
+        setTimeout(resolve, 100);
+      });
+
+      // Run code in bundle1 to update its timestamp
+      await runInVM('1 + 1', bundle1);
+
+      // Add a new VM - should remove bundle2 as it's the oldest
+      await buildVM(bundle3);
+
+      // Bundle1 should still exist as it was used more recently
+      expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+      expect(hasVMContextForBundle(bundle2)).toBeFalsy();
+      expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+    });
+
+    test('reuses existing VM context', async () => {
+      const bundle = path.resolve(
+        __dirname,
+        './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+      );
+
+      // Build VM first time
+      await buildVM(bundle);
+
+      // Set a variable in the VM context
+      await runInVM('global.testVar = "test value"', bundle);
+
+      // Build VM second time - should reuse existing context
+      await buildVM(bundle);
+
+      // Variable should still exist if context was reused
+      const result = await runInVM('global.testVar', bundle);
+      expect(result).toBe('test value');
+    });
+  });
 });
diff --git a/packages/node-renderer/tests/worker.test.ts b/packages/node-renderer/tests/worker.test.ts
index bbd46d0e2..8f52ab1d0 100644
--- a/packages/node-renderer/tests/worker.test.ts
+++ b/packages/node-renderer/tests/worker.test.ts
@@ -6,10 +6,12 @@ import worker, { disableHttp2 } from '../src/worker';
 import packageJson from '../../../package.json';
 import {
   BUNDLE_TIMESTAMP,
+  SECONDARY_BUNDLE_TIMESTAMP,
   createVmBundle,
   resetForTest,
   vmBundlePath,
   getFixtureBundle,
+  getFixtureSecondaryBundle,
   getFixtureAsset,
   getOtherFixtureAsset,
   createAsset,
@@ -59,8 +61,38 @@ describe('worker', () => {
     expect(res.headers['cache-control']).toBe('public, max-age=31536000');
     expect(res.payload).toBe('{"html":"Dummy Object"}');
     expect(fs.existsSync(vmBundlePath(testName))).toBe(true);
-    expect(fs.existsSync(assetPath(testName))).toBe(true);
-    expect(fs.existsSync(assetPathOther(testName))).toBe(true);
+    expect(fs.existsSync(assetPath(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+  });
+
+  test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest', async () => {
+    const app = worker({
+      bundlePath: bundlePathForTest(),
+    });
+
+    const form = formAutoContent({
+      gemVersion,
+      protocolVersion,
+      renderingRequest: 'ReactOnRails.dummy',
+      bundle: createReadStream(getFixtureBundle()),
+      [`bundle_${SECONDARY_BUNDLE_TIMESTAMP}`]: createReadStream(getFixtureSecondaryBundle()),
+      asset1: createReadStream(getFixtureAsset()),
+      asset2: createReadStream(getOtherFixtureAsset()),
+    });
+    const res = await app
+      .inject()
+      .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
+      .payload(form.payload)
+      .headers(form.headers)
+      .end();
+    expect(res.statusCode).toBe(200);
+    expect(res.headers['cache-control']).toBe('public, max-age=31536000');
+    expect(res.payload).toBe('{"html":"Dummy Object"}');
+    expect(fs.existsSync(vmBundlePath(testName))).toBe(true);
+    expect(fs.existsSync(assetPath(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+    expect(fs.existsSync(assetPath(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true);
   });
 
   test(
@@ -168,7 +200,9 @@ describe('worker', () => {
   );
 
   test('post /asset-exists when asset exists', async () => {
-    await createAsset(testName);
+    const bundleHash = 'some-bundle-hash';
+    await createAsset(testName, bundleHash);
+
     const app = worker({
       bundlePath: bundlePathForTest(),
       password: 'my_password',
@@ -181,14 +215,20 @@ describe('worker', () => {
       .post(`/asset-exists?${query}`)
       .payload({
         password: 'my_password',
+        targetBundles: [bundleHash],
       })
       .end();
     expect(res.statusCode).toBe(200);
-    expect(res.json()).toEqual({ exists: true });
+    expect(res.json()).toEqual({
+      exists: true,
+      results: [{ bundleHash, exists: true }],
+    });
   });
 
   test('post /asset-exists when asset not exists', async () => {
-    await createAsset(testName);
+    const bundleHash = 'some-bundle-hash';
+    await createAsset(testName, bundleHash);
+
     const app = worker({
       bundlePath: bundlePathForTest(),
       password: 'my_password',
@@ -201,13 +241,63 @@ describe('worker', () => {
       .post(`/asset-exists?${query}`)
       .payload({
         password: 'my_password',
+        targetBundles: [bundleHash],
       })
       .end();
     expect(res.statusCode).toBe(200);
-    expect(res.json()).toEqual({ exists: false });
+    expect(res.json()).toEqual({
+      exists: false,
+      results: [{ bundleHash, exists: false }],
+    });
+  });
+
+  test('post /asset-exists requires targetBundles (protocol version 2.0.0)', async () => {
+    await createAsset(testName, String(BUNDLE_TIMESTAMP));
+    const app = worker({
+      bundlePath: bundlePathForTest(),
+      password: 'my_password',
+    });
+
+    const query = querystring.stringify({ filename: 'loadable-stats.json' });
+
+    const res = await app
+      .inject()
+      .post(`/asset-exists?${query}`)
+      .payload({
+        password: 'my_password',
+      })
+      .end();
+    expect(res.statusCode).toBe(400);
+
+    expect(res.payload).toContain('No targetBundles provided');
   });
 
   test('post /upload-assets', async () => {
+    const bundleHash = 'some-bundle-hash';
+
+    const app = worker({
+      bundlePath: bundlePathForTest(),
+      password: 'my_password',
+    });
+
+    const form = formAutoContent({
+      gemVersion,
+      protocolVersion,
+      password: 'my_password',
+      targetBundles: [bundleHash],
+      asset1: createReadStream(getFixtureAsset()),
+      asset2: createReadStream(getOtherFixtureAsset()),
+    });
+    const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end();
+    expect(res.statusCode).toBe(200);
+    expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true);
+  });
+
+  test('post /upload-assets with multiple bundles and assets', async () => {
+    const bundleHash = 'some-bundle-hash';
+    const bundleHashOther = 'some-other-bundle-hash';
+
     const app = worker({
       bundlePath: bundlePathForTest(),
       password: 'my_password',
@@ -217,12 +307,16 @@ describe('worker', () => {
       gemVersion,
       protocolVersion,
       password: 'my_password',
+      targetBundles: [bundleHash, bundleHashOther],
       asset1: createReadStream(getFixtureAsset()),
       asset2: createReadStream(getOtherFixtureAsset()),
     });
+
     const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end();
     expect(res.statusCode).toBe(200);
-    expect(fs.existsSync(assetPath(testName))).toBe(true);
-    expect(fs.existsSync(assetPathOther(testName))).toBe(true);
+    expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true);
+    expect(fs.existsSync(assetPath(testName, bundleHashOther))).toBe(true);
+    expect(fs.existsSync(assetPathOther(testName, bundleHashOther))).toBe(true);
   });
 });
diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile
index d3adb84a0..735c6e403 100644
--- a/spec/dummy/Gemfile
+++ b/spec/dummy/Gemfile
@@ -9,5 +9,6 @@ gem "react_on_rails_pro", path: "../.."
 
 gem "graphql"
 gem "prism-rails"
+gem "redis"
 
 gem "csso-rails", "~> 1.0"
diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock
index 2563d3150..dd04070d1 100644
--- a/spec/dummy/Gemfile.lock
+++ b/spec/dummy/Gemfile.lock
@@ -315,12 +315,16 @@ GEM
       ffi (~> 1.0)
     rdoc (6.12.0)
       psych (>= 4.0.0)
-    react_on_rails (15.0.0.alpha.2)
+    react_on_rails (15.0.0.rc.1)
       addressable
       connection_pool
       execjs (~> 2.5)
       rails (>= 5.2)
       rainbow (~> 3.0)
+    redis (5.4.0)
+      redis-client (>= 0.22.0)
+    redis-client (0.24.0)
+      connection_pool
     regexp_parser (2.9.2)
     reline (0.6.0)
       io-console (~> 0.5)
@@ -512,8 +516,9 @@ DEPENDENCIES
   pry-theme
   puma (~> 6)
   rails (~> 7.1)
-  react_on_rails (= 15.0.0.alpha.2)
+  react_on_rails (= 15.0.0.rc.1)
   react_on_rails_pro!
+  redis
   rspec-rails
   rspec-retry
   rspec_junit_formatter
diff --git a/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb b/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb
new file mode 100644
index 000000000..783e05ce4
--- /dev/null
+++ b/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module RscPostsPageOverRedisHelper
+  extend ActiveSupport::Concern
+
+  private
+
+  def artificial_delay
+    delay = params[:artificial_delay].to_i
+    # Cap delay to prevent DoS attacks
+    [delay, 10_000].min.clamp(0, 10_000)
+  end
+
+  def write_posts_and_comments_to_redis(redis)
+    posts = fetch_posts
+    add_posts_to_stream(redis, posts)
+    write_comments_for_posts_to_redis(redis, posts)
+    redis.xadd("stream:#{@request_id}", { "end" => "true" })
+  end
+
+  def add_posts_to_stream(redis, posts)
+    Rails.logger.info "Adding posts to stream #{@request_id}"
+    redis.xadd("stream:#{@request_id}", { ":posts" => posts.to_json })
+  end
+
+  def write_comments_for_posts_to_redis(redis, posts)
+    posts.each do |post|
+      post_comments = fetch_post_comments(post, [])
+      redis.xadd("stream:#{@request_id}", { ":comments:#{post[:id]}" => post_comments.to_json })
+      post_comments.each do |comment|
+        user = fetch_comment_user(comment)
+        redis.xadd("stream:#{@request_id}", { ":user:#{comment[:user_id]}" => user.to_json })
+      end
+    end
+  end
+
+  def fetch_posts
+    posts = Post.with_delay(artificial_delay)
+    posts.group_by { |post| post[:user_id] }.map { |_, user_posts| user_posts.first }
+  end
+
+  def fetch_post_comments(post, all_posts_comments)
+    post_id = post["id"]
+    post_comments = Comment.with_delay(artificial_delay).where(post_id: post_id)
+    all_posts_comments.concat(post_comments)
+    post_comments
+  end
+
+  def fetch_comment_user(comment)
+    user_id = comment["user_id"]
+    User.with_delay(artificial_delay).find(user_id)
+  end
+end
diff --git a/spec/dummy/app/controllers/pages_controller.rb b/spec/dummy/app/controllers/pages_controller.rb
index 9d59f7b5f..c1e3492fd 100644
--- a/spec/dummy/app/controllers/pages_controller.rb
+++ b/spec/dummy/app/controllers/pages_controller.rb
@@ -2,6 +2,7 @@
 
 class PagesController < ApplicationController
   include ReactOnRailsPro::RSCPayloadRenderer
+  include RscPostsPageOverRedisHelper
 
   XSS_PAYLOAD = { "" => '' }.freeze
   PROPS_NAME = "Mr. Server Side Rendering"
@@ -40,27 +41,54 @@ def stream_async_components_for_testing
     stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing")
   end
 
-  def rsc_posts_page
-    stream_view_containing_react_components(template: "/pages/rsc_posts_page")
+  def rsc_posts_page_over_http
+    stream_view_containing_react_components(template: "/pages/rsc_posts_page_over_http")
   end
 
-  def posts_page # rubocop:disable Metrics/AbcSize
-    artificial_delay = params[:artificial_delay] || 0
-    posts = JSON.parse(HTTPX.get("http://localhost:3000/api/posts").body, symbolize_names: true)
-    # pick one post per user
-    posts = posts.group_by { |post| post[:user_id] }.map { |_, user_posts| user_posts.first }
-    posts = posts.map do |post|
-      comments = JSON.parse(HTTPX.get("http://localhost:3000/api/posts/#{post[:id]}/comments").body,
-                            symbolize_names: true)
-      comments = comments.map do |comment|
-        comment.merge(user: JSON.parse(HTTPX.get("http://localhost:3000/api/users/#{comment[:user_id]}").body,
-                                       symbolize_names: true))
-      end
-      post.merge(comments: comments)
+  def rsc_posts_page_over_redis
+    @request_id = SecureRandom.uuid
+
+    redis_thread = Thread.new do
+      redis = ::Redis.new
+      write_posts_and_comments_to_redis(redis)
     rescue StandardError => e
-      raise "Error while fetching post #{post} #{post[:id]}: #{e.message}"
+      Rails.logger.error "Error writing posts and comments to Redis: #{e.message}"
+      Rails.logger.error e.backtrace.join("\n")
+      raise e
+    end
+
+    stream_view_containing_react_components(template: "/pages/rsc_posts_page_over_redis")
+
+    return if redis_thread.join(10)
+
+    Rails.logger.error "Redis thread timed out"
+    raise "Redis thread timed out"
+  end
+
+  def async_on_server_sync_on_client
+    @render_on_server = true
+    stream_view_containing_react_components(template: "/pages/async_on_server_sync_on_client")
+  end
+
+  def async_on_server_sync_on_client_client_render
+    @render_on_server = false
+    render "/pages/async_on_server_sync_on_client"
+  end
+
+  def server_router
+    stream_view_containing_react_components(template: "/pages/server_router")
+  end
+
+  def posts_page
+    posts = fetch_posts.as_json
+    posts.each do |post|
+      post_comments = fetch_post_comments(post, []).as_json
+      post_comments.each do |comment|
+        comment["user"] = fetch_comment_user(comment).as_json
+      end
+      post["comments"] = post_comments
     end
-    sleep artificial_delay.to_i / 1000 * 2
+
     @posts = posts
     render "/pages/posts_page"
   end
diff --git a/spec/dummy/app/models/application_record.rb b/spec/dummy/app/models/application_record.rb
index 71fbba5b3..55476b269 100644
--- a/spec/dummy/app/models/application_record.rb
+++ b/spec/dummy/app/models/application_record.rb
@@ -2,4 +2,9 @@
 
 class ApplicationRecord < ActiveRecord::Base
   self.abstract_class = true
+
+  scope :with_delay, lambda { |ms|
+    sleep(ms / 1000.0)
+    all
+  }
 end
diff --git a/spec/dummy/app/views/layouts/application.html.erb b/spec/dummy/app/views/layouts/application.html.erb
index e874253a8..9b57229e7 100644
--- a/spec/dummy/app/views/layouts/application.html.erb
+++ b/spec/dummy/app/views/layouts/application.html.erb
@@ -2,11 +2,13 @@
 
 
   
-  
-  
-  
-  
-  
+  <% unless request.path.include?('posts_page') %>
+    
+    
+    
+    
+    
+  <% end %>
   <% if content_for?(:title) %>
     <%= yield(:title) %>
   <% else %>
diff --git a/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb b/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb
new file mode 100644
index 000000000..07d613c11
--- /dev/null
+++ b/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb
@@ -0,0 +1,79 @@
+<%= send(@render_on_server ? :stream_react_component : :react_component, "AsyncOnServerSyncOnClient", 
+  props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), 
+  trace: true,
+  prerender: @render_on_server,
+  id: "AsyncOnServerSyncOnClient-react-component-0") %>
+
+ +
+
+

Understanding Server/Client Component Hydration Patterns

+ +

This page demonstrates an important React on Rails pattern: components that behave asynchronously on the server but synchronously on the client during hydration.

+ +

Component Implementation

+ +

+<%= File.read(Rails.root.join "client/app/components/AsyncOnServerSyncOnClient.tsx") %>
+
+ +

Key Implementation Detail

+ +

+const ComponentToUse = typeof window === 'undefined' ? AsyncComponentOnServer : SyncComponentOnClient;
+
+ +

This implementation uses environment detection to conditionally render:

+
    +
  • AsyncComponentOnServer: An asynchronous component that waits for promises to resolve before rendering children (server-side)
  • +
  • SyncComponentOnClient: A synchronous component that renders children immediately (client-side)
  • +
+ +

Client-Only Rendering Behavior

+ +

View the client-only rendering example to observe:

+ +
    +
  • No server-side rendering (page source lacks component HTML)
  • +
  • Immediate rendering of all client components (no loading states)
  • +
  • The Server Component inside the fourth Suspense boundary makes a separate HTTP request to /rsc_payload/SimpleComponent to fetch its RSC payload
  • +
+ +

Server Rendering + Client Hydration Behavior

+ +

This current page demonstrates the server-rendering with client hydration pattern:

+ +
    +
  • Complete server-side rendering (page source contains all component HTML)
  • +
  • Progressive component rendering with appropriate loading states: +
      +
    • First Suspense boundary: Content appears after 2000ms (contains components with 1000ms and 2000ms delays)
    • +
    • Second Suspense boundary: Content appears after 3000ms
    • +
    • Third Suspense boundary: Content appears after 1000ms
    • +
    • Server Component: Appears after 2000ms delay
    • +
    +
  • +
  • No additional HTTP requests for RSC payloads as they're embedded directly in the HTML
  • +
  • Component lifecycle events occur in alignment with their respective render delays (check browser console)
  • +
+ +

RSC Payload Optimization

+ +

When a server component is rendered via server-side rendering, the React on Rails framework optimizes performance by embedding its RSC payload directly in the HTML rather than fetching it through a separate HTTP request.

+ +

How RSC Payloads Work

+ +

Server components are integrated into client components using the RSCRoute component:

+ +

+
+
+ +

The RSCRoute component leverages React on Rails to:

+
    +
  1. Insert an HTML marker indicating an embedded RSC payload will follow
  2. +
  3. During hydration, detect this marker and use the embedded payload
  4. +
  5. Skip additional HTTP requests that would otherwise be required
  6. +
+ +

This optimization significantly improves initial page load performance while maintaining component integrity across server and client environments.

diff --git a/spec/dummy/app/views/pages/posts_page.html.erb b/spec/dummy/app/views/pages/posts_page.html.erb index 96bf64dcc..936391580 100644 --- a/spec/dummy/app/views/pages/posts_page.html.erb +++ b/spec/dummy/app/views/pages/posts_page.html.erb @@ -1,5 +1,9 @@ <%= react_component("PostsPage", - props: @app_props_server_render.merge(posts: @posts), + props: @app_props_server_render.merge( + artificialDelay: params[:artificial_delay] || 0, + postsCount: params[:posts_count] || 2, + posts: @posts + ), prerender: true, trace: true, cache: false, diff --git a/spec/dummy/app/views/pages/rsc_posts_page.html.erb b/spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb similarity index 74% rename from spec/dummy/app/views/pages/rsc_posts_page.html.erb rename to spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb index efdb75da1..59c35a7fb 100644 --- a/spec/dummy/app/views/pages/rsc_posts_page.html.erb +++ b/spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb @@ -1,8 +1,8 @@ -<%= stream_react_component("RSCPostsPage", +<%= stream_react_component("RSCPostsPageOverHTTP", props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), prerender: true, trace: true, - id: "RSCPostsPage-react-component-0") %> + id: "RSCPostsPageOverHTTP-react-component-0") %>

Artificial delay: <%= params[:artificial_delay] %>

diff --git a/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb b/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb new file mode 100644 index 000000000..0dd449eec --- /dev/null +++ b/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb @@ -0,0 +1,13 @@ +<%= stream_react_component("RSCPostsPageOverRedis", + props: @app_props_server_render.merge( + artificialDelay: params[:artificial_delay] || 0, + postsCount: params[:posts_count] || 2, + requestId: @request_id + ), + prerender: true, + trace: true, + id: "RSCPostsPageOverRedis-react-component-0") %> +
+ +

Artificial delay: <%= params[:artificial_delay] %>

+

React Rails Server Streaming Server Rendered RSC Components

diff --git a/spec/dummy/app/views/pages/server_router.html.erb b/spec/dummy/app/views/pages/server_router.html.erb new file mode 100644 index 000000000..c5b3cb405 --- /dev/null +++ b/spec/dummy/app/views/pages/server_router.html.erb @@ -0,0 +1,7 @@ +<%= stream_react_component("ServerComponentRouter", + props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), + trace: true, + id: "ServerComponentRouter-react-component-0") %> +
+ +

React Rails Server Streaming Server Rendered RSC Components

diff --git a/spec/dummy/app/views/pages/server_router_client_render.html.erb b/spec/dummy/app/views/pages/server_router_client_render.html.erb new file mode 100644 index 000000000..878b56cad --- /dev/null +++ b/spec/dummy/app/views/pages/server_router_client_render.html.erb @@ -0,0 +1,8 @@ +<%= react_component("ServerComponentRouter", + props: @app_props_server_render.merge(basePath: 'server_router_client_render', artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), + prerender: false, + trace: true, + id: "ServerComponentRouter-react-component-0") %> +
+ +

React Rails Server Client Side Rendering of React Router containing Server Components

diff --git a/spec/dummy/app/views/pages/stream_async_components_for_testing.html.erb b/spec/dummy/app/views/pages/stream_async_components_for_testing.html.erb index 61dd2df29..9a2a11975 100644 --- a/spec/dummy/app/views/pages/stream_async_components_for_testing.html.erb +++ b/spec/dummy/app/views/pages/stream_async_components_for_testing.html.erb @@ -1,5 +1,10 @@ <%# This file is used for testing only, it doesn't contain external http requests like stream_async_components.html.erb %> -<%= stream_react_component("AsyncComponentsTreeForTesting", props: @app_props_server_render, prerender: true, trace: true, id: "AsyncComponentsTreeForTesting-react-component-0") %> +<%= stream_react_component( + "AsyncComponentsTreeForTesting", + props: @app_props_server_render.merge(params.permit(:throwSyncError, :throwAsyncError)), + trace: true, + id: "AsyncComponentsTreeForTesting-react-component-0", +) %>

React Rails Server Streaming Server Rendered Async React Components Tree For Testing

diff --git a/spec/dummy/app/views/pages/stream_async_components_for_testing_client_render.html.erb b/spec/dummy/app/views/pages/stream_async_components_for_testing_client_render.html.erb new file mode 100644 index 000000000..87fc50bed --- /dev/null +++ b/spec/dummy/app/views/pages/stream_async_components_for_testing_client_render.html.erb @@ -0,0 +1,5 @@ +<%# This file is used for testing only, it doesn't contain external http requests like stream_async_components.html.erb %> +<%= react_component("AsyncComponentsTreeForTesting", props: @app_props_server_render, prerender: false, trace: true, id: "AsyncComponentsTreeForTesting-react-component-0") %> +
+ +

React Rails Client Rendered Async React Components Tree For Testing

diff --git a/spec/dummy/app/views/shared/_menu.erb b/spec/dummy/app/views/shared/_menu.erb index 4741cd3a8..5d02e4f28 100644 --- a/spec/dummy/app/views/shared/_menu.erb +++ b/spec/dummy/app/views/shared/_menu.erb @@ -11,7 +11,22 @@ end <%= link_to "Streaming async React components", stream_async_components_path, class: cp(stream_async_components_path) %>
  • - <%= link_to "RSC Posts Page", rsc_posts_page_path, class: cp(rsc_posts_page_path) %> + <%= link_to "RSC Posts Page Over HTTP", rsc_posts_page_over_http_path, class: cp(rsc_posts_page_over_http_path) %> +
  • +
  • + <%= link_to "RSC Posts Page Over Redis", rsc_posts_page_over_redis_path, class: cp(rsc_posts_page_over_redis_path) %> +
  • +
  • + <%= link_to "Async On Server Sync On Client", async_on_server_sync_on_client_path, class: cp(async_on_server_sync_on_client_path) %> +
  • +
  • + <%= link_to "Async On Server Sync On Client Client Render", async_on_server_sync_on_client_client_render_path, class: cp(async_on_server_sync_on_client_client_render_path) %> +
  • +
  • + <%= link_to "Server Router", server_router_path, class: cp(server_router_path) %> +
  • +
  • + <%= link_to "Server Router Client Render", server_router_client_render_path, class: cp(server_router_client_render_path) %>
  • <%= link_to "Posts Page", posts_page_path, class: cp(posts_page_path) %> diff --git a/spec/dummy/client/app/components/AsyncOnServerSyncOnClient.tsx b/spec/dummy/client/app/components/AsyncOnServerSyncOnClient.tsx new file mode 100644 index 000000000..14f4f8d88 --- /dev/null +++ b/spec/dummy/client/app/components/AsyncOnServerSyncOnClient.tsx @@ -0,0 +1,95 @@ +'use client'; + +import * as React from 'react'; +import { Suspense, useEffect } from 'react'; +import RSCRoute from 'react-on-rails/RSCRoute'; + +const AsyncComponentOnServer = async ({ + promise, + children, +}: { + promise: Promise; + children: React.ReactNode; +}) => { + await promise; + return children; +}; + +const SyncComponentOnClient = ({ children }: { children: React.ReactNode }) => { + return children; +}; + +const ComponentToUse = typeof window === 'undefined' ? AsyncComponentOnServer : SyncComponentOnClient; + +const LoadingComponent = ({ content }: { content: string }) => { + console.log(`[AsyncOnServerSyncOnClient] LoadingComponent rendered ${content}`); + return
    {content}
    ; +}; + +const RealComponent = ({ content, children }: { content: string; children?: React.ReactNode }) => { + console.log(`[AsyncOnServerSyncOnClient] RealComponent rendered ${content}`); + useEffect(() => { + console.log(`[AsyncOnServerSyncOnClient] RealComponent has been mounted ${content}`); + }, [content]); + return
    {children ?? content}
    ; +}; + +function AsyncContent() { + console.log('[AsyncOnServerSyncOnClient] AsyncContent rendered'); + const promise1 = new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 1000); + }); + const promise2 = new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 2000); + }); + const promise3 = new Promise((resolve) => { + setTimeout(() => { + resolve(undefined); + }, 3000); + }); + + useEffect(() => { + console.log('[AsyncOnServerSyncOnClient] AsyncContent has been mounted'); + }, []); + + return ( +
    + }> + {/* @ts-expect-error - ComponentToUse is conditionally typed based on environment */} + + + + {/* @ts-expect-error - ComponentToUse is conditionally typed based on environment */} + + + + + }> + {/* @ts-expect-error - ComponentToUse is conditionally typed based on environment */} + + + + + }> + {/* @ts-expect-error - ComponentToUse is conditionally typed based on environment */} + + + + + }> + {/* @ts-expect-error - ComponentToUse is conditionally typed based on environment */} + + + + + + +
    + ); +} + +export default AsyncContent; diff --git a/spec/dummy/client/app/components/ErrorBoundary.ts b/spec/dummy/client/app/components/ErrorBoundary.ts deleted file mode 100644 index c01018171..000000000 --- a/spec/dummy/client/app/components/ErrorBoundary.ts +++ /dev/null @@ -1,3 +0,0 @@ -'use client'; - -export { ErrorBoundary } from 'react-error-boundary'; diff --git a/spec/dummy/client/app/components/ErrorBoundary.tsx b/spec/dummy/client/app/components/ErrorBoundary.tsx new file mode 100644 index 000000000..7b175db23 --- /dev/null +++ b/spec/dummy/client/app/components/ErrorBoundary.tsx @@ -0,0 +1,11 @@ +'use client'; + +import React from 'react'; +import { ErrorBoundary as ErrorBoundaryLib } from 'react-error-boundary'; +import ErrorComponent from './ErrorComponent'; + +export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +export default ErrorBoundary; diff --git a/spec/dummy/client/app/components/ErrorComponent.jsx b/spec/dummy/client/app/components/ErrorComponent.tsx similarity index 66% rename from spec/dummy/client/app/components/ErrorComponent.jsx rename to spec/dummy/client/app/components/ErrorComponent.tsx index 1b89903d0..860f5aafb 100644 --- a/spec/dummy/client/app/components/ErrorComponent.jsx +++ b/spec/dummy/client/app/components/ErrorComponent.tsx @@ -2,11 +2,11 @@ import React from 'react'; -const ErrorComponent = ({ error }) => { +const ErrorComponent = ({ error }: { error: Error }) => { return (

    Error happened while rendering RSC Page

    -

    {error?.message ?? error}

    +

    {error.message}

    ); }; diff --git a/spec/dummy/client/app/components/HelloWorldHooks.jsx b/spec/dummy/client/app/components/HelloWorldHooks.jsx index 6d4d2a467..79cd34e89 100644 --- a/spec/dummy/client/app/components/HelloWorldHooks.jsx +++ b/spec/dummy/client/app/components/HelloWorldHooks.jsx @@ -1,5 +1,3 @@ -'use client'; - // Super simple example of the simplest possible React component import React, { useState } from 'react'; // TODO: CSS modules need to be configured to work properly with React Server Components (RSC) diff --git a/spec/dummy/client/app/components/HelloWorldHooksForServerComponents.jsx b/spec/dummy/client/app/components/HelloWorldHooksForServerComponents.jsx new file mode 100644 index 000000000..edd1c44dc --- /dev/null +++ b/spec/dummy/client/app/components/HelloWorldHooksForServerComponents.jsx @@ -0,0 +1,12 @@ +'use client'; + +// This file serves as a thin wrapper around HelloWorldHooks to optimize bundle size. +// When HelloWorldHooks is imported directly by other client components (like PostsPage), +// the resulting bundle includes all dependent code. However, server components using +// HelloWorldHooks don't need this additional code. By importing from this wrapper file +// instead, server components will only receive the minimal bundle containing just +// HelloWorldHooks, improving performance and reducing unnecessary code transfer. + +import HelloWorldHooks from './HelloWorldHooks'; + +export default HelloWorldHooks; diff --git a/spec/dummy/client/app/components/RSCPostsPage/Comment.jsx b/spec/dummy/client/app/components/RSCPostsPage/Comment.jsx index b026756c0..8de480d79 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/Comment.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/Comment.jsx @@ -1,12 +1,12 @@ import React, { Suspense } from 'react'; import User from './User'; -const Comment = ({ comment }) => { +const Comment = ({ comment, fetchUser }) => { return (

    {comment.body}

    Loading User...
    }> - + ); diff --git a/spec/dummy/client/app/components/RSCPostsPage/Comments.jsx b/spec/dummy/client/app/components/RSCPostsPage/Comments.jsx index b38cff7d0..32c86dea3 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/Comments.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/Comments.jsx @@ -1,11 +1,10 @@ import React from 'react'; -import fetch from 'node-fetch'; import _ from 'lodash'; -import ToggleContainer from './ToggleContainer'; +import ToggleContainer from './ToggleContainerForServerComponents'; import Comment from './Comment'; -const Comments = async ({ postId, artificialDelay }) => { - const postComments = await (await fetch(`http://localhost:3000/api/posts/${postId}/comments`)).json(); +const Comments = async ({ postId, artificialDelay, fetchComments, fetchUser }) => { + const postComments = await fetchComments(postId); await new Promise((resolve) => { setTimeout(resolve, artificialDelay); }); @@ -22,7 +21,7 @@ const Comments = async ({ postId, artificialDelay }) => {

    Comments

    {postComments.map((comment) => ( - + ))} diff --git a/spec/dummy/client/app/components/RSCPostsPage/Main.jsx b/spec/dummy/client/app/components/RSCPostsPage/Main.jsx new file mode 100644 index 000000000..0048c44ef --- /dev/null +++ b/spec/dummy/client/app/components/RSCPostsPage/Main.jsx @@ -0,0 +1,27 @@ +import React, { Suspense } from 'react'; +import { ErrorBoundary } from '../ErrorBoundary'; +import Posts from './Posts'; +import HelloWorld from '../HelloWorldHooksForServerComponents'; +import Spinner from '../Spinner'; + +const RSCPostsPage = ({ artificialDelay, postsCount, fetchPosts, fetchComments, fetchUser, ...props }) => { + return ( + +
    + +

    RSC Posts Page

    + }> + + +
    +
    + ); +}; + +export default RSCPostsPage; diff --git a/spec/dummy/client/app/components/RSCPostsPage/Post.jsx b/spec/dummy/client/app/components/RSCPostsPage/Post.jsx index 37f91d571..5945726bc 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/Post.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/Post.jsx @@ -3,7 +3,7 @@ import moment from 'moment'; import Comments from './Comments'; import Spinner from '../Spinner'; -const Post = ({ post, artificialDelay }) => { +const Post = ({ post, artificialDelay, fetchComments, fetchUser }) => { // render the post with its thumbnail return (
    @@ -14,7 +14,12 @@ const Post = ({ post, artificialDelay }) => {

    {post.title} }> - +
    ); diff --git a/spec/dummy/client/app/components/RSCPostsPage/Posts.jsx b/spec/dummy/client/app/components/RSCPostsPage/Posts.jsx index 57b06c2a2..50d94b2d5 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/Posts.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/Posts.jsx @@ -1,13 +1,12 @@ import React from 'react'; -import fetch from 'node-fetch'; import _ from 'lodash'; import Post from './Post'; -const Posts = async ({ artificialDelay, postsCount = 2 }) => { +const Posts = async ({ artificialDelay, postsCount = 2, fetchPosts, fetchComments, fetchUser }) => { await new Promise((resolve) => { setTimeout(resolve, artificialDelay); }); - const posts = await (await fetch(`http://localhost:3000/api/posts`)).json(); + const posts = await fetchPosts(); const postsByUser = _.groupBy(posts, 'user_id'); const onePostPerUser = _.map(postsByUser, (group) => group[0]); const postsToShow = onePostPerUser.slice(0, postsCount); @@ -15,7 +14,13 @@ const Posts = async ({ artificialDelay, postsCount = 2 }) => { return (
    {postsToShow.map((post) => ( - + ))}
    ); diff --git a/spec/dummy/client/app/components/RSCPostsPage/PreloadedComments.jsx b/spec/dummy/client/app/components/RSCPostsPage/PreloadedComments.jsx index 9c60f1f3b..2a8fcf815 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/PreloadedComments.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/PreloadedComments.jsx @@ -1,7 +1,7 @@ import React from 'react'; import _ from 'lodash'; -import ToggleContainer from './ToggleContainer'; import PreloadedComment from './PreloadedComment'; +import ToggleContainer from './ToggleContainer'; const PreloadedComments = ({ post }) => { const postComments = post.comments; diff --git a/spec/dummy/client/app/components/RSCPostsPage/PreloadedPosts.jsx b/spec/dummy/client/app/components/RSCPostsPage/PreloadedPosts.jsx index c473ab352..e3b38b520 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/PreloadedPosts.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/PreloadedPosts.jsx @@ -2,17 +2,18 @@ import React from 'react'; import _ from 'lodash'; import PreloadedPost from './PreloadedPost'; -const PreloadedPosts = ({ posts }) => { +const PreloadedPosts = ({ posts, postsCount }) => { if (!posts || !Array.isArray(posts) || posts.length === 0) { return
    No posts found
    ; } const postsByUser = _.groupBy(posts, 'user_id'); const onePostPerUser = _.map(postsByUser, (group) => group[0]); + const postsToShow = onePostPerUser.slice(0, postsCount); return (
    - {onePostPerUser.map((post) => ( + {postsToShow.map((post) => ( ))}
    diff --git a/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx b/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx index 240fe9692..ce68fb919 100644 --- a/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx +++ b/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx @@ -1,11 +1,13 @@ -'use client'; - import React, { useState } from 'react'; const ToggleContainer = ({ children, childrenTitle }) => { const [isVisible, setIsVisible] = useState(true); const showOrHideText = isVisible ? `Hide ${childrenTitle}` : `Show ${childrenTitle}`; + React.useEffect(() => { + console.log('ToggleContainer with title', childrenTitle); + }, [childrenTitle]); + return (
    }> + + } + /> + } + /> + } /> + } + /> + } + > + } + /> + } /> + + } + /> + } /> + + + + ); +} diff --git a/spec/dummy/client/app/components/ServerComponentWithRetry.tsx b/spec/dummy/client/app/components/ServerComponentWithRetry.tsx new file mode 100644 index 000000000..87163507f --- /dev/null +++ b/spec/dummy/client/app/components/ServerComponentWithRetry.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import RSCRoute from 'react-on-rails/RSCRoute'; +import { useRSC } from 'react-on-rails/RSCProvider'; +import { isServerComponentFetchError } from 'react-on-rails/ServerComponentFetchError'; + +const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => { + const { refetchComponent } = useRSC(); + + if (isServerComponentFetchError(error)) { + const { serverComponentName, serverComponentProps } = error; + return ( +
    +
    Error happened while fetching the server component: {error.message}
    + +
    + ); + } + + return ( +
    +
    Error: {error.message}
    +
    + ); +}; + +const ServerComponentWithRetry: React.FC = () => { + const { refetchComponent } = useRSC(); + // Used to force re-render the component + const [, setKey] = useState(0); + + return ( +
    + + + + +
    + ); +}; + +export default ServerComponentWithRetry; diff --git a/spec/dummy/client/app/components/SimpleClientComponent.jsx b/spec/dummy/client/app/components/SimpleClientComponent.jsx index f6961eea8..288dd2116 100644 --- a/spec/dummy/client/app/components/SimpleClientComponent.jsx +++ b/spec/dummy/client/app/components/SimpleClientComponent.jsx @@ -5,6 +5,10 @@ import React, { useState } from 'react'; const SimpleClientComponent = ({ content }) => { const [shown, setShown] = useState(true); + React.useEffect(() => { + console.log('SimpleClientComponent mounted'); + }, []); + return (
    + ); +} diff --git a/spec/dummy/client/app/ror-auto-load-components/SimpleComponent.jsx b/spec/dummy/client/app/ror-auto-load-components/SimpleComponent.jsx index e91df5d5d..789f40ac5 100644 --- a/spec/dummy/client/app/ror-auto-load-components/SimpleComponent.jsx +++ b/spec/dummy/client/app/ror-auto-load-components/SimpleComponent.jsx @@ -1,9 +1,8 @@ import React, { Suspense } from 'react'; -import fetch from 'node-fetch'; import SimpleClientComponent from '../components/SimpleClientComponent.jsx'; -const Post = async () => { - const post = await (await fetch('https://jsonplaceholder.org/posts/1')).json(); +const Post = async ({ postPromise }) => { + const post = await postPromise; return (

    {post.title}

    @@ -13,9 +12,13 @@ const Post = async () => { }; const SimpleComponent = () => { + const postPromise = Promise.resolve({ + title: 'Post 1', + content: 'Content 1', + }); return ( Loading Post...
    }> - + ); }; diff --git a/spec/dummy/client/app/utils/redisReceiver.ts b/spec/dummy/client/app/utils/redisReceiver.ts new file mode 100644 index 000000000..beeeac14c --- /dev/null +++ b/spec/dummy/client/app/utils/redisReceiver.ts @@ -0,0 +1,341 @@ +import { createClient, RedisClientType } from 'redis'; + +/** + * Redis xRead result message structure + */ +interface RedisStreamMessage { + id: string; + message: Record; +} + +/** + * Redis xRead result structure + */ +interface RedisStreamResult { + name: string; + messages: RedisStreamMessage[]; +} + +/** + * Listener interface + */ +interface RequestListener { + getValue: (key: string) => Promise; + close: () => Promise; +} + +interface PendingPromise { + promise: Promise; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + timer: NodeJS.Timeout; + resolved?: boolean; +} + +// Shared Redis client +let sharedRedisClient: RedisClientType | null = null; +let isClientConnected = false; + +// Store active listeners by requestId +const activeListeners: Record = {}; + +// Store pending promises +const pendingPromises: Record = {}; + +/** + * Gets or creates the shared Redis client + */ +async function getRedisClient() { + if (!sharedRedisClient) { + const url = process.env.REDIS_URL || 'redis://localhost:6379'; + sharedRedisClient = createClient({ url }); + } + + if (!isClientConnected) { + await sharedRedisClient.connect(); + isClientConnected = true; + } + + return sharedRedisClient; +} + +/** + * Closes the shared Redis client + */ +async function closeRedisClient() { + if (sharedRedisClient && isClientConnected) { + await sharedRedisClient.quit(); + isClientConnected = false; + } +} + +/** + * Listens to a Redis stream for data based on a requestId + * @param requestId - The stream key to listen on + * @returns An object with a getValue function to get values by key + */ +export function listenToRequestData(requestId: string): RequestListener { + // If a listener for this requestId already exists, return it + const existingListener = activeListeners[requestId]; + if (existingListener) { + return existingListener; + } + + const receivedKeys: string[] = []; + + // Stream key for this request + const streamKey = `stream:${requestId}`; + + // IDs of messages that need to be deleted + const messagesToDelete: string[] = []; + + // Track if this listener is active + let isActive = true; + // Track if we've received the end message + let isEnded = false; + + /** + * Process a message from the Redis stream + */ + function processMessage(message: Record, messageId: string) { + // Add message to delete queue + messagesToDelete.push(messageId); + + // Check for end message + if ('end' in message) { + isEnded = true; + + // Reject any pending promises that haven't been resolved yet + Object.entries(pendingPromises).forEach(([key, pendingPromise]) => { + if (pendingPromise && !pendingPromise.resolved) { + clearTimeout(pendingPromise.timer); + pendingPromise.reject(new Error(`Key ${key} not found before stream ended`)); + pendingPromises[key] = undefined; + } + }); + + return; + } + + // Process each key-value pair in the message + Object.entries(message).forEach(([key, value]) => { + const parsedValue = JSON.parse(value) as unknown; + + // Remove colon prefix if it exists + const normalizedKey = key.startsWith(':') ? key.substring(1) : key; + receivedKeys.push(normalizedKey); + + // Resolve any pending promises for this key + const pendingPromise = pendingPromises[normalizedKey]; + if (pendingPromise) { + clearTimeout(pendingPromise.timer); + pendingPromise.resolve(parsedValue); + pendingPromise.resolved = true; // Mark as resolved + } else { + pendingPromises[normalizedKey] = { + promise: Promise.resolve(parsedValue), + resolve: () => {}, + reject: () => {}, + timer: setTimeout(() => {}, 0), + resolved: true, // Mark as resolved immediately + }; + } + }); + } + + /** + * Delete processed messages from the stream + */ + async function deleteProcessedMessages() { + if (messagesToDelete.length === 0 || !isActive) { + return; + } + + try { + const client = await getRedisClient(); + await client.xDel(streamKey, messagesToDelete); + messagesToDelete.length = 0; // Clear the array + } catch (error) { + console.error('Error deleting messages from stream:', error); + } + } + + /** + * Check for existing messages in the stream + */ + async function checkExistingMessages() { + if (!isActive) { + return; + } + + try { + const client = await getRedisClient(); + + // Read all messages from the beginning of the stream + const results = (await client.xRead({ key: streamKey, id: '0' }, { COUNT: 100 })) as + | RedisStreamResult[] + | null; + + if (results && Array.isArray(results) && results.length > 0) { + const [{ messages }] = results; + + // Process each message + for (const { id, message } of messages) { + processMessage(message, id); + } + + // Delete processed messages + await deleteProcessedMessages(); + } + } catch (error) { + console.error('Error checking existing messages:', error); + } + } + + /** + * Setup a listener for new messages in the stream + */ + async function setupStreamListener() { + if (!isActive) { + return; + } + + try { + const client = await getRedisClient(); + + // Use $ as the ID to read only new messages + let lastId = '$'; + + // Start reading from the stream + const readStream = async () => { + if (!isActive || isEnded) { + return; + } + + try { + const results = (await client.xRead( + { key: streamKey, id: lastId }, + { COUNT: 100, BLOCK: 1000 }, + )) as RedisStreamResult[] | null; + + if (results && Array.isArray(results) && results.length > 0) { + const [{ messages }] = results; + + // Process each message from the stream + for (const { id, message } of messages) { + lastId = id; // Update the last ID for subsequent reads + processMessage(message, id); + } + + // Delete processed messages + await deleteProcessedMessages(); + } + } catch (error) { + console.error('Error reading from stream:', error); + } finally { + void readStream(); + } + }; + + void readStream(); + } catch (error) { + console.error('Error setting up stream listener:', error); + } + } + + // Start listening to existing and new messages immediately + (async () => { + try { + await checkExistingMessages(); + await setupStreamListener(); + } catch (error) { + console.error('Error initializing Redis listener:', error); + } + })().catch((error: unknown) => { + console.error('Error initializing Redis listener:', error); + }); + + // Create the listener object + const listener: RequestListener = { + /** + * Gets a value for a specific key from the Redis stream + * @param key - The key to look for in the stream + * @returns A promise that resolves when the key is found + */ + getValue: async (key: string) => { + // If we already have a promise for this key, return it + const existingPromise = pendingPromises[key]; + if (existingPromise) { + return existingPromise.promise; + } + + // If we've received the end message and don't have this key, reject immediately + if (isEnded) { + return Promise.reject(new Error(`Key ${key} not available, stream has ended`)); + } + + // Create a new promise for this key + let resolvePromise: ((value: unknown) => void) | undefined; + let rejectPromise: ((reason: unknown) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + // Create a timeout that will reject the promise after 8 seconds + const timer = setTimeout(() => { + const pendingPromise = pendingPromises[key]; + if (pendingPromise) { + pendingPromise.reject( + new Error(`Timeout waiting for key: ${key}, available keys: ${receivedKeys.join(', ')}`), + ); + // Keep the pending promise in the dictionary with the error state + } + }, 8000); + + // Store the promise and its controllers + if (resolvePromise && rejectPromise) { + pendingPromises[key] = { + promise, + resolve: resolvePromise, + reject: rejectPromise, + timer, + resolved: false, // Mark as not resolved initially + }; + } + + return promise; + }, + + /** + * Closes the Redis client connection + */ + close: async () => { + isActive = false; + + // Delete this listener from active listeners + activeListeners[requestId] = undefined; + + // Reject any pending promises + Object.entries(pendingPromises).forEach(([key, pendingPromise]) => { + if (pendingPromise) { + clearTimeout(pendingPromise.timer); + pendingPromise.reject(new Error('Redis connection closed')); + pendingPromises[key] = undefined; + } + }); + + // Only close the Redis client if no other listeners are active + const hasActiveListeners = Object.values(activeListeners).some(Boolean); + if (!hasActiveListeners) { + await closeRedisClient(); + } + }, + }; + + // Store the listener in active listeners + activeListeners[requestId] = listener; + + return listener; +} diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 64eabf4da..cdad314a8 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -15,5 +15,6 @@ class Application < Rails::Application # -- all .rb files in that directory are automatically loaded. config.load_defaults 7.0 + config.middleware.use Rack::Deflater end end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 30be0d617..1ca20720b 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -23,7 +23,15 @@ get "stream_async_components" => "pages#stream_async_components", as: :stream_async_components get "stream_async_components_for_testing" => "pages#stream_async_components_for_testing", as: :stream_async_components_for_testing - get "rsc_posts_page" => "pages#rsc_posts_page", as: :rsc_posts_page + get "stream_async_components_for_testing_client_render" => "pages#stream_async_components_for_testing_client_render", + as: :stream_async_components_for_testing_client_render + get "rsc_posts_page_over_http" => "pages#rsc_posts_page_over_http", as: :rsc_posts_page_over_http + get "rsc_posts_page_over_redis" => "pages#rsc_posts_page_over_redis", as: :rsc_posts_page_over_redis + get "async_on_server_sync_on_client" => "pages#async_on_server_sync_on_client", as: :async_on_server_sync_on_client + get "async_on_server_sync_on_client_client_render" => "pages#async_on_server_sync_on_client_client_render", + as: :async_on_server_sync_on_client_client_render + get "server_router/(*all)" => "pages#server_router", as: :server_router + get "server_router_client_render/(*all)" => "pages#server_router_client_render", as: :server_router_client_render rsc_payload_route controller: "pages" # routes copied over from react on rails diff --git a/spec/dummy/config/webpack/clientWebpackConfig.js b/spec/dummy/config/webpack/clientWebpackConfig.js index 4fda87ef0..b373a7a48 100644 --- a/spec/dummy/config/webpack/clientWebpackConfig.js +++ b/spec/dummy/config/webpack/clientWebpackConfig.js @@ -22,6 +22,7 @@ const configureClient = () => { clientConfig.resolve.fallback = { fs: false, path: false, + stream: false, }; return clientConfig; diff --git a/spec/dummy/config/webpack/rscWebpackConfig.js b/spec/dummy/config/webpack/rscWebpackConfig.js index eb88663f4..18fc05825 100644 --- a/spec/dummy/config/webpack/rscWebpackConfig.js +++ b/spec/dummy/config/webpack/rscWebpackConfig.js @@ -1,7 +1,7 @@ const { default: serverWebpackConfig, extractLoader } = require('./serverWebpackConfig'); const configureRsc = () => { - const rscConfig = serverWebpackConfig(); + const rscConfig = serverWebpackConfig(true); // Update the entry name to be `rsc-bundle` instead of `server-bundle` const rscEntry = { diff --git a/spec/dummy/config/webpack/serverWebpackConfig.js b/spec/dummy/config/webpack/serverWebpackConfig.js index bab18c20b..6e9d4d2a8 100644 --- a/spec/dummy/config/webpack/serverWebpackConfig.js +++ b/spec/dummy/config/webpack/serverWebpackConfig.js @@ -18,7 +18,7 @@ function extractLoader(rule, loaderName) { }); } -const configureServer = () => { +const configureServer = (rscBundle = false) => { // We need to use "merge" because the clientConfigObject, EVEN after running // toWebpackConfig() is a mutable GLOBAL. Thus any changes, like modifying the // entry value will result in changing the client config! @@ -54,7 +54,9 @@ const configureServer = () => { minimize: false, }; - serverWebpackConfig.plugins.push(new RSCWebpackPlugin({ isServer: true })); + if (!rscBundle) { + serverWebpackConfig.plugins.push(new RSCWebpackPlugin({ isServer: true })); + } serverWebpackConfig.plugins.unshift(new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })); // Custom output for the server-bundle that matches the config in // config/initializers/react_on_rails.rb diff --git a/spec/dummy/package.json b/spec/dummy/package.json index bac5eba90..ceab4288b 100644 --- a/spec/dummy/package.json +++ b/spec/dummy/package.json @@ -51,12 +51,13 @@ "react-dom": "19.0.0", "react-error-boundary": "^4.1.2", "react-helmet": "^6.0.0-beta.2", - "react-on-rails": "15.0.0-alpha.2", - "react-on-rails-rsc": "19.0.0", + "react-on-rails": "15.0.0-rc.1", + "react-on-rails-rsc": "^19.0.2", "react-proptypes": "^1.0.0", "react-redux": "^9.2.0", "react-refresh": "^0.11.0", "react-router-dom": "^6.15.0", + "redis": "^5.0.1", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "sass": "^1.43.4", diff --git a/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb index 0d0321669..b833d669d 100644 --- a/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb @@ -25,6 +25,7 @@ def response; end allow(self).to receive(:request) { RequestDetails.new("http://foobar.com/development", { "HTTP_ACCEPT_LANGUAGE" => "en" }) } + allow(ReactOnRails::ReactComponent::RenderOptions).to receive(:generate_request_id).and_return("123") end let(:hash) do @@ -208,12 +209,13 @@ def response; end data-trace="true" data-dom-id="TestingStreamableComponent-react-component-0" data-force-load="true" + data-render-request-id="123" >{"helloWorldData":{"name":"Mr. Server Side Rendering"}} SCRIPT end let(:rails_context_tag) do <<-SCRIPT.strip_heredoc - + SCRIPT end let(:react_component_div_with_initial_chunk) do diff --git a/spec/dummy/spec/support/async_component_helpers.rb b/spec/dummy/spec/support/async_component_helpers.rb new file mode 100644 index 000000000..64528f6d6 --- /dev/null +++ b/spec/dummy/spec/support/async_component_helpers.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module AsyncComponentHelpers + ASYNC_COMPONENTS_DELAYS = [[1000, 2000], [3000], [1000], [2000]].freeze + + def async_component_rendered_message(suspense_boundary, component) + component_name = suspense_boundary == 3 ? "Server Component" : "Async Component #{component + 1}" + delay = ASYNC_COMPONENTS_DELAYS[suspense_boundary][component] + "RealComponent rendered #{component_name} from Suspense Boundary#{suspense_boundary + 1} " \ + "(#{delay}ms server side delay)" + end + + def async_component_hydrated_message(suspense_boundary, component) + component_name = suspense_boundary == 3 ? "Server Component" : "Async Component #{component + 1}" + delay = ASYNC_COMPONENTS_DELAYS[suspense_boundary][component] + "RealComponent has been mounted #{component_name} from " \ + "Suspense Boundary#{suspense_boundary + 1} (#{delay}ms server side delay)" + end + + def async_loading_component_message(suspense_boundary) + "LoadingComponent rendered Loading Server Component on Suspense Boundary#{suspense_boundary + 1}" + end +end + +RSpec.configure do |config| + config.include AsyncComponentHelpers, type: :system +end diff --git a/spec/dummy/spec/support/custom_navigation.rb b/spec/dummy/spec/support/custom_navigation.rb index 979b52f9b..60ad5d9a7 100644 --- a/spec/dummy/spec/support/custom_navigation.rb +++ b/spec/dummy/spec/support/custom_navigation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +FETCH_LOG_MESSAGE = "REACT_ON_RAILS_PRO_DUMMY_APP: FETCH" + module CustomNavigation def navigate_with_streaming(path, base_url = nil) base_url ||= Capybara.app_host || Capybara.current_session.server.base_url @@ -7,59 +9,140 @@ def navigate_with_streaming(path, base_url = nil) # The app must create an empty page, so we need to navigate to it first # We need to navigate to an empty page first to avoid CORS issues and to update the page host visit empty_page_url - url = URI.join(base_url, path).to_s + override_fetch_for_logging + url = URI.join(base_url, path).to_s inject_javascript_to_stream_page(url) + until finished_streaming? + chunk = next_streamed_page_chunk + break if chunk.nil? + + yield chunk if block_given? + end + end + + # Returns the next chunk of the streamed page content + # Blocks until a chunk is available or the page has finished loading + # Raises an error if no page is currently being streamed and there are no chunks to process + def next_streamed_page_chunk + # Check if we're either actively streaming or have chunks to process + raise "No page is currently being streamed. Call navigate_with_streaming first." if finished_streaming? loop do - # check if the page has content - if page.evaluate_script("window.loaded_content") - loaded_content = page.evaluate_script("window.loaded_content;") - page.evaluate_script("window.loaded_content = undefined;") - yield loaded_content + loaded_content = page.evaluate_script(<<~JS) + (function() { + const content = window.loaded_content; + window.loaded_content = undefined; + return content; + })(); + JS + if loaded_content + page.execute_script("window.processNextChunk()") + return loaded_content end - # check if the page has finished loading - if page.evaluate_script("window.finished_loading") - page.evaluate_script("window.finished_loading = false;") - break - end + # If streaming is finished and no more chunks, we're done + return nil if finished_streaming? - # Sleep briefly to avoid busy-waiting. sleep 0.1 end end + # Logs all fetch requests happening while streaming the page using the `navigate_with_streaming` method + def fetch_requests_while_streaming + logs = page.driver.browser.logs.get(:browser) + fetch_requests = logs.select { |log| log.message.include?(FETCH_LOG_MESSAGE) } + fetch_requests.map do |log| + double_stringified_fetch_info = log.message.split(FETCH_LOG_MESSAGE.to_json).last + JSON.parse(JSON.parse(double_stringified_fetch_info), symbolize_names: true) + end + end + private + def finished_streaming? + page.evaluate_script(<<~JS) + window.streaming_state === 'finished' && + window.chunkBuffer.length === 0 && + !window.loaded_content + JS + end + + def override_fetch_for_logging + page.execute_script(<<~JS) + if (typeof window.originalFetch !== 'function') { + window.originalFetch = window.fetch; + window.fetch = function(url, options) { + const stringifiedFetchInfo = JSON.stringify({ url, options }); + console.debug('#{FETCH_LOG_MESSAGE}', stringifiedFetchInfo); + return window.originalFetch(url, options); + } + } + JS + end + def inject_javascript_to_stream_page(url) js = <<-JS (function() { history.replaceState({}, '', '#{url}'); document.open(); + + // Create a buffer for chunks and initialize streaming state + window.chunkBuffer = []; + window.streaming_state = 'streaming'; + + // Define the global function to process the next chunk + window.processNextChunk = function() { + if (window.chunkBuffer.length === 0 || window.loaded_content) { + return; + } + + const chunk = window.chunkBuffer.shift(); + document.write(chunk); + window.loaded_content = chunk; + + if (window.chunkBuffer.length === 0 && window.streaming_state === 'finished') { + document.close(); + } + }; + // Fetch the actual HTML content - fetch('#{url}') + originalFetch('#{url}') .then(response => { const reader = response.body.getReader(); const decoder = new TextDecoder(); - - function readChunk() { - return reader.read().then(({ done, value }) => { - if (done) { - document.close(); - window.finished_loading = true; - return; - } - const chunk = decoder.decode(value); - document.write(chunk); - window.loaded_content = chunk; - readChunk(); - }); - } - readChunk(); + #{streaming_reader_js} }); })(); JS page.execute_script(js) end + + def streaming_reader_js + <<~JS + function readChunk() { + reader.read().then(({ done, value }) => { + if (done) { + window.streaming_state = 'finished'; + if (window.chunkBuffer.length === 0) { + document.close(); + } + return; + } + + const chunk = decoder.decode(value); + window.chunkBuffer.push(chunk); + + // If this is the first chunk, set it as loaded_content + if (window.chunkBuffer.length === 1 && !window.loaded_content) { + window.processNextChunk(); + } + + readChunk(); + }); + } + + readChunk(); + JS + end end diff --git a/spec/dummy/spec/system/integration_spec.rb b/spec/dummy/spec/system/integration_spec.rb index cbba7d6f8..b08d15f86 100644 --- a/spec/dummy/spec/system/integration_spec.rb +++ b/spec/dummy/spec/system/integration_spec.rb @@ -2,6 +2,8 @@ require "rails_helper" +RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil + def change_text_expect_dom_selector(dom_selector, expect_no_change: false) new_text = "John Doe" @@ -25,6 +27,7 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false) end end +# Basic ReactOnRails specs describe "Pages/Index", :js do subject { page } @@ -122,140 +125,6 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false) end end -describe "Pages/stream_async_components_for_testing", :js do - subject { page } - - it "renders the component" do - visit "/stream_async_components_for_testing" - expect(page).to have_text "Header for AsyncComponentsTreeForTesting" - expect(page).to have_text "Footer for AsyncComponentsTreeForTesting" - end - - it "hydrates the component" do - visit "/stream_async_components_for_testing" - expect(page.html).to include("client-bundle.js") - change_text_expect_dom_selector("#AsyncComponentsTreeForTesting-react-component-0") - end - - it "renders the page completely on server and displays content on client even without JavaScript" do - # Don't add client-bundle.js to the page to ensure that the app is not hydrated - visit "/stream_async_components_for_testing?skip_js_packs=true" - expect(page.html).not_to include("client-bundle.js") - # Ensure that the component state is not updated - change_text_expect_dom_selector("#AsyncComponentsTreeForTesting-react-component-0", expect_no_change: true) - - expect(page).not_to have_text "Loading branch1" - expect(page).not_to have_text "Loading branch2" - expect(page).not_to have_text(/Loading branch1 at level \d+/) - expect(page).to have_text(/branch1 \(level \d+\)/, count: 5) - end - - shared_examples "shows loading fallback while rendering async components" do |skip_js_packs| - it "shows the loading fallback while rendering async components" \ - "#{skip_js_packs ? ' when the page is not hydrated' : ''}" do - path = "/stream_async_components_for_testing#{skip_js_packs ? '?skip_js_packs=true' : ''}" - chunks_count = 0 - navigate_with_streaming(path) do |_content| - chunks_count += 1 - expect(page).to have_text(/Loading branch1 at level \d+/, count: 1) if chunks_count < 5 - expect(page).to have_text(/Loading branch2 at level \d+/, count: 1) if chunks_count == 1 - expect(page).not_to have_text(/Loading branch2 at level \d+/) if chunks_count > 2 - end - expect(page).not_to have_text(/Loading branch1 at level \d+/) - expect(page).not_to have_text(/Loading branch2 at level \d+/) - expect(chunks_count).to be >= 5 - - # Check if the page is hydrated or not - change_text_expect_dom_selector("#AsyncComponentsTreeForTesting-react-component-0", - expect_no_change: skip_js_packs) - end - end - - it_behaves_like "shows loading fallback while rendering async components", false - it_behaves_like "shows loading fallback while rendering async components", true - - it "replays console logs" do - visit "/stream_async_components_for_testing" - logs = page.driver.browser.logs.get(:browser) - info = logs.select { |log| log.level == "INFO" } - info_messages = info.map(&:message) - errors = logs.select { |log| log.level == "SEVERE" } - errors_messages = errors.map(&:message) - - expect(info_messages).to include(/\[SERVER\] Sync console log from AsyncComponentsTreeForTesting/) - 5.times do |i| - expect(info_messages).to include(/\[SERVER\] branch1 \(level #{i}\)/) - expect(errors_messages).to include( - /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":#{i}}"/ - ) - end - 2.times do |i| - expect(info_messages).to include(/\[SERVER\] branch2 \(level #{i}\)/) - expect(errors_messages).to include( - /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch2\\",\\"level\\":#{i}}"/ - ) - end - end - - it "replays console logs with each chunk" do - chunks_count = 0 - navigate_with_streaming("/stream_async_components_for_testing") do |content| - chunks_count += 1 - logs = page.driver.browser.logs.get(:browser) - info = logs.select { |log| log.level == "INFO" } - info_messages = info.map(&:message) - errors = logs.select { |log| log.level == "SEVERE" } - errors_messages = errors.map(&:message) - - next if content.empty? || chunks_count == 1 - - expect(info_messages).to include(/\[SERVER\] branch1 \(level \d+\)/) - expect(errors_messages).to include( - /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":\d+}/ - ) - end - expect(chunks_count).to be >= 5 - end - - it "doesn't hydrate status component if packs are not loaded" do - # visit waits for the page to load, so we ensure that the page is loaded before checking the hydration status - visit "/stream_async_components_for_testing?skip_js_packs=true" - expect(page).to have_text "HydrationStatus: Streaming server render" - expect(page).not_to have_text "HydrationStatus: Hydrated" - expect(page).not_to have_text "HydrationStatus: Page loaded" - end - - it "hydrates loaded components early before the full page is loaded" do - chunks_count = 0 - status_component_hydrated_on_chunk = nil - input_component_hydrated_on_chunk = nil - navigate_with_streaming("/stream_async_components_for_testing") do |_content| - chunks_count += 1 - expect(page).to have_text "HydrationStatus: Streaming server render" if chunks_count == 1 - - # The code that updates the states to Hydrated is executed on `useEffect` which is called only on hydration - if status_component_hydrated_on_chunk.nil? && page.has_text?("HydrationStatus: Hydrated") - status_component_hydrated_on_chunk = chunks_count - end - - if input_component_hydrated_on_chunk.nil? - begin - # Checks that the input field is hydrated - change_text_expect_dom_selector("#AsyncComponentsTreeForTesting-react-component-0") - input_component_hydrated_on_chunk = chunks_count - rescue RSpec::Expectations::ExpectationNotMetError - # Do nothing if the test fails - component not yet hydrated - end - end - end - - # The component should be hydrated before the full page is loaded - expect(status_component_hydrated_on_chunk).to be < chunks_count - expect(input_component_hydrated_on_chunk).to be < chunks_count - expect(page).to have_text "HydrationStatus: Page loaded" - end -end - describe "Pages/Pure Component", :js do subject { page } @@ -491,3 +360,472 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false) describe "2 react components, 1 store, server side, defer", :js do include_examples "React Component Shared Store", "/server_side_hello_world_shared_store_defer" end + +# ReactOnRails Pro specific specs (Streaming and RSC related) +shared_examples "streamed component tests" do |path, selector| + subject { page } + + it "renders the component" do + visit path + expect(page).to have_text "Header for AsyncComponentsTreeForTesting" + expect(page).to have_text "Footer for AsyncComponentsTreeForTesting" + end + + it "hydrates the component" do + visit path + expect(page.html).to include("client-bundle.js") + change_text_expect_dom_selector(selector) + end + + it "renders the page completely on server and displays content on client even without JavaScript" do + # Don't add client-bundle.js to the page to ensure that the app is not hydrated + visit "#{path}?skip_js_packs=true" + expect(page.html).not_to include("client-bundle.js") + # Ensure that the component state is not updated + change_text_expect_dom_selector(selector, expect_no_change: true) + + expect(page).not_to have_text "Loading branch1" + expect(page).not_to have_text "Loading branch2" + expect(page).not_to have_text(/Loading branch1 at level \d+/) + expect(page).to have_text(/branch1 \(level \d+\)/, count: 5) + end + + shared_examples "shows loading fallback while rendering async components" do |skip_js_packs| + it "shows the loading fallback while rendering async components" \ + "#{skip_js_packs ? ' when the page is not hydrated' : ''}" do + path = "#{path}#{skip_js_packs ? '?skip_js_packs=true' : ''}" + chunks_count = 0 + chunks_count_having_branch1_loading_fallback = 0 + chunks_count_having_branch2_loading_fallback = 0 + navigate_with_streaming(path) do |_content| + chunks_count += 1 + chunks_count_having_branch1_loading_fallback += 1 if page.has_text?(/Loading branch1 at level \d+/) + chunks_count_having_branch2_loading_fallback += 1 if page.has_text?(/Loading branch2 at level \d+/) + end + + expect(chunks_count_having_branch1_loading_fallback).to be_between(3, 6) + expect(chunks_count_having_branch2_loading_fallback).to be_between(1, 3) + expect(page).not_to have_text(/Loading branch1 at level \d+/) + expect(page).not_to have_text(/Loading branch2 at level \d+/) + expect(chunks_count).to be_between(5, 7) + + # Check if the page is hydrated or not + change_text_expect_dom_selector(selector, expect_no_change: skip_js_packs) + end + end + + it_behaves_like "shows loading fallback while rendering async components", false + it_behaves_like "shows loading fallback while rendering async components", true + + it "replays console logs" do + visit path + logs = page.driver.browser.logs.get(:browser) + info = logs.select { |log| log.level == "INFO" } + info_messages = info.map(&:message) + errors = logs.select { |log| log.level == "SEVERE" } + errors_messages = errors.map(&:message) + + expect(info_messages).to include(/\[SERVER\] Sync console log from AsyncComponentsTreeForTesting/) + 5.times do |i| + expect(info_messages).to include(/\[SERVER\] branch1 \(level #{i}\)/) + expect(errors_messages).to include( + /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":#{i}}"/ + ) + end + 2.times do |i| + expect(info_messages).to include(/\[SERVER\] branch2 \(level #{i}\)/) + expect(errors_messages).to include( + /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch2\\",\\"level\\":#{i}}"/ + ) + end + end + + it "replays console logs with each chunk" do + chunks_count = 0 + chunks_count_containing_server_logs = 0 + navigate_with_streaming(path) do |content| + chunks_count += 1 + logs = page.driver.browser.logs.get(:browser) + info = logs.select { |log| log.level == "INFO" } + info_messages = info.map(&:message) + errors = logs.select { |log| log.level == "SEVERE" } + errors_messages = errors.map(&:message) + + next if content.empty? || chunks_count == 1 + + if info_messages.any?(/\[SERVER\] branch1 \(level \d+\)/) && errors_messages.any?( + /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":\d+}/ + ) + chunks_count_containing_server_logs += 1 + end + end + expect(chunks_count).to be >= 5 + expect(chunks_count_containing_server_logs).to be > 2 + end + + it "doesn't hydrate status component if packs are not loaded" do + # visit waits for the page to load, so we ensure that the page is loaded before checking the hydration status + visit "#{path}?skip_js_packs=true" + expect(page).to have_text "HydrationStatus: Streaming server render" + expect(page).not_to have_text "HydrationStatus: Hydrated" + expect(page).not_to have_text "HydrationStatus: Page loaded" + end + + it "hydrates loaded components early before the full page is loaded" do + chunks_count = 0 + status_component_hydrated_on_chunk = nil + input_component_hydrated_on_chunk = nil + navigate_with_streaming(path) do |_content| + chunks_count += 1 + + # The code that updates the states to Hydrated is executed on `useEffect` which is called only on hydration + if status_component_hydrated_on_chunk.nil? && page.has_text?("HydrationStatus: Hydrated") + status_component_hydrated_on_chunk = chunks_count + end + + if input_component_hydrated_on_chunk.nil? + begin + # Checks that the input field is hydrated + change_text_expect_dom_selector(selector) + input_component_hydrated_on_chunk = chunks_count + rescue RSpec::Expectations::ExpectationNotMetError, Capybara::ElementNotFound + # Do nothing if the test fails - component not yet hydrated + end + end + end + + # The component should be hydrated before the full page is loaded + expect(status_component_hydrated_on_chunk).to be < chunks_count + expect(input_component_hydrated_on_chunk).to be < chunks_count + expect(page).to have_text "HydrationStatus: Page loaded" + end +end + +describe "Pages/stream_async_components_for_testing", :js do + it_behaves_like "streamed component tests", "/stream_async_components_for_testing", + "#AsyncComponentsTreeForTesting-react-component-0" +end + +describe "React Router Sixth Page", :js do + it_behaves_like "streamed component tests", "/server_router/streaming-server-component", + "#ServerComponentRouter-react-component-0" +end + +def rsc_payload_fetch_requests + fetch_requests_while_streaming.select { |request| request[:url].include?("/rsc_payload/") } +end + +shared_examples "RSC payload only fetched if component is not server-side rendered" do |server_rendered_path, + client_rendered_path| + before do + # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation + page.driver.browser.logs.get(:browser) + end + + it "doesn't fetch RSC payload if component is server-side rendered" do + navigate_with_streaming server_rendered_path + + expect(rsc_payload_fetch_requests).to eq([]) + end + + it "fetches RSC payload if component is client-side rendered" do + navigate_with_streaming client_rendered_path + + expect(rsc_payload_fetch_requests.size).to be > 0 + end +end + +describe "Pages/server_router/streaming-server-component rsc payload fetching", :js do + it_behaves_like "RSC payload only fetched if component is not server-side rendered", "/server_router/sixth", + "/server_router_client_render/streaming-server-component" +end + +describe "Pages/stream_async_components_for_testing rsc payload fetching", :js do + it_behaves_like "RSC payload only fetched if component is not server-side rendered", + "/stream_async_components_for_testing", "/stream_async_components_for_testing_client_render" +end + +describe "Pages/server_router", :js do + subject { page } + + it "navigates between pages" do + navigate_with_streaming("/server_router/simple-server-component") + expect_client_component_inside_server_component_hydrated(page) + expect(page).not_to have_text("Server Component Title") + expect(page).not_to have_text("Server Component Description") + expect(rsc_payload_fetch_requests).to eq([]) + + click_link "Another Simple Server Component" + expect(rsc_payload_fetch_requests).to eq([ + { url: "/rsc_payload/MyServerComponent?props=%7B%7D" } + ]) + + expect(page).to have_text("Server Component Title") + expect(page).to have_text("Server Component Description") + expect(page).not_to have_text("Post 1") + expect(page).not_to have_text("Content 1") + end + + it "streams the navigation between pages" do + navigate_with_streaming("/server_router/simple-server-component") + + click_link "Server Component with visible streaming behavior" + expect(rsc_payload_fetch_requests.first[:url]).to include("/rsc_payload/AsyncComponentsTreeForTesting") + + expect(page).not_to have_text("Post 1") + expect(page).not_to have_text("Content 1") + expect(page).to have_text("Loading branch1 at level 3...", wait: 5) + + # Client component is hydrated before the full page is loaded + expect(page).to have_text("HydrationStatus: Hydrated") + change_text_expect_dom_selector("#ServerComponentRouter-react-component-0") + + expect(page).to have_text("Loading branch1 at level 1...", wait: 5) + expect(page).to have_text("branch1 (level 1)") + expect(page).not_to have_text("Loading branch1 at level 1...") + expect(page).not_to have_text("Loading branch1 at level 3...") + end +end + +def async_on_server_sync_on_client_client_render_logs + logs = page.driver.browser.logs.get(:browser) + component_logs = logs.select { |log| log.message.include?(component_logs_tag) } + client_component_logs = component_logs.reject { |log| log.message.include?("[SERVER]") } + client_component_logs.map do |log| + # Extract string between double quotes that contains component_logs_tag + # The string can contain escaped double quotes (\"). + message = log.message.match(/"([^"]*(?:\\"[^"]*)*#{component_logs_tag}[^"]*(?:\\"[^"]*)*)"/)[1] + JSON.parse("\"#{message}\"").gsub(component_logs_tag, "").strip + end +end + +def expect_client_component_inside_server_component_hydrated(page) + expect(page).to have_text("Post 1") + expect(page).to have_text("Content 1") + expect(page).to have_button("Toggle") + + # Check that the client component is hydrated + click_button "Toggle" + expect(page).not_to have_text("Content 1") +end + +# The following two tests ensure that server components can be rendered inside client components +# and ensure that no race condition happens that make client side refetch the RSC payload +# that is already embedded in the HTML +# By ensuring that the client component is only hydrated after the server component is +# rendered and its HTML is embedded in the page +describe "Pages/async_on_server_sync_on_client_client_render", :js do + subject(:async_component) { find_by_id("AsyncOnServerSyncOnClient-react-component-0") } + + let(:component_logs_tag) { "[AsyncOnServerSyncOnClient]" } + + before do + # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation + page.driver.browser.logs.get(:browser) + end + + it "all components are rendered on client" do + chunks_count = 0 + # Nothing is rendered on the server + navigate_with_streaming("/async_on_server_sync_on_client_client_render") do |content| + next unless content.include?("Understanding Server/Client Component Hydration Patterns") + + chunks_count += 1 + # This part is rendered from the rails view + expect(content).to include("Understanding Server/Client Component Hydration Patterns") + # remove the rails view content + rails_view_index = content.index("Understanding Server/Client Component Hydration Patterns") + content = content[0...rails_view_index] + + # This part is rendered from the server component on client + expect(content).not_to include("Async Component 1 from Suspense Boundary1") + expect(content).not_to include("Async Component 1 from Suspense Boundary2") + expect(content).not_to include("Async Component 1 from Suspense Boundary3") + end + expect(chunks_count).to be <= 1 + + # After client side rendering, the component should exist in the DOM + expect(async_component).to have_text("Async Component 1 from Suspense Boundary1") + expect(async_component).to have_text("Async Component 1 from Suspense Boundary2") + expect(async_component).to have_text("Async Component 1 from Suspense Boundary3") + + # Should render "Simple Component" server component + expect(async_component).to have_text("Post 1") + expect(async_component).to have_button("Toggle") + end + + it "fetches RSC payload of the Simple Component to render it on client" do + fetch_requests_while_streaming + + navigate_with_streaming "/async_on_server_sync_on_client_client_render" + expect(async_component).to have_text("Post 1") + expect(async_component).to have_button("Toggle") + fetch_requests = fetch_requests_while_streaming + expect(fetch_requests).to eq([ + { url: "/rsc_payload/SimpleComponent?props=%7B%7D" } + ]) + end + + it "renders the client components on the client side in a sync manner" do + navigate_with_streaming "/async_on_server_sync_on_client_client_render" + + component_logs = async_on_server_sync_on_client_client_render_logs + # The last log happen if the test catched the re-render of the suspensed component on the client + expect(component_logs.size).to be_between(13, 15) + + # To understand how these logs show that components are rendered in a sync manner, + # check the component page in the dummy app `/async_on_server_sync_on_client_client_render` + expect(component_logs[0...13]).to eq([ + "AsyncContent rendered", + async_component_rendered_message(0, 0), + async_component_rendered_message(0, 1), + async_component_rendered_message(1, 0), + async_component_rendered_message(2, 0), + async_component_rendered_message(3, 0), + async_loading_component_message(3), + async_component_hydrated_message(0, 0), + async_component_hydrated_message(0, 1), + async_component_hydrated_message(1, 0), + async_component_hydrated_message(2, 0), + "AsyncContent has been mounted", + async_component_rendered_message(3, 0) + ]) + end + + it "hydrates the client component inside server component" do # rubocop:disable RSpec/NoExpectationExample + navigate_with_streaming "/async_on_server_sync_on_client_client_render" + expect_client_component_inside_server_component_hydrated(async_component) + end +end + +describe "Pages/async_on_server_sync_on_client", :js do + subject(:async_component) { find_by_id("AsyncOnServerSyncOnClient-react-component-0") } + + let(:component_logs_tag) { "[AsyncOnServerSyncOnClient]" } + + before do + # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation + page.driver.browser.logs.get(:browser) + end + + it "all components are rendered on server" do + received_server_html = "" + navigate_with_streaming("/async_on_server_sync_on_client") do |content| + received_server_html += content + end + expect(received_server_html).to include("Async Component 1 from Suspense Boundary1") + expect(received_server_html).to include("Async Component 1 from Suspense Boundary2") + expect(received_server_html).to include("Async Component 1 from Suspense Boundary3") + expect(received_server_html).to include("Post 1") + expect(received_server_html).to include("Content 1") + expect(received_server_html).to include("Toggle") + expect(received_server_html).to include( + "Understanding Server/Client Component Hydration Patterns" + ) + end + + it "doesn't fetch the RSC payload of the server component in the page" do + navigate_with_streaming "/async_on_server_sync_on_client" + expect(fetch_requests_while_streaming).to eq([]) + end + + it "hydrates the client component inside server component" do # rubocop:disable RSpec/NoExpectationExample + navigate_with_streaming "/async_on_server_sync_on_client" + expect_client_component_inside_server_component_hydrated(page) + end + + it "progressively renders the page content" do + rendering_stages_count = 0 + navigate_with_streaming "/async_on_server_sync_on_client" do |content| + # The first stage when all components are still being rendered on the server + if content.include?("Loading Suspense Boundary3") + rendering_stages_count += 1 + expect(async_component).to have_text("Loading Suspense Boundary3") + expect(async_component).to have_text("Loading Suspense Boundary2") + expect(async_component).to have_text("Loading Suspense Boundary1") + + expect(async_component).not_to have_text("Post 1") + expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary1") + expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary2") + expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary3") + # The second stage when the Suspense Boundary3 (with 1000ms delay) is rendered on the server + elsif content.include?("Async Component 1 from Suspense Boundary3") + rendering_stages_count += 1 + expect(async_component).to have_text("Async Component 1 from Suspense Boundary3") + expect(async_component).not_to have_text("Post 1") + expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary1") + expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary2") + expect(async_component).not_to have_text("Loading Suspense Boundary3") + # The third stage when the Suspense Boundary2 (with 3000ms delay) is rendered on the server + elsif content.include?("Async Component 1 from Suspense Boundary2") + rendering_stages_count += 1 + expect(async_component).to have_text("Async Component 1 from Suspense Boundary3") + expect(async_component).to have_text("Post 1") + expect(async_component).to have_text("Async Component 1 from Suspense Boundary1") + expect(async_component).to have_text("Async Component 1 from Suspense Boundary2") + expect(async_component).not_to have_text("Loading Suspense Boundary2") + + # Expect that client component is hydrated + expect(async_component).to have_text("Content 1") + expect(async_component).to have_button("Toggle") + + # Expect that the client component is hydrated + click_button "Toggle" + expect(page).not_to have_text("Content 1") + end + end + expect(rendering_stages_count).to be 3 + end + + it "doesn't hydrate client components until they are rendered on the server" do + rendering_stages_count = 0 + component_logs = [] + + navigate_with_streaming "/async_on_server_sync_on_client" do |content| + component_logs += async_on_server_sync_on_client_client_render_logs + + # The first stage when all components are still being rendered on the server + if content.include?("
    Loading Suspense Boundary3
    ") + rendering_stages_count += 1 + expect(component_logs).not_to include(async_component_rendered_message(0, 0)) + expect(component_logs).not_to include(async_component_rendered_message(1, 0)) + expect(component_logs).not_to include(async_component_rendered_message(2, 0)) + # The second stage when the Suspense Boundary3 (with 1000ms delay) is rendered on the server + elsif content.include?("
    Async Component 1 from Suspense Boundary3 (1000ms server side delay)
    ") + rendering_stages_count += 1 + expect(component_logs).to include("AsyncContent rendered") + expect(component_logs).to include("AsyncContent has been mounted") + expect(component_logs).not_to include(async_component_rendered_message(1, 0)) + # The third stage when the Suspense Boundary2 (with 3000ms delay) is rendered on the server + elsif content.include?("
    Async Component 1 from Suspense Boundary2 (3000ms server side delay)
    ") + rendering_stages_count += 1 + expect(component_logs).to include(async_component_rendered_message(1, 0)) + expect(component_logs).to include(async_component_rendered_message(2, 0)) + end + end + + expect(rendering_stages_count).to be 3 + end + + it "hydrates the client component inside server component before the full page is loaded" do + chunks_count = 0 + client_component_hydrated_on_chunk = nil + component_logs = [] + navigate_with_streaming "/async_on_server_sync_on_client" do |_content| + chunks_count += 1 + component_logs += async_on_server_sync_on_client_client_render_logs + + if client_component_hydrated_on_chunk.nil? && component_logs.include?(async_component_hydrated_message(3, 0)) + client_component_hydrated_on_chunk = chunks_count + expect_client_component_inside_server_component_hydrated(async_component) + end + end + expect(client_component_hydrated_on_chunk).to be < chunks_count + end + + it "Server component is pre-rendered on the server and not showing loading component on the client" do + navigate_with_streaming "/async_on_server_sync_on_client" + component_logs = async_on_server_sync_on_client_client_render_logs + expect(component_logs).not_to include(async_loading_component_message(3)) + end +end diff --git a/spec/dummy/yarn.lock b/spec/dummy/yarn.lock index f61268706..9a9acfc64 100644 --- a/spec/dummy/yarn.lock +++ b/spec/dummy/yarn.lock @@ -1139,6 +1139,33 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@redis/bloom@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-5.0.1.tgz#5f4c6bb2cce3908a4c3d246c69187321db9e1818" + integrity sha512-F7L+rnuJvq/upKaVoEgsf8VT7g5pLQYWRqSUOV3uO4vpVtARzSKJ7CLyJjVsQS+wZVCGxsLMh8DwAIDcny1B+g== + +"@redis/client@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-5.0.1.tgz#d39d9c0114b865e2186e46b6d891701f46b293ad" + integrity sha512-k0EJvlMGEyBqUD3orKe0UMZ66fPtfwqPIr+ZSd853sXj2EyhNtPXSx+J6sENXJNgAlEBhvD+57Dwt0qTisKB0A== + dependencies: + cluster-key-slot "1.1.2" + +"@redis/json@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-5.0.1.tgz#7eef09a2fac9e6ca6188ec34f9d313d4617f5b5b" + integrity sha512-t94HOTk5myfhvaHZzlUzk2hoUvH2jsjftcnMgJWuHL/pzjAJQoZDCUJzjkoXIUjWXuyJixTguaaDyOZWwqH2Kg== + +"@redis/search@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-5.0.1.tgz#805038a010b3d58765c65374d1a730247dfd2a5f" + integrity sha512-wipK6ZptY7K68B7YLVhP5I/wYCDUU+mDJMyJiUcQLuOs7/eKOBc8lTXKUSssor8QnzZSPy4A5ulcC5PZY22Zgw== + +"@redis/time-series@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-5.0.1.tgz#b63654dad199fcbcd8315c27d6ae46db9120c211" + integrity sha512-k6PgbrakhnohsEWEAdQZYt3e5vSKoIzpKvgQt8//lnWLrTZx+c3ed2sj0+pKIF4FvnSeuXLo4bBWcH0Z7Urg1A== + "@remix-run/router@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.17.1.tgz#bf93997beb81863fde042ebd05013a2618471362" @@ -2225,6 +2252,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +cluster-key-slot@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -5312,17 +5344,19 @@ react-is@^16.12.0, react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-on-rails-rsc@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-on-rails-rsc/-/react-on-rails-rsc-19.0.0.tgz#0061d8f3eb7cdf12a23c66985212f46e3e2ed2ef" - integrity sha512-70K46d9Zs071VgUNxZLz0ModMkPRKpSCu5XiICd/8ChMdivSyxdHEZzY4GSiztQnizzKFX7k25N0EIv/DmUPNg== +react-on-rails-rsc@^19.0.2: + version "19.0.2" + resolved "https://registry.yarnpkg.com/react-on-rails-rsc/-/react-on-rails-rsc-19.0.2.tgz#9b0077674b0b55a45ec0fb7d9d22f59fb45bf55f" + integrity sha512-0q26jcWcr6v9nfYfB4wxtAdTwEC4PCDSb/5U7TPperP4Ac9U2K7nt3uLOSVh7BX4bacX3PrpDeI1C30cIkBPog== dependencies: - react-server-dom-webpack "19.0.0" + acorn-loose "^8.3.0" + neo-async "^2.6.1" + webpack-sources "^3.2.0" -react-on-rails@15.0.0-alpha.2: - version "15.0.0-alpha.2" - resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-15.0.0-alpha.2.tgz#57c5e34f8d2fcbd2789deabf8dd126e8da15c663" - integrity sha512-0T05V9thaVV1k0VINLcjeBU67m+MMLC7pZuqijYZW6fm90n1rMYj+HVCYDhfWXx90jh/wuF744Futilll4NEoA== +react-on-rails@15.0.0-rc.1: + version "15.0.0-rc.1" + resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-15.0.0-rc.1.tgz#d8fe7c260f24dcad9ed373ac8f8b28cf3ddd89cb" + integrity sha512-px2UheLn1U2t1ubO7lGkmg44m4IAIYJJHC8BAivWCvPEpluag8VpDD7pt4miG63HosiSK6muqnkFZ3KwyyKfsg== react-proptypes@^1.0.0: version "1.0.0" @@ -5359,15 +5393,6 @@ react-router@6.24.1: dependencies: "@remix-run/router" "1.17.1" -react-server-dom-webpack@19.0.0: - version "19.0.0" - resolved "https://registry.yarnpkg.com/react-server-dom-webpack/-/react-server-dom-webpack-19.0.0.tgz#c60819b6cb54e317e675ddc0c5959ff915b789d0" - integrity sha512-hLug9KEXLc8vnU9lDNe2b2rKKDaqrp5gNiES4uyu2Up3FZfZJZmdwLFXlWzdA9gTB/6/cWduSB2K1Lfag2pSvw== - dependencies: - acorn-loose "^8.3.0" - neo-async "^2.6.1" - webpack-sources "^3.2.0" - react-side-effect@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a" @@ -5431,6 +5456,17 @@ rechoir@^0.7.0: dependencies: resolve "^1.9.0" +redis@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redis/-/redis-5.0.1.tgz#2eda8388e1350638616fa4b2dc4a9f5dbdfa1911" + integrity sha512-J8nqUjrfSq0E8NQkcHDZ4HdEQk5RMYjP3jZq02PE+ERiRxolbDNxPaTT4xh6tdrme+lJ86Goje9yMt9uzh23hQ== + dependencies: + "@redis/bloom" "5.0.1" + "@redis/client" "5.0.1" + "@redis/json" "5.0.1" + "@redis/search" "5.0.1" + "@redis/time-series" "5.0.1" + redux-thunk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" diff --git a/spec/react_on_rails_pro/request_spec.rb b/spec/react_on_rails_pro/request_spec.rb index fd317ef7d..7151b9476 100644 --- a/spec/react_on_rails_pro/request_spec.rb +++ b/spec/react_on_rails_pro/request_spec.rb @@ -131,9 +131,37 @@ second_request_body = second_request_info[:request].body.instance_variable_get(:@body) second_request_form = second_request_body.instance_variable_get(:@form) - expect(second_request_form).to have_key("bundle") - expect(second_request_form["bundle"][:body]).to be_a(FakeFS::Pathname) - expect(second_request_form["bundle"][:body].to_s).to eq(server_bundle_path) + expect(second_request_form).to have_key("bundle_server_bundle.js") + expect(second_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) + expect(second_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) + end + + it "raises duplicate bundle upload error when server asks for bundle twice" do + first_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE) do |yielder| + yielder.call("Bundle not found\n") + end + second_request_info = mock_streaming_response(render_full_url, ReactOnRailsPro::STATUS_SEND_BUNDLE) do |yielder| + yielder.call("Bundle still not found\n") + end + + stream = described_class.render_code_as_stream("/render", "console.log('Hello, world!');", is_rsc_payload: false) + expect do + stream.each_chunk do |chunk| + # Do nothing + end + end.to raise_error(ReactOnRailsPro::Error, /The bundle has already been uploaded/) + + # First request should not have a bundle + expect(first_request_info[:request].body.to_s).to include("renderingRequest=console.log") + expect(first_request_info[:request].body.to_s).not_to include("bundle") + + # Second request should have a bundle + second_request_body = second_request_info[:request].body.instance_variable_get(:@body) + second_request_form = second_request_body.instance_variable_get(:@form) + + expect(second_request_form).to have_key("bundle_server_bundle.js") + expect(second_request_form["bundle_server_bundle.js"][:body]).to be_a(FakeFS::Pathname) + expect(second_request_form["bundle_server_bundle.js"][:body].to_s).to eq(server_bundle_path) end it "raises incompatible error when server returns incompatible error" do diff --git a/yarn.lock b/yarn.lock index 1bbfe444e..0245e7306 100644 --- a/yarn.lock +++ b/yarn.lock @@ -153,6 +153,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35" integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg== +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + "@babel/helper-remap-async-to-generator@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz#e53956ab3d5b9fb88be04b3e2f31b523afd34b92" @@ -297,6 +302,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" +"@babel/plugin-syntax-import-attributes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" @@ -1585,6 +1597,33 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@redis/bloom@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-5.0.1.tgz#5f4c6bb2cce3908a4c3d246c69187321db9e1818" + integrity sha512-F7L+rnuJvq/upKaVoEgsf8VT7g5pLQYWRqSUOV3uO4vpVtARzSKJ7CLyJjVsQS+wZVCGxsLMh8DwAIDcny1B+g== + +"@redis/client@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-5.0.1.tgz#d39d9c0114b865e2186e46b6d891701f46b293ad" + integrity sha512-k0EJvlMGEyBqUD3orKe0UMZ66fPtfwqPIr+ZSd853sXj2EyhNtPXSx+J6sENXJNgAlEBhvD+57Dwt0qTisKB0A== + dependencies: + cluster-key-slot "1.1.2" + +"@redis/json@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-5.0.1.tgz#7eef09a2fac9e6ca6188ec34f9d313d4617f5b5b" + integrity sha512-t94HOTk5myfhvaHZzlUzk2hoUvH2jsjftcnMgJWuHL/pzjAJQoZDCUJzjkoXIUjWXuyJixTguaaDyOZWwqH2Kg== + +"@redis/search@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-5.0.1.tgz#805038a010b3d58765c65374d1a730247dfd2a5f" + integrity sha512-wipK6ZptY7K68B7YLVhP5I/wYCDUU+mDJMyJiUcQLuOs7/eKOBc8lTXKUSssor8QnzZSPy4A5ulcC5PZY22Zgw== + +"@redis/time-series@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-5.0.1.tgz#b63654dad199fcbcd8315c27d6ae46db9120c211" + integrity sha512-k6PgbrakhnohsEWEAdQZYt3e5vSKoIzpKvgQt8//lnWLrTZx+c3ed2sj0+pKIF4FvnSeuXLo4bBWcH0Z7Urg1A== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -2853,6 +2892,11 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== +cluster-key-slot@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -6794,10 +6838,10 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-on-rails@15.0.0-alpha.2: - version "15.0.0-alpha.2" - resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-15.0.0-alpha.2.tgz#57c5e34f8d2fcbd2789deabf8dd126e8da15c663" - integrity sha512-0T05V9thaVV1k0VINLcjeBU67m+MMLC7pZuqijYZW6fm90n1rMYj+HVCYDhfWXx90jh/wuF744Futilll4NEoA== +react-on-rails@15.0.0-rc.1: + version "15.0.0-rc.1" + resolved "https://registry.yarnpkg.com/react-on-rails/-/react-on-rails-15.0.0-rc.1.tgz#d8fe7c260f24dcad9ed373ac8f8b28cf3ddd89cb" + integrity sha512-px2UheLn1U2t1ubO7lGkmg44m4IAIYJJHC8BAivWCvPEpluag8VpDD7pt4miG63HosiSK6muqnkFZ3KwyyKfsg== readable-stream@^2.2.2: version "2.3.8" @@ -6856,6 +6900,17 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redis@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redis/-/redis-5.0.1.tgz#2eda8388e1350638616fa4b2dc4a9f5dbdfa1911" + integrity sha512-J8nqUjrfSq0E8NQkcHDZ4HdEQk5RMYjP3jZq02PE+ERiRxolbDNxPaTT4xh6tdrme+lJ86Goje9yMt9uzh23hQ== + dependencies: + "@redis/bloom" "5.0.1" + "@redis/client" "5.0.1" + "@redis/json" "5.0.1" + "@redis/search" "5.0.1" + "@redis/time-series" "5.0.1" + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9"