Modern Event System: Support nested portal/root boundaries (#18201)

This commit is contained in:
Dominic Gannaway
2020-03-05 09:00:13 +00:00
committed by GitHub
parent fa03206ee4
commit 60b11f6c1b
2 changed files with 936 additions and 2 deletions
+65 -1
View File
@@ -19,6 +19,8 @@ import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
import {plugins} from 'legacy-events/EventPluginRegistry';
import {HostRoot, HostPortal} from 'shared/ReactWorkTags';
import {trapEventForPluginEventSystem} from './ReactDOMEventListener';
import getEventTarget from './getEventTarget';
import {getListenerMapForElement} from './DOMEventListenerMap';
@@ -56,7 +58,8 @@ import {
TOP_PROGRESS,
TOP_PLAYING,
} from './DOMTopLevelEventTypes';
import {DOCUMENT_NODE} from '../shared/HTMLNodeType';
import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree';
import {DOCUMENT_NODE, COMMENT_NODE} from '../shared/HTMLNodeType';
import {enableLegacyFBPrimerSupport} from 'shared/ReactFeatureFlags';
@@ -206,6 +209,17 @@ function willDeferLaterForFBLegacyPrimer(nativeEvent: any): boolean {
return false;
}
function isMatchingRootContainer(
grandContainer: Element,
rootContainer: Document | Element,
): boolean {
return (
grandContainer === rootContainer ||
(grandContainer.nodeType === COMMENT_NODE &&
grandContainer.parentNode === rootContainer)
);
}
export function dispatchEventForPluginEventSystem(
topLevelType: DOMTopLevelEventType,
eventSystemFlags: EventSystemFlags,
@@ -224,6 +238,56 @@ export function dispatchEventForPluginEventSystem(
) {
return;
}
// The below logic attempts to work out if we need to change
// the target fiber to a different ancestor. We had similar logic
// in the legacy event system, except the big difference between
// systems is that the modern event system now has an event listener
// attached to each React Root and React Portal Root. Together,
// the DOM nodes representing these roots are the "rootContainer".
// To figure out which ancestor instance we should use, we traverse
// up the fiber tree from the target instance and attempt to find
// root boundaries that match that of our current "rootContainer".
// If we find that "rootContainer", we find the parent fiber
// sub-tree for that root and make that our ancestor instance.
let node = targetInst;
while (true) {
if (node === null) {
return;
}
if (node.tag === HostRoot || node.tag === HostPortal) {
const container = node.stateNode.containerInfo;
if (isMatchingRootContainer(container, rootContainer)) {
break;
}
if (node.tag === HostPortal) {
// The target is a portal, but it's not the rootContainer we're looking for.
// Normally portals handle their own events all the way down to the root.
// So we should be able to stop now. However, we don't know if this portal
// was part of *our* root.
let grandNode = node.return;
while (grandNode !== null) {
if (grandNode.tag === HostRoot || grandNode.tag === HostPortal) {
const grandContainer = grandNode.stateNode.containerInfo;
if (isMatchingRootContainer(grandContainer, rootContainer)) {
// This is the rootContainer we're looking for and we found it as
// a parent of the Portal. That means we can ignore it because the
// Portal will bubble through to us.
return;
}
}
grandNode = grandNode.return;
}
}
const parentSubtreeInst = getClosestInstanceFromNode(container);
if (parentSubtreeInst === null) {
return;
}
node = ancestorInst = parentSubtreeInst;
continue;
}
node = node.return;
}
}
batchedEventUpdates(() =>
@@ -12,6 +12,8 @@
let React;
let ReactFeatureFlags;
let ReactDOM;
let ReactDOMServer;
let Scheduler;
function dispatchClickEvent(element) {
const event = document.createEvent('Event');
@@ -29,6 +31,8 @@ describe('DOMModernPluginEventSystem', () => {
React = require('react');
ReactDOM = require('react-dom');
Scheduler = require('scheduler');
ReactDOMServer = require('react-dom/server');
container = document.createElement('div');
document.body.appendChild(container);
});
@@ -77,6 +81,500 @@ describe('DOMModernPluginEventSystem', () => {
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events between roots', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const childRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
function Child() {
return (
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}>
<div ref={childRef} />
</button>
);
}
ReactDOM.render(<Parent />, container);
ReactDOM.render(<Child />, childRef.current);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', divElement]);
expect(log[3]).toEqual(['bubble', divElement]);
expect(log[4]).toEqual(['capture', buttonElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events between disjointed roots', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
function Child() {
return (
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}
/>
);
}
const disjointedNode = document.createElement('div');
ReactDOM.render(<Parent />, container);
buttonRef.current.appendChild(disjointedNode);
ReactDOM.render(<Child />, disjointedNode);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', divElement]);
expect(log[3]).toEqual(['bubble', divElement]);
expect(log[4]).toEqual(['capture', buttonElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events between disjointed comment roots', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
function Child() {
return (
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}
/>
);
}
// We use a comment node here, then mount to it
const disjointedNode = document.createComment(
' react-mount-point-unstable ',
);
ReactDOM.render(<Parent />, container);
buttonRef.current.appendChild(disjointedNode);
ReactDOM.render(<Child />, disjointedNode);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', divElement]);
expect(log[3]).toEqual(['bubble', divElement]);
expect(log[4]).toEqual(['capture', buttonElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events between disjointed comment roots #2', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const spanRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
function Child() {
return (
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}>
<span ref={spanRef} />
</button>
);
}
// We use a comment node here, then mount to it
const disjointedNode = document.createComment(
' react-mount-point-unstable ',
);
ReactDOM.render(<Parent />, container);
spanRef.current.appendChild(disjointedNode);
ReactDOM.render(<Child />, disjointedNode);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', divElement]);
expect(log[3]).toEqual(['bubble', divElement]);
expect(log[4]).toEqual(['capture', buttonElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events between portals', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
const portalElement = document.createElement('div');
document.body.appendChild(portalElement);
function Child() {
return (
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}>
{ReactDOM.createPortal(<Child />, portalElement)}
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['capture', divElement]);
expect(log[4]).toEqual(['bubble', divElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
document.body.removeChild(portalElement);
});
it('handle click events on document.body portals', () => {
const log = [];
function Child({label}) {
return <div onClick={() => log.push(label)}>{label}</div>;
}
function Parent() {
return (
<>
{ReactDOM.createPortal(<Child label={'first'} />, document.body)}
{ReactDOM.createPortal(<Child label={'second'} />, document.body)}
</>
);
}
ReactDOM.render(<Parent />, container);
const second = document.body.lastChild;
expect(second.textContent).toEqual('second');
dispatchClickEvent(second);
expect(log).toEqual(['second']);
const first = second.previousSibling;
expect(first.textContent).toEqual('first');
dispatchClickEvent(first);
expect(log).toEqual(['second', 'first']);
});
it.experimental(
'does not invoke an event on a parent tree when a subtree is dehydrated',
async () => {
let suspend = false;
let resolve;
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
let clicks = 0;
let childSlotRef = React.createRef();
function Parent() {
return <div onClick={() => clicks++} ref={childSlotRef} />;
}
function Child({text}) {
if (suspend) {
throw promise;
} else {
return <a>Click me</a>;
}
}
function App() {
// The root is a Suspense boundary.
return (
<React.Suspense fallback="Loading...">
<Child />
</React.Suspense>
);
}
suspend = false;
let finalHTML = ReactDOMServer.renderToString(<App />);
let parentContainer = document.createElement('div');
let childContainer = document.createElement('div');
// We need this to be in the document since we'll dispatch events on it.
document.body.appendChild(parentContainer);
// We're going to use a different root as a parent.
// This lets us detect whether an event goes through React's event system.
let parentRoot = ReactDOM.createRoot(parentContainer);
parentRoot.render(<Parent />);
Scheduler.unstable_flushAll();
childSlotRef.current.appendChild(childContainer);
childContainer.innerHTML = finalHTML;
let a = childContainer.getElementsByTagName('a')[0];
suspend = true;
// Hydrate asynchronously.
let root = ReactDOM.createRoot(childContainer, {hydrate: true});
root.render(<App />);
jest.runAllTimers();
Scheduler.unstable_flushAll();
// The Suspense boundary is not yet hydrated.
a.click();
expect(clicks).toBe(0);
// Resolving the promise so that rendering can complete.
suspend = false;
resolve();
await promise;
Scheduler.unstable_flushAll();
jest.runAllTimers();
// We're now full hydrated.
expect(clicks).toBe(1);
document.body.removeChild(parentContainer);
},
);
it('handle click events on dynamic portals', () => {
const log = [];
function Parent() {
const ref = React.useRef(null);
const [portal, setPortal] = React.useState(null);
React.useEffect(() => {
setPortal(
ReactDOM.createPortal(
<span onClick={() => log.push('child')} id="child" />,
ref.current,
),
);
});
return (
<div ref={ref} onClick={() => log.push('parent')} id="parent">
{portal}
</div>
);
}
ReactDOM.render(<Parent />, container);
const parent = container.lastChild;
expect(parent.id).toEqual('parent');
dispatchClickEvent(parent);
expect(log).toEqual(['parent']);
const child = parent.lastChild;
expect(child.id).toEqual('child');
dispatchClickEvent(child);
// we add both 'child' and 'parent' due to bubbling
expect(log).toEqual(['parent', 'child', 'parent']);
});
// Slight alteration to the last test, to catch
// a subtle difference in traversal.
it('handle click events on dynamic portals #2', () => {
const log = [];
function Parent() {
const ref = React.useRef(null);
const [portal, setPortal] = React.useState(null);
React.useEffect(() => {
setPortal(
ReactDOM.createPortal(
<span onClick={() => log.push('child')} id="child" />,
ref.current,
),
);
});
return (
<div ref={ref} onClick={() => log.push('parent')} id="parent">
<div>{portal}</div>
</div>
);
}
ReactDOM.render(<Parent />, container);
const parent = container.lastChild;
expect(parent.id).toEqual('parent');
dispatchClickEvent(parent);
expect(log).toEqual(['parent']);
const child = parent.lastChild;
expect(child.id).toEqual('child');
dispatchClickEvent(child);
// we add both 'child' and 'parent' due to bubbling
expect(log).toEqual(['parent', 'child', 'parent']);
});
it('native stopPropagation on click events between portals', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const middelDivRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
const portalElement = document.createElement('div');
document.body.appendChild(portalElement);
function Child() {
return (
<div ref={middelDivRef}>
<div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
Click me!
</div>
</div>
);
}
function Parent() {
React.useLayoutEffect(() => {
// This should prevent the portalElement listeners from
// capturing the events in the bubble phase.
middelDivRef.current.addEventListener('click', e => {
e.stopPropagation();
});
});
return (
<button
ref={buttonRef}
onClick={onClick}
onClickCapture={onClickCapture}>
{ReactDOM.createPortal(<Child />, portalElement)}
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClickCapture).toHaveBeenCalledTimes(1);
document.body.removeChild(portalElement);
});
it('handle propagation of focus events', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
@@ -120,6 +618,377 @@ describe('DOMModernPluginEventSystem', () => {
expect(log[5]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of focus events between roots', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const childRef = React.createRef();
const log = [];
const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
function Child() {
return (
<div
ref={divRef}
onFocus={onFocus}
onFocusCapture={onFocusCapture}
tabIndex={0}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onFocus={onFocus}
onFocusCapture={onFocusCapture}>
<div ref={childRef} />
</button>
);
}
ReactDOM.render(<Parent />, container);
ReactDOM.render(<Child />, childRef.current);
let buttonElement = buttonRef.current;
buttonElement.focus();
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocusCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
divElement.focus();
expect(onFocus).toHaveBeenCalledTimes(3);
expect(onFocusCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['bubble', buttonElement]);
expect(log[4]).toEqual(['capture', divElement]);
expect(log[5]).toEqual(['bubble', divElement]);
});
it('handle propagation of focus events between portals', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
const portalElement = document.createElement('div');
document.body.appendChild(portalElement);
function Child() {
return (
<div
ref={divRef}
onFocus={onFocus}
onFocusCapture={onFocusCapture}
tabIndex={0}>
Click me!
</div>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onFocus={onFocus}
onFocusCapture={onFocusCapture}>
{ReactDOM.createPortal(<Child />, portalElement)}
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
buttonElement.focus();
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocusCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
divElement.focus();
expect(onFocus).toHaveBeenCalledTimes(3);
expect(onFocusCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['capture', divElement]);
expect(log[4]).toEqual(['bubble', divElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
document.body.removeChild(portalElement);
});
it('native stopPropagation on focus events between portals', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const middelDivRef = React.createRef();
const log = [];
const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
const portalElement = document.createElement('div');
document.body.appendChild(portalElement);
function Child() {
return (
<div ref={middelDivRef}>
<div
ref={divRef}
onClick={onFocus}
onClickCapture={onFocusCapture}
tabIndex={0}>
Click me!
</div>
</div>
);
}
function Parent() {
React.useLayoutEffect(() => {
// This should prevent the portalElement listeners from
// capturing the events in the bubble phase.
middelDivRef.current.addEventListener('click', e => {
e.stopPropagation();
});
});
return (
<button
ref={buttonRef}
onFocus={onFocus}
onFocusCapture={onFocusCapture}>
{ReactDOM.createPortal(<Child />, portalElement)}
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
buttonElement.focus();
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocusCapture).toHaveBeenCalledTimes(1);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
let divElement = divRef.current;
divElement.focus();
expect(onFocus).toHaveBeenCalledTimes(1);
expect(onFocusCapture).toHaveBeenCalledTimes(1);
document.body.removeChild(portalElement);
});
it('handle propagation of enter and leave events between portals', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onMouseEnter = jest.fn(e => log.push(e.currentTarget));
const onMouseLeave = jest.fn(e => log.push(e.currentTarget));
const portalElement = document.createElement('div');
document.body.appendChild(portalElement);
function Child() {
return (
<div
ref={divRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
}
function Parent() {
return (
<button
ref={buttonRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{ReactDOM.createPortal(<Child />, portalElement)}
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
buttonElement.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: null,
}),
);
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onMouseLeave).toHaveBeenCalledTimes(0);
expect(log[0]).toEqual(buttonElement);
let divElement = divRef.current;
buttonElement.dispatchEvent(
new MouseEvent('mouseout', {
bubbles: true,
cancelable: true,
relatedTarget: divElement,
}),
);
divElement.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: buttonElement,
}),
);
expect(onMouseEnter).toHaveBeenCalledTimes(2);
expect(onMouseLeave).toHaveBeenCalledTimes(0);
expect(log[1]).toEqual(divElement);
document.body.removeChild(portalElement);
});
it('handle propagation of enter and leave events between portals #2', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const portalRef = React.createRef();
const log = [];
const onMouseEnter = jest.fn(e => log.push(e.currentTarget));
const onMouseLeave = jest.fn(e => log.push(e.currentTarget));
function Child() {
return (
<div
ref={divRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
}
function Parent() {
const [portal, setPortal] = React.useState(null);
React.useLayoutEffect(() => {
setPortal(ReactDOM.createPortal(<Child />, portalRef.current));
}, []);
return (
<button
ref={buttonRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
<div ref={portalRef}>{portal}</div>
</button>
);
}
ReactDOM.render(<Parent />, container);
let buttonElement = buttonRef.current;
buttonElement.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: null,
}),
);
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onMouseLeave).toHaveBeenCalledTimes(0);
expect(log[0]).toEqual(buttonElement);
let divElement = divRef.current;
buttonElement.dispatchEvent(
new MouseEvent('mouseout', {
bubbles: true,
cancelable: true,
relatedTarget: divElement,
}),
);
divElement.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
relatedTarget: buttonElement,
}),
);
expect(onMouseEnter).toHaveBeenCalledTimes(2);
expect(onMouseLeave).toHaveBeenCalledTimes(0);
expect(log[1]).toEqual(divElement);
});
it('should preserve bubble/capture order between roots and nested portals', () => {
const targetRef = React.createRef();
let log = [];
const onClickRoot = jest.fn(e => log.push('bubble root'));
const onClickCaptureRoot = jest.fn(e => log.push('capture root'));
const onClickPortal = jest.fn(e => log.push('bubble portal'));
const onClickCapturePortal = jest.fn(e => log.push('capture portal'));
function Portal() {
return (
<div
onClick={onClickPortal}
onClickCapture={onClickCapturePortal}
ref={targetRef}>
Click me!
</div>
);
}
const portalContainer = document.createElement('div');
let shouldStopPropagation = false;
portalContainer.addEventListener(
'click',
e => {
if (shouldStopPropagation) {
e.stopPropagation();
}
},
false,
);
function Root() {
let portalTargetRef = React.useRef(null);
React.useLayoutEffect(() => {
portalTargetRef.current.appendChild(portalContainer);
});
return (
<div onClick={onClickRoot} onClickCapture={onClickCaptureRoot}>
<div ref={portalTargetRef} />
{ReactDOM.createPortal(<Portal />, portalContainer)}
</div>
);
}
ReactDOM.render(<Root />, container);
let divElement = targetRef.current;
dispatchClickEvent(divElement);
expect(log).toEqual([
'capture root',
'capture portal',
'bubble portal',
'bubble root',
]);
log = [];
shouldStopPropagation = true;
dispatchClickEvent(divElement);
expect(log).toEqual([
// The events on root probably shouldn't fire if a non-React intermediated. but current behavior is that they do.
'capture root',
'capture portal',
'bubble portal',
'bubble root',
]);
});
it('handle propagation of click events correctly with FB primer', () => {
ReactFeatureFlags.enableLegacyFBPrimerSupport = true;
const aRef = React.createRef();
@@ -164,8 +1033,9 @@ describe('DOMModernPluginEventSystem', () => {
);
}
ReactDOM.render(<Test2 />, container);
aElement = aRef.current;
dispatchClickEvent(aElement);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(2);
expect(log).toEqual([]);
expect(onDivClick).toHaveBeenCalledTimes(0);
});