diff --git a/CHANGELOG.md b/CHANGELOG.md index 637f08a56..f0ba90d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### 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. +- Introduced `RSCServerRoot` and `RSCPayloadContainer` components to enable server-side rendering (SSR) of server components using RSC payload, and to embed the RSC payload directly into the page. + +[PR 1696](https://github.com/shakacode/react_on_rails/pull/1696) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + #### Added - Configuration option `generated_component_packs_loading_strategy` to control how generated component packs are loaded. It supports `sync`, `async`, and `defer` strategies. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban). diff --git a/eslint.config.ts b/eslint.config.ts index d70f0dd79..c3a535b69 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -42,6 +42,7 @@ const config = tsEslint.config([ '**/node_modules/', // fixtures '**/fixtures/', + '**/.yalc/**/*', ]), { files: ['**/*.[jt]s', '**/*.[jt]sx', '**/*.[cm][jt]s'], @@ -71,7 +72,7 @@ const config = tsEslint.config([ alias: [['Assets', './spec/dummy/client/app/assets']], node: { - extensions: ['.js', '.jsx', '.ts', '.d.ts'], + extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], }, }, }, @@ -96,6 +97,7 @@ const config = tsEslint.config([ js: 'never', jsx: 'never', ts: 'never', + tsx: 'never', }, ], @@ -128,6 +130,12 @@ const config = tsEslint.config([ 'react/jsx-props-no-spreading': 'off', 'react/static-property-placement': 'off', 'jsx-a11y/anchor-is-valid': 'off', + 'react/jsx-filename-extension': [ + 'error', + { + extensions: ['.jsx', '.tsx'], + }, + ], }, }, { diff --git a/knip.ts b/knip.ts index 6d4d898e6..9480c139d 100644 --- a/knip.ts +++ b/knip.ts @@ -9,6 +9,7 @@ const config: KnipConfig = { 'node_package/src/ReactOnRailsRSC.ts!', 'node_package/src/registerServerComponent/client.ts!', 'node_package/src/registerServerComponent/server.ts!', + 'node_package/src/registerServerComponent/server.rsc.ts!', 'node_package/src/RSCClientRoot.ts!', 'eslint.config.ts', ], diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 48e81fdba..d033b7645 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -10,6 +10,7 @@ def self.configure DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json" + DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json" DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000 def self.configuration @@ -21,6 +22,7 @@ def self.configuration server_bundle_js_file: "", rsc_bundle_js_file: "", react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE, + react_server_client_manifest_file: DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE, prerender: false, auto_load_bundle: false, replay_console: true, @@ -66,7 +68,7 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :generated_component_packs_loading_strategy, :force_load, :rsc_bundle_js_file, - :react_client_manifest_file, :component_registry_timeout + :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -82,7 +84,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, components_subdirectory: nil, auto_load_bundle: nil, force_load: nil, - rsc_bundle_js_file: nil, react_client_manifest_file: nil, component_registry_timeout: nil) + rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil, + component_registry_timeout: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root self.generated_assets_dirs = generated_assets_dirs self.generated_assets_dir = generated_assets_dir @@ -112,6 +115,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.server_bundle_js_file = server_bundle_js_file self.rsc_bundle_js_file = rsc_bundle_js_file self.react_client_manifest_file = react_client_manifest_file + self.react_server_client_manifest_file = react_server_client_manifest_file self.same_bundle_for_client_and_server = same_bundle_for_client_and_server self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size self.server_renderer_timeout = server_renderer_timeout # seconds @@ -305,8 +309,11 @@ def ensure_webpack_generated_files_exists "manifest.json", server_bundle_js_file, rsc_bundle_js_file, - react_client_manifest_file + 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 6534fede9..4c8532d31 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -358,10 +358,14 @@ def json_safe_and_pretty(hash_or_string) # second parameter passed to both component and store Render-Functions. # This method can be called from views and from the controller, as `helpers.rails_context` # - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def rails_context(server_side: true) # ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext @rails_context ||= begin + rsc_url = if ReactOnRails::Utils.react_on_rails_pro? + ReactOnRailsPro.configuration.rsc_payload_generation_url_path + end + result = { componentRegistryTimeout: ReactOnRails.configuration.component_registry_timeout, railsEnv: Rails.env, @@ -373,8 +377,11 @@ def rails_context(server_side: true) # TODO: v13 just use the version if existing rorPro: ReactOnRails::Utils.react_on_rails_pro? } + if ReactOnRails::Utils.react_on_rails_pro? result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version + + result[:rscPayloadGenerationUrl] = rsc_url if ReactOnRailsPro.configuration.enable_rsc_support end if defined?(request) && request.present? @@ -432,7 +439,7 @@ def load_pack_for_generated_component(react_component_name, render_options) append_stylesheet_pack_tag("generated/#{react_component_name}") end - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity private diff --git a/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb index f3014af8d..fbd1889a0 100644 --- a/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +++ b/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb @@ -52,6 +52,8 @@ def all_compiled_assets webpack_generated_files = @webpack_generated_files.map do |bundle_name| if bundle_name == ReactOnRails.configuration.react_client_manifest_file ReactOnRails::Utils.react_client_manifest_file_path + elsif bundle_name == ReactOnRails.configuration.react_server_client_manifest_file + ReactOnRails::Utils.react_server_client_manifest_file_path else ReactOnRails::Utils.bundle_js_file_path(bundle_name) end diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index d53a64495..5e50d4478 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -122,6 +122,15 @@ def self.react_client_manifest_file_path end end + # React Server Manifest is generated by the server bundle. + # So, it will never be served from the dev server. + 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 + @react_server_manifest_path = File.join(generated_assets_full_path, asset_name) + end + def self.running_on_windows? (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil end diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts index 7a1d09572..08b5c2fad 100644 --- a/node_package/src/RSCClientRoot.ts +++ b/node_package/src/RSCClientRoot.ts @@ -5,12 +5,15 @@ 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 } from './types/index.ts'; +import { RailsContext, RenderFunction, RSCPayloadChunk } from './types/index.ts'; +import { ensureReactUseAvailable } from './reactApis.cts'; -const { use } = React; +ensureReactUseAvailable(); -if (typeof use !== 'function') { - throw new Error('React.use is not defined. Please ensure you are using React 19 to use server components.'); +declare global { + interface Window { + REACT_ON_RAILS_RSC_PAYLOAD?: RSCPayloadChunk[]; + } } export type RSCClientRootProps = { @@ -35,6 +38,60 @@ const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath, componentProps } 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. @@ -53,7 +110,6 @@ const RSCClientRoot: RenderFunction = async ( _railsContext?: RailsContext, domNodeId?: string, ) => { - const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps }); if (!domNodeId) { throw new Error('RSCClientRoot: No domNodeId provided'); } @@ -62,8 +118,10 @@ const RSCClientRoot: RenderFunction = async ( 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 diff --git a/node_package/src/RSCPayloadContainer.tsx b/node_package/src/RSCPayloadContainer.tsx new file mode 100644 index 000000000..d1c58fe29 --- /dev/null +++ b/node_package/src/RSCPayloadContainer.tsx @@ -0,0 +1,128 @@ +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