@samitouri / QOS-React / commits / a600408b28

ReactDOM.useEvent: add `EventTarget` support (#18355)

* ReactDOM.useEvent: add support for all EventTarget types

Dominic Gannaway committed Mar 20, 2020 at 18:31 UTC a600408b284e92ed3d801d7f4e37d10b5da06b10
9 files changed +593 -197
packages/legacy-events/EventPluginUtils.js
+2 -1
@@ -64,7 +64,8 @@ if (__DEV__) {
64 */
65 export function executeDispatch(event, listener, inst) {
66 const type = event.type || 'unknown-event';
67 - event.currentTarget = getNodeFromInstance(inst);
67 + event.currentTarget =
68 + inst.tag !== undefined ? getNodeFromInstance(inst) : inst;
69 invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
70 event.currentTarget = null;
71 }
packages/legacy-events/EventSystemFlags.js
+2 -1
@@ -16,4 +16,5 @@ export const IS_ACTIVE = 1 << 3;
16 export const PASSIVE_NOT_SUPPORTED = 1 << 4;
17 export const IS_REPLAYED = 1 << 5;
18 export const IS_FIRST_ANCESTOR = 1 << 6;
19 -export const LEGACY_FB_SUPPORT = 1 << 7;
19 +export const IS_TARGET_EVENT_ONLY = 1 << 7;
20 +export const LEGACY_FB_SUPPORT = 1 << 8;
packages/legacy-events/ReactSyntheticEventType.js
+1 -1
@@ -39,7 +39,7 @@ export type ReactSyntheticEvent = {|
39 nativeEventTarget: EventTarget,
40 ) => ReactSyntheticEvent,
41 isPersistent: () => boolean,
42 - _dispatchInstances: null | Array<Fiber> | Fiber,
42 + _dispatchInstances: null | Array<Fiber | EventTarget> | Fiber | EventTarget,
43 _dispatchListeners: null | Array<Function> | Function,
44 _targetInst: Fiber,
45 type: string,
packages/react-dom/src/client/ReactDOMHostConfig.js
+17 -25
@@ -75,11 +75,13 @@ import {
75 IS_PASSIVE,
76 } from 'legacy-events/EventSystemFlags';
77 import {
78 - attachElementListener,
79 - detachElementListener,
80 - isDOMDocument,
81 - isDOMElement,
78 + isManagedDOMElement,
79 + isValidEventTarget,
80 listenToTopLevelEvent,
81 + detachListenerFromManagedDOMElement,
82 + attachListenerFromManagedDOMElement,
83 + detachTargetEventListener,
84 + attachTargetEventListener,
85 } from '../events/DOMModernPluginEventSystem';
86 import {getListenerMapForElement} from '../events/DOMEventListenerMap';
87
@@ -1122,12 +1124,10 @@ export function registerEvent(
1124 export function mountEventListener(listener: ReactDOMListener): void {
1125 if (enableUseEventAPI) {
1126 const {target} = listener;
1125 - if (target === window) {
1126 - // TODO (useEvent)
1127 - } else if (isDOMDocument(target)) {
1128 - // TODO (useEvent)
1129 - } else if (isDOMElement(target)) {
1130 - attachElementListener(listener);
1127 + if (isManagedDOMElement(target)) {
1128 + attachListenerFromManagedDOMElement(listener);
1129 + } else {
1130 + attachTargetEventListener(listener);
1131 }
1132 }
1133 }
@@ -1135,12 +1135,10 @@ export function mountEventListener(listener: ReactDOMListener): void {
1135 export function unmountEventListener(listener: ReactDOMListener): void {
1136 if (enableUseEventAPI) {
1137 const {target} = listener;
1138 - if (target === window) {
1139 - // TODO (useEvent)
1140 - } else if (isDOMDocument(target)) {
1141 - // TODO (useEvent)
1142 - } else if (isDOMElement(target)) {
1143 - detachElementListener(listener);
1138 + if (isManagedDOMElement(target)) {
1139 + detachListenerFromManagedDOMElement(listener);
1140 + } else {
1141 + detachTargetEventListener(listener);
1142 }
1143 }
1144 }
@@ -1152,10 +1150,7 @@ export function validateEventListenerTarget(
1150 if (enableUseEventAPI) {
1151 if (
1152 target != null &&
1155 - (target === window ||
1156 - isDOMDocument(target) ||
1157 - (isDOMElement(target) &&
1158 - getClosestInstanceFromNode(((target: any): Element))))
1153 + (isManagedDOMElement(target) || isValidEventTarget(target))
1154 ) {
1155 if (listener == null || typeof listener === 'function') {
1156 return true;
@@ -1169,11 +1164,8 @@ export function validateEventListenerTarget(
1164 }
1165 if (__DEV__) {
1166 console.warn(
1172 - 'Event listener method setListener() from useEvent() hook requires the first argument to be either:' +
1173 - '\n\n' +
1174 - '1. A valid DOM node that was rendered and managed by React\n' +
1175 - '2. The "window" object\n' +
1176 - '3. The "document" object',
1167 + 'Event listener method setListener() from useEvent() hook requires the first argument to be ' +
1168 + 'a valid DOM EventTarget. If using a ref, ensure the current value is not null.',
1169 );
1170 }
1171 }
packages/react-dom/src/events/DOMEventListenerMap.js
+5 -5
@@ -12,11 +12,11 @@ import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
12 import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
13
14 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
15 -// prettier-ignore
16 -const elementListenerMap:
17 - // $FlowFixMe Work around Flow bug
18 - | WeakMap
19 - | Map<EventTarget, ElementListenerMap> = new PossiblyWeakMap();
15 +// $FlowFixMe: Flow cannot handle polymorphic WeakMaps
16 +const elementListenerMap: WeakMap<
17 + EventTarget,
18 + ElementListenerMap,
19 +> = new PossiblyWeakMap();
20
21 export type ElementListenerMap = Map<
22 DOMTopLevelEventType | string,
packages/react-dom/src/events/DOMModernPluginEventSystem.js
+181 -112
@@ -27,7 +27,11 @@ import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
27 import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
28 import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
29 import {plugins} from 'legacy-events/EventPluginRegistry';
30 -import {LEGACY_FB_SUPPORT, IS_REPLAYED} from 'legacy-events/EventSystemFlags';
30 +import {
31 + LEGACY_FB_SUPPORT,
32 + IS_REPLAYED,
33 + IS_TARGET_EVENT_ONLY,
34 +} from 'legacy-events/EventSystemFlags';
35
36 import {HostRoot, HostPortal} from 'shared/ReactWorkTags';
37
@@ -77,11 +81,7 @@ import {
81 getListenersFromTarget,
82 initListenersSet,
83 } from '../client/ReactDOMComponentTree';
80 -import {
81 - DOCUMENT_NODE,
82 - COMMENT_NODE,
83 - ELEMENT_NODE,
84 -} from '../shared/HTMLNodeType';
84 +import {COMMENT_NODE} from '../shared/HTMLNodeType';
85 import {topLevelEventsToDispatchConfig} from './DOMEventProperties';
86
87 import {enableLegacyFBSupport} from 'shared/ReactFeatureFlags';
@@ -132,6 +132,17 @@ const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = {
132
133 const isArray = Array.isArray;
134
135 +// $FlowFixMe: Flow struggles with this pattern
136 +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
137 +// $FlowFixMe: Flow cannot handle polymorphic WeakMaps
138 +export const eventTargetEventListenerStore: WeakMap<
139 + EventTarget,
140 + Map<
141 + DOMTopLevelEventType,
142 + {bubbled: Set<ReactDOMListener>, captured: Set<ReactDOMListener>},
143 + >,
144 +> = new PossiblyWeakMap();
145 +
146 function dispatchEventsForPlugins(
147 topLevelType: DOMTopLevelEventType,
148 eventSystemFlags: EventSystemFlags,
@@ -198,11 +209,27 @@ export function listenToTopLevelEvent(
209 listenerMap: ElementListenerMap,
210 passive?: boolean,
211 priority?: EventPriority,
212 + capture?: boolean,
213 ): void {
202 - const listenerEntry = listenerMap.get(topLevelType);
214 + // If we explicitly define capture, then these are for EventTarget objects,
215 + // rather than React managed DOM elements. So we need to ensure we separate
216 + // capture and non-capture events. For React managed DOM nodes we only use
217 + // one or the other, never both. Which one we use is determined by the the
218 + // capturePhaseEvents Set (in this module) that defines if the event listener
219 + // should use the capture phase – otherwise we always use the bubble phase.
220 + // Finally, when we get to dispatching and accumulating event listeners, we
221 + // check if the user wanted capture/bubble and emulate the behavior at that
222 + // point (we call this accumulating two phase listeners).
223 + const typeStr = ((topLevelType: any): string);
224 + const listenerMapKey =
225 + capture === undefined
226 + ? topLevelType
227 + : `${typeStr}_${capture ? 'capture' : 'bubble'}`;
228 + const listenerEntry = listenerMap.get(listenerMapKey);
229 const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive);
230 if (listenerEntry === undefined || shouldUpgrade) {
205 - const isCapturePhase = capturePhaseEvents.has(topLevelType);
231 + const isCapturePhase =
232 + capture === undefined ? capturePhaseEvents.has(topLevelType) : capture;
233 // If we should upgrade, then we need to remove the existing trapped
234 // event listener for the target container.
235 if (shouldUpgrade) {
@@ -221,7 +248,7 @@ export function listenToTopLevelEvent(
248 passive,
249 priority,
250 );
224 - listenerMap.set(topLevelType, {passive, listener});
251 + listenerMap.set(listenerMapKey, {passive, listener});
252 }
253 }
254
@@ -269,14 +296,12 @@ function isMatchingRootContainer(
296 );
297 }
298
272 -export function isDOMElement(target: EventTarget): boolean {
273 - const nodeType = ((target: any): Node).nodeType;
274 - return (nodeType: any) && nodeType === ELEMENT_NODE;
299 +export function isManagedDOMElement(target: EventTarget): boolean {
300 + return getClosestInstanceFromNode(((target: any): Node)) !== null;
301 }
302
277 -export function isDOMDocument(target: EventTarget): boolean {
278 - const nodeType = ((target: any): Node).nodeType;
279 - return nodeType === DOCUMENT_NODE;
303 +export function isValidEventTarget(target: EventTarget): boolean {
304 + return typeof target.addEventListener === 'function';
305 }
306
307 export function dispatchEventForPluginEventSystem(
@@ -288,87 +313,85 @@ export function dispatchEventForPluginEventSystem(
313 ): void {
314 let ancestorInst = targetInst;
315 if (targetContainer !== null) {
291 - const possibleTargetContainerNode = ((targetContainer: any): Node);
292 - // Given the rootContainer can be any EventTarget, if the
293 - // target is not a valid DOM element then we'll skip this part.
294 - if (
295 - possibleTargetContainerNode === window ||
296 - !isDOMElement(possibleTargetContainerNode)
297 - ) {
298 - // TODO: useEvent for document and window
299 - return;
300 - }
301 - // If we are using the legacy FB support flag, we
302 - // defer the event to the null with a one
303 - // time event listener so we can defer the event.
304 - if (
305 - enableLegacyFBSupport &&
306 - // We do not want to defer if the event system has already been
307 - // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when
308 - // we call willDeferLaterForLegacyFBSupport, thus not bailing out
309 - // will result in endless cycles like an infinite loop.
310 - (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 &&
311 - // We also don't want to defer during event replaying.
312 - (eventSystemFlags & IS_REPLAYED) === 0 &&
313 - willDeferLaterForLegacyFBSupport(topLevelType, targetContainer)
314 - ) {
315 - return;
316 - }
317 - if (targetInst !== null) {
318 - // The below logic attempts to work out if we need to change
319 - // the target fiber to a different ancestor. We had similar logic
320 - // in the legacy event system, except the big difference between
321 - // systems is that the modern event system now has an event listener
322 - // attached to each React Root and React Portal Root. Together,
323 - // the DOM nodes representing these roots are the "rootContainer".
324 - // To figure out which ancestor instance we should use, we traverse
325 - // up the fiber tree from the target instance and attempt to find
326 - // root boundaries that match that of our current "rootContainer".
327 - // If we find that "rootContainer", we find the parent fiber
328 - // sub-tree for that root and make that our ancestor instance.
329 - let node = targetInst;
316 + if (eventTargetEventListenerStore.has(targetContainer)) {
317 + // For TargetEvent nodes (i.e. document, window)
318 + ancestorInst = null;
319 + eventSystemFlags |= IS_TARGET_EVENT_ONLY;
320 + } else {
321 + const targetContainerNode = ((targetContainer: any): Node);
322
331 - while (true) {
332 - if (node === null) {
333 - return;
334 - }
335 - if (node.tag === HostRoot || node.tag === HostPortal) {
336 - const container = node.stateNode.containerInfo;
337 - if (isMatchingRootContainer(container, possibleTargetContainerNode)) {
338 - break;
323 + // If we are using the legacy FB support flag, we
324 + // defer the event to the null with a one
325 + // time event listener so we can defer the event.
326 + if (
327 + enableLegacyFBSupport &&
328 + // We do not want to defer if the event system has already been
329 + // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when
330 + // we call willDeferLaterForLegacyFBSupport, thus not bailing out
331 + // will result in endless cycles like an infinite loop.
332 + (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 &&
333 + // We also don't want to defer during event replaying.
334 + (eventSystemFlags & IS_REPLAYED) === 0 &&
335 + willDeferLaterForLegacyFBSupport(topLevelType, targetContainer)
336 + ) {
337 + return;
338 + }
339 + if (targetInst !== null) {
340 + // The below logic attempts to work out if we need to change
341 + // the target fiber to a different ancestor. We had similar logic
342 + // in the legacy event system, except the big difference between
343 + // systems is that the modern event system now has an event listener
344 + // attached to each React Root and React Portal Root. Together,
345 + // the DOM nodes representing these roots are the "rootContainer".
346 + // To figure out which ancestor instance we should use, we traverse
347 + // up the fiber tree from the target instance and attempt to find
348 + // root boundaries that match that of our current "rootContainer".
349 + // If we find that "rootContainer", we find the parent fiber
350 + // sub-tree for that root and make that our ancestor instance.
351 + let node = targetInst;
352 +
353 + while (true) {
354 + if (node === null) {
355 + return;
356 }
340 - if (node.tag === HostPortal) {
341 - // The target is a portal, but it's not the rootContainer we're looking for.
342 - // Normally portals handle their own events all the way down to the root.
343 - // So we should be able to stop now. However, we don't know if this portal
344 - // was part of *our* root.
345 - let grandNode = node.return;
346 - while (grandNode !== null) {
347 - if (grandNode.tag === HostRoot || grandNode.tag === HostPortal) {
348 - const grandContainer = grandNode.stateNode.containerInfo;
357 + if (node.tag === HostRoot || node.tag === HostPortal) {
358 + const container = node.stateNode.containerInfo;
359 + if (isMatchingRootContainer(container, targetContainerNode)) {
360 + break;
361 + }
362 + if (node.tag === HostPortal) {
363 + // The target is a portal, but it's not the rootContainer we're looking for.
364 + // Normally portals handle their own events all the way down to the root.
365 + // So we should be able to stop now. However, we don't know if this portal
366 + // was part of *our* root.
367 + let grandNode = node.return;
368 + while (grandNode !== null) {
369 if (
350 - isMatchingRootContainer(
351 - grandContainer,
352 - possibleTargetContainerNode,
353 - )
370 + grandNode.tag === HostRoot ||
371 + grandNode.tag === HostPortal
372 ) {
355 - // This is the rootContainer we're looking for and we found it as
356 - // a parent of the Portal. That means we can ignore it because the
357 - // Portal will bubble through to us.
358 - return;
373 + const grandContainer = grandNode.stateNode.containerInfo;
374 + if (
375 + isMatchingRootContainer(grandContainer, targetContainerNode)
376 + ) {
377 + // This is the rootContainer we're looking for and we found it as
378 + // a parent of the Portal. That means we can ignore it because the
379 + // Portal will bubble through to us.
380 + return;
381 + }
382 }
383 + grandNode = grandNode.return;
384 }
361 - grandNode = grandNode.return;
385 }
386 + const parentSubtreeInst = getClosestInstanceFromNode(container);
387 + if (parentSubtreeInst === null) {
388 + return;
389 + }
390 + node = ancestorInst = parentSubtreeInst;
391 + continue;
392 }
364 - const parentSubtreeInst = getClosestInstanceFromNode(container);
365 - if (parentSubtreeInst === null) {
366 - return;
367 - }
368 - node = ancestorInst = parentSubtreeInst;
369 - continue;
393 + node = node.return;
394 }
371 - node = node.return;
395 }
396 }
397 }
@@ -398,21 +421,32 @@ function getNearestRootOrPortalContainer(instance: Element): Element {
421 return instance;
422 }
423
401 -export function attachElementListener(listener: ReactDOMListener): void {
424 +function addEventTypeToDispatchConfig(type: DOMTopLevelEventType): void {
425 + let dispatchConfig = topLevelEventsToDispatchConfig.get(type);
426 + // If we don't have a dispatchConfig, then we're dealing with
427 + // an event type that React does not know about (i.e. a custom event).
428 + // We need to register an event config for this or the SimpleEventPlugin
429 + // will not appropriately provide a SyntheticEvent, so we use out empty
430 + // dispatch config for custom events.
431 + if (dispatchConfig === undefined) {
432 + topLevelEventsToDispatchConfig.set(
433 + type,
434 + emptyDispatchConfigForCustomEvents,
435 + );
436 + }
437 +}
438 +
439 +export function attachListenerFromManagedDOMElement(
440 + listener: ReactDOMListener,
441 +): void {
442 const {event, target} = listener;
443 const {passive, priority, type} = event;
444 + const possibleManagedTarget = ((target: any): Element);
445 let containerEventTarget = target;
405 - // If we the target is a managed React element, then we need to
406 - // find the nearest root/portal contained to attach the event listener
407 - // to. If it's not managed, i.e. the window, then we just attach
408 - // the listener to the target.
409 - if (isDOMElement(target)) {
410 - const possibleManagedTarget = ((target: any): Element);
411 - if (getClosestInstanceFromNode(possibleManagedTarget)) {
412 - containerEventTarget = getNearestRootOrPortalContainer(
413 - possibleManagedTarget,
414 - );
415 - }
446 + if (getClosestInstanceFromNode(possibleManagedTarget)) {
447 + containerEventTarget = getNearestRootOrPortalContainer(
448 + possibleManagedTarget,
449 + );
450 }
451 const listenerMap = getListenerMapForElement(containerEventTarget);
452 // Add the event listener to the target container (falling back to
@@ -434,21 +468,12 @@ export function attachElementListener(listener: ReactDOMListener): void {
468 // Add our listener to the listeners Set.
469 listeners.add(listener);
470 // Finally, add the event to our known event types list.
437 - let dispatchConfig = topLevelEventsToDispatchConfig.get(type);
438 - // If we don't have a dispatchConfig, then we're dealing with
439 - // an event type that React does not know about (i.e. a custom event).
440 - // We need to register an event config for this or the SimpleEventPlugin
441 - // will not appropriately provide a SyntheticEvent, so we use out empty
442 - // dispatch config for custom events.
443 - if (dispatchConfig === undefined) {
444 - topLevelEventsToDispatchConfig.set(
445 - type,
446 - emptyDispatchConfigForCustomEvents,
447 - );
448 - }
471 + addEventTypeToDispatchConfig(type);
472 }
473
451 -export function detachElementListener(listener: ReactDOMListener): void {
474 +export function detachListenerFromManagedDOMElement(
475 + listener: ReactDOMListener,
476 +): void {
477 const {target} = listener;
478 // Get the internal listeners Set from the target instance.
479 const listeners = getListenersFromTarget(target);
@@ -457,3 +482,47 @@ export function detachElementListener(listener: ReactDOMListener): void {
482 listeners.delete(listener);
483 }
484 }
485 +
486 +export function attachTargetEventListener(listener: ReactDOMListener): void {
487 + const {event, target} = listener;
488 + const {capture, passive, priority, type} = event;
489 + const listenerMap = getListenerMapForElement(target);
490 + // Add the event listener to the TargetEvent object.
491 + listenToTopLevelEvent(type, target, listenerMap, passive, priority, capture);
492 + let eventTypeMap = eventTargetEventListenerStore.get(target);
493 + if (eventTypeMap === undefined) {
494 + eventTypeMap = new Map();
495 + eventTargetEventListenerStore.set(target, eventTypeMap);
496 + }
497 + // Get the listeners by the event type
498 + let listeners = eventTypeMap.get(type);
499 + if (listeners === undefined) {
500 + listeners = {captured: new Set(), bubbled: new Set()};
501 + eventTypeMap.set(type, listeners);
502 + }
503 + // Add our listener to the listeners Set.
504 + if (capture) {
505 + listeners.captured.add(listener);
506 + } else {
507 + listeners.bubbled.add(listener);
508 + }
509 + // Finally, add the event to our known event types list.
510 + addEventTypeToDispatchConfig(type);
511 +}
512 +
513 +export function detachTargetEventListener(listener: ReactDOMListener): void {
514 + const {event, target} = listener;
515 + const {capture, type} = event;
516 + const eventTypeMap = eventTargetEventListenerStore.get(target);
517 + if (eventTypeMap !== undefined) {
518 + const listeners = eventTypeMap.get(type);
519 + if (listeners !== undefined) {
520 + // Remove out listener from the listeners Set.
521 + if (capture) {
522 + listeners.captured.delete(listener);
523 + } else {
524 + listeners.bubbled.delete(listener);
525 + }
526 + }
527 + }
528 +}
packages/react-dom/src/events/SimpleEventPlugin.js
+2 -1
@@ -83,6 +83,7 @@ const SimpleEventPlugin: PluginModule<MouseEvent> = {
83 nativeEvent: MouseEvent,
84 nativeEventTarget: null | EventTarget,
85 eventSystemFlags: EventSystemFlags,
86 + targetContainer?: null | EventTarget,
87 ): null | ReactSyntheticEvent {
88 const dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);
89 if (!dispatchConfig) {
@@ -194,7 +195,7 @@ const SimpleEventPlugin: PluginModule<MouseEvent> = {
195 nativeEvent,
196 nativeEventTarget,
197 );
197 - accumulateTwoPhaseListeners(event, true);
198 + accumulateTwoPhaseListeners(event, true, eventSystemFlags, targetContainer);
199 return event;
200 },
201 };
packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js
+285 -6
@@ -25,6 +25,27 @@ function dispatchClickEvent(element) {
25 dispatchEvent(element, 'click');
26 }
27
28 +let eventListenersToClear = [];
29 +
30 +function startNativeEventListenerClearDown() {
31 + const nativeWindowEventListener = window.addEventListener;
32 + window.addEventListener = function(...params) {
33 + eventListenersToClear.push({target: window, params});
34 + return nativeWindowEventListener.apply(this, params);
35 + };
36 + const nativeDocumentEventListener = document.addEventListener;
37 + document.addEventListener = function(...params) {
38 + eventListenersToClear.push({target: document, params});
39 + return nativeDocumentEventListener.apply(this, params);
40 + };
41 +}
42 +
43 +function endNativeEventListenerClearDown() {
44 + eventListenersToClear.forEach(({target, params}) => {
45 + target.removeEventListener(...params);
46 + });
47 +}
48 +
49 describe('DOMModernPluginEventSystem', () => {
50 let container;
51
@@ -45,11 +66,13 @@ describe('DOMModernPluginEventSystem', () => {
66 ReactDOMServer = require('react-dom/server');
67 container = document.createElement('div');
68 document.body.appendChild(container);
69 + startNativeEventListenerClearDown();
70 });
71
72 afterEach(() => {
73 document.body.removeChild(container);
74 container = null;
75 + endNativeEventListenerClearDown();
76 });
77
78 it('handle propagation of click events', () => {
@@ -1328,14 +1351,18 @@ describe('DOMModernPluginEventSystem', () => {
1351 expect(log[0]).toEqual(['capture', buttonElement]);
1352 expect(log[1]).toEqual(['bubble', buttonElement]);
1353
1354 + log.length = 0;
1355 + onClick.mockClear();
1356 + onClickCapture.mockClear();
1357 +
1358 let divElement = divRef.current;
1359 dispatchClickEvent(divElement);
1333 - expect(onClick).toHaveBeenCalledTimes(3);
1334 - expect(onClickCapture).toHaveBeenCalledTimes(3);
1335 - expect(log[2]).toEqual(['capture', buttonElement]);
1336 - expect(log[3]).toEqual(['capture', divElement]);
1337 - expect(log[4]).toEqual(['bubble', divElement]);
1338 - expect(log[5]).toEqual(['bubble', buttonElement]);
1360 + expect(onClick).toHaveBeenCalledTimes(2);
1361 + expect(onClickCapture).toHaveBeenCalledTimes(2);
1362 + expect(log[0]).toEqual(['capture', buttonElement]);
1363 + expect(log[1]).toEqual(['capture', divElement]);
1364 + expect(log[2]).toEqual(['bubble', divElement]);
1365 + expect(log[3]).toEqual(['bubble', buttonElement]);
1366 });
1367
1368 it('handle propagation of click events mixed with onClick events', () => {
@@ -1787,6 +1814,258 @@ describe('DOMModernPluginEventSystem', () => {
1814 expect(clickEvent).toHaveBeenCalledTimes(1);
1815 });
1816
1817 + it('should correctly work for a basic "click" window listener', () => {
1818 + const log = [];
1819 + const clickEvent = jest.fn(event => {
1820 + log.push({
1821 + eventPhase: event.eventPhase,
1822 + type: event.type,
1823 + currentTarget: event.currentTarget,
1824 + target: event.target,
1825 + });
1826 + });
1827 +
1828 + function Test() {
1829 + const click = ReactDOM.unstable_useEvent('click');
1830 +
1831 + React.useEffect(() => {
1832 + click.setListener(window, clickEvent);
1833 + });
1834 +
1835 + return <button>Click anything!</button>;
1836 + }
1837 + ReactDOM.render(<Test />, container);
1838 + Scheduler.unstable_flushAll();
1839 +
1840 + expect(container.innerHTML).toBe(
1841 + '<button>Click anything!</button>',
1842 + );
1843 +
1844 + // Clicking outside the button should trigger the event callback
1845 + dispatchClickEvent(document.body);
1846 + expect(log[0]).toEqual({
1847 + eventPhase: 3,
1848 + type: 'click',
1849 + currentTarget: window,
1850 + target: document.body,
1851 + });
1852 +
1853 + // Unmounting the container and clicking should not work
1854 + ReactDOM.render(null, container);
1855 + Scheduler.unstable_flushAll();
1856 +
1857 + dispatchClickEvent(document.body);
1858 + expect(clickEvent).toBeCalledTimes(1);
1859 +
1860 + // Re-rendering and clicking the body should work again
1861 + ReactDOM.render(<Test />, container);
1862 + Scheduler.unstable_flushAll();
1863 +
1864 + dispatchClickEvent(document.body);
1865 + expect(clickEvent).toBeCalledTimes(2);
1866 + });
1867 +
1868 + it('handle propagation of click events on the window', () => {
1869 + const buttonRef = React.createRef();
1870 + const divRef = React.createRef();
1871 + const log = [];
1872 + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
1873 + const onClickCapture = jest.fn(e =>
1874 + log.push(['capture', e.currentTarget]),
1875 + );
1876 +
1877 + function Test() {
1878 + const click = ReactDOM.unstable_useEvent('click');
1879 + const clickCapture = ReactDOM.unstable_useEvent('click', {
1880 + capture: true,
1881 + });
1882 +
1883 + React.useEffect(() => {
1884 + click.setListener(window, onClick);
1885 + clickCapture.setListener(window, onClickCapture);
1886 + click.setListener(buttonRef.current, onClick);
1887 + clickCapture.setListener(buttonRef.current, onClickCapture);
1888 + click.setListener(divRef.current, onClick);
1889 + clickCapture.setListener(divRef.current, onClickCapture);
1890 + });
1891 +
1892 + return (
1893 + <button ref={buttonRef}>
1894 + <div ref={divRef}>Click me!</div>
1895 + </button>
1896 + );
1897 + }
1898 +
1899 + ReactDOM.render(<Test />, container);
1900 + Scheduler.unstable_flushAll();
1901 +
1902 + let buttonElement = buttonRef.current;
1903 + dispatchClickEvent(buttonElement);
1904 + expect(onClick).toHaveBeenCalledTimes(2);
1905 + expect(onClickCapture).toHaveBeenCalledTimes(2);
1906 + expect(log[0]).toEqual(['capture', window]);
1907 + expect(log[1]).toEqual(['capture', buttonElement]);
1908 + expect(log[2]).toEqual(['bubble', buttonElement]);
1909 + expect(log[3]).toEqual(['bubble', window]);
1910 +
1911 + log.length = 0;
1912 + onClick.mockClear();
1913 + onClickCapture.mockClear();
1914 +
1915 + let divElement = divRef.current;
1916 + dispatchClickEvent(divElement);
1917 + expect(onClick).toHaveBeenCalledTimes(3);
1918 + expect(onClickCapture).toHaveBeenCalledTimes(3);
1919 + expect(log[0]).toEqual(['capture', window]);
1920 + expect(log[1]).toEqual(['capture', buttonElement]);
1921 + expect(log[2]).toEqual(['capture', divElement]);
1922 + expect(log[3]).toEqual(['bubble', divElement]);
1923 + expect(log[4]).toEqual(['bubble', buttonElement]);
1924 + expect(log[5]).toEqual(['bubble', window]);
1925 + });
1926 +
1927 + it('should correctly handle stopPropagation for mixed listeners', () => {
1928 + const buttonRef = React.createRef();
1929 + const rootListerner1 = jest.fn(e => e.stopPropagation());
1930 + const rootListerner2 = jest.fn();
1931 + const targetListerner1 = jest.fn();
1932 + const targetListerner2 = jest.fn();
1933 +
1934 + function Test() {
1935 + const click1 = ReactDOM.unstable_useEvent('click', {
1936 + capture: true,
1937 + });
1938 + const click2 = ReactDOM.unstable_useEvent('click', {
1939 + capture: true,
1940 + });
1941 + const click3 = ReactDOM.unstable_useEvent('click');
1942 + const click4 = ReactDOM.unstable_useEvent('click');
1943 +
1944 + React.useEffect(() => {
1945 + click1.setListener(window, rootListerner1);
1946 + click2.setListener(buttonRef.current, targetListerner1);
1947 + click3.setListener(window, rootListerner2);
1948 + click4.setListener(buttonRef.current, targetListerner2);
1949 + });
1950 +
1951 + return <button ref={buttonRef}>Click me!</button>;
1952 + }
1953 +
1954 + ReactDOM.render(<Test />, container);
1955 + Scheduler.unstable_flushAll();
1956 +
1957 + let buttonElement = buttonRef.current;
1958 + dispatchClickEvent(buttonElement);
1959 + expect(rootListerner1).toHaveBeenCalledTimes(1);
1960 + expect(targetListerner1).toHaveBeenCalledTimes(0);
1961 + expect(targetListerner2).toHaveBeenCalledTimes(0);
1962 + expect(rootListerner2).toHaveBeenCalledTimes(0);
1963 + });
1964 +
1965 + it('should correctly handle stopPropagation for delegated listeners', () => {
1966 + const buttonRef = React.createRef();
1967 + const rootListerner1 = jest.fn(e => e.stopPropagation());
1968 + const rootListerner2 = jest.fn();
1969 + const rootListerner3 = jest.fn(e => e.stopPropagation());
1970 + const rootListerner4 = jest.fn();
1971 +
1972 + function Test() {
1973 + const click1 = ReactDOM.unstable_useEvent('click', {
1974 + capture: true,
1975 + });
1976 + const click2 = ReactDOM.unstable_useEvent('click', {
1977 + capture: true,
1978 + });
1979 + const click3 = ReactDOM.unstable_useEvent('click');
1980 + const click4 = ReactDOM.unstable_useEvent('click');
1981 +
1982 + React.useEffect(() => {
1983 + click1.setListener(window, rootListerner1);
1984 + click2.setListener(window, rootListerner2);
1985 + click3.setListener(window, rootListerner3);
1986 + click4.setListener(window, rootListerner4);
1987 + });
1988 +
1989 + return <button ref={buttonRef}>Click me!</button>;
1990 + }
1991 +
1992 + ReactDOM.render(<Test />, container);
1993 +
1994 + Scheduler.unstable_flushAll();
1995 +
1996 + let buttonElement = buttonRef.current;
1997 + dispatchClickEvent(buttonElement);
1998 + expect(rootListerner1).toHaveBeenCalledTimes(1);
1999 + expect(rootListerner2).toHaveBeenCalledTimes(1);
2000 + expect(rootListerner3).toHaveBeenCalledTimes(0);
2001 + expect(rootListerner4).toHaveBeenCalledTimes(0);
2002 + });
2003 +
2004 + it('handle propagation of click events on the window and document', () => {
2005 + const buttonRef = React.createRef();
2006 + const divRef = React.createRef();
2007 + const log = [];
2008 + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
2009 + const onClickCapture = jest.fn(e =>
2010 + log.push(['capture', e.currentTarget]),
2011 + );
2012 +
2013 + function Test() {
2014 + const click = ReactDOM.unstable_useEvent('click');
2015 + const clickCapture = ReactDOM.unstable_useEvent('click', {
2016 + capture: true,
2017 + });
2018 +
2019 + React.useEffect(() => {
2020 + click.setListener(window, onClick);
2021 + clickCapture.setListener(window, onClickCapture);
2022 + click.setListener(document, onClick);
2023 + clickCapture.setListener(document, onClickCapture);
2024 + click.setListener(buttonRef.current, onClick);
2025 + clickCapture.setListener(buttonRef.current, onClickCapture);
2026 + click.setListener(divRef.current, onClick);
2027 + clickCapture.setListener(divRef.current, onClickCapture);
2028 + });
2029 +
2030 + return (
2031 + <button ref={buttonRef}>
2032 + <div ref={divRef}>Click me!</div>
2033 + </button>
2034 + );
2035 + }
2036 +
2037 + ReactDOM.render(<Test />, container);
2038 + Scheduler.unstable_flushAll();
2039 +
2040 + let buttonElement = buttonRef.current;
2041 + dispatchClickEvent(buttonElement);
2042 + expect(onClick).toHaveBeenCalledTimes(3);
2043 + expect(onClickCapture).toHaveBeenCalledTimes(3);
2044 + expect(log[0]).toEqual(['capture', window]);
2045 + expect(log[1]).toEqual(['capture', document]);
2046 + expect(log[2]).toEqual(['capture', buttonElement]);
2047 + expect(log[3]).toEqual(['bubble', buttonElement]);
2048 + expect(log[4]).toEqual(['bubble', document]);
2049 + expect(log[5]).toEqual(['bubble', window]);
2050 +
2051 + log.length = 0;
2052 + onClick.mockClear();
2053 + onClickCapture.mockClear();
2054 +
2055 + let divElement = divRef.current;
2056 + dispatchClickEvent(divElement);
2057 + expect(onClick).toHaveBeenCalledTimes(4);
2058 + expect(onClickCapture).toHaveBeenCalledTimes(4);
2059 + expect(log[0]).toEqual(['capture', window]);
2060 + expect(log[1]).toEqual(['capture', document]);
2061 + expect(log[2]).toEqual(['capture', buttonElement]);
2062 + expect(log[3]).toEqual(['capture', divElement]);
2063 + expect(log[4]).toEqual(['bubble', divElement]);
2064 + expect(log[5]).toEqual(['bubble', buttonElement]);
2065 + expect(log[6]).toEqual(['bubble', document]);
2066 + expect(log[7]).toEqual(['bubble', window]);
2067 + });
2068 +
2069 it('handles propagation of custom user events', () => {
2070 const buttonRef = React.createRef();
2071 const divRef = React.createRef();
packages/react-dom/src/events/accumulateTwoPhaseListeners.js
+98 -45
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
11 +import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';
12 import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType';
13
14 import {HostComponent} from 'shared/ReactWorkTags';
@@ -14,69 +16,120 @@ import {enableUseEventAPI} from 'shared/ReactFeatureFlags';
16
17 import getListener from 'legacy-events/getListener';
18 import {getListenersFromTarget} from '../client/ReactDOMComponentTree';
19 +import {IS_TARGET_EVENT_ONLY} from 'legacy-events/EventSystemFlags';
20 +import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem';
21
22 export default function accumulateTwoPhaseListeners(
23 event: ReactSyntheticEvent,
24 accumulateUseEventListeners?: boolean,
25 + eventSystemFlags?: EventSystemFlags,
26 + targetContainer?: null | EventTarget,
27 ): void {
28 const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames;
29 const dispatchListeners = [];
30 const dispatchInstances = [];
25 - const {bubbled, captured} = phasedRegistrationNames;
26 - let node = event._targetInst;
31
28 - // Accumulate all instances and listeners via the target -> root path.
29 - while (node !== null) {
30 - // We only care for listeners that are on HostComponents (i.e. <div>)
31 - if (node.tag === HostComponent) {
32 - // For useEvent listenrs
33 - if (enableUseEventAPI && accumulateUseEventListeners) {
34 - // useEvent event listeners
35 - const instance = node.stateNode;
36 - const targetType = event.type;
37 - const listeners = getListenersFromTarget(instance);
32 + // For TargetEvent only accumulation, we do not traverse through
33 + // the React tree looking for managed React DOM elements that have
34 + // events. Instead we only check the EventTarget Store Map to see
35 + // if the container has listeners for the particular phase we're
36 + // interested in. This is because we attach the native event listener
37 + // only in the given phase.
38 + if (
39 + enableUseEventAPI &&
40 + accumulateUseEventListeners &&
41 + eventSystemFlags !== undefined &&
42 + eventSystemFlags & IS_TARGET_EVENT_ONLY &&
43 + targetContainer != null
44 + ) {
45 + const eventTypeMap = eventTargetEventListenerStore.get(targetContainer);
46 + if (eventTypeMap !== undefined) {
47 + const type = ((event.type: any): DOMTopLevelEventType);
48 + const listeners = eventTypeMap.get(type);
49 + if (listeners !== undefined) {
50 + const isCapturePhase = (event: any).eventPhase === 1;
51
39 - if (listeners !== null) {
40 - const listenersArr = Array.from(listeners);
41 - for (let i = 0; i < listenersArr.length; i++) {
42 - const listener = listenersArr[i];
43 - const {
44 - callback,
45 - event: {capture, type},
46 - } = listener;
47 - if (type === targetType) {
48 - if (capture === true) {
49 - dispatchListeners.unshift(callback);
50 - dispatchInstances.unshift(node);
51 - } else {
52 - dispatchListeners.push(callback);
53 - dispatchInstances.push(node);
52 + if (isCapturePhase) {
53 + const captureListeners = Array.from(listeners.captured);
54 +
55 + for (let i = captureListeners.length - 1; i >= 0; i--) {
56 + const listener = captureListeners[i];
57 + const {callback} = listener;
58 + dispatchListeners.push(callback);
59 + dispatchInstances.push(targetContainer);
60 + }
61 + } else {
62 + const bubbleListeners = Array.from(listeners.bubbled);
63 +
64 + for (let i = 0; i < bubbleListeners.length; i++) {
65 + const listener = bubbleListeners[i];
66 + const {callback} = listener;
67 + dispatchListeners.push(callback);
68 + dispatchInstances.push(targetContainer);
69 + }
70 + }
71 + }
72 + }
73 + } else {
74 + const {bubbled, captured} = phasedRegistrationNames;
75 + // If we are not handling EventTarget only phase, then we're doing the
76 + // usual two phase accumulation using the React fiber tree to pick up
77 + // all relevant useEvent and on* prop events.
78 + let node = event._targetInst;
79 +
80 + // Accumulate all instances and listeners via the target -> root path.
81 + while (node !== null) {
82 + // We only care for listeners that are on HostComponents (i.e. <div>)
83 + if (node.tag === HostComponent) {
84 + // For useEvent listenrs
85 + if (enableUseEventAPI && accumulateUseEventListeners) {
86 + // useEvent event listeners
87 + const instance = node.stateNode;
88 + const targetType = event.type;
89 + const listeners = getListenersFromTarget(instance);
90 +
91 + if (listeners !== null) {
92 + const listenersArr = Array.from(listeners);
93 + for (let i = 0; i < listenersArr.length; i++) {
94 + const listener = listenersArr[i];
95 + const {
96 + callback,
97 + event: {capture, type},
98 + } = listener;
99 + if (type === targetType) {
100 + if (capture === true) {
101 + dispatchListeners.unshift(callback);
102 + dispatchInstances.unshift(node);
103 + } else {
104 + dispatchListeners.push(callback);
105 + dispatchInstances.push(node);
106 + }
107 }
108 }
109 }
110 }
58 - }
59 - // Standard React on* listeners, i.e. onClick prop
60 - if (captured !== null) {
61 - const captureListener = getListener(node, captured);
62 - if (captureListener != null) {
63 - // Capture listeners/instances should go at the start, so we
64 - // unshift them to the start of the array.
65 - dispatchListeners.unshift(captureListener);
66 - dispatchInstances.unshift(node);
111 + // Standard React on* listeners, i.e. onClick prop
112 + if (captured !== null) {
113 + const captureListener = getListener(node, captured);
114 + if (captureListener != null) {
115 + // Capture listeners/instances should go at the start, so we
116 + // unshift them to the start of the array.
117 + dispatchListeners.unshift(captureListener);
118 + dispatchInstances.unshift(node);
119 + }
120 }
68 - }
69 - if (bubbled !== null) {
70 - const bubbleListener = getListener(node, bubbled);
71 - if (bubbleListener != null) {
72 - // Bubble listeners/instances should go at the end, so we
73 - // push them to the end of the array.
74 - dispatchListeners.push(bubbleListener);
75 - dispatchInstances.push(node);
121 + if (bubbled !== null) {
122 + const bubbleListener = getListener(node, bubbled);
123 + if (bubbleListener != null) {
124 + // Bubble listeners/instances should go at the end, so we
125 + // push them to the end of the array.
126 + dispatchListeners.push(bubbleListener);
127 + dispatchInstances.push(node);
128 + }
129 }
130 }
131 + node = node.return;
132 }
79 - node = node.return;
133 }
134 // To prevent allocation to the event unless we actually
135 // have listeners we check the length of one of the arrays.