diff --git a/.changelog/5982-fix-react-16-shadow-dom.md b/.changelog/5982-fix-react-16-shadow-dom.md
new file mode 100644
index 0000000000..1672fb8251
--- /dev/null
+++ b/.changelog/5982-fix-react-16-shadow-dom.md
@@ -0,0 +1,3 @@
+### Changed
+
+- Made `renderWebChat()` on React 16 throw for an element inside a shadow root, where clicks and typing were ignored, related to [#5315](https://github.com/microsoft/BotFramework-WebChat/issues/5315), in PR [#5982](https://github.com/microsoft/BotFramework-WebChat/pull/5982), by [@OEvgeny](https://github.com/OEvgeny)
diff --git a/__tests__/html2/customElement/shadowRoot.html b/__tests__/html2/customElement/shadowRoot.html
index 7686c64923..31e336ef43 100644
--- a/__tests__/html2/customElement/shadowRoot.html
+++ b/__tests__/html2/customElement/shadowRoot.html
@@ -3,8 +3,9 @@
-
-
+
+
+
@@ -59,6 +60,8 @@
await pageConditions.uiConnected();
await pageConditions.scrollToBottomCompleted();
+
+ // THEN: Web Chat in the shadow root shows the transcript, styled through `stylesRoot`.
await host.snapshot('local');
for (const style of document.querySelector('webchat-element').shadowRoot.querySelectorAll('style')) {
diff --git a/__tests__/html2/customElement/shadowRoot.interact.react18.html b/__tests__/html2/customElement/shadowRoot.interact.react18.html
new file mode 100644
index 0000000000..6997afd79d
--- /dev/null
+++ b/__tests__/html2/customElement/shadowRoot.interact.react18.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-1.png b/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-1.png
new file mode 100644
index 0000000000..2d653de1e2
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-1.png differ
diff --git a/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-2.png b/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-2.png
new file mode 100644
index 0000000000..ce1ef4f3f0
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.react18.html.snap-2.png differ
diff --git a/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html
new file mode 100644
index 0000000000..e0af60c043
--- /dev/null
+++ b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-1.png b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-1.png
new file mode 100644
index 0000000000..cc78eb605b
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-1.png differ
diff --git a/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-2.png b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-2.png
new file mode 100644
index 0000000000..fc74059d73
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.renderIntoShadowRoot.html.snap-2.png differ
diff --git a/__tests__/html2/customElement/shadowRoot.react16.throw.html b/__tests__/html2/customElement/shadowRoot.react16.throw.html
new file mode 100644
index 0000000000..1e882179f7
--- /dev/null
+++ b/__tests__/html2/customElement/shadowRoot.react16.throw.html
@@ -0,0 +1,47 @@
+
+
+
+ React 16: renderWebChat() throws for an element inside a shadow root
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/customElement/shadowRoot.react16.throw.html.snap-1.png b/__tests__/html2/customElement/shadowRoot.react16.throw.html.snap-1.png
new file mode 100644
index 0000000000..041fcd2500
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.react16.throw.html.snap-1.png differ
diff --git a/packages/bundle/src/renderWebChat.spec.tsx b/packages/bundle/src/renderWebChat.spec.tsx
index b569dfcada..cac3e5975b 100644
--- a/packages/bundle/src/renderWebChat.spec.tsx
+++ b/packages/bundle/src/renderWebChat.spec.tsx
@@ -14,7 +14,9 @@ let render: jest.Mock;
beforeEach(() => {
render = jest.fn();
- Object.assign(ReactDOM, { render });
+
+ // React DOM 16.8.6 has no `version`.
+ Object.assign(ReactDOM, { createPortal: jest.fn(), render, version: undefined });
});
test('Element should be rendered by ReactDOM.render()', () => {
@@ -69,3 +71,56 @@ 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 ReactDOM.createRoot().'
));
+
+describe('Element in a shadow root', () => {
+ let element: HTMLElement;
+ let shadowRoot: ShadowRoot;
+
+ beforeEach(() => {
+ element = document.createElement('div');
+ shadowRoot = document.createElement('div').attachShadow({ mode: 'open' });
+
+ shadowRoot.append(element);
+ });
+
+ test('on React 16 should throw an error suggesting the shadow root or React 17', () => {
+ expect(() => renderWebChat(ReactWebChat, {}, element)).toThrow(
+ 'renderWebChat() on React 16 does not support an element inside a shadow root, React 16 would ignore clicks and typing. Pass the shadow root itself instead of the element, or use React 17 or later (with webchat.js, set window.React and window.ReactDOM before loading it).'
+ );
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ test('on React 16.14 should throw', () => {
+ Object.assign(ReactDOM, { version: '16.14.0' });
+
+ expect(() => renderWebChat(ReactWebChat, {}, element)).toThrow('on React 16 does not support');
+ expect(render).not.toHaveBeenCalled();
+ });
+
+ test('on React 17 or later should be rendered by ReactDOM.render()', () => {
+ Object.assign(ReactDOM, { version: '17.0.2' });
+
+ renderWebChat(ReactWebChat, {}, element);
+
+ expect(render).toHaveBeenCalledTimes(1);
+ expect(render.mock.calls[0][1]).toBe(element);
+ });
+
+ test('passed as the shadow root itself on React 16 should be rendered by ReactDOM.render()', () => {
+ renderWebChat(ReactWebChat, {}, shadowRoot);
+
+ expect(render).toHaveBeenCalledTimes(1);
+ expect(render.mock.calls[0][1]).toBe(shadowRoot);
+ });
+});
+
+test('Element outside of a shadow root on React 16 should be rendered by ReactDOM.render()', () => {
+ const element = document.createElement('div');
+
+ document.body.append(element);
+ renderWebChat(ReactWebChat, {}, element);
+ element.remove();
+
+ expect(render).toHaveBeenCalledTimes(1);
+ expect(render.mock.calls[0][1]).toBe(element);
+});
diff --git a/packages/bundle/src/renderWebChat.tsx b/packages/bundle/src/renderWebChat.tsx
index 9f12077a9d..4c741c2f2f 100644
--- a/packages/bundle/src/renderWebChat.tsx
+++ b/packages/bundle/src/renderWebChat.tsx
@@ -1,7 +1,20 @@
import { reactNode } from '@msinternal/botframework-webchat-react-valibot';
import React, { type ComponentType, type ReactNode } from 'react';
import ReactDOM from 'react-dom';
-import { args, custom, function_, is, object, picklist, pipe, tuple } from 'valibot';
+import {
+ args,
+ custom,
+ function_,
+ is,
+ literal,
+ object,
+ optional,
+ picklist,
+ pipe,
+ startsWith,
+ string,
+ tuple
+} from 'valibot';
/** A root from `createRoot()` of "react-dom/client". */
type RenderWebChatRoot = { render(children: ReactNode): void };
@@ -16,7 +29,7 @@ type RenderWebChat = {
* @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.
+ * @throws When an element is passed on React 19 or later, an element inside a shadow root on React 16, or when `elementOrRoot` is neither an element nor a root.
*/
(props: any, elementOrRoot: RenderWebChatContainer | RenderWebChatRoot): void;
};
@@ -38,6 +51,14 @@ const legacyReactDOMSchema = object({ render: pipe(function_(), args(tuple([reac
// `root.render(children)`, root from `createRoot()` of "react-dom/client".
const reactRootSchema = object({ render: pipe(function_(), args(tuple([reactNode()]))) });
+// React DOM 16.13 added `version`, earlier versions do not have it.
+const react16DOMSchema = object({ createPortal: function_(), version: optional(pipe(string(), startsWith('16.'))) });
+
+// A shadow root, by `nodeType` and `host` to work across realms.
+const shadowRootSchema = custom(value =>
+ is(object({ host: object({ nodeType: literal(ELEMENT_NODE) }), nodeType: literal(DOCUMENT_FRAGMENT_NODE) }), value)
+);
+
export default function renderWebChat(
ReactWebChat: ComponentType,
props: any,
@@ -53,6 +74,17 @@ export default function renderWebChat(
);
}
+ const rootNode = elementOrRoot.getRootNode?.();
+
+ // React 16 listens to events on the document, unless the container is a document or a shadow root.
+ // In a shadow root, the document gets events retargeted to the shadow host, React 16 would ignore every click and keystroke.
+ // React 17 listens on the container: remove with React 16.
+ if (is(react16DOMSchema, ReactDOM) && rootNode !== elementOrRoot && is(shadowRootSchema, rootNode)) {
+ throw new Error(
+ 'botframework-webchat: renderWebChat() on React 16 does not support an element inside a shadow root, React 16 would ignore clicks and typing. Pass the shadow root itself instead of the element, or use React 17 or later (with webchat.js, set window.React and window.ReactDOM before loading it).'
+ );
+ }
+
// 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(reactRootSchema, elementOrRoot)) {
diff --git a/packages/component/src/SendBox/SuggestedActions.tsx b/packages/component/src/SendBox/SuggestedActions.tsx
index 9b1e5032b5..43e660b86e 100644
--- a/packages/component/src/SendBox/SuggestedActions.tsx
+++ b/packages/component/src/SendBox/SuggestedActions.tsx
@@ -34,6 +34,7 @@ const ROOT_STYLE = {
const SuggestedActionCarouselContainer = ({ children, className, label }) => {
const [
{
+ stylesRoot,
suggestedActionsCarouselFlipperBoxWidth,
suggestedActionsCarouselFlipperCursor,
suggestedActionsCarouselFlipperSize
@@ -99,6 +100,7 @@ const SuggestedActionCarouselContainer = ({ children, className, label }) => {
showDots={false}
showScrollBar={false}
styleSet={filmStyleSet}
+ stylesRoot={stylesRoot}
>
{children}