UNPKG

react-on-rails

Version:

react-on-rails JavaScript for react_on_rails Ruby gem

191 lines 7.95 kB
import ComponentRegistry from "./ComponentRegistry.js"; import StoreRegistry from "./StoreRegistry.js"; import createReactOutput from "./createReactOutput.js"; import reactHydrateOrRender from "./reactHydrateOrRender.js"; import { getRailsContext } from "./context.js"; import { isServerRenderHash } from "./isServerRenderResult.js"; import { onPageUnloaded } from "./pageLifecycle.js"; import { supportsRootApi, unmountComponentAtNode } from "./reactApis.cjs"; const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store'; // Track all rendered roots for cleanup const renderedRoots = new Map(); function initializeStore(el, railsContext) { const name = el.getAttribute(REACT_ON_RAILS_STORE_ATTRIBUTE) || ''; const props = el.textContent !== null ? JSON.parse(el.textContent) : {}; const storeGenerator = StoreRegistry.getStoreGenerator(name); const store = storeGenerator(props, railsContext); StoreRegistry.setStore(name, store); } function forEachStore(railsContext) { const els = document.querySelectorAll(`[${REACT_ON_RAILS_STORE_ATTRIBUTE}]`); for (let i = 0; i < els.length; i += 1) { initializeStore(els[i], railsContext); } } function domNodeIdForEl(el) { return el.getAttribute('data-dom-id') || ''; } function delegateToRenderer(componentObj, props, railsContext, domNodeId, trace) { 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); } // Call the renderer function with the expected signature component(props, railsContext, domNodeId); return true; } return false; } /** * Used for client rendering by ReactOnRails. Either calls ReactDOM.hydrate, ReactDOM.render, or * delegates to a renderer registered by the user. */ function renderElement(el, railsContext) { // 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) { // Check if this component was already rendered by a previous call // This prevents hydration errors when reactOnRailsPageLoaded() is called multiple times // (e.g., for asynchronously loaded content) const existing = renderedRoots.get(domNodeId); if (existing) { // Only skip if it's the exact same DOM node and it's still connected to the document. // If the node was replaced (e.g., via innerHTML or Turbo), we need to unmount the old // root and re-render to the new node to prevent memory leaks and ensure rendering works. const sameNode = existing.domNode === domNode && existing.domNode.isConnected; if (sameNode) { if (trace) { console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`); } return; } // DOM node was replaced (e.g., via async HTML injection) - clean up the old root try { if (supportsRootApi && existing.root && typeof existing.root === 'object' && 'unmount' in existing.root) { existing.root.unmount(); } else { unmountComponentAtNode(existing.domNode); } } catch (unmountError) { // Ignore unmount errors for replaced nodes if (trace) { console.log(`Error unmounting replaced component: ${name}`, unmountError); } } renderedRoots.delete(domNodeId); } const componentObj = ComponentRegistry.get(name); if (delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) { return; } // Hydrate if the DOM node has content (server-rendered HTML) // Since we skip already-rendered components above, this check now correctly // identifies only server-rendered content, not previously client-rendered content const shouldHydrate = !!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 root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate); // Track the root for cleanup renderedRoots.set(domNodeId, { root, domNode }); } } } catch (e) { const error = e; console.error(error.message); error.message = `ReactOnRails encountered an error while rendering component: ${name}. See above error message.`; throw error; } } /** * Render a single component by its DOM ID. * This is the main entry point for rendering individual components. * @public */ export function renderComponent(domId) { const railsContext = getRailsContext(); // If no react on rails context if (!railsContext) return; // Initialize stores first forEachStore(railsContext); // Find the element with the matching data-dom-id const el = document.querySelector(`[data-dom-id="${domId}"]`); if (!el) return; renderElement(el, railsContext); } /** * Render all components on the page. * Core package renders all components after page load. */ export function renderAllComponents() { const railsContext = getRailsContext(); if (!railsContext) return; // Initialize all stores first forEachStore(railsContext); // Render all components const componentElements = document.querySelectorAll('.js-react-on-rails-component'); for (let i = 0; i < componentElements.length; i += 1) { renderElement(componentElements[i], railsContext); } } /** * Public API function that can be called to render a component after it has been loaded. * This is the function that should be exported and used by the Rails integration. * Returns a Promise for API compatibility with pro version. */ export function reactOnRailsComponentLoaded(domId) { renderComponent(domId); return Promise.resolve(); } /** * Unmount all rendered React components and clear roots. * This should be called on page unload to prevent memory leaks. */ function unmountAllComponents() { renderedRoots.forEach(({ root, domNode }) => { try { if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) { // React 18+ Root API root.unmount(); } else { // React 16-17 legacy API unmountComponentAtNode(domNode); } } catch (error) { console.error('Error unmounting component:', error); } }); renderedRoots.clear(); } // Register cleanup on page unload onPageUnloaded(unmountAllComponents); //# sourceMappingURL=ClientRenderer.js.map