ReactDOM.useEvent: fix scope propagation issue (#18464)
Dominic Gannaway committed
Apr 2, 2020 at 18:26 UTC
5e464546af4a32c5ae5e704f68bcf4b98a4c440a
9 files changed
+235
-43
packages/legacy-events/EventPluginUtils.js
+2
-11
@@ -64,8 +64,7 @@ if (__DEV__) {
64
*/
65
export function executeDispatch(event, listener, inst) {
66
const type = event.type || 'unknown-event';
67
- event.currentTarget =
68
- inst.tag !== undefined ? getNodeFromInstance(inst) : inst;
67
+ event.currentTarget = getNodeFromInstance(inst);
68
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
69
event.currentTarget = null;
70
}
@@ -80,20 +79,12 @@ export function executeDispatchesInOrder(event) {
79
validateEventDispatches(event);
80
}
81
if (Array.isArray(dispatchListeners)) {
83
- let previousInstance;
82
for (let i = 0; i < dispatchListeners.length; i++) {
85
- const instance = dispatchInstances[i];
86
- // We check if the instance was the same as the last one,
87
- // if it was, then we're still on the same instance thus
88
- // propagation should not stop. If we add support for
89
- // stopImmediatePropagation at some point, then we'll
90
- // need to handle that case here differently.
91
- if (instance !== previousInstance && event.isPropagationStopped()) {
83
+ if (event.isPropagationStopped()) {
84
break;
85
}
86
// Listeners and Instances are two parallel arrays that are always in sync.
87
executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
96
- previousInstance = instance;
88
}
89
} else if (dispatchListeners) {
90
executeDispatch(event, dispatchListeners, dispatchInstances);
packages/legacy-events/ReactSyntheticEventType.js
+4
-1
@@ -40,8 +40,11 @@ export type ReactSyntheticEvent = {|
40
nativeEventTarget: EventTarget,
41
) => ReactSyntheticEvent,
42
isPersistent: () => boolean,
43
- _dispatchInstances: null | Array<Fiber | EventTarget> | Fiber | EventTarget,
43
+ isPropagationStopped: () => boolean,
44
+ _dispatchInstances: null | Array<Fiber | null> | Fiber,
45
_dispatchListeners: null | Array<Function> | Function,
46
+ _dispatchCurrentTargets: null | Array<EventTarget>,
47
_targetInst: Fiber,
48
type: string,
49
+ currentTarget: null | EventTarget,
50
|};
packages/legacy-events/SyntheticEvent.js
+2
@@ -78,6 +78,7 @@ function SyntheticEvent(
78
this.nativeEvent = nativeEvent;
79
this._dispatchListeners = null;
80
this._dispatchInstances = null;
81
+ this._dispatchCurrentTargets = null;
82
83
const Interface = this.constructor.Interface;
84
for (const propName in Interface) {
@@ -187,6 +188,7 @@ Object.assign(SyntheticEvent.prototype, {
188
this.isPropagationStopped = functionThatReturnsFalse;
189
this._dispatchListeners = null;
190
this._dispatchInstances = null;
191
+ this._dispatchCurrentTargets = null;
192
if (__DEV__) {
193
Object.defineProperty(
194
this,
packages/react-dom/src/events/DOMModernPluginEventSystem.js
+47
-1
@@ -25,7 +25,6 @@ import type {ReactDOMListener} from '../shared/ReactDOMTypes';
25
26
import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
27
import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
28
-import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
28
import {plugins} from 'legacy-events/EventPluginRegistry';
29
import {
30
PLUGIN_EVENT_SYSTEM,
@@ -92,6 +91,7 @@ import {
91
enableLegacyFBSupport,
92
enableUseEventAPI,
93
} from 'shared/ReactFeatureFlags';
94
+import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils';
95
96
const capturePhaseEvents = new Set([
97
TOP_FOCUS,
@@ -167,6 +167,52 @@ export const reactScopeListenerStore: WeakMap<
167
>,
168
> = new PossiblyWeakMap();
169
170
+function executeDispatch(
171
+ event: ReactSyntheticEvent,
172
+ listener: Function,
173
+ currentTarget: EventTarget,
174
+): void {
175
+ const type = event.type || 'unknown-event';
176
+ event.currentTarget = currentTarget;
177
+ invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
178
+ event.currentTarget = null;
179
+}
180
+
181
+function executeDispatchesInOrder(event: ReactSyntheticEvent): void {
182
+ // TODO we should remove _dispatchListeners and _dispatchInstances at some point.
183
+ const dispatchListeners = event._dispatchListeners;
184
+ const dispatchInstances = event._dispatchInstances;
185
+ const dispatchCurrentTargets = event._dispatchCurrentTargets;
186
+ let previousInstance;
187
+
188
+ if (
189
+ dispatchListeners !== null &&
190
+ dispatchInstances !== null &&
191
+ dispatchCurrentTargets !== null
192
+ ) {
193
+ for (let i = 0; i < dispatchListeners.length; i++) {
194
+ const instance = dispatchInstances[i];
195
+ const listener = dispatchListeners[i];
196
+ const currentTarget = dispatchCurrentTargets[i];
197
+
198
+ // We check if the instance was the same as the last one,
199
+ // if it was, then we're still on the same instance thus
200
+ // propagation should not stop. If we add support for
201
+ // stopImmediatePropagation at some point, then we'll
202
+ // need to handle that case here differently.
203
+ if (instance !== previousInstance && event.isPropagationStopped()) {
204
+ break;
205
+ }
206
+ // Listeners and Instances are two parallel arrays that are always in sync.
207
+ executeDispatch(event, listener, currentTarget);
208
+ previousInstance = instance;
209
+ }
210
+ }
211
+ event._dispatchListeners = null;
212
+ event._dispatchInstances = null;
213
+ event._dispatchCurrentTargets = null;
214
+}
215
+
216
function dispatchEventsForPlugins(
217
topLevelType: DOMTopLevelEventType,
218
eventSystemFlags: EventSystemFlags,
packages/react-dom/src/events/EventSystemFlags.js
new
+20
@@ -0,0 +1,20 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+export type EventSystemFlags = number;
11
+
12
+export const PLUGIN_EVENT_SYSTEM = 1;
13
+export const RESPONDER_EVENT_SYSTEM = 1 << 1;
14
+export const IS_PASSIVE = 1 << 2;
15
+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 IS_TARGET_EVENT_ONLY = 1 << 7;
20
+export const LEGACY_FB_SUPPORT = 1 << 8;
packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js
+106
@@ -2512,6 +2512,112 @@ describe('DOMModernPluginEventSystem', () => {
2512
// <button>, which is actually outside the scope.
2513
expect(clickEvent).toBeCalledTimes(0);
2514
});
2515
+
2516
+ it('handle stopPropagation (inner) correctly between scopes', () => {
2517
+ const buttonRef = React.createRef();
2518
+ const outerOnClick = jest.fn();
2519
+ const innerOnClick = jest.fn(e => e.stopPropagation());
2520
+ const TestScope = React.unstable_createScope();
2521
+ const TestScope2 = React.unstable_createScope();
2522
+
2523
+ function Test() {
2524
+ const click = ReactDOM.unstable_useEvent('click');
2525
+ const scopeRef = React.useRef(null);
2526
+ const scope2Ref = React.useRef(null);
2527
+
2528
+ React.useEffect(() => {
2529
+ click.setListener(scopeRef.current, outerOnClick);
2530
+ click.setListener(scope2Ref.current, innerOnClick);
2531
+ });
2532
+
2533
+ return (
2534
+ <TestScope ref={scopeRef}>
2535
+ <TestScope2 ref={scope2Ref}>
2536
+ <button ref={buttonRef} />
2537
+ </TestScope2>
2538
+ </TestScope>
2539
+ );
2540
+ }
2541
+
2542
+ ReactDOM.render(<Test />, container);
2543
+ Scheduler.unstable_flushAll();
2544
+
2545
+ const buttonElement = buttonRef.current;
2546
+ dispatchClickEvent(buttonElement);
2547
+
2548
+ expect(innerOnClick).toHaveBeenCalledTimes(1);
2549
+ expect(outerOnClick).toHaveBeenCalledTimes(0);
2550
+ });
2551
+
2552
+ it('handle stopPropagation (outer) correctly between scopes', () => {
2553
+ const buttonRef = React.createRef();
2554
+ const outerOnClick = jest.fn(e => e.stopPropagation());
2555
+ const innerOnClick = jest.fn();
2556
+ const TestScope = React.unstable_createScope();
2557
+ const TestScope2 = React.unstable_createScope();
2558
+
2559
+ function Test() {
2560
+ const click = ReactDOM.unstable_useEvent('click');
2561
+ const scopeRef = React.useRef(null);
2562
+ const scope2Ref = React.useRef(null);
2563
+
2564
+ React.useEffect(() => {
2565
+ click.setListener(scopeRef.current, outerOnClick);
2566
+ click.setListener(scope2Ref.current, innerOnClick);
2567
+ });
2568
+
2569
+ return (
2570
+ <TestScope ref={scopeRef}>
2571
+ <TestScope2 ref={scope2Ref}>
2572
+ <button ref={buttonRef} />
2573
+ </TestScope2>
2574
+ </TestScope>
2575
+ );
2576
+ }
2577
+
2578
+ ReactDOM.render(<Test />, container);
2579
+ Scheduler.unstable_flushAll();
2580
+
2581
+ const buttonElement = buttonRef.current;
2582
+ dispatchClickEvent(buttonElement);
2583
+
2584
+ expect(innerOnClick).toHaveBeenCalledTimes(1);
2585
+ expect(outerOnClick).toHaveBeenCalledTimes(1);
2586
+ });
2587
+
2588
+ it('handle stopPropagation (inner and outer) correctly between scopes', () => {
2589
+ const buttonRef = React.createRef();
2590
+ const onClick = jest.fn(e => e.stopPropagation());
2591
+ const TestScope = React.unstable_createScope();
2592
+ const TestScope2 = React.unstable_createScope();
2593
+
2594
+ function Test() {
2595
+ const click = ReactDOM.unstable_useEvent('click');
2596
+ const scopeRef = React.useRef(null);
2597
+ const scope2Ref = React.useRef(null);
2598
+
2599
+ React.useEffect(() => {
2600
+ click.setListener(scopeRef.current, onClick);
2601
+ click.setListener(scope2Ref.current, onClick);
2602
+ });
2603
+
2604
+ return (
2605
+ <TestScope ref={scopeRef}>
2606
+ <TestScope2 ref={scope2Ref}>
2607
+ <button ref={buttonRef} />
2608
+ </TestScope2>
2609
+ </TestScope>
2610
+ );
2611
+ }
2612
+
2613
+ ReactDOM.render(<Test />, container);
2614
+ Scheduler.unstable_flushAll();
2615
+
2616
+ const buttonElement = buttonRef.current;
2617
+ dispatchClickEvent(buttonElement);
2618
+
2619
+ expect(onClick).toHaveBeenCalledTimes(1);
2620
+ });
2621
});
2622
});
2623
},
packages/react-dom/src/events/accumulateEnterLeaveListeners.js
+15
-10
@@ -83,42 +83,47 @@ function accumulateEnterLeaveListenersForEvent(
83
}
84
const dispatchListeners = [];
85
const dispatchInstances = [];
86
+ const dispatchCurrentTargets = [];
87
87
- let node = target;
88
- while (node !== null) {
89
- if (node === common) {
88
+ let instance = target;
89
+ while (instance !== null) {
90
+ if (instance === common) {
91
break;
92
}
92
- const alternate = node.alternate;
93
+ const {alternate, stateNode, tag} = instance;
94
if (alternate !== null && alternate === common) {
95
break;
96
}
96
- if (node.tag === HostComponent) {
97
+ if (tag === HostComponent && stateNode !== null) {
98
+ const currentTarget = stateNode;
99
if (capture) {
98
- const captureListener = getListener(node, registrationName);
100
+ const captureListener = getListener(instance, registrationName);
101
if (captureListener != null) {
102
// Capture listeners/instances should go at the start, so we
103
// unshift them to the start of the array.
104
dispatchListeners.unshift(captureListener);
103
- dispatchInstances.unshift(node);
105
+ dispatchInstances.unshift(instance);
106
+ dispatchCurrentTargets.unshift(currentTarget);
107
}
108
} else {
106
- const bubbleListener = getListener(node, registrationName);
109
+ const bubbleListener = getListener(instance, registrationName);
110
if (bubbleListener != null) {
111
// Bubble listeners/instances should go at the end, so we
112
// push them to the end of the array.
113
dispatchListeners.push(bubbleListener);
111
- dispatchInstances.push(node);
114
+ dispatchInstances.push(instance);
115
+ dispatchCurrentTargets.push(currentTarget);
116
}
117
}
118
}
115
- node = node.return;
119
+ instance = instance.return;
120
}
121
// To prevent allocation to the event unless we actually
122
// have listeners we check the length of one of the arrays.
123
if (dispatchListeners.length > 0) {
124
event._dispatchListeners = dispatchListeners;
125
event._dispatchInstances = dispatchInstances;
126
+ event._dispatchCurrentTargets = dispatchCurrentTargets;
127
}
128
}
129
packages/react-dom/src/events/accumulateEventTargetListeners.js
+13
-4
@@ -14,11 +14,13 @@ import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem';
14
15
export default function accumulateEventTargetListeners(
16
event: ReactSyntheticEvent,
17
- targetContainer: EventTarget,
17
+ currentTarget: EventTarget,
18
): void {
19
const dispatchListeners = [];
20
const dispatchInstances = [];
21
- const eventTypeMap = eventTargetEventListenerStore.get(targetContainer);
21
+ const dispatchCurrentTargets = [];
22
+
23
+ const eventTypeMap = eventTargetEventListenerStore.get(currentTarget);
24
if (eventTypeMap !== undefined) {
25
const type = ((event.type: any): DOMTopLevelEventType);
26
const listeners = eventTypeMap.get(type);
@@ -32,7 +34,10 @@ export default function accumulateEventTargetListeners(
34
const listener = captureListeners[i];
35
const {callback} = listener;
36
dispatchListeners.push(callback);
35
- dispatchInstances.push(targetContainer);
37
+ // EventTarget listeners do not have instances, as there
38
+ // is no backing Fiber instance for them (window, document etc).
39
+ dispatchInstances.push(null);
40
+ dispatchCurrentTargets.push(currentTarget);
41
}
42
} else {
43
const bubbleListeners = Array.from(listeners.bubbled);
@@ -41,7 +46,10 @@ export default function accumulateEventTargetListeners(
46
const listener = bubbleListeners[i];
47
const {callback} = listener;
48
dispatchListeners.push(callback);
44
- dispatchInstances.push(targetContainer);
49
+ // EventTarget listeners do not have instances, as there
50
+ // is no backing Fiber instance for them (window, document etc).
51
+ dispatchInstances.push(null);
52
+ dispatchCurrentTargets.push(currentTarget);
53
}
54
}
55
}
@@ -51,5 +59,6 @@ export default function accumulateEventTargetListeners(
59
if (dispatchListeners.length > 0) {
60
event._dispatchListeners = dispatchListeners;
61
event._dispatchInstances = dispatchInstances;
62
+ event._dispatchCurrentTargets = dispatchCurrentTargets;
63
}
64
}
packages/react-dom/src/events/accumulateTwoPhaseListeners.js
+26
-16
@@ -31,20 +31,22 @@ export default function accumulateTwoPhaseListeners(
31
const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames;
32
const dispatchListeners = [];
33
const dispatchInstances = [];
34
+ const dispatchCurrentTargets = [];
35
36
const {bubbled, captured} = phasedRegistrationNames;
37
// If we are not handling EventTarget only phase, then we're doing the
38
// usual two phase accumulation using the React fiber tree to pick up
39
// all relevant useEvent and on* prop events.
39
- let node = event._targetInst;
40
+ let instance = event._targetInst;
41
let lastHostComponent = null;
42
43
// Accumulate all instances and listeners via the target -> root path.
43
- while (node !== null) {
44
- const {stateNode: instance, tag} = node;
44
+ while (instance !== null) {
45
+ const {stateNode, tag} = instance;
46
// Handle listeners that are on HostComponents (i.e. <div>)
46
- if (instance !== null && tag === HostComponent) {
47
- lastHostComponent = instance;
47
+ if (tag === HostComponent && stateNode !== null) {
48
+ const currentTarget = stateNode;
49
+ lastHostComponent = currentTarget;
50
// For useEvent listenrs
51
if (
52
enableModernEventSystem &&
@@ -53,7 +55,7 @@ export default function accumulateTwoPhaseListeners(
55
) {
56
// useEvent event listeners
57
const targetType = event.type;
56
- const listeners = getListenersFromTarget(instance);
58
+ const listeners = getListenersFromTarget(currentTarget);
59
60
if (listeners !== null) {
61
const listenersArr = Array.from(listeners);
@@ -66,10 +68,12 @@ export default function accumulateTwoPhaseListeners(
68
if (type === targetType) {
69
if (capture === true) {
70
dispatchListeners.unshift(callback);
69
- dispatchInstances.unshift(node);
71
+ dispatchInstances.unshift(instance);
72
+ dispatchCurrentTargets.unshift(currentTarget);
73
} else {
74
dispatchListeners.push(callback);
72
- dispatchInstances.push(node);
75
+ dispatchInstances.push(instance);
76
+ dispatchCurrentTargets.push(currentTarget);
77
}
78
}
79
}
@@ -77,21 +81,23 @@ export default function accumulateTwoPhaseListeners(
81
}
82
// Standard React on* listeners, i.e. onClick prop
83
if (captured !== null) {
80
- const captureListener = getListener(node, captured);
84
+ const captureListener = getListener(instance, captured);
85
if (captureListener != null) {
86
// Capture listeners/instances should go at the start, so we
87
// unshift them to the start of the array.
88
dispatchListeners.unshift(captureListener);
85
- dispatchInstances.unshift(node);
89
+ dispatchInstances.unshift(instance);
90
+ dispatchCurrentTargets.unshift(currentTarget);
91
}
92
}
93
if (bubbled !== null) {
89
- const bubbleListener = getListener(node, bubbled);
94
+ const bubbleListener = getListener(instance, bubbled);
95
if (bubbleListener != null) {
96
// Bubble listeners/instances should go at the end, so we
97
// push them to the end of the array.
98
dispatchListeners.push(bubbleListener);
94
- dispatchInstances.push(node);
99
+ dispatchInstances.push(instance);
100
+ dispatchCurrentTargets.push(currentTarget);
101
}
102
}
103
}
@@ -103,7 +109,7 @@ export default function accumulateTwoPhaseListeners(
109
tag === ScopeComponent &&
110
lastHostComponent !== null
111
) {
106
- const reactScope = instance.methods;
112
+ const reactScope = stateNode.methods;
113
const eventTypeMap = reactScopeListenerStore.get(reactScope);
114
if (eventTypeMap !== undefined) {
115
const type = ((event.type: any): DOMTopLevelEventType);
@@ -111,23 +117,26 @@ export default function accumulateTwoPhaseListeners(
117
if (listeners !== undefined) {
118
const captureListeners = Array.from(listeners.captured);
119
const bubbleListeners = Array.from(listeners.bubbled);
120
+ const lastCurrentTarget = ((lastHostComponent: any): Element);
121
122
for (let i = 0; i < captureListeners.length; i++) {
123
const listener = captureListeners[i];
124
const {callback} = listener;
125
dispatchListeners.unshift(callback);
119
- dispatchInstances.unshift(((lastHostComponent: any): Element));
126
+ dispatchInstances.unshift(instance);
127
+ dispatchCurrentTargets.unshift(lastCurrentTarget);
128
}
129
for (let i = 0; i < bubbleListeners.length; i++) {
130
const listener = bubbleListeners[i];
131
const {callback} = listener;
132
dispatchListeners.push(callback);
125
- dispatchInstances.push(((lastHostComponent: any): Element));
133
+ dispatchInstances.push(instance);
134
+ dispatchCurrentTargets.push(lastCurrentTarget);
135
}
136
}
137
}
138
}
130
- node = node.return;
139
+ instance = instance.return;
140
}
141
142
// To prevent allocation to the event unless we actually
@@ -135,5 +144,6 @@ export default function accumulateTwoPhaseListeners(
144
if (dispatchListeners.length > 0) {
145
event._dispatchListeners = dispatchListeners;
146
event._dispatchInstances = dispatchInstances;
147
+ event._dispatchCurrentTargets = dispatchCurrentTargets;
148
}
149
}