From 7d6577c600b1841c427288f641df94e54ca77e88 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 18 Nov 2024 20:28:59 +0200 Subject: [PATCH] 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 ea41f9af8c..5747f61d21 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -435,7 +435,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) @@ -501,12 +501,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 @@ -518,10 +525,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 @@ -553,6 +565,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 a8f42dd278..eef11e5028 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 4326715499..1f5a7aeddf 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 1be0c56e82..34f8571446 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 d48924bcd5..50a4d41c49 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;