diff --git a/CHANGELOG.md b/CHANGELOG.md index 637f08a56..afe4c8b6b 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. +- Added `RSCRoute` component to enable seamless server-side rendering of React Server Components. This component automatically handles RSC payload injection and hydration, allowing server components to be rendered directly within client components while maintaining optimal performance. + +[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/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 d70f0dd79..d8f523101 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'], + }, + ], }, }, { @@ -153,7 +161,7 @@ const config = tsEslint.config([ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'], + allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.{ts,tsx}'], // Needed because `import * as ... from` instead of `import ... from` doesn't work in this file // for some imports. defaultProject: 'tsconfig.eslint.json', diff --git a/jest.config.js b/jest.config.js index 25e9afd1e..fcf1d7d37 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,8 +14,7 @@ export default { }), testEnvironment: 'jsdom', setupFiles: ['/node_package/tests/jest.setup.js'], - // React Server Components tests are compatible with React 19 - // That only run with node version 18 and above + // React Server Components tests require React 19 and only run with Node version 18 (`newest` in our CI matrix) moduleNameMapper: nodeVersion < 18 ? { diff --git a/knip.ts b/knip.ts index 6d4d898e6..4376a9f8a 100644 --- a/knip.ts +++ b/knip.ts @@ -7,9 +7,13 @@ 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/RSCClientRoot.ts!', + 'node_package/src/registerServerComponent/client.tsx!', + 'node_package/src/registerServerComponent/server.tsx!', + 'node_package/src/registerServerComponent/server.rsc.ts!', + 'node_package/src/wrapServerComponentRenderer/server.tsx!', + 'node_package/src/wrapServerComponentRenderer/server.rsc.tsx!', + 'node_package/src/RSCRoute.tsx!', + 'node_package/src/ServerComponentFetchError.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 48e81fdba..347314cc1 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,7 +309,8 @@ 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 end diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 6534fede9..10c0c9818 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -373,8 +373,14 @@ 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 + + if ReactOnRails::Utils.rsc_support_enabled? + rsc_payload_url = ReactOnRailsPro.configuration.rsc_payload_generation_url_path + result[:rscPayloadGenerationUrlPath] = rsc_payload_url + end end if defined?(request) && request.present? @@ -644,7 +650,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..3f6e510fe 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -89,18 +89,13 @@ def client_entrypoint?(file_path) def pack_file_contents(file_path) registered_component_name = component_name(file_path) - load_server_components = ReactOnRails::Utils.react_on_rails_pro? && - ReactOnRailsPro.configuration.enable_rsc_support + load_server_components = ReactOnRails::Utils.rsc_support_enabled? 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 @@ -146,8 +141,7 @@ def generated_server_pack_file_content "import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';" end - load_server_components = ReactOnRails::Utils.react_on_rails_pro? && - ReactOnRailsPro.configuration.enable_rsc_support + load_server_components = ReactOnRails::Utils.rsc_support_enabled? server_components = component_for_server_registration_to_path.keys.delete_if do |name| next true unless load_server_components diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 9cf334257..e0a0303ae 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -15,9 +15,17 @@ 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. + # This ID is only present if RSC support is enabled because it's only used in that case. + @render_request_id = self.class.generate_request_id if ReactOnRails::Utils.rsc_support_enabled? 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 +147,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/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..bca6b39a9 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -122,6 +122,20 @@ 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 + if asset_name.nil? + raise ReactOnRails::Error, + "react_server_client_manifest_file is nil, ensure it is set in your configuration" + end + + @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 @@ -199,6 +213,15 @@ def self.react_on_rails_pro_version end end + def self.rsc_support_enabled? + return false unless react_on_rails_pro? + + return @rsc_support_enabled if defined?(@rsc_support_enabled) + + rorp_config = ReactOnRailsPro.configuration + @rsc_support_enabled = rorp_config.respond_to?(:enable_rsc_support) && rorp_config.enable_rsc_support + end + def self.full_text_errors_enabled? ENV["FULL_TEXT_ERRORS"] == "true" end diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index b4978c598..51ca5460d 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'); + + // The renderRequestId is optional and only present when React Server Components (RSC) support is enabled. + // When RSC is enabled, this ID helps track and associate server-rendered components with their client-side hydration. + const componentSpecificRailsContext = renderRequestId + ? { + ...railsContext, + componentSpecificMetadata: { + renderRequestId, + }, + } + : railsContext; 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 7a1d09572..000000000 --- a/node_package/src/RSCClientRoot.ts +++ /dev/null @@ -1,75 +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 } from './types/index.ts'; - -const { use } = React; - -if (typeof use !== 'function') { - throw new Error('React.use is not defined. Please ensure you are using React 19 to use server components.'); -} - -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}`)); -}; - -/** - * 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, -) => { - const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps }); - 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) { - ReactDOMClient.hydrateRoot(domNode, root); - } else { - 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/RSCPayloadGenerator.ts b/node_package/src/RSCPayloadGenerator.ts new file mode 100644 index 000000000..2804ea969 --- /dev/null +++ b/node_package/src/RSCPayloadGenerator.ts @@ -0,0 +1,188 @@ +import { PassThrough, Readable } from 'stream'; +import { + RailsContextWithServerComponentCapabilities, + RSCPayloadStreamInfo, + RSCPayloadCallback, +} from './types/index.ts'; + +declare global { + function generateRSCPayload( + componentName: string, + props: unknown, + railsContext: RailsContextWithServerComponentCapabilities, + ): Promise; +} + +const rscPayloadStreams = new Map(); +const rscPayloadCallbacks = new Map(); + +/** + * TTL (Time To Live) tracking for RSC payload cleanup. + * This Map stores timeout IDs for automatic cleanup of RSC payload data. + * The TTL mechanism serves as a safety net to prevent memory leaks in case + * the normal cleanup path (via clearRSCPayloadStreams) is not called. + */ +const rscPayloadTTLs = new Map(); + +/** + * Default TTL duration of 5 minutes (300000 ms). + * This duration should be long enough to accommodate normal request processing + * while preventing long-term memory leaks if cleanup is missed. + */ +const DEFAULT_TTL = 300000; + +export const clearRSCPayloadStreams = (railsContext: RailsContextWithServerComponentCapabilities) => { + const { renderRequestId } = railsContext.componentSpecificMetadata; + // Close any active streams before clearing + const streams = rscPayloadStreams.get(renderRequestId); + if (streams) { + streams.forEach(({ stream }) => { + if (typeof (stream as Readable).destroy === 'function') { + (stream as Readable).destroy(); + } + }); + } + rscPayloadStreams.delete(renderRequestId); + rscPayloadCallbacks.delete(renderRequestId); + + // Clear TTL if it exists + const ttl = rscPayloadTTLs.get(renderRequestId); + if (ttl) { + clearTimeout(ttl); + rscPayloadTTLs.delete(renderRequestId); + } +}; + +/** + * Schedules automatic cleanup of RSC payload data after a TTL period. + * The TTL mechanism is necessary because: + * - It prevents memory leaks if clearRSCPayloadStreams is not called (e.g., due to errors) + * - It ensures cleanup happens even if the request is abandoned or times out + * - It provides a safety net for edge cases where the normal cleanup path might be missed + * + * @param railsContext - The Rails context containing the renderRequestId to schedule cleanup for + */ +function scheduleCleanup(railsContext: RailsContextWithServerComponentCapabilities) { + const { renderRequestId } = railsContext.componentSpecificMetadata; + // Clear any existing TTL to prevent multiple cleanup timers + const existingTTL = rscPayloadTTLs.get(renderRequestId); + if (existingTTL) { + clearTimeout(existingTTL); + } + + // Set new TTL that will trigger cleanup after DEFAULT_TTL milliseconds + const ttl = setTimeout(() => { + clearRSCPayloadStreams(railsContext); + }, DEFAULT_TTL); + + rscPayloadTTLs.set(renderRequestId, ttl); +} + +/** + * Registers a callback to be executed when RSC payloads are generated. + * + * This function: + * 1. Stores the callback function by railsContext + * 2. Immediately executes the callback for any existing streams + * + * This synchronous execution is critical for preventing hydration race conditions. + * It ensures payload array initialization happens before component HTML appears + * in the response stream. + * + * @param railsContext - Context for the current request + * @param callback - Function to call when an RSC payload is generated + */ +export const onRSCPayloadGenerated = ( + railsContext: RailsContextWithServerComponentCapabilities, + callback: RSCPayloadCallback, +) => { + const { renderRequestId } = railsContext.componentSpecificMetadata; + const callbacks = rscPayloadCallbacks.get(renderRequestId); + if (callbacks) { + callbacks.push(callback); + } else { + rscPayloadCallbacks.set(renderRequestId, [callback]); + } + + // This ensures we have a safety net even if the normal cleanup path fails + scheduleCleanup(railsContext); + + // Call callback for any existing streams for this context + const existingStreams = rscPayloadStreams.get(renderRequestId); + if (existingStreams) { + existingStreams.forEach((streamInfo) => callback(streamInfo)); + } +}; + +/** + * Generates and tracks RSC payloads for server components. + * + * getRSCPayloadStream: + * 1. Calls the global generateRSCPayload function + * 2. Tracks streams by railsContext for later injection + * 3. Notifies callbacks immediately to enable early payload embedding + * + * The immediate callback notification is critical for preventing hydration race conditions, + * as it ensures the payload array is initialized in the HTML stream before component rendering. + * + * @param componentName - Name of the server component + * @param props - Props for the server component + * @param railsContext - Context for the current request + * @returns A stream of the RSC payload + */ +export const getRSCPayloadStream = async ( + componentName: string, + props: unknown, + railsContext: RailsContextWithServerComponentCapabilities, +): Promise => { + if (typeof generateRSCPayload !== 'function') { + throw new Error( + 'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' + + 'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' + + 'is set to true.', + ); + } + + const { renderRequestId } = railsContext.componentSpecificMetadata; + const stream = await generateRSCPayload(componentName, props, railsContext); + // Tee stream to allow for multiple consumers: + // 1. stream1 - Used by React's runtime to perform server-side rendering + // 2. stream2 - Used by react-on-rails to embed the RSC payloads + // into the HTML stream for client-side hydration + const stream1 = new PassThrough(); + stream.pipe(stream1); + const stream2 = new PassThrough(); + stream.pipe(stream2); + + const streamInfo: RSCPayloadStreamInfo = { + componentName, + props, + stream: stream2, + }; + const streams = rscPayloadStreams.get(renderRequestId); + if (streams) { + streams.push(streamInfo); + } else { + rscPayloadStreams.set(renderRequestId, [streamInfo]); + } + + // Notify callbacks about the new stream in a sync manner to maintain proper hydration timing + // as described in the comment above onRSCPayloadGenerated + const callbacks = rscPayloadCallbacks.get(renderRequestId); + if (callbacks) { + callbacks.forEach((callback) => callback(streamInfo)); + } + + return stream1; +}; + +export const getRSCPayloadStreams = ( + railsContext: RailsContextWithServerComponentCapabilities, +): { + componentName: string; + props: unknown; + stream: NodeJS.ReadableStream; +}[] => { + const { renderRequestId } = railsContext.componentSpecificMetadata; + return rscPayloadStreams.get(renderRequestId) ?? []; +}; diff --git a/node_package/src/RSCProvider.tsx b/node_package/src/RSCProvider.tsx new file mode 100644 index 000000000..86979d7ba --- /dev/null +++ b/node_package/src/RSCProvider.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { RailsContextWithComponentSpecificMetadata } from './types/index.ts'; +import getReactServerComponent from './getReactServerComponent.client.ts'; +import { createRSCPayloadKey } from './utils.ts'; + +type RSCContextType = { + getComponent: (componentName: string, componentProps: unknown) => Promise; + + refetchComponent: (componentName: string, componentProps: unknown) => Promise; +}; + +const RSCContext = React.createContext(undefined); + +/** + * Creates a provider context for React Server Components. + * + * RSCProvider is a foundational component that: + * 1. Provides caching for server components to prevent redundant requests + * 2. Manages the fetching of server components through getComponent + * 3. Offers environment-agnostic access to server components + * + * This factory function accepts an environment-specific getServerComponent implementation, + * allowing it to work correctly in both client and server environments. + * + * @param railsContext - Context for the current request + * @param getServerComponent - Environment-specific function for fetching server components + * @returns A provider component that wraps children with RSC context + * + * @important This is an internal function. End users should not use this directly. + * Instead, use wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client' + * for client-side rendering or 'react-on-rails/wrapServerComponentRenderer/server' for server-side rendering. + */ +export const createRSCProvider = ({ + railsContext, + getServerComponent, +}: { + railsContext: RailsContextWithComponentSpecificMetadata; + getServerComponent: typeof getReactServerComponent; +}) => { + const fetchRSCPromises: Record> = {}; + + const getComponent = (componentName: string, componentProps: unknown) => { + const key = createRSCPayloadKey(componentName, componentProps, railsContext); + if (key in fetchRSCPromises) { + return fetchRSCPromises[key]; + } + + const promise = getServerComponent({ componentName, componentProps, railsContext }); + fetchRSCPromises[key] = promise; + return promise; + }; + + const refetchComponent = (componentName: string, componentProps: unknown) => { + const key = createRSCPayloadKey(componentName, componentProps, railsContext); + const promise = getServerComponent({ + componentName, + componentProps, + railsContext, + enforceRefetch: true, + }); + fetchRSCPromises[key] = promise; + return promise; + }; + + const contextValue = { getComponent, refetchComponent }; + + return ({ children }: { children: React.ReactNode }) => { + return {children}; + }; +}; + +/** + * Hook to access the RSC context within client components. + * + * This hook provides access to: + * - getComponent: For fetching and rendering server components + * - refetchComponent: For refetching server components + * + * It must be used within a component wrapped by RSCProvider (typically done + * automatically by wrapServerComponentRenderer). + * + * @returns The RSC context containing methods for working with server components + * @throws Error if used outside of an RSCProvider + * + * @example + * ```tsx + * const { getComponent } = useRSC(); + * const serverComponent = React.use(getComponent('MyServerComponent', props)); + * ``` + */ +export const useRSC = () => { + const context = React.useContext(RSCContext); + if (!context) { + throw new Error('useRSC must be used within a RSCProvider'); + } + return context; +}; diff --git a/node_package/src/RSCRoute.tsx b/node_package/src/RSCRoute.tsx new file mode 100644 index 000000000..eb52e2d9f --- /dev/null +++ b/node_package/src/RSCRoute.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { useRSC } from './RSCProvider.tsx'; +import { ServerComponentFetchError } from './ServerComponentFetchError.ts'; + +/** + * Error boundary component for RSCRoute that adds server component name and props to the error + * So, the parent ErrorBoundary can refetch the server component + */ +class RSCRouteErrorBoundary extends React.Component< + { children: React.ReactNode; componentName: string; componentProps: unknown }, + { error: Error | null } +> { + constructor(props: { children: React.ReactNode; componentName: string; componentProps: unknown }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + const { error } = this.state; + const { componentName, componentProps, children } = this.props; + if (error) { + throw new ServerComponentFetchError(error.message, componentName, componentProps, error); + } + + return children; + } +} + +/** + * Renders a React Server Component inside a React Client Component. + * + * RSCRoute provides a bridge between client and server components, allowing server components + * to be directly rendered inside client components. This component: + * + * 1. During initial SSR - Uses the RSC payload to render the server component and embeds the payload in the page + * 2. During hydration - Uses the embedded RSC payload already in the page + * 3. During client navigation - Fetches the RSC payload via HTTP + * + * @example + * ```tsx + * + * ``` + * + * @important Only use for server components whose props change rarely. Frequent prop changes + * will cause network requests for each change, impacting performance. + * + * @important This component expects that the component tree that contains it is wrapped using + * wrapServerComponentRenderer from 'react-on-rails/wrapServerComponentRenderer/client' for client-side + * rendering or 'react-on-rails/wrapServerComponentRenderer/server' for server-side rendering. + */ +export type RSCRouteProps = { + componentName: string; + componentProps: unknown; +}; + +const PromiseWrapper = ({ promise }: { promise: Promise }) => { + return React.use(promise); +}; + +const RSCRoute = ({ componentName, componentProps }: RSCRouteProps): React.ReactNode => { + const { getComponent } = useRSC(); + const componentPromise = getComponent(componentName, componentProps); + return ( + + + + ); +}; + +export default RSCRoute; diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 97d4e1078..d07a911f6 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -188,6 +188,14 @@ globalThis.ReactOnRails = { resetOptions(): void { this.options = { ...DEFAULT_OPTIONS }; }, + + isRSCBundle: false, + + addPostSSRHook(): void { + throw new Error( + 'addPostSSRHook is not available in "react-on-rails/client". Import "react-on-rails" server-side.', + ); + }, }; globalThis.ReactOnRails.resetOptions(); diff --git a/node_package/src/ReactOnRails.node.ts b/node_package/src/ReactOnRails.node.ts index 407d2658b..62562d2db 100644 --- a/node_package/src/ReactOnRails.node.ts +++ b/node_package/src/ReactOnRails.node.ts @@ -1,7 +1,19 @@ import ReactOnRails from './ReactOnRails.full.ts'; import streamServerRenderedReactComponent from './streamServerRenderedReactComponent.ts'; +import { + getRSCPayloadStream, + getRSCPayloadStreams, + clearRSCPayloadStreams, + onRSCPayloadGenerated, +} from './RSCPayloadGenerator.ts'; +import { addPostSSRHook } from './postSSRHooks.ts'; ReactOnRails.streamServerRenderedReactComponent = streamServerRenderedReactComponent; +ReactOnRails.getRSCPayloadStream = getRSCPayloadStream; +ReactOnRails.getRSCPayloadStreams = getRSCPayloadStreams; +ReactOnRails.clearRSCPayloadStreams = clearRSCPayloadStreams; +ReactOnRails.onRSCPayloadGenerated = onRSCPayloadGenerated; +ReactOnRails.addPostSSRHook = addPostSSRHook; export * from './ReactOnRails.full.ts'; // eslint-disable-next-line no-restricted-exports -- see https://github.com/eslint/eslint/issues/15617 diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 428787eab..ea34574a3 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -1,61 +1,84 @@ -import { renderToPipeableStream } from 'react-on-rails-rsc/server.node'; -import { PassThrough, Readable } from 'stream'; +import { BundleManifest } from 'react-on-rails-rsc'; +import { buildServerRenderer } from 'react-on-rails-rsc/server.node'; +import { Readable } from 'stream'; -import { RSCRenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts'; +import { + RSCRenderParams, + assertRailsContextWithServerComponentCapabilities, + StreamRenderState, + StreamableComponentResult, +} from './types/index.ts'; import ReactOnRails from './ReactOnRails.full.ts'; -import buildConsoleReplay from './buildConsoleReplay.ts'; import handleError from './handleError.ts'; -import { convertToError, createResultObject } from './serverRenderUtils.ts'; +import { convertToError } from './serverRenderUtils.ts'; +import { notifySSREnd, addPostSSRHook } from './postSSRHooks.ts'; import { streamServerRenderedComponent, transformRenderStreamChunksToResultObject, } from './streamServerRenderedReactComponent.ts'; -import loadReactClientManifest from './loadReactClientManifest.ts'; +import loadJsonFile from './loadJsonFile.ts'; -const stringToStream = (str: string) => { - const stream = new PassThrough(); - stream.push(str); - stream.push(null); - return stream; -}; +let serverRendererPromise: Promise> | undefined; const streamRenderRSCComponent = ( reactRenderingResult: StreamableComponentResult, options: RSCRenderParams, ): Readable => { - const { throwJsErrors, reactClientManifestFileName } = options; + const { throwJsErrors } = options; + const { railsContext } = options; + assertRailsContextWithServerComponentCapabilities(railsContext); + + const { reactClientManifestFileName } = railsContext; const renderState: StreamRenderState = { result: null, hasErrors: false, isShellReady: true, }; - const { pipeToTransform, readableStream, emitError } = + const { pipeToTransform, readableStream, emitError, writeChunk, endStream } = transformRenderStreamChunksToResultObject(renderState); - Promise.all([loadReactClientManifest(reactClientManifestFileName), reactRenderingResult]) - .then(([reactClientManifest, reactElement]) => { - const rscStream = renderToPipeableStream(reactElement, reactClientManifest, { - onError: (err) => { - const error = convertToError(err); - console.error('Error in RSC stream', error); - if (throwJsErrors) { - emitError(error); - } - renderState.hasErrors = true; - renderState.error = error; - }, - }); - pipeToTransform(rscStream); - }) - .catch((e: unknown) => { - const error = convertToError(e); - renderState.hasErrors = true; - renderState.error = error; - const htmlResult = handleError({ e: error, name: options.name, serverSide: true }); - const jsonResult = JSON.stringify(createResultObject(htmlResult, buildConsoleReplay(), renderState)); - return stringToStream(jsonResult); + + const reportError = (error: Error) => { + console.error('Error in RSC stream', error); + if (throwJsErrors) { + emitError(error); + } + renderState.hasErrors = true; + renderState.error = error; + }; + + const initializeAndRender = async () => { + if (!serverRendererPromise) { + serverRendererPromise = loadJsonFile(reactClientManifestFileName) + .then((reactClientManifest) => buildServerRenderer(reactClientManifest)) + .catch((err: unknown) => { + serverRendererPromise = undefined; + throw err; + }); + } + + const { renderToPipeableStream } = await serverRendererPromise; + const rscStream = renderToPipeableStream(await reactRenderingResult, { + onError: (err) => { + const error = convertToError(err); + reportError(error); + }, }); + pipeToTransform(rscStream); + }; + + initializeAndRender().catch((e: unknown) => { + const error = convertToError(e); + reportError(error); + const errorHtml = handleError({ e: error, name: options.name, serverSide: true }); + writeChunk(errorHtml); + endStream(); + }); + + readableStream.on('end', () => { + notifySSREnd(railsContext); + }); return readableStream; }; @@ -67,5 +90,9 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +ReactOnRails.addPostSSRHook = addPostSSRHook; + +ReactOnRails.isRSCBundle = true; + export * from './types/index.ts'; export default ReactOnRails; diff --git a/node_package/src/ServerComponentFetchError.ts b/node_package/src/ServerComponentFetchError.ts new file mode 100644 index 000000000..f1c4d95cc --- /dev/null +++ b/node_package/src/ServerComponentFetchError.ts @@ -0,0 +1,26 @@ +/** + * Custom error type for when there's an issue fetching or rendering a server component. + * This error includes information about the server component and the original error that occurred. + */ +export class ServerComponentFetchError extends Error { + serverComponentName: string; + + serverComponentProps: unknown; + + originalError: Error; + + constructor(message: string, componentName: string, componentProps: unknown, originalError: Error) { + super(message); + this.name = 'ServerComponentFetchError'; + this.serverComponentName = componentName; + this.serverComponentProps = componentProps; + this.originalError = originalError; + } +} + +/** + * Type guard to check if an error is a ServerComponentFetchError + */ +export function isServerComponentFetchError(error: unknown): error is ServerComponentFetchError { + return error instanceof ServerComponentFetchError; +} diff --git a/node_package/src/buildConsoleReplay.ts b/node_package/src/buildConsoleReplay.ts index a85437753..b8e924dde 100644 --- a/node_package/src/buildConsoleReplay.ts +++ b/node_package/src/buildConsoleReplay.ts @@ -54,5 +54,9 @@ export default function buildConsoleReplay( customConsoleHistory: (typeof console)['history'] | undefined = undefined, numberOfMessagesToSkip: number = 0, ): string { - return wrapInScriptTags('consoleReplayLog', consoleReplay(customConsoleHistory, numberOfMessagesToSkip)); + const consoleReplayJS = consoleReplay(customConsoleHistory, numberOfMessagesToSkip); + if (consoleReplayJS.length === 0) { + return ''; + } + return wrapInScriptTags('consoleReplayLog', consoleReplayJS); } diff --git a/node_package/src/getReactServerComponent.client.ts b/node_package/src/getReactServerComponent.client.ts new file mode 100644 index 000000000..8a44ba5b8 --- /dev/null +++ b/node_package/src/getReactServerComponent.client.ts @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { createFromReadableStream } from 'react-on-rails-rsc/client.browser'; +import { createRSCPayloadKey, fetch, wrapInNewPromise } from './utils.ts'; +import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts'; +import { assertRailsContextWithComponentSpecificMetadata, RailsContext } from './types/index.ts'; + +declare global { + interface Window { + REACT_ON_RAILS_RSC_PAYLOADS?: Record; + } +} + +type ClientGetReactServerComponentProps = { + componentName: string; + componentProps: unknown; + railsContext: RailsContext; + enforceRefetch?: boolean; +}; + +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); + const renderPromise = createFromReadableStream(transformedStream); + return wrapInNewPromise(renderPromise); +}; + +/** + * Fetches an RSC payload via HTTP request. + * + * This function: + * 1. Serializes the component props + * 2. Makes an HTTP request to the RSC payload generation endpoint + * 3. Processes the response stream into React elements + * + * This is used for client-side navigation or when rendering components + * that weren't part of the initial server render. + * + * @param props - Object containing component name, props, and railsContext + * @returns A Promise resolving to the rendered React element + */ +const fetchRSC = ({ componentName, componentProps, railsContext }: ClientGetReactServerComponentProps) => { + const propsString = JSON.stringify(componentProps); + const { rscPayloadGenerationUrlPath } = railsContext; + const strippedUrlPath = rscPayloadGenerationUrlPath?.replace(/^\/|\/$/g, ''); + const encodedParams = new URLSearchParams({ props: propsString }).toString(); + return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?${encodedParams}`)); +}; + +const createRSCStreamFromArray = (payloads: string[]) => { + let streamController: ReadableStreamController | undefined; + const stream = new ReadableStream({ + start(controller) { + if (typeof window === 'undefined') { + return; + } + const handleChunk = (chunk: string) => { + controller.enqueue(chunk); + }; + + payloads.forEach(handleChunk); + // eslint-disable-next-line no-param-reassign + payloads.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; +}; + +/** + * Creates React elements from preloaded RSC payloads in the page. + * + * This function: + * 1. Creates a ReadableStream from the array of payload chunks + * 2. Transforms the stream to handle console logs and other processing + * 3. Uses React's createFromReadableStream to process the payload + * + * This is used during hydration to avoid making HTTP requests when + * the payload is already embedded in the page. + * + * @param payloads - Array of RSC payload chunks from the global array + * @returns A Promise resolving to the rendered React element + */ +const createFromPreloadedPayloads = (payloads: string[]) => { + const stream = createRSCStreamFromArray(payloads); + const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream); + const renderPromise = createFromReadableStream(transformedStream); + return wrapInNewPromise(renderPromise); +}; + +/** + * Fetches and renders a server component on the client side. + * + * This function: + * 1. Checks for embedded RSC payloads in window.REACT_ON_RAILS_RSC_PAYLOADS + * 2. If found, uses the embedded payload to avoid an HTTP request + * 3. If not found (during client navigation or dynamic rendering), fetches via HTTP + * 4. Processes the RSC payload into React elements + * + * The embedded payload approach ensures optimal performance during initial page load, + * while the HTTP fallback enables dynamic rendering after navigation. + * + * @param componentName - Name of the server component to render + * @param componentProps - Props to pass to the server component + * @param railsContext - Context for the current request + * @param enforceRefetch - Whether to enforce a refetch of the component + * @returns A Promise resolving to the rendered React element + * + * @important This is an internal function. End users should not use this directly. + * Instead, use the useRSC hook which provides getComponent and refetchComponent functions + * for fetching or retrieving cached server components. For rendering server components, + * consider using RSCRoute component which handles the rendering logic automatically. + */ +const getReactServerComponent = ({ + componentName, + componentProps, + railsContext, + enforceRefetch = false, +}: ClientGetReactServerComponentProps) => { + assertRailsContextWithComponentSpecificMetadata(railsContext); + const componentKey = createRSCPayloadKey(componentName, componentProps, railsContext); + const payloads = window.REACT_ON_RAILS_RSC_PAYLOADS?.[componentKey]; + if (!enforceRefetch && payloads) { + return createFromPreloadedPayloads(payloads); + } + return fetchRSC({ componentName, componentProps, railsContext }); +}; + +export default getReactServerComponent; diff --git a/node_package/src/getReactServerComponent.server.ts b/node_package/src/getReactServerComponent.server.ts new file mode 100644 index 000000000..f8068c9ae --- /dev/null +++ b/node_package/src/getReactServerComponent.server.ts @@ -0,0 +1,89 @@ +import { BundleManifest } from 'react-on-rails-rsc'; +import { buildClientRenderer } from 'react-on-rails-rsc/client.node'; +import transformRSCStream from './transformRSCNodeStream.ts'; +import loadJsonFile from './loadJsonFile.ts'; +import { assertRailsContextWithServerComponentCapabilities, RailsContext } from './types/index.ts'; + +type GetReactServerComponentOnServerProps = { + componentName: string; + componentProps: unknown; + railsContext: RailsContext; +}; + +let clientRendererPromise: Promise> | undefined; + +const createFromReactOnRailsNodeStream = async ( + stream: NodeJS.ReadableStream, + reactServerManifestFileName: string, + reactClientManifestFileName: string, +) => { + if (!clientRendererPromise) { + clientRendererPromise = Promise.all([ + loadJsonFile(reactServerManifestFileName), + loadJsonFile(reactClientManifestFileName), + ]) + .then(([reactServerManifest, reactClientManifest]) => + buildClientRenderer(reactClientManifest, reactServerManifest), + ) + .catch((err: unknown) => { + clientRendererPromise = undefined; + throw err; + }); + } + + const { createFromNodeStream } = await clientRendererPromise; + const transformedStream = transformRSCStream(stream); + return createFromNodeStream(transformedStream); +}; + +/** + * Fetches and renders a server component on the server side. + * + * This function: + * 1. Validates the railsContext for required properties + * 2. Creates an SSR manifest mapping server and client modules + * 3. Gets the RSC payload stream via getRSCPayloadStream + * 4. Processes the stream with React's SSR runtime + * + * During SSR, this function ensures that the RSC payload is both: + * - Used to render the server component + * - Tracked so it can be embedded in the HTML response + * + * @param componentName - Name of the server component to render + * @param componentProps - Props to pass to the server component + * @param railsContext - Context for the current request + * @param enforceRefetch - Whether to enforce a refetch of the component + * @returns A Promise resolving to the rendered React element + * + * @important This is an internal function. End users should not use this directly. + * Instead, use the useRSC hook which provides getComponent and refetchComponent functions + * for fetching or retrieving cached server components. For rendering server components, + * consider using RSCRoute component which handles the rendering logic automatically. + */ +const getReactServerComponent = async ({ + componentName, + componentProps, + railsContext, +}: GetReactServerComponentOnServerProps) => { + assertRailsContextWithServerComponentCapabilities(railsContext); + + if (typeof ReactOnRails.getRSCPayloadStream !== 'function') { + throw new Error( + 'ReactOnRails.getRSCPayloadStream is not defined. This likely means that you are not building the server bundle correctly. Please ensure that your server bundle is targeting Node.js', + ); + } + + const rscPayloadStream = await ReactOnRails.getRSCPayloadStream( + componentName, + componentProps, + railsContext, + ); + + return createFromReactOnRailsNodeStream( + rscPayloadStream, + railsContext.reactServerClientManifestFileName, + railsContext.reactClientManifestFileName, + ); +}; + +export default getReactServerComponent; diff --git a/node_package/src/handleError.ts b/node_package/src/handleError.ts index 4884f6d39..eb9c3b12c 100644 --- a/node_package/src/handleError.ts +++ b/node_package/src/handleError.ts @@ -59,8 +59,10 @@ Message: ${e.message} ${e.stack}`; - const reactElement = React.createElement('pre', null, msg); + // In RSC (React Server Components) bundles, renderToString is not available. + // Therefore, we return the raw error message as a string instead of converting it to HTML. if (typeof renderToString === 'function') { + const reactElement = React.createElement('pre', null, msg); return renderToString(reactElement); } return msg; diff --git a/node_package/src/injectRSCPayload.ts b/node_package/src/injectRSCPayload.ts new file mode 100644 index 000000000..3673bc98a --- /dev/null +++ b/node_package/src/injectRSCPayload.ts @@ -0,0 +1,153 @@ +import { PassThrough, Transform } from 'stream'; +import { finished } from 'stream/promises'; +import { createRSCPayloadKey } from './utils.ts'; +import { RailsContextWithServerComponentCapabilities, PipeableOrReadableStream } from './types/index.ts'; + +// 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