From a600408b284e92ed3d801d7f4e37d10b5da06b10 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 20 Mar 2020 18:31:47 +0000 Subject: [PATCH] ReactDOM.useEvent: add `EventTarget` support (#18355) * ReactDOM.useEvent: add support for all EventTarget types --- packages/legacy-events/EventPluginUtils.js | 3 +- packages/legacy-events/EventSystemFlags.js | 3 +- .../legacy-events/ReactSyntheticEventType.js | 2 +- .../src/client/ReactDOMHostConfig.js | 42 +-- .../src/events/DOMEventListenerMap.js | 10 +- .../src/events/DOMModernPluginEventSystem.js | 303 +++++++++++------- .../react-dom/src/events/SimpleEventPlugin.js | 3 +- ...OMModernPluginEventSystem-test.internal.js | 291 ++++++++++++++++- .../src/events/accumulateTwoPhaseListeners.js | 143 ++++++--- 9 files changed, 598 insertions(+), 202 deletions(-) diff --git a/packages/legacy-events/EventPluginUtils.js b/packages/legacy-events/EventPluginUtils.js index e9081e3c99..cd99330693 100644 --- a/packages/legacy-events/EventPluginUtils.js +++ b/packages/legacy-events/EventPluginUtils.js @@ -64,7 +64,8 @@ if (__DEV__) { */ export function executeDispatch(event, listener, inst) { const type = event.type || 'unknown-event'; - event.currentTarget = getNodeFromInstance(inst); + event.currentTarget = + inst.tag !== undefined ? getNodeFromInstance(inst) : inst; invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event); event.currentTarget = null; } diff --git a/packages/legacy-events/EventSystemFlags.js b/packages/legacy-events/EventSystemFlags.js index 98c2fd9394..e31af6bae3 100644 --- a/packages/legacy-events/EventSystemFlags.js +++ b/packages/legacy-events/EventSystemFlags.js @@ -16,4 +16,5 @@ export const IS_ACTIVE = 1 << 3; export const PASSIVE_NOT_SUPPORTED = 1 << 4; export const IS_REPLAYED = 1 << 5; export const IS_FIRST_ANCESTOR = 1 << 6; -export const LEGACY_FB_SUPPORT = 1 << 7; +export const IS_TARGET_EVENT_ONLY = 1 << 7; +export const LEGACY_FB_SUPPORT = 1 << 8; diff --git a/packages/legacy-events/ReactSyntheticEventType.js b/packages/legacy-events/ReactSyntheticEventType.js index 5c98337e03..e0240f3b66 100644 --- a/packages/legacy-events/ReactSyntheticEventType.js +++ b/packages/legacy-events/ReactSyntheticEventType.js @@ -39,7 +39,7 @@ export type ReactSyntheticEvent = {| nativeEventTarget: EventTarget, ) => ReactSyntheticEvent, isPersistent: () => boolean, - _dispatchInstances: null | Array | Fiber, + _dispatchInstances: null | Array | Fiber | EventTarget, _dispatchListeners: null | Array | Function, _targetInst: Fiber, type: string, diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index d7b647c055..a6e3eeefa5 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -75,11 +75,13 @@ import { IS_PASSIVE, } from 'legacy-events/EventSystemFlags'; import { - attachElementListener, - detachElementListener, - isDOMDocument, - isDOMElement, + isManagedDOMElement, + isValidEventTarget, listenToTopLevelEvent, + detachListenerFromManagedDOMElement, + attachListenerFromManagedDOMElement, + detachTargetEventListener, + attachTargetEventListener, } from '../events/DOMModernPluginEventSystem'; import {getListenerMapForElement} from '../events/DOMEventListenerMap'; @@ -1122,12 +1124,10 @@ export function registerEvent( export function mountEventListener(listener: ReactDOMListener): void { if (enableUseEventAPI) { const {target} = listener; - if (target === window) { - // TODO (useEvent) - } else if (isDOMDocument(target)) { - // TODO (useEvent) - } else if (isDOMElement(target)) { - attachElementListener(listener); + if (isManagedDOMElement(target)) { + attachListenerFromManagedDOMElement(listener); + } else { + attachTargetEventListener(listener); } } } @@ -1135,12 +1135,10 @@ export function mountEventListener(listener: ReactDOMListener): void { export function unmountEventListener(listener: ReactDOMListener): void { if (enableUseEventAPI) { const {target} = listener; - if (target === window) { - // TODO (useEvent) - } else if (isDOMDocument(target)) { - // TODO (useEvent) - } else if (isDOMElement(target)) { - detachElementListener(listener); + if (isManagedDOMElement(target)) { + detachListenerFromManagedDOMElement(listener); + } else { + detachTargetEventListener(listener); } } } @@ -1152,10 +1150,7 @@ export function validateEventListenerTarget( if (enableUseEventAPI) { if ( target != null && - (target === window || - isDOMDocument(target) || - (isDOMElement(target) && - getClosestInstanceFromNode(((target: any): Element)))) + (isManagedDOMElement(target) || isValidEventTarget(target)) ) { if (listener == null || typeof listener === 'function') { return true; @@ -1169,11 +1164,8 @@ export function validateEventListenerTarget( } if (__DEV__) { console.warn( - 'Event listener method setListener() from useEvent() hook requires the first argument to be either:' + - '\n\n' + - '1. A valid DOM node that was rendered and managed by React\n' + - '2. The "window" object\n' + - '3. The "document" object', + 'Event listener method setListener() from useEvent() hook requires the first argument to be ' + + 'a valid DOM EventTarget. If using a ref, ensure the current value is not null.', ); } } diff --git a/packages/react-dom/src/events/DOMEventListenerMap.js b/packages/react-dom/src/events/DOMEventListenerMap.js index ec4ad61c3e..a777fb1bb1 100644 --- a/packages/react-dom/src/events/DOMEventListenerMap.js +++ b/packages/react-dom/src/events/DOMEventListenerMap.js @@ -12,11 +12,11 @@ import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; -// prettier-ignore -const elementListenerMap: - // $FlowFixMe Work around Flow bug - | WeakMap - | Map = new PossiblyWeakMap(); +// $FlowFixMe: Flow cannot handle polymorphic WeakMaps +const elementListenerMap: WeakMap< + EventTarget, + ElementListenerMap, +> = new PossiblyWeakMap(); export type ElementListenerMap = Map< DOMTopLevelEventType | string, diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index 1b09bb3cc2..9188c120f9 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -27,7 +27,11 @@ import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching'; import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils'; import {plugins} from 'legacy-events/EventPluginRegistry'; -import {LEGACY_FB_SUPPORT, IS_REPLAYED} from 'legacy-events/EventSystemFlags'; +import { + LEGACY_FB_SUPPORT, + IS_REPLAYED, + IS_TARGET_EVENT_ONLY, +} from 'legacy-events/EventSystemFlags'; import {HostRoot, HostPortal} from 'shared/ReactWorkTags'; @@ -77,11 +81,7 @@ import { getListenersFromTarget, initListenersSet, } from '../client/ReactDOMComponentTree'; -import { - DOCUMENT_NODE, - COMMENT_NODE, - ELEMENT_NODE, -} from '../shared/HTMLNodeType'; +import {COMMENT_NODE} from '../shared/HTMLNodeType'; import {topLevelEventsToDispatchConfig} from './DOMEventProperties'; import {enableLegacyFBSupport} from 'shared/ReactFeatureFlags'; @@ -132,6 +132,17 @@ const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = { const isArray = Array.isArray; +// $FlowFixMe: Flow struggles with this pattern +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +// $FlowFixMe: Flow cannot handle polymorphic WeakMaps +export const eventTargetEventListenerStore: WeakMap< + EventTarget, + Map< + DOMTopLevelEventType, + {bubbled: Set, captured: Set}, + >, +> = new PossiblyWeakMap(); + function dispatchEventsForPlugins( topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, @@ -198,11 +209,27 @@ export function listenToTopLevelEvent( listenerMap: ElementListenerMap, passive?: boolean, priority?: EventPriority, + capture?: boolean, ): void { - const listenerEntry = listenerMap.get(topLevelType); + // If we explicitly define capture, then these are for EventTarget objects, + // rather than React managed DOM elements. So we need to ensure we separate + // capture and non-capture events. For React managed DOM nodes we only use + // one or the other, never both. Which one we use is determined by the the + // capturePhaseEvents Set (in this module) that defines if the event listener + // should use the capture phase – otherwise we always use the bubble phase. + // Finally, when we get to dispatching and accumulating event listeners, we + // check if the user wanted capture/bubble and emulate the behavior at that + // point (we call this accumulating two phase listeners). + const typeStr = ((topLevelType: any): string); + const listenerMapKey = + capture === undefined + ? topLevelType + : `${typeStr}_${capture ? 'capture' : 'bubble'}`; + const listenerEntry = listenerMap.get(listenerMapKey); const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive); if (listenerEntry === undefined || shouldUpgrade) { - const isCapturePhase = capturePhaseEvents.has(topLevelType); + const isCapturePhase = + capture === undefined ? capturePhaseEvents.has(topLevelType) : capture; // If we should upgrade, then we need to remove the existing trapped // event listener for the target container. if (shouldUpgrade) { @@ -221,7 +248,7 @@ export function listenToTopLevelEvent( passive, priority, ); - listenerMap.set(topLevelType, {passive, listener}); + listenerMap.set(listenerMapKey, {passive, listener}); } } @@ -269,14 +296,12 @@ function isMatchingRootContainer( ); } -export function isDOMElement(target: EventTarget): boolean { - const nodeType = ((target: any): Node).nodeType; - return (nodeType: any) && nodeType === ELEMENT_NODE; +export function isManagedDOMElement(target: EventTarget): boolean { + return getClosestInstanceFromNode(((target: any): Node)) !== null; } -export function isDOMDocument(target: EventTarget): boolean { - const nodeType = ((target: any): Node).nodeType; - return nodeType === DOCUMENT_NODE; +export function isValidEventTarget(target: EventTarget): boolean { + return typeof target.addEventListener === 'function'; } export function dispatchEventForPluginEventSystem( @@ -288,87 +313,85 @@ export function dispatchEventForPluginEventSystem( ): void { let ancestorInst = targetInst; if (targetContainer !== null) { - const possibleTargetContainerNode = ((targetContainer: any): Node); - // Given the rootContainer can be any EventTarget, if the - // target is not a valid DOM element then we'll skip this part. - if ( - possibleTargetContainerNode === window || - !isDOMElement(possibleTargetContainerNode) - ) { - // TODO: useEvent for document and window - return; - } - // If we are using the legacy FB support flag, we - // defer the event to the null with a one - // time event listener so we can defer the event. - if ( - enableLegacyFBSupport && - // We do not want to defer if the event system has already been - // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when - // we call willDeferLaterForLegacyFBSupport, thus not bailing out - // will result in endless cycles like an infinite loop. - (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 && - // We also don't want to defer during event replaying. - (eventSystemFlags & IS_REPLAYED) === 0 && - willDeferLaterForLegacyFBSupport(topLevelType, targetContainer) - ) { - return; - } - if (targetInst !== null) { - // 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; + if (eventTargetEventListenerStore.has(targetContainer)) { + // For TargetEvent nodes (i.e. document, window) + ancestorInst = null; + eventSystemFlags |= IS_TARGET_EVENT_ONLY; + } else { + const targetContainerNode = ((targetContainer: any): Node); - while (true) { - if (node === null) { - return; - } - if (node.tag === HostRoot || node.tag === HostPortal) { - const container = node.stateNode.containerInfo; - if (isMatchingRootContainer(container, possibleTargetContainerNode)) { - 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, - possibleTargetContainerNode, - ) - ) { - // 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) { + // If we are using the legacy FB support flag, we + // defer the event to the null with a one + // time event listener so we can defer the event. + if ( + enableLegacyFBSupport && + // We do not want to defer if the event system has already been + // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when + // we call willDeferLaterForLegacyFBSupport, thus not bailing out + // will result in endless cycles like an infinite loop. + (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 && + // We also don't want to defer during event replaying. + (eventSystemFlags & IS_REPLAYED) === 0 && + willDeferLaterForLegacyFBSupport(topLevelType, targetContainer) + ) { + return; + } + if (targetInst !== null) { + // 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; } - node = ancestorInst = parentSubtreeInst; - continue; + if (node.tag === HostRoot || node.tag === HostPortal) { + const container = node.stateNode.containerInfo; + if (isMatchingRootContainer(container, targetContainerNode)) { + 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, targetContainerNode) + ) { + // 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; } - node = node.return; } } } @@ -398,21 +421,32 @@ function getNearestRootOrPortalContainer(instance: Element): Element { return instance; } -export function attachElementListener(listener: ReactDOMListener): void { +function addEventTypeToDispatchConfig(type: DOMTopLevelEventType): void { + let dispatchConfig = topLevelEventsToDispatchConfig.get(type); + // If we don't have a dispatchConfig, then we're dealing with + // an event type that React does not know about (i.e. a custom event). + // We need to register an event config for this or the SimpleEventPlugin + // will not appropriately provide a SyntheticEvent, so we use out empty + // dispatch config for custom events. + if (dispatchConfig === undefined) { + topLevelEventsToDispatchConfig.set( + type, + emptyDispatchConfigForCustomEvents, + ); + } +} + +export function attachListenerFromManagedDOMElement( + listener: ReactDOMListener, +): void { const {event, target} = listener; const {passive, priority, type} = event; + const possibleManagedTarget = ((target: any): Element); let containerEventTarget = target; - // If we the target is a managed React element, then we need to - // find the nearest root/portal contained to attach the event listener - // to. If it's not managed, i.e. the window, then we just attach - // the listener to the target. - if (isDOMElement(target)) { - const possibleManagedTarget = ((target: any): Element); - if (getClosestInstanceFromNode(possibleManagedTarget)) { - containerEventTarget = getNearestRootOrPortalContainer( - possibleManagedTarget, - ); - } + if (getClosestInstanceFromNode(possibleManagedTarget)) { + containerEventTarget = getNearestRootOrPortalContainer( + possibleManagedTarget, + ); } const listenerMap = getListenerMapForElement(containerEventTarget); // Add the event listener to the target container (falling back to @@ -434,21 +468,12 @@ export function attachElementListener(listener: ReactDOMListener): void { // Add our listener to the listeners Set. listeners.add(listener); // Finally, add the event to our known event types list. - let dispatchConfig = topLevelEventsToDispatchConfig.get(type); - // If we don't have a dispatchConfig, then we're dealing with - // an event type that React does not know about (i.e. a custom event). - // We need to register an event config for this or the SimpleEventPlugin - // will not appropriately provide a SyntheticEvent, so we use out empty - // dispatch config for custom events. - if (dispatchConfig === undefined) { - topLevelEventsToDispatchConfig.set( - type, - emptyDispatchConfigForCustomEvents, - ); - } + addEventTypeToDispatchConfig(type); } -export function detachElementListener(listener: ReactDOMListener): void { +export function detachListenerFromManagedDOMElement( + listener: ReactDOMListener, +): void { const {target} = listener; // Get the internal listeners Set from the target instance. const listeners = getListenersFromTarget(target); @@ -457,3 +482,47 @@ export function detachElementListener(listener: ReactDOMListener): void { listeners.delete(listener); } } + +export function attachTargetEventListener(listener: ReactDOMListener): void { + const {event, target} = listener; + const {capture, passive, priority, type} = event; + const listenerMap = getListenerMapForElement(target); + // Add the event listener to the TargetEvent object. + listenToTopLevelEvent(type, target, listenerMap, passive, priority, capture); + let eventTypeMap = eventTargetEventListenerStore.get(target); + if (eventTypeMap === undefined) { + eventTypeMap = new Map(); + eventTargetEventListenerStore.set(target, eventTypeMap); + } + // Get the listeners by the event type + let listeners = eventTypeMap.get(type); + if (listeners === undefined) { + listeners = {captured: new Set(), bubbled: new Set()}; + eventTypeMap.set(type, listeners); + } + // Add our listener to the listeners Set. + if (capture) { + listeners.captured.add(listener); + } else { + listeners.bubbled.add(listener); + } + // Finally, add the event to our known event types list. + addEventTypeToDispatchConfig(type); +} + +export function detachTargetEventListener(listener: ReactDOMListener): void { + const {event, target} = listener; + const {capture, type} = event; + const eventTypeMap = eventTargetEventListenerStore.get(target); + if (eventTypeMap !== undefined) { + const listeners = eventTypeMap.get(type); + if (listeners !== undefined) { + // Remove out listener from the listeners Set. + if (capture) { + listeners.captured.delete(listener); + } else { + listeners.bubbled.delete(listener); + } + } + } +} diff --git a/packages/react-dom/src/events/SimpleEventPlugin.js b/packages/react-dom/src/events/SimpleEventPlugin.js index 0f96651e3c..bb48fde73a 100644 --- a/packages/react-dom/src/events/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/SimpleEventPlugin.js @@ -83,6 +83,7 @@ const SimpleEventPlugin: PluginModule = { nativeEvent: MouseEvent, nativeEventTarget: null | EventTarget, eventSystemFlags: EventSystemFlags, + targetContainer?: null | EventTarget, ): null | ReactSyntheticEvent { const dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType); if (!dispatchConfig) { @@ -194,7 +195,7 @@ const SimpleEventPlugin: PluginModule = { nativeEvent, nativeEventTarget, ); - accumulateTwoPhaseListeners(event, true); + accumulateTwoPhaseListeners(event, true, eventSystemFlags, targetContainer); return event; }, }; 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 e49236cacb..0794b89a2e 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -25,6 +25,27 @@ function dispatchClickEvent(element) { dispatchEvent(element, 'click'); } +let eventListenersToClear = []; + +function startNativeEventListenerClearDown() { + const nativeWindowEventListener = window.addEventListener; + window.addEventListener = function(...params) { + eventListenersToClear.push({target: window, params}); + return nativeWindowEventListener.apply(this, params); + }; + const nativeDocumentEventListener = document.addEventListener; + document.addEventListener = function(...params) { + eventListenersToClear.push({target: document, params}); + return nativeDocumentEventListener.apply(this, params); + }; +} + +function endNativeEventListenerClearDown() { + eventListenersToClear.forEach(({target, params}) => { + target.removeEventListener(...params); + }); +} + describe('DOMModernPluginEventSystem', () => { let container; @@ -45,11 +66,13 @@ describe('DOMModernPluginEventSystem', () => { ReactDOMServer = require('react-dom/server'); container = document.createElement('div'); document.body.appendChild(container); + startNativeEventListenerClearDown(); }); afterEach(() => { document.body.removeChild(container); container = null; + endNativeEventListenerClearDown(); }); it('handle propagation of click events', () => { @@ -1328,14 +1351,18 @@ describe('DOMModernPluginEventSystem', () => { expect(log[0]).toEqual(['capture', buttonElement]); expect(log[1]).toEqual(['bubble', buttonElement]); + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + 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]); + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['capture', divElement]); + expect(log[2]).toEqual(['bubble', divElement]); + expect(log[3]).toEqual(['bubble', buttonElement]); }); it('handle propagation of click events mixed with onClick events', () => { @@ -1787,6 +1814,258 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toHaveBeenCalledTimes(1); }); + it('should correctly work for a basic "click" window listener', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(window, clickEvent); + }); + + return ; + } + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe( + '', + ); + + // Clicking outside the button should trigger the event callback + dispatchClickEvent(document.body); + expect(log[0]).toEqual({ + eventPhase: 3, + type: 'click', + currentTarget: window, + target: document.body, + }); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(document.body); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering and clicking the body should work again + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(document.body); + expect(clickEvent).toBeCalledTimes(2); + }); + + it('handle propagation of click events on the window', () => { + 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 Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + + React.useEffect(() => { + click.setListener(window, onClick); + clickCapture.setListener(window, onClickCapture); + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['bubble', buttonElement]); + expect(log[3]).toEqual(['bubble', window]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['bubble', buttonElement]); + expect(log[5]).toEqual(['bubble', window]); + }); + + it('should correctly handle stopPropagation for mixed listeners', () => { + const buttonRef = React.createRef(); + const rootListerner1 = jest.fn(e => e.stopPropagation()); + const rootListerner2 = jest.fn(); + const targetListerner1 = jest.fn(); + const targetListerner2 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(window, rootListerner1); + click2.setListener(buttonRef.current, targetListerner1); + click3.setListener(window, rootListerner2); + click4.setListener(buttonRef.current, targetListerner2); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner1).toHaveBeenCalledTimes(0); + expect(targetListerner2).toHaveBeenCalledTimes(0); + expect(rootListerner2).toHaveBeenCalledTimes(0); + }); + + it('should correctly handle stopPropagation for delegated listeners', () => { + const buttonRef = React.createRef(); + const rootListerner1 = jest.fn(e => e.stopPropagation()); + const rootListerner2 = jest.fn(); + const rootListerner3 = jest.fn(e => e.stopPropagation()); + const rootListerner4 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(window, rootListerner1); + click2.setListener(window, rootListerner2); + click3.setListener(window, rootListerner3); + click4.setListener(window, rootListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(rootListerner2).toHaveBeenCalledTimes(1); + expect(rootListerner3).toHaveBeenCalledTimes(0); + expect(rootListerner4).toHaveBeenCalledTimes(0); + }); + + it('handle propagation of click events on the window and document', () => { + 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 Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + + React.useEffect(() => { + click.setListener(window, onClick); + clickCapture.setListener(window, onClickCapture); + click.setListener(document, onClick); + clickCapture.setListener(document, onClickCapture); + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['bubble', buttonElement]); + expect(log[4]).toEqual(['bubble', document]); + expect(log[5]).toEqual(['bubble', window]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(4); + expect(onClickCapture).toHaveBeenCalledTimes(4); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + expect(log[6]).toEqual(['bubble', document]); + expect(log[7]).toEqual(['bubble', window]); + }); + it('handles propagation of custom user events', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); diff --git a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js index e43fff6ed3..243a3c91bf 100644 --- a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js +++ b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js @@ -7,6 +7,8 @@ * @flow */ +import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; import {HostComponent} from 'shared/ReactWorkTags'; @@ -14,69 +16,120 @@ import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; import getListener from 'legacy-events/getListener'; import {getListenersFromTarget} from '../client/ReactDOMComponentTree'; +import {IS_TARGET_EVENT_ONLY} from 'legacy-events/EventSystemFlags'; +import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem'; export default function accumulateTwoPhaseListeners( event: ReactSyntheticEvent, accumulateUseEventListeners?: boolean, + eventSystemFlags?: EventSystemFlags, + targetContainer?: null | EventTarget, ): void { const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; const dispatchListeners = []; const dispatchInstances = []; - const {bubbled, captured} = phasedRegistrationNames; - let node = event._targetInst; - // Accumulate all instances and listeners via the target -> root path. - while (node !== null) { - // We only care for listeners that are on HostComponents (i.e.
) - if (node.tag === HostComponent) { - // For useEvent listenrs - if (enableUseEventAPI && accumulateUseEventListeners) { - // useEvent event listeners - const instance = node.stateNode; - const targetType = event.type; - const listeners = getListenersFromTarget(instance); + // For TargetEvent only accumulation, we do not traverse through + // the React tree looking for managed React DOM elements that have + // events. Instead we only check the EventTarget Store Map to see + // if the container has listeners for the particular phase we're + // interested in. This is because we attach the native event listener + // only in the given phase. + if ( + enableUseEventAPI && + accumulateUseEventListeners && + eventSystemFlags !== undefined && + eventSystemFlags & IS_TARGET_EVENT_ONLY && + targetContainer != null + ) { + const eventTypeMap = eventTargetEventListenerStore.get(targetContainer); + if (eventTypeMap !== undefined) { + const type = ((event.type: any): DOMTopLevelEventType); + const listeners = eventTypeMap.get(type); + if (listeners !== undefined) { + const isCapturePhase = (event: any).eventPhase === 1; - if (listeners !== null) { - const listenersArr = Array.from(listeners); - for (let i = 0; i < listenersArr.length; i++) { - const listener = listenersArr[i]; - const { - callback, - event: {capture, type}, - } = listener; - if (type === targetType) { - if (capture === true) { - dispatchListeners.unshift(callback); - dispatchInstances.unshift(node); - } else { - dispatchListeners.push(callback); - dispatchInstances.push(node); + if (isCapturePhase) { + const captureListeners = Array.from(listeners.captured); + + for (let i = captureListeners.length - 1; i >= 0; i--) { + const listener = captureListeners[i]; + const {callback} = listener; + dispatchListeners.push(callback); + dispatchInstances.push(targetContainer); + } + } else { + const bubbleListeners = Array.from(listeners.bubbled); + + for (let i = 0; i < bubbleListeners.length; i++) { + const listener = bubbleListeners[i]; + const {callback} = listener; + dispatchListeners.push(callback); + dispatchInstances.push(targetContainer); + } + } + } + } + } else { + const {bubbled, captured} = phasedRegistrationNames; + // If we are not handling EventTarget only phase, then we're doing the + // usual two phase accumulation using the React fiber tree to pick up + // all relevant useEvent and on* prop events. + let node = event._targetInst; + + // Accumulate all instances and listeners via the target -> root path. + while (node !== null) { + // We only care for listeners that are on HostComponents (i.e.
) + if (node.tag === HostComponent) { + // For useEvent listenrs + if (enableUseEventAPI && accumulateUseEventListeners) { + // useEvent event listeners + const instance = node.stateNode; + const targetType = event.type; + const listeners = getListenersFromTarget(instance); + + if (listeners !== null) { + const listenersArr = Array.from(listeners); + for (let i = 0; i < listenersArr.length; i++) { + const listener = listenersArr[i]; + const { + callback, + event: {capture, type}, + } = listener; + if (type === targetType) { + if (capture === true) { + dispatchListeners.unshift(callback); + dispatchInstances.unshift(node); + } else { + dispatchListeners.push(callback); + dispatchInstances.push(node); + } } } } } - } - // Standard React on* listeners, i.e. onClick prop - if (captured !== null) { - const captureListener = getListener(node, captured); - if (captureListener != null) { - // Capture listeners/instances should go at the start, so we - // unshift them to the start of the array. - dispatchListeners.unshift(captureListener); - dispatchInstances.unshift(node); + // Standard React on* listeners, i.e. onClick prop + if (captured !== null) { + const captureListener = getListener(node, captured); + if (captureListener != null) { + // Capture listeners/instances should go at the start, so we + // unshift them to the start of the array. + dispatchListeners.unshift(captureListener); + dispatchInstances.unshift(node); + } } - } - if (bubbled !== null) { - const bubbleListener = getListener(node, bubbled); - if (bubbleListener != null) { - // Bubble listeners/instances should go at the end, so we - // push them to the end of the array. - dispatchListeners.push(bubbleListener); - dispatchInstances.push(node); + if (bubbled !== null) { + const bubbleListener = getListener(node, bubbled); + if (bubbleListener != null) { + // Bubble listeners/instances should go at the end, so we + // push them to the end of the array. + dispatchListeners.push(bubbleListener); + dispatchInstances.push(node); + } } } + node = node.return; } - node = node.return; } // To prevent allocation to the event unless we actually // have listeners we check the length of one of the arrays.