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/5982-fix-react-16-shadow-dom.md
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 5 additions & 2 deletions __tests__/html2/customElement/shadowRoot.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone@7.8.7/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<!-- React 18: React 16 does not support an element inside a shadow root, see shadowRoot.react16.throw.html. -->
<script crossorigin="anonymous" src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
Expand Down Expand Up @@ -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')) {
Expand Down
97 changes: 97 additions & 0 deletions __tests__/html2/customElement/shadowRoot.interact.react18.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<webchat-element>
<template shadowrootmode="open">
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<link href="/assets/focusManagement.css" rel="stylesheet" type="text/css" />
<style>
/* Same as "body.jest input" in index.css, which does not reach into the shadow root: no blinking caret in snapshots. */
input {
caret-color: transparent;
}
</style>
<main id="webchat"></main>
</template>
</webchat-element>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for"
}
}
</script>
<script type="module">
import { waitFor } from '@testduet/wait-for';

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();
const { shadowRoot } = document.querySelector('webchat-element');

// GIVEN: Web Chat with React DOM 18.3.1 renders into an element inside a shadow root.
WebChat.renderWebChat(
{ directLine, store, styleOptions: { disableFileUpload: true, stylesRoot: shadowRoot } },
shadowRoot.getElementById('webchat')
);

pageElements.root(shadowRoot);

await pageConditions.uiConnected();

// WHEN: The user types a message and presses ENTER.
const { activity: typedActivity, resolveAll: resolveTypedActivity } = await directLine.actPostActivity(
async () => {
await host.click(pageElements.sendBoxTextBox());
await host.sendKeys('Hello, World!', 'ENTER');

await pageConditions.numActivitiesShown(1);
}
);

await resolveTypedActivity();

expect(typedActivity).toHaveProperty('text', 'Hello, World!');
expect(shadowRoot.activeElement).toBe(pageElements.sendBoxTextBox());

// WHEN: The bot replies with a suggested action.
await directLine.emulateIncomingActivity({
suggestedActions: { actions: [{ title: 'USA', type: 'imBack', value: 'USA' }] },
text: 'Where are you from?'
});

await pageConditions.suggestedActionsShown();

// THEN: "Hello, World!" is sent, the bot asks "Where are you from?", the send box is empty and has the focus.
await host.snapshot('local');

// WHEN: The user clicks the suggested action.
const { activity: clickedActivity, resolveAll: resolveClickedActivity } = await directLine.actPostActivity(
async () => {
await pageObjects.clickNthSuggestedAction(1);
await pageConditions.numActivitiesShown(3);
}
);

await resolveClickedActivity();

expect(clickedActivity).toHaveProperty('text', 'USA');

await waitFor(() => expect(pageElements.suggestedActions()).toHaveLength(0));

// Clicking a suggested action moves the focus to the send box.
await waitFor(() => expect(shadowRoot.activeElement).toBe(pageElements.sendBoxTextBox()));

// THEN: "USA" is sent, the suggested action is gone and the send box has the focus.
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<!-- No React on the page: Web Chat uses the React DOM 16.8.6 it bundles. -->
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<template id="shadow-root-styles">
<link href="/assets/focusManagement.css" rel="stylesheet" type="text/css" />
<style>
/* Same as "body.jest input" in index.css, which does not reach into the shadow root: no blinking caret in snapshots. */
input {
caret-color: transparent;
}
</style>
</template>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for"
}
}
</script>
<script type="module">
import { waitFor } from '@testduet/wait-for';

run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();
const shadowRoot = document.getElementById('webchat').attachShadow({ mode: 'open' });

// GIVEN: Web Chat with React DOM 16.8.6 renders into the shadow root itself.
WebChat.renderWebChat(
{ directLine, store, styleOptions: { disableFileUpload: true, stylesRoot: shadowRoot } },
shadowRoot
);

// React empties the container when it first renders: add the styles after.
shadowRoot.append(document.getElementById('shadow-root-styles').content.cloneNode(true));

pageElements.root(shadowRoot);

await pageConditions.uiConnected();

// WHEN: The user types a message and presses ENTER.
const { activity: typedActivity, resolveAll: resolveTypedActivity } = await directLine.actPostActivity(
async () => {
await host.click(pageElements.sendBoxTextBox());
await host.sendKeys('Hello, World!', 'ENTER');

await pageConditions.numActivitiesShown(1);
}
);

await resolveTypedActivity();

expect(typedActivity).toHaveProperty('text', 'Hello, World!');
expect(shadowRoot.activeElement).toBe(pageElements.sendBoxTextBox());

// WHEN: The bot replies with a suggested action.
await directLine.emulateIncomingActivity({
suggestedActions: { actions: [{ title: 'USA', type: 'imBack', value: 'USA' }] },
text: 'Where are you from?'
});

await pageConditions.suggestedActionsShown();

// THEN: "Hello, World!" is sent, the bot asks "Where are you from?", the send box is empty and has the focus.
await host.snapshot('local');

// WHEN: The user clicks the suggested action.
const { activity: clickedActivity, resolveAll: resolveClickedActivity } = await directLine.actPostActivity(
async () => {
await pageObjects.clickNthSuggestedAction(1);
await pageConditions.numActivitiesShown(3);
}
);

await resolveClickedActivity();

expect(clickedActivity).toHaveProperty('text', 'USA');

await waitFor(() => expect(pageElements.suggestedActions()).toHaveLength(0));

// Clicking a suggested action moves the focus to the send box.
await waitFor(() => expect(shadowRoot.activeElement).toBe(pageElements.sendBoxTextBox()));

// THEN: "USA" is sent, the suggested action is gone and the send box has the focus.
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.
47 changes: 47 additions & 0 deletions __tests__/html2/customElement/shadowRoot.react16.throw.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 16: renderWebChat() throws for an element inside a shadow root</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<!-- No React on the page: Web Chat uses the React DOM 16.8.6 it bundles. -->
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat">
<template shadowrootmode="open">
<div id="element-in-shadow-root"></div>
</template>
</main>
<script>
run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();
const { shadowRoot } = document.getElementById('webchat');
const props = { directLine, store, styleOptions: { hideSendBox: true, stylesRoot: shadowRoot } };

// GIVEN: Web Chat with React DOM 16.8.6, which would ignore clicks and typing in an element inside a shadow root.
// WHEN: Web Chat is rendered by renderWebChat() into an element inside a shadow root.
// THEN: It throws, suggesting the shadow root itself or React 17 or later.
expect(() => WebChat.renderWebChat(props, shadowRoot.getElementById('element-in-shadow-root'))).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(shadowRoot.getElementById('element-in-shadow-root').childNodes).toHaveLength(0);

// WHEN: Web Chat is rendered by renderWebChat() into the shadow root itself, as the error says.
WebChat.renderWebChat(props, shadowRoot);

pageElements.root(shadowRoot);

await pageConditions.uiConnected();

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

// THEN: Web Chat shows the message, rendered into the shadow root.
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.
57 changes: 56 additions & 1 deletion packages/bundle/src/renderWebChat.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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);
});
36 changes: 34 additions & 2 deletions packages/bundle/src/renderWebChat.tsx
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -16,7 +29,7 @@ type RenderWebChat = {
* @param props Props of `<ReactWebChat>`.
* @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;
};
Expand All @@ -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<ShadowRoot>(value =>
is(object({ host: object({ nodeType: literal(ELEMENT_NODE) }), nodeType: literal(DOCUMENT_FRAGMENT_NODE) }), value)
);

export default function renderWebChat(
ReactWebChat: ComponentType<any>,
props: any,
Expand All @@ -53,6 +74,17 @@ export default function renderWebChat(
);
}

const rootNode = elementOrRoot.getRootNode?.();

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.

Does this trip the check if passed the element's shadow root where the element is already inside another shadow root?

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.

No: ShadowRoot.getRootNode() returns the shadow root itself however deep its host is nested, so rootNode === elementOrRoot skips the check, and React 16 listens on that inner shadow root, where the events bubble. I tested it locally rather than pushing onto the approved head: a unit test (nested shadow root renders, an element inside it throws) and an html2 test rendering into a shadow root nested in another one that types, sends and clicks a suggested action (3 of 3 runs, same snapshots as the non-nested test; an element inside that shadow root throws, and with the check bypassed it ignores typing as expected); I can send both as a follow-up after this merges.


// 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)) {
Expand Down
Loading
Loading