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..f7fe3c2ef3
--- /dev/null
+++ b/.changelog/5982-fix-react-16-shadow-dom.md
@@ -0,0 +1,3 @@
+### Fixed
+
+- Fixed `renderWebChat()` ignoring clicks and typing inside a shadow root on React 16, 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.interact.html b/__tests__/html2/customElement/shadowRoot.interact.html
new file mode 100644
index 0000000000..e6ceae1168
--- /dev/null
+++ b/__tests__/html2/customElement/shadowRoot.interact.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/customElement/shadowRoot.interact.html.snap-1.png b/__tests__/html2/customElement/shadowRoot.interact.html.snap-1.png
new file mode 100644
index 0000000000..2d653de1e2
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.html.snap-1.png differ
diff --git a/__tests__/html2/customElement/shadowRoot.interact.html.snap-2.png b/__tests__/html2/customElement/shadowRoot.interact.html.snap-2.png
new file mode 100644
index 0000000000..ce1ef4f3f0
Binary files /dev/null and b/__tests__/html2/customElement/shadowRoot.interact.html.snap-2.png differ
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/packages/bundle/src/private/react16EventListenerProps.ts b/packages/bundle/src/private/react16EventListenerProps.ts
new file mode 100644
index 0000000000..244fac81b4
--- /dev/null
+++ b/packages/bundle/src/private/react16EventListenerProps.ts
@@ -0,0 +1,102 @@
+// Every event prop of React 16, from its event plugins, except the ones that block scrolling (see below).
+const REACT_16_EVENT_PROP_NAMES = Object.freeze([
+ 'onAbort',
+ 'onAnimationEnd',
+ 'onAnimationIteration',
+ 'onAnimationStart',
+ 'onAuxClick',
+ 'onBeforeInput',
+ 'onBlur',
+ 'onCanPlay',
+ 'onCanPlayThrough',
+ 'onCancel',
+ 'onChange',
+ 'onClick',
+ 'onClose',
+ 'onCompositionEnd',
+ 'onCompositionStart',
+ 'onCompositionUpdate',
+ 'onContextMenu',
+ 'onCopy',
+ 'onCut',
+ 'onDoubleClick',
+ 'onDrag',
+ 'onDragEnd',
+ 'onDragEnter',
+ 'onDragExit',
+ 'onDragLeave',
+ 'onDragOver',
+ 'onDragStart',
+ 'onDrop',
+ 'onDurationChange',
+ 'onEmptied',
+ 'onEncrypted',
+ 'onEnded',
+ 'onError',
+ 'onFocus',
+ 'onGotPointerCapture',
+ 'onInput',
+ 'onInvalid',
+ 'onKeyDown',
+ 'onKeyPress',
+ 'onKeyUp',
+ 'onLoad',
+ 'onLoadStart',
+ 'onLoadedData',
+ 'onLoadedMetadata',
+ 'onLostPointerCapture',
+ 'onMouseDown',
+ 'onMouseEnter',
+ 'onMouseLeave',
+ 'onMouseMove',
+ 'onMouseOut',
+ 'onMouseOver',
+ 'onMouseUp',
+ 'onPaste',
+ 'onPause',
+ 'onPlay',
+ 'onPlaying',
+ 'onPointerCancel',
+ 'onPointerDown',
+ 'onPointerEnter',
+ 'onPointerLeave',
+ 'onPointerMove',
+ 'onPointerOut',
+ 'onPointerOver',
+ 'onPointerUp',
+ 'onProgress',
+ 'onRateChange',
+ 'onReset',
+ 'onScroll',
+ 'onSeeked',
+ 'onSeeking',
+ 'onSelect',
+ 'onStalled',
+ 'onSubmit',
+ 'onSuspend',
+ 'onTimeUpdate',
+ 'onToggle',
+ 'onTouchCancel',
+ 'onTouchEnd',
+ // Without `onTouchMove`, `onTouchStart` and `onWheel`: React 16 listens to them as blocking (not passive), which can delay
+ // scrolling. Web Chat does not use them.
+ 'onTransitionEnd',
+ 'onVolumeChange',
+ 'onWaiting'
+] as const);
+
+function noop() {
+ // Only here for React 16 to listen to the event.
+}
+
+/**
+ * A no-op handler for every event of React 16 that Web Chat may use.
+ *
+ * React 16 listens to an event on the root of the container when it first renders a handler for it.
+ * Rendering these props makes React 16 listen to these events.
+ */
+const react16EventListenerProps: { readonly [name: string]: () => void } = Object.freeze(
+ Object.fromEntries(REACT_16_EVENT_PROP_NAMES.map(name => [name, noop]))
+);
+
+export default react16EventListenerProps;
diff --git a/packages/bundle/src/renderWebChat.spec.tsx b/packages/bundle/src/renderWebChat.spec.tsx
index b569dfcada..34b921fd44 100644
--- a/packages/bundle/src/renderWebChat.spec.tsx
+++ b/packages/bundle/src/renderWebChat.spec.tsx
@@ -3,6 +3,7 @@
import ReactDOM from 'react-dom';
+import react16EventListenerProps from './private/react16EventListenerProps';
import renderWebChat from './renderWebChat';
// `render` is set before each test, the React 19 test removes it.
@@ -10,11 +11,15 @@ jest.mock('react-dom', () => ({ __esModule: true, default: {} }));
const ReactWebChat = () => null;
+let createPortal: jest.Mock;
let render: jest.Mock;
beforeEach(() => {
+ createPortal = jest.fn((children, container) => ({ children, container }));
render = jest.fn();
- Object.assign(ReactDOM, { render });
+
+ // React DOM 16.8.6 has no `version`.
+ Object.assign(ReactDOM, { createPortal, render, version: undefined });
});
test('Element should be rendered by ReactDOM.render()', () => {
@@ -69,3 +74,80 @@ 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 render a portal into the shadow root with every event handler', () => {
+ renderWebChat(ReactWebChat, {}, element);
+
+ expect(createPortal).toHaveBeenCalledTimes(1);
+ expect(createPortal.mock.calls[0][0].props).toEqual({ hidden: true, ...react16EventListenerProps });
+ expect(createPortal.mock.calls[0][1]).toBe(shadowRoot);
+ expect(render.mock.calls[0][0].props.children).toEqual([
+ expect.objectContaining({ type: ReactWebChat }),
+ createPortal.mock.results[0].value
+ ]);
+ expect(render.mock.calls[0][1]).toBe(element);
+ });
+
+ test('on React 16.14 should render a portal into the shadow root', () => {
+ Object.assign(ReactDOM, { version: '16.14.0' });
+
+ renderWebChat(ReactWebChat, {}, element);
+
+ expect(createPortal).toHaveBeenCalledTimes(1);
+ });
+
+ test('on React 17 or later should render without a portal', () => {
+ Object.assign(ReactDOM, { version: '17.0.2' });
+
+ renderWebChat(ReactWebChat, {}, element);
+
+ expect(createPortal).not.toHaveBeenCalled();
+ expect(render.mock.calls[0][0]).toEqual(expect.objectContaining({ type: ReactWebChat }));
+ });
+
+ test('rendered into the shadow root itself should render without a portal', () => {
+ renderWebChat(ReactWebChat, {}, shadowRoot);
+
+ expect(createPortal).not.toHaveBeenCalled();
+ expect(render.mock.calls[0][0]).toEqual(expect.objectContaining({ type: ReactWebChat }));
+ expect(render.mock.calls[0][1]).toBe(shadowRoot);
+ });
+});
+
+test('Element outside of a shadow root on React 16 should render without a portal', () => {
+ const element = document.createElement('div');
+
+ document.body.append(element);
+ renderWebChat(ReactWebChat, {}, element);
+ element.remove();
+
+ expect(createPortal).not.toHaveBeenCalled();
+ expect(render.mock.calls[0][0]).toEqual(expect.objectContaining({ type: ReactWebChat }));
+});
+
+test('React 16 event handlers should cover every event of React DOM 16.8.6 except the ones that block scrolling', () => {
+ const actualReactDOM = jest.requireActual('react-dom');
+
+ // Private API of React DOM 16.8.6: `Events[4]` is the dispatch config of every event, by event name.
+ const [, , , , eventNameDispatchConfigs] = actualReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Events;
+
+ // React DOM 16.8.6 has no `version`.
+ expect(actualReactDOM).not.toHaveProperty('version');
+ expect(Object.keys(react16EventListenerProps).sort()).toEqual(
+ Object.values(eventNameDispatchConfigs)
+ .map(({ phasedRegistrationNames, registrationName }) => registrationName || phasedRegistrationNames.bubbled)
+ .filter(name => !['onTouchMove', 'onTouchStart', 'onWheel'].includes(name))
+ .sort()
+ );
+});
diff --git a/packages/bundle/src/renderWebChat.tsx b/packages/bundle/src/renderWebChat.tsx
index 9f12077a9d..5411078471 100644
--- a/packages/bundle/src/renderWebChat.tsx
+++ b/packages/bundle/src/renderWebChat.tsx
@@ -1,7 +1,22 @@
import { reactNode } from '@msinternal/botframework-webchat-react-valibot';
-import React, { type ComponentType, type ReactNode } from 'react';
+import React, { Fragment, 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';
+
+import react16EventListenerProps from './private/react16EventListenerProps';
/** A root from `createRoot()` of "react-dom/client". */
type RenderWebChatRoot = { render(children: ReactNode): void };
@@ -38,6 +53,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,8 +76,23 @@ export default function renderWebChat(
);
}
+ const rootNode = elementOrRoot.getRootNode?.();
+
// 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);
+ ReactDOM.render(
+ // 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 ignores every click and keystroke.
+ // A portal into the shadow root makes React 16 listen there too. React 17 listens on the container: remove with React 16.
+ is(react16DOMSchema, ReactDOM) && rootNode !== elementOrRoot && is(shadowRootSchema, rootNode) ? (
+
+ {children}
+ {ReactDOM.createPortal(, rootNode)}
+
+ ) : (
+ children
+ ),
+ 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);
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}