From a15f78a65e27f78b2b0250bb4f27ecc8df5ad1f5 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 18 Nov 2024 20:28:59 +0200 Subject: [PATCH 1/8] hydrate the component immediately when loaded and registered --- lib/react_on_rails/helper.rb | 25 +++++++--- node_package/src/ComponentRegistry.ts | 38 +++++++++++++- node_package/src/ReactOnRails.ts | 13 +++++ node_package/src/clientStartup.ts | 72 +++++++++++++++++---------- node_package/src/types/index.ts | 2 + 5 files changed, 117 insertions(+), 33 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 7246327ff..fe12576c6 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -444,7 +444,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 +510,19 @@ 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 = dom_id.present? ? content_tag(:script, %( +window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}'); +if (window.ReactOnRails) { + window.ReactOnRails.renderOrHydrateLoadedComponents(); +} + ).html_safe) : "" # 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 +534,15 @@ 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") + rails_context_tag.concat( + content_tag(:script, %( +window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; + ).html_safe) + ) end # prepend the rails_context if not yet applied @@ -555,6 +567,7 @@ 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) diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index a8f42dd27..eef11e502 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -2,8 +2,31 @@ import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunctio import isRenderFunction from './isRenderFunction'; const registeredComponents = new Map(); +const registrationCallbacks = new Map void>>(); 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: (component: RegisteredComponent) => void + ): void { + // If component is already registered, schedule callback + const existingComponent = registeredComponents.get(componentName); + if (existingComponent) { + setTimeout(() => callback(existingComponent), 0); + return; + } + + // Store callback for future registration + const callbacks = registrationCallbacks.get(componentName) || []; + callbacks.push(callback); + registrationCallbacks.set(componentName, callbacks); + }, + /** * @param components { component1: component1, component2: component2, etc. } */ @@ -21,12 +44,19 @@ export default { const renderFunction = isRenderFunction(component); const isRenderer = renderFunction && (component as RenderFunction).length === 3; - registeredComponents.set(name, { + const registeredComponent = { name, component, renderFunction, isRenderer, + }; + registeredComponents.set(name, registeredComponent); + + const callbacks = registrationCallbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(registeredComponent), 0); }); + registrationCallbacks.delete(name); }); }, @@ -45,6 +75,12 @@ export default { Registered component names include [ ${keys} ]. Maybe you forgot to register the component?`); }, + async getOrWaitForComponent(name: string): Promise { + return new Promise((resolve) => { + this.onComponentRegistered(name, resolve); + }); + }, + /** * Get a Map containing all registered components. Useful for debugging. * @returns Map where key is the component name and values are the diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 17723ad0d..c246707a3 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -136,6 +136,10 @@ ctx.ReactOnRails = { ClientStartup.reactOnRailsPageLoaded(); }, + renderOrHydrateLoadedComponents(): void { + ClientStartup.renderOrHydrateLoadedComponents(); + }, + reactOnRailsComponentLoaded(domId: string): void { ClientStartup.reactOnRailsComponentLoaded(domId); }, @@ -240,6 +244,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/clientStartup.ts b/node_package/src/clientStartup.ts index 1be0c56e8..34f857144 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -20,12 +20,15 @@ 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_UNMOUNTED_BEFORE?: boolean; } namespace NodeJS { interface Global { ReactOnRails: ReactOnRailsType; roots: Root[]; + REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; } } namespace Turbolinks { @@ -134,7 +137,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 +147,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 +183,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 +198,62 @@ 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 }; +} + export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); - const railsContext = parseRailsContext(); - + const { context, railsContext } = getContextAndRailsContext(); + // If no react on rails components if (!railsContext) return; - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } forEachStore(context, railsContext); - forEachReactOnRailsComponentRender(context, railsContext); } -export function reactOnRailsComponentLoaded(domId: string): void { - debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); +async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) { + const el = document.querySelector(`[data-dom-id=${domId}]`); + if (!el) return; - const railsContext = parseRailsContext(); + await render(el, context, railsContext); +} +export async function renderOrHydrateLoadedComponents(): Promise { + debugTurbolinks('renderOrHydrateLoadedComponents'); + + const { context, railsContext } = getContextAndRailsContext(); + // If no react on rails components if (!railsContext) return; - const context = findContext(); - if (supportsRootApi) { - context.roots = []; - } + // 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); + }) + ); +} - 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 +352,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..50a4d41c4 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -158,6 +158,7 @@ export interface ReactOnRails { setOptions(newOptions: {traceTurbolinks: boolean}): void; reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; reactOnRailsPageLoaded(): void; + renderOrHydrateLoadedComponents(): void; reactOnRailsComponentLoaded(domId: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; @@ -169,6 +170,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; From 2db081f8d39dd8d3edd1e494fd0d2c01efe354aa Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 2 Dec 2024 10:30:33 +0200 Subject: [PATCH 2/8] auto register server components and immediately hydrate stores --- lib/react_on_rails/configuration.rb | 11 +- lib/react_on_rails/helper.rb | 71 +++-- lib/react_on_rails/packs_generator.rb | 51 +++- .../react_component/render_options.rb | 4 + node_package/src/ComponentRegistry.ts | 14 +- node_package/src/RSCClientRoot.ts | 23 ++ node_package/src/ReactOnRails.ts | 32 +++ node_package/src/StoreRegistry.ts | 80 ++++++ node_package/src/clientStartup.ts | 43 +-- .../src/react-server-dom-webpack.d.ts | 4 + node_package/src/types/index.ts | 4 + package.json | 6 +- spec/dummy/spec/packs_generator_spec.rb | 268 ++++++++++++++++++ 13 files changed, 566 insertions(+), 45 deletions(-) create mode 100644 node_package/src/RSCClientRoot.ts create mode 100644 node_package/src/react-server-dom-webpack.d.ts 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 fe12576c6..6f4dccc9c 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 @@ -400,6 +402,17 @@ def run_stream_inside_fiber rendering_fiber.resume end + def registered_stores + (@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.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) @@ -511,12 +524,8 @@ 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, dom_id = nil) - hydrate_script = dom_id.present? ? content_tag(:script, %( -window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}'); -if (window.ReactOnRails) { - window.ReactOnRails.renderOrHydrateLoadedComponents(); -} - ).html_safe) : "" + add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');" + hydrate_script = dom_id.present? ? content_tag(:script, add_component_to_pending_hydration_code.html_safe) : "" # IMPORTANT: Ensure that we mark string as html_safe to avoid escaping. html_content = <<~HTML #{rendered_output} @@ -538,11 +547,26 @@ def rails_context_if_not_already_rendered 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, %( -window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = []; - ).html_safe) - ) + content_tag(:script, pending_hydration_script.html_safe) + ).html_safe end # prepend the rails_context if not yet applied @@ -558,8 +582,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. @@ -570,7 +593,9 @@ def internal_react_component(react_component_name, options = {}) 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( @@ -592,12 +617,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) @@ -654,7 +684,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy 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 ) @@ -688,17 +718,18 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy 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.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/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index eef11e502..e19036232 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,4 +1,5 @@ -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index'; +import React from 'react'; +import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction, ReactComponent } from './types/index'; import isRenderFunction from './isRenderFunction'; const registeredComponents = new Map(); @@ -60,6 +61,17 @@ export default { }); }, + registerServerComponent(...componentNames: string[]): void { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const RSCClientRoot = require('./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 } diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts new file mode 100644 index 000000000..fa0a41e5f --- /dev/null +++ b/node_package/src/RSCClientRoot.ts @@ -0,0 +1,23 @@ +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.3.0-canary-670811593-20240322 or later to use server components.'); +} + +// It's not the exact type, but it's close enough for now +type Use = (promise: Promise) => T; +const { use } = React as { use: Use }; + +const renderCache: Record> = {}; + +const fetchRSC = ({ componentName }: { componentName: string }) => { + if (!renderCache[componentName]) { + renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)); + } + 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 c246707a3..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 @@ -140,6 +168,10 @@ ctx.ReactOnRails = { ClientStartup.renderOrHydrateLoadedComponents(); }, + hydratePendingStores(): void { + ClientStartup.hydratePendingStores(); + }, + reactOnRailsComponentLoaded(domId: string): void { ClientStartup.reactOnRailsComponentLoaded(domId); }, diff --git a/node_package/src/StoreRegistry.ts b/node_package/src/StoreRegistry.ts index 7be95e6f3..efc895862 100644 --- a/node_package/src/StoreRegistry.ts +++ b/node_package/src/StoreRegistry.ts @@ -2,6 +2,8 @@ import type { Store, StoreGenerator } from './types'; const registeredStoreGenerators = new Map(); const hydratedStores = new Map(); +const hydrationCallbacks = new Map void>>(); +const generatorCallbacks = new Map void>>(); export default { /** @@ -21,6 +23,12 @@ export default { } registeredStoreGenerators.set(name, store); + + const callbacks = generatorCallbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(store), 0); + }); + generatorCallbacks.delete(name); }); }, @@ -80,6 +88,12 @@ This can happen if you are server rendering and either: */ setStore(name: string, store: Store): void { hydratedStores.set(name, store); + + const callbacks = hydrationCallbacks.get(name) || []; + callbacks.forEach(callback => { + setTimeout(() => callback(store), 0); + }); + hydrationCallbacks.delete(name); }, /** @@ -104,4 +118,70 @@ This can happen if you are server rendering and either: stores(): Map { return hydratedStores; }, + + /** + * 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: (store: Store) => void + ): void { + // If store is already hydrated, schedule callback + const existingStore = hydratedStores.get(storeName); + if (existingStore) { + setTimeout(() => callback(existingStore), 0); + return; + } + + // Store callback for future hydration + const callbacks = hydrationCallbacks.get(storeName) || []; + callbacks.push(callback); + hydrationCallbacks.set(storeName, callbacks); + }, + + /** + * 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 + */ + async getOrWaitForStore(name: string): Promise { + return new Promise((resolve) => { + this.onStoreHydrated(name, resolve); + }); + }, + + /** + * 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: (generator: StoreGenerator) => void + ): void { + // If generator is already registered, schedule callback + const existingGenerator = registeredStoreGenerators.get(storeName); + if (existingGenerator) { + setTimeout(() => callback(existingGenerator), 0); + return; + } + + // Store callback for future registration + const callbacks = generatorCallbacks.get(storeName) || []; + callbacks.push(callback); + generatorCallbacks.set(storeName, callbacks); + }, + + /** + * 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 + */ + async getOrWaitForStoreGenerator(name: string): Promise { + return new Promise((resolve) => { + this.onStoreGeneratorRegistered(name, resolve); + }); + }, }; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 34f857144..9fbfc4a66 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -21,6 +21,7 @@ declare global { __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; } @@ -29,6 +30,7 @@ declare global { ReactOnRails: ReactOnRailsType; roots: Root[]; REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS?: string[]; + REACT_ON_RAILS_PENDING_STORE_NAMES?: string[]; } } namespace Turbolinks { @@ -83,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'); } @@ -209,21 +204,20 @@ function getContextAndRailsContext(): { context: Context; railsContext: RailsCon return { context, railsContext }; } +// TODO: remove it export function reactOnRailsPageLoaded(): void { debugTurbolinks('reactOnRailsPageLoaded'); - - const { context, railsContext } = getContextAndRailsContext(); - - // If no react on rails components - if (!railsContext) return; - - forEachStore(context, railsContext); } async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) { const el = document.querySelector(`[data-dom-id=${domId}]`); if (!el) return; + 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); } @@ -232,7 +226,6 @@ export async function renderOrHydrateLoadedComponents(): Promise { const { context, railsContext } = getContextAndRailsContext(); - // If no react on rails components if (!railsContext) return; // copy and clear the pending dom ids, so they don't get processed again @@ -245,6 +238,22 @@ export async function renderOrHydrateLoadedComponents(): Promise { ); } +export async function hydratePendingStores(): Promise { + debugTurbolinks('hydratePendingStores'); + + const { context, railsContext } = getContextAndRailsContext(); + + if (!railsContext) return; + + 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); + })); +} + export async function reactOnRailsComponentLoaded(domId: string): Promise { debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); diff --git a/node_package/src/react-server-dom-webpack.d.ts b/node_package/src/react-server-dom-webpack.d.ts new file mode 100644 index 000000000..26e2948d1 --- /dev/null +++ b/node_package/src/react-server-dom-webpack.d.ts @@ -0,0 +1,4 @@ +declare module 'react-server-dom-webpack/client' { + // eslint-disable-next-line import/prefer-default-export + export const createFromFetch: (promise: Promise) => Promise; +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 50a4d41c4..25001222d 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -151,14 +151,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; diff --git a/package.json b/package.json index 62f94dd67..64767d1a4 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 From 3c3af1df5dc813dd8790109a2aa09ec682ebb1ee Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 11 Dec 2024 19:50:15 +0200 Subject: [PATCH 3/8] move react-server-dom-webpack.d.ts to types directory --- node_package/{src => types}/react-server-dom-webpack.d.ts | 3 ++- tsconfig.json | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) rename node_package/{src => types}/react-server-dom-webpack.d.ts (60%) diff --git a/node_package/src/react-server-dom-webpack.d.ts b/node_package/types/react-server-dom-webpack.d.ts similarity index 60% rename from node_package/src/react-server-dom-webpack.d.ts rename to node_package/types/react-server-dom-webpack.d.ts index 26e2948d1..31c75a634 100644 --- a/node_package/src/react-server-dom-webpack.d.ts +++ b/node_package/types/react-server-dom-webpack.d.ts @@ -1,4 +1,5 @@ declare module 'react-server-dom-webpack/client' { - // eslint-disable-next-line import/prefer-default-export export const createFromFetch: (promise: Promise) => Promise; + + export const createFromReadableStream: (stream: ReadableStream) => Promise; } diff --git a/tsconfig.json b/tsconfig.json index 13fa3eb7a..9f0fe6f51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,11 @@ "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/**/*" + ] } From fc7e2937a0978bd7ee47dd45811363fd7bd7eec1 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 18 Dec 2024 12:29:33 +0200 Subject: [PATCH 4/8] ensure to initialize registered stores array before accessing --- lib/react_on_rails/helper.rb | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 6f4dccc9c..517e09868 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -199,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 @@ -217,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 @@ -403,12 +401,20 @@ def run_stream_inside_fiber end def registered_stores - (@registered_stores || []) + (@registered_stores_defer_render || []) + @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.map { |store| store[:store_name] } + 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 @@ -727,7 +733,7 @@ def initialize_redux_stores(render_options) return result unless store_dependencies.present? declarations = +"var reduxProps, store, storeGenerator;\n" - store_objects = registered_stores.select { |store| store_dependencies.include?(store[:store_name]) } + store_objects = registered_stores_including_deferred.select { |store| store_dependencies.include?(store[:store_name]) } result << store_objects.each_with_object(declarations) do |redux_store_data, memo| store_name = redux_store_data[:store_name] From 8cd044fae5de899ebc3dfcf79622f7019440c8e6 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 18 Dec 2024 13:22:14 +0200 Subject: [PATCH 5/8] refactoring --- lib/react_on_rails/helper.rb | 9 +++++++-- node_package/src/ComponentRegistry.ts | 15 ++++++++++----- node_package/src/RSCClientRoot.ts | 10 ++++------ node_package/src/types/index.ts | 5 +++++ 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 517e09868..86998e7d1 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -530,8 +530,13 @@ 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, dom_id = nil) - add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');" - hydrate_script = dom_id.present? ? content_tag(:script, add_component_to_pending_hydration_code.html_safe) : "" + 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} diff --git a/node_package/src/ComponentRegistry.ts b/node_package/src/ComponentRegistry.ts index e19036232..7bb6c3c1d 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -1,9 +1,14 @@ import React from 'react'; -import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction, ReactComponent } from './types/index'; +import { + type RegisteredComponent, + type ReactComponentOrRenderFunction, + type RenderFunction, + type ComponentRegistrationCallback, +} from './types'; import isRenderFunction from './isRenderFunction'; const registeredComponents = new Map(); -const registrationCallbacks = new Map void>>(); +const registrationCallbacks = new Map>(); export default { /** @@ -12,8 +17,8 @@ export default { * @param callback Function called with the component details when registered */ onComponentRegistered( - componentName: string, - callback: (component: RegisteredComponent) => void + componentName: string, + callback: (component: RegisteredComponent) => void, ): void { // If component is already registered, schedule callback const existingComponent = registeredComponents.get(componentName); @@ -63,7 +68,7 @@ export default { registerServerComponent(...componentNames: string[]): void { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires - const RSCClientRoot = require('./RSCClientRoot').default; + const RSCClientRoot = (require('./RSCClientRoot') as typeof import('./RSCClientRoot')).default; const componentsWrappedInRSCClientRoot = componentNames.reduce( (acc, name) => ({ ...acc, [name]: () => React.createElement(RSCClientRoot, { componentName: name }) }), diff --git a/node_package/src/RSCClientRoot.ts b/node_package/src/RSCClientRoot.ts index fa0a41e5f..bdf11d77d 100644 --- a/node_package/src/RSCClientRoot.ts +++ b/node_package/src/RSCClientRoot.ts @@ -2,18 +2,16 @@ 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.3.0-canary-670811593-20240322 or later to use server components.'); + 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.'); } -// It's not the exact type, but it's close enough for now -type Use = (promise: Promise) => T; -const { use } = React as { use: Use }; +const { use } = React; -const renderCache: Record> = {}; +const renderCache: Record> = {}; const fetchRSC = ({ componentName }: { componentName: string }) => { if (!renderCache[componentName]) { - renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)); + renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`)) as Promise; } return renderCache[componentName]; } diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 25001222d..a2b9dea18 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 ComponentRegistrationCallback = (component: RegisteredComponent) => void; + interface Params { props?: Record; railsContext?: RailsContext; From bdc2d51b83f26ea0bb4f0930586bf3181d5d93ea Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 18 Dec 2024 13:29:02 +0200 Subject: [PATCH 6/8] refactor registration callback into separate class --- node_package/src/CallbackRegistry.ts | 50 ++++++++++++++ node_package/src/ComponentRegistry.ts | 47 ++++---------- node_package/src/StoreRegistry.ts | 94 +++++++-------------------- node_package/src/types/index.ts | 2 +- 4 files changed, 88 insertions(+), 105 deletions(-) create mode 100644 node_package/src/CallbackRegistry.ts 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 7bb6c3c1d..50624cc4c 100644 --- a/node_package/src/ComponentRegistry.ts +++ b/node_package/src/ComponentRegistry.ts @@ -3,12 +3,12 @@ import { type RegisteredComponent, type ReactComponentOrRenderFunction, type RenderFunction, - type ComponentRegistrationCallback, + type ItemRegistrationCallback, } from './types'; import isRenderFunction from './isRenderFunction'; +import CallbackRegistry from './CallbackRegistry'; -const registeredComponents = new Map(); -const registrationCallbacks = new Map>(); +const componentRegistry = new CallbackRegistry(); export default { /** @@ -18,19 +18,9 @@ export default { */ onComponentRegistered( componentName: string, - callback: (component: RegisteredComponent) => void, + callback: ItemRegistrationCallback, ): void { - // If component is already registered, schedule callback - const existingComponent = registeredComponents.get(componentName); - if (existingComponent) { - setTimeout(() => callback(existingComponent), 0); - return; - } - - // Store callback for future registration - const callbacks = registrationCallbacks.get(componentName) || []; - callbacks.push(callback); - registrationCallbacks.set(componentName, callbacks); + componentRegistry.onItemRegistered(componentName, callback); }, /** @@ -38,7 +28,7 @@ export default { */ 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); } @@ -50,19 +40,12 @@ export default { const renderFunction = isRenderFunction(component); const isRenderer = renderFunction && (component as RenderFunction).length === 3; - const registeredComponent = { + componentRegistry.set(name, { name, component, renderFunction, isRenderer, - }; - registeredComponents.set(name, registeredComponent); - - const callbacks = registrationCallbacks.get(name) || []; - callbacks.forEach(callback => { - setTimeout(() => callback(registeredComponent), 0); }); - registrationCallbacks.delete(name); }); }, @@ -82,20 +65,16 @@ export default { * @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?`); }, - async getOrWaitForComponent(name: string): Promise { - return new Promise((resolve) => { - this.onComponentRegistered(name, resolve); - }); + getOrWaitForComponent(name: string): Promise { + return componentRegistry.getOrWaitForItem(name); }, /** @@ -104,6 +83,6 @@ Registered component names include [ ${keys} ]. Maybe you forgot to register the * { name, component, renderFunction, isRenderer} */ components(): Map { - return registeredComponents; + return componentRegistry.getAll(); }, }; diff --git a/node_package/src/StoreRegistry.ts b/node_package/src/StoreRegistry.ts index efc895862..7f054ef26 100644 --- a/node_package/src/StoreRegistry.ts +++ b/node_package/src/StoreRegistry.ts @@ -1,9 +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 hydrationCallbacks = new Map void>>(); -const generatorCallbacks = new Map void>>(); +const storeGeneratorRegistry = new CallbackRegistry(); +const hydratedStoreRegistry = new CallbackRegistry(); export default { /** @@ -12,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); } @@ -22,13 +21,7 @@ export default { `for the store generator with key ${name}.`); } - registeredStoreGenerators.set(name, store); - - const callbacks = generatorCallbacks.get(name) || []; - callbacks.forEach(callback => { - setTimeout(() => callback(store), 0); - }); - generatorCallbacks.delete(name); + storeGeneratorRegistry.set(name, store); }); }, @@ -40,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 = @@ -71,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?`); }, @@ -87,20 +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); - - const callbacks = hydrationCallbacks.get(name) || []; - callbacks.forEach(callback => { - setTimeout(() => callback(store), 0); - }); - hydrationCallbacks.delete(name); + hydratedStoreRegistry.set(name, store); }, /** * Internally used function to completely clear hydratedStores Map. */ clearHydratedStores(): void { - hydratedStores.clear(); + hydratedStoreRegistry.clear(); }, /** @@ -108,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(); }, /** @@ -116,7 +100,7 @@ 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(); }, /** @@ -124,21 +108,8 @@ This can happen if you are server rendering and either: * @param storeName Name of the store to watch for * @param callback Function called with the store when hydrated */ - onStoreHydrated( - storeName: string, - callback: (store: Store) => void - ): void { - // If store is already hydrated, schedule callback - const existingStore = hydratedStores.get(storeName); - if (existingStore) { - setTimeout(() => callback(existingStore), 0); - return; - } - - // Store callback for future hydration - const callbacks = hydrationCallbacks.get(storeName) || []; - callbacks.push(callback); - hydrationCallbacks.set(storeName, callbacks); + onStoreHydrated(storeName: string, callback: ItemRegistrationCallback): void { + hydratedStoreRegistry.onItemRegistered(storeName, callback); }, /** @@ -146,10 +117,8 @@ This can happen if you are server rendering and either: * @param name Name of the store to wait for * @returns Promise that resolves with the Store once hydrated */ - async getOrWaitForStore(name: string): Promise { - return new Promise((resolve) => { - this.onStoreHydrated(name, resolve); - }); + getOrWaitForStore(name: string): Promise { + return hydratedStoreRegistry.getOrWaitForItem(name); }, /** @@ -157,21 +126,8 @@ This can happen if you are server rendering and either: * @param storeName Name of the store generator to watch for * @param callback Function called with the store generator when registered */ - onStoreGeneratorRegistered( - storeName: string, - callback: (generator: StoreGenerator) => void - ): void { - // If generator is already registered, schedule callback - const existingGenerator = registeredStoreGenerators.get(storeName); - if (existingGenerator) { - setTimeout(() => callback(existingGenerator), 0); - return; - } - - // Store callback for future registration - const callbacks = generatorCallbacks.get(storeName) || []; - callbacks.push(callback); - generatorCallbacks.set(storeName, callbacks); + onStoreGeneratorRegistered(storeName: string, callback: ItemRegistrationCallback): void { + storeGeneratorRegistry.onItemRegistered(storeName, callback); }, /** @@ -179,9 +135,7 @@ This can happen if you are server rendering and either: * @param name Name of the store generator to wait for * @returns Promise that resolves with the StoreGenerator once registered */ - async getOrWaitForStoreGenerator(name: string): Promise { - return new Promise((resolve) => { - this.onStoreGeneratorRegistered(name, resolve); - }); + getOrWaitForStoreGenerator(name: string): Promise { + return storeGeneratorRegistry.getOrWaitForItem(name); }, }; diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index a2b9dea18..72fa8ed7e 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -107,7 +107,7 @@ export interface RegisteredComponent { isRenderer: boolean; } -export type ComponentRegistrationCallback = (component: RegisteredComponent) => void; +export type ItemRegistrationCallback = (component: T) => void; interface Params { props?: Record; From 4ebf72e818cc3bdc37224904e6ea7813edbb9809 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Wed, 18 Dec 2024 13:29:34 +0200 Subject: [PATCH 7/8] linting --- tsconfig.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 9f0fe6f51..d66366c8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,5 @@ "target": "es5", "typeRoots": ["./node_modules/@types", "./node_package/types"] }, - "include": [ - "node_package/src/**/*", - "node_package/types/**/*" - ] + "include": ["node_package/src/**/*", "node_package/types/**/*"] } From a7617358c41e8de12d1c1d40d18fe0ee89c4875b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 23 Dec 2024 19:45:25 +0200 Subject: [PATCH 8/8] make the early hydration compatible with turbopack, backward compatible and refactor --- lib/react_on_rails/helper.rb | 82 +++---- node_package/src/ClientSideRenderer.ts | 293 +++++++++++++++++++++++++ node_package/src/ReactOnRails.ts | 13 +- node_package/src/clientStartup.ts | 285 ++---------------------- node_package/src/context.ts | 33 ++- node_package/src/types/index.ts | 3 +- 6 files changed, 384 insertions(+), 325 deletions(-) create mode 100644 node_package/src/ClientSideRenderer.ts diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 86998e7d1..23d32aa83 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -17,8 +17,6 @@ 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 @@ -126,6 +124,7 @@ def react_component(component_name, options = {}) # @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering # Any other options are passed to the content tag, including the id. def stream_react_component(component_name, options = {}) + options = options.merge(force_load: true) unless options.key?(:force_load) run_stream_inside_fiber do internal_stream_react_component(component_name, options) end @@ -195,9 +194,12 @@ def react_component_hash(component_name, options = {}) # props: Ruby Hash or JSON string which contains the properties to pass to the redux store. # Options # defer: false -- pass as true if you wish to render this below your component. - def redux_store(store_name, props: {}, defer: false) + # force_load: false -- pass as true if you wish to hydrate this store immediately instead of + # waiting for the page to load. + def redux_store(store_name, props: {}, defer: false, force_load: false) redux_store_data = { store_name: store_name, - props: props } + props: props, + force_load: force_load } if defer registered_stores_defer_render << redux_store_data "YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \ @@ -463,7 +465,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, render_options.dom_id + component_specification_tag, rendered_output, result_console_script ) prepend_render_rails_context(result) @@ -529,20 +531,13 @@ 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, 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 - + def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, + console_script) # 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 @@ -554,30 +549,10 @@ def rails_context_if_not_already_rendered @rendered_rails_context = true - 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 + content_tag(:script, + json_safe_and_pretty(data).html_safe, + type: "application/json", + id: "js-react-on-rails-context") end # prepend the rails_context if not yet applied @@ -606,7 +581,7 @@ def internal_react_component(react_component_name, options = {}) "data-trace" => (render_options.trace ? true : nil), "data-dom-id" => render_options.dom_id, "data-store-dependencies" => render_options.store_dependencies.to_json, - ) + "data-force-load" => (render_options.force_load ? true : nil)) if render_options.force_load component_specification_tag.concat( @@ -629,16 +604,21 @@ def internal_react_component(react_component_name, options = {}) def render_redux_store_data(redux_store_data) 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 <<~HTML - #{store_hydration_data} - #{store_hydration_script} - HTML + 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, + "data-force-load" => (redux_store_data[:force_load] ? true : nil)) + + if redux_store_data[:force_load] + store_hydration_data.concat( + content_tag(:script, <<~JS.strip_heredoc.html_safe + ReactOnRails.reactOnRailsStoreLoaded('#{redux_store_data[:store_name]}'); + JS + ) + ) + end + + prepend_render_rails_context(store_hydration_data) end def props_string(props) @@ -738,7 +718,9 @@ def initialize_redux_stores(render_options) return result unless store_dependencies.present? declarations = +"var reduxProps, store, storeGenerator;\n" - store_objects = registered_stores_including_deferred.select { |store| store_dependencies.include?(store[:store_name]) } + store_objects = registered_stores_including_deferred.select do |store| + store_dependencies.include?(store[:store_name]) + end result << store_objects.each_with_object(declarations) do |redux_store_data, memo| store_name = redux_store_data[:store_name] diff --git a/node_package/src/ClientSideRenderer.ts b/node_package/src/ClientSideRenderer.ts new file mode 100644 index 000000000..23f775987 --- /dev/null +++ b/node_package/src/ClientSideRenderer.ts @@ -0,0 +1,293 @@ +import ReactDOM from 'react-dom'; +import type { ReactElement } from 'react'; +import type { + RailsContext, + RegisteredComponent, + RenderFunction, + Root, +} from './types'; + +import { reactOnRailsContext, type Context } from './context'; +import createReactOutput from './createReactOutput'; +import { isServerRenderHash } from './isServerRenderResult'; +import reactHydrateOrRender from './reactHydrateOrRender'; +import { supportsRootApi } from './reactApis'; + +const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; + +function delegateToRenderer( + componentObj: RegisteredComponent, + props: Record, + railsContext: RailsContext, + domNodeId: string, + trace: boolean, +): boolean { + const { name, component, isRenderer } = componentObj; + + if (isRenderer) { + if (trace) { + console.log(`\ +DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, + props, railsContext); + } + + (component as RenderFunction)(props, railsContext, domNodeId); + return true; + } + + return false; +} + +const getDomId = (domIdOrElement: string | Element): string => typeof domIdOrElement === 'string' ? domIdOrElement : domIdOrElement.getAttribute('data-dom-id') || ''; + +let currentContext: Context | null = null; +let currentRailsContext: RailsContext | null = null; + +// caches context and railsContext to avoid re-parsing rails-context each time a component is rendered +// Cached values will be reset when unmountAll() is called +function getContextAndRailsContext(): { context: Context | null; railsContext: RailsContext | null } { + // Return cached values if already set + if (currentContext && currentRailsContext) { + return { context: currentContext, railsContext: currentRailsContext }; + } + + currentContext = reactOnRailsContext(); + + const el = document.getElementById('js-react-on-rails-context'); + if (!el || !el.textContent) { + return { context: null, railsContext: null }; + } + + try { + currentRailsContext = JSON.parse(el.textContent); + } catch (e) { + console.error('Error parsing rails context:', e); + return { context: null, railsContext: null }; + } + + return { context: currentContext, railsContext: currentRailsContext }; +} + +class ComponentRenderer { + private domNodeId: string; + private state: 'unmounted' | 'rendering' | 'rendered'; + private root?: Root; + private renderPromise?: Promise; + + constructor(domIdOrElement: string | Element) { + const domId = getDomId(domIdOrElement); + this.domNodeId = domId; + this.state = 'rendering'; + const el = typeof domIdOrElement === 'string' ? document.querySelector(`[data-dom-id=${domId}]`) : domIdOrElement; + if (!el) return; + + const storeDependencies = el.getAttribute('data-store-dependencies'); + const storeDependenciesArray = storeDependencies ? JSON.parse(storeDependencies) as string[] : []; + + const { context, railsContext } = getContextAndRailsContext(); + if (!context || !railsContext) return; + + // Wait for all store dependencies to be loaded + this.renderPromise = Promise.all( + storeDependenciesArray.map(storeName => context.ReactOnRails.getOrWaitForStore(storeName)), + ).then(() => { + if (this.state === 'unmounted') return Promise.resolve(); + return this.render(el, context, railsContext); + }); + } + + /** + * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or + * delegates to a renderer registered by the user. + */ + private async 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 } = this; + const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; + const trace = el.getAttribute('data-trace') === 'true'; + + try { + const domNode = document.getElementById(domNodeId); + if (domNode) { + const componentObj = await context.ReactOnRails.getOrWaitForComponent(name); + if (this.state === 'unmounted') { + return; + } + + if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { + return; + } + + // Hydrate if available and was server rendered + // @ts-expect-error potentially present if React 18 or greater + const shouldHydrate = !!(ReactDOM.hydrate || ReactDOM.hydrateRoot) && !!domNode.innerHTML; + + const reactElementOrRouterResult = createReactOutput({ + componentObj, + props, + domNodeId, + trace, + railsContext, + shouldHydrate, + }); + + if (isServerRenderHash(reactElementOrRouterResult)) { + throw new Error(`\ + You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} + You should return a React.Component always for the client side entry point.`); + } else { + const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate); + this.state = 'rendered'; + if (supportsRootApi) { + this.root = rootOrElement as Root; + } + } + } + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error('Unknown error'); + console.error(error.message); + error.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.` + throw error; + } + } + + unmount(): void { + if (this.state === 'rendering') { + this.state = 'unmounted'; + return; + } + this.state = 'unmounted'; + + if (supportsRootApi) { + this.root?.unmount(); + this.root = undefined; + } else { + const domNode = document.getElementById(this.domNodeId); + if (!domNode) { + return; + } + + try { + ReactDOM.unmountComponentAtNode(domNode); + } catch (e: unknown) { + const error = e instanceof Error ? e : new Error('Unknown error'); + console.info(`Caught error calling unmountComponentAtNode: ${error.message} for domNode`, + domNode, error); + } + } + } + + waitUntilRendered(): Promise { + if (this.state === 'rendering') { + return this.renderPromise!; + } + return Promise.resolve(); + } +} + +class StoreRenderer { + private hydratePromise?: Promise; + private state: 'unmounted' | 'hydrating' | 'hydrated'; + + constructor(storeDataElement: Element) { + this.state = 'hydrating'; + const { context, railsContext } = getContextAndRailsContext(); + if (!context || !railsContext) { + return; + } + + const name = storeDataElement.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; + const props = (storeDataElement.textContent !== null) ? JSON.parse(storeDataElement.textContent) : {}; + this.hydratePromise = this.hydrate(context, railsContext, name, props); + } + + private async hydrate(context: Context, railsContext: RailsContext, name: string, props: Record) { + const storeGenerator = await context.ReactOnRails.getOrWaitForStoreGenerator(name); + if (this.state === 'unmounted') { + return; + } + + const store = storeGenerator(props, railsContext); + context.ReactOnRails.setStore(name, store); + this.state = 'hydrated'; + } + + waitUntilHydrated(): Promise { + if (this.state === 'hydrating') { + return this.hydratePromise!; + } + return Promise.resolve(); + } + + unmount(): void { + this.state = 'unmounted'; + } +} + +const renderedRoots = new Map(); + +export function renderOrHydrateComponent(domIdOrElement: string | Element): ComponentRenderer | undefined { + const domId = getDomId(domIdOrElement); + let root = renderedRoots.get(domId); + if (!root) { + root = new ComponentRenderer(domIdOrElement); + renderedRoots.set(domId, root); + } + return root; +} + + +export function renderOrHydrateForceLoadedComponents(): void { + const els = document.querySelectorAll(`.js-react-on-rails-component[data-force-load="true"]`); + els.forEach((el) => renderOrHydrateComponent(el)); +} + +export function renderOrHydrateAllComponents(): void { + const els = document.querySelectorAll(`.js-react-on-rails-component`); + els.forEach((el) => renderOrHydrateComponent(el)); +} + +function unmountAllComponents(): void { + renderedRoots.forEach((root) => root.unmount()); + renderedRoots.clear(); + currentContext = null; + currentRailsContext = null; +} + +const storeRenderers = new Map(); + +export async function hydrateStore(storeNameOrElement: string | Element) { + const storeName = typeof storeNameOrElement === 'string' ? storeNameOrElement : storeNameOrElement.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; + let storeRenderer = storeRenderers.get(storeName); + if (!storeRenderer) { + const storeDataElement = typeof storeNameOrElement === 'string' ? document.querySelector(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}="${storeNameOrElement}"]`) : storeNameOrElement; + if (!storeDataElement) { + return; + } + + storeRenderer = new StoreRenderer(storeDataElement); + storeRenderers.set(storeName, storeRenderer); + } + await storeRenderer.waitUntilHydrated(); +} + +export async function hydrateForceLoadedStores(): Promise { + const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}][data-force-load="true"]`); + await Promise.all(Array.from(els).map((el) => hydrateStore(el))); +} + +export async function hydrateAllStores(): Promise { + const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`); + await Promise.all(Array.from(els).map((el) => hydrateStore(el))); +} + +function unmountAllStores(): void { + storeRenderers.forEach((storeRenderer) => storeRenderer.unmount()); + storeRenderers.clear(); +} + +export function unmountAll(): void { + unmountAllComponents(); + unmountAllStores(); +} diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 561395a17..d02f80da8 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import type { Readable, PassThrough } from 'stream'; import * as ClientStartup from './clientStartup'; +import { renderOrHydrateComponent, hydrateStore } from './ClientSideRenderer'; import handleError from './handleError'; import ComponentRegistry from './ComponentRegistry'; import StoreRegistry from './StoreRegistry'; @@ -164,16 +165,12 @@ ctx.ReactOnRails = { ClientStartup.reactOnRailsPageLoaded(); }, - renderOrHydrateLoadedComponents(): void { - ClientStartup.renderOrHydrateLoadedComponents(); - }, - - hydratePendingStores(): void { - ClientStartup.hydratePendingStores(); + reactOnRailsComponentLoaded(domId: string): void { + renderOrHydrateComponent(domId); }, - reactOnRailsComponentLoaded(domId: string): void { - ClientStartup.reactOnRailsComponentLoaded(domId); + reactOnRailsStoreLoaded(storeName: string): void { + hydrateStore(storeName); }, /** diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 9fbfc4a66..1f5d92562 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -1,38 +1,15 @@ -import ReactDOM from 'react-dom'; -import type { ReactElement } from 'react'; -import type { - RailsContext, - ReactOnRails as ReactOnRailsType, - RegisteredComponent, - RenderFunction, - Root, -} from './types'; - -import createReactOutput from './createReactOutput'; -import { isServerRenderHash } from './isServerRenderResult'; -import reactHydrateOrRender from './reactHydrateOrRender'; -import { supportsRootApi } from './reactApis'; +import { reactOnRailsContext, type Context } from './context'; +import { + renderOrHydrateForceLoadedComponents, + renderOrHydrateAllComponents, + hydrateForceLoadedStores, + hydrateAllStores, + unmountAll, +} from './ClientSideRenderer'; /* eslint-disable @typescript-eslint/no-explicit-any */ declare global { - interface Window { - 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 { interface TurbolinksStatic { controller?: unknown; @@ -40,30 +17,13 @@ declare global { } } -declare const ReactOnRails: ReactOnRailsType; - -const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; - -type Context = Window | NodeJS.Global; - -function findContext(): Context { - if (typeof window.ReactOnRails !== 'undefined') { - return window; - } else if (typeof ReactOnRails !== 'undefined') { - return global; - } - - throw new Error(`\ -ReactOnRails is undefined in both global and window namespaces. - `); -} function debugTurbolinks(...msg: string[]): void { if (!window) { return; } - const context = findContext(); + const context = reactOnRailsContext(); if (context.ReactOnRails && context.ReactOnRails.option('traceTurbolinks')) { console.log('TURBO:', ...msg); } @@ -74,25 +34,13 @@ function turbolinksInstalled(): boolean { } function turboInstalled() { - const context = findContext(); + const context = reactOnRailsContext(); if (context.ReactOnRails) { return context.ReactOnRails.option('turbo') === true; } return false; } -function reactOnRailsHtmlElements(): HTMLCollectionOf { - return document.getElementsByClassName('js-react-on-rails-component'); -} - -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 = await context.ReactOnRails.getOrWaitForStoreGenerator(name); - const store = storeGenerator(props, railsContext); - context.ReactOnRails.setStore(name, store); -} - function turbolinksVersion5(): boolean { return (typeof Turbolinks.controller !== 'undefined'); } @@ -101,201 +49,15 @@ function turbolinksSupported(): boolean { return Turbolinks.supported; } -function delegateToRenderer( - componentObj: RegisteredComponent, - props: Record, - railsContext: RailsContext, - domNodeId: string, - trace: boolean, -): boolean { - const { name, component, isRenderer } = componentObj; - - if (isRenderer) { - if (trace) { - console.log(`\ -DELEGATING TO RENDERER ${name} for dom node with id: ${domNodeId} with props, railsContext:`, - props, railsContext); - } - - (component as RenderFunction)(props, railsContext, domNodeId); - return true; - } - - return false; -} - -function domNodeIdForEl(el: Element): string { - return el.getAttribute('data-dom-id') || ''; -} - -/** - * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or - * delegates to a renderer registered by the user. - */ -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); - const props = (el.textContent !== null) ? JSON.parse(el.textContent) : {}; - const trace = el.getAttribute('data-trace') === 'true'; - - try { - const domNode = document.getElementById(domNodeId); - if (domNode) { - const componentObj = await context.ReactOnRails.getOrWaitForComponent(name); - if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { - return; - } - - // Hydrate if available and was server rendered - // @ts-expect-error potentially present if React 18 or greater - const shouldHydrate = !!(ReactDOM.hydrate || ReactDOM.hydrateRoot) && !!domNode.innerHTML; - - const reactElementOrRouterResult = createReactOutput({ - componentObj, - props, - domNodeId, - trace, - railsContext, - shouldHydrate, - }); - - if (isServerRenderHash(reactElementOrRouterResult)) { - throw new Error(`\ -You returned a server side type of react-router error: ${JSON.stringify(reactElementOrRouterResult)} -You should return a React.Component always for the client side entry point.`); - } else { - const rootOrElement = reactHydrateOrRender(domNode, reactElementOrRouterResult as ReactElement, shouldHydrate); - if (supportsRootApi) { - context.roots.push(rootOrElement as Root); - } - } - } - } catch (e: any) { - console.error(e.message); - e.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.` - throw e; - } -} - -function parseRailsContext(): RailsContext | null { - const el = document.getElementById('js-react-on-rails-context'); - if (!el) { - // The HTML page will not have an element with ID 'js-react-on-rails-context' if there are no - // react on rails components - return null; - } - - if (!el.textContent) { - throw new Error('The HTML element with ID \'js-react-on-rails-context\' has no textContent'); - } - - 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'); -} - -async function renderUsingDomId(domId: string, context: Context, railsContext: RailsContext) { - const el = document.querySelector(`[data-dom-id=${domId}]`); - if (!el) return; - - 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; - - // 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 async function hydratePendingStores(): Promise { - debugTurbolinks('hydratePendingStores'); - - const { context, railsContext } = getContextAndRailsContext(); - - if (!railsContext) return; - - 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); - })); -} - -export async function reactOnRailsComponentLoaded(domId: string): Promise { - debugTurbolinks(`reactOnRailsComponentLoaded ${domId}`); - - const { context, railsContext } = getContextAndRailsContext(); - - // If no react on rails components - if (!railsContext) return; - - await renderUsingDomId(domId, context, railsContext); -} - -function unmount(el: Element): void { - const domNodeId = domNodeIdForEl(el); - const domNode = document.getElementById(domNodeId); - if (domNode === null) { - return; - } - try { - ReactDOM.unmountComponentAtNode(domNode); - } catch (e: any) { - console.info(`Caught error calling unmountComponentAtNode: ${e.message} for domNode`, - domNode, e); - } + hydrateAllStores(); + renderOrHydrateAllComponents(); } function reactOnRailsPageUnloaded(): void { debugTurbolinks('reactOnRailsPageUnloaded'); - if (supportsRootApi) { - const { roots } = findContext(); - - // If no react on rails components - if (!roots) return; - - for (const root of roots) { - root.unmount(); - } - } else { - const els = reactOnRailsHtmlElements(); - for (let i = 0; i < els.length; i += 1) { - unmount(els[i]); - } - } + unmountAll(); } function renderInit(): void { @@ -335,17 +97,6 @@ function isWindow(context: Context): context is Window { return (context as Window).document !== undefined; } -function onPageReady(callback: () => void) { - if (document.readyState === "complete") { - callback(); - } else { - document.addEventListener("readystatechange", function onReadyStateChange() { - onPageReady(callback); - document.removeEventListener("readystatechange", onReadyStateChange); - }); - } -} - export function clientStartup(context: Context): void { // Check if server rendering if (!isWindow(context)) { @@ -361,6 +112,12 @@ 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); + if (document.readyState !== 'complete') { + // force loaded components and stores are rendered and hydrated immediately + renderOrHydrateForceLoadedComponents(); + hydrateForceLoadedStores(); + + // Other components and stores are rendered and hydrated when the page is fully loaded + document.addEventListener('DOMContentLoaded', renderInit); + } } diff --git a/node_package/src/context.ts b/node_package/src/context.ts index 81b0569f5..e9c462ac0 100644 --- a/node_package/src/context.ts +++ b/node_package/src/context.ts @@ -1,9 +1,40 @@ +import type { ReactOnRails as ReactOnRailsType } from './types'; + +declare global { + interface Window { + ReactOnRails: ReactOnRailsType; + __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__?: boolean; + } + + namespace NodeJS { + interface Global { + ReactOnRails: ReactOnRailsType; + } + } + namespace Turbolinks { + interface TurbolinksStatic { + controller?: unknown; + } + } +} + +export type Context = Window | NodeJS.Global; + /** * Get the context, be it window or global * @returns {boolean|Window|*|context} */ -export default function context(this: void): Window | NodeJS.Global | void { +export default function context(this: void): Context | void { return ((typeof window !== 'undefined') && window) || ((typeof global !== 'undefined') && global) || this; } + + +export function reactOnRailsContext(): Context { + const ctx = context(); + if (ctx === undefined || typeof ctx.ReactOnRails === 'undefined') { + throw new Error('ReactOnRails is undefined in both global and window namespaces.'); + } + return ctx; +} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 72fa8ed7e..beb8b17f5 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -166,9 +166,8 @@ export interface ReactOnRails { setOptions(newOptions: {traceTurbolinks: boolean}): void; reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType; reactOnRailsPageLoaded(): void; - renderOrHydrateLoadedComponents(): void; - hydratePendingStores(): void; reactOnRailsComponentLoaded(domId: string): void; + reactOnRailsStoreLoaded(storeName: string): void; authenticityToken(): string | null; authenticityHeaders(otherHeaders: { [id: string]: string }): AuthenticityHeaders; option(key: string): string | number | boolean | undefined;