diff --git a/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js b/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js index 64099b4b8fed..44c94a4cff62 100644 --- a/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js @@ -17,6 +17,7 @@ let ReactDOMComponentTree; let listenToEvent; let ReactDOMEventListener; let ReactTestUtils; +let ReactFeatureFlags; let idCallOrder; const recordID = function(id) { @@ -60,13 +61,20 @@ describe('ReactBrowserEventEmitter', () => { jest.resetModules(); LISTENER.mockClear(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); EventPluginGetListener = require('legacy-events/getListener').default; EventPluginRegistry = require('legacy-events/EventPluginRegistry'); React = require('react'); ReactDOM = require('react-dom'); ReactDOMComponentTree = require('../client/ReactDOMComponentTree'); - listenToEvent = require('../events/DOMLegacyEventPluginSystem') - .legacyListenToEvent; + if (ReactFeatureFlags.enableModernEventSystem) { + listenToEvent = require('../events/DOMModernPluginEventSystem') + .listenToEvent; + } else { + listenToEvent = require('../events/DOMLegacyEventPluginSystem') + .legacyListenToEvent; + } + ReactDOMEventListener = require('../events/ReactDOMEventListener'); ReactTestUtils = require('react-dom/test-utils'); diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js index a1cd76874beb..729044b6b4b9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js @@ -12,6 +12,7 @@ describe('ReactDOMEventListener', () => { let React; let ReactDOM; + let ReactFeatureFlags = require('shared/ReactFeatureFlags'); beforeEach(() => { jest.resetModules(); @@ -19,29 +20,33 @@ describe('ReactDOMEventListener', () => { ReactDOM = require('react-dom'); }); - it('should dispatch events from outside React tree', () => { - const mock = jest.fn(); + // We attached events to roots with the modern system, + // so this test is no longer valid. + if (!ReactFeatureFlags.enableModernEventSystem) { + it('should dispatch events from outside React tree', () => { + const mock = jest.fn(); - const container = document.createElement('div'); - const node = ReactDOM.render(
, container); - const otherNode = document.createElement('h1'); - document.body.appendChild(container); - document.body.appendChild(otherNode); + const container = document.createElement('div'); + const node = ReactDOM.render(
, container); + const otherNode = document.createElement('h1'); + document.body.appendChild(container); + document.body.appendChild(otherNode); - try { - otherNode.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - cancelable: true, - relatedTarget: node, - }), - ); - expect(mock).toBeCalled(); - } finally { - document.body.removeChild(container); - document.body.removeChild(otherNode); - } - }); + try { + otherNode.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: node, + }), + ); + expect(mock).toBeCalled(); + } finally { + document.body.removeChild(container); + document.body.removeChild(otherNode); + } + }); + } describe('Propagation', () => { it('should propagate events one level down', () => { @@ -189,9 +194,25 @@ describe('ReactDOMEventListener', () => { // The first call schedules a render of '1' into the 'Child'. // However, we're batching so it isn't flushed yet. expect(mock.mock.calls[0][0]).toBe('Child'); - // The first call schedules a render of '2' into the 'Child'. - // We're still batching so it isn't flushed yet either. - expect(mock.mock.calls[1][0]).toBe('Child'); + if (ReactFeatureFlags.enableModernEventSystem) { + // As we have two roots, it means we have two event listeners. + // This also means we enter the event batching phase twice, + // flushing the child to be 1. + + // We don't have any good way of knowing if another event will + // occur because another event handler might invoke + // stopPropagation() along the way. After discussions internally + // with Sebastian, it seems that for now over-flushing should + // be fine, especially as the new event system is a breaking + // change anyway. We can maybe revisit this later as part of + // the work to refine this in the scheduler (maybe by leveraging + // isInputPending?). + expect(mock.mock.calls[1][0]).toBe('1'); + } else { + // The first call schedules a render of '2' into the 'Child'. + // We're still batching so it isn't flushed yet either. + expect(mock.mock.calls[1][0]).toBe('Child'); + } // By the time we leave the handler, the second update is flushed. expect(childNode.textContent).toBe('2'); } finally { @@ -362,13 +383,25 @@ describe('ReactDOMEventListener', () => { bubbles: false, }), ); - // Historically, we happened to not support onLoadStart - // on , and this test documents that lack of support. - // If we decide to support it in the future, we should change - // this line to expect 1 call. Note that fixing this would - // be simple but would require attaching a handler to each - // . So far nobody asked us for it. - expect(handleImgLoadStart).toHaveBeenCalledTimes(0); + if (ReactFeatureFlags.enableModernEventSystem) { + // 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. + expect(handleImgLoadStart).toHaveBeenCalledTimes(1); + } else { + // Historically, we happened to not support onLoadStart + // on , and this test documents that lack of support. + // If we decide to support it in the future, we should change + // this line to expect 1 call. Note that fixing this would + // be simple but would require attaching a handler to each + // . So far nobody asked us for it. + expect(handleImgLoadStart).toHaveBeenCalledTimes(0); + } videoRef.current.dispatchEvent( new ProgressEvent('loadstart', { diff --git a/packages/react-dom/src/__tests__/ReactTreeTraversal-test.js b/packages/react-dom/src/__tests__/ReactTreeTraversal-test.js index 11428dd42901..5b0379827f0e 100644 --- a/packages/react-dom/src/__tests__/ReactTreeTraversal-test.js +++ b/packages/react-dom/src/__tests__/ReactTreeTraversal-test.js @@ -11,6 +11,7 @@ let React; let ReactDOM; +let ReactFeatureFlags = require('shared/ReactFeatureFlags'); const ChildComponent = ({id, eventHandler}) => (
{ expect(mockFn.mock.calls).toEqual(expectedCalls); }); - it('should enter from the window', () => { - const enterNode = document.getElementById('P_P1_C1__DIV'); - - const expectedCalls = [ - ['P', 'mouseenter'], - ['P_P1', 'mouseenter'], - ['P_P1_C1__DIV', 'mouseenter'], - ]; - - outerNode1.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - cancelable: true, - relatedTarget: enterNode, - }), - ); - - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); - - it('should enter from the window to the shallowest', () => { - const enterNode = document.getElementById('P'); - - const expectedCalls = [['P', 'mouseenter']]; - - outerNode1.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - cancelable: true, - relatedTarget: enterNode, - }), - ); - - expect(mockFn.mock.calls).toEqual(expectedCalls); - }); + // This will not work with the modern event system that + // attaches event listeners to roots as the event below + // is being triggered on a node that React does not listen + // to any more. Instead we should fire mouseover. + if (ReactFeatureFlags.enableModernEventSystem) { + it('should enter from the window', () => { + const enterNode = document.getElementById('P_P1_C1__DIV'); + + const expectedCalls = [ + ['P', 'mouseenter'], + ['P_P1', 'mouseenter'], + ['P_P1_C1__DIV', 'mouseenter'], + ]; + + enterNode.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: outerNode1, + }), + ); + + expect(mockFn.mock.calls).toEqual(expectedCalls); + }); + } else { + it('should enter from the window', () => { + const enterNode = document.getElementById('P_P1_C1__DIV'); + + const expectedCalls = [ + ['P', 'mouseenter'], + ['P_P1', 'mouseenter'], + ['P_P1_C1__DIV', 'mouseenter'], + ]; + + outerNode1.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: enterNode, + }), + ); + + expect(mockFn.mock.calls).toEqual(expectedCalls); + }); + } + + // This will not work with the modern event system that + // attaches event listeners to roots as the event below + // is being triggered on a node that React does not listen + // to any more. Instead we should fire mouseover. + if (ReactFeatureFlags.enableModernEventSystem) { + it('should enter from the window to the shallowest', () => { + const enterNode = document.getElementById('P'); + + const expectedCalls = [['P', 'mouseenter']]; + + enterNode.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: outerNode1, + }), + ); + + expect(mockFn.mock.calls).toEqual(expectedCalls); + }); + } else { + it('should enter from the window to the shallowest', () => { + const enterNode = document.getElementById('P'); + + const expectedCalls = [['P', 'mouseenter']]; + + outerNode1.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: enterNode, + }), + ); + + expect(mockFn.mock.calls).toEqual(expectedCalls); + }); + } it('should leave to the window', () => { const leaveNode = document.getElementById('P_P1_C1__DIV'); diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index aeeba5933d6a..b8c07cc4e914 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -11,10 +11,16 @@ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import type {PluginModule} from 'legacy-events/PluginModuleType'; +import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; 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 {trapEventForPluginEventSystem} from './ReactDOMEventListener'; +import getEventTarget from './getEventTarget'; import {getListenerMapForElement} from './DOMEventListenerMap'; import { TOP_FOCUS, @@ -87,6 +93,49 @@ const capturePhaseEvents = new Set([ TOP_WAITING, ]); +const isArray = Array.isArray; + +function dispatchEventsForPlugins( + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, + targetInst: null | Fiber, + rootContainer: Element | Document, +): void { + const nativeEventTarget = getEventTarget(nativeEvent); + const syntheticEvents: Array = []; + + for (let i = 0; i < plugins.length; i++) { + const possiblePlugin: PluginModule = plugins[i]; + if (possiblePlugin !== undefined) { + const extractedEvents = possiblePlugin.extractEvents( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + rootContainer, + ); + if (isArray(extractedEvents)) { + // Flow complains about @@iterator being missing in ReactSyntheticEvent, + // so we cast to avoid the Flow error. + const arrOfExtractedEvents = ((extractedEvents: any): Array); + syntheticEvents.push(...arrOfExtractedEvents); + } else if (extractedEvents != null) { + syntheticEvents.push(extractedEvents); + } + } + } + for (let i = 0; i < syntheticEvents.length; i++) { + const syntheticEvent = syntheticEvents[i]; + executeDispatchesInOrder(syntheticEvent); + // Release the event from the pool if needed + if (!syntheticEvent.isPersistent()) { + syntheticEvent.constructor.release(syntheticEvent); + } + } +} + export function listenToTopLevelEvent( topLevelType: DOMTopLevelEventType, rootContainerElement: Element, @@ -123,5 +172,15 @@ export function dispatchEventForPluginEventSystem( targetInst: null | Fiber, rootContainer: Document | Element, ): void { - // TODO + let ancestorInst = targetInst; + + batchedEventUpdates(() => + dispatchEventsForPlugins( + topLevelType, + eventSystemFlags, + nativeEvent, + ancestorInst, + rootContainer, + ), + ); } diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index de9b130462fb..4130cd709ad2 100644 --- a/packages/react-dom/src/events/EnterLeaveEventPlugin.js +++ b/packages/react-dom/src/events/EnterLeaveEventPlugin.js @@ -22,6 +22,7 @@ import { } from '../client/ReactDOMComponentTree'; import {HostComponent, HostText} from 'shared/ReactWorkTags'; import {getNearestMountedFiber} from 'react-reconciler/reflection'; +import {enableModernEventSystem} from 'shared/ReactFeatureFlags'; const eventTypes = { mouseEnter: { @@ -64,16 +65,26 @@ const EnterLeaveEventPlugin = { const isOutEvent = topLevelType === TOP_MOUSE_OUT || topLevelType === TOP_POINTER_OUT; - if ( - isOverEvent && - (eventSystemFlags & IS_REPLAYED) === 0 && - (nativeEvent.relatedTarget || nativeEvent.fromElement) - ) { - // If this is an over event with a target, then we've already dispatched - // the event in the out event of the other target. If this is replayed, - // then it's because we couldn't dispatch against this target previously - // so we have to do it now instead. - return null; + if (isOverEvent && (eventSystemFlags & IS_REPLAYED) === 0) { + const related = nativeEvent.relatedTarget || nativeEvent.fromElement; + if (related) { + if (enableModernEventSystem) { + // Due to the fact we don't add listeners to the document with the + // modern event system and instead attach listeners to roots, we + // need to handle the over event case. To ensure this, we just need to + // make sure the node that we're coming from is managed by React. + const inst = getClosestInstanceFromNode(related); + if (inst !== null) { + return null; + } + } else { + // If this is an over event with a target, then we've already dispatched + // the event in the out event of the other target. If this is replayed, + // then it's because we couldn't dispatch against this target previously + // so we have to do it now instead. + return null; + } + } } if (!isOutEvent && !isOverEvent) { @@ -163,11 +174,13 @@ const EnterLeaveEventPlugin = { accumulateEnterLeaveDispatches(leave, enter, from, to); - // If we are not processing the first ancestor, then we - // should not process the same nativeEvent again, as we - // will have already processed it in the first ancestor. - if ((eventSystemFlags & IS_FIRST_ANCESTOR) === 0) { - return [leave]; + if (!enableModernEventSystem) { + // If we are not processing the first ancestor, then we + // should not process the same nativeEvent again, as we + // will have already processed it in the first ancestor. + if ((eventSystemFlags & IS_FIRST_ANCESTOR) === 0) { + return [leave]; + } } return [leave, enter]; diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js new file mode 100644 index 000000000000..66c670b7ca3e --- /dev/null +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -0,0 +1,122 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactDOM; + +function dispatchClickEvent(element) { + const event = document.createEvent('Event'); + event.initEvent('click', true, true); + element.dispatchEvent(event); +} + +describe('DOMModernPluginEventSystem', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + + React = require('react'); + ReactDOM = require('react-dom'); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('handle propagation of click events', () => { + 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() { + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + 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]); + }); + + it('handle propagation of focus events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); + + function Test() { + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + divElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(3); + expect(onFocusCapture).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]); + }); +});