From 60b11f6c1be111c14c3607809ccad2b8ea680152 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 5 Mar 2020 09:00:13 +0000 Subject: [PATCH] Modern Event System: Support nested portal/root boundaries (#18201) --- .../src/events/DOMModernPluginEventSystem.js | 66 +- ...OMModernPluginEventSystem-test.internal.js | 872 +++++++++++++++++- 2 files changed, 936 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index 32c56b15c62fb..daea82b498147 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -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(() => diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index 009b74ef69bdc..31ab9e26d5d56 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -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 ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + ReactDOM.render(, 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 ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + // We use a comment node here, then mount to it + const disjointedNode = document.createComment( + ' react-mount-point-unstable ', + ); + ReactDOM.render(, container); + spanRef.current.appendChild(disjointedNode); + ReactDOM.render(, 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 ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, 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
log.push(label)}>{label}
; + } + + function Parent() { + return ( + <> + {ReactDOM.createPortal(, document.body)} + {ReactDOM.createPortal(, document.body)} + + ); + } + + ReactDOM.render(, 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
clicks++} ref={childSlotRef} />; + } + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return Click me; + } + } + + function App() { + // The root is a Suspense boundary. + return ( + + + + ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + 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(); + 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(); + 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( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> + {portal} +
+ ); + } + + ReactDOM.render(, 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( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> +
{portal}
+
+ ); + } + + ReactDOM.render(, 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 ( +
+
+ Click me! +
+
+ ); + } + + 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 ( + + ); + } + + ReactDOM.render(, 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 ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + ReactDOM.render(, 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 ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, 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 ( +
+
+ Click me! +
+
+ ); + } + + 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 ( + + ); + } + + ReactDOM.render(, 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 ( +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, 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 ( +
+ ); + } + + function Parent() { + const [portal, setPortal] = React.useState(null); + + React.useLayoutEffect(() => { + setPortal(ReactDOM.createPortal(, portalRef.current)); + }, []); + + return ( + + ); + } + + ReactDOM.render(, 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 ( +
+ Click me! +
+ ); + } + + 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 ( +
+
+ {ReactDOM.createPortal(, portalContainer)} +
+ ); + } + + ReactDOM.render(, 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(, container); + aElement = aRef.current; dispatchClickEvent(aElement); - expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(2); expect(log).toEqual([]); expect(onDivClick).toHaveBeenCalledTimes(0); });