From 1dcee8656523668038551b3d1b69f7d679e769f8 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 21 Jul 2020 22:26:09 +0100 Subject: [PATCH 1/2] Regression test for media event bubbling (#19428) --- .../__tests__/ReactDOMEventListener-test.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js index b8329a56536d2..4d2ba7b69f608 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js @@ -461,4 +461,32 @@ describe('ReactDOMEventListener', () => { document.body.removeChild(container); } }); + + // Unlike browsers, we delegate media events. + // (This doesn't make a lot of sense but it would be a breaking change not to.) + it('should delegate media events even without a direct listener', () => { + const container = document.createElement('div'); + const ref = React.createRef(); + const handleVideoPlayDelegated = jest.fn(); + document.body.appendChild(container); + try { + ReactDOM.render( +
+ {/* Intentionally no handler on the target: */} +
, + container, + ); + ref.current.dispatchEvent( + new Event('play', { + bubbles: false, + }), + ); + // Regression test: ensure React tree delegation still works + // even if the actual DOM element did not have a handler. + expect(handleVideoPlayDelegated).toHaveBeenCalledTimes(1); + } finally { + document.body.removeChild(container); + } + }); }); From 356c17108f4e132371450338fa86e195f5e0acf4 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 21 Jul 2020 22:40:50 +0100 Subject: [PATCH 2/2] Remove capturePhaseEvents and separate events by bubbling (#19278) * Remove capturePhaseEvents and separate events by bubbling WIP Refine all logic Revise types Fix Fix conflicts Fix flags Fix Fix Fix test Revise Cleanup Refine Deal with replaying Fix * Add non delegated listeners unconditionally * Add media events * Fix a previously ignored test * Address feedback Co-authored-by: Dan Abramov --- .../__tests__/ReactDOMEventListener-test.js | 38 ++- .../react-dom/src/client/ReactDOMComponent.js | 109 ++++++-- .../src/client/ReactDOMEventHandle.js | 19 +- .../src/client/ReactDOMHostConfig.js | 2 +- packages/react-dom/src/client/ReactDOMRoot.js | 2 +- .../src/events/DOMModernPluginEventSystem.js | 234 +++++++++--------- .../src/events/DOMTopLevelEventTypes.js | 29 --- .../react-dom/src/events/EventSystemFlags.js | 22 +- .../src/events/ReactDOMEventListener.js | 6 +- .../src/events/ReactDOMEventReplaying.js | 34 +-- 10 files changed, 277 insertions(+), 218 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js index 4d2ba7b69f608..410e1006edb23 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js @@ -349,13 +349,10 @@ describe('ReactDOMEventListener', () => { }), ); // As of the modern event system refactor, we now support - // this on . The reason for this, is because we now - // attach all media events to the "root" or "portal" in the - // capture phase, rather than the bubble phase. This allows - // us to assign less event listeners to individual elements, - // which also nicely allows us to support more without needing - // to add more individual code paths to support various - // events that do not bubble. + // this on . The reason for this, is because we allow + // events to be attached to nodes regardless of if they + // necessary support them. This is a strange test, as this + // would never occur from normal browser behavior. expect(handleImgLoadStart).toHaveBeenCalledTimes(1); videoRef.current.dispatchEvent( @@ -374,7 +371,9 @@ describe('ReactDOMEventListener', () => { document.body.appendChild(container); const videoRef = React.createRef(); - const handleVideoPlay = jest.fn(); // We'll test this one. + // We'll test this event alone. + const handleVideoPlay = jest.fn(); + const handleVideoPlayDelegated = jest.fn(); const mediaEvents = { onAbort() {}, onCanPlay() {}, @@ -401,10 +400,20 @@ describe('ReactDOMEventListener', () => { onWaiting() {}, }; - const originalAddEventListener = document.addEventListener; + const originalDocAddEventListener = document.addEventListener; + const originalRootAddEventListener = container.addEventListener; document.addEventListener = function(type) { throw new Error( - `Did not expect to add a top-level listener for the "${type}" event.`, + `Did not expect to add a document-level listener for the "${type}" event.`, + ); + }; + container.addEventListener = function(type) { + if (type === 'mouseout' || type === 'mouseover') { + // We currently listen to it unconditionally. + return; + } + throw new Error( + `Did not expect to add a root-level listener for the "${type}" event.`, ); }; @@ -412,12 +421,11 @@ describe('ReactDOMEventListener', () => { // We expect that mounting this tree will // *not* attach handlers for any top-level events. ReactDOM.render( -
+
, container, ); @@ -429,8 +437,12 @@ describe('ReactDOMEventListener', () => { }), ); expect(handleVideoPlay).toHaveBeenCalledTimes(1); + // Unlike browsers, we delegate media events. + // (This doesn't make a lot of sense but it would be a breaking change not to.) + expect(handleVideoPlayDelegated).toHaveBeenCalledTimes(1); } finally { - document.addEventListener = originalAddEventListener; + document.addEventListener = originalDocAddEventListener; + container.addEventListener = originalRootAddEventListener; document.body.removeChild(container); } }); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index acd4c4a4d9cde..bf3222582c19f 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -85,8 +85,18 @@ import { enableDeprecatedFlareAPI, enableTrustedTypesIntegration, } from 'shared/ReactFeatureFlags'; -import {listenToReactEvent} from '../events/DOMModernPluginEventSystem'; +import { + listenToReactEvent, + mediaEventTypes, + listenToNonDelegatedEvent, +} from '../events/DOMModernPluginEventSystem'; import {getEventListenerMap} from './ReactDOMComponentTree'; +import { + TOP_LOAD, + TOP_ERROR, + TOP_TOGGLE, + TOP_INVALID, +} from '../events/DOMTopLevelEventTypes'; let didWarnInvalidHydration = false; let didWarnScriptTags = false; @@ -266,6 +276,7 @@ if (__DEV__) { export function ensureListeningTo( rootContainerInstance: Element | Node, reactPropEvent: string, + targetElement: Element | null, ): void { // If we have a comment node, then use the parent node, // which should be an element. @@ -282,7 +293,11 @@ export function ensureListeningTo( 'ensureListeningTo(): received a container that was not an element node. ' + 'This is likely a bug in React.', ); - listenToReactEvent(reactPropEvent, ((rootContainerElement: any): Element)); + listenToReactEvent( + reactPropEvent, + ((rootContainerElement: any): Element), + targetElement, + ); } function getOwnerDocumentFromRootContainer( @@ -364,7 +379,7 @@ function setInitialDOMProperties( if (__DEV__ && typeof nextProp !== 'function') { warnForInvalidEventListener(propKey, nextProp); } - ensureListeningTo(rootContainerElement, propKey); + ensureListeningTo(rootContainerElement, propKey, domElement); } } else if (nextProp != null) { setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag); @@ -527,32 +542,50 @@ export function setInitialProperties( case 'iframe': case 'object': case 'embed': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the load event. + listenToNonDelegatedEvent(TOP_LOAD, domElement); props = rawProps; break; case 'video': case 'audio': + // We listen to these events in case to ensure emulated bubble + // listeners still fire for all the media events. + for (let i = 0; i < mediaEventTypes.length; i++) { + listenToNonDelegatedEvent(mediaEventTypes[i], domElement); + } props = rawProps; break; case 'source': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the error event. + listenToNonDelegatedEvent(TOP_ERROR, domElement); props = rawProps; break; case 'img': case 'image': case 'link': - props = rawProps; - break; - case 'form': + // We listen to these events in case to ensure emulated bubble + // listeners still fire for error and load events. + listenToNonDelegatedEvent(TOP_ERROR, domElement); + listenToNonDelegatedEvent(TOP_LOAD, domElement); props = rawProps; break; case 'details': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the toggle event. + listenToNonDelegatedEvent(TOP_TOGGLE, domElement); props = rawProps; break; case 'input': ReactDOMInputInitWrapperState(domElement, rawProps); props = ReactDOMInputGetHostProps(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; case 'option': ReactDOMOptionValidateProps(domElement, rawProps); @@ -561,16 +594,22 @@ export function setInitialProperties( case 'select': ReactDOMSelectInitWrapperState(domElement, rawProps); props = ReactDOMSelectGetHostProps(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; case 'textarea': ReactDOMTextareaInitWrapperState(domElement, rawProps); props = ReactDOMTextareaGetHostProps(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; default: props = rawProps; @@ -790,7 +829,7 @@ export function diffProperties( if (__DEV__ && typeof nextProp !== 'function') { warnForInvalidEventListener(propKey, nextProp); } - ensureListeningTo(rootContainerElement, propKey); + ensureListeningTo(rootContainerElement, propKey, domElement); } if (!updatePayload && lastProp !== nextProp) { // This is a special case. If any listener updates we need to ensure @@ -900,26 +939,68 @@ export function diffHydratedProperties( // TODO: Make sure that we check isMounted before firing any of these events. switch (tag) { + case 'iframe': + case 'object': + case 'embed': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the load event. + listenToNonDelegatedEvent(TOP_LOAD, domElement); + break; + case 'video': + case 'audio': + // We listen to these events in case to ensure emulated bubble + // listeners still fire for all the media events. + for (let i = 0; i < mediaEventTypes.length; i++) { + listenToNonDelegatedEvent(mediaEventTypes[i], domElement); + } + break; + case 'source': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the error event. + listenToNonDelegatedEvent(TOP_ERROR, domElement); + break; + case 'img': + case 'image': + case 'link': + // We listen to these events in case to ensure emulated bubble + // listeners still fire for error and load events. + listenToNonDelegatedEvent(TOP_ERROR, domElement); + listenToNonDelegatedEvent(TOP_LOAD, domElement); + break; + case 'details': + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the toggle event. + listenToNonDelegatedEvent(TOP_TOGGLE, domElement); + break; case 'input': ReactDOMInputInitWrapperState(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; case 'option': ReactDOMOptionValidateProps(domElement, rawProps); break; case 'select': ReactDOMSelectInitWrapperState(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; case 'textarea': ReactDOMTextareaInitWrapperState(domElement, rawProps); + // We listen to this event in case to ensure emulated bubble + // listeners still fire for the invalid event. + listenToNonDelegatedEvent(TOP_INVALID, domElement); // For controlled components we always need to ensure we're listening // to onChange. Even if there is no listener. - ensureListeningTo(rootContainerElement, 'onChange'); + ensureListeningTo(rootContainerElement, 'onChange', domElement); break; } @@ -986,7 +1067,7 @@ export function diffHydratedProperties( if (__DEV__ && typeof nextProp !== 'function') { warnForInvalidEventListener(propKey, nextProp); } - ensureListeningTo(rootContainerElement, propKey); + ensureListeningTo(rootContainerElement, propKey, domElement); } } else if ( __DEV__ && diff --git a/packages/react-dom/src/client/ReactDOMEventHandle.js b/packages/react-dom/src/client/ReactDOMEventHandle.js index 24d6b73e95aea..84f6a2ecf293b 100644 --- a/packages/react-dom/src/client/ReactDOMEventHandle.js +++ b/packages/react-dom/src/client/ReactDOMEventHandle.js @@ -19,7 +19,6 @@ import { getClosestInstanceFromNode, getEventHandlerListeners, setEventHandlerListeners, - getEventListenerMap, getFiberFromScopeInstance, } from './ReactDOMComponentTree'; import {ELEMENT_NODE, COMMENT_NODE} from '../shared/HTMLNodeType'; @@ -87,6 +86,7 @@ function registerEventOnNearestTargetContainer( isPassiveListener: boolean | void, listenerPriority: EventPriority | void, isCapturePhaseListener: boolean, + targetElement: Element | null, ): void { // If it is, find the nearest root or portal and make it // our event handle target container. @@ -101,13 +101,11 @@ function registerEventOnNearestTargetContainer( if (targetContainer.nodeType === COMMENT_NODE) { targetContainer = ((targetContainer.parentNode: any): Element); } - const listenerMap = getEventListenerMap(targetContainer); listenToNativeEvent( topLevelType, - targetContainer, - listenerMap, - PLUGIN_EVENT_SYSTEM, isCapturePhaseListener, + targetContainer, + targetElement, isPassiveListener, listenerPriority, ); @@ -138,6 +136,7 @@ function registerReactDOMEvent( isPassiveListener, listenerPriority, isCapturePhaseListener, + targetElement, ); } else if (enableScopeAPI && isReactScope(target)) { const scopeTarget = ((target: any): ReactScopeInstance); @@ -152,18 +151,20 @@ function registerReactDOMEvent( isPassiveListener, listenerPriority, isCapturePhaseListener, + null, ); } else if (isValidEventTarget(target)) { const eventTarget = ((target: any): EventTarget); - const listenerMap = getEventListenerMap(eventTarget); + // These are valid event targets, but they are also + // non-managed React nodes. listenToNativeEvent( topLevelType, - eventTarget, - listenerMap, - PLUGIN_EVENT_SYSTEM | IS_EVENT_HANDLE_NON_MANAGED_NODE, isCapturePhaseListener, + eventTarget, + null, isPassiveListener, listenerPriority, + PLUGIN_EVENT_SYSTEM | IS_EVENT_HANDLE_NON_MANAGED_NODE, ); } else { invariant( diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 3aaf0b577cf7f..61b7ba6c867f3 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -1111,7 +1111,7 @@ export function makeOpaqueHydratingObject( } export function preparePortalMount(portalInstance: Instance): void { - listenToReactEvent('onMouseEnter', portalInstance); + listenToReactEvent('onMouseEnter', portalInstance, null); } export function prepareScopeUpdate( diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 31a869ba2d794..d9e446749c120 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -145,7 +145,7 @@ function createRootImpl( containerNodeType !== DOCUMENT_FRAGMENT_NODE && containerNodeType !== DOCUMENT_NODE ) { - ensureListeningTo(container, 'onMouseEnter'); + ensureListeningTo(container, 'onMouseEnter', null); } if (mutableSources) { diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index 81ff02d5d34e1..732840b5085ef 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -8,23 +8,24 @@ */ import type {TopLevelType, DOMTopLevelEventType} from './TopLevelEventTypes'; -import type {EventSystemFlags} from './EventSystemFlags'; +import { + type EventSystemFlags, + SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE, + IS_LEGACY_FB_SUPPORT_MODE, + SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS, +} from './EventSystemFlags'; import type {AnyNativeEvent} from './PluginModuleType'; import type {ReactSyntheticEvent} from './ReactSyntheticEventType'; -import type { - ElementListenerMap, - ElementListenerMapEntry, -} from '../client/ReactDOMComponentTree'; +import type {ElementListenerMapEntry} from '../client/ReactDOMComponentTree'; import type {EventPriority} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import {registrationNameDependencies} from './EventRegistry'; import { PLUGIN_EVENT_SYSTEM, - LEGACY_FB_SUPPORT, - IS_REPLAYED, IS_CAPTURE_PHASE, IS_EVENT_HANDLE_NON_MANAGED_NODE, + IS_NON_DELEGATED, } from './EventSystemFlags'; import { @@ -67,7 +68,6 @@ import { TOP_PLAYING, TOP_CLICK, TOP_SELECTION_CHANGE, - TOP_AFTER_BLUR, getRawEventName, } from './DOMTopLevelEventTypes'; import { @@ -150,8 +150,7 @@ function extractEvents( targetContainer, ); const shouldProcessPolyfillPlugins = - (eventSystemFlags & IS_CAPTURE_PHASE) === 0 || - capturePhaseEvents.has(topLevelType); + (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0; // We don't process these events unless we are in the // event's native "bubble" phase, which means that we're // not in the capture phase. That's because we emulate @@ -209,13 +208,8 @@ function extractEvents( } } -export const capturePhaseEvents: Set = new Set([ - TOP_SCROLL, - TOP_LOAD, - TOP_ABORT, - TOP_CANCEL, - TOP_CLOSE, - TOP_INVALID, +// List of events that need to be individually attached to media elements. +export const mediaEventTypes = [ TOP_ABORT, TOP_CAN_PLAY, TOP_CAN_PLAY_THROUGH, @@ -239,11 +233,23 @@ export const capturePhaseEvents: Set = new Set([ TOP_TIME_UPDATE, TOP_VOLUME_CHANGE, TOP_WAITING, -]); +]; -if (enableCreateEventHandleAPI) { - capturePhaseEvents.add(TOP_AFTER_BLUR); -} +// We should not delegate these events to the container, but rather +// set them on the actual target element itself. This is primarily +// because these events do not consistently bubble in the DOM. +export const nonDelegatedEvents: Set = new Set([ + TOP_SCROLL, + TOP_LOAD, + TOP_CANCEL, + TOP_CLOSE, + TOP_INVALID, + // In order to reduce bytes, we insert the above array of media events + // into this Set. Note: some events like "load" and "error" aren't + // exclusively media events, but rather than duplicate them, we just + // take them from the media events array. + ...mediaEventTypes, +]); function executeDispatch( event: ReactSyntheticEvent, @@ -327,22 +333,53 @@ function shouldUpgradeListener( ); } +export function listenToNonDelegatedEvent( + topLevelType: DOMTopLevelEventType, + targetElement: Element, +): void { + const isCapturePhaseListener = false; + const listenerMap = getEventListenerMap(targetElement); + const listenerMapKey = getListenerMapKey( + topLevelType, + isCapturePhaseListener, + ); + const listenerEntry = ((listenerMap.get( + listenerMapKey, + ): any): ElementListenerMapEntry | void); + if (listenerEntry === undefined) { + const listener = addTrappedEventListener( + targetElement, + topLevelType, + PLUGIN_EVENT_SYSTEM | IS_NON_DELEGATED, + isCapturePhaseListener, + ); + listenerMap.set(listenerMapKey, {passive: false, listener}); + } +} + export function listenToNativeEvent( topLevelType: DOMTopLevelEventType, - target: EventTarget, - listenerMap: ElementListenerMap, - eventSystemFlags: EventSystemFlags, isCapturePhaseListener: boolean, + rootContainerElement: EventTarget, + targetElement: Element | null, isPassiveListener?: boolean, - priority?: EventPriority, + listenerPriority?: EventPriority, + eventSystemFlags?: EventSystemFlags = PLUGIN_EVENT_SYSTEM, ): void { + let target = rootContainerElement; // TOP_SELECTION_CHANGE needs to be attached to the document // otherwise it won't capture incoming events that are only // triggered on the document directly. if (topLevelType === TOP_SELECTION_CHANGE) { - target = (target: any).ownerDocument || target; - listenerMap = getEventListenerMap(target); + target = (rootContainerElement: any).ownerDocument; + } + // If the event can be delegated, we can register it to the root container. + // Otherwise, we should register the event to the target element. + if (targetElement !== null && nonDelegatedEvents.has(topLevelType)) { + eventSystemFlags |= IS_NON_DELEGATED; + target = targetElement; } + const listenerMap = getEventListenerMap(target); const listenerMapKey = getListenerMapKey( topLevelType, isCapturePhaseListener, @@ -375,50 +412,51 @@ export function listenToNativeEvent( isCapturePhaseListener, false, isPassiveListener, - priority, + listenerPriority, ); listenerMap.set(listenerMapKey, {passive: isPassiveListener, listener}); } } -function isCaptureRegistrationName(registrationName: string): boolean { - const len = registrationName.length; - return registrationName.substr(len - 7) === 'Capture'; -} - export function listenToReactEvent( - reactPropEvent: string, + reactEvent: string, rootContainerElement: Element, + targetElement: Element | null, ): void { - const listenerMap = getEventListenerMap(rootContainerElement); - // 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 dependencies = registrationNameDependencies[reactEvent]; const dependenciesLength = dependencies.length; // If the dependencies length is 1, that means we're not using a polyfill - // plugin like ChangeEventPlugin, BeforeInputPlugin, EnterLeavePlugin and - // SelectEventPlugin. SimpleEventPlugin always only has a single dependency. - // Given this, we know that we never need to apply capture phase event - // listeners to anything other than the SimpleEventPlugin. - const registrationCapturePhase = - isCaptureRegistrationName(reactPropEvent) && dependenciesLength === 1; + // plugin like ChangeEventPlugin, BeforeInputPlugin, EnterLeavePlugin + // and SelectEventPlugin. We always use the native bubble event phase for + // these plugins and emulate two phase event dispatching. SimpleEventPlugin + // always only has a single dependency and SimpleEventPlugin events also + // use either the native capture event phase or bubble event phase, there + // is no emulation (except for focus/blur, but that will be removed soon). + const isPolyfillEventPlugin = dependenciesLength !== 1; - for (let i = 0; i < dependenciesLength; i++) { - const dependency = dependencies[i]; - const capture = - capturePhaseEvents.has(dependency) || registrationCapturePhase; + if (isPolyfillEventPlugin) { + const listenerMap = getEventListenerMap(rootContainerElement); + // For optimization, we register plugins on the listener map, so we + // don't need to check each of their dependencies each time. + if (!listenerMap.has(reactEvent)) { + listenerMap.set(reactEvent, null); + for (let i = 0; i < dependenciesLength; i++) { + listenToNativeEvent( + dependencies[i], + false, + rootContainerElement, + targetElement, + ); + } + } + } else { + // Check if the react event ends in "Capture" + const isCapturePhaseListener = reactEvent.substr(-7) === 'Capture'; listenToNativeEvent( - dependency, + dependencies[0], + isCapturePhaseListener, rootContainerElement, - listenerMap, - PLUGIN_EVENT_SYSTEM, - capture, + targetElement, ); } } @@ -509,13 +547,10 @@ function addTrappedEventListener( return unsubscribeListener; } -function willDeferLaterForLegacyFBSupport( +function deferClickToDocumentForLegacyFBSupport( topLevelType: DOMTopLevelEventType, targetContainer: EventTarget, -): boolean { - if (topLevelType !== TOP_CLICK) { - return false; - } +): void { // We defer all click events with legacy FB support mode on. // This means we add a one time event listener to trigger // after the FB delegated listeners fire. @@ -523,11 +558,10 @@ function willDeferLaterForLegacyFBSupport( addTrappedEventListener( targetContainer, topLevelType, - PLUGIN_EVENT_SYSTEM | LEGACY_FB_SUPPORT, + PLUGIN_EVENT_SYSTEM | IS_LEGACY_FB_SUPPORT_MODE, false, isDeferredListenerForLegacyFBSupport, ); - return true; } function isMatchingRootContainer( @@ -549,10 +583,10 @@ export function dispatchEventForPluginEventSystem( targetContainer: EventTarget, ): void { let ancestorInst = targetInst; - if (eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE) { - // For TargetEvent nodes (i.e. document, window) - ancestorInst = null; - } else { + if ( + (eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE) === 0 && + (eventSystemFlags & IS_NON_DELEGATED) === 0 + ) { const targetContainerNode = ((targetContainer: any): Node); // If we are using the legacy FB support flag, we @@ -560,17 +594,15 @@ export function dispatchEventForPluginEventSystem( // 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 && - // We don't apply this during capture phase. - (eventSystemFlags & IS_CAPTURE_PHASE) === 0 && - willDeferLaterForLegacyFBSupport(topLevelType, targetContainer) + // If our event flags match the required flags for entering + // FB legacy mode and we are prcocessing the "click" event, + // then we can defer the event to the "document", to allow + // for legacy FB support, where the expected behavior was to + // match React < 16 behavior of delegated clicks to the doc. + topLevelType === TOP_CLICK && + (eventSystemFlags & SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE) === 0 ) { + deferClickToDocumentForLegacyFBSupport(topLevelType, targetContainer); return; } if (targetInst !== null) { @@ -691,11 +723,6 @@ export function accumulateSinglePhaseListeners( let instance = targetFiber; let lastHostComponent = null; const targetType = event.type; - // 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) { @@ -721,17 +748,10 @@ export function accumulateSinglePhaseListeners( listeners.push( createDispatchListener(instance, callback, currentTarget), ); - } else if (!isCapturePhaseListener) { - const entry = createDispatchListener( - instance, - callback, - currentTarget, + } else if (!isCapturePhaseListener && !inCapturePhase) { + listeners.push( + createDispatchListener(instance, callback, currentTarget), ); - if (shouldEmulateTwoPhase) { - listeners.unshift(entry); - } else if (!inCapturePhase) { - listeners.push(entry); - } } } } @@ -746,19 +766,12 @@ export function accumulateSinglePhaseListeners( ); } } - if (bubbled !== null) { + if (bubbled !== null && !inCapturePhase) { const bubbleListener = getListener(instance, bubbled); if (bubbleListener != null) { - const entry = createDispatchListener( - instance, - bubbleListener, - currentTarget, + listeners.push( + createDispatchListener(instance, bubbleListener, currentTarget), ); - if (shouldEmulateTwoPhase) { - listeners.unshift(entry); - } else if (!inCapturePhase) { - listeners.push(entry); - } } } } else if ( @@ -786,17 +799,10 @@ export function accumulateSinglePhaseListeners( listeners.push( createDispatchListener(instance, callback, lastCurrentTarget), ); - } else if (!isCapturePhaseListener) { - const entry = createDispatchListener( - instance, - callback, - lastCurrentTarget, + } else if (!isCapturePhaseListener && !inCapturePhase) { + listeners.push( + createDispatchListener(instance, callback, lastCurrentTarget), ); - if (shouldEmulateTwoPhase) { - listeners.unshift(entry); - } else if (!inCapturePhase) { - listeners.push(entry); - } } } } diff --git a/packages/react-dom/src/events/DOMTopLevelEventTypes.js b/packages/react-dom/src/events/DOMTopLevelEventTypes.js index 140deafc0be06..e8bdc7eaccc54 100644 --- a/packages/react-dom/src/events/DOMTopLevelEventTypes.js +++ b/packages/react-dom/src/events/DOMTopLevelEventTypes.js @@ -153,35 +153,6 @@ export const TOP_BEFORE_BLUR = unsafeCastStringToDOMTopLevelType('beforeblur'); export const TOP_FOCUS_IN = unsafeCastStringToDOMTopLevelType('focusin'); export const TOP_FOCUS_OUT = unsafeCastStringToDOMTopLevelType('focusout'); -// List of events that need to be individually attached to media elements. -// Note that events in this list will *not* be listened to at the top level -// unless they're explicitly listed in `ReactBrowserEventEmitter.listenTo`. -export const mediaEventTypes = [ - TOP_ABORT, - TOP_CAN_PLAY, - TOP_CAN_PLAY_THROUGH, - TOP_DURATION_CHANGE, - TOP_EMPTIED, - TOP_ENCRYPTED, - TOP_ENDED, - TOP_ERROR, - TOP_LOADED_DATA, - TOP_LOADED_METADATA, - TOP_LOAD_START, - TOP_PAUSE, - TOP_PLAY, - TOP_PLAYING, - TOP_PROGRESS, - TOP_RATE_CHANGE, - TOP_SEEKED, - TOP_SEEKING, - TOP_STALLED, - TOP_SUSPEND, - TOP_TIME_UPDATE, - TOP_VOLUME_CHANGE, - TOP_WAITING, -]; - export function getRawEventName(topLevelType: DOMTopLevelEventType): string { return unsafeCastDOMTopLevelTypeToString(topLevelType); } diff --git a/packages/react-dom/src/events/EventSystemFlags.js b/packages/react-dom/src/events/EventSystemFlags.js index 38cb9196b589e..a6b34ab3b3a3d 100644 --- a/packages/react-dom/src/events/EventSystemFlags.js +++ b/packages/react-dom/src/events/EventSystemFlags.js @@ -12,9 +12,21 @@ export type EventSystemFlags = number; export const PLUGIN_EVENT_SYSTEM = 1; export const RESPONDER_EVENT_SYSTEM = 1 << 1; export const IS_EVENT_HANDLE_NON_MANAGED_NODE = 1 << 2; -export const IS_CAPTURE_PHASE = 1 << 3; -export const IS_PASSIVE = 1 << 4; -export const PASSIVE_NOT_SUPPORTED = 1 << 5; +export const IS_NON_DELEGATED = 1 << 3; +export const IS_CAPTURE_PHASE = 1 << 4; +export const IS_PASSIVE = 1 << 5; export const IS_REPLAYED = 1 << 6; -export const IS_FIRST_ANCESTOR = 1 << 7; -export const LEGACY_FB_SUPPORT = 1 << 8; +export const IS_LEGACY_FB_SUPPORT_MODE = 1 << 7; +// This is used by React Flare +export const PASSIVE_NOT_SUPPORTED = 1 << 8; + +export const SHOULD_NOT_DEFER_CLICK_FOR_FB_SUPPORT_MODE = + IS_LEGACY_FB_SUPPORT_MODE | IS_REPLAYED | IS_CAPTURE_PHASE; + +// 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. +// We also don't want to defer during event replaying. +export const SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS = + IS_EVENT_HANDLE_NON_MANAGED_NODE | IS_NON_DELEGATED | IS_CAPTURE_PHASE; diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index fad2d1d56c11a..ace0595305f40 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -33,7 +33,7 @@ import { import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; import { type EventSystemFlags, - LEGACY_FB_SUPPORT, + IS_LEGACY_FB_SUPPORT_MODE, PLUGIN_EVENT_SYSTEM, RESPONDER_EVENT_SYSTEM, } from './EventSystemFlags'; @@ -132,9 +132,9 @@ function dispatchDiscreteEvent( ) { if ( !enableLegacyFBSupport || - // If we have Legacy FB support, it means we've already + // If we are in Legacy FB support mode, it means we've already // flushed for this event and we don't need to do it again. - (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 + (eventSystemFlags & IS_LEGACY_FB_SUPPORT_MODE) === 0 ) { flushDiscreteUpdatesIfNeeded(nativeEvent.timeStamp); } diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index a486cf4e347fb..f94f5d61e7ba0 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -108,8 +108,6 @@ import { TOP_KEY_UP, TOP_INPUT, TOP_TEXT_INPUT, - TOP_CLOSE, - TOP_CANCEL, TOP_COPY, TOP_CUT, TOP_PASTE, @@ -129,11 +127,8 @@ import { TOP_FOCUS_IN, TOP_FOCUS_OUT, } from './DOMTopLevelEventTypes'; -import {IS_REPLAYED, PLUGIN_EVENT_SYSTEM} from './EventSystemFlags'; -import { - listenToNativeEvent, - capturePhaseEvents, -} from './DOMModernPluginEventSystem'; +import {IS_REPLAYED} from './EventSystemFlags'; +import {listenToNativeEvent} from './DOMModernPluginEventSystem'; import {addResponderEventSystemEvent} from './DeprecatedDOMEventResponderSystem'; type QueuedReplayableEvent = {| @@ -198,8 +193,6 @@ const discreteReplayableEvents = [ TOP_KEY_UP, TOP_INPUT, TOP_TEXT_INPUT, - TOP_CLOSE, - TOP_CANCEL, TOP_COPY, TOP_CUT, TOP_PASTE, @@ -232,16 +225,8 @@ export function isReplayableDiscreteEvent( function trapReplayableEventForContainer( topLevelType: DOMTopLevelEventType, container: Container, - listenerMap: ElementListenerMap, ) { - const capture = capturePhaseEvents.has(topLevelType); - listenToNativeEvent( - topLevelType, - ((container: any): Element), - listenerMap, - PLUGIN_EVENT_SYSTEM, - capture, - ); + listenToNativeEvent(topLevelType, false, ((container: any): Element), null); } function trapReplayableEventForDocument( @@ -273,23 +258,14 @@ export function eagerlyTrapReplayableEvents( document: Document, ) { const listenerMapForDoc = getEventListenerMap(document); - const listenerMapForContainer = getEventListenerMap(container); // Discrete discreteReplayableEvents.forEach(topLevelType => { - trapReplayableEventForContainer( - topLevelType, - container, - listenerMapForContainer, - ); + trapReplayableEventForContainer(topLevelType, container); trapReplayableEventForDocument(topLevelType, document, listenerMapForDoc); }); // Continuous continuousReplayableEvents.forEach(topLevelType => { - trapReplayableEventForContainer( - topLevelType, - container, - listenerMapForContainer, - ); + trapReplayableEventForContainer(topLevelType, container); trapReplayableEventForDocument(topLevelType, document, listenerMapForDoc); }); }