diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 8dbdc723a..1a14cfb8f 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -9,6 +9,7 @@ def self.configure end DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze + DEFAULT_RSC_RENDERING_URL = "rsc/".freeze def self.configuration @configuration ||= Configuration.new( @@ -42,7 +43,9 @@ def self.configuration make_generated_server_bundle_the_entrypoint: false, defer_generated_component_packs: true, # forces the loading of React components - force_load: false + force_load: false, + auto_load_server_components: true, + rsc_rendering_url: DEFAULT_RSC_RENDERING_URL ) end @@ -57,7 +60,7 @@ class Configuration :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, :defer_generated_component_packs, :rsc_bundle_js_file, - :force_load + :force_load, :auto_load_server_components, :rsc_rendering_url # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, @@ -73,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: 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) + rsc_bundle_js_file: nil, auto_load_server_components: nil, rsc_rendering_url: 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 @@ -113,6 +116,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint self.defer_generated_component_packs = defer_generated_component_packs self.force_load = force_load + self.auto_load_server_components = auto_load_server_components + self.rsc_rendering_url = rsc_rendering_url end # rubocop:enable Metrics/AbcSize diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 852d9ef0e..99c432ffb 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -17,6 +17,8 @@ module Helper include ReactOnRails::Utils::Required COMPONENT_HTML_KEY = "componentHtml" + ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PC" + ADD_STORE_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PS" # react_component_name: can be a React function or class component or a "Render-Function". # "Render-Functions" differ from a React function in that they take two parameters, the @@ -197,13 +199,11 @@ def redux_store(store_name, props: {}, defer: false) redux_store_data = { store_name: store_name, props: props } if defer - @registered_stores_defer_render ||= [] - @registered_stores_defer_render << redux_store_data + registered_stores_defer_render << redux_store_data "YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \ "and not <%= redux store %>" else - @registered_stores ||= [] - @registered_stores << redux_store_data + registered_stores << redux_store_data result = render_redux_store_data(redux_store_data) prepend_render_rails_context(result) end @@ -215,9 +215,9 @@ def redux_store(store_name, props: {}, defer: false) # client side rendering of this hydration data, which is a hidden div with a matching class # that contains a data props. def redux_store_hydration_data - return if @registered_stores_defer_render.blank? + return if registered_stores_defer_render.blank? - @registered_stores_defer_render.reduce(+"") do |accum, redux_store_data| + registered_stores_defer_render.reduce(+"") do |accum, redux_store_data| accum << render_redux_store_data(redux_store_data) end.html_safe end @@ -400,6 +400,25 @@ def run_stream_inside_fiber rendering_fiber.resume end + def registered_stores + @registered_stores ||= [] + end + + def registered_stores_defer_render + @registered_stores_defer_render ||= [] + end + + def registered_stores_including_deferred + registered_stores + registered_stores_defer_render + end + + def create_render_options(react_component_name, options) + # If no store dependencies are passed, default to all registered stores up till now + options[:store_dependencies] ||= registered_stores_including_deferred.map { |store| store[:store_name] } + ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, + options: options) + end + def internal_stream_react_component(component_name, options = {}) options = options.merge(stream?: true) result = internal_react_component(component_name, options) @@ -444,7 +463,7 @@ def build_react_component_result_for_server_rendered_string( result_console_script = render_options.replay_console ? console_script : "" result = compose_react_component_html_with_spec_and_console( - component_specification_tag, rendered_output, result_console_script + component_specification_tag, rendered_output, result_console_script, render_options.dom_id ) prepend_render_rails_context(result) @@ -510,12 +529,20 @@ def build_react_component_result_for_server_rendered_hash( ) end - def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script) + def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script, dom_id = nil) + hydrate_script = if dom_id.present? + add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');" + content_tag(:script, add_component_to_pending_hydration_code.html_safe) + else + "" + end + # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. html_content = <<~HTML #{rendered_output} #{component_specification_tag} #{console_script} + #{hydrate_script} HTML html_content.strip.html_safe end @@ -527,10 +554,30 @@ def rails_context_if_not_already_rendered @rendered_rails_context = true - content_tag(:script, - json_safe_and_pretty(data).html_safe, - type: "application/json", - id: "js-react-on-rails-context") + rails_context_tag = content_tag(:script, + json_safe_and_pretty(data).html_safe, + type: "application/json", + id: "js-react-on-rails-context") + + pending_hydration_script = <<~JS.strip_heredoc + window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; + window.REACT_ON_RAILS_PENDING_STORE_NAMES = []; + window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION} = function(domId) { + window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push(domId); + if (window.ReactOnRails) { + window.ReactOnRails.renderOrHydrateLoadedComponents(); + } + }; + window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION} = function(storeName) { + window.REACT_ON_RAILS_PENDING_STORE_NAMES.push(storeName); + if (window.ReactOnRails) { + window.ReactOnRails.hydratePendingStores(); + } + }; + JS + rails_context_tag.concat( + content_tag(:script, pending_hydration_script.html_safe) + ).html_safe end # prepend the rails_context if not yet applied @@ -546,8 +593,7 @@ def internal_react_component(react_component_name, options = {}) # (re-hydrate the data). This enables react rendered on the client to see that the # server has already rendered the HTML. - render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name, - options: options) + render_options = create_render_options(react_component_name, options) # Setup the page_loaded_js, which is the same regardless of prerendering or not! # The reason is that React is smart about not doing extra work if the server rendering did its job. @@ -555,9 +601,12 @@ def internal_react_component(react_component_name, options = {}) json_safe_and_pretty(render_options.client_props).html_safe, type: "application/json", class: "js-react-on-rails-component", + id: "js-react-on-rails-component-#{render_options.dom_id}", "data-component-name" => render_options.react_component_name, "data-trace" => (render_options.trace ? true : nil), - "data-dom-id" => render_options.dom_id) + "data-dom-id" => render_options.dom_id, + "data-store-dependencies" => render_options.store_dependencies.to_json, + ) if render_options.force_load component_specification_tag.concat( @@ -579,12 +628,17 @@ def internal_react_component(react_component_name, options = {}) end def render_redux_store_data(redux_store_data) - result = content_tag(:script, + store_hydration_data = content_tag(:script, json_safe_and_pretty(redux_store_data[:props]).html_safe, type: "application/json", "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe) + hydration_code = "window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION}('#{redux_store_data[:store_name]}');" + store_hydration_script = content_tag(:script, hydration_code.html_safe) - prepend_render_rails_context(result) + prepend_render_rails_context <<~HTML + #{store_hydration_data} + #{store_hydration_script} + HTML end def props_string(props) @@ -641,7 +695,7 @@ def server_rendered_react_component(render_options) js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code( props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'), rails_context: rails_context(server_side: true).to_json, - redux_stores: initialize_redux_stores, + redux_stores: initialize_redux_stores(render_options), react_component_name: react_component_name, render_options: render_options ) @@ -675,17 +729,18 @@ def server_rendered_react_component(render_options) result end - def initialize_redux_stores + def initialize_redux_stores(render_options) result = +<<-JS ReactOnRails.clearHydratedStores(); JS - return result unless @registered_stores.present? || @registered_stores_defer_render.present? + store_dependencies = render_options.store_dependencies + return result unless store_dependencies.present? declarations = +"var reduxProps, store, storeGenerator;\n" - all_stores = (@registered_stores || []) + (@registered_stores_defer_render || []) + store_objects = registered_stores_including_deferred.select { |store| store_dependencies.include?(store[:store_name]) } - result << all_stores.each_with_object(declarations) do |redux_store_data, memo| + result << store_objects.each_with_object(declarations) do |redux_store_data, memo| store_name = redux_store_data[:store_name] props = props_string(redux_store_data[:props]) memo << <<-JS.strip_heredoc diff --git a/lib/react_on_rails/packs_generator.rb b/lib/react_on_rails/packs_generator.rb index 2db3d6b0c..7faccb830 100644 --- a/lib/react_on_rails/packs_generator.rb +++ b/lib/react_on_rails/packs_generator.rb @@ -44,13 +44,60 @@ def create_pack(file_path) puts(Rainbow("Generated Packs: #{output_path}").yellow) end + def first_js_statement_in_code(content) + return "" if content.nil? || content.empty? + + start_index = 0 + content_length = content.length + + while start_index < content_length + # Skip whitespace + while start_index < content_length && content[start_index].match?(/\s/) + start_index += 1 + end + + break if start_index >= content_length + + current_chars = content[start_index, 2] + + case current_chars + when '//' + # Single-line comment + newline_index = content.index("\n", start_index) + return "" if newline_index.nil? + start_index = newline_index + 1 + when '/*' + # Multi-line comment + comment_end = content.index('*/', start_index) + return "" if comment_end.nil? + start_index = comment_end + 2 + else + # Found actual content + next_line_index = content.index("\n", start_index) + return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip + end + end + + "" + end + + def is_client_entrypoint?(file_path) + content = File.read(file_path) + # has "use client" directive. It can be "use client" or 'use client' + first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/) + end + def pack_file_contents(file_path) registered_component_name = component_name(file_path) + register_as_server_component = ReactOnRails.configuration.auto_load_server_components && !is_client_entrypoint?(file_path) + import_statement = register_as_server_component ? "" : "import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';" + register_call = register_as_server_component ? "registerServerComponent(\"#{registered_component_name}\")" : "register({#{registered_component_name}})"; + <<~FILE_CONTENT import ReactOnRails from 'react-on-rails'; - import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}'; + #{import_statement} - ReactOnRails.register({#{registered_component_name}}); + ReactOnRails.#{register_call}; FILE_CONTENT end diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 8054e65a6..1725df070 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -119,6 +119,10 @@ def rsc? options[:rsc?] end + def store_dependencies + options[:store_dependencies] + end + private attr_reader :options diff --git a/node_package/src/CallbackRegistry.ts b/node_package/src/CallbackRegistry.ts new file mode 100644 index 000000000..ee6cedb44 --- /dev/null +++ b/node_package/src/CallbackRegistry.ts @@ -0,0 +1,50 @@ +import { ItemRegistrationCallback } from "./types"; + +export default class CallbackRegistry { + private registeredItems = new Map(); + private callbacks = new Map>>(); + + set(name: string, item: T): void { + this.registeredItems.set(name, item); + + const callbacks = this.callbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(item), 0); + }); + this.callbacks.delete(name); + } + + get(name: string): T | undefined { + return this.registeredItems.get(name); + } + + has(name: string): boolean { + return this.registeredItems.has(name); + } + + clear(): void { + this.registeredItems.clear(); + } + + getAll(): Map { + return this.registeredItems; + } + + onItemRegistered(name: string, callback: ItemRegistrationCallback): void { + const existingItem = this.registeredItems.get(name); + if (existingItem) { + setTimeout(() => callback(existingItem), 0); + return; + } + + const callbacks = this.callbacks.get(name) || []; + callbacks.push(callback); + this.callbacks.set(name, callbacks); + } + + getOrWaitForItem(name: string): Promise { + return new Promise((resolve) => { + this.onItemRegistered(name, resolve); + }); + } +} diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index a8f42dd27..50624cc4c 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,15 +1,34 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index'; +import React from 'react'; +import { + type RegisteredComponent, + type ReactComponentOrRenderFunction, + type RenderFunction, + type ItemRegistrationCallback, +} from './types'; import isRenderFunction from './isRenderFunction'; +import CallbackRegistry from './CallbackRegistry'; -const registeredComponents = new Map(); +const componentRegistry = new CallbackRegistry(); export default { + /** + * Register a callback to be called when a specific component is registered + * @param componentName Name of the component to watch for + * @param callback Function called with the component details when registered + */ + onComponentRegistered( + componentName: string, + callback: ItemRegistrationCallback, + ): void { + componentRegistry.onItemRegistered(componentName, callback); + }, + /** * @param components { component1: component1, component2: component2, etc. } */ register(components: { [id: string]: ReactComponentOrRenderFunction }): void { Object.keys(components).forEach(name => { - if (registeredComponents.has(name)) { + if (componentRegistry.has(name)) { console.warn('Called register for component that is already registered', name); } @@ -21,7 +40,7 @@ export default { const renderFunction = isRenderFunction(component); const isRenderer = renderFunction && (component as RenderFunction).length === 3; - registeredComponents.set(name, { + componentRegistry.set(name, { name, component, renderFunction, @@ -30,27 +49,40 @@ export default { }); }, + registerServerComponent(...componentNames: string[]): void { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const RSCClientRoot = (require('./RSCClientRoot') as typeof import('./RSCClientRoot')).default; + + const componentsWrappedInRSCClientRoot = componentNames.reduce( + (acc, name) => ({ ...acc, [name]: () => React.createElement(RSCClientRoot, { componentName: name }) }), + {} + ); + this.register(componentsWrappedInRSCClientRoot); + }, + /** * @param name * @returns { name, component, isRenderFunction, isRenderer } */ get(name: string): RegisteredComponent { - const registeredComponent = registeredComponents.get(name); - if (registeredComponent !== undefined) { - return registeredComponent; - } + const component = componentRegistry.get(name); + if (component !== undefined) return component; - const keys = Array.from(registeredComponents.keys()).join(', '); + const keys = Array.from(componentRegistry.getAll().keys()).join(', '); throw new Error(`Could not find component registered with name ${name}. \ Registered component names include [ ${keys} ]. Maybe you forgot to register the component?`); }, + getOrWaitForComponent(name: string): Promise { + return componentRegistry.getOrWaitForItem(name); + }, + /** * Get a Map containing all registered components. Useful for debugging. * @returns Map where key is the component name and values are the * { name, component, renderFunction, isRenderer} */ components(): Map { - return registeredComponents; + return componentRegistry.getAll(); }, }; diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts new file mode 100644 index 000000000..bdf11d77d --- /dev/null +++ b/node_package/src/RSCClientRoot.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import RSDWClient from 'react-server-dom-webpack/client'; + +if (!('use' in React)) { + 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; + +const renderCache: Record> = {}; + +const fetchRSC = ({ componentName }: { componentName: string }) => { + if (!renderCache[componentName]) { + renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)) as Promise; + } + return renderCache[componentName]; +} + +const RSCClientRoot = ({ componentName }: { componentName: string }) => use(fetchRSC({ componentName })); + +export default RSCClientRoot; diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 17723ad0d..561395a17 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -55,6 +55,16 @@ ctx.ReactOnRails = { ComponentRegistry.register(components); }, + /** + * Register a specific component as a server component. + * The component will not be included in the client bundle. + * When it's rendered, a call will be made to the server to render it. + * @param componentNames + */ + registerServerComponent(...componentNames: string[]): void { + ComponentRegistry.registerServerComponent(...componentNames); + }, + registerStore(stores: { [id: string]: StoreGenerator }): void { this.registerStoreGenerators(stores); }, @@ -87,6 +97,24 @@ ctx.ReactOnRails = { return StoreRegistry.getStore(name, throwIfMissing); }, + /** + * Get a store by name, or wait for it to be registered. + * @param name + * @returns Promise + */ + getOrWaitForStore(name: string): Promise { + return StoreRegistry.getOrWaitForStore(name); + }, + + /** + * Get a store generator by name, or wait for it to be registered. + * @param name + * @returns Promise + */ + getOrWaitForStoreGenerator(name: string): Promise { + return StoreRegistry.getOrWaitForStoreGenerator(name); + }, + /** * Renders or hydrates the react element passed. In case react version is >=18 will use the new api. * @param domNode @@ -136,6 +164,14 @@ ctx.ReactOnRails = { ClientStartup.reactOnRailsPageLoaded(); }, + renderOrHydrateLoadedComponents(): void { + ClientStartup.renderOrHydrateLoadedComponents(); + }, + + hydratePendingStores(): void { + ClientStartup.hydratePendingStores(); + }, + reactOnRailsComponentLoaded(domId: string): void { ClientStartup.reactOnRailsComponentLoaded(domId); }, @@ -240,6 +276,15 @@ ctx.ReactOnRails = { return ComponentRegistry.get(name); }, + /** + * Get the component that you registered, or wait for it to be registered + * @param name + * @returns {name, component, renderFunction, isRenderer} + */ + getOrWaitForComponent(name: string): Promise { + return ComponentRegistry.getOrWaitForComponent(name); + }, + /** * Used by server rendering by Rails * @param options diff --git a/node_package/src/StoreRegistry.ts b/node_package/src/StoreRegistry.ts index 7be95e6f3..7f054ef26 100644 --- a/node_package/src/StoreRegistry.ts +++ b/node_package/src/StoreRegistry.ts @@ -1,7 +1,8 @@ -import type { Store, StoreGenerator } from './types'; +import CallbackRegistry from './CallbackRegistry'; +import type { Store, StoreGenerator, ItemRegistrationCallback } from './types'; -const registeredStoreGenerators = new Map(); -const hydratedStores = new Map(); +const storeGeneratorRegistry = new CallbackRegistry(); +const hydratedStoreRegistry = new CallbackRegistry(); export default { /** @@ -10,7 +11,7 @@ export default { */ register(storeGenerators: { [id: string]: StoreGenerator }): void { Object.keys(storeGenerators).forEach(name => { - if (registeredStoreGenerators.has(name)) { + if (storeGeneratorRegistry.has(name)) { console.warn('Called registerStore for store that is already registered', name); } @@ -20,7 +21,7 @@ export default { `for the store generator with key ${name}.`); } - registeredStoreGenerators.set(name, store); + storeGeneratorRegistry.set(name, store); }); }, @@ -32,11 +33,10 @@ export default { * @returns Redux Store, possibly hydrated */ getStore(name: string, throwIfMissing = true): Store | undefined { - if (hydratedStores.has(name)) { - return hydratedStores.get(name); - } + const store = hydratedStoreRegistry.get(name); + if (store) return store; - const storeKeys = Array.from(hydratedStores.keys()).join(', '); + const storeKeys = Array.from(hydratedStoreRegistry.getAll().keys()).join(', '); if (storeKeys.length === 0) { const msg = @@ -63,12 +63,10 @@ This can happen if you are server rendering and either: * @returns storeCreator with given name */ getStoreGenerator(name: string): StoreGenerator { - const registeredStoreGenerator = registeredStoreGenerators.get(name); - if (registeredStoreGenerator) { - return registeredStoreGenerator; - } + const generator = storeGeneratorRegistry.get(name); + if (generator) return generator; - const storeKeys = Array.from(registeredStoreGenerators.keys()).join(', '); + const storeKeys = Array.from(storeGeneratorRegistry.getAll().keys()).join(', '); throw new Error(`Could not find store registered with name '${name}'. Registered store ` + `names include [ ${storeKeys} ]. Maybe you forgot to register the store?`); }, @@ -79,14 +77,14 @@ This can happen if you are server rendering and either: * @param store (not the storeGenerator, but the hydrated store) */ setStore(name: string, store: Store): void { - hydratedStores.set(name, store); + hydratedStoreRegistry.set(name, store); }, /** * Internally used function to completely clear hydratedStores Map. */ clearHydratedStores(): void { - hydratedStores.clear(); + hydratedStoreRegistry.clear(); }, /** @@ -94,7 +92,7 @@ This can happen if you are server rendering and either: * @returns Map where key is the component name and values are the store generators. */ storeGenerators(): Map { - return registeredStoreGenerators; + return storeGeneratorRegistry.getAll(); }, /** @@ -102,6 +100,42 @@ This can happen if you are server rendering and either: * @returns Map where key is the component name and values are the hydrated stores. */ stores(): Map { - return hydratedStores; + return hydratedStoreRegistry.getAll(); + }, + + /** + * Register a callback to be called when a specific store is hydrated + * @param storeName Name of the store to watch for + * @param callback Function called with the store when hydrated + */ + onStoreHydrated(storeName: string, callback: ItemRegistrationCallback): void { + hydratedStoreRegistry.onItemRegistered(storeName, callback); + }, + + /** + * Used by components to get the hydrated store, waiting for it to be hydrated if necessary. + * @param name Name of the store to wait for + * @returns Promise that resolves with the Store once hydrated + */ + getOrWaitForStore(name: string): Promise { + return hydratedStoreRegistry.getOrWaitForItem(name); + }, + + /** + * Register a callback to be called when a specific store generator is registered + * @param storeName Name of the store generator to watch for + * @param callback Function called with the store generator when registered + */ + onStoreGeneratorRegistered(storeName: string, callback: ItemRegistrationCallback): void { + storeGeneratorRegistry.onItemRegistered(storeName, callback); + }, + + /** + * Used by components to get the store generator, waiting for it to be registered if necessary. + * @param name Name of the store generator to wait for + * @returns Promise that resolves with the StoreGenerator once registered + */ + getOrWaitForStoreGenerator(name: string): Promise { + return storeGeneratorRegistry.getOrWaitForItem(name); }, }; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 1be0c56e8..9fbfc4a66 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -20,12 +20,17 @@ declare global { ReactOnRails: ReactOnRailsType; __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; roots: Root[]; + REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; + REACT_ON_RAILS_PENDING_STORE_NAMES?: string[]; + REACT_ON_RAILS_UNMOUNTED_BEFORE?: boolean; } namespace NodeJS { interface Global { ReactOnRails: ReactOnRailsType; roots: Root[]; + REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; + REACT_ON_RAILS_PENDING_STORE_NAMES?: string[]; } } namespace Turbolinks { @@ -80,21 +85,14 @@ function reactOnRailsHtmlElements(): HTMLCollectionOf { return document.getElementsByClassName('js-react-on-rails-component'); } -function initializeStore(el: Element, context: Context, railsContext: RailsContext): void { +async function initializeStore(el: Element, context: Context, railsContext: RailsContext): Promise { const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; - const storeGenerator = context.ReactOnRails.getStoreGenerator(name); + const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name); const store = storeGenerator(props, railsContext); context.ReactOnRails.setStore(name, store); } -function forEachStore(context: Context, railsContext: RailsContext): void { - const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`); - for (let i = 0; i < els.length; i += 1) { - initializeStore(els[i], context, railsContext); - } -} - function turbolinksVersion5(): boolean { return (typeof Turbolinks.controller !== 'undefined'); } @@ -134,7 +132,7 @@ function domNodeIdForEl(el: Element): string { * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or * delegates to a renderer registered by the user. */ -function render(el: Element, context: Context, railsContext: RailsContext): void { +async function render(el: Element, context: Context, railsContext: RailsContext): Promise { // This must match lib/react_on_rails/helper.rb const name = el.getAttribute('data-component-name') || ''; const domNodeId = domNodeIdForEl(el); @@ -144,7 +142,7 @@ function render(el: Element, context: Context, railsContext: RailsContext): void try { const domNode = document.getElementById(domNodeId); if (domNode) { - const componentObj = context.ReactOnRails.getComponent(name); + const componentObj = await context.ReactOnRails.getOrWaitForComponent(name); if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { return; } @@ -180,13 +178,6 @@ You should return a React.Component always for the client side entry point.`); } } -function forEachReactOnRailsComponentRender(context: Context, railsContext: RailsContext): void { - const els = reactOnRailsHtmlElements(); - for (let i = 0; i < els.length; i += 1) { - render(els[i], context, railsContext); - } -} - function parseRailsContext(): RailsContext | null { const el = document.getElementById('js-react-on-rails-context'); if (!el) { @@ -202,39 +193,76 @@ function parseRailsContext(): RailsContext | null { return JSON.parse(el.textContent); } +function getContextAndRailsContext(): { context: Context; railsContext: RailsContext | null } { + const railsContext = parseRailsContext(); + const context = findContext(); + + if (railsContext && supportsRootApi && !context.roots) { + context.roots = []; + } + + return { context, railsContext }; +} + +// TODO: remove it export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); +} - const railsContext = parseRailsContext(); +async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) { + const el = document.querySelector(`[data-dom-id=${domId}]`); + if (!el) return; - // If no react on rails components + const storeDependencies = el.getAttribute('data-store-dependencies'); + const storeDependenciesArray = storeDependencies ? JSON.parse(storeDependencies) as string[] : []; + if (storeDependenciesArray.length > 0) { + await Promise.all(storeDependenciesArray.map(storeName => context.ReactOnRails.getOrWaitForStore(storeName))); + } + await render(el, context, railsContext); +} + +export async function renderOrHydrateLoadedComponents(): Promise { + debugTurbolinks('renderOrHydrateLoadedComponents'); + + const { context, railsContext } = getContextAndRailsContext(); + if (!railsContext) return; - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } - forEachStore(context, railsContext); - forEachReactOnRailsComponentRender(context, railsContext); + // copy and clear the pending dom ids, so they don't get processed again + const pendingDomIds = context.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS ?? []; + context.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; + await Promise.all( + pendingDomIds.map(async (domId) => { + await renderUsingDomId(domId, context, railsContext); + }) + ); } -export function reactOnRailsComponentLoaded(domId: string): void { - debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); +export async function hydratePendingStores(): Promise { + debugTurbolinks('hydratePendingStores'); - const railsContext = parseRailsContext(); + const { context, railsContext } = getContextAndRailsContext(); - // If no react on rails components if (!railsContext) return; - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } + const pendingStoreNames = context.REACT_ON_RAILS_PENDING_STORE_NAMES ?? []; + context.REACT_ON_RAILS_PENDING_STORE_NAMES = []; + await Promise.all(pendingStoreNames.map(async (storeName) => { + const storeElement = document.querySelector(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}=${storeName}]`); + if (!storeElement) throw new Error(`Store element with name ${storeName} not found`); + await initializeStore(storeElement, context, railsContext); + })); +} - const el = document.querySelector(`[data-dom-id=${domId}]`); - if (!el) return; +export async function reactOnRailsComponentLoaded(domId: string): Promise { + debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); + + const { context, railsContext } = getContextAndRailsContext(); + + // If no react on rails components + if (!railsContext) return; - render(el, context, railsContext); + await renderUsingDomId(domId, context, railsContext); } function unmount(el: Element): void { @@ -333,5 +361,6 @@ export function clientStartup(context: Context): void { // eslint-disable-next-line no-underscore-dangle, no-param-reassign context.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ = true; + console.log('clientStartup'); onPageReady(renderInit); } diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index d48924bcd..72fa8ed7e 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -1,3 +1,6 @@ +// eslint-disable-next-line spaced-comment +/// + import type { ReactElement, ReactNode, Component, ComponentType } from 'react'; import type { Readable, PassThrough } from 'stream'; @@ -104,6 +107,8 @@ export interface RegisteredComponent { isRenderer: boolean; } +export type ItemRegistrationCallback = (component: T) => void; + interface Params { props?: Record; railsContext?: RailsContext; @@ -151,13 +156,18 @@ export type RenderReturnType = void | Element | Component | Root; export interface ReactOnRails { register(components: { [id: string]: ReactComponentOrRenderFunction }): void; + registerServerComponent(...componentNames: string[]): void; /** @deprecated Use registerStoreGenerators instead */ registerStore(stores: { [id: string]: StoreGenerator }): void; registerStoreGenerators(storeGenerators: { [id: string]: StoreGenerator }): void; getStore(name: string, throwIfMissing?: boolean): Store | undefined; + getOrWaitForStore(name: string): Promise; + getOrWaitForStoreGenerator(name: string): Promise; setOptions(newOptions: {traceTurbolinks: boolean}): void; reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; reactOnRailsPageLoaded(): void; + renderOrHydrateLoadedComponents(): void; + hydratePendingStores(): void; reactOnRailsComponentLoaded(domId: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; @@ -169,6 +179,7 @@ export interface ReactOnRails { name: string, props: Record, domNodeId: string, hydrate: boolean ): RenderReturnType; getComponent(name: string): RegisteredComponent; + getOrWaitForComponent(name: string): Promise; serverRenderReactComponent(options: RenderParams): null | string | Promise; streamServerRenderedReactComponent(options: RenderParams): Readable; serverRenderRSCReactComponent(options: RenderParams): PassThrough; diff --git a/node_package/types/react-server-dom-webpack.d.ts b/node_package/types/react-server-dom-webpack.d.ts new file mode 100644 index 000000000..31c75a634 --- /dev/null +++ b/node_package/types/react-server-dom-webpack.d.ts @@ -0,0 +1,5 @@ +declare module 'react-server-dom-webpack/client' { + export const createFromFetch: (promise: Promise) => Promise; + + export const createFromReadableStream: (stream: ReadableStream) => Promise; +} diff --git a/package.json b/package.json index e4afb8d16..fb5946da5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ ".": { "rsc-server": "./node_package/lib/ReactOnRailsRSC.js", "default": "./node_package/lib/ReactOnRails.js" - } + }, + "./RSCClientRoot": "./node_package/lib/RSCClientRoot.js" }, "directories": { "doc": "docs" @@ -58,7 +59,8 @@ "peerDependencies": { "js-yaml": ">= 3.0.0", "react": ">= 16", - "react-dom": ">= 16" + "react-dom": ">= 16", + "react-server-dom-webpack": ">= 18.3.0-canary-670811593-20240322" }, "files": [ "node_package/lib" diff --git a/spec/dummy/spec/packs_generator_spec.rb b/spec/dummy/spec/packs_generator_spec.rb index f7988b337..dff16efd1 100644 --- a/spec/dummy/spec/packs_generator_spec.rb +++ b/spec/dummy/spec/packs_generator_spec.rb @@ -291,6 +291,274 @@ def stub_packer_source_path(packer_source_path:, component_name:) allow(ReactOnRails::PackerUtils).to receive(:packer_source_path) .and_return("#{packer_source_path}/components/#{component_name}") end + + describe "#first_js_statement_in_code" do + subject { described_class.instance.send(:first_js_statement_in_code, content) } + + context "with simple content" do + let(:content) { "const x = 1;" } + it { is_expected.to eq "const x = 1;" } + end + + context "with single-line comments" do + let(:content) do + <<~JS + // First comment + // Second comment + const x = 1; + const y = 2; + JS + end + it { is_expected.to eq "const x = 1;" } + end + + context "with multi-line comments" do + let(:content) do + <<~JS + /* This is a + multiline comment */ + const x = 1; + JS + end + it { is_expected.to eq "const x = 1;" } + end + + context "with mixed comments" do + let(:content) do + <<~JS + // Single line comment + /* Multi-line + comment */ + // Another single line + const x = 1; + JS + end + it { is_expected.to eq "const x = 1;" } + end + + context "with mixed comments and whitespace" do + let(:content) do + <<~JS + + // First comment + + /* + multiline comment + */ + + // comment with preceding whitespace + + // Another single line + + + const x = 1; + JS + end + it { is_expected.to eq "const x = 1;" } + end + + context "with only comments" do + let(:content) do + <<~JS + // Just a comment + /* Another comment */ + JS + end + it { is_expected.to eq "" } + end + + context "with comment at end of file" do + let(:content) { "const x = 1;\n// Final comment" } + it { is_expected.to eq "const x = 1;" } + end + + context "with empty content" do + let(:content) { "" } + it { is_expected.to eq "" } + end + + context "with only whitespace" do + let(:content) { " \n \t " } + it { is_expected.to eq "" } + end + + context "with statement containing comment-like strings" do + let(:content) { 'const url = "http://example.com"; // Real comment' } + # it returns the statement starting from non-space character until the next line even if it contains a comment + it { is_expected.to eq 'const url = "http://example.com"; // Real comment' } + end + + context "with unclosed multi-line comment" do + let(:content) do + <<~JS + /* This comment + never ends + const x = 1; + JS + end + it { is_expected.to eq "" } + end + + context "with nested comments" do + let(:content) do + <<~JS + // /* This is still a single line comment */ + const x = 1; + JS + end + it { is_expected.to eq "const x = 1;" } + end + + context "with one line comment with no space after //" do + let(:content) { "//const x = 1;" } + it { is_expected.to eq "" } + end + + context "with one line comment with no new line after it" do + let(:content) { "// const x = 1" } + it { is_expected.to eq "" } + end + + context "with string directive" do + context "on top of the file" do + let(:content) do + <<~JS + "use client"; + // const x = 1 + const b = 2; + JS + end + it { is_expected.to eq '"use client";' } + end + + context "on top of the file and one line comment" do + let(:content) { '"use client"; // const x = 1' } + it { is_expected.to eq '"use client"; // const x = 1' } + end + + context "after some one-line comments" do + let(:content) do + <<~JS + // First comment + // Second comment + "use client"; + JS + end + it { is_expected.to eq '"use client";' } + end + + context "after some multi-line comments" do + let(:content) do + <<~JS + /* First comment */ + /* + multiline comment + */ + "use client"; + JS + end + it { is_expected.to eq '"use client";' } + end + + context "after some mixed comments" do + let(:content) do + <<~JS + // First comment + /* + multiline comment + */ + "use client"; + JS + end + it { is_expected.to eq '"use client";' } + end + + context "after any non-comment code" do + let(:content) do + <<~JS + // First comment + const x = 1; + "use client"; + JS + end + it { is_expected.to eq 'const x = 1;' } + end + end + end + + describe "#is_client_entrypoint?", :focus do + subject { described_class.instance.send(:is_client_entrypoint?, "dummy_path.js") } + + before do + allow(File).to receive(:read).with("dummy_path.js").and_return(content) + end + + context "when file has 'use client' directive" do + context "with double quotes" do + let(:content) { '"use client";' } + it { is_expected.to be true } + end + + context "with single quotes" do + let(:content) { "'use client';" } + it { is_expected.to be true } + end + + context "without semicolon" do + let(:content) { '"use client"' } + it { is_expected.to be true } + end + + context "with trailing whitespace" do + let(:content) { '"use client" ' } + it { is_expected.to be true } + end + + context "with comments before directive" do + let(:content) do + <<~JS + // some comment + /* multi-line + comment */ + "use client"; + JS + end + it { is_expected.to be true } + end + end + + context "when file does not have 'use client' directive" do + context "with empty file" do + let(:content) { "" } + it { is_expected.to be false } + end + + context "with regular JS code" do + let(:content) { "const x = 1;" } + it { is_expected.to be false } + end + + context "with 'use client' in a comment" do + let(:content) { "// 'use client'" } + it { is_expected.to be false } + end + + context "with 'use client' in middle of file" do + let(:content) do + <<~JS + const x = 1; + "use client"; + JS + end + it { is_expected.to be false } + end + + context "with similar but incorrect directive" do + let(:content) { 'use client;' } # without quotes + it { is_expected.to be false } + end + end + end end # rubocop:enable Metrics/BlockLength end diff --git a/tsconfig.json b/tsconfig.json index 13fa3eb7a..d66366c8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "outDir": "node_package/lib", "strict": true, "incremental": true, - "target": "es5" + "target": "es5", + "typeRoots": ["./node_modules/@types", "./node_package/types"] }, - "include": ["node_package/src/**/*"] + "include": ["node_package/src/**/*", "node_package/types/**/*"] }