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.