Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/5963-fix-render-webchat-without-render.md
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions __tests__/html2/react19/asset/reactDOMWithoutRender.js
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en-US">
<head>
<title>renderWebChat() renders into a custom element that has its own render() method (ESM build)</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main>
<!-- Custom elements are inline by default. -->
<chat-container id="webchat" style="display: block"></chat-container>
</main>
<script type="importmap">
{
"imports": {
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { createStoreWithOptions, renderWebChat, testIds } from 'botframework-webchat';
import { version } from 'react';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: React 18, which still has `ReactDOM.render()`.
expect(version).toMatch(/^18\./u);

// GIVEN: A custom element with its own `render()` method, like Lit components.
let numRenderCalls = 0;

customElements.define(
'chat-container',
class extends HTMLElement {
render() {
numRenderCalls++;
}
}
);

const chatContainer = document.getElementById('webchat');

expect(typeof chatContainer.render).toBe('function');

// WHEN: Web Chat is rendered by renderWebChat() into the custom element, without the send box.
renderWebChat({ directLine, store, styleOptions: { hideSendBox: true } }, chatContainer);

// THEN: The custom element is treated as an element, not as a root: its `render()` is not called.
expect(numRenderCalls).toBe(0);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);

// THEN: Web Chat is rendered inside the custom element.
expect(chatContainer.contains(pageElements.transcript())).toBe(true);

// THEN: Web Chat shows the message without the send box.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions __tests__/html2/react19/renderWebChat.esm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en-US">
<head>
<title>renderWebChat() renders with the root passed when ReactDOM.render() is unavailable (ESM build)</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<!--
React 18 with "react-dom" emulating React 19 (no `ReactDOM.render()`).
TODO: Use React 19 when https://github.com/spyip/react-film/pull/110 is released and bumped: the static build cannot load with React 19 until then.
-->
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "/__dist__/packages/bundle/static/react.js",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this use esm.sh/react@19.3.1 as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried: the static build fails with "'react-dom' does not provide an export named 'render'", from react-film's import { render } from 'react-dom'. Real React 19 with the static build is tested on the react-film stack (react19/esm.renderWebChat.html); this page switches once react-film is gone (TODO updated in #5971).

"react-dom": "./asset/reactDOMWithoutRender.js",
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, renderWebChat, testIds } from 'botframework-webchat';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();
const webChatElement = document.getElementById('webchat');

// GIVEN: "react-dom" without `ReactDOM.render()`, like React 19.
expect(ReactDOM.render).toBeUndefined();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add expect(ReactDOM.version).toBe(expect.stringMatching(/^19\./)) for clearer expectation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #5971 (69ed9a9ed). This page runs React 18 with ReactDOM.render() removed (the static build cannot load real React 19 yet), so it expects /^18\./; the real React 19 pages (IIFE, Fluent IIFE, copy button) expect /^19\./. toEqual(), as toBe() ignores asymmetric matchers.


// WHEN: Web Chat is rendered by renderWebChat() into the element.
// THEN: It throws, asking for a root: the page imports "react-dom/client", not Web Chat.
expect(() => renderWebChat({ directLine, store }, webChatElement)).toThrow(
'renderWebChat() on React 19 or later needs a root: pass createRoot(element) from "react-dom/client" instead of the element.'
);

// WHEN: Web Chat is rendered by renderWebChat() with a root from createRoot() of the page, without the send box.
const root = createRoot(webChatElement);

renderWebChat({ directLine, store, styleOptions: { hideSendBox: true } }, root);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);

expect(pageElements.sendBoxTextBox()).toBeFalsy();

// THEN: Web Chat shows the message without the send box.
await host.snapshot('local');

const transcript = pageElements.transcript();

// WHEN: renderWebChat() is called again with the same root, with the send box.
renderWebChat({ directLine, store, styleOptions: { hideSendBox: false } }, root);

// THEN: The send box appears.
await waitFor(() => expect(pageElements.sendBoxTextBox()).toBeTruthy());

// THEN: The same root is updated: the transcript is not mounted again.
expect(pageElements.transcript()).toBe(transcript);

// THEN: Web Chat shows the same message with the send box.
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 97 additions & 0 deletions __tests__/html2/react19/renderWebChat.iife.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19: renderWebChat() of the IIFE build renders with the root passed</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"react": "https://esm.sh/react@19.3.1?dev",
"react-dom": "https://esm.sh/react-dom@19.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client?dev"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script type="module">
// React 19 has no UMD build: the IIFE build of Web Chat uses `window.React` and `window.ReactDOM` when they are set.
import React from 'react';
import ReactDOM from 'react-dom';

window.React = React;
window.ReactDOM = ReactDOM;
</script>
<script crossorigin="anonymous" defer src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { waitFor } from '@testduet/wait-for';
import { version } from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

run(async function () {
const {
WebChat: { renderWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();
const webChatElement = document.getElementById('webchat');

// GIVEN: React 19, which no longer has `ReactDOM.render()`.
expect(version).toBe('19.3.1');
expect(ReactDOM.render).toBeUndefined();

// WHEN: Web Chat is rendered by renderWebChat() into the element.
// THEN: It throws, asking for a root: React 19 removed `ReactDOM.render()`.
expect(() => renderWebChat({ directLine, store }, webChatElement)).toThrow(
'renderWebChat() on React 19 or later needs a root: pass createRoot(element) from "react-dom/client" instead of the element.'
);

// WHEN: Web Chat is rendered by renderWebChat() with something that is neither an element nor a root.
// THEN: It throws.
expect(() => renderWebChat({ directLine, store }, {})).toThrow(
'renderWebChat() expects an HTMLElement or a root from createRoot().'
);

// WHEN: Web Chat is rendered by renderWebChat() with a root from createRoot() instead of the element, without the send box, as the error says.
const root = createRoot(webChatElement);

renderWebChat({ directLine, store, styleOptions: { hideSendBox: true } }, root);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);

expect(pageElements.sendBoxTextBox()).toBeFalsy();

// THEN: Web Chat shows the message without the send box.
await host.snapshot('local');

const transcript = pageElements.transcript();

// WHEN: renderWebChat() is called again with the same root, with the send box.
renderWebChat({ directLine, store, styleOptions: { hideSendBox: false } }, root);

// THEN: The send box appears.
await waitFor(() => expect(pageElements.sendBoxTextBox()).toBeTruthy());

// THEN: Web Chat is updated, not mounted again: the transcript is the same element.
expect(pageElements.transcript()).toBe(transcript);

// THEN: Web Chat shows the same message with the send box.
await host.snapshot('local');

// WHEN: The root is unmounted.
root.unmount();

// THEN: Web Chat is gone: it was rendered by the root passed.
expect(pageElements.transcript()).toBeFalsy();
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions __tests__/html2/react19/renderWebChat.root.react18.esm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 18: renderWebChat() renders with the root passed instead of ReactDOM.render() (ESM build)</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js",
"react-dom/client": "/__dist__/packages/bundle/static/react-dom/client.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, renderWebChat, testIds } from 'botframework-webchat';
import { version } from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: React 18, which still has `ReactDOM.render()`.
expect(version).toMatch(/^18\./u);
expect(typeof ReactDOM.render).toBe('function');

// GIVEN: A root from createRoot(), which records what it renders.
const root = createRoot(document.getElementById('webchat'));
const renderedChildren = [];
const recordingRoot = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider use jest-mock/spyOn.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #5971 (c1693e72d): spyOn(root, 'render') from jest-mock replaces the hand-written recording root; it still calls through.

render(children) {
renderedChildren.push(children);
root.render(children);
}
};

// WHEN: Web Chat is rendered by renderWebChat() with the root instead of the element, without the send box.
renderWebChat({ directLine, store, styleOptions: { hideSendBox: true } }, recordingRoot);

// THEN: The root rendered Web Chat once.
expect(renderedChildren).toHaveLength(1);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);

expect(pageElements.sendBoxTextBox()).toBeFalsy();

// THEN: Web Chat shows the message without the send box.
await host.snapshot('local');

const transcript = pageElements.transcript();

// WHEN: renderWebChat() is called again with the same root, with the send box.
renderWebChat({ directLine, store, styleOptions: { hideSendBox: false } }, recordingRoot);

// THEN: The root rendered Web Chat again.
expect(renderedChildren).toHaveLength(2);

// THEN: The send box appears.
await waitFor(() => expect(pageElements.sendBoxTextBox()).toBeTruthy());

// THEN: Web Chat is updated, not mounted again: the transcript is the same element.
expect(pageElements.transcript()).toBe(transcript);

// WHEN: The root is unmounted.
root.unmount();

// THEN: Web Chat is gone: it was rendered by the root, not by `ReactDOM.render()`.
expect(pageElements.transcript()).toBeFalsy();
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion docs/HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ReactWebChat>` component

Expand Down
4 changes: 2 additions & 2 deletions packages/bundle/src/boot/actual/renderWebChat/full.ts
Original file line number Diff line number Diff line change
@@ -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 };
4 changes: 2 additions & 2 deletions packages/bundle/src/boot/actual/renderWebChat/minimal.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading