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/5971-fix-react-19-follow-ups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Fixed the IIFE build of the Fluent theme crashing on load with React 19, in PR [#5971](https://github.com/microsoft/BotFramework-WebChat/pull/5971), by [@OEvgeny](https://github.com/OEvgeny)
92 changes: 92 additions & 0 deletions __tests__/html2/react19/codeBlockCopyButton.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19: renders the copy button of code blocks</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 { createElement, version } from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

run(async function () {
await host.sendDevToolsCommand('Browser.setPermission', {
permission: { name: 'clipboard-write' },
setting: 'granted'
});

const {
WebChat: { ReactWebChat, testIds }
} = window; // Imports in UMD fashion.

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

// GIVEN: Web Chat rendered with React 19, which no longer has `ReactDOM.render()`.
expect(version).toBe('19.3.1');
expect(ReactDOM.version).toEqual(expect.stringMatching(/^19\./u));
expect(ReactDOM.render).toBeUndefined();

createRoot(document.getElementById('webchat')).render(
createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } })
);

await pageConditions.uiConnected();

// WHEN: The bot sends a code block.
await directLine.emulateIncomingActivity({
text: 'Here is the code:\n\n```\nconsole.log("Hello, World!");\n```',
type: 'message'
});

await pageConditions.numActivitiesShown(1);

// THEN: The code block should have a "Copy" button.
const copyButton = await waitFor(() => {
const element = document.querySelector(`[data-testid="${testIds.codeBlockCopyButton}"]`);

expect(element).toBeTruthy();

return element;
});

const { name, role } = await testHelpers.getAccessibilityNode(copyButton);

expect(name.value).toBe('Copy');
expect(role.value).toBe('button');

// WHEN: The "Copy" button is clicked.
await host.click(copyButton);

// THEN: The button should change to "Copied".
await waitFor(async () => expect(await testHelpers.getAccessibleName(copyButton)).toBe('Copied'));

// THEN: The code block shows the copy button with a check mark.
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/fluentTheme.iife.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19: the IIFE build of the Fluent theme sends a message and shows the reply</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"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 builds of Web Chat and the Fluent theme use `window.React` and `window.ReactDOM`.
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>
<script crossorigin="anonymous" defer src="/__dist__/botframework-webchat-fluent-theme.development.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { createElement, version } from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';

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

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

// GIVEN: The Fluent theme rendered with React 19, which no longer has `ReactDOM.render()`.
expect(version).toBe('19.3.1');
expect(ReactDOM.version).toEqual(expect.stringMatching(/^19\./u));
expect(ReactDOM.render).toBeUndefined();

createRoot(document.getElementById('webchat')).render(
createElement(FluentThemeProvider, {}, createElement(ReactWebChat, { directLine, store }))
);

await pageConditions.uiConnected();

// WHEN: A message is sent through the send box.
const sendMessage = await directLine.actPostActivity(() =>
pageObjects.sendMessageViaSendBox('Aloha!', { waitForSend: false })
);

await sendMessage.resolveAll();

// WHEN: The bot replies.
await directLine.emulateIncomingActivity({
from: { id: 'bot', role: 'bot' },
text: 'Aloha! How can I help?',
type: 'message'
});

// THEN: The transcript shows the message and the reply.
await pageConditions.numActivitiesShown(2);

const [sent, reply] = pageElements.activities();

expect(sent.textContent).toContain('Aloha!');
expect(reply.textContent).toContain('Aloha! How can I help?');

// THEN: The Fluent theme shows the message, the reply and its 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.
5 changes: 3 additions & 2 deletions __tests__/html2/react19/renderWebChat.esm.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<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.
TODO: Use React 19 once the static build no longer bundles react-film, which imports `render` from "react-dom" (removed, or https://github.com/spyip/react-film/pull/110 released and bumped): it fails to load with React 19 until then.
-->
<script type="importmap">
{
Expand Down Expand Up @@ -37,7 +37,8 @@
const { directLine, store } = testHelpers.createDirectLineEmulator();
const webChatElement = document.getElementById('webchat');

// GIVEN: "react-dom" without `ReactDOM.render()`, like React 19.
// GIVEN: "react-dom" of React 18 without `ReactDOM.render()`, like React 19.
expect(ReactDOM.version).toEqual(expect.stringMatching(/^18\./u)); // React 18 underneath the emulation, see the import map.
expect(ReactDOM.render).toBeUndefined();

// WHEN: Web Chat is rendered by renderWebChat() into the element.
Expand Down
3 changes: 2 additions & 1 deletion __tests__/html2/react19/renderWebChat.iife.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

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

// WHEN: Web Chat is rendered by renderWebChat() into the element.
Expand All @@ -54,7 +55,7 @@
// 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().'
'renderWebChat() expects an HTMLElement or a root from ReactDOM.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.
Expand Down
20 changes: 8 additions & 12 deletions __tests__/html2/react19/renderWebChat.root.react18.esm.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"jest-mock": "https://esm.sh/jest-mock",
"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"
Expand All @@ -23,6 +24,7 @@

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, renderWebChat, testIds } from 'botframework-webchat';
import { spyOn } from 'jest-mock';
import { version } from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
Expand All @@ -37,21 +39,15 @@
expect(version).toMatch(/^18\./u);
expect(typeof ReactDOM.render).toBe('function');

// GIVEN: A root from createRoot(), which records what it renders.
// GIVEN: A root from createRoot(), with its `render()` spied on.
const root = createRoot(document.getElementById('webchat'));
const renderedChildren = [];
const recordingRoot = {
render(children) {
renderedChildren.push(children);
root.render(children);
}
};
const renderSpy = spyOn(root, 'render');

// 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);
renderWebChat({ directLine, store, styleOptions: { hideSendBox: true } }, root);

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

await pageConditions.uiConnected();

Expand All @@ -66,10 +62,10 @@
const transcript = pageElements.transcript();

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

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

// THEN: The send box appears.
await waitFor(() => expect(pageElements.sendBoxTextBox()).toBeTruthy());
Expand Down
3 changes: 1 addition & 2 deletions docs/HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ When deploying to locked down environment, clients may not have access to smart
"imports": {
"botframework-webchat": "https://cdn.botframework.com/4.19.0/static/botframework-webchat.js",
"react": "https://cdn.botframework.com/4.19.0/static/react.baseline.js",
"react-dom": "https://cdn.botframework.com/4.19.0/static/react-dom.baseline.js",
"react-dom/client": "https://cdn.botframework.com/4.19.0/static/react-dom.baseline/client.js"
"react-dom": "https://cdn.botframework.com/4.19.0/static/react-dom.baseline.js"
}
}
</script>
Expand Down
14 changes: 13 additions & 1 deletion packages/bundle/src/renderWebChat.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ test('Root should be rendered by root.render() instead of ReactDOM.render()', ()
expect(render).not.toHaveBeenCalled();
});

test('Error from root.render() should be thrown synchronously to the host', () => {
// React throws this when rendering with a root after `root.unmount()`.
const root = {
render: jest.fn(() => {
throw new Error('Cannot update an unmounted root.');
})
};

expect(() => renderWebChat(ReactWebChat, {}, root)).toThrow('Cannot update an unmounted root.');
expect(root.render).toHaveBeenCalledTimes(1);
});

test('Element on React 19 should throw an error asking for a root', () => {
// React 19 removed `ReactDOM.render()`.
Object.assign(ReactDOM, { render: undefined });
Expand All @@ -55,5 +67,5 @@ test('Element on React 19 should throw an error asking for a root', () => {

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().'
'renderWebChat() expects an HTMLElement or a root from ReactDOM.createRoot().'
));
20 changes: 13 additions & 7 deletions packages/bundle/src/renderWebChat.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { reactNode } from '@msinternal/botframework-webchat-react-valibot';
import React, { type ComponentType, type ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { custom, function_, is, object, picklist } from 'valibot';
import { args, custom, function_, is, object, picklist, pipe, tuple } from 'valibot';

/** A root from `createRoot()` of "react-dom/client". */
type RenderWebChatRoot = { render(children: ReactNode): void };
Expand All @@ -26,13 +27,16 @@ 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.
// Checked before `reactRootSchema`: some custom elements (e.g. Lit) have their own `render()` method.
const containerSchema = custom<Exclude<RenderWebChatContainer, null>>(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_() });
// `ReactDOM.render(children, container)`, React 18 or earlier.
const legacyReactDOMSchema = object({ render: pipe(function_(), args(tuple([reactNode(), containerSchema]))) });

// `root.render(children)`, root from `createRoot()` of "react-dom/client".
const reactRootSchema = object({ render: pipe(function_(), args(tuple([reactNode()]))) });

export default function renderWebChat(
ReactWebChat: ComponentType<any>,
Expand All @@ -43,19 +47,21 @@ export default function renderWebChat(

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)) {
if (!is(legacyReactDOMSchema, 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)) {
} else if (is(reactRootSchema, 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().');
throw new Error(
'botframework-webchat: renderWebChat() expects an HTMLElement or a root from ReactDOM.createRoot().'
);
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/fluent-theme/src/external.umd/react-dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// <reference types="node" />

// The React DOM of the page, as with "react": a bundled React DOM cannot initialize with another React, such as React 19.
module.exports = (globalThis as any).ReactDOM;
6 changes: 6 additions & 0 deletions packages/fluent-theme/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const umdResolvePlugin = {
path: join(fileURLToPath(import.meta.url), '../src/external.umd/react.ts')
}));

// ESBuild use Go regular expressions and does not understand Unicode flag.
// eslint-disable-next-line require-unicode-regexp
build.onResolve({ filter: /^react-dom$/ }, () => ({
Comment thread
OEvgeny marked this conversation as resolved.
Resolved
path: join(fileURLToPath(import.meta.url), '../src/external.umd/react-dom.ts')
}));

// ESBuild use Go regular expressions and does not understand Unicode flag.
// eslint-disable-next-line require-unicode-regexp
build.onResolve({ filter: /^botframework-webchat-api$/ }, () => ({
Expand Down
Loading