Skip to content

Commit 5e46454

Browse files
authored
ReactDOM.useEvent: fix scope propagation issue (#18464)
1 parent d5e4b3a commit 5e46454

9 files changed

Lines changed: 235 additions & 43 deletions

packages/legacy-events/EventPluginUtils.js

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ if (__DEV__) {
6464
*/
6565
export function executeDispatch(event, listener, inst) {
6666
const type = event.type || 'unknown-event';
67-
event.currentTarget =
68-
inst.tag !== undefined ? getNodeFromInstance(inst) : inst;
67+
event.currentTarget = getNodeFromInstance(inst);
6968
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
7069
event.currentTarget = null;
7170
}
@@ -80,20 +79,12 @@ export function executeDispatchesInOrder(event) {
8079
validateEventDispatches(event);
8180
}
8281
if (Array.isArray(dispatchListeners)) {
83-
let previousInstance;
8482
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()) {
9284
break;
9385
}
9486
// Listeners and Instances are two parallel arrays that are always in sync.
9587
executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
96-
previousInstance = instance;
9788
}
9889
} else if (dispatchListeners) {
9990
executeDispatch(event, dispatchListeners, dispatchInstances);

packages/legacy-events/ReactSyntheticEventType.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ export type ReactSyntheticEvent = {|
4040
nativeEventTarget: EventTarget,
4141
) => ReactSyntheticEvent,
4242
isPersistent: () => boolean,
43-
_dispatchInstances: null | Array<Fiber | EventTarget> | Fiber | EventTarget,
43+
isPropagationStopped: () => boolean,
44+
_dispatchInstances: null | Array<Fiber | null> | Fiber,
4445
_dispatchListeners: null | Array<Function> | Function,
46+
_dispatchCurrentTargets: null | Array<EventTarget>,
4547
_targetInst: Fiber,
4648
type: string,
49+
currentTarget: null | EventTarget,
4750
|};

packages/legacy-events/SyntheticEvent.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function SyntheticEvent(
7878
this.nativeEvent = nativeEvent;
7979
this._dispatchListeners = null;
8080
this._dispatchInstances = null;
81+
this._dispatchCurrentTargets = null;
8182

8283
const Interface = this.constructor.Interface;
8384
for (const propName in Interface) {
@@ -187,6 +188,7 @@ Object.assign(SyntheticEvent.prototype, {
187188
this.isPropagationStopped = functionThatReturnsFalse;
188189
this._dispatchListeners = null;
189190
this._dispatchInstances = null;
191+
this._dispatchCurrentTargets = null;
190192
if (__DEV__) {
191193
Object.defineProperty(
192194
this,

packages/react-dom/src/events/DOMModernPluginEventSystem.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import type {ReactDOMListener} from '../shared/ReactDOMTypes';
2525

2626
import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
2727
import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
28-
import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
2928
import {plugins} from 'legacy-events/EventPluginRegistry';
3029
import {
3130
PLUGIN_EVENT_SYSTEM,
@@ -92,6 +91,7 @@ import {
9291
enableLegacyFBSupport,
9392
enableUseEventAPI,
9493
} from 'shared/ReactFeatureFlags';
94+
import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils';
9595

9696
const capturePhaseEvents = new Set([
9797
TOP_FOCUS,
@@ -167,6 +167,52 @@ export const reactScopeListenerStore: WeakMap<
167167
>,
168168
> = new PossiblyWeakMap();
169169

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+
170216
function dispatchEventsForPlugins(
171217
topLevelType: DOMTopLevelEventType,
172218
eventSystemFlags: EventSystemFlags,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,6 +2512,112 @@ describe('DOMModernPluginEventSystem', () => {
25122512
// <button>, which is actually outside the scope.
25132513
expect(clickEvent).toBeCalledTimes(0);
25142514
});
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+
});
25152621
});
25162622
});
25172623
},

packages/react-dom/src/events/accumulateEnterLeaveListeners.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,42 +83,47 @@ function accumulateEnterLeaveListenersForEvent(
8383
}
8484
const dispatchListeners = [];
8585
const dispatchInstances = [];
86+
const dispatchCurrentTargets = [];
8687

87-
let node = target;
88-
while (node !== null) {
89-
if (node === common) {
88+
let instance = target;
89+
while (instance !== null) {
90+
if (instance === common) {
9091
break;
9192
}
92-
const alternate = node.alternate;
93+
const {alternate, stateNode, tag} = instance;
9394
if (alternate !== null && alternate === common) {
9495
break;
9596
}
96-
if (node.tag === HostComponent) {
97+
if (tag === HostComponent && stateNode !== null) {
98+
const currentTarget = stateNode;
9799
if (capture) {
98-
const captureListener = getListener(node, registrationName);
100+
const captureListener = getListener(instance, registrationName);
99101
if (captureListener != null) {
100102
// Capture listeners/instances should go at the start, so we
101103
// unshift them to the start of the array.
102104
dispatchListeners.unshift(captureListener);
103-
dispatchInstances.unshift(node);
105+
dispatchInstances.unshift(instance);
106+
dispatchCurrentTargets.unshift(currentTarget);
104107
}
105108
} else {
106-
const bubbleListener = getListener(node, registrationName);
109+
const bubbleListener = getListener(instance, registrationName);
107110
if (bubbleListener != null) {
108111
// Bubble listeners/instances should go at the end, so we
109112
// push them to the end of the array.
110113
dispatchListeners.push(bubbleListener);
111-
dispatchInstances.push(node);
114+
dispatchInstances.push(instance);
115+
dispatchCurrentTargets.push(currentTarget);
112116
}
113117
}
114118
}
115-
node = node.return;
119+
instance = instance.return;
116120
}
117121
// To prevent allocation to the event unless we actually
118122
// have listeners we check the length of one of the arrays.
119123
if (dispatchListeners.length > 0) {
120124
event._dispatchListeners = dispatchListeners;
121125
event._dispatchInstances = dispatchInstances;
126+
event._dispatchCurrentTargets = dispatchCurrentTargets;
122127
}
123128
}
124129

packages/react-dom/src/events/accumulateEventTargetListeners.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem';
1414

1515
export default function accumulateEventTargetListeners(
1616
event: ReactSyntheticEvent,
17-
targetContainer: EventTarget,
17+
currentTarget: EventTarget,
1818
): void {
1919
const dispatchListeners = [];
2020
const dispatchInstances = [];
21-
const eventTypeMap = eventTargetEventListenerStore.get(targetContainer);
21+
const dispatchCurrentTargets = [];
22+
23+
const eventTypeMap = eventTargetEventListenerStore.get(currentTarget);
2224
if (eventTypeMap !== undefined) {
2325
const type = ((event.type: any): DOMTopLevelEventType);
2426
const listeners = eventTypeMap.get(type);
@@ -32,7 +34,10 @@ export default function accumulateEventTargetListeners(
3234
const listener = captureListeners[i];
3335
const {callback} = listener;
3436
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);
3641
}
3742
} else {
3843
const bubbleListeners = Array.from(listeners.bubbled);
@@ -41,7 +46,10 @@ export default function accumulateEventTargetListeners(
4146
const listener = bubbleListeners[i];
4247
const {callback} = listener;
4348
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);
4553
}
4654
}
4755
}
@@ -51,5 +59,6 @@ export default function accumulateEventTargetListeners(
5159
if (dispatchListeners.length > 0) {
5260
event._dispatchListeners = dispatchListeners;
5361
event._dispatchInstances = dispatchInstances;
62+
event._dispatchCurrentTargets = dispatchCurrentTargets;
5463
}
5564
}

0 commit comments

Comments
 (0)