diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index bcf7bde39c0c3..e6df842d9a831 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -2714,11 +2714,6 @@ describe('ReactDOMComponent', () => { innerRef.current.click(); - // The order we receive here is not ideal since it is expected that the - // capture listener fire before all bubble listeners. Other React apps - // might depend on this. - // - // @see https://github.com/facebook/react/pull/12919#issuecomment-395224674 if ( ReactFeatureFlags.enableModernEventSystem & ReactFeatureFlags.enableLegacyFBSupport @@ -2728,17 +2723,17 @@ describe('ReactDOMComponent', () => { expect(eventOrder).toEqual([ 'document capture', 'document bubble', + 'outer capture', 'inner capture', 'inner bubble', - 'outer capture', 'outer bubble', ]); } else { expect(eventOrder).toEqual([ 'document capture', + 'outer capture', 'inner capture', 'inner bubble', - 'outer capture', 'outer bubble', 'document bubble', ]); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index fc16a49d7c793..83f1e449110bd 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -7,6 +7,8 @@ * @flow */ +import type {ElementListenerMapEntry} from '../client/ReactDOMComponentTree'; + import {registrationNameModules} from '../legacy-events/EventPluginRegistry'; import {canUseDOM} from 'shared/ExecutionEnvironment'; import invariant from 'shared/invariant'; @@ -94,7 +96,7 @@ import { legacyListenToEvent, legacyTrapBubbledEvent, } from '../events/DOMLegacyEventPluginSystem'; -import {listenToEvent} from '../events/DOMModernPluginEventSystem'; +import {listenToReactPropEvent} from '../events/DOMModernPluginEventSystem'; import {getEventListenerMap} from './ReactDOMComponentTree'; let didWarnInvalidHydration = false; @@ -271,7 +273,7 @@ if (__DEV__) { export function ensureListeningTo( rootContainerInstance: Element | Node, - registrationName: string, + reactPropEvent: string, ): void { if (enableModernEventSystem) { // If we have a comment node, then use the parent node, @@ -289,7 +291,10 @@ export function ensureListeningTo( 'ensureListeningTo(): received a container that was not an element node. ' + 'This is likely a bug in React.', ); - listenToEvent(registrationName, ((rootContainerElement: any): Element)); + listenToReactPropEvent( + reactPropEvent, + ((rootContainerElement: any): Element), + ); } else { // Legacy plugin event system path const isDocumentOrFragment = @@ -298,7 +303,7 @@ export function ensureListeningTo( const doc = isDocumentOrFragment ? rootContainerInstance : rootContainerInstance.ownerDocument; - legacyListenToEvent(registrationName, ((doc: any): Document)); + legacyListenToEvent(reactPropEvent, ((doc: any): Document)); } } @@ -1368,7 +1373,9 @@ export function listenToEventResponderEventTypes( // existing passive event listener before we add the // active event listener. const passiveKey = targetEventType + '_passive'; - const passiveItem = listenerMap.get(passiveKey); + const passiveItem = ((listenerMap.get( + passiveKey, + ): any): ElementListenerMapEntry | void); if (passiveItem !== undefined) { removeTrappedEventListener( document, diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index 9d2822f60dfec..f58bf309df04d 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -42,7 +42,7 @@ const internalEventHandlerListenersKey = '__reactListeners$' + randomKey; export type ElementListenerMap = Map< DOMTopLevelEventType | string, - ElementListenerMapEntry, + null | ElementListenerMapEntry, >; export type ElementListenerMapEntry = { diff --git a/packages/react-dom/src/client/ReactDOMEventHandle.js b/packages/react-dom/src/client/ReactDOMEventHandle.js index b477685c176ff..c60fe2247ddd6 100644 --- a/packages/react-dom/src/client/ReactDOMEventHandle.js +++ b/packages/react-dom/src/client/ReactDOMEventHandle.js @@ -86,6 +86,7 @@ function registerEventOnNearestTargetContainer( topLevelType: DOMTopLevelEventType, passive: boolean | void, priority: EventPriority | void, + capture: boolean, ): void { // If it is, find the nearest root or portal and make it // our event handle target container. @@ -103,6 +104,7 @@ function registerEventOnNearestTargetContainer( targetContainer, listenerMap, PLUGIN_EVENT_SYSTEM, + capture, passive, priority, ); @@ -179,6 +181,7 @@ export function createEventHandle( topLevelType, passive, priority, + capture, ); } else if (enableScopeAPI && isReactScope(target)) { const scopeTarget = ((target: any): ReactScopeInstance); @@ -192,6 +195,7 @@ export function createEventHandle( topLevelType, passive, priority, + capture, ); } else if (isValidEventTarget(target)) { const eventTarget = ((target: any): EventTarget); @@ -201,9 +205,9 @@ export function createEventHandle( eventTarget, listenerMap, PLUGIN_EVENT_SYSTEM | IS_TARGET_PHASE_ONLY, + capture, passive, priority, - capture, ); } else { invariant( diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index a7039702b8de0..8b76e1a4a8281 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -82,7 +82,7 @@ import { import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes'; import { - listenToEvent, + listenToReactPropEvent, clearEventHandleListenersForTarget, } from '../events/DOMModernPluginEventSystem'; @@ -1124,7 +1124,7 @@ export function makeOpaqueHydratingObject( export function preparePortalMount(portalInstance: Instance): void { if (enableModernEventSystem) { - listenToEvent('onMouseEnter', portalInstance); + listenToReactPropEvent('onMouseEnter', portalInstance); } } diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index 5cebb608e4af7..f338f6d05dc79 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -34,6 +34,7 @@ import { PLUGIN_EVENT_SYSTEM, LEGACY_FB_SUPPORT, IS_REPLAYED, + IS_CAPTURE_PHASE, IS_TARGET_PHASE_ONLY, } from './EventSystemFlags'; @@ -150,6 +151,13 @@ export const capturePhaseEvents: Set = new Set([ TOP_WAITING, ]); +// Map these back to their originals +const alwaysBubblePhaseEvents = new Set([ + 'onChangeCapture', + 'onBeforeInputCapture', + 'onSelectCapture', +]); + if (enableCreateEventHandleAPI) { capturePhaseEvents.add(TOP_AFTER_BLUR); } @@ -175,36 +183,40 @@ function executeDispatch( function executeDispatchesInOrder( event: ReactSyntheticEvent, - capture: DispatchQueueItemPhase, - bubble: DispatchQueueItemPhase, + phase: DispatchQueueItemPhase, + eventSystemFlags: EventSystemFlags, ): void { + const inCapturePhase = eventSystemFlags & IS_CAPTURE_PHASE; let previousInstance; - // Dispatch capture phase first. - for (let i = capture.length - 1; i >= 0; i--) { - const {instance, currentTarget, listener} = capture[i]; - if (instance !== previousInstance && event.isPropagationStopped()) { - return; + if (inCapturePhase) { + for (let i = phase.length - 1; i >= 0; i--) { + const {instance, currentTarget, listener} = phase[i]; + if (instance !== previousInstance && event.isPropagationStopped()) { + return; + } + executeDispatch(event, listener, currentTarget); + previousInstance = instance; } - executeDispatch(event, listener, currentTarget); - previousInstance = instance; - } - previousInstance = undefined; - // Dispatch bubble phase second. - for (let i = 0; i < bubble.length; i++) { - const {instance, currentTarget, listener} = bubble[i]; - if (instance !== previousInstance && event.isPropagationStopped()) { - return; + } else { + for (let i = 0; i < phase.length; i++) { + const {instance, currentTarget, listener} = phase[i]; + if (instance !== previousInstance && event.isPropagationStopped()) { + return; + } + executeDispatch(event, listener, currentTarget); + previousInstance = instance; } - executeDispatch(event, listener, currentTarget); - previousInstance = instance; } } -export function dispatchEventsInBatch(dispatchQueue: DispatchQueue): void { +export function dispatchEventsInBatch( + dispatchQueue: DispatchQueue, + eventSystemFlags: EventSystemFlags, +): void { for (let i = 0; i < dispatchQueue.length; i++) { const dispatchQueueItem: DispatchQueueItem = dispatchQueue[i]; - const {event, capture, bubble} = dispatchQueueItem; - executeDispatchesInOrder(event, capture, bubble); + const {event, phase} = dispatchQueueItem; + executeDispatchesInOrder(event, phase, eventSystemFlags); // Modern event system doesn't use pooling. } // This would be a good time to rethrow if any of the event handlers threw. @@ -234,7 +246,7 @@ function dispatchEventsForPlugins( targetContainer, ); } - dispatchEventsInBatch(dispatchQueue); + dispatchEventsInBatch(dispatchQueue, eventSystemFlags); } function shouldUpgradeListener( @@ -251,9 +263,9 @@ export function listenToTopLevelEvent( target: EventTarget, listenerMap: ElementListenerMap, eventSystemFlags: EventSystemFlags, + capturePhase: boolean, passive?: boolean, priority?: EventPriority, - capture?: boolean, ): void { // TOP_SELECTION_CHANGE needs to be attached to the document // otherwise it won't capture incoming events that are only @@ -262,12 +274,10 @@ export function listenToTopLevelEvent( target = (target: any).ownerDocument || target; listenerMap = getEventListenerMap(target); } - capture = - capture === undefined ? capturePhaseEvents.has(topLevelType) : capture; - const listenerMapKey = getListenerMapKey(topLevelType, capture); - const listenerEntry: ElementListenerMapEntry | void = listenerMap.get( + const listenerMapKey = getListenerMapKey(topLevelType, capturePhase); + const listenerEntry = ((listenerMap.get( listenerMapKey, - ); + ): any): ElementListenerMapEntry | void); const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive); // If the listener entry is empty or we should upgrade, then @@ -279,15 +289,18 @@ export function listenToTopLevelEvent( removeTrappedEventListener( target, topLevelType, - capture, + capturePhase, ((listenerEntry: any): ElementListenerMapEntry).listener, ); } + if (capturePhase) { + eventSystemFlags |= IS_CAPTURE_PHASE; + } const listener = addTrappedEventListener( target, topLevelType, eventSystemFlags, - capture, + capturePhase, false, passive, priority, @@ -296,20 +309,39 @@ export function listenToTopLevelEvent( } } -export function listenToEvent( - registrationName: string, +function isCaptureRegistrationName(registrationName: string): boolean { + const len = registrationName.length; + return registrationName.substr(len - 7) === 'Capture'; +} + +export function listenToReactPropEvent( + reactPropEvent: string, rootContainerElement: Element, ): void { const listenerMap = getEventListenerMap(rootContainerElement); - const dependencies = registrationNameDependencies[registrationName]; + // For optimization, let's check if we have the registration name + // on the rootContainerElement. + if (listenerMap.has(reactPropEvent)) { + return; + } + // Add the registration name to the map, so we can avoid processing + // this React prop event again. + listenerMap.set(reactPropEvent, null); + const dependencies = registrationNameDependencies[reactPropEvent]; + const registrationCapturePhase = + isCaptureRegistrationName(reactPropEvent) && + !alwaysBubblePhaseEvents.has(reactPropEvent); for (let i = 0; i < dependencies.length; i++) { const dependency = dependencies[i]; + const capturePhase = + capturePhaseEvents.has(dependency) || registrationCapturePhase; listenToTopLevelEvent( dependency, rootContainerElement, listenerMap, PLUGIN_EVENT_SYSTEM, + capturePhase, ); } } @@ -318,7 +350,7 @@ function addTrappedEventListener( targetContainer: EventTarget, topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, - capture: boolean, + capturePhase: boolean, isDeferredListenerForLegacyFBSupport?: boolean, passive?: boolean, priority?: EventPriority, @@ -364,12 +396,12 @@ function addTrappedEventListener( targetContainer, rawEventName, unsubscribeListener, - capture, + capturePhase, ); } }; } - if (capture) { + if (capturePhase) { if (enableCreateEventHandleAPI && passive !== undefined) { unsubscribeListener = addEventCaptureListenerWithPassiveFlag( targetContainer, @@ -461,6 +493,8 @@ export function dispatchEventForPluginEventSystem( (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 && // We also don't want to defer during event replaying. (eventSystemFlags & IS_REPLAYED) === 0 && + // We don't apply this during capture phase. + (eventSystemFlags & IS_CAPTURE_PHASE) === 0 && willDeferLaterForLegacyFBSupport(topLevelType, targetContainer) ) { return; @@ -559,25 +593,23 @@ function createDispatchQueueItemPhaseEntry( function createDispatchQueueItem( event: ReactSyntheticEvent, - capture: DispatchQueueItemPhase, - bubble: DispatchQueueItemPhase, + phase: DispatchQueueItemPhase, ): DispatchQueueItem { return { event, - capture, - bubble, + phase, }; } -export function accumulateTwoPhaseListeners( +export function accumulateNativePhaseListeners( targetFiber: Fiber | null, dispatchQueue: DispatchQueue, event: ReactSyntheticEvent, + eventSystemFlags: EventSystemFlags, accumulateEventHandleListeners?: boolean, ): void { const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; - const capturePhase: DispatchQueueItemPhase = []; - const bubblePhase: DispatchQueueItemPhase = []; + const phase: DispatchQueueItemPhase = []; const {bubbled, captured} = phasedRegistrationNames; // If we are not handling EventTarget only phase, then we're doing the @@ -586,6 +618,12 @@ export function accumulateTwoPhaseListeners( let instance = targetFiber; let lastHostComponent = null; const targetType = event.type; + const inCapturePhase = eventSystemFlags & IS_CAPTURE_PHASE; + // shouldEmulateTwoPhase is temporary till we can polyfill focus/blur to + // focusin/focusout. + const shouldEmulateTwoPhase = capturePhaseEvents.has( + ((targetType: any): DOMTopLevelEventType), + ); // Accumulate all instances and listeners via the target -> root path. while (instance !== null) { @@ -604,32 +642,35 @@ export function accumulateTwoPhaseListeners( const listener = listenersArr[i]; const {callback, capture, type} = listener; if (type === targetType) { - if (capture === true) { - capturePhase.push( + if (capture && inCapturePhase) { + phase.push( createDispatchQueueItemPhaseEntry( instance, callback, currentTarget, ), ); - } else { - bubblePhase.push( - createDispatchQueueItemPhaseEntry( - instance, - callback, - currentTarget, - ), + } else if (!capture) { + const entry = createDispatchQueueItemPhaseEntry( + instance, + callback, + currentTarget, ); + if (shouldEmulateTwoPhase) { + phase.unshift(entry); + } else if (!inCapturePhase) { + phase.push(entry); + } } } } } } // Standard React on* listeners, i.e. onClick prop - if (captured !== null) { + if (captured !== null && inCapturePhase) { const captureListener = getListener(instance, captured); if (captureListener != null) { - capturePhase.push( + phase.push( createDispatchQueueItemPhaseEntry( instance, captureListener, @@ -641,13 +682,16 @@ export function accumulateTwoPhaseListeners( if (bubbled !== null) { const bubbleListener = getListener(instance, bubbled); if (bubbleListener != null) { - bubblePhase.push( - createDispatchQueueItemPhaseEntry( - instance, - bubbleListener, - currentTarget, - ), + const entry = createDispatchQueueItemPhaseEntry( + instance, + bubbleListener, + currentTarget, ); + if (shouldEmulateTwoPhase) { + phase.unshift(entry); + } else if (!inCapturePhase) { + phase.push(entry); + } } } } else if ( @@ -667,22 +711,25 @@ export function accumulateTwoPhaseListeners( const listener = listenersArr[i]; const {callback, capture, type} = listener; if (type === targetType) { - if (capture === true) { - capturePhase.push( + if (capture && inCapturePhase) { + phase.push( createDispatchQueueItemPhaseEntry( instance, callback, lastCurrentTarget, ), ); - } else { - bubblePhase.push( - createDispatchQueueItemPhaseEntry( - instance, - callback, - lastCurrentTarget, - ), + } else if (!capture) { + const entry = createDispatchQueueItemPhaseEntry( + instance, + callback, + lastCurrentTarget, ); + if (shouldEmulateTwoPhase) { + phase.unshift(entry); + } else if (!inCapturePhase) { + phase.push(entry); + } } } } @@ -690,10 +737,64 @@ export function accumulateTwoPhaseListeners( } instance = instance.return; } - if (capturePhase.length !== 0 || bubblePhase.length !== 0) { - dispatchQueue.push( - createDispatchQueueItem(event, capturePhase, bubblePhase), - ); + if (phase.length !== 0) { + dispatchQueue.push(createDispatchQueueItem(event, phase)); + } +} + +// We should only use this function for: +// - ModernBeforeInputEventPlugin +// - ModernChangeEventPlugin +// - ModernSelectEventPlugin +// This is because we only process these plugins +// in the bubble phase, so we need to accumulate two +// phase event listeners. +export function accumulateTwoPhaseListeners( + targetFiber: Fiber | null, + dispatchQueue: DispatchQueue, + event: ReactSyntheticEvent, +): void { + const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; + const phase: DispatchQueueItemPhase = []; + const {bubbled, captured} = phasedRegistrationNames; + let instance = targetFiber; + + // Accumulate all instances and listeners via the target -> root path. + while (instance !== null) { + const {stateNode, tag} = instance; + // Handle listeners that are on HostComponents (i.e.
) + if (tag === HostComponent && stateNode !== null) { + const currentTarget = stateNode; + // Standard React on* listeners, i.e. onClick prop + if (captured !== null) { + const captureListener = getListener(instance, captured); + if (captureListener != null) { + phase.unshift( + createDispatchQueueItemPhaseEntry( + instance, + captureListener, + currentTarget, + ), + ); + } + } + if (bubbled !== null) { + const bubbleListener = getListener(instance, bubbled); + if (bubbleListener != null) { + phase.push( + createDispatchQueueItemPhaseEntry( + instance, + bubbleListener, + currentTarget, + ), + ); + } + } + } + instance = instance.return; + } + if (phase.length !== 0) { + dispatchQueue.push(createDispatchQueueItem(event, phase)); } } @@ -766,8 +867,7 @@ function accumulateEnterLeaveListenersForEvent( if (registrationName === undefined) { return; } - const capturePhase: DispatchQueueItemPhase = []; - const bubblePhase: DispatchQueueItemPhase = []; + const phase: DispatchQueueItemPhase = []; let instance = target; while (instance !== null) { @@ -783,7 +883,7 @@ function accumulateEnterLeaveListenersForEvent( if (capture) { const captureListener = getListener(instance, registrationName); if (captureListener != null) { - capturePhase.push( + phase.unshift( createDispatchQueueItemPhaseEntry( instance, captureListener, @@ -791,10 +891,10 @@ function accumulateEnterLeaveListenersForEvent( ), ); } - } else { + } else if (!capture) { const bubbleListener = getListener(instance, registrationName); if (bubbleListener != null) { - bubblePhase.push( + phase.push( createDispatchQueueItemPhaseEntry( instance, bubbleListener, @@ -806,14 +906,17 @@ function accumulateEnterLeaveListenersForEvent( } instance = instance.return; } - if (capturePhase.length !== 0 || bubblePhase.length !== 0) { - dispatchQueue.push( - createDispatchQueueItem(event, capturePhase, bubblePhase), - ); + if (phase.length !== 0) { + dispatchQueue.push(createDispatchQueueItem(event, phase)); } } -export function accumulateEnterLeaveListeners( +// We should only use this function for: +// - ModernEnterLeaveEventPlugin +// This is because we only process this plugin +// in the bubble phase, so we need to accumulate two +// phase event listeners. +export function accumulateEnterLeaveTwoPhaseListeners( dispatchQueue: DispatchQueue, leaveEvent: ReactSyntheticEvent, enterEvent: null | ReactSyntheticEvent, @@ -846,36 +949,34 @@ export function accumulateEventTargetListeners( dispatchQueue: DispatchQueue, event: ReactSyntheticEvent, currentTarget: EventTarget, + eventSystemFlags: EventSystemFlags, ): void { - const capturePhase: DispatchQueueItemPhase = []; - const bubblePhase: DispatchQueueItemPhase = []; + const phase: DispatchQueueItemPhase = []; const eventListeners = getEventHandlerListeners(currentTarget); if (eventListeners !== null) { const listenersArr = Array.from(eventListeners); const targetType = ((event.type: any): DOMTopLevelEventType); - const isCapturePhase = (event: any).eventPhase === 1; + const inCapturePhase = eventSystemFlags & IS_CAPTURE_PHASE; for (let i = 0; i < listenersArr.length; i++) { const listener = listenersArr[i]; const {callback, capture, type} = listener; if (type === targetType) { - if (isCapturePhase && capture) { - capturePhase.push( + if (inCapturePhase && capture) { + phase.push( createDispatchQueueItemPhaseEntry(null, callback, currentTarget), ); - } else if (!isCapturePhase && !capture) { - bubblePhase.push( + } else if (!inCapturePhase && !capture) { + phase.push( createDispatchQueueItemPhaseEntry(null, callback, currentTarget), ); } } } } - if (capturePhase.length !== 0 || bubblePhase.length !== 0) { - dispatchQueue.push( - createDispatchQueueItem(event, capturePhase, bubblePhase), - ); + if (phase.length !== 0) { + dispatchQueue.push(createDispatchQueueItem(event, phase)); } } diff --git a/packages/react-dom/src/events/EventSystemFlags.js b/packages/react-dom/src/events/EventSystemFlags.js index 69e39bf5fc927..35af32c3b2ce7 100644 --- a/packages/react-dom/src/events/EventSystemFlags.js +++ b/packages/react-dom/src/events/EventSystemFlags.js @@ -12,8 +12,9 @@ export type EventSystemFlags = number; export const PLUGIN_EVENT_SYSTEM = 1; export const RESPONDER_EVENT_SYSTEM = 1 << 1; export const IS_TARGET_PHASE_ONLY = 1 << 2; -export const IS_PASSIVE = 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_CAPTURE_PHASE = 1 << 3; +export const IS_PASSIVE = 1 << 4; +export const PASSIVE_NOT_SUPPORTED = 1 << 5; +export const IS_REPLAYED = 1 << 6; +export const IS_FIRST_ANCESTOR = 1 << 7; +export const LEGACY_FB_SUPPORT = 1 << 8; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index f544755937edc..c4b58f13b9f00 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -222,6 +222,7 @@ function trapReplayableEventForContainer( ((container: any): Element), listenerMap, PLUGIN_EVENT_SYSTEM, + false, ); } diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index ce7d006905255..ac52ee79c41a9 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -190,9 +190,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(3); expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); }); @@ -242,9 +242,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(3); expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); }); @@ -321,9 +321,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(3); expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); // Inside @@ -331,9 +331,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(buttonElement2); expect(onClick).toHaveBeenCalledTimes(5); expect(onClickCapture).toHaveBeenCalledTimes(5); - expect(log[6]).toEqual(['capture', buttonElement2]); - expect(log[7]).toEqual(['bubble', buttonElement2]); - expect(log[8]).toEqual(['capture', buttonElement]); + expect(log[6]).toEqual(['capture', buttonElement]); + expect(log[7]).toEqual(['capture', buttonElement2]); + expect(log[8]).toEqual(['bubble', buttonElement2]); expect(log[9]).toEqual(['bubble', buttonElement]); }); @@ -386,9 +386,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(3); expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); }); @@ -443,9 +443,9 @@ describe('DOMModernPluginEventSystem', () => { dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(3); expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); expect(log[5]).toEqual(['bubble', buttonElement]); }); @@ -758,7 +758,7 @@ describe('DOMModernPluginEventSystem', () => { const divElement = divRef.current; dispatchClickEvent(divElement); expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(3); document.body.removeChild(portalElement); }); @@ -1180,7 +1180,7 @@ describe('DOMModernPluginEventSystem', () => { if (enableLegacyFBSupport) { // We aren't using roots with legacyFBSupport, we put clicks on the document, so we exbit the previous // behavior. - expect(log).toEqual([]); + expect(log).toEqual(['capture root', 'capture portal']); } else { expect(log).toEqual([ // The events on root probably shouldn't fire if a non-React intermediated. but current behavior is that they do. @@ -2172,8 +2172,8 @@ describe('DOMModernPluginEventSystem', () => { if (enableLegacyFBSupport) { expect(log[0]).toEqual(['capture', window]); expect(log[1]).toEqual(['capture', document]); - expect(log[2]).toEqual(['bubble', document]); - expect(log[3]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['bubble', document]); expect(log[4]).toEqual(['bubble', buttonElement]); expect(log[5]).toEqual(['bubble', window]); } else { @@ -2197,9 +2197,9 @@ describe('DOMModernPluginEventSystem', () => { if (enableLegacyFBSupport) { expect(log[0]).toEqual(['capture', window]); expect(log[1]).toEqual(['capture', document]); - expect(log[2]).toEqual(['bubble', document]); - expect(log[3]).toEqual(['capture', buttonElement]); - expect(log[4]).toEqual(['capture', divElement]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', document]); expect(log[5]).toEqual(['bubble', divElement]); expect(log[6]).toEqual(['bubble', buttonElement]); expect(log[7]).toEqual(['bubble', window]); diff --git a/packages/react-dom/src/events/plugins/ModernBeforeInputEventPlugin.js b/packages/react-dom/src/events/plugins/ModernBeforeInputEventPlugin.js index 1bbf54bde966c..23b596fbf184d 100644 --- a/packages/react-dom/src/events/plugins/ModernBeforeInputEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ModernBeforeInputEventPlugin.js @@ -28,7 +28,11 @@ import { } from '../FallbackCompositionState'; import SyntheticCompositionEvent from '../SyntheticCompositionEvent'; import SyntheticInputEvent from '../SyntheticInputEvent'; -import {accumulateTwoPhaseListeners} from '../DOMModernPluginEventSystem'; +import { + accumulateTwoPhaseListeners, + capturePhaseEvents, +} from '../DOMModernPluginEventSystem'; +import {IS_CAPTURE_PHASE} from '../EventSystemFlags'; const END_KEYCODES = [9, 13, 27, 32]; // Tab, Return, Esc, Space const START_KEYCODE = 229; @@ -469,6 +473,13 @@ const BeforeInputEventPlugin = { eventSystemFlags, container, ) { + // onBeforeInput only works in the bubble phase. + if ( + eventSystemFlags & IS_CAPTURE_PHASE && + !capturePhaseEvents.has(topLevelType) + ) { + return; + } extractCompositionEvent( dispatchQueue, topLevelType, diff --git a/packages/react-dom/src/events/plugins/ModernChangeEventPlugin.js b/packages/react-dom/src/events/plugins/ModernChangeEventPlugin.js index ee47a2280691b..4643e539eed41 100644 --- a/packages/react-dom/src/events/plugins/ModernChangeEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ModernChangeEventPlugin.js @@ -31,7 +31,9 @@ import {batchedUpdates} from '../ReactDOMUpdateBatching'; import { dispatchEventsInBatch, accumulateTwoPhaseListeners, + capturePhaseEvents, } from '../DOMModernPluginEventSystem'; +import {IS_CAPTURE_PHASE} from '../EventSystemFlags'; const eventTypes = { change: { @@ -277,6 +279,13 @@ const ChangeEventPlugin = { eventSystemFlags, container, ) { + // onChange only works in the bubble phase. + if ( + eventSystemFlags & IS_CAPTURE_PHASE && + !capturePhaseEvents.has(topLevelType) + ) { + return; + } const targetNode = targetInst ? getNodeFromInstance(targetInst) : window; let getTargetInstFunc, handleEventFunc; diff --git a/packages/react-dom/src/events/plugins/ModernEnterLeaveEventPlugin.js b/packages/react-dom/src/events/plugins/ModernEnterLeaveEventPlugin.js index 679d3897d23b7..34641e079029a 100644 --- a/packages/react-dom/src/events/plugins/ModernEnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ModernEnterLeaveEventPlugin.js @@ -11,14 +11,17 @@ import { TOP_POINTER_OUT, TOP_POINTER_OVER, } from '../DOMTopLevelEventTypes'; -import {IS_REPLAYED} from 'react-dom/src/events/EventSystemFlags'; +import { + IS_REPLAYED, + IS_CAPTURE_PHASE, +} from 'react-dom/src/events/EventSystemFlags'; import SyntheticMouseEvent from '../SyntheticMouseEvent'; import SyntheticPointerEvent from '../SyntheticPointerEvent'; import { getClosestInstanceFromNode, getNodeFromInstance, } from '../../client/ReactDOMComponentTree'; -import {accumulateEnterLeaveListeners} from '../DOMModernPluginEventSystem'; +import {accumulateEnterLeaveTwoPhaseListeners} from '../DOMModernPluginEventSystem'; import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {getNearestMountedFiber} from 'react-reconciler/src/ReactFiberTreeReflection'; @@ -61,6 +64,10 @@ const EnterLeaveEventPlugin = { eventSystemFlags, container, ) { + // onMouseEnter/onMouseLeave only works in the bubble phase. + if (eventSystemFlags & IS_CAPTURE_PHASE) { + return; + } const isOverEvent = topLevelType === TOP_MOUSE_OVER || topLevelType === TOP_POINTER_OVER; const isOutEvent = @@ -173,7 +180,13 @@ const EnterLeaveEventPlugin = { enter = null; } - accumulateEnterLeaveListeners(dispatchQueue, leave, enter, from, to); + accumulateEnterLeaveTwoPhaseListeners( + dispatchQueue, + leave, + enter, + from, + to, + ); }, }; diff --git a/packages/react-dom/src/events/plugins/ModernSelectEventPlugin.js b/packages/react-dom/src/events/plugins/ModernSelectEventPlugin.js index 9761593a28a8e..cef8d07ff614b 100644 --- a/packages/react-dom/src/events/plugins/ModernSelectEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ModernSelectEventPlugin.js @@ -30,9 +30,9 @@ import {hasSelectionCapabilities} from '../../client/ReactInputSelection'; import {DOCUMENT_NODE} from '../../shared/HTMLNodeType'; import { accumulateTwoPhaseListeners, - getListenerMapKey, capturePhaseEvents, } from '../DOMModernPluginEventSystem'; +import {IS_CAPTURE_PHASE} from '../EventSystemFlags'; const skipSelectionChangeEvent = canUseDOM && 'documentMode' in document && document.documentMode <= 11; @@ -150,32 +150,6 @@ function constructSelectEvent(dispatchQueue, nativeEvent, nativeEventTarget) { } } -function isListeningToEvents( - events: Array, - mountAt: Document | Element, -): boolean { - const listenerMap = getEventListenerMap(mountAt); - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const capture = capturePhaseEvents.has(event); - const listenerMapKey = getListenerMapKey(event, capture); - if (!listenerMap.has(listenerMapKey)) { - return false; - } - } - return true; -} - -function isListeningToEvent( - registrationName: string, - mountAt: Document | Element, -): boolean { - const listenerMap = getEventListenerMap(mountAt); - const capture = capturePhaseEvents.has(registrationName); - const listenerMapKey = getListenerMapKey(registrationName, capture); - return listenerMap.has(listenerMapKey); -} - /** * This plugin creates an `onSelect` event that normalizes select events * across form elements. @@ -202,19 +176,24 @@ const SelectEventPlugin = { eventSystemFlags, container, ) { - const doc = getEventTargetDocument(nativeEventTarget); + // onSelect only works in the bubble phase. + if ( + eventSystemFlags & IS_CAPTURE_PHASE && + !capturePhaseEvents.has(topLevelType) + ) { + return; + } + const eventListenerMap = getEventListenerMap(container); // Track whether all listeners exists for this plugin. If none exist, we do // not extract events. See #3639. if ( - // We only listen to TOP_SELECTION_CHANGE on the document, never the - // root. - !isListeningToEvent(TOP_SELECTION_CHANGE, doc) || // If we are handling TOP_SELECTION_CHANGE, then we don't need to // check for the other dependencies, as TOP_SELECTION_CHANGE is only // event attached from the onChange plugin and we don't expose an // onSelectionChange event from React. - (topLevelType !== TOP_SELECTION_CHANGE && - !isListeningToEvents(rootTargetDependencies, container)) + topLevelType !== TOP_SELECTION_CHANGE && + !eventListenerMap.has('onSelect') && + !eventListenerMap.has('onSelectCapture') ) { return; } @@ -247,7 +226,12 @@ const SelectEventPlugin = { case TOP_MOUSE_UP: case TOP_DRAG_END: mouseDown = false; - constructSelectEvent(dispatchQueue, nativeEvent, nativeEventTarget); + constructSelectEvent( + dispatchQueue, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); break; // Chrome and IE fire non-standard event when selection is changed (and // sometimes when it hasn't). IE's event fires out of order with respect @@ -265,7 +249,12 @@ const SelectEventPlugin = { // falls through case TOP_KEY_DOWN: case TOP_KEY_UP: - constructSelectEvent(dispatchQueue, nativeEvent, nativeEventTarget); + constructSelectEvent( + dispatchQueue, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); } return; diff --git a/packages/react-dom/src/events/plugins/ModernSimpleEventPlugin.js b/packages/react-dom/src/events/plugins/ModernSimpleEventPlugin.js index 27db617a5b079..f5fab318f8a68 100644 --- a/packages/react-dom/src/events/plugins/ModernSimpleEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ModernSimpleEventPlugin.js @@ -26,7 +26,7 @@ import { simpleEventPluginEventTypes, } from '../DOMEventProperties'; import { - accumulateTwoPhaseListeners, + accumulateNativePhaseListeners, accumulateEventTargetListeners, } from '../DOMModernPluginEventSystem'; import {IS_TARGET_PHASE_ONLY} from '../EventSystemFlags'; @@ -213,9 +213,20 @@ const SimpleEventPlugin: ModernPluginModule = { eventSystemFlags & IS_TARGET_PHASE_ONLY && targetContainer != null ) { - accumulateEventTargetListeners(dispatchQueue, event, targetContainer); + accumulateEventTargetListeners( + dispatchQueue, + event, + targetContainer, + eventSystemFlags, + ); } else { - accumulateTwoPhaseListeners(targetInst, dispatchQueue, event, true); + accumulateNativePhaseListeners( + targetInst, + dispatchQueue, + event, + eventSystemFlags, + true, + ); } return event; }, diff --git a/packages/react-dom/src/events/plugins/__tests__/ModernChangeEventPlugin-test.internal.js b/packages/react-dom/src/events/plugins/__tests__/ModernChangeEventPlugin-test.internal.js index b099d9e696f72..1db819fe780a0 100644 --- a/packages/react-dom/src/events/plugins/__tests__/ModernChangeEventPlugin-test.internal.js +++ b/packages/react-dom/src/events/plugins/__tests__/ModernChangeEventPlugin-test.internal.js @@ -99,6 +99,30 @@ describe('ChangeEventPlugin', () => { } }); + it('should consider initial text value to be current (capture)', () => { + let called = 0; + + function cb(e) { + called++; + expect(e.type).toBe('change'); + } + + const node = ReactDOM.render( + , + container, + ); + node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); + node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); + + if (ReactFeatureFlags.disableInputAttributeSyncing) { + // TODO: figure out why. This might be a bug. + expect(called).toBe(1); + } else { + // There should be no React change events because the value stayed the same. + expect(called).toBe(0); + } + }); + it('should consider initial checkbox checked=true to be current', () => { let called = 0; diff --git a/packages/react-dom/src/events/plugins/__tests__/ModernSelectEventPlugin-test.internal.js b/packages/react-dom/src/events/plugins/__tests__/ModernSelectEventPlugin-test.internal.js index be367329596d2..1d5d95dcb4f5c 100644 --- a/packages/react-dom/src/events/plugins/__tests__/ModernSelectEventPlugin-test.internal.js +++ b/packages/react-dom/src/events/plugins/__tests__/ModernSelectEventPlugin-test.internal.js @@ -111,6 +111,40 @@ describe('SelectEventPlugin', () => { expect(select).toHaveBeenCalledTimes(1); }); + it('should fire `onSelectCapture` when a listener is present', () => { + const select = jest.fn(); + const onSelectCapture = event => { + expect(typeof event).toBe('object'); + expect(event.type).toBe('select'); + expect(event.target).toBe(node); + select(event.currentTarget); + }; + + const node = ReactDOM.render( + , + container, + ); + node.focus(); + + let nativeEvent = new MouseEvent('focus', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(nativeEvent); + expect(select).toHaveBeenCalledTimes(0); + + nativeEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }); + node.dispatchEvent(nativeEvent); + expect(select).toHaveBeenCalledTimes(0); + + nativeEvent = new MouseEvent('mouseup', {bubbles: true, cancelable: true}); + node.dispatchEvent(nativeEvent); + expect(select).toHaveBeenCalledTimes(1); + }); + // Regression test for https://github.com/facebook/react/issues/11379 it('should not wait for `mouseup` after receiving `dragend`', () => { const select = jest.fn(); diff --git a/packages/react-dom/src/legacy-events/PluginModuleType.js b/packages/react-dom/src/legacy-events/PluginModuleType.js index 7f29ccc642f4f..b7d2e325200f9 100644 --- a/packages/react-dom/src/legacy-events/PluginModuleType.js +++ b/packages/react-dom/src/legacy-events/PluginModuleType.js @@ -45,8 +45,7 @@ export type DispatchQueueItemPhase = Array; export type DispatchQueueItem = {| event: ReactSyntheticEvent, - capture: DispatchQueueItemPhase, - bubble: DispatchQueueItemPhase, + phase: DispatchQueueItemPhase, |}; export type DispatchQueue = Array; diff --git a/packages/react-interactions/events/src/dom/create-event-handle/Focus.js b/packages/react-interactions/events/src/dom/create-event-handle/Focus.js index a98404154c39a..fa9fcee0fb097 100644 --- a/packages/react-interactions/events/src/dom/create-event-handle/Focus.js +++ b/packages/react-interactions/events/src/dom/create-event-handle/Focus.js @@ -126,6 +126,7 @@ function handleGlobalFocusVisibleEvent( } const passiveObject = {passive: true}; +const passiveCaptureObject = {capture: true, passive: false}; function handleFocusVisibleTargetEvent( type: string, @@ -242,8 +243,8 @@ export function useFocus( ): void { // Setup controlled state for this useFocus hook const stateRef = useRef({isFocused: false, isFocusVisible: false}); - const focusHandle = useEvent('focus', passiveObject); - const blurHandle = useEvent('blur', passiveObject); + const focusHandle = useEvent('focus', passiveCaptureObject); + const blurHandle = useEvent('blur', passiveCaptureObject); const focusVisibleHandles = useFocusVisibleInputHandles(); useEffect(() => { @@ -329,8 +330,8 @@ export function useFocusWithin( ) { // Setup controlled state for this useFocus hook const stateRef = useRef({isFocused: false, isFocusVisible: false}); - const focusHandle = useEvent('focus', passiveObject); - const blurHandle = useEvent('blur', passiveObject); + const focusHandle = useEvent('focus', passiveCaptureObject); + const blurHandle = useEvent('blur', passiveCaptureObject); const afterBlurHandle = useEvent('afterblur', passiveObject); const beforeBlurHandle = useEvent('beforeblur', passiveObject); const focusVisibleHandles = useFocusVisibleInputHandles();