ReactDOM.useEvent: add EventTarget support (#18355)

* ReactDOM.useEvent: add support for all EventTarget types
This commit is contained in:
Dominic Gannaway
2020-03-20 18:31:47 +00:00
committed by GitHub
parent dbd85a08d9
commit a600408b28
9 changed files with 598 additions and 202 deletions
+2 -1
View File
@@ -64,7 +64,8 @@ if (__DEV__) {
*/
export function executeDispatch(event, listener, inst) {
const type = event.type || 'unknown-event';
event.currentTarget = getNodeFromInstance(inst);
event.currentTarget =
inst.tag !== undefined ? getNodeFromInstance(inst) : inst;
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
event.currentTarget = null;
}
+2 -1
View File
@@ -16,4 +16,5 @@ 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 LEGACY_FB_SUPPORT = 1 << 7;
export const IS_TARGET_EVENT_ONLY = 1 << 7;
export const LEGACY_FB_SUPPORT = 1 << 8;
@@ -39,7 +39,7 @@ export type ReactSyntheticEvent = {|
nativeEventTarget: EventTarget,
) => ReactSyntheticEvent,
isPersistent: () => boolean,
_dispatchInstances: null | Array<Fiber> | Fiber,
_dispatchInstances: null | Array<Fiber | EventTarget> | Fiber | EventTarget,
_dispatchListeners: null | Array<Function> | Function,
_targetInst: Fiber,
type: string,
+17 -25
View File
@@ -75,11 +75,13 @@ import {
IS_PASSIVE,
} from 'legacy-events/EventSystemFlags';
import {
attachElementListener,
detachElementListener,
isDOMDocument,
isDOMElement,
isManagedDOMElement,
isValidEventTarget,
listenToTopLevelEvent,
detachListenerFromManagedDOMElement,
attachListenerFromManagedDOMElement,
detachTargetEventListener,
attachTargetEventListener,
} from '../events/DOMModernPluginEventSystem';
import {getListenerMapForElement} from '../events/DOMEventListenerMap';
@@ -1122,12 +1124,10 @@ export function registerEvent(
export function mountEventListener(listener: ReactDOMListener): void {
if (enableUseEventAPI) {
const {target} = listener;
if (target === window) {
// TODO (useEvent)
} else if (isDOMDocument(target)) {
// TODO (useEvent)
} else if (isDOMElement(target)) {
attachElementListener(listener);
if (isManagedDOMElement(target)) {
attachListenerFromManagedDOMElement(listener);
} else {
attachTargetEventListener(listener);
}
}
}
@@ -1135,12 +1135,10 @@ export function mountEventListener(listener: ReactDOMListener): void {
export function unmountEventListener(listener: ReactDOMListener): void {
if (enableUseEventAPI) {
const {target} = listener;
if (target === window) {
// TODO (useEvent)
} else if (isDOMDocument(target)) {
// TODO (useEvent)
} else if (isDOMElement(target)) {
detachElementListener(listener);
if (isManagedDOMElement(target)) {
detachListenerFromManagedDOMElement(listener);
} else {
detachTargetEventListener(listener);
}
}
}
@@ -1152,10 +1150,7 @@ export function validateEventListenerTarget(
if (enableUseEventAPI) {
if (
target != null &&
(target === window ||
isDOMDocument(target) ||
(isDOMElement(target) &&
getClosestInstanceFromNode(((target: any): Element))))
(isManagedDOMElement(target) || isValidEventTarget(target))
) {
if (listener == null || typeof listener === 'function') {
return true;
@@ -1169,11 +1164,8 @@ export function validateEventListenerTarget(
}
if (__DEV__) {
console.warn(
'Event listener method setListener() from useEvent() hook requires the first argument to be either:' +
'\n\n' +
'1. A valid DOM node that was rendered and managed by React\n' +
'2. The "window" object\n' +
'3. The "document" object',
'Event listener method setListener() from useEvent() hook requires the first argument to be ' +
'a valid DOM EventTarget. If using a ref, ensure the current value is not null.',
);
}
}
+5 -5
View File
@@ -12,11 +12,11 @@ import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
// prettier-ignore
const elementListenerMap:
// $FlowFixMe Work around Flow bug
| WeakMap
| Map<EventTarget, ElementListenerMap> = new PossiblyWeakMap();
// $FlowFixMe: Flow cannot handle polymorphic WeakMaps
const elementListenerMap: WeakMap<
EventTarget,
ElementListenerMap,
> = new PossiblyWeakMap();
export type ElementListenerMap = Map<
DOMTopLevelEventType | string,
+186 -117
View File
@@ -27,7 +27,11 @@ 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 {LEGACY_FB_SUPPORT, IS_REPLAYED} from 'legacy-events/EventSystemFlags';
import {
LEGACY_FB_SUPPORT,
IS_REPLAYED,
IS_TARGET_EVENT_ONLY,
} from 'legacy-events/EventSystemFlags';
import {HostRoot, HostPortal} from 'shared/ReactWorkTags';
@@ -77,11 +81,7 @@ import {
getListenersFromTarget,
initListenersSet,
} from '../client/ReactDOMComponentTree';
import {
DOCUMENT_NODE,
COMMENT_NODE,
ELEMENT_NODE,
} from '../shared/HTMLNodeType';
import {COMMENT_NODE} from '../shared/HTMLNodeType';
import {topLevelEventsToDispatchConfig} from './DOMEventProperties';
import {enableLegacyFBSupport} from 'shared/ReactFeatureFlags';
@@ -132,6 +132,17 @@ const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = {
const isArray = Array.isArray;
// $FlowFixMe: Flow struggles with this pattern
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
// $FlowFixMe: Flow cannot handle polymorphic WeakMaps
export const eventTargetEventListenerStore: WeakMap<
EventTarget,
Map<
DOMTopLevelEventType,
{bubbled: Set<ReactDOMListener>, captured: Set<ReactDOMListener>},
>,
> = new PossiblyWeakMap();
function dispatchEventsForPlugins(
topLevelType: DOMTopLevelEventType,
eventSystemFlags: EventSystemFlags,
@@ -198,11 +209,27 @@ export function listenToTopLevelEvent(
listenerMap: ElementListenerMap,
passive?: boolean,
priority?: EventPriority,
capture?: boolean,
): void {
const listenerEntry = listenerMap.get(topLevelType);
// If we explicitly define capture, then these are for EventTarget objects,
// rather than React managed DOM elements. So we need to ensure we separate
// capture and non-capture events. For React managed DOM nodes we only use
// one or the other, never both. Which one we use is determined by the the
// capturePhaseEvents Set (in this module) that defines if the event listener
// should use the capture phase otherwise we always use the bubble phase.
// Finally, when we get to dispatching and accumulating event listeners, we
// check if the user wanted capture/bubble and emulate the behavior at that
// point (we call this accumulating two phase listeners).
const typeStr = ((topLevelType: any): string);
const listenerMapKey =
capture === undefined
? topLevelType
: `${typeStr}_${capture ? 'capture' : 'bubble'}`;
const listenerEntry = listenerMap.get(listenerMapKey);
const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive);
if (listenerEntry === undefined || shouldUpgrade) {
const isCapturePhase = capturePhaseEvents.has(topLevelType);
const isCapturePhase =
capture === undefined ? capturePhaseEvents.has(topLevelType) : capture;
// If we should upgrade, then we need to remove the existing trapped
// event listener for the target container.
if (shouldUpgrade) {
@@ -221,7 +248,7 @@ export function listenToTopLevelEvent(
passive,
priority,
);
listenerMap.set(topLevelType, {passive, listener});
listenerMap.set(listenerMapKey, {passive, listener});
}
}
@@ -269,14 +296,12 @@ function isMatchingRootContainer(
);
}
export function isDOMElement(target: EventTarget): boolean {
const nodeType = ((target: any): Node).nodeType;
return (nodeType: any) && nodeType === ELEMENT_NODE;
export function isManagedDOMElement(target: EventTarget): boolean {
return getClosestInstanceFromNode(((target: any): Node)) !== null;
}
export function isDOMDocument(target: EventTarget): boolean {
const nodeType = ((target: any): Node).nodeType;
return nodeType === DOCUMENT_NODE;
export function isValidEventTarget(target: EventTarget): boolean {
return typeof target.addEventListener === 'function';
}
export function dispatchEventForPluginEventSystem(
@@ -288,87 +313,85 @@ export function dispatchEventForPluginEventSystem(
): void {
let ancestorInst = targetInst;
if (targetContainer !== null) {
const possibleTargetContainerNode = ((targetContainer: any): Node);
// Given the rootContainer can be any EventTarget, if the
// target is not a valid DOM element then we'll skip this part.
if (
possibleTargetContainerNode === window ||
!isDOMElement(possibleTargetContainerNode)
) {
// TODO: useEvent for document and window
return;
}
// If we are using the legacy FB support flag, we
// defer the event to the null with a one
// time event listener so we can defer the event.
if (
enableLegacyFBSupport &&
// We do not want to defer if the event system has already been
// set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when
// we call willDeferLaterForLegacyFBSupport, thus not bailing out
// will result in endless cycles like an infinite loop.
(eventSystemFlags & LEGACY_FB_SUPPORT) === 0 &&
// We also don't want to defer during event replaying.
(eventSystemFlags & IS_REPLAYED) === 0 &&
willDeferLaterForLegacyFBSupport(topLevelType, targetContainer)
) {
return;
}
if (targetInst !== null) {
// The below logic attempts to work out if we need to change
// the target fiber to a different ancestor. We had similar logic
// in the legacy event system, except the big difference between
// systems is that the modern event system now has an event listener
// attached to each React Root and React Portal Root. Together,
// the DOM nodes representing these roots are the "rootContainer".
// To figure out which ancestor instance we should use, we traverse
// up the fiber tree from the target instance and attempt to find
// root boundaries that match that of our current "rootContainer".
// If we find that "rootContainer", we find the parent fiber
// sub-tree for that root and make that our ancestor instance.
let node = targetInst;
if (eventTargetEventListenerStore.has(targetContainer)) {
// For TargetEvent nodes (i.e. document, window)
ancestorInst = null;
eventSystemFlags |= IS_TARGET_EVENT_ONLY;
} else {
const targetContainerNode = ((targetContainer: any): Node);
while (true) {
if (node === null) {
return;
}
if (node.tag === HostRoot || node.tag === HostPortal) {
const container = node.stateNode.containerInfo;
if (isMatchingRootContainer(container, possibleTargetContainerNode)) {
break;
}
if (node.tag === HostPortal) {
// The target is a portal, but it's not the rootContainer we're looking for.
// Normally portals handle their own events all the way down to the root.
// So we should be able to stop now. However, we don't know if this portal
// was part of *our* root.
let grandNode = node.return;
while (grandNode !== null) {
if (grandNode.tag === HostRoot || grandNode.tag === HostPortal) {
const grandContainer = grandNode.stateNode.containerInfo;
if (
isMatchingRootContainer(
grandContainer,
possibleTargetContainerNode,
)
) {
// This is the rootContainer we're looking for and we found it as
// a parent of the Portal. That means we can ignore it because the
// Portal will bubble through to us.
return;
}
}
grandNode = grandNode.return;
}
}
const parentSubtreeInst = getClosestInstanceFromNode(container);
if (parentSubtreeInst === null) {
// If we are using the legacy FB support flag, we
// defer the event to the null with a one
// time event listener so we can defer the event.
if (
enableLegacyFBSupport &&
// We do not want to defer if the event system has already been
// set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when
// we call willDeferLaterForLegacyFBSupport, thus not bailing out
// will result in endless cycles like an infinite loop.
(eventSystemFlags & LEGACY_FB_SUPPORT) === 0 &&
// We also don't want to defer during event replaying.
(eventSystemFlags & IS_REPLAYED) === 0 &&
willDeferLaterForLegacyFBSupport(topLevelType, targetContainer)
) {
return;
}
if (targetInst !== null) {
// The below logic attempts to work out if we need to change
// the target fiber to a different ancestor. We had similar logic
// in the legacy event system, except the big difference between
// systems is that the modern event system now has an event listener
// attached to each React Root and React Portal Root. Together,
// the DOM nodes representing these roots are the "rootContainer".
// To figure out which ancestor instance we should use, we traverse
// up the fiber tree from the target instance and attempt to find
// root boundaries that match that of our current "rootContainer".
// If we find that "rootContainer", we find the parent fiber
// sub-tree for that root and make that our ancestor instance.
let node = targetInst;
while (true) {
if (node === null) {
return;
}
node = ancestorInst = parentSubtreeInst;
continue;
if (node.tag === HostRoot || node.tag === HostPortal) {
const container = node.stateNode.containerInfo;
if (isMatchingRootContainer(container, targetContainerNode)) {
break;
}
if (node.tag === HostPortal) {
// The target is a portal, but it's not the rootContainer we're looking for.
// Normally portals handle their own events all the way down to the root.
// So we should be able to stop now. However, we don't know if this portal
// was part of *our* root.
let grandNode = node.return;
while (grandNode !== null) {
if (
grandNode.tag === HostRoot ||
grandNode.tag === HostPortal
) {
const grandContainer = grandNode.stateNode.containerInfo;
if (
isMatchingRootContainer(grandContainer, targetContainerNode)
) {
// This is the rootContainer we're looking for and we found it as
// a parent of the Portal. That means we can ignore it because the
// Portal will bubble through to us.
return;
}
}
grandNode = grandNode.return;
}
}
const parentSubtreeInst = getClosestInstanceFromNode(container);
if (parentSubtreeInst === null) {
return;
}
node = ancestorInst = parentSubtreeInst;
continue;
}
node = node.return;
}
node = node.return;
}
}
}
@@ -398,21 +421,32 @@ function getNearestRootOrPortalContainer(instance: Element): Element {
return instance;
}
export function attachElementListener(listener: ReactDOMListener): void {
function addEventTypeToDispatchConfig(type: DOMTopLevelEventType): void {
let dispatchConfig = topLevelEventsToDispatchConfig.get(type);
// If we don't have a dispatchConfig, then we're dealing with
// an event type that React does not know about (i.e. a custom event).
// We need to register an event config for this or the SimpleEventPlugin
// will not appropriately provide a SyntheticEvent, so we use out empty
// dispatch config for custom events.
if (dispatchConfig === undefined) {
topLevelEventsToDispatchConfig.set(
type,
emptyDispatchConfigForCustomEvents,
);
}
}
export function attachListenerFromManagedDOMElement(
listener: ReactDOMListener,
): void {
const {event, target} = listener;
const {passive, priority, type} = event;
const possibleManagedTarget = ((target: any): Element);
let containerEventTarget = target;
// If we the target is a managed React element, then we need to
// find the nearest root/portal contained to attach the event listener
// to. If it's not managed, i.e. the window, then we just attach
// the listener to the target.
if (isDOMElement(target)) {
const possibleManagedTarget = ((target: any): Element);
if (getClosestInstanceFromNode(possibleManagedTarget)) {
containerEventTarget = getNearestRootOrPortalContainer(
possibleManagedTarget,
);
}
if (getClosestInstanceFromNode(possibleManagedTarget)) {
containerEventTarget = getNearestRootOrPortalContainer(
possibleManagedTarget,
);
}
const listenerMap = getListenerMapForElement(containerEventTarget);
// Add the event listener to the target container (falling back to
@@ -434,21 +468,12 @@ export function attachElementListener(listener: ReactDOMListener): void {
// Add our listener to the listeners Set.
listeners.add(listener);
// Finally, add the event to our known event types list.
let dispatchConfig = topLevelEventsToDispatchConfig.get(type);
// If we don't have a dispatchConfig, then we're dealing with
// an event type that React does not know about (i.e. a custom event).
// We need to register an event config for this or the SimpleEventPlugin
// will not appropriately provide a SyntheticEvent, so we use out empty
// dispatch config for custom events.
if (dispatchConfig === undefined) {
topLevelEventsToDispatchConfig.set(
type,
emptyDispatchConfigForCustomEvents,
);
}
addEventTypeToDispatchConfig(type);
}
export function detachElementListener(listener: ReactDOMListener): void {
export function detachListenerFromManagedDOMElement(
listener: ReactDOMListener,
): void {
const {target} = listener;
// Get the internal listeners Set from the target instance.
const listeners = getListenersFromTarget(target);
@@ -457,3 +482,47 @@ export function detachElementListener(listener: ReactDOMListener): void {
listeners.delete(listener);
}
}
export function attachTargetEventListener(listener: ReactDOMListener): void {
const {event, target} = listener;
const {capture, passive, priority, type} = event;
const listenerMap = getListenerMapForElement(target);
// Add the event listener to the TargetEvent object.
listenToTopLevelEvent(type, target, listenerMap, passive, priority, capture);
let eventTypeMap = eventTargetEventListenerStore.get(target);
if (eventTypeMap === undefined) {
eventTypeMap = new Map();
eventTargetEventListenerStore.set(target, eventTypeMap);
}
// Get the listeners by the event type
let listeners = eventTypeMap.get(type);
if (listeners === undefined) {
listeners = {captured: new Set(), bubbled: new Set()};
eventTypeMap.set(type, listeners);
}
// Add our listener to the listeners Set.
if (capture) {
listeners.captured.add(listener);
} else {
listeners.bubbled.add(listener);
}
// Finally, add the event to our known event types list.
addEventTypeToDispatchConfig(type);
}
export function detachTargetEventListener(listener: ReactDOMListener): void {
const {event, target} = listener;
const {capture, type} = event;
const eventTypeMap = eventTargetEventListenerStore.get(target);
if (eventTypeMap !== undefined) {
const listeners = eventTypeMap.get(type);
if (listeners !== undefined) {
// Remove out listener from the listeners Set.
if (capture) {
listeners.captured.delete(listener);
} else {
listeners.bubbled.delete(listener);
}
}
}
}
+2 -1
View File
@@ -83,6 +83,7 @@ const SimpleEventPlugin: PluginModule<MouseEvent> = {
nativeEvent: MouseEvent,
nativeEventTarget: null | EventTarget,
eventSystemFlags: EventSystemFlags,
targetContainer?: null | EventTarget,
): null | ReactSyntheticEvent {
const dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);
if (!dispatchConfig) {
@@ -194,7 +195,7 @@ const SimpleEventPlugin: PluginModule<MouseEvent> = {
nativeEvent,
nativeEventTarget,
);
accumulateTwoPhaseListeners(event, true);
accumulateTwoPhaseListeners(event, true, eventSystemFlags, targetContainer);
return event;
},
};
@@ -25,6 +25,27 @@ function dispatchClickEvent(element) {
dispatchEvent(element, 'click');
}
let eventListenersToClear = [];
function startNativeEventListenerClearDown() {
const nativeWindowEventListener = window.addEventListener;
window.addEventListener = function(...params) {
eventListenersToClear.push({target: window, params});
return nativeWindowEventListener.apply(this, params);
};
const nativeDocumentEventListener = document.addEventListener;
document.addEventListener = function(...params) {
eventListenersToClear.push({target: document, params});
return nativeDocumentEventListener.apply(this, params);
};
}
function endNativeEventListenerClearDown() {
eventListenersToClear.forEach(({target, params}) => {
target.removeEventListener(...params);
});
}
describe('DOMModernPluginEventSystem', () => {
let container;
@@ -45,11 +66,13 @@ describe('DOMModernPluginEventSystem', () => {
ReactDOMServer = require('react-dom/server');
container = document.createElement('div');
document.body.appendChild(container);
startNativeEventListenerClearDown();
});
afterEach(() => {
document.body.removeChild(container);
container = null;
endNativeEventListenerClearDown();
});
it('handle propagation of click events', () => {
@@ -1328,14 +1351,18 @@ describe('DOMModernPluginEventSystem', () => {
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['bubble', buttonElement]);
log.length = 0;
onClick.mockClear();
onClickCapture.mockClear();
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['capture', divElement]);
expect(log[4]).toEqual(['bubble', divElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
expect(onClick).toHaveBeenCalledTimes(2);
expect(onClickCapture).toHaveBeenCalledTimes(2);
expect(log[0]).toEqual(['capture', buttonElement]);
expect(log[1]).toEqual(['capture', divElement]);
expect(log[2]).toEqual(['bubble', divElement]);
expect(log[3]).toEqual(['bubble', buttonElement]);
});
it('handle propagation of click events mixed with onClick events', () => {
@@ -1787,6 +1814,258 @@ describe('DOMModernPluginEventSystem', () => {
expect(clickEvent).toHaveBeenCalledTimes(1);
});
it('should correctly work for a basic "click" window listener', () => {
const log = [];
const clickEvent = jest.fn(event => {
log.push({
eventPhase: event.eventPhase,
type: event.type,
currentTarget: event.currentTarget,
target: event.target,
});
});
function Test() {
const click = ReactDOM.unstable_useEvent('click');
React.useEffect(() => {
click.setListener(window, clickEvent);
});
return <button>Click anything!</button>;
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
expect(container.innerHTML).toBe(
'<button>Click anything!</button>',
);
// Clicking outside the button should trigger the event callback
dispatchClickEvent(document.body);
expect(log[0]).toEqual({
eventPhase: 3,
type: 'click',
currentTarget: window,
target: document.body,
});
// Unmounting the container and clicking should not work
ReactDOM.render(null, container);
Scheduler.unstable_flushAll();
dispatchClickEvent(document.body);
expect(clickEvent).toBeCalledTimes(1);
// Re-rendering and clicking the body should work again
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
dispatchClickEvent(document.body);
expect(clickEvent).toBeCalledTimes(2);
});
it('handle propagation of click events on the window', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e =>
log.push(['capture', e.currentTarget]),
);
function Test() {
const click = ReactDOM.unstable_useEvent('click');
const clickCapture = ReactDOM.unstable_useEvent('click', {
capture: true,
});
React.useEffect(() => {
click.setListener(window, onClick);
clickCapture.setListener(window, onClickCapture);
click.setListener(buttonRef.current, onClick);
clickCapture.setListener(buttonRef.current, onClickCapture);
click.setListener(divRef.current, onClick);
clickCapture.setListener(divRef.current, onClickCapture);
});
return (
<button ref={buttonRef}>
<div ref={divRef}>Click me!</div>
</button>
);
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(2);
expect(onClickCapture).toHaveBeenCalledTimes(2);
expect(log[0]).toEqual(['capture', window]);
expect(log[1]).toEqual(['capture', buttonElement]);
expect(log[2]).toEqual(['bubble', buttonElement]);
expect(log[3]).toEqual(['bubble', window]);
log.length = 0;
onClick.mockClear();
onClickCapture.mockClear();
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[0]).toEqual(['capture', window]);
expect(log[1]).toEqual(['capture', buttonElement]);
expect(log[2]).toEqual(['capture', divElement]);
expect(log[3]).toEqual(['bubble', divElement]);
expect(log[4]).toEqual(['bubble', buttonElement]);
expect(log[5]).toEqual(['bubble', window]);
});
it('should correctly handle stopPropagation for mixed listeners', () => {
const buttonRef = React.createRef();
const rootListerner1 = jest.fn(e => e.stopPropagation());
const rootListerner2 = jest.fn();
const targetListerner1 = jest.fn();
const targetListerner2 = jest.fn();
function Test() {
const click1 = ReactDOM.unstable_useEvent('click', {
capture: true,
});
const click2 = ReactDOM.unstable_useEvent('click', {
capture: true,
});
const click3 = ReactDOM.unstable_useEvent('click');
const click4 = ReactDOM.unstable_useEvent('click');
React.useEffect(() => {
click1.setListener(window, rootListerner1);
click2.setListener(buttonRef.current, targetListerner1);
click3.setListener(window, rootListerner2);
click4.setListener(buttonRef.current, targetListerner2);
});
return <button ref={buttonRef}>Click me!</button>;
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(rootListerner1).toHaveBeenCalledTimes(1);
expect(targetListerner1).toHaveBeenCalledTimes(0);
expect(targetListerner2).toHaveBeenCalledTimes(0);
expect(rootListerner2).toHaveBeenCalledTimes(0);
});
it('should correctly handle stopPropagation for delegated listeners', () => {
const buttonRef = React.createRef();
const rootListerner1 = jest.fn(e => e.stopPropagation());
const rootListerner2 = jest.fn();
const rootListerner3 = jest.fn(e => e.stopPropagation());
const rootListerner4 = jest.fn();
function Test() {
const click1 = ReactDOM.unstable_useEvent('click', {
capture: true,
});
const click2 = ReactDOM.unstable_useEvent('click', {
capture: true,
});
const click3 = ReactDOM.unstable_useEvent('click');
const click4 = ReactDOM.unstable_useEvent('click');
React.useEffect(() => {
click1.setListener(window, rootListerner1);
click2.setListener(window, rootListerner2);
click3.setListener(window, rootListerner3);
click4.setListener(window, rootListerner4);
});
return <button ref={buttonRef}>Click me!</button>;
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(rootListerner1).toHaveBeenCalledTimes(1);
expect(rootListerner2).toHaveBeenCalledTimes(1);
expect(rootListerner3).toHaveBeenCalledTimes(0);
expect(rootListerner4).toHaveBeenCalledTimes(0);
});
it('handle propagation of click events on the window and document', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
const log = [];
const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
const onClickCapture = jest.fn(e =>
log.push(['capture', e.currentTarget]),
);
function Test() {
const click = ReactDOM.unstable_useEvent('click');
const clickCapture = ReactDOM.unstable_useEvent('click', {
capture: true,
});
React.useEffect(() => {
click.setListener(window, onClick);
clickCapture.setListener(window, onClickCapture);
click.setListener(document, onClick);
clickCapture.setListener(document, onClickCapture);
click.setListener(buttonRef.current, onClick);
clickCapture.setListener(buttonRef.current, onClickCapture);
click.setListener(divRef.current, onClick);
clickCapture.setListener(divRef.current, onClickCapture);
});
return (
<button ref={buttonRef}>
<div ref={divRef}>Click me!</div>
</button>
);
}
ReactDOM.render(<Test />, container);
Scheduler.unstable_flushAll();
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(onClick).toHaveBeenCalledTimes(3);
expect(onClickCapture).toHaveBeenCalledTimes(3);
expect(log[0]).toEqual(['capture', window]);
expect(log[1]).toEqual(['capture', document]);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['bubble', buttonElement]);
expect(log[4]).toEqual(['bubble', document]);
expect(log[5]).toEqual(['bubble', window]);
log.length = 0;
onClick.mockClear();
onClickCapture.mockClear();
let divElement = divRef.current;
dispatchClickEvent(divElement);
expect(onClick).toHaveBeenCalledTimes(4);
expect(onClickCapture).toHaveBeenCalledTimes(4);
expect(log[0]).toEqual(['capture', window]);
expect(log[1]).toEqual(['capture', document]);
expect(log[2]).toEqual(['capture', buttonElement]);
expect(log[3]).toEqual(['capture', divElement]);
expect(log[4]).toEqual(['bubble', divElement]);
expect(log[5]).toEqual(['bubble', buttonElement]);
expect(log[6]).toEqual(['bubble', document]);
expect(log[7]).toEqual(['bubble', window]);
});
it('handles propagation of custom user events', () => {
const buttonRef = React.createRef();
const divRef = React.createRef();
+98 -45
View File
@@ -7,6 +7,8 @@
* @flow
*/
import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';
import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType';
import {HostComponent} from 'shared/ReactWorkTags';
@@ -14,69 +16,120 @@ import {enableUseEventAPI} from 'shared/ReactFeatureFlags';
import getListener from 'legacy-events/getListener';
import {getListenersFromTarget} from '../client/ReactDOMComponentTree';
import {IS_TARGET_EVENT_ONLY} from 'legacy-events/EventSystemFlags';
import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem';
export default function accumulateTwoPhaseListeners(
event: ReactSyntheticEvent,
accumulateUseEventListeners?: boolean,
eventSystemFlags?: EventSystemFlags,
targetContainer?: null | EventTarget,
): void {
const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames;
const dispatchListeners = [];
const dispatchInstances = [];
const {bubbled, captured} = phasedRegistrationNames;
let node = event._targetInst;
// Accumulate all instances and listeners via the target -> root path.
while (node !== null) {
// We only care for listeners that are on HostComponents (i.e. <div>)
if (node.tag === HostComponent) {
// For useEvent listenrs
if (enableUseEventAPI && accumulateUseEventListeners) {
// useEvent event listeners
const instance = node.stateNode;
const targetType = event.type;
const listeners = getListenersFromTarget(instance);
// For TargetEvent only accumulation, we do not traverse through
// the React tree looking for managed React DOM elements that have
// events. Instead we only check the EventTarget Store Map to see
// if the container has listeners for the particular phase we're
// interested in. This is because we attach the native event listener
// only in the given phase.
if (
enableUseEventAPI &&
accumulateUseEventListeners &&
eventSystemFlags !== undefined &&
eventSystemFlags & IS_TARGET_EVENT_ONLY &&
targetContainer != null
) {
const eventTypeMap = eventTargetEventListenerStore.get(targetContainer);
if (eventTypeMap !== undefined) {
const type = ((event.type: any): DOMTopLevelEventType);
const listeners = eventTypeMap.get(type);
if (listeners !== undefined) {
const isCapturePhase = (event: any).eventPhase === 1;
if (listeners !== null) {
const listenersArr = Array.from(listeners);
for (let i = 0; i < listenersArr.length; i++) {
const listener = listenersArr[i];
const {
callback,
event: {capture, type},
} = listener;
if (type === targetType) {
if (capture === true) {
dispatchListeners.unshift(callback);
dispatchInstances.unshift(node);
} else {
dispatchListeners.push(callback);
dispatchInstances.push(node);
if (isCapturePhase) {
const captureListeners = Array.from(listeners.captured);
for (let i = captureListeners.length - 1; i >= 0; i--) {
const listener = captureListeners[i];
const {callback} = listener;
dispatchListeners.push(callback);
dispatchInstances.push(targetContainer);
}
} else {
const bubbleListeners = Array.from(listeners.bubbled);
for (let i = 0; i < bubbleListeners.length; i++) {
const listener = bubbleListeners[i];
const {callback} = listener;
dispatchListeners.push(callback);
dispatchInstances.push(targetContainer);
}
}
}
}
} else {
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;
// Accumulate all instances and listeners via the target -> root path.
while (node !== null) {
// We only care for listeners that are on HostComponents (i.e. <div>)
if (node.tag === HostComponent) {
// For useEvent listenrs
if (enableUseEventAPI && accumulateUseEventListeners) {
// useEvent event listeners
const instance = node.stateNode;
const targetType = event.type;
const listeners = getListenersFromTarget(instance);
if (listeners !== null) {
const listenersArr = Array.from(listeners);
for (let i = 0; i < listenersArr.length; i++) {
const listener = listenersArr[i];
const {
callback,
event: {capture, type},
} = listener;
if (type === targetType) {
if (capture === true) {
dispatchListeners.unshift(callback);
dispatchInstances.unshift(node);
} else {
dispatchListeners.push(callback);
dispatchInstances.push(node);
}
}
}
}
}
}
// Standard React on* listeners, i.e. onClick prop
if (captured !== null) {
const captureListener = getListener(node, 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);
// Standard React on* listeners, i.e. onClick prop
if (captured !== null) {
const captureListener = getListener(node, 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);
}
}
}
if (bubbled !== null) {
const bubbleListener = getListener(node, 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);
if (bubbled !== null) {
const bubbleListener = getListener(node, 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);
}
}
}
node = node.return;
}
node = node.return;
}
// To prevent allocation to the event unless we actually
// have listeners we check the length of one of the arrays.