diff --git a/.changelog/5963-fix-render-webchat-without-render.md b/.changelog/5963-fix-render-webchat-without-render.md new file mode 100644 index 0000000000..c54e7a0e76 --- /dev/null +++ b/.changelog/5963-fix-render-webchat-without-render.md @@ -0,0 +1,3 @@ +### Added + +- `renderWebChat()`: added support of a root from `createRoot()` for React 19, related to [#5552](https://github.com/microsoft/BotFramework-WebChat/issues/5552) and [#5850](https://github.com/microsoft/BotFramework-WebChat/issues/5850), in PR [#5963](https://github.com/microsoft/BotFramework-WebChat/pull/5963), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/html2/react19/asset/reactDOMWithoutRender.js b/__tests__/html2/react19/asset/reactDOMWithoutRender.js new file mode 100644 index 0000000000..03bedf7469 --- /dev/null +++ b/__tests__/html2/react19/asset/reactDOMWithoutRender.js @@ -0,0 +1,9 @@ +// Emulates "react-dom" of React 19, where `ReactDOM.render()` was removed, on top of React 18. +// Named exports are kept: react-film, bundled in the static build, imports `render` by name (spyip/react-film#110). +import ReactDOM from '/__dist__/packages/bundle/static/react-dom.js'; + +export * from '/__dist__/packages/bundle/static/react-dom.js'; + +const { render: _render, ...ReactDOMWithoutRender } = ReactDOM; + +export default Object.freeze(ReactDOMWithoutRender); diff --git a/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html b/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html new file mode 100644 index 0000000000..859a74e2fa --- /dev/null +++ b/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html @@ -0,0 +1,72 @@ + + + + renderWebChat() renders into a custom element that has its own render() method (ESM build) + + + +
+ + +
+ + + + diff --git a/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html.snap-1.png b/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html.snap-1.png new file mode 100644 index 0000000000..041fcd2500 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.customElement.react18.esm.html.snap-1.png differ diff --git a/__tests__/html2/react19/renderWebChat.esm.html b/__tests__/html2/react19/renderWebChat.esm.html new file mode 100644 index 0000000000..ac79ff7a4d --- /dev/null +++ b/__tests__/html2/react19/renderWebChat.esm.html @@ -0,0 +1,80 @@ + + + + renderWebChat() renders with the root passed when ReactDOM.render() is unavailable (ESM build) + + + +
+ + + + + diff --git a/__tests__/html2/react19/renderWebChat.esm.html.snap-1.png b/__tests__/html2/react19/renderWebChat.esm.html.snap-1.png new file mode 100644 index 0000000000..041fcd2500 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.esm.html.snap-1.png differ diff --git a/__tests__/html2/react19/renderWebChat.esm.html.snap-2.png b/__tests__/html2/react19/renderWebChat.esm.html.snap-2.png new file mode 100644 index 0000000000..575a402053 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.esm.html.snap-2.png differ diff --git a/__tests__/html2/react19/renderWebChat.iife.html b/__tests__/html2/react19/renderWebChat.iife.html new file mode 100644 index 0000000000..221d62ab38 --- /dev/null +++ b/__tests__/html2/react19/renderWebChat.iife.html @@ -0,0 +1,97 @@ + + + + React 19: renderWebChat() of the IIFE build renders with the root passed + + + + + + + + +
+ + + diff --git a/__tests__/html2/react19/renderWebChat.iife.html.snap-1.png b/__tests__/html2/react19/renderWebChat.iife.html.snap-1.png new file mode 100644 index 0000000000..041fcd2500 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.iife.html.snap-1.png differ diff --git a/__tests__/html2/react19/renderWebChat.iife.html.snap-2.png b/__tests__/html2/react19/renderWebChat.iife.html.snap-2.png new file mode 100644 index 0000000000..575a402053 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.iife.html.snap-2.png differ diff --git a/__tests__/html2/react19/renderWebChat.root.react18.esm.html b/__tests__/html2/react19/renderWebChat.root.react18.esm.html new file mode 100644 index 0000000000..f11837f58d --- /dev/null +++ b/__tests__/html2/react19/renderWebChat.root.react18.esm.html @@ -0,0 +1,88 @@ + + + + React 18: renderWebChat() renders with the root passed instead of ReactDOM.render() (ESM build) + + + +
+ + + + diff --git a/__tests__/html2/react19/renderWebChat.root.react18.esm.html.snap-1.png b/__tests__/html2/react19/renderWebChat.root.react18.esm.html.snap-1.png new file mode 100644 index 0000000000..041fcd2500 Binary files /dev/null and b/__tests__/html2/react19/renderWebChat.root.react18.esm.html.snap-1.png differ diff --git a/docs/HOSTING.md b/docs/HOSTING.md index e7e566c98a..993f092618 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -230,7 +230,13 @@ Then, run the following JavaScript code to render Web Chat into the placeholder. renderWebChat({ directLine }, document.getElementById('webchat')); ``` -> Note: The `renderWebChat()` function is a simple wrapper around `render()` function in React DOM. +> Note: The `renderWebChat()` function is a simple wrapper around `render()` function in React DOM. React 19 removed `render()`: create a root with `createRoot()` from `react-dom/client` and pass it instead of the element. + +```tsx +import { createRoot } from 'react-dom/client'; + +renderWebChat({ directLine }, createRoot(document.getElementById('webchat'))); +``` ### Using `` component diff --git a/packages/bundle/src/boot/actual/renderWebChat/full.ts b/packages/bundle/src/boot/actual/renderWebChat/full.ts index 03cfdbedea..e36b4fe0d0 100644 --- a/packages/bundle/src/boot/actual/renderWebChat/full.ts +++ b/packages/bundle/src/boot/actual/renderWebChat/full.ts @@ -1,6 +1,6 @@ -import coreRenderWebChat from '../../../renderWebChat'; +import coreRenderWebChat, { type RenderWebChat } from '../../../renderWebChat'; import { ReactWebChat } from '../component/full'; -const renderWebChat = coreRenderWebChat.bind(null, ReactWebChat); +const renderWebChat: RenderWebChat = coreRenderWebChat.bind(null, ReactWebChat); export { renderWebChat }; diff --git a/packages/bundle/src/boot/actual/renderWebChat/minimal.ts b/packages/bundle/src/boot/actual/renderWebChat/minimal.ts index e21586847c..f190c722c3 100644 --- a/packages/bundle/src/boot/actual/renderWebChat/minimal.ts +++ b/packages/bundle/src/boot/actual/renderWebChat/minimal.ts @@ -1,6 +1,6 @@ -import coreRenderWebChat from '../../../renderWebChat'; +import coreRenderWebChat, { type RenderWebChat } from '../../../renderWebChat'; import { ReactWebChat } from '../component/minimal'; -const renderWebChat = coreRenderWebChat.bind(null, ReactWebChat); +const renderWebChat: RenderWebChat = coreRenderWebChat.bind(null, ReactWebChat); export { renderWebChat }; diff --git a/packages/bundle/src/renderWebChat.spec.tsx b/packages/bundle/src/renderWebChat.spec.tsx new file mode 100644 index 0000000000..1ca156f321 --- /dev/null +++ b/packages/bundle/src/renderWebChat.spec.tsx @@ -0,0 +1,59 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import ReactDOM from 'react-dom'; + +import renderWebChat from './renderWebChat'; + +// `render` is set before each test, the React 19 test removes it. +jest.mock('react-dom', () => ({ __esModule: true, default: {} })); + +const ReactWebChat = () => null; + +let render: jest.Mock; + +beforeEach(() => { + render = jest.fn(); + Object.assign(ReactDOM, { render }); +}); + +test('Element should be rendered by ReactDOM.render()', () => { + const element = document.createElement('div'); + + renderWebChat(ReactWebChat, {}, element); + + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0][1]).toBe(element); +}); + +test('DocumentFragment should be rendered by ReactDOM.render() as a container', () => { + const fragment = document.createDocumentFragment(); + + renderWebChat(ReactWebChat, {}, fragment); + + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0][1]).toBe(fragment); +}); + +test('Root should be rendered by root.render() instead of ReactDOM.render()', () => { + const root = { render: jest.fn() }; + + renderWebChat(ReactWebChat, {}, root); + + expect(root.render).toHaveBeenCalledTimes(1); + expect(render).not.toHaveBeenCalled(); +}); + +test('Element on React 19 should throw an error asking for a root', () => { + // React 19 removed `ReactDOM.render()`. + Object.assign(ReactDOM, { render: undefined }); + + expect(() => renderWebChat(ReactWebChat, {}, document.createElement('div'))).toThrow( + 'renderWebChat() on React 19 or later needs a root: pass createRoot(element) from "react-dom/client" instead of the element.' + ); +}); + +test('Neither a container nor a root should throw', () => + expect(() => renderWebChat(ReactWebChat, {}, {} as unknown as HTMLElement)).toThrow( + 'renderWebChat() expects an HTMLElement or a root from createRoot().' + )); diff --git a/packages/bundle/src/renderWebChat.tsx b/packages/bundle/src/renderWebChat.tsx index a702bdd147..d1e9230573 100644 --- a/packages/bundle/src/renderWebChat.tsx +++ b/packages/bundle/src/renderWebChat.tsx @@ -1,6 +1,62 @@ -import React, { ComponentType } from 'react'; +import React, { type ComponentType, type ReactNode } from 'react'; import ReactDOM from 'react-dom'; +import { custom, function_, is, object, picklist } from 'valibot'; -export default function renderWebChat(ReactWebChat: ComponentType, props: any, element: HTMLElement): void { - ReactDOM.render(, element); +/** A root from `createRoot()` of "react-dom/client". */ +type RenderWebChatRoot = { render(children: ReactNode): void }; + +/** Containers `ReactDOM.render()` accepts, `null` included as in its typings (it throws at run time). */ +type RenderWebChatContainer = Element | Document | DocumentFragment | null; + +type RenderWebChat = { + /** + * Renders Web Chat into an element, or with a root from `createRoot()`. + * + * @param props Props of ``. + * @param elementOrRoot Element to render Web Chat into with `ReactDOM.render()`, or a root from `createRoot()` of "react-dom/client" to render Web Chat with. + * React 19 or later removed `ReactDOM.render()`: pass `createRoot(element)` instead of the element. + * @throws When an element is passed on React 19 or later, or when `elementOrRoot` is neither an element nor a root. + */ + (props: any, elementOrRoot: RenderWebChatContainer | RenderWebChatRoot): void; +}; + +// Same as `Node.ELEMENT_NODE`, `Node.DOCUMENT_NODE` and `Node.DOCUMENT_FRAGMENT_NODE`, without needing `Node` on import. +const ELEMENT_NODE = 1; +const DOCUMENT_NODE = 9; +const DOCUMENT_FRAGMENT_NODE = 11; + +// Containers `ReactDOM.render()` accepts: element, document and document fragment, by `nodeType` to work across realms. +// Checked before `renderSchema`: some custom elements (e.g. Lit) have their own `render()` method. +const containerSchema = custom>(value => + is(object({ nodeType: picklist([DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE]) }), value) +); + +// Both `ReactDOM.render()` (React 18 or earlier) and `root.render()`. +const renderSchema = object({ render: function_() }); + +export default function renderWebChat( + ReactWebChat: ComponentType, + props: any, + elementOrRoot: RenderWebChatContainer | RenderWebChatRoot +): void { + const children = ; + + if (is(containerSchema, elementOrRoot)) { + // React 19 or later removed `ReactDOM.render()`: Web Chat does not import "react-dom/client", the host creates the root. + if (!is(renderSchema, ReactDOM)) { + throw new Error( + 'botframework-webchat: renderWebChat() on React 19 or later needs a root: pass createRoot(element) from "react-dom/client" instead of the element.' + ); + } + + // React 16 to 18, and Preact. Also the IIFE build, which bundles React DOM 16.8.6 unless `window.ReactDOM` is set. + ReactDOM.render(children, elementOrRoot); + } else if (is(renderSchema, elementOrRoot)) { + // The host created the root: render with it on any React version, it is the host's to unmount. + elementOrRoot.render(children); + } else { + throw new Error('botframework-webchat: renderWebChat() expects an HTMLElement or a root from createRoot().'); + } } + +export { type RenderWebChat, type RenderWebChatContainer, type RenderWebChatRoot }; diff --git a/packages/isomorphic-react-dom/src/ReactDOM.js b/packages/isomorphic-react-dom/src/ReactDOM.js index 69b3b0d32a..1aac433ea2 100644 --- a/packages/isomorphic-react-dom/src/ReactDOM.js +++ b/packages/isomorphic-react-dom/src/ReactDOM.js @@ -1,3 +1,2 @@ -const ReactDOM = require('react-dom'); - -module.exports = typeof window.ReactDOM === 'undefined' ? ReactDOM : window.ReactDOM; +// The bundled React DOM is only evaluated when it is used: it cannot initialize with another React, such as React 19 in `window.React`. +module.exports = typeof window.ReactDOM === 'undefined' ? require('react-dom') : window.ReactDOM;