From af52b8aa838ca1d1ecefcd684a28e051bab5b92f Mon Sep 17 00:00:00 2001 From: Tom Dracz Date: Mon, 25 Apr 2022 12:03:01 +0100 Subject: [PATCH 1/4] Draft React 18 alternative support --- node_package/src/ReactOnRails.ts | 3 +-- node_package/src/clientStartup.ts | 7 +++---- node_package/src/helpers/legacyRootHandler.ts | 12 ++++++++++++ node_package/src/helpers/modernRootHandler.ts | 14 ++++++++++++++ node_package/src/helpers/renderHelper.ts | 19 +++++++++++++++++++ .../{ => helpers}/supportsReactCreateRoot.ts | 0 node_package/src/reactHydrate.ts | 12 ------------ node_package/src/reactRender.ts | 15 --------------- node_package/src/types/index.ts | 6 ++++++ 9 files changed, 55 insertions(+), 33 deletions(-) create mode 100644 node_package/src/helpers/legacyRootHandler.ts create mode 100644 node_package/src/helpers/modernRootHandler.ts create mode 100644 node_package/src/helpers/renderHelper.ts rename node_package/src/{ => helpers}/supportsReactCreateRoot.ts (100%) delete mode 100644 node_package/src/reactHydrate.ts delete mode 100644 node_package/src/reactRender.ts diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 16ab9efbf..570ebe152 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -18,8 +18,7 @@ import type { AuthenticityHeaders, StoreGenerator } from './types/index'; -import reactHydrate from './reactHydrate'; -import reactRender from './reactRender'; +import { reactHydrate, reactRender } from './helpers/renderHelper'; /* eslint-disable @typescript-eslint/no-explicit-any */ type Store = any; diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index 9914814d1..c711c1273 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 { reactHydrate, reactRender, canHydrate } from './helpers/renderHelper'; declare global { interface Window { @@ -152,8 +151,7 @@ function render(el: Element, railsContext: RailsContext): void { } // 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 shouldHydrate = canHydrate && !!domNode.innerHTML; const reactElementOrRouterResult = createReactOutput({ componentObj, @@ -213,6 +211,7 @@ 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) { console.info(`Caught error calling unmountComponentAtNode: ${e.message} for domNode`, diff --git a/node_package/src/helpers/legacyRootHandler.ts b/node_package/src/helpers/legacyRootHandler.ts new file mode 100644 index 000000000..efbe0c8ce --- /dev/null +++ b/node_package/src/helpers/legacyRootHandler.ts @@ -0,0 +1,12 @@ +import ReactDOM from 'react-dom'; +import type { RootRenderFunction, RootHydrateFunction } from '../types'; + +export const canHydrate = !!ReactDOM.hydrate + +export const hydrate: RootHydrateFunction = (domNode, reactElement) => { + return ReactDOM.hydrate(reactElement, domNode) +} + +export const render: RootRenderFunction = (domNode, reactElement) => { + return 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 000000000..71f79e381 --- /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 000000000..e0590f861 --- /dev/null +++ b/node_package/src/helpers/renderHelper.ts @@ -0,0 +1,19 @@ +import supportsReactCreateRoot from './supportsReactCreateRoot'; +import * as legacyRootHandler from './legacyRootHandler'; +import * as modernRootHandler from './modernRootHandler'; + + +let toExport; + +if (supportsReactCreateRoot) { + toExport = legacyRootHandler; +} else { + toExport = modernRootHandler; +} + +export const { + canHydrate, + hydrate: reactHydrate, + render: reactRender +} = toExport; + 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 5b92b9ca2..000000000 --- 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 36a57c330..000000000 --- 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 1e8f35f9b..f26afc30e 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 { From 47912e2c89ff979508fc765c22b89b5e9bdc16bb Mon Sep 17 00:00:00 2001 From: Tom Dracz Date: Mon, 25 Apr 2022 12:15:58 +0100 Subject: [PATCH 2/4] Conditions wrong way around --- node_package/src/helpers/renderHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/node_package/src/helpers/renderHelper.ts b/node_package/src/helpers/renderHelper.ts index e0590f861..6c9954ba9 100644 --- a/node_package/src/helpers/renderHelper.ts +++ b/node_package/src/helpers/renderHelper.ts @@ -6,9 +6,9 @@ import * as modernRootHandler from './modernRootHandler'; let toExport; if (supportsReactCreateRoot) { - toExport = legacyRootHandler; -} else { toExport = modernRootHandler; +} else { + toExport = legacyRootHandler; } export const { From f2bc39ba258dcc20cc15c6c84138637be9a087e9 Mon Sep 17 00:00:00 2001 From: Tom Dracz Date: Sun, 1 May 2022 10:51:07 +0100 Subject: [PATCH 3/4] Promise based approach --- node_package/src/clientStartup.ts | 89 +++++++++++-------- node_package/src/helpers/legacyRootHandler.ts | 15 ++-- node_package/src/helpers/modernRootHandler.ts | 22 ++--- node_package/src/helpers/renderHelper.ts | 45 +++++++--- 4 files changed, 100 insertions(+), 71 deletions(-) diff --git a/node_package/src/clientStartup.ts b/node_package/src/clientStartup.ts index c711c1273..d80a7f4f7 100644 --- a/node_package/src/clientStartup.ts +++ b/node_package/src/clientStartup.ts @@ -9,7 +9,7 @@ import type { import createReactOutput from './createReactOutput'; import {isServerRenderHash} from './isServerRenderResult'; -import { reactHydrate, reactRender, canHydrate } from './helpers/renderHelper'; +import renderHelperPromise from './helpers/renderHelper'; declare global { interface Window { @@ -137,46 +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 - 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)} + 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,7 +226,7 @@ function unmount(el: Element): void { 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 index efbe0c8ce..9125a6c0d 100644 --- a/node_package/src/helpers/legacyRootHandler.ts +++ b/node_package/src/helpers/legacyRootHandler.ts @@ -1,12 +1,9 @@ -import ReactDOM from 'react-dom'; -import type { RootRenderFunction, RootHydrateFunction } from '../types'; +// import ReactDOM from 'react-dom'; +// import type { RootRenderFunction, RootHydrateFunction } from '../types'; -export const canHydrate = !!ReactDOM.hydrate +// export const canHydrate = !!ReactDOM.hydrate -export const hydrate: RootHydrateFunction = (domNode, reactElement) => { - return ReactDOM.hydrate(reactElement, domNode) -} +// export const hydrate: RootHydrateFunction = (domNode, reactElement) => ReactDOM.hydrate(reactElement, domNode) -export const render: RootRenderFunction = (domNode, reactElement) => { - return ReactDOM.render(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 index 71f79e381..fd9488793 100644 --- a/node_package/src/helpers/modernRootHandler.ts +++ b/node_package/src/helpers/modernRootHandler.ts @@ -1,14 +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'; +// // @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 canHydrate = !!ReactDOM.hydrateRoot; -export const hydrate: RootHydrateFunction = (domNode, reactElement) => ReactDOM.hydrateRoot(domNode, reactElement); +// 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; -}; +// 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 index 6c9954ba9..052b036dc 100644 --- a/node_package/src/helpers/renderHelper.ts +++ b/node_package/src/helpers/renderHelper.ts @@ -1,19 +1,38 @@ import supportsReactCreateRoot from './supportsReactCreateRoot'; -import * as legacyRootHandler from './legacyRootHandler'; -import * as modernRootHandler from './modernRootHandler'; +import type { RootRenderFunction, RootHydrateFunction } from '../types'; +interface RenderHelper { + canHydrate: boolean; + reactHydrate: RootHydrateFunction; + reactRender: RootRenderFunction; +} -let toExport; +export default (async (): Promise => { + const toImport = supportsReactCreateRoot === true ? 'react-dom/client' : 'react-dom'; + const ReactDOM = await import(toImport); -if (supportsReactCreateRoot) { - toExport = modernRootHandler; -} else { - toExport = legacyRootHandler; -} + let canHydrate: RenderHelper['canHydrate']; + let reactHydrate: RenderHelper['reactHydrate']; + let reactRender: RenderHelper['reactRender']; -export const { - canHydrate, - hydrate: reactHydrate, - render: reactRender -} = toExport; + 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, + }; +})(); From 503fd63007e0336275f2006430380c7151d6b879 Mon Sep 17 00:00:00 2001 From: Tom Dracz Date: Sun, 1 May 2022 11:14:41 +0100 Subject: [PATCH 4/4] Changes --- node_package/src/ReactOnRails.ts | 16 ++++++++++------ .../tests/supportsReactCreateRoot.test.js | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/node_package/src/ReactOnRails.ts b/node_package/src/ReactOnRails.ts index 570ebe152..d7d3f9683 100644 --- a/node_package/src/ReactOnRails.ts +++ b/node_package/src/ReactOnRails.ts @@ -16,9 +16,10 @@ import type { ErrorOptions, ReactComponentOrRenderFunction, AuthenticityHeaders, - StoreGenerator + StoreGenerator, + RootHydrateFunction } from './types/index'; -import { reactHydrate, reactRender } from './helpers/renderHelper'; +import renderHelperPromise from './helpers/renderHelper'; /* eslint-disable @typescript-eslint/no-explicit-any */ type Store = any; @@ -183,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/tests/supportsReactCreateRoot.test.js b/node_package/tests/supportsReactCreateRoot.test.js index 44f521f94..70660d331 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', () => {