@samitouri / QOS-React-2 / commits / 5e464546af

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 }