From a57382cd28c3c0e70781b8dcbad02278f84b6b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 20 Feb 2020 23:56:40 -0800 Subject: [PATCH] Rename Chunks API to Blocks (#18086) Sounds like this is the name we're going with. This also helps us distinguish it from other "chunking" implementation details. Add Modern Event System fork Fix FIX FIX Refine comment Support handling of listening to comment nodes Update error codes FXI FXI Add test cases and revise traversal Fix Refactor twak Rename Address traversal of comment nodes Fix Fix WIP --- .../ReactBrowserEventEmitter-test.internal.js | 12 +- .../__tests__/ReactDOMEventListener-test.js | 95 ++- .../src/__tests__/ReactTreeTraversal-test.js | 119 ++- .../react-dom/src/client/ReactDOMComponent.js | 130 ++- .../src/events/DOMModernPluginEventSystem.js | 264 ++++++ .../src/events/EnterLeaveEventPlugin.js | 43 +- .../src/events/ReactDOMEventListener.js | 96 ++- .../src/events/ReactDOMEventReplaying.js | 32 +- ...OMModernPluginEventSystem-test.internal.js | 786 ++++++++++++++++++ packages/shared/ReactTreeTraversal.js | 54 +- scripts/error-codes/codes.json | 3 +- 11 files changed, 1476 insertions(+), 158 deletions(-) create mode 100644 packages/react-dom/src/events/DOMModernPluginEventSystem.js create mode 100644 packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js diff --git a/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js b/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js index 64099b4b8fed4..44c94a4cff623 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 a1cd76874beb7..729044b6b4b91 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 11428dd429017..5b0379827f0ee 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/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 854137dec6fa6..c00fc93008d90 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -76,7 +76,12 @@ import { shouldRemoveAttribute, } from '../shared/DOMProperty'; import assertValidProps from '../shared/assertValidProps'; -import {DOCUMENT_NODE, DOCUMENT_FRAGMENT_NODE} from '../shared/HTMLNodeType'; +import { + DOCUMENT_NODE, + DOCUMENT_FRAGMENT_NODE, + ELEMENT_NODE, + COMMENT_NODE, +} from '../shared/HTMLNodeType'; import isCustomComponent from '../shared/isCustomComponent'; import possibleStandardNames from '../shared/possibleStandardNames'; import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook'; @@ -86,8 +91,11 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO import { enableDeprecatedFlareAPI, enableTrustedTypesIntegration, + enableModernEventSystem, } from 'shared/ReactFeatureFlags'; +import invariant from 'shared/invariant'; import {legacyListenToEvent} from '../events/DOMLegacyEventPluginSystem'; +import {listenToEvent} from '../events/DOMModernPluginEventSystem'; let didWarnInvalidHydration = false; let didWarnShadyDOM = false; @@ -266,13 +274,31 @@ function ensureListeningTo( rootContainerElement: Element | Node, registrationName: string, ): void { - const isDocumentOrFragment = - rootContainerElement.nodeType === DOCUMENT_NODE || - rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE; - const doc = isDocumentOrFragment - ? rootContainerElement - : rootContainerElement.ownerDocument; - legacyListenToEvent(registrationName, doc); + if (enableModernEventSystem) { + // If we have a comment node, then use the parent node, + // which should be an element. + const container = + rootContainerElement.nodeType === COMMENT_NODE + ? rootContainerElement.parentNode + : rootContainerElement; + // Containers can only ever be element nodes. We do not + // want to register events to document fragments or documents + // with the modern plugin event system. + invariant( + container && container.nodeType === ELEMENT_NODE, + 'ensureListeningTo(): recieved a container that was not an element node. ' + + 'This is likely a bug in React.', + ); + listenToEvent(registrationName, ((container: any): Element)); + } else { + const isDocumentOrFragment = + rootContainerElement.nodeType === DOCUMENT_NODE || + rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE; + const doc = isDocumentOrFragment + ? rootContainerElement + : rootContainerElement.ownerDocument; + legacyListenToEvent(registrationName, doc); + } } function getOwnerDocumentFromRootContainer( @@ -529,41 +555,55 @@ export function setInitialProperties( case 'iframe': case 'object': case 'embed': - trapBubbledEvent(TOP_LOAD, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_LOAD, domElement); + } props = rawProps; break; case 'video': case 'audio': - // Create listener for each media event - for (let i = 0; i < mediaEventTypes.length; i++) { - trapBubbledEvent(mediaEventTypes[i], domElement); + if (!enableModernEventSystem) { + // Create listener for each media event + for (let i = 0; i < mediaEventTypes.length; i++) { + trapBubbledEvent(mediaEventTypes[i], domElement); + } } props = rawProps; break; case 'source': - trapBubbledEvent(TOP_ERROR, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_ERROR, domElement); + } props = rawProps; break; case 'img': case 'image': case 'link': - trapBubbledEvent(TOP_ERROR, domElement); - trapBubbledEvent(TOP_LOAD, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_ERROR, domElement); + trapBubbledEvent(TOP_LOAD, domElement); + } props = rawProps; break; case 'form': - trapBubbledEvent(TOP_RESET, domElement); - trapBubbledEvent(TOP_SUBMIT, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_RESET, domElement); + trapBubbledEvent(TOP_SUBMIT, domElement); + } props = rawProps; break; case 'details': - trapBubbledEvent(TOP_TOGGLE, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_TOGGLE, domElement); + } props = rawProps; break; case 'input': ReactDOMInputInitWrapperState(domElement, rawProps); props = ReactDOMInputGetHostProps(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); @@ -575,7 +615,9 @@ export function setInitialProperties( case 'select': ReactDOMSelectInitWrapperState(domElement, rawProps); props = ReactDOMSelectGetHostProps(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); @@ -583,7 +625,9 @@ export function setInitialProperties( case 'textarea': ReactDOMTextareaInitWrapperState(domElement, rawProps); props = ReactDOMTextareaGetHostProps(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); @@ -925,34 +969,48 @@ export function diffHydratedProperties( case 'iframe': case 'object': case 'embed': - trapBubbledEvent(TOP_LOAD, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_LOAD, domElement); + } break; case 'video': case 'audio': - // Create listener for each media event - for (let i = 0; i < mediaEventTypes.length; i++) { - trapBubbledEvent(mediaEventTypes[i], domElement); + if (!enableModernEventSystem) { + // Create listener for each media event + for (let i = 0; i < mediaEventTypes.length; i++) { + trapBubbledEvent(mediaEventTypes[i], domElement); + } } break; case 'source': - trapBubbledEvent(TOP_ERROR, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_ERROR, domElement); + } break; case 'img': case 'image': case 'link': - trapBubbledEvent(TOP_ERROR, domElement); - trapBubbledEvent(TOP_LOAD, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_ERROR, domElement); + trapBubbledEvent(TOP_LOAD, domElement); + } break; case 'form': - trapBubbledEvent(TOP_RESET, domElement); - trapBubbledEvent(TOP_SUBMIT, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_RESET, domElement); + trapBubbledEvent(TOP_SUBMIT, domElement); + } break; case 'details': - trapBubbledEvent(TOP_TOGGLE, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(TOP_TOGGLE, domElement); + } break; case 'input': ReactDOMInputInitWrapperState(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); @@ -962,14 +1020,18 @@ export function diffHydratedProperties( break; case 'select': ReactDOMSelectInitWrapperState(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); break; case 'textarea': ReactDOMTextareaInitWrapperState(domElement, rawProps); - trapBubbledEvent(TOP_INVALID, domElement); + if (!enableModernEventSystem) { + trapBubbledEvent(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'); diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js new file mode 100644 index 0000000000000..8e7c5260d520f --- /dev/null +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -0,0 +1,264 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +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 {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; +import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching'; +import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils'; +import {plugins} from 'legacy-events/EventPluginRegistry'; + +import {HostRoot, HostPortal} from 'shared/ReactWorkTags'; +import {getParent} from 'shared/ReactTreeTraversal'; + +import {getListenerMapForElement} from './DOMEventListenerMap'; +import { + TOP_FOCUS, + TOP_LOAD, + TOP_ABORT, + TOP_CANCEL, + TOP_INVALID, + TOP_BLUR, + TOP_SCROLL, + TOP_CLOSE, + TOP_RESET, + TOP_SUBMIT, + TOP_CAN_PLAY, + TOP_CAN_PLAY_THROUGH, + TOP_DURATION_CHANGE, + TOP_EMPTIED, + TOP_ENCRYPTED, + TOP_ENDED, + TOP_ERROR, + TOP_WAITING, + TOP_VOLUME_CHANGE, + TOP_TIME_UPDATE, + TOP_SUSPEND, + TOP_STALLED, + TOP_SEEKING, + TOP_SEEKED, + TOP_PLAY, + TOP_PAUSE, + TOP_LOAD_START, + TOP_LOADED_DATA, + TOP_LOADED_METADATA, + TOP_RATE_CHANGE, + TOP_PROGRESS, + TOP_PLAYING, +} from './DOMTopLevelEventTypes'; +import {trapEventForPluginEventSystem} from './ReactDOMEventListener'; +import getEventTarget from './getEventTarget'; +import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; +import {COMMENT_NODE} from '../shared/HTMLNodeType'; + +const capturePhaseEvents = new Set([ + TOP_FOCUS, + TOP_BLUR, + TOP_SCROLL, + TOP_LOAD, + TOP_ABORT, + TOP_CANCEL, + TOP_CLOSE, + TOP_INVALID, + TOP_RESET, + TOP_SUBMIT, + 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, +]); + +const isArray = Array.isArray; + +function getContainerInstance( + container: Document | Element | Node, +): Fiber | null { + const rootContainer = (container: any)._reactRootContainer; + if (rootContainer) { + return rootContainer._internalRoot.current; + } + return getClosestInstanceFromNode(container); +} + +function getContainerNode(instance: Fiber): Element | Node | Document { + return instance.stateNode.containerInfo; +} + +function getContainerAncestorInstance( + containerInst: null | Fiber, + targetInst: null | Fiber, +): null | Fiber { + // We should never dispatch events if the target is the + // same instance as the container. + if (containerInst === targetInst) { + return null; + } + // Return the target instance if we do not have a container + // instance. + if (containerInst === null) { + return targetInst; + } + const containerInstAlt = containerInst.alternate; + let ancestor = targetInst; + let node = targetInst; + + while (node !== null) { + const tag = node.tag; + if (node === containerInst || node === containerInstAlt) { + return ancestor; + } else if (tag === HostRoot) { + const hostNode = getContainerNode(node); + const hostInst = getClosestInstanceFromNode(hostNode); + // If the portal's container is the same as our container + // instance and it's a comment node, then use the current + // ancestor we have. + if ( + ((hostInst === containerInst || hostInst === containerInstAlt) && + hostNode.nodeType === COMMENT_NODE) || + hostInst === null + ) { + return ancestor; + } + ancestor = node = hostInst; + continue; + } else if (tag === HostPortal) { + const portalNode = getContainerNode(node); + const portalInst = getClosestInstanceFromNode(portalNode); + // If the portal's container is the same as the one passed + // through, then use the current ancestor we have. + if (portalInst === containerInst || portalInst === containerInstAlt) { + return ancestor; + } + const parent = getParent(node); + if (parent === null) { + return null; + } + ancestor = node = parent; + continue; + } + node = node.return; + } + return ancestor; +} + +function dispatchEventsForPlugins( + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, + targetInst: null | Fiber, + container: Document | Element | Node, +): void { + const nativeEventTarget = getEventTarget(nativeEvent); + const syntheticEvents = []; + + for (let i = 0; i < plugins.length; i++) { + const possiblePlugin: PluginModule = plugins[i]; + if (possiblePlugin) { + const extractedEvents = possiblePlugin.extractEvents( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + container, + ); + if (extractedEvents) { + if (isArray(extractedEvents)) { + syntheticEvents.push(...(extractedEvents: any)); + } else { + 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, + container: Element, + listenerMap: Map void)>, +): void { + if (!listenerMap.has(topLevelType)) { + listenerMap.set(topLevelType, null); + const isCapturePhase = capturePhaseEvents.has(topLevelType); + trapEventForPluginEventSystem(container, topLevelType, isCapturePhase); + } +} + +export function listenToEvent( + registrationName: string, + container: Element, +): void { + const listenerMap = getListenerMapForElement(container); + const dependencies = registrationNameDependencies[registrationName]; + + for (let i = 0; i < dependencies.length; i++) { + const dependency = dependencies[i]; + listenToTopLevelEvent(dependency, container, listenerMap); + } +} + +export function dispatchEventForPluginEventSystem( + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, + targetInst: null | Fiber, + container: Document | Element | Node, +): void { + const containerInst = getContainerInstance(container); + // Due to the fact we can render multiple roots or portals + // within one another, we need to find the correct ancestor + // to use as our target fiber instance. + const ancestorTargetInst = getContainerAncestorInstance( + containerInst, + targetInst, + ); + if (ancestorTargetInst !== null) { + batchedEventUpdates(() => + dispatchEventsForPlugins( + topLevelType, + eventSystemFlags, + nativeEvent, + ancestorTargetInst, + container, + ), + ); + } +} diff --git a/packages/react-dom/src/events/EnterLeaveEventPlugin.js b/packages/react-dom/src/events/EnterLeaveEventPlugin.js index de9b130462fb0..4130cd709ad20 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/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index c63dc0ee648dd..f2cc6fa068d12 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -53,7 +53,10 @@ import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; import {getRawEventName} from './DOMTopLevelEventTypes'; import {passiveBrowserEventsSupported} from './checkPassiveEvents'; -import {enableDeprecatedFlareAPI} from 'shared/ReactFeatureFlags'; +import { + enableDeprecatedFlareAPI, + enableModernEventSystem, +} from 'shared/ReactFeatureFlags'; import { UserBlockingEvent, ContinuousEvent, @@ -61,6 +64,7 @@ import { } from 'shared/ReactTypes'; import {getEventPriorityForPluginSystem} from './DOMEventProperties'; import {dispatchEventForLegacyPluginEventSystem} from './DOMLegacyEventPluginSystem'; +import {dispatchEventForPluginEventSystem} from './DOMModernPluginEventSystem'; const { unstable_UserBlockingPriority: UserBlockingPriority, @@ -149,7 +153,7 @@ export function removeActiveResponderEventSystemEvent( } } -function trapEventForPluginEventSystem( +export function trapEventForPluginEventSystem( container: Document | Element | Node, topLevelType: DOMTopLevelEventType, capture: boolean, @@ -293,12 +297,22 @@ export function dispatchEvent( // in case the event system needs to trace it. if (enableDeprecatedFlareAPI) { if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { - dispatchEventForLegacyPluginEventSystem( - topLevelType, - eventSystemFlags, - nativeEvent, - null, - ); + if (enableModernEventSystem) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + null, + container, + ); + } else { + dispatchEventForLegacyPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + null, + ); + } } if (eventSystemFlags & RESPONDER_EVENT_SYSTEM) { // React Flare event system @@ -311,12 +325,22 @@ export function dispatchEvent( ); } } else { - dispatchEventForLegacyPluginEventSystem( - topLevelType, - eventSystemFlags, - nativeEvent, - null, - ); + if (enableModernEventSystem) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + null, + container, + ); + } else { + dispatchEventForLegacyPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + null, + ); + } } } @@ -372,12 +396,22 @@ export function attemptToDispatchEvent( if (enableDeprecatedFlareAPI) { if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { - dispatchEventForLegacyPluginEventSystem( - topLevelType, - eventSystemFlags, - nativeEvent, - targetInst, - ); + if (enableModernEventSystem) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + container, + ); + } else { + dispatchEventForLegacyPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); + } } if (eventSystemFlags & RESPONDER_EVENT_SYSTEM) { // React Flare event system @@ -390,12 +424,22 @@ export function attemptToDispatchEvent( ); } } else { - dispatchEventForLegacyPluginEventSystem( - topLevelType, - eventSystemFlags, - nativeEvent, - targetInst, - ); + if (enableModernEventSystem) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + container, + ); + } else { + dispatchEventForLegacyPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); + } } // We're not blocked on anything. return null; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index 48ff7a5b00190..c7b695cd00c8f 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -17,6 +17,7 @@ import type {DOMContainer} from '../client/ReactDOM'; import { enableDeprecatedFlareAPI, enableSelectiveHydration, + enableModernEventSystem, } from 'shared/ReactFeatureFlags'; import { unstable_runWithPriority as runWithPriority, @@ -119,6 +120,7 @@ import { } from './DOMTopLevelEventTypes'; import {IS_REPLAYED} from 'legacy-events/EventSystemFlags'; import {legacyListenToTopLevelEvent} from './DOMLegacyEventPluginSystem'; +import {listenToTopLevelEvent} from './DOMModernPluginEventSystem'; type QueuedReplayableEvent = {| blockedOn: null | Container | SuspenseInstance, @@ -212,12 +214,22 @@ export function isReplayableDiscreteEvent( return discreteReplayableEvents.indexOf(eventType) > -1; } +function trapReplayableEventForContainer( + topLevelType: DOMTopLevelEventType, + container: DOMContainer, + listenerMap: Map void)>, +) { + listenToTopLevelEvent(topLevelType, ((container: any): Element), listenerMap); +} + function trapReplayableEventForDocument( topLevelType: DOMTopLevelEventType, document: Document, listenerMap: Map void)>, ) { - legacyListenToTopLevelEvent(topLevelType, document, listenerMap); + if (!enableModernEventSystem) { + legacyListenToTopLevelEvent(topLevelType, document, listenerMap); + } if (enableDeprecatedFlareAPI) { // Trap events for the responder system. const topLevelTypeString = unsafeCastDOMTopLevelTypeToString(topLevelType); @@ -242,12 +254,30 @@ export function eagerlyTrapReplayableEvents( document: Document, ) { const listenerMapForDoc = getListenerMapForElement(document); + let listenerMapForContainer; + if (enableModernEventSystem) { + listenerMapForContainer = getListenerMapForElement(container); + } // Discrete discreteReplayableEvents.forEach(topLevelType => { + if (enableModernEventSystem) { + trapReplayableEventForContainer( + topLevelType, + container, + listenerMapForContainer, + ); + } trapReplayableEventForDocument(topLevelType, document, listenerMapForDoc); }); // Continuous continuousReplayableEvents.forEach(topLevelType => { + if (enableModernEventSystem) { + trapReplayableEventForContainer( + topLevelType, + container, + listenerMapForContainer, + ); + } trapReplayableEventForDocument(topLevelType, document, listenerMapForDoc); }); } 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 0000000000000..549dee97a01b9 --- /dev/null +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -0,0 +1,786 @@ +/** + * 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; +let ReactDOMServer; +let Scheduler; + +function dispatchEvent(element, type) { + const event = document.createEvent('Event'); + event.initEvent(type, true, true); + element.dispatchEvent(event); +} + +function dispatchClickEvent(element) { + dispatchEvent(element, 'click'); +} + +describe('DOMModernPluginEventSystem', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + 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 click events between roots', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const childRef = 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 Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + ReactDOM.render(, childRef.current); + + 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', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); + + it('handle propagation of click events between disjointed roots', () => { + 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 Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + // We use a comment node here, then mount to it + const disjointedNode = document.createComment( + ' react-mount-point-unstable ', + ); + ReactDOM.render(, container); + spanRef.current.appendChild(disjointedNode); + ReactDOM.render(, disjointedNode); + + 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', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); + + it('handle propagation of click events between portals', () => { + 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])); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + 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]); + + document.body.removeChild(portalElement); + }); + + it('handle click events on document.body portals', () => { + const log = []; + + function Child({label}) { + return
log.push(label)}>{label}
; + } + + function Parent() { + return ( + <> + {ReactDOM.createPortal(, document.body)} + {ReactDOM.createPortal(, document.body)} + + ); + } + + ReactDOM.render(, container); + + const second = document.body.lastChild; + expect(second.textContent).toEqual('second'); + dispatchClickEvent(second); + + expect(log).toEqual(['second']); + + const first = second.previousSibling; + expect(first.textContent).toEqual('first'); + dispatchClickEvent(first); + + expect(log).toEqual(['second', 'first']); + }); + + it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + let clicks = 0; + let childSlotRef = React.createRef(); + + function Parent() { + return
clicks++} ref={childSlotRef} />; + } + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return Click me; + } + } + + function App() { + // The root is a Suspense boundary. + return ( + + + + ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let parentContainer = document.createElement('div'); + let childContainer = document.createElement('div'); + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(parentContainer); + + // We're going to use a different root as a parent. + // This lets us detect whether an event goes through React's event system. + let parentRoot = ReactDOM.createRoot(parentContainer); + parentRoot.render(); + Scheduler.unstable_flushAll(); + + childSlotRef.current.appendChild(childContainer); + + childContainer.innerHTML = finalHTML; + + let a = childContainer.getElementsByTagName('a')[0]; + + suspend = true; + + // Hydrate asynchronously. + let root = ReactDOM.createRoot(childContainer, {hydrate: true}); + root.render(); + jest.runAllTimers(); + Scheduler.unstable_flushAll(); + + // The Suspense boundary is not yet hydrated. + a.click(); + expect(clicks).toBe(0); + + // Resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We're now full hydrated. + + expect(clicks).toBe(1); + + document.body.removeChild(parentContainer); + }); + + it('handle click events on document.body dynamic portals', () => { + const log = []; + + function Parent() { + const ref = React.useRef(null); + const [portal, setPortal] = React.useState(null); + + React.useEffect(() => { + setPortal( + ReactDOM.createPortal( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> + {portal} +
+ ); + } + + ReactDOM.render(, container); + + const parent = container.lastChild; + expect(parent.id).toEqual('parent'); + dispatchClickEvent(parent); + + expect(log).toEqual(['parent']); + + const child = parent.lastChild; + expect(child.id).toEqual('child'); + dispatchClickEvent(child); + + // we add both 'child' and 'parent' due to bubbling + expect(log).toEqual(['parent', 'child', 'parent']); + }); + + // Slight alteration to the last test, to catch + // a subtle difference in traversal. + it('handle click events on document.body dynamic portals #2', () => { + const log = []; + + function Parent() { + const ref = React.useRef(null); + const [portal, setPortal] = React.useState(null); + + React.useEffect(() => { + setPortal( + ReactDOM.createPortal( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> +
{portal}
+
+ ); + } + + ReactDOM.render(, container); + + const parent = container.lastChild; + expect(parent.id).toEqual('parent'); + dispatchClickEvent(parent); + + expect(log).toEqual(['parent']); + + const child = parent.lastChild; + expect(child.id).toEqual('child'); + dispatchClickEvent(child); + + // we add both 'child' and 'parent' due to bubbling + expect(log).toEqual(['parent', 'child', 'parent']); + }); + + it('native stopPropagation on click events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const middelDivRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+
+ Click me! +
+
+ ); + } + + function Parent() { + React.useLayoutEffect(() => { + // This should prevent the portalElement listeners from + // capturing the events in the bubble phase. + middelDivRef.current.addEventListener('click', e => { + e.stopPropagation(); + }); + }); + + 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(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + + document.body.removeChild(portalElement); + }); + + 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]); + }); + + it('handle propagation of focus events between roots', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const childRef = 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 Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + ReactDOM.render(, childRef.current); + + 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(['bubble', buttonElement]); + expect(log[4]).toEqual(['capture', divElement]); + expect(log[5]).toEqual(['bubble', divElement]); + }); + + it('handle propagation of focus events between portals', () => { + 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])); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + 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]); + + document.body.removeChild(portalElement); + }); + + it('native stopPropagation on focus events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const middelDivRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+
+ Click me! +
+
+ ); + } + + function Parent() { + React.useLayoutEffect(() => { + // This should prevent the portalElement listeners from + // capturing the events in the bubble phase. + middelDivRef.current.addEventListener('click', e => { + e.stopPropagation(); + }); + }); + + 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(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + + document.body.removeChild(portalElement); + }); +}); diff --git a/packages/shared/ReactTreeTraversal.js b/packages/shared/ReactTreeTraversal.js index 08a1a3d87c2cc..56bb3d2789061 100644 --- a/packages/shared/ReactTreeTraversal.js +++ b/packages/shared/ReactTreeTraversal.js @@ -5,21 +5,49 @@ * LICENSE file in the root directory of this source tree. */ -import {HostComponent} from './ReactWorkTags'; +import {HostComponent, HostPortal, HostRoot} from './ReactWorkTags'; +import {enableModernEventSystem} from './ReactFeatureFlags'; -function getParent(inst) { - do { - inst = inst.return; - // TODO: If this is a HostRoot we might want to bail out. - // That is depending on if we want nested subtrees (layers) to bubble - // events to their parent. We could also go through parentNode on the - // host node but that wouldn't work for React Native and doesn't let us - // do the portal feature. - } while (inst && inst.tag !== HostComponent); - if (inst) { - return inst; +export function getParent(inst) { + if (enableModernEventSystem) { + let node = inst.return; + + while (node !== null) { + if (node.tag === HostPortal) { + let grandNode = node; + const portalNode = node.stateNode.containerInfo; + while (grandNode !== null) { + // If we find a root that is actually a parent in the DOM tree + // then we don't continue with getting the parent, as that root + // will have its own event listener. + if ( + grandNode.tag === HostRoot && + grandNode.stateNode.containerInfo.contains(portalNode) + ) { + return null; + } + grandNode = grandNode.return; + } + } else if (node.tag === HostComponent) { + return node; + } + node = node.return; + } + return null; + } else { + do { + inst = inst.return; + // TODO: If this is a HostRoot we might want to bail out. + // That is depending on if we want nested subtrees (layers) to bubble + // events to their parent. We could also go through parentNode on the + // host node but that wouldn't work for React Native and doesn't let us + // do the portal feature. + } while (inst && inst.tag !== HostComponent); + if (inst) { + return inst; + } + return null; } - return null; } /** diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 5a2e9b8075e1e..851ad137a0fc8 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -345,5 +345,6 @@ "344": "Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.", "345": "Root did not complete. This is a bug in React.", "346": "An event responder context was used outside of an event cycle.", - "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead." + "347": "Maps are not valid as a React child (found: %s). Consider converting children to an array of keyed ReactElements instead.", + "348": "ensureListeningTo(): recieved a container that was not an element node. This is likely a bug in React." }