From 2d263045f3dd336ed5220e45e09c305cdfd3c795 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Mar 2025 12:55:37 +0200 Subject: [PATCH 01/31] add needed utils to use rsc payload on the server --- lib/react_on_rails/configuration.rb | 21 ++++--- .../webpack_assets_status_checker.rb | 2 + lib/react_on_rails/utils.rb | 9 +++ node_package/src/RSCServerRoot.ts | 58 +++++++++++++++++++ node_package/src/ReactOnRailsRSC.ts | 4 +- node_package/src/loadJsonFile.ts | 25 ++++++++ node_package/src/loadReactClientManifest.ts | 24 -------- .../src/streamServerRenderedReactComponent.ts | 30 ++++++++++ ...nsformRSCNodeStreamAndReplayConsoleLogs.ts | 31 ++++++++++ node_package/src/types/index.ts | 4 ++ package.json | 4 +- yarn.lock | 14 +---- 12 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 node_package/src/RSCServerRoot.ts create mode 100644 node_package/src/loadJsonFile.ts delete mode 100644 node_package/src/loadReactClientManifest.ts create mode 100644 node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 48e81fdba..26e68ed78 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_MANIFEST_FILE = "react-server-client-manifest" 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_manifest_file: DEFAULT_REACT_SERVER_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_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_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_manifest_file = react_server_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 @@ -301,12 +305,13 @@ def configure_generated_assets_dirs_deprecation def ensure_webpack_generated_files_exists return unless webpack_generated_files.empty? - self.webpack_generated_files = [ - "manifest.json", - server_bundle_js_file, - rsc_bundle_js_file, - react_client_manifest_file - ].compact_blank + files = ["manifest.json"] + files << server_bundle_js_file if server_bundle_js_file.present? + files << rsc_bundle_js_file if rsc_bundle_js_file.present? + files << react_client_manifest_file if react_client_manifest_file.present? + files << react_server_manifest_file if react_server_manifest_file.present? + + self.webpack_generated_files = files end def configure_skip_display_none_deprecation 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..a201364a0 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_manifest_file + ReactOnRails::Utils.react_server_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..0d6469542 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_manifest_file_path + return @react_server_manifest_path if @react_server_manifest_path && !Rails.env.development? + + asset_name = ReactOnRails.configuration.react_server_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/RSCServerRoot.ts b/node_package/src/RSCServerRoot.ts new file mode 100644 index 000000000..bc8aca344 --- /dev/null +++ b/node_package/src/RSCServerRoot.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { createFromNodeStream } from 'react-on-rails-rsc/client.node'; +import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs'; +import loadJsonFile from './loadJsonFile'; + +if (!('use' in React && typeof React.use === 'function')) { + throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.'); +} + +const { use } = React; + +export type RSCServerRootProps = { + getRscPromise: NodeJS.ReadableStream, + reactClientManifestFileName: string, + reactServerManifestFileName: string, +} + +const createFromFetch = (stream: NodeJS.ReadableStream, ssrManifest: Record) => { + const transformedStream = transformRSCStream(stream); + return createFromNodeStream(transformedStream, ssrManifest); +} + +const createSSRManifest = (reactServerManifestFileName: string, reactClientManifestFileName: string) => { + const reactServerManifest = loadJsonFile(reactServerManifestFileName); + const reactClientManifest = loadJsonFile(reactClientManifestFileName); + + const ssrManifest = { + moduleLoading: { + prefix: "/webpack/development/", + crossOrigin: null, + }, + moduleMap: {} as Record, + }; + + Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => { + const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl]; + ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = { + '*': { + id: (serverFileBundlingInfo as { id: string }).id, + chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks, + name: '*', + } + }; + }); + + return ssrManifest; +} + +const RSCServerRoot = ({ + getRscPromise, + reactClientManifestFileName, + reactServerManifestFileName, +}: RSCServerRootProps) => { + const ssrManifest = createSSRManifest(reactServerManifestFileName, reactClientManifestFileName); + return use(createFromFetch(getRscPromise, ssrManifest)); +}; + +export default RSCServerRoot; diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 428787eab..62c469773 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -11,7 +11,7 @@ import { streamServerRenderedComponent, transformRenderStreamChunksToResultObject, } from './streamServerRenderedReactComponent.ts'; -import loadReactClientManifest from './loadReactClientManifest.ts'; +import loadJsonFile from './loadJsonFile.ts'; const stringToStream = (str: string) => { const stream = new PassThrough(); @@ -33,7 +33,7 @@ const streamRenderRSCComponent = ( const { pipeToTransform, readableStream, emitError } = transformRenderStreamChunksToResultObject(renderState); - Promise.all([loadReactClientManifest(reactClientManifestFileName), reactRenderingResult]) + Promise.all([loadJsonFile(reactClientManifestFileName), reactRenderingResult]) .then(([reactClientManifest, reactElement]) => { const rscStream = renderToPipeableStream(reactElement, reactClientManifest, { onError: (err) => { diff --git a/node_package/src/loadJsonFile.ts b/node_package/src/loadJsonFile.ts new file mode 100644 index 000000000..68d562abe --- /dev/null +++ b/node_package/src/loadJsonFile.ts @@ -0,0 +1,25 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; + +type LoadedJsonFile = Record; +const loadedJsonFiles = new Map(); + +export default async function loadJsonFile(fileName: string) { + // Asset JSON files are uploaded to node renderer. + // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. + // Thus, the __dirname of this code is where we can find the manifest file. + const filePath = path.resolve(__dirname, fileName); + const loadedJsonFile = loadedJsonFiles.get(filePath); + if (loadedJsonFile) { + return loadedJsonFile; + } + + try { + const file = JSON.parse(await fs.readFile(filePath, 'utf8')); + loadedJsonFiles.set(filePath, file); + return file; + } catch (error) { + console.error(`Failed to load JSON file: ${filePath}`, error); + throw error; + } +} diff --git a/node_package/src/loadReactClientManifest.ts b/node_package/src/loadReactClientManifest.ts deleted file mode 100644 index 0295c2391..000000000 --- a/node_package/src/loadReactClientManifest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs/promises'; - -type ClientManifest = Record; -const loadedReactClientManifests = new Map(); - -export default async function loadReactClientManifest(reactClientManifestFileName: string) { - // React client manifest is uploaded to node renderer as an asset. - // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. - // Thus, the __dirname of this code is where we can find the manifest file. - const manifestPath = path.resolve(__dirname, reactClientManifestFileName); - const loadedReactClientManifest = loadedReactClientManifests.get(manifestPath); - if (loadedReactClientManifest) { - return loadedReactClientManifest; - } - - try { - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as ClientManifest; - loadedReactClientManifests.set(manifestPath, manifest); - return manifest; - } catch (error) { - throw new Error(`Failed to load React client manifest from ${manifestPath}: ${error}`); - } -} diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index c57229c7f..307322b4f 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -9,12 +9,42 @@ import handleError from './handleError.ts'; import { renderToPipeableStream, PipeableStream } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import type { RenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts'; +import loadJsonFile from './loadJsonFile.ts'; type BufferedEvent = { event: 'data' | 'error' | 'end'; data: unknown; }; +const createSSRManifest = async ( + reactServerManifestFileName: string, + reactClientManifestFileName: string, +) => { + const reactServerManifest = await loadJsonFile(reactServerManifestFileName); + const reactClientManifest = await loadJsonFile(reactClientManifestFileName); + + const ssrManifest = { + moduleLoading: { + prefix: '/webpack/development/', + crossOrigin: null, + }, + moduleMap: {} as Record, + }; + + Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => { + const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl]; + ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = { + '*': { + id: (serverFileBundlingInfo as { id: string }).id, + chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks, + name: '*', + }, + }; + }); + + return ssrManifest; +}; + /** * Creates a new Readable stream that safely buffers all events from the input stream until reading begins. * diff --git a/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts new file mode 100644 index 000000000..2b8b6c64c --- /dev/null +++ b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts @@ -0,0 +1,31 @@ +import { Transform } from 'stream'; + +export default function transformRSCStream(stream: NodeJS.ReadableStream): NodeJS.ReadableStream { + const decoder = new TextDecoder(); + let lastIncompleteChunk = ''; + + const htmlExtractor = new Transform({ + transform(oneOrMoreChunks, _, callback) { + try { + const decodedChunk = lastIncompleteChunk + decoder.decode(oneOrMoreChunks); + const separateChunks = decodedChunk.split('\n').filter((chunk) => chunk.trim() !== ''); + + if (!decodedChunk.endsWith('\n')) { + lastIncompleteChunk = separateChunks.pop() ?? ''; + } else { + lastIncompleteChunk = ''; + } + + for (const chunk of separateChunks) { + const parsedData = JSON.parse(chunk) as { html: string }; + this.push(parsedData.html); + } + callback(); + } catch (error) { + callback(error as Error); + } + }, + }); + + return stream.pipe(htmlExtractor); +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 16a8a8897..2a2f92051 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -376,6 +376,10 @@ export interface ReactOnRailsInternal extends ReactOnRails { * Current options. */ options: ReactOnRailsOptions; + /** + * Indicates if the RSC bundle is being used. + */ + isRSCBundle: boolean; } export type RenderStateHtml = FinalHtmlResult | Promise; diff --git a/package.json b/package.json index e6ada551a..def521896 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "publint": "^0.3.8", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-on-rails-rsc": "19.0.0", + "react-on-rails-rsc": "git+https://github.com/shakacode/react_on_rails_rsc.git#add-support-for-generating-client-components-manifest-for-server-bundle", "redux": "^4.2.1", "ts-jest": "^29.2.5", "typescript": "^5.8.3" @@ -64,7 +64,7 @@ "peerDependencies": { "react": ">= 16", "react-dom": ">= 16", - "react-on-rails-rsc": "19.0.0" + "react-on-rails-rsc": "git+https://github.com/shakacode/react_on_rails_rsc.git#add-support-for-generating-client-components-manifest-for-server-bundle" }, "files": [ "node_package/lib" diff --git a/yarn.lock b/yarn.lock index 9918ffe9a..0a7c48d25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5215,17 +5215,9 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -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== - dependencies: - react-server-dom-webpack "19.0.0" - -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== +"react-on-rails-rsc@git+https://github.com/shakacode/react_on_rails_rsc.git#add-support-for-generating-client-components-manifest-for-server-bundle": + version "19.0.0-rc.2" + resolved "git+https://github.com/shakacode/react_on_rails_rsc.git#f49dc3fd94629ff23c1c6a397b19b6feff437b2f" dependencies: acorn-loose "^8.3.0" neo-async "^2.6.1" From fa4a981b65870dcd022d7ac357fe4a14420474ef Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Mar 2025 12:58:07 +0200 Subject: [PATCH 02/31] Refactor component registration and rendering logic to support `server-component-reference` type --- node_package/src/ClientSideRenderer.ts | 8 ++-- node_package/src/ComponentRegistry.ts | 30 ++++++++++---- node_package/src/ReactOnRailsRSC.ts | 6 +++ node_package/src/createReactOutput.ts | 6 +-- .../src/registerServerComponent/server.ts | 5 ++- node_package/src/serverRenderUtils.ts | 2 +- .../src/streamServerRenderedReactComponent.ts | 18 +++++---- node_package/src/types/index.ts | 40 +++++++++++++------ 8 files changed, 77 insertions(+), 38 deletions(-) diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index b4978c598..49cb67e70 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -21,18 +21,16 @@ async function delegateToRenderer( domNodeId: string, trace: boolean, ): Promise { - const { name, component, isRenderer } = componentObj; - - if (isRenderer) { + if (componentObj.type === 'renderer-function') { if (trace) { console.log( - `DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, + `DELEGATING TO RENDERER ${componentObj.name} for dom node with id: ${domNodeId} with props, railsContext:`, props, railsContext, ); } - await (component as RenderFunction)(props, railsContext, domNodeId); + await componentObj.component(props, railsContext, domNodeId); return true; } diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index d6cf72a48..fb3707c76 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -18,14 +18,28 @@ export function register(components: Record { + componentRegistry.set(reference, { + name: reference, + component: undefined, + type: 'server-component-reference', }); }); } diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 62c469773..91c2f35bc 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -67,5 +67,11 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +ReactOnRails.isRSCBundle = true; + +ReactOnRails.registerServerComponentReferences = () => { + throw new Error('registerServerComponentReferences is not supported in the RSC bundle. Server components themselves should be registered not referenced.'); +} + export * from './types/index.ts'; export default ReactOnRails; diff --git a/node_package/src/createReactOutput.ts b/node_package/src/createReactOutput.ts index a63f88649..9eabb736b 100644 --- a/node_package/src/createReactOutput.ts +++ b/node_package/src/createReactOutput.ts @@ -41,7 +41,7 @@ export default function createReactOutput({ trace, shouldHydrate, }: CreateParams): CreateReactOutputResult { - const { name, component, renderFunction } = componentObj; + const { name, type, component } = componentObj; if (trace) { if (railsContext && railsContext.serverSide) { @@ -61,12 +61,12 @@ export default function createReactOutput({ } } - if (renderFunction) { + if (type === 'render-function') { // Let's invoke the function to get the result if (trace) { console.log(`${name} is a renderFunction`); } - const renderFunctionResult = (component as RenderFunction)(props, railsContext); + const renderFunctionResult = component(props, railsContext); if (isServerRenderHash(renderFunctionResult)) { // We just return at this point, because calling function knows how to handle this case and // we can't call React.createElement with this type of Object. diff --git a/node_package/src/registerServerComponent/server.ts b/node_package/src/registerServerComponent/server.ts index 40d68a1da..7b74672e0 100644 --- a/node_package/src/registerServerComponent/server.ts +++ b/node_package/src/registerServerComponent/server.ts @@ -31,7 +31,10 @@ import { ReactComponent } from '../types/index.ts'; * ``` */ const registerServerComponent = (components: Record) => { - ReactOnRails.register(components); + if (ReactOnRails.isRSCBundle) { + return ReactOnRails.register(components); + } + ReactOnRails.registerServerComponentReferences(...Object.keys(components)); }; export default registerServerComponent; diff --git a/node_package/src/serverRenderUtils.ts b/node_package/src/serverRenderUtils.ts index d5ae25f2d..a3738bd90 100644 --- a/node_package/src/serverRenderUtils.ts +++ b/node_package/src/serverRenderUtils.ts @@ -28,7 +28,7 @@ export function convertToError(e: unknown): Error { } export function validateComponent(componentObj: RegisteredComponent, componentName: string) { - if (componentObj.isRenderer) { + if (componentObj.type === 'renderer-function') { throw new Error( `Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`, ); diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 307322b4f..8068847fe 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -229,13 +229,17 @@ export const streamServerRenderedComponent = ( const componentObj = ComponentRegistry.get(componentName); validateComponent(componentObj, componentName); - const reactRenderingResult = createReactOutput({ - componentObj, - domNodeId, - trace, - props, - railsContext, - }); + if (componentObj.type === 'server-component-reference') { + const reactRenderingResult = React.createElement(componentObj.component as ReactComponent, props); + } else { + const reactRenderingResult = createReactOutput({ + componentObj, + domNodeId, + trace, + props, + railsContext, + }); + } if (isServerRenderHash(reactRenderingResult)) { throw new Error('Server rendering of streams is not supported for server render hashes.'); diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 2a2f92051..a5b97bf5e 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -122,20 +122,32 @@ export type { StreamableComponentResult, }; -export interface RegisteredComponent { +export type RegisteredComponent = { name: string; - component: ReactComponentOrRenderFunction; - /** - * Indicates if the registered component is a RenderFunction - * @see RenderFunction for more details on its behavior and usage. - */ - renderFunction: boolean; - // Indicates if the registered component is a Renderer function. - // Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId) - // Supported on the client side only. - // All renderer functions are render-functions, but not all render-functions are renderer functions. - isRenderer: boolean; -} +} & ({ + component: ReactComponent; + type: 'react-component'; +} | { + component: RenderFunction; + type: + /** + * Indicates if the registered component is a RenderFunction + * @see RenderFunction for more details on its behavior and usage. + */ + 'render-function' | + /** + * Indicates if the registered component is a Renderer function. + * Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId) + * Supported on the client side only. + * All renderer functions are render-functions, but not all render-functions are renderer-functions. + */ + 'renderer-function'; +} | { + // This variant exists to support server component references, where we only need the type field + // but want to maintain consistent destructuring patterns like { type, component } = componentObj + component: undefined; + type: 'server-component-reference'; +}); export interface RegisterServerComponentOptions { rscPayloadGenerationUrlPath: string; @@ -204,6 +216,7 @@ export interface ReactOnRailsOptions { traceTurbolinks?: boolean; /** Turbo (the successor of Turbolinks) events will be registered, if set to true. */ turbo?: boolean; + rscPayloadGenerationUrlPath: string; } export interface ReactOnRails { @@ -213,6 +226,7 @@ export interface ReactOnRails { * @param components keys are component names, values are components */ register(components: Record): void; + registerServerComponentReferences(...references: string[]): void; /** @deprecated Use registerStoreGenerators instead */ registerStore(stores: Record): void; /** From c18983a7e47afd411e6bf56def0059d99d32bf3b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Mar 2025 12:58:14 +0200 Subject: [PATCH 03/31] Revert "Refactor component registration and rendering logic to support `server-component-reference` type" This reverts commit 8df9e113edd01a8c9e284026a6b07213412c9316. --- node_package/src/ClientSideRenderer.ts | 8 ++-- node_package/src/ComponentRegistry.ts | 30 ++++---------- node_package/src/ReactOnRailsRSC.ts | 6 --- node_package/src/createReactOutput.ts | 6 +-- .../src/registerServerComponent/server.ts | 5 +-- node_package/src/serverRenderUtils.ts | 2 +- .../src/streamServerRenderedReactComponent.ts | 18 ++++----- node_package/src/types/index.ts | 40 ++++++------------- 8 files changed, 38 insertions(+), 77 deletions(-) diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts index 49cb67e70..0cd74ed59 100644 --- a/node_package/src/ClientSideRenderer.ts +++ b/node_package/src/ClientSideRenderer.ts @@ -20,8 +20,10 @@ async function delegateToRenderer( railsContext: RailsContext, domNodeId: string, trace: boolean, -): Promise { - if (componentObj.type === 'renderer-function') { +): boolean { + const { name, component, isRenderer } = componentObj; + + if (isRenderer) { if (trace) { console.log( `DELEGATING TO RENDERER ${componentObj.name} for dom node with id: ${domNodeId} with props, railsContext:`, @@ -30,7 +32,7 @@ async function delegateToRenderer( ); } - await componentObj.component(props, railsContext, domNodeId); + (component as RenderFunction)(props, railsContext, domNodeId); return true; } diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index fb3707c76..00e91271a 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -18,28 +18,14 @@ export function register(components: Record { - componentRegistry.set(reference, { - name: reference, - component: undefined, - type: 'server-component-reference', + const renderFunction = isRenderFunction(component); + const isRenderer = renderFunction && (component as RenderFunction).length === 3; + + componentRegistry.set(name, { + name, + component, + renderFunction, + isRenderer, }); }); } diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 91c2f35bc..62c469773 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -67,11 +67,5 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; -ReactOnRails.isRSCBundle = true; - -ReactOnRails.registerServerComponentReferences = () => { - throw new Error('registerServerComponentReferences is not supported in the RSC bundle. Server components themselves should be registered not referenced.'); -} - export * from './types/index.ts'; export default ReactOnRails; diff --git a/node_package/src/createReactOutput.ts b/node_package/src/createReactOutput.ts index 9eabb736b..a63f88649 100644 --- a/node_package/src/createReactOutput.ts +++ b/node_package/src/createReactOutput.ts @@ -41,7 +41,7 @@ export default function createReactOutput({ trace, shouldHydrate, }: CreateParams): CreateReactOutputResult { - const { name, type, component } = componentObj; + const { name, component, renderFunction } = componentObj; if (trace) { if (railsContext && railsContext.serverSide) { @@ -61,12 +61,12 @@ export default function createReactOutput({ } } - if (type === 'render-function') { + if (renderFunction) { // Let's invoke the function to get the result if (trace) { console.log(`${name} is a renderFunction`); } - const renderFunctionResult = component(props, railsContext); + const renderFunctionResult = (component as RenderFunction)(props, railsContext); if (isServerRenderHash(renderFunctionResult)) { // We just return at this point, because calling function knows how to handle this case and // we can't call React.createElement with this type of Object. diff --git a/node_package/src/registerServerComponent/server.ts b/node_package/src/registerServerComponent/server.ts index 7b74672e0..40d68a1da 100644 --- a/node_package/src/registerServerComponent/server.ts +++ b/node_package/src/registerServerComponent/server.ts @@ -31,10 +31,7 @@ import { ReactComponent } from '../types/index.ts'; * ``` */ const registerServerComponent = (components: Record) => { - if (ReactOnRails.isRSCBundle) { - return ReactOnRails.register(components); - } - ReactOnRails.registerServerComponentReferences(...Object.keys(components)); + ReactOnRails.register(components); }; export default registerServerComponent; diff --git a/node_package/src/serverRenderUtils.ts b/node_package/src/serverRenderUtils.ts index a3738bd90..d5ae25f2d 100644 --- a/node_package/src/serverRenderUtils.ts +++ b/node_package/src/serverRenderUtils.ts @@ -28,7 +28,7 @@ export function convertToError(e: unknown): Error { } export function validateComponent(componentObj: RegisteredComponent, componentName: string) { - if (componentObj.type === 'renderer-function') { + if (componentObj.isRenderer) { throw new Error( `Detected a renderer while server rendering component '${componentName}'. See https://github.com/shakacode/react_on_rails#renderer-functions`, ); diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 8068847fe..307322b4f 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -229,17 +229,13 @@ export const streamServerRenderedComponent = ( const componentObj = ComponentRegistry.get(componentName); validateComponent(componentObj, componentName); - if (componentObj.type === 'server-component-reference') { - const reactRenderingResult = React.createElement(componentObj.component as ReactComponent, props); - } else { - const reactRenderingResult = createReactOutput({ - componentObj, - domNodeId, - trace, - props, - railsContext, - }); - } + const reactRenderingResult = createReactOutput({ + componentObj, + domNodeId, + trace, + props, + railsContext, + }); if (isServerRenderHash(reactRenderingResult)) { throw new Error('Server rendering of streams is not supported for server render hashes.'); diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index a5b97bf5e..2a2f92051 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -122,32 +122,20 @@ export type { StreamableComponentResult, }; -export type RegisteredComponent = { +export interface RegisteredComponent { name: string; -} & ({ - component: ReactComponent; - type: 'react-component'; -} | { - component: RenderFunction; - type: - /** - * Indicates if the registered component is a RenderFunction - * @see RenderFunction for more details on its behavior and usage. - */ - 'render-function' | - /** - * Indicates if the registered component is a Renderer function. - * Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId) - * Supported on the client side only. - * All renderer functions are render-functions, but not all render-functions are renderer-functions. - */ - 'renderer-function'; -} | { - // This variant exists to support server component references, where we only need the type field - // but want to maintain consistent destructuring patterns like { type, component } = componentObj - component: undefined; - type: 'server-component-reference'; -}); + component: ReactComponentOrRenderFunction; + /** + * Indicates if the registered component is a RenderFunction + * @see RenderFunction for more details on its behavior and usage. + */ + renderFunction: boolean; + // Indicates if the registered component is a Renderer function. + // Renderer function handles DOM rendering or hydration with 3 args: (props, railsContext, domNodeId) + // Supported on the client side only. + // All renderer functions are render-functions, but not all render-functions are renderer functions. + isRenderer: boolean; +} export interface RegisterServerComponentOptions { rscPayloadGenerationUrlPath: string; @@ -216,7 +204,6 @@ export interface ReactOnRailsOptions { traceTurbolinks?: boolean; /** Turbo (the successor of Turbolinks) events will be registered, if set to true. */ turbo?: boolean; - rscPayloadGenerationUrlPath: string; } export interface ReactOnRails { @@ -226,7 +213,6 @@ export interface ReactOnRails { * @param components keys are component names, values are components */ register(components: Record): void; - registerServerComponentReferences(...references: string[]): void; /** @deprecated Use registerStoreGenerators instead */ registerStore(stores: Record): void; /** From 73fd4bb1048c0758807195f0397206605545039c Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Mar 2025 16:22:57 +0200 Subject: [PATCH 04/31] Enhance ReactOnRails options management --- node_package/src/ReactOnRails.client.ts | 27 ++++++++----- node_package/tests/ReactOnRails.test.jsx | 48 +++++++++++++++++++++++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 97d4e1078..023d819b0 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -25,9 +25,10 @@ This could be caused by setting Webpack's optimization.runtimeChunk to "true" or Check your Webpack configuration. Read more at https://github.com/shakacode/react_on_rails/issues/1558.`); } -const DEFAULT_OPTIONS = { +const DEFAULT_OPTIONS: ReactOnRailsOptions = { traceTurbolinks: false, turbo: false, + rscPayloadGenerationUrlPath: '/rsc_payload', }; globalThis.ReactOnRails = { @@ -76,16 +77,22 @@ globalThis.ReactOnRails = { delete newOptions.traceTurbolinks; } - if (typeof newOptions.turbo !== 'undefined') { - this.options.turbo = newOptions.turbo; - - // eslint-disable-next-line no-param-reassign - delete newOptions.turbo; + const validOptionKeys = Object.keys(DEFAULT_OPTIONS); + const providedOptionKeys = Object.keys(newOptions); + + const invalidOptions = providedOptionKeys.filter(key => !validOptionKeys.includes(key)); + if (invalidOptions.length > 0) { + throw new Error( + `Invalid options passed to ReactOnRails.options: ${JSON.stringify(invalidOptions)}`, + ); } - if (Object.keys(newOptions).length > 0) { - throw new Error(`Invalid options passed to ReactOnRails.options: ${JSON.stringify(newOptions)}`); - } + // Filter out undefined values before merging + const definedOptions = Object.fromEntries( + Object.entries(newOptions).filter(([_, value]) => value !== undefined) + ); + + this.options = { ...this.options, ...definedOptions }; }, reactOnRailsPageLoaded() { @@ -188,6 +195,8 @@ globalThis.ReactOnRails = { resetOptions(): void { this.options = { ...DEFAULT_OPTIONS }; }, + + isRSCBundle: false, }; globalThis.ReactOnRails.resetOptions(); diff --git a/node_package/tests/ReactOnRails.test.jsx b/node_package/tests/ReactOnRails.test.jsx index 3e7f757ad..116df1bc8 100644 --- a/node_package/tests/ReactOnRails.test.jsx +++ b/node_package/tests/ReactOnRails.test.jsx @@ -55,7 +55,53 @@ describe('ReactOnRails', () => { it('setOptions method throws error for invalid options', () => { ReactOnRails.resetOptions(); expect.assertions(1); - expect(() => ReactOnRails.setOptions({ foobar: true })).toThrow(/Invalid option/); + expect(() => ReactOnRails.setOptions({ foobar: true })).toThrow(/Invalid options/); + }); + + it('setOptions allows setting multiple options at once', () => { + ReactOnRails.resetOptions(); + ReactOnRails.setOptions({ + traceTurbolinks: true, + rscPayloadGenerationUrlPath: '/custom_rsc' + }); + expect(ReactOnRails.option('traceTurbolinks')).toBe(true); + expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/custom_rsc'); + }); + + it('setOptions preserves unspecified options when setting specific ones', () => { + ReactOnRails.resetOptions(); + + ReactOnRails.setOptions({ + traceTurbolinks: true, + turbo: true, + rscPayloadGenerationUrlPath: '/custom_rsc' + }); + + ReactOnRails.setOptions({ + rscPayloadGenerationUrlPath: '/different_path' + }); + + expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/different_path'); + + expect(ReactOnRails.option('traceTurbolinks')).toBe(true); + expect(ReactOnRails.option('turbo')).toBe(true); + }); + + it('setOptions ignores undefined values', () => { + ReactOnRails.resetOptions(); + ReactOnRails.setOptions({ + traceTurbolinks: true, + turbo: true, + rscPayloadGenerationUrlPath: '/custom_rsc' + }); + + ReactOnRails.setOptions({ + rscPayloadGenerationUrlPath: undefined + }); + + expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/custom_rsc'); + expect(ReactOnRails.option('traceTurbolinks')).toBe(true); + expect(ReactOnRails.option('turbo')).toBe(true); }); it('registerStore throws if passed a falsey object (null, undefined, etc)', () => { From 630dad9cc7afa04a43c3a5e5d578bd4c82222231 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 17 Mar 2025 16:23:04 +0200 Subject: [PATCH 05/31] Revert "Enhance ReactOnRails options management" This reverts commit 2f99ea9f6380efd42ded3ae25ffca10629f0cac2. --- node_package/src/ReactOnRails.client.ts | 29 ++++++-------- node_package/tests/ReactOnRails.test.jsx | 48 +----------------------- 2 files changed, 12 insertions(+), 65 deletions(-) diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 023d819b0..296c5bc45 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -25,10 +25,9 @@ This could be caused by setting Webpack's optimization.runtimeChunk to "true" or Check your Webpack configuration. Read more at https://github.com/shakacode/react_on_rails/issues/1558.`); } -const DEFAULT_OPTIONS: ReactOnRailsOptions = { +const DEFAULT_OPTIONS = { traceTurbolinks: false, turbo: false, - rscPayloadGenerationUrlPath: '/rsc_payload', }; globalThis.ReactOnRails = { @@ -77,22 +76,18 @@ globalThis.ReactOnRails = { delete newOptions.traceTurbolinks; } - const validOptionKeys = Object.keys(DEFAULT_OPTIONS); - const providedOptionKeys = Object.keys(newOptions); - - const invalidOptions = providedOptionKeys.filter(key => !validOptionKeys.includes(key)); - if (invalidOptions.length > 0) { + if (typeof newOptions.turbo !== 'undefined') { + this.options.turbo = newOptions.turbo; + + // eslint-disable-next-line no-param-reassign + delete newOptions.turbo; + } + + if (Object.keys(newOptions).length > 0) { throw new Error( - `Invalid options passed to ReactOnRails.options: ${JSON.stringify(invalidOptions)}`, + `Invalid options passed to ReactOnRails.options: ${JSON.stringify(newOptions)}`, ); } - - // Filter out undefined values before merging - const definedOptions = Object.fromEntries( - Object.entries(newOptions).filter(([_, value]) => value !== undefined) - ); - - this.options = { ...this.options, ...definedOptions }; }, reactOnRailsPageLoaded() { @@ -193,10 +188,8 @@ globalThis.ReactOnRails = { }, resetOptions(): void { - this.options = { ...DEFAULT_OPTIONS }; + this.options = Object.assign({}, DEFAULT_OPTIONS); }, - - isRSCBundle: false, }; globalThis.ReactOnRails.resetOptions(); diff --git a/node_package/tests/ReactOnRails.test.jsx b/node_package/tests/ReactOnRails.test.jsx index 116df1bc8..3e7f757ad 100644 --- a/node_package/tests/ReactOnRails.test.jsx +++ b/node_package/tests/ReactOnRails.test.jsx @@ -55,53 +55,7 @@ describe('ReactOnRails', () => { it('setOptions method throws error for invalid options', () => { ReactOnRails.resetOptions(); expect.assertions(1); - expect(() => ReactOnRails.setOptions({ foobar: true })).toThrow(/Invalid options/); - }); - - it('setOptions allows setting multiple options at once', () => { - ReactOnRails.resetOptions(); - ReactOnRails.setOptions({ - traceTurbolinks: true, - rscPayloadGenerationUrlPath: '/custom_rsc' - }); - expect(ReactOnRails.option('traceTurbolinks')).toBe(true); - expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/custom_rsc'); - }); - - it('setOptions preserves unspecified options when setting specific ones', () => { - ReactOnRails.resetOptions(); - - ReactOnRails.setOptions({ - traceTurbolinks: true, - turbo: true, - rscPayloadGenerationUrlPath: '/custom_rsc' - }); - - ReactOnRails.setOptions({ - rscPayloadGenerationUrlPath: '/different_path' - }); - - expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/different_path'); - - expect(ReactOnRails.option('traceTurbolinks')).toBe(true); - expect(ReactOnRails.option('turbo')).toBe(true); - }); - - it('setOptions ignores undefined values', () => { - ReactOnRails.resetOptions(); - ReactOnRails.setOptions({ - traceTurbolinks: true, - turbo: true, - rscPayloadGenerationUrlPath: '/custom_rsc' - }); - - ReactOnRails.setOptions({ - rscPayloadGenerationUrlPath: undefined - }); - - expect(ReactOnRails.option('rscPayloadGenerationUrlPath')).toBe('/custom_rsc'); - expect(ReactOnRails.option('traceTurbolinks')).toBe(true); - expect(ReactOnRails.option('turbo')).toBe(true); + expect(() => ReactOnRails.setOptions({ foobar: true })).toThrow(/Invalid option/); }); it('registerStore throws if passed a falsey object (null, undefined, etc)', () => { From bc19283a6a6058fe4b949a5960c4693e803ed5d7 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 18 Mar 2025 09:56:54 +0200 Subject: [PATCH 06/31] add support for returning promise of react component from render function --- node_package/src/ReactOnRails.client.ts | 2 ++ node_package/src/ReactOnRailsRSC.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/node_package/src/ReactOnRails.client.ts b/node_package/src/ReactOnRails.client.ts index 296c5bc45..122a955be 100644 --- a/node_package/src/ReactOnRails.client.ts +++ b/node_package/src/ReactOnRails.client.ts @@ -190,6 +190,8 @@ globalThis.ReactOnRails = { resetOptions(): void { this.options = Object.assign({}, DEFAULT_OPTIONS); }, + + isRSCBundle: false, }; globalThis.ReactOnRails.resetOptions(); diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index 62c469773..dd8c6c572 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -67,5 +67,7 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +ReactOnRails.isRSCBundle = true; + export * from './types/index.ts'; export default ReactOnRails; From b99337b2f57577ce6c4198b4fb22c8f209dfb0b9 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Tue, 18 Mar 2025 14:31:50 +0200 Subject: [PATCH 07/31] Update ReactOnRails configuration to rename server manifest file for consistency. Adjust related methods and references to use the new naming convention for the server client manifest file. --- lib/react_on_rails/configuration.rb | 12 ++++++------ .../test_helper/webpack_assets_status_checker.rb | 4 ++-- lib/react_on_rails/utils.rb | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 26e68ed78..0a1b5ee6b 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -10,7 +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_MANIFEST_FILE = "react-server-client-manifest" + DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json" DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000 def self.configuration @@ -22,7 +22,7 @@ def self.configuration server_bundle_js_file: "", rsc_bundle_js_file: "", react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE, - react_server_manifest_file: DEFAULT_REACT_SERVER_MANIFEST_FILE, + react_server_client_manifest_file: DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE, prerender: false, auto_load_bundle: false, replay_console: true, @@ -68,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, :react_server_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, @@ -84,7 +84,7 @@ 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, react_server_manifest_file: 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 @@ -115,7 +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_manifest_file = react_server_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 @@ -309,7 +309,7 @@ def ensure_webpack_generated_files_exists files << server_bundle_js_file if server_bundle_js_file.present? files << rsc_bundle_js_file if rsc_bundle_js_file.present? files << react_client_manifest_file if react_client_manifest_file.present? - files << react_server_manifest_file if react_server_manifest_file.present? + files << react_server_client_manifest_file if react_server_client_manifest_file.present? self.webpack_generated_files = files end 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 a201364a0..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,8 +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_manifest_file - ReactOnRails::Utils.react_server_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 0d6469542..5e50d4478 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -124,10 +124,10 @@ def self.react_client_manifest_file_path # React Server Manifest is generated by the server bundle. # So, it will never be served from the dev server. - def self.react_server_manifest_file_path + 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_manifest_file + asset_name = ReactOnRails.configuration.react_server_client_manifest_file @react_server_manifest_path = File.join(generated_assets_full_path, asset_name) end From d078ee2516811959d88caf7f9509eb54c243b19b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 19 Mar 2025 21:35:37 +0200 Subject: [PATCH 08/31] Refactor ReactOnRails to support React Server Components (RSC) registration and rendering --- node_package/src/RSCServerRoot.ts | 66 ++++++++++++++----- node_package/src/ReactOnRailsRSC.ts | 7 +- .../src/registerServerComponent/server.rsc.ts | 26 ++++++++ .../src/registerServerComponent/server.ts | 38 +++++------ .../src/streamServerRenderedReactComponent.ts | 30 --------- ...nsformRSCNodeStreamAndReplayConsoleLogs.ts | 6 +- node_package/src/types/index.ts | 19 +++++- package.json | 5 +- 8 files changed, 125 insertions(+), 72 deletions(-) create mode 100644 node_package/src/registerServerComponent/server.rsc.ts diff --git a/node_package/src/RSCServerRoot.ts b/node_package/src/RSCServerRoot.ts index bc8aca344..503eaf2c3 100644 --- a/node_package/src/RSCServerRoot.ts +++ b/node_package/src/RSCServerRoot.ts @@ -1,28 +1,38 @@ import * as React from 'react'; import { createFromNodeStream } from 'react-on-rails-rsc/client.node'; +import type { RenderFunction, RailsContext } from './types'; import transformRSCStream from './transformRSCNodeStreamAndReplayConsoleLogs'; import loadJsonFile from './loadJsonFile'; +declare global { + function generateRSCPayload( + componentName: string, + props: Record, + serverSideRSCPayloadParameters: unknown, + ): Promise; +} + +type RSCServerRootProps = { + componentName: string; + componentProps: Record; +} + if (!('use' in React && typeof React.use === 'function')) { throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.'); } const { use } = React; -export type RSCServerRootProps = { - getRscPromise: NodeJS.ReadableStream, - reactClientManifestFileName: string, - reactServerManifestFileName: string, -} - -const createFromFetch = (stream: NodeJS.ReadableStream, ssrManifest: Record) => { +const createFromReactOnRailsNodeStream = (stream: NodeJS.ReadableStream, ssrManifest: Record) => { const transformedStream = transformRSCStream(stream); return createFromNodeStream(transformedStream, ssrManifest); } -const createSSRManifest = (reactServerManifestFileName: string, reactClientManifestFileName: string) => { - const reactServerManifest = loadJsonFile(reactServerManifestFileName); - const reactClientManifest = loadJsonFile(reactClientManifestFileName); +const createSSRManifest = async (reactServerManifestFileName: string, reactClientManifestFileName: string) => { + const [reactServerManifest, reactClientManifest] = await Promise.all([ + loadJsonFile(reactServerManifestFileName), + loadJsonFile(reactClientManifestFileName), + ]); const ssrManifest = { moduleLoading: { @@ -46,13 +56,35 @@ const createSSRManifest = (reactServerManifestFileName: string, reactClientManif return ssrManifest; } -const RSCServerRoot = ({ - getRscPromise, - reactClientManifestFileName, - reactServerManifestFileName, -}: RSCServerRootProps) => { - const ssrManifest = createSSRManifest(reactServerManifestFileName, reactClientManifestFileName); - return use(createFromFetch(getRscPromise, ssrManifest)); +const RSCServerRoot: RenderFunction = async ({ componentName, componentProps }: RSCServerRootProps, railsContext?: RailsContext) => { + if (!railsContext?.serverSide || !railsContext?.reactClientManifestFileName || !railsContext?.reactServerClientManifestFileName) { + throw new Error( + `${'serverClientManifestFileName and reactServerClientManifestFileName are required. ' + + 'Please ensure that React Server Component webpack configurations are properly set ' + + 'as stated in the React Server Component tutorial. The received rails context is: '}${ JSON.stringify(railsContext)}` + ); + } + + 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 ssrManifest = await createSSRManifest( + railsContext.reactServerClientManifestFileName, + railsContext.reactClientManifestFileName + ); + const rscPayloadStream = await generateRSCPayload( + componentName, + componentProps, + railsContext.serverSideRSCPayloadParameters + ); + const serverComponentElement = createFromReactOnRailsNodeStream(rscPayloadStream, ssrManifest); + + return () => use(serverComponentElement); }; export default RSCServerRoot; diff --git a/node_package/src/ReactOnRailsRSC.ts b/node_package/src/ReactOnRailsRSC.ts index dd8c6c572..08f38eb81 100644 --- a/node_package/src/ReactOnRailsRSC.ts +++ b/node_package/src/ReactOnRailsRSC.ts @@ -24,7 +24,12 @@ const streamRenderRSCComponent = ( reactRenderingResult: StreamableComponentResult, options: RSCRenderParams, ): Readable => { - const { throwJsErrors, reactClientManifestFileName } = options; + const { throwJsErrors } = options; + if (!options.railsContext?.serverSide || !options.railsContext.reactClientManifestFileName) { + throw new Error('Rails context is not available'); + } + + const { reactClientManifestFileName } = options.railsContext; const renderState: StreamRenderState = { result: null, hasErrors: false, diff --git a/node_package/src/registerServerComponent/server.rsc.ts b/node_package/src/registerServerComponent/server.rsc.ts new file mode 100644 index 000000000..8bd345512 --- /dev/null +++ b/node_package/src/registerServerComponent/server.rsc.ts @@ -0,0 +1,26 @@ +import ReactOnRails from '../ReactOnRails.client'; +import { ReactComponent, RenderFunction } from '../types'; + +/** + * Registers React Server Components (RSC) with React on Rails for the RSC bundle. + * + * This function handles the registration of components in the RSC bundle context, + * where components are registered directly into the ComponentRegistry without any + * additional wrapping. This is different from the server bundle registration, + * which wraps components with RSCServerRoot. + * + * @param components - Object mapping component names to their implementations + * + * @example + * ```js + * registerServerComponent({ + * ServerComponent1: ServerComponent1Component, + * ServerComponent2: ServerComponent2Component + * }); + * ``` + */ +const registerServerComponent = ( + components: { [id: string]: ReactComponent | RenderFunction }, +) => ReactOnRails.register(components); + +export default registerServerComponent; diff --git a/node_package/src/registerServerComponent/server.ts b/node_package/src/registerServerComponent/server.ts index 40d68a1da..c14dfec57 100644 --- a/node_package/src/registerServerComponent/server.ts +++ b/node_package/src/registerServerComponent/server.ts @@ -1,24 +1,17 @@ import ReactOnRails from '../ReactOnRails.client.ts'; -import { ReactComponent } from '../types/index.ts'; +import RSCServerRoot from '../RSCServerRoot.ts'; +import { ReactComponent, RenderFunction, RailsContext } from '../types/index.ts'; /** - * Registers React Server Components (RSC) with React on Rails for both server and RSC bundles. - * Currently, this function behaves identically to ReactOnRails.register, but is introduced to enable - * future RSC-specific functionality without breaking changes. - * - * Future behavior will differ based on bundle type: - * - * RSC Bundle: - * - Components are registered as any other component by adding the component to the ComponentRegistry - * - * Server Bundle: - * - It works like the function defined at `registerServerComponent/client` - * - The function itself is not added to the ComponentRegistry - * - Instead, a RSCServerRoot component is added to the ComponentRegistry - * - This RSCServerRoot component will use the pre-generated RSC payloads from the RSC bundle to - * build the rendering tree of the server component instead of rendering it again - * - * This functionality is added now without real implementation to avoid breaking changes in the future. + * Registers React Server Components (RSC) with React on Rails for the server bundle. + * + * This function wraps each component with RSCServerRoot, which handles the server-side + * rendering of React Server Components using pre-generated RSC payloads. + * + * The RSCServerRoot component: + * - Uses pre-generated RSC payloads from the RSC bundle + * - Builds the rendering tree of the server component + * - Handles the integration with React's streaming SSR * * @param components - Object mapping component names to their implementations * @@ -31,7 +24,14 @@ import { ReactComponent } from '../types/index.ts'; * ``` */ const registerServerComponent = (components: Record) => { - ReactOnRails.register(components); + const componentsWrappedInRSCServerRoot: Record = {}; + for (const [componentName] of Object.entries(components)) { + componentsWrappedInRSCServerRoot[componentName] = ( + componentProps?: unknown, + railsContext?: RailsContext, + ) => RSCServerRoot({ componentName, componentProps }, railsContext); + } + return ReactOnRails.register(componentsWrappedInRSCServerRoot); }; export default registerServerComponent; diff --git a/node_package/src/streamServerRenderedReactComponent.ts b/node_package/src/streamServerRenderedReactComponent.ts index 307322b4f..c57229c7f 100644 --- a/node_package/src/streamServerRenderedReactComponent.ts +++ b/node_package/src/streamServerRenderedReactComponent.ts @@ -9,42 +9,12 @@ import handleError from './handleError.ts'; import { renderToPipeableStream, PipeableStream } from './ReactDOMServer.cts'; import { createResultObject, convertToError, validateComponent } from './serverRenderUtils.ts'; import type { RenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts'; -import loadJsonFile from './loadJsonFile.ts'; type BufferedEvent = { event: 'data' | 'error' | 'end'; data: unknown; }; -const createSSRManifest = async ( - reactServerManifestFileName: string, - reactClientManifestFileName: string, -) => { - const reactServerManifest = await loadJsonFile(reactServerManifestFileName); - const reactClientManifest = await loadJsonFile(reactClientManifestFileName); - - const ssrManifest = { - moduleLoading: { - prefix: '/webpack/development/', - crossOrigin: null, - }, - moduleMap: {} as Record, - }; - - Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => { - const serverFileBundlingInfo = reactServerManifest[aboluteFileUrl]; - ssrManifest.moduleMap[(clientFileBundlingInfo as { id: string }).id] = { - '*': { - id: (serverFileBundlingInfo as { id: string }).id, - chunks: (serverFileBundlingInfo as { chunks: string[] }).chunks, - name: '*', - }, - }; - }); - - return ssrManifest; -}; - /** * Creates a new Readable stream that safely buffers all events from the input stream until reading begins. * diff --git a/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts index 2b8b6c64c..2502d7639 100644 --- a/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts +++ b/node_package/src/transformRSCNodeStreamAndReplayConsoleLogs.ts @@ -27,5 +27,9 @@ export default function transformRSCStream(stream: NodeJS.ReadableStream): NodeJ }, }); - return stream.pipe(htmlExtractor); + try { + return stream.pipe(htmlExtractor); + } catch (error) { + throw new Error(`Error transforming RSC stream (${stream.constructor.name}), (stream: ${stream}), stringified stream: ${JSON.stringify(stream)}, error: ${error}`); + } } diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 2a2f92051..d7c82196b 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -17,7 +17,7 @@ type Store = { type ReactComponent = ComponentType | string; // Keep these in sync with method lib/react_on_rails/helper.rb#rails_context -export interface RailsContext { +export type RailsContext = { componentRegistryTimeout: number; railsEnv: string; inMailer: boolean; @@ -26,7 +26,6 @@ export interface RailsContext { rorVersion: string; rorPro: boolean; rorProVersion?: string; - serverSide: boolean; href: string; location: string; scheme: string; @@ -35,7 +34,21 @@ export interface RailsContext { pathname: string; search: string | null; httpAcceptLanguage: string; -} +} & ({ + serverSide: false; + rscPayloadGenerationUrl: string; +} | { + serverSide: true; + // These parameters are passed from React on Rails Pro to the node renderer. + // They contain the necessary information to generate the RSC (React Server Components) payload. + // Typically, this includes the bundle hash of the RSC bundle. + // The react-on-rails package uses 'unknown' for these parameters to avoid direct dependency. + // This ensures that if the communication protocol between the node renderer and the Rails server changes, + // we don't need to update this type or introduce a breaking change. + serverSideRSCPayloadParameters: unknown; + reactClientManifestFileName?: string; + reactServerClientManifestFileName?: string; +}); // not strictly what we want, see https://github.com/microsoft/TypeScript/issues/17867#issuecomment-323164375 type AuthenticityHeaders = Record & { diff --git a/package.json b/package.json index def521896..2e4e5a9ed 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ }, "./client": "./node_package/lib/ReactOnRails.client.js", "./registerServerComponent/client": "./node_package/lib/registerServerComponent/client.js", - "./registerServerComponent/server": "./node_package/lib/registerServerComponent/server.js" + "./registerServerComponent/server": { + "react-server": "./node_package/lib/registerServerComponent/server.rsc.js", + "default": "./node_package/lib/registerServerComponent/server.js" + } }, "directories": { "doc": "docs" From 245046e0e8dfda2a9027993b1c3f62e90458448f Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 26 Mar 2025 18:04:51 +0200 Subject: [PATCH 09/31] embed rsc payload inside the html page --- lib/react_on_rails/helper.rb | 7 +- node_package/src/RSCClientRoot.ts | 50 ++++++++- node_package/src/RSCPayloadContainer.ts | 100 ++++++++++++++++++ node_package/src/RSCServerRoot.ts | 25 ++++- node_package/src/buildConsoleReplay.ts | 8 +- .../src/streamServerRenderedReactComponent.ts | 33 +++++- .../transformRSCStreamAndReplayConsoleLogs.ts | 58 +++++----- node_package/src/types/index.ts | 2 +- 8 files changed, 246 insertions(+), 37 deletions(-) create mode 100644 node_package/src/RSCPayloadContainer.ts diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 6534fede9..f85f9d29c 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -362,6 +362,10 @@ def json_safe_and_pretty(hash_or_string) 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, @@ -371,7 +375,8 @@ def rails_context(server_side: true) i18nDefaultLocale: I18n.default_locale, rorVersion: ReactOnRails::VERSION, # TODO: v13 just use the version if existing - rorPro: ReactOnRails::Utils.react_on_rails_pro? + rorPro: ReactOnRails::Utils.react_on_rails_pro?, + rscPayloadGenerationUrl: rsc_url } if ReactOnRails::Utils.react_on_rails_pro? result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts index 7a1d09572..ce31f57b6 100644 --- a/node_package/src/RSCClientRoot.ts +++ b/node_package/src/RSCClientRoot.ts @@ -1,5 +1,7 @@ 'use client'; +/* eslint-disable no-underscore-dangle */ + import * as React from 'react'; import * as ReactDOMClient from 'react-dom/client'; import { createFromReadableStream } from 'react-on-rails-rsc/client'; @@ -13,6 +15,12 @@ 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 { + __FLIGHT_DATA: unknown[]; + } +} + export type RSCClientRootProps = { componentName: string; rscPayloadGenerationUrlPath: string; @@ -35,6 +43,45 @@ 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: unknown) => { + controller.enqueue(chunk); + }; + if (!window.__FLIGHT_DATA) { + window.__FLIGHT_DATA = []; + } + window.__FLIGHT_DATA.forEach(handleChunk); + window.__FLIGHT_DATA.push = (...chunks: unknown[]) => { + 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 +100,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 +108,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.ts b/node_package/src/RSCPayloadContainer.ts new file mode 100644 index 000000000..89b85db05 --- /dev/null +++ b/node_package/src/RSCPayloadContainer.ts @@ -0,0 +1,100 @@ +import * as React from 'react'; + +type StreamChunk = { + chunk: string; + isLastChunk: boolean; +} + +type RSCPayloadContainerProps = { + RSCPayloadStream: NodeJS.ReadableStream; +} + +type RSCPayloadContainerInnerProps = { + chunkIndex: number; + getChunkPromise: (chunkIndex: number) => Promise; +} + +function escapeScript(script: string) { + return script + .replace(/