From a7617358c41e8de12d1c1d40d18fe0ee89c4875b Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Mon, 23 Dec 2024 19:45:25 +0200 Subject: [PATCH] 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;