diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 16ab9efbf1..d7d3f96830 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -16,10 +16,10 @@ import type { ErrorOptions, ReactComponentOrRenderFunction, AuthenticityHeaders, - StoreGenerator + StoreGenerator, + RootHydrateFunction } from './types/index'; -import reactHydrate from './reactHydrate'; -import reactRender from './reactRender'; +import renderHelperPromise from './helpers/renderHelper'; /* eslint-disable @typescript-eslint/no-explicit-any */ type Store = any; @@ -184,13 +184,16 @@ ctx.ReactOnRails = { * @param hydrate Pass truthy to update server rendered html. Default is falsy * @returns {virtualDomElement} Reference to your component's backing instance */ - render(name: string, props: Record, domNodeId: string, hydrate: boolean): void | Element | Component { + render(name: string, props: Record, domNodeId: string, hydrate: boolean): Promise { const componentObj = ComponentRegistry.get(name); const reactElement = createReactOutput({ componentObj, props, domNodeId }); - const render = hydrate ? reactHydrate : reactRender; - // eslint-disable-next-line react/no-render-return-value - return render(document.getElementById(domNodeId) as Element, reactElement as ReactElement); + let rendered: ReturnType + + return renderHelperPromise.then(({ reactHydrate, reactRender }) => { + const render = hydrate ? reactHydrate : reactRender; + return render(document.getElementById(domNodeId) as Element, reactElement as ReactElement); + }) }, /** diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 9914814d16..d80a7f4f7e 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -9,8 +9,7 @@ import type { import createReactOutput from './createReactOutput'; import {isServerRenderHash} from './isServerRenderResult'; -import reactHydrate from './reactHydrate'; -import reactRender from './reactRender'; +import renderHelperPromise from './helpers/renderHelper'; declare global { interface Window { @@ -138,47 +137,59 @@ function domNodeIdForEl(el: Element): string { function render(el: Element, railsContext: RailsContext): void { const context = findContext(); // This must match lib/react_on_rails/helper.rb - const name = el.getAttribute('data-component-name') || ""; + 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 = context.ReactOnRails.getComponent(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)} + const props = el.textContent !== null ? JSON.parse(el.textContent) : {}; + const trace = el.getAttribute('data-trace') === 'true'; + + renderHelperPromise.then(({ canHydrate, reactHydrate, reactRender }) => { + try { + const domNode = document.getElementById(domNodeId); + if (domNode) { + const componentObj = context.ReactOnRails.getComponent(name); + if ( + delegateToRenderer( + componentObj, + props, + railsContext, + domNodeId, + trace + ) + ) { + return; + } + + // Hydrate if available and was server rendered + const shouldHydrate = canHydrate && !!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 if (shouldHydrate) { - reactHydrate(domNode, reactElementOrRouterResult as ReactElement); - } else { - reactRender(domNode, reactElementOrRouterResult as ReactElement); + } else if (shouldHydrate) { + reactHydrate(domNode, reactElementOrRouterResult as ReactElement); + } else { + reactRender(domNode, reactElementOrRouterResult as ReactElement); + } } + } catch (e: any) { + e.message = + `ReactOnRails encountered an error while rendering component: ${name}.\n` + + `Original message: ${e.message}`; + throw e; } - } catch (e) { - e.message = `ReactOnRails encountered an error while rendering component: ${name}.\n` + - `Original message: ${e.message}`; - throw e; - } + }); } function parseRailsContext(): RailsContext | null { @@ -213,8 +224,9 @@ function unmount(el: Element): void { const domNode = document.getElementById(domNodeId); if(domNode === null){return;} try { + // Might need updating? https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html ReactDOM.unmountComponentAtNode(domNode); - } catch (e) { + } catch (e: any) { console.info(`Caught error calling unmountComponentAtNode: ${e.message} for domNode`, domNode, e); } diff --git a/node_package/src/helpers/legacyRootHandler.ts b/node_package/src/helpers/legacyRootHandler.ts new file mode 100644 index 0000000000..9125a6c0da --- /dev/null +++ b/node_package/src/helpers/legacyRootHandler.ts @@ -0,0 +1,9 @@ +// import ReactDOM from 'react-dom'; +// import type { RootRenderFunction, RootHydrateFunction } from '../types'; + +// export const canHydrate = !!ReactDOM.hydrate + +// export const hydrate: RootHydrateFunction = (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode) + +// // eslint-disable-next-line react/no-render-return-value +// export const render: RootRenderFunction = (domNode, reactElement) => ReactDOM.render(reactElement, domNode) diff --git a/node_package/src/helpers/modernRootHandler.ts b/node_package/src/helpers/modernRootHandler.ts new file mode 100644 index 0000000000..fd9488793f --- /dev/null +++ b/node_package/src/helpers/modernRootHandler.ts @@ -0,0 +1,14 @@ +// // @ts-expect-error react-dom/client only available in React 18 +// // eslint-disable-next-line import/no-unresolved +// import ReactDOM from 'react-dom/client'; +// import type { RootRenderFunction, RootHydrateFunction } from '../types'; + +// export const canHydrate = !!ReactDOM.hydrateRoot; + +// export const hydrate: RootHydrateFunction = (domNode, reactElement) => ReactDOM.hydrateRoot(domNode, reactElement); + +// export const render: RootRenderFunction = (domNode, reactElement) => { +// const root = ReactDOM.createRoot(domNode); +// root.render(reactElement); +// return root; +// }; diff --git a/node_package/src/helpers/renderHelper.ts b/node_package/src/helpers/renderHelper.ts new file mode 100644 index 0000000000..052b036dcb --- /dev/null +++ b/node_package/src/helpers/renderHelper.ts @@ -0,0 +1,38 @@ +import supportsReactCreateRoot from './supportsReactCreateRoot'; +import type { RootRenderFunction, RootHydrateFunction } from '../types'; + +interface RenderHelper { + canHydrate: boolean; + reactHydrate: RootHydrateFunction; + reactRender: RootRenderFunction; +} + +export default (async (): Promise => { + const toImport = supportsReactCreateRoot === true ? 'react-dom/client' : 'react-dom'; + const ReactDOM = await import(toImport); + + let canHydrate: RenderHelper['canHydrate']; + let reactHydrate: RenderHelper['reactHydrate']; + let reactRender: RenderHelper['reactRender']; + + if (supportsReactCreateRoot === true) { + canHydrate = !!ReactDOM.hydrateRoot; + reactHydrate = (domNode, reactElement) => ReactDOM.hydrateRoot(domNode, reactElement); + reactRender = (domNode, reactElement) => { + const root = ReactDOM.createRoot(domNode); + root.render(reactElement); + return root; + }; + } else { + canHydrate = !!ReactDOM.hydrate; + reactHydrate = (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode); + // eslint-disable-next-line react/no-render-return-value + reactRender = (domNode, reactElement) => ReactDOM.render(reactElement, domNode); + } + + return { + canHydrate, + reactHydrate, + reactRender, + }; +})(); diff --git a/node_package/src/supportsReactCreateRoot.ts b/node_package/src/helpers/supportsReactCreateRoot.ts similarity index 100% rename from node_package/src/supportsReactCreateRoot.ts rename to node_package/src/helpers/supportsReactCreateRoot.ts diff --git a/node_package/src/reactHydrate.ts b/node_package/src/reactHydrate.ts deleted file mode 100644 index 5b92b9ca2c..0000000000 --- a/node_package/src/reactHydrate.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ReactDOM from 'react-dom'; -import { ReactElement, Component } from 'react'; -import supportsReactCreateRoot from './supportsReactCreateRoot'; - -export default function reactHydrate(domNode: Element, reactElement: ReactElement): void | Element | Component { - if (supportsReactCreateRoot) { - // @ts-expect-error potentially present if React 18 or greater - return ReactDOM.hydrateRoot(domNode, reactElement); - } - - return ReactDOM.hydrate(reactElement, domNode); -} diff --git a/node_package/src/reactRender.ts b/node_package/src/reactRender.ts deleted file mode 100644 index 36a57c3303..0000000000 --- a/node_package/src/reactRender.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ReactDOM from 'react-dom'; -import { ReactElement, Component } from 'react'; -import supportsReactCreateRoot from './supportsReactCreateRoot'; - -export default function reactRender(domNode: Element, reactElement: ReactElement): void | Element | Component { - if (supportsReactCreateRoot) { - // @ts-expect-error potentially present if React 18 or greater - const root = ReactDOM.createRoot(domNode); - root.render(reactElement); - return root - } - - // eslint-disable-next-line react/no-render-return-value - return ReactDOM.render(reactElement, domNode); -} diff --git a/node_package/src/types/index.ts b/node_package/src/types/index.ts index 1e8f35f9b2..f26afc30e0 100644 --- a/node_package/src/types/index.ts +++ b/node_package/src/types/index.ts @@ -52,6 +52,10 @@ interface RenderFunction { type ReactComponentOrRenderFunction = ReactComponent | RenderFunction; +type RootRenderFunction = (domNode: Element, reactElement: ReactElement) => void | Element | Component; + +type RootHydrateFunction = RootRenderFunction; + export type { // eslint-disable-line import/prefer-default-export ReactComponentOrRenderFunction, ReactComponent, @@ -61,6 +65,8 @@ export type { // eslint-disable-line import/prefer-default-export StoreGenerator, CreateReactOutputResult, ServerRenderResult, + RootRenderFunction, + RootHydrateFunction, } export interface RegisteredComponent { diff --git a/node_package/tests/supportsReactCreateRoot.test.js b/node_package/tests/supportsReactCreateRoot.test.js index 44f521f941..70660d3310 100644 --- a/node_package/tests/supportsReactCreateRoot.test.js +++ b/node_package/tests/supportsReactCreateRoot.test.js @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom'; -import { isVersionGreaterThanOrEqualTo18 } from '../src/supportsReactCreateRoot'; +import { isVersionGreaterThanOrEqualTo18 } from '../src/helpers/supportsReactCreateRoot'; describe('supportsReactCreateRoot', () => { it('returns false for ReactDOM v16, no version', () => {