diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 9ae346208..dd033382a 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -104,6 +104,31 @@ ReactOnRails.configure do |config| # you should include a name that matches your bundle name in your Webpack config. config.server_bundle_js_file = "server-bundle.js" + # When using React on Rails Pro with RSC support enabled, these configuration options work together: + # + # 1. In RORP, set `config.enable_rsc_support = true` in your react_on_rails_pro.rb initializer + # + # 2. The `rsc_bundle_js_file` (typically "rsc-bundle.js") contains only server components and + # references to client components. It's generated using the RSC Webpack Loader which transforms + # client components into references. This bundle is specifically used for generating RSC payloads + # and is configured with the `react-server` condition. + config.rsc_bundle_js_file = "rsc-bundle.js" + # + # 3. The `react_client_manifest_file` contains mappings for client components that need hydration. + # It's generated by the React Server Components Webpack plugin and is required for client-side + # hydration of components. + # This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename. + config.react_client_manifest_file = "react-client-manifest.json" + # + # 4. The `react_server_client_manifest_file` is used during server-side rendering with RSC to + # properly resolve references between server and client components. + # + # These files are crucial when implementing React Server Components with streaming, which offers + # benefits like reduced JavaScript bundle sizes, faster page loading, and selective hydration + # of client components. + # This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename. + config.react_server_client_manifest_file = "react-server-client-manifest.json" + # `prerender` means server-side rendering # default is false. This is an option for view helpers `render_component` and `render_component_hash`. # Set to true to change the default value to true. diff --git a/eslint.config.ts b/eslint.config.ts index 6d64fd073..297e6d703 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -164,9 +164,7 @@ const config = tsEslint.config([ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'], - // Needed because `import * as ... from` instead of `import ... from` doesn't work in this file - // for some imports. + allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.{ts,tsx}'], defaultProject: 'tsconfig.eslint.json', }, }, diff --git a/knip.ts b/knip.ts index 6c1b9d67c..7e549e2c1 100644 --- a/knip.ts +++ b/knip.ts @@ -7,10 +7,12 @@ const config: KnipConfig = { entry: [ 'node_package/src/ReactOnRails.node.ts!', 'node_package/src/ReactOnRailsRSC.ts!', - 'node_package/src/registerServerComponent/client.ts!', - 'node_package/src/registerServerComponent/server.ts!', + 'node_package/src/registerServerComponent/client.tsx!', + 'node_package/src/registerServerComponent/server.tsx!', 'node_package/src/registerServerComponent/server.rsc.ts!', - 'node_package/src/RSCClientRoot.ts!', + 'node_package/src/wrapServerComponentRenderer/server.tsx!', + 'node_package/src/wrapServerComponentRenderer/server.rsc.tsx!', + 'node_package/src/RSCRoute.ts!', 'eslint.config.ts', ], project: ['node_package/src/**/*.[jt]s{x,}!', 'node_package/tests/**/*.[jt]s{x,}'], diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index d033b7645..347314cc1 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -312,8 +312,6 @@ def ensure_webpack_generated_files_exists react_client_manifest_file, react_server_client_manifest_file ].compact_blank - - self.webpack_generated_files = files end def configure_skip_display_none_deprecation diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 4c8532d31..52844ab6d 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -651,7 +651,8 @@ def internal_react_component(react_component_name, options = {}) "data-trace" => (render_options.trace ? true : nil), "data-dom-id" => render_options.dom_id, "data-store-dependencies" => render_options.store_dependencies&.to_json, - "data-force-load" => (render_options.force_load ? true : nil)) + "data-force-load" => (render_options.force_load ? true : nil), + "data-render-request-id" => render_options.render_request_id) if render_options.force_load component_specification_tag.concat( diff --git a/lib/react_on_rails/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index ae4169162..883c860a5 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -93,14 +93,10 @@ def pack_file_contents(file_path) ReactOnRailsPro.configuration.enable_rsc_support if load_server_components && !client_entrypoint?(file_path) - rsc_payload_generation_url_path = ReactOnRailsPro.configuration.rsc_payload_generation_url_path - return <<~FILE_CONTENT.strip import registerServerComponent from 'react-on-rails/registerServerComponent/client'; - registerServerComponent({ - rscPayloadGenerationUrlPath: "#{rsc_payload_generation_url_path}", - }, "#{registered_component_name}") + registerServerComponent("#{registered_component_name}"); FILE_CONTENT end diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 9cf334257..29bfbb7c7 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -15,9 +15,16 @@ class RenderOptions def initialize(react_component_name: required("react_component_name"), options: required("options")) @react_component_name = react_component_name.camelize @options = options + # The render_request_id serves as a unique identifier for each render request. + # We cannot rely solely on dom_id, as it should be unique for each component on the page, + # but the server can render the same page multiple times concurrently for different users. + # Therefore, we need an additional unique identifier that can be used both on the client and server. + # This ID can also be used to associate specific data with a particular rendered component + # on either the server or client. + @render_request_id = self.class.generate_request_id end - attr_reader :react_component_name + attr_reader :react_component_name, :render_request_id def throw_js_errors options.fetch(:throw_js_errors, false) @@ -139,6 +146,10 @@ def store_dependencies options[:store_dependencies] end + def self.generate_request_id + SecureRandom.uuid + end + private attr_reader :options diff --git a/lib/react_on_rails/server_rendering_js_code.rb b/lib/react_on_rails/server_rendering_js_code.rb index dc807b7a2..cd0b8b677 100644 --- a/lib/react_on_rails/server_rendering_js_code.rb +++ b/lib/react_on_rails/server_rendering_js_code.rb @@ -38,6 +38,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend <<-JS (function() { var railsContext = #{rails_context}; + railsContext.componentSpecificMetadata = {renderRequestId: '#{render_options.render_request_id}'}; #{redux_stores} var props = #{props_string}; return ReactOnRails.serverRenderReactComponent({ diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index 5e50d4478..3645daaba 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -128,6 +128,11 @@ def self.react_server_client_manifest_file_path return @react_server_manifest_path if @react_server_manifest_path && !Rails.env.development? asset_name = ReactOnRails.configuration.react_server_client_manifest_file + if asset_name.nil? + raise ReactOnRails::Error, + "react_server_client_manifest_file is nil, ensure to set it in your configuration" + end + @react_server_manifest_path = File.join(generated_assets_full_path, asset_name) end diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index b4978c598..e1b5239dd 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -83,6 +83,18 @@ class ComponentRenderer { const { domNodeId } = this; const props = el.textContent !== null ? (JSON.parse(el.textContent) as Record) : {}; const trace = el.getAttribute('data-trace') === 'true'; + const renderRequestId = el.getAttribute('data-render-request-id'); + + if (!renderRequestId) { + console.error(`renderRequestId is missing for ${name} in dom node with id: ${domNodeId}`); + } + + const componentSpecificRailsContext = { + ...railsContext, + componentSpecificMetadata: { + renderRequestId: renderRequestId || '', + }, + }; try { const domNode = document.getElementById(domNodeId); @@ -93,7 +105,7 @@ class ComponentRenderer { } if ( - (await delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) || + (await delegateToRenderer(componentObj, props, componentSpecificRailsContext, domNodeId, trace)) || // @ts-expect-error The state can change while awaiting delegateToRenderer this.state === 'unmounted' ) { @@ -108,7 +120,7 @@ class ComponentRenderer { props, domNodeId, trace, - railsContext, + railsContext: componentSpecificRailsContext, shouldHydrate, }); diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts deleted file mode 100644 index 08b5c2fad..000000000 --- a/node_package/src/RSCClientRoot.ts +++ /dev/null @@ -1,133 +0,0 @@ -'use client'; - -import * as React from 'react'; -import * as ReactDOMClient from 'react-dom/client'; -import { createFromReadableStream } from 'react-on-rails-rsc/client'; -import { fetch } from './utils.ts'; -import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts'; -import { RailsContext, RenderFunction, RSCPayloadChunk } from './types/index.ts'; -import { ensureReactUseAvailable } from './reactApis.cts'; - -ensureReactUseAvailable(); - -declare global { - interface Window { - REACT_ON_RAILS_RSC_PAYLOAD?: RSCPayloadChunk[]; - } -} - -export type RSCClientRootProps = { - componentName: string; - rscPayloadGenerationUrlPath: string; - componentProps?: unknown; -}; - -const createFromFetch = async (fetchPromise: Promise) => { - const response = await fetchPromise; - const stream = response.body; - if (!stream) { - throw new Error('No stream found in response'); - } - const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream); - return createFromReadableStream(transformedStream); -}; - -const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath, componentProps }: RSCClientRootProps) => { - const propsString = JSON.stringify(componentProps); - const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, ''); - return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`)); -}; - -const createRSCStreamFromPage = () => { - let streamController: ReadableStreamController | undefined; - const stream = new ReadableStream({ - start(controller) { - if (typeof window === 'undefined') { - return; - } - const handleChunk = (chunk: RSCPayloadChunk) => { - controller.enqueue(chunk); - }; - - // The RSC payload transfer mechanism works in two possible scenarios: - // 1. RSCClientRoot executes first: - // - Initializes REACT_ON_RAILS_RSC_PAYLOAD as an empty array - // - Overrides the push function to handle incoming chunks - // - When server scripts run later, they use the overridden push function - // 2. Server scripts execute first: - // - Initialize REACT_ON_RAILS_RSC_PAYLOAD as an empty array - // - Buffer RSC payload chunks in the array - // - When RSCClientRoot runs, it reads buffered chunks and overrides push - // - // Key points: - // - The array is never reassigned, ensuring data consistency - // - The push function override ensures all chunks are properly handled - // - Execution order is irrelevant - both scenarios work correctly - if (!window.REACT_ON_RAILS_RSC_PAYLOAD) { - window.REACT_ON_RAILS_RSC_PAYLOAD = []; - } - window.REACT_ON_RAILS_RSC_PAYLOAD.forEach(handleChunk); - window.REACT_ON_RAILS_RSC_PAYLOAD.push = (...chunks) => { - chunks.forEach(handleChunk); - return chunks.length; - }; - streamController = controller; - }, - }); - - if (typeof document !== 'undefined' && document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - streamController?.close(); - }); - } else { - streamController?.close(); - } - - return stream; -}; - -const createFromRSCStream = () => { - const stream = createRSCStreamFromPage(); - const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream); - return createFromReadableStream(transformedStream); -}; - -/** - * RSCClientRoot is a React component that handles client-side rendering of React Server Components (RSC). - * It manages the fetching, caching, and rendering of RSC payloads from the server. - * - * This component: - * 1. Fetches RSC payloads from the server using the provided URL path - * 2. Caches the responses to prevent duplicate requests - * 3. Transforms the response stream to replay server-side console logs - * 4. Uses React.use() to handle the async data fetching - * - * @requires React 19+ - * @requires react-on-rails-rsc - */ -const RSCClientRoot: RenderFunction = async ( - { componentName, rscPayloadGenerationUrlPath, componentProps }: RSCClientRootProps, - _railsContext?: RailsContext, - domNodeId?: string, -) => { - if (!domNodeId) { - throw new Error('RSCClientRoot: No domNodeId provided'); - } - const domNode = document.getElementById(domNodeId); - if (!domNode) { - throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`); - } - if (domNode.innerHTML) { - const root = await createFromRSCStream(); - ReactDOMClient.hydrateRoot(domNode, root); - } else { - const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps }); - ReactDOMClient.createRoot(domNode).render(root); - } - // Added only to satisfy the return type of RenderFunction - // However, the returned value of renderFunction is not used in ReactOnRails - // TODO: fix this behavior - return ''; -}; - -export default RSCClientRoot; diff --git a/node_package/src/RSCPayloadContainer.tsx b/node_package/src/RSCPayloadContainer.tsx deleted file mode 100644 index d1c58fe29..000000000 --- a/node_package/src/RSCPayloadContainer.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from 'react'; - -type StreamChunk = { - chunk: string; - isLastChunk: boolean; -}; - -type RSCPayloadContainerProps = { - RSCPayloadStream: NodeJS.ReadableStream; -}; - -type RSCPayloadContainerInnerProps = { - chunkIndex: number; - getChunkPromise: (chunkIndex: number) => Promise; -}; - -// In JavaScript, when an escape sequence with a backslash (\) is followed by a character -// that isn't a recognized escape character, the backslash is ignored, and the character -// is treated as-is. -// This behavior allows us to use the backslash to escape characters that might be -// interpreted as HTML tags, preventing them from being processed by the HTML parser. -// For example, we can escape the comment tag