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( + ) : ( + 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}