ReactDOM.useEvent: fix scope propagation issue (#18464)

This commit is contained in:
Dominic Gannaway
2020-04-02 18:26:56 +01:00
committed by GitHub
parent d5e4b3ae1d
commit 5e464546af
9 changed files with 235 additions and 43 deletions
+2 -11
View File
@@ -64,8 +64,7 @@ if (__DEV__) {
*/
export function executeDispatch(event, listener, inst) {
const type = event.type || 'unknown-event';
event.currentTarget =
inst.tag !== undefined ? getNodeFromInstance(inst) : inst;
event.currentTarget = getNodeFromInstance(inst);
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
event.currentTarget = null;
}
@@ -80,20 +79,12 @@ export function executeDispatchesInOrder(event) {
validateEventDispatches(event);
}
if (Array.isArray(dispatchListeners)) {
let previousInstance;
for (let i = 0; i < dispatchListeners.length; i++) {
const instance = dispatchInstances[i];
// We check if the instance was the same as the last one,
// if it was, then we're still on the same instance thus
// propagation should not stop. If we add support for
// stopImmediatePropagation at some point, then we'll
// need to handle that case here differently.
if (instance !== previousInstance && event.isPropagationStopped()) {
if (event.isPropagationStopped()) {
break;
}
// Listeners and Instances are two parallel arrays that are always in sync.
executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
previousInstance = instance;
}
} else if (dispatchListeners) {
executeDispatch(event, dispatchListeners, dispatchInstances);
@@ -40,8 +40,11 @@ export type ReactSyntheticEvent = {|
nativeEventTarget: EventTarget,
) => ReactSyntheticEvent,
isPersistent: () => boolean,
_dispatchInstances: null | Array<Fiber | EventTarget> | Fiber | EventTarget,
isPropagationStopped: () => boolean,
_dispatchInstances: null | Array<Fiber | null> | Fiber,
_dispatchListeners: null | Array<Function> | Function,
_dispatchCurrentTargets: null | Array<EventTarget>,
_targetInst: Fiber,
type: string,
currentTarget: null | EventTarget,
|};
+2
View File
@@ -78,6 +78,7 @@ function SyntheticEvent(
this.nativeEvent = nativeEvent;
this._dispatchListeners = null;
this._dispatchInstances = null;
this._dispatchCurrentTargets = null;
const Interface = this.constructor.Interface;
for (const propName in Interface) {
@@ -187,6 +188,7 @@ Object.assign(SyntheticEvent.prototype, {
this.isPropagationStopped = functionThatReturnsFalse;
this._dispatchListeners = null;
this._dispatchInstances = null;
this._dispatchCurrentTargets = null;
if (__DEV__) {
Object.defineProperty(
this,
+47 -1
View File
@@ -25,7 +25,6 @@ import type {ReactDOMListener} from '../shared/ReactDOMTypes';
import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
import {plugins} from 'legacy-events/EventPluginRegistry';
import {
PLUGIN_EVENT_SYSTEM,
@@ -92,6 +91,7 @@ import {
enableLegacyFBSupport,
enableUseEventAPI,
} from 'shared/ReactFeatureFlags';
import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils';
const capturePhaseEvents = new Set([
TOP_FOCUS,
@@ -167,6 +167,52 @@ export const reactScopeListenerStore: WeakMap<
>,
> = new PossiblyWeakMap();
function executeDispatch(
event: ReactSyntheticEvent,
listener: Function,
currentTarget: EventTarget,
): void {
const type = event.type || 'unknown-event';
event.currentTarget = currentTarget;
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
event.currentTarget = null;
}
function executeDispatchesInOrder(event: ReactSyntheticEvent): void {
// TODO we should remove _dispatchListeners and _dispatchInstances at some point.
const dispatchListeners = event._dispatchListeners;
const dispatchInstances = event._dispatchInstances;
const dispatchCurrentTargets = event._dispatchCurrentTargets;
let previousInstance;
if (
dispatchListeners !== null &&
dispatchInstances !== null &&
dispatchCurrentTargets !== null
) {
for (let i = 0; i < dispatchListeners.length; i++) {
const instance = dispatchInstances[i];
const listener = dispatchListeners[i];
const currentTarget = dispatchCurrentTargets[i];
// We check if the instance was the same as the last one,
// if it was, then we're still on the same instance thus
// propagation should not stop. If we add support for
// stopImmediatePropagation at some point, then we'll
// need to handle that case here differently.
if (instance !== previousInstance && event.isPropagationStopped()) {
break;
}
// Listeners and Instances are two parallel arrays that are always in sync.
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}
event._dispatchListeners = null;
event._dispatchInstances = null;
event._dispatchCurrentTargets = null;
}
function dispatchEventsForPlugins(
topLevelType: DOMTopLevelEventType,
eventSystemFlags: EventSystemFlags,
+20
View File
@@ -0,0 +1,20 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export type EventSystemFlags = number;
export const PLUGIN_EVENT_SYSTEM = 1;
export const RESPONDER_EVENT_SYSTEM = 1 << 1;
export const IS_PASSIVE = 1 << 2;
export const IS_ACTIVE = 1 << 3;
export const PASSIVE_NOT_SUPPORTED = 1 << 4;
export const IS_REPLAYED = 1 << 5;
export const IS_FIRST_ANCESTOR = 1 << 6;
export const IS_TARGET_EVENT_ONLY = 1 << 7;
export const LEGACY_FB_SUPPORT = 1 << 8;
@@ -2512,6 +2512,112 @@ describe('DOMModernPluginEventSystem', () => {
// <button>, which is actually outside the scope.
expect(clickEvent).toBeCalledTimes(0);
});
it('handle stopPropagation (inner) correctly between scopes', () => {
const buttonRef = React.createRef();
const outerOnClick = jest.fn();
const innerOnClick = jest.fn(e => e.stopPropagation());
const TestScope = React.unstable_createScope();
const TestScope2 = React.unstable_createScope();
function Test() {
const click = ReactDOM.unstable_useEvent('click');
const scopeRef = React.useRef(null);
const scope2Ref = React.useRef(null);
React.useEffect(() => {
click.setListener(scopeRef.current, outerOnClick);
click.setListener(scope2Ref.current, innerOnClick);
});
return (
<TestScope ref={scopeRef}>
<TestScope2 ref={scope2Ref}>
<button ref={buttonRef} />
</TestScope2>
</TestScope>
);
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
const buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(innerOnClick).toHaveBeenCalledTimes(1);
expect(outerOnClick).toHaveBeenCalledTimes(0);
});
it('handle stopPropagation (outer) correctly between scopes', () => {
const buttonRef = React.createRef();
const outerOnClick = jest.fn(e => e.stopPropagation());
const innerOnClick = jest.fn();
const TestScope = React.unstable_createScope();
const TestScope2 = React.unstable_createScope();
function Test() {
const click = ReactDOM.unstable_useEvent('click');
const scopeRef = React.useRef(null);
const scope2Ref = React.useRef(null);
React.useEffect(() => {
click.setListener(scopeRef.current, outerOnClick);
click.setListener(scope2Ref.current, innerOnClick);
});
return (
<TestScope ref={scopeRef}>
<TestScope2 ref={scope2Ref}>
<button ref={buttonRef} />
</TestScope2>
</TestScope>
);
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
const buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(innerOnClick).toHaveBeenCalledTimes(1);
expect(outerOnClick).toHaveBeenCalledTimes(1);
});
it('handle stopPropagation (inner and outer) correctly between scopes', () => {
const buttonRef = React.createRef();
const onClick = jest.fn(e => e.stopPropagation());
const TestScope = React.unstable_createScope();
const TestScope2 = React.unstable_createScope();
function Test() {
const click = ReactDOM.unstable_useEvent('click');
const scopeRef = React.useRef(null);
const scope2Ref = React.useRef(null);
React.useEffect(() => {
click.setListener(scopeRef.current, onClick);
click.setListener(scope2Ref.current, onClick);
});
return (
<TestScope ref={scopeRef}>
<TestScope2 ref={scope2Ref}>
<button ref={buttonRef} />
</TestScope2>
</TestScope>
);
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
const buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
});
},
@@ -83,42 +83,47 @@ function accumulateEnterLeaveListenersForEvent(
}
const dispatchListeners = [];
const dispatchInstances = [];
const dispatchCurrentTargets = [];
let node = target;
while (node !== null) {
if (node === common) {
let instance = target;
while (instance !== null) {
if (instance === common) {
break;
}
const alternate = node.alternate;
const {alternate, stateNode, tag} = instance;
if (alternate !== null && alternate === common) {
break;
}
if (node.tag === HostComponent) {
if (tag === HostComponent && stateNode !== null) {
const currentTarget = stateNode;
if (capture) {
const captureListener = getListener(node, registrationName);
const captureListener = getListener(instance, registrationName);
if (captureListener != null) {
// Capture listeners/instances should go at the start, so we
// unshift them to the start of the array.
dispatchListeners.unshift(captureListener);
dispatchInstances.unshift(node);
dispatchInstances.unshift(instance);
dispatchCurrentTargets.unshift(currentTarget);
}
} else {
const bubbleListener = getListener(node, registrationName);
const bubbleListener = getListener(instance, registrationName);
if (bubbleListener != null) {
// Bubble listeners/instances should go at the end, so we
// push them to the end of the array.
dispatchListeners.push(bubbleListener);
dispatchInstances.push(node);
dispatchInstances.push(instance);
dispatchCurrentTargets.push(currentTarget);
}
}
}
node = node.return;
instance = instance.return;
}
// To prevent allocation to the event unless we actually
// have listeners we check the length of one of the arrays.
if (dispatchListeners.length > 0) {
event._dispatchListeners = dispatchListeners;
event._dispatchInstances = dispatchInstances;
event._dispatchCurrentTargets = dispatchCurrentTargets;
}
}
@@ -14,11 +14,13 @@ import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem';
export default function accumulateEventTargetListeners(
event: ReactSyntheticEvent,
targetContainer: EventTarget,
currentTarget: EventTarget,
): void {
const dispatchListeners = [];
const dispatchInstances = [];
const eventTypeMap = eventTargetEventListenerStore.get(targetContainer);
const dispatchCurrentTargets = [];
const eventTypeMap = eventTargetEventListenerStore.get(currentTarget);
if (eventTypeMap !== undefined) {
const type = ((event.type: any): DOMTopLevelEventType);
const listeners = eventTypeMap.get(type);
@@ -32,7 +34,10 @@ export default function accumulateEventTargetListeners(
const listener = captureListeners[i];
const {callback} = listener;
dispatchListeners.push(callback);
dispatchInstances.push(targetContainer);
// EventTarget listeners do not have instances, as there
// is no backing Fiber instance for them (window, document etc).
dispatchInstances.push(null);
dispatchCurrentTargets.push(currentTarget);
}
} else {
const bubbleListeners = Array.from(listeners.bubbled);
@@ -41,7 +46,10 @@ export default function accumulateEventTargetListeners(
const listener = bubbleListeners[i];
const {callback} = listener;
dispatchListeners.push(callback);
dispatchInstances.push(targetContainer);
// EventTarget listeners do not have instances, as there
// is no backing Fiber instance for them (window, document etc).
dispatchInstances.push(null);
dispatchCurrentTargets.push(currentTarget);
}
}
}
@@ -51,5 +59,6 @@ export default function accumulateEventTargetListeners(
if (dispatchListeners.length > 0) {
event._dispatchListeners = dispatchListeners;
event._dispatchInstances = dispatchInstances;
event._dispatchCurrentTargets = dispatchCurrentTargets;
}
}
+26 -16
View File
@@ -31,20 +31,22 @@ export default function accumulateTwoPhaseListeners(
const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames;
const dispatchListeners = [];
const dispatchInstances = [];
const dispatchCurrentTargets = [];
const {bubbled, captured} = phasedRegistrationNames;
// If we are not handling EventTarget only phase, then we're doing the
// usual two phase accumulation using the React fiber tree to pick up
// all relevant useEvent and on* prop events.
let node = event._targetInst;
let instance = event._targetInst;
let lastHostComponent = null;
// Accumulate all instances and listeners via the target -> root path.
while (node !== null) {
const {stateNode: instance, tag} = node;
while (instance !== null) {
const {stateNode, tag} = instance;
// Handle listeners that are on HostComponents (i.e. <div>)
if (instance !== null && tag === HostComponent) {
lastHostComponent = instance;
if (tag === HostComponent && stateNode !== null) {
const currentTarget = stateNode;
lastHostComponent = currentTarget;
// For useEvent listenrs
if (
enableModernEventSystem &&
@@ -53,7 +55,7 @@ export default function accumulateTwoPhaseListeners(
) {
// useEvent event listeners
const targetType = event.type;
const listeners = getListenersFromTarget(instance);
const listeners = getListenersFromTarget(currentTarget);
if (listeners !== null) {
const listenersArr = Array.from(listeners);
@@ -66,10 +68,12 @@ export default function accumulateTwoPhaseListeners(
if (type === targetType) {
if (capture === true) {
dispatchListeners.unshift(callback);
dispatchInstances.unshift(node);
dispatchInstances.unshift(instance);
dispatchCurrentTargets.unshift(currentTarget);
} else {
dispatchListeners.push(callback);
dispatchInstances.push(node);
dispatchInstances.push(instance);
dispatchCurrentTargets.push(currentTarget);
}
}
}
@@ -77,21 +81,23 @@ export default function accumulateTwoPhaseListeners(
}
// Standard React on* listeners, i.e. onClick prop
if (captured !== null) {
const captureListener = getListener(node, captured);
const captureListener = getListener(instance, captured);
if (captureListener != null) {
// Capture listeners/instances should go at the start, so we
// unshift them to the start of the array.
dispatchListeners.unshift(captureListener);
dispatchInstances.unshift(node);
dispatchInstances.unshift(instance);
dispatchCurrentTargets.unshift(currentTarget);
}
}
if (bubbled !== null) {
const bubbleListener = getListener(node, bubbled);
const bubbleListener = getListener(instance, bubbled);
if (bubbleListener != null) {
// Bubble listeners/instances should go at the end, so we
// push them to the end of the array.
dispatchListeners.push(bubbleListener);
dispatchInstances.push(node);
dispatchInstances.push(instance);
dispatchCurrentTargets.push(currentTarget);
}
}
}
@@ -103,7 +109,7 @@ export default function accumulateTwoPhaseListeners(
tag === ScopeComponent &&
lastHostComponent !== null
) {
const reactScope = instance.methods;
const reactScope = stateNode.methods;
const eventTypeMap = reactScopeListenerStore.get(reactScope);
if (eventTypeMap !== undefined) {
const type = ((event.type: any): DOMTopLevelEventType);
@@ -111,23 +117,26 @@ export default function accumulateTwoPhaseListeners(
if (listeners !== undefined) {
const captureListeners = Array.from(listeners.captured);
const bubbleListeners = Array.from(listeners.bubbled);
const lastCurrentTarget = ((lastHostComponent: any): Element);
for (let i = 0; i < captureListeners.length; i++) {
const listener = captureListeners[i];
const {callback} = listener;
dispatchListeners.unshift(callback);
dispatchInstances.unshift(((lastHostComponent: any): Element));
dispatchInstances.unshift(instance);
dispatchCurrentTargets.unshift(lastCurrentTarget);
}
for (let i = 0; i < bubbleListeners.length; i++) {
const listener = bubbleListeners[i];
const {callback} = listener;
dispatchListeners.push(callback);
dispatchInstances.push(((lastHostComponent: any): Element));
dispatchInstances.push(instance);
dispatchCurrentTargets.push(lastCurrentTarget);
}
}
}
}
node = node.return;
instance = instance.return;
}
// To prevent allocation to the event unless we actually
@@ -135,5 +144,6 @@ export default function accumulateTwoPhaseListeners(
if (dispatchListeners.length > 0) {
event._dispatchListeners = dispatchListeners;
event._dispatchInstances = dispatchInstances;
event._dispatchCurrentTargets = dispatchCurrentTargets;
}
}