From 00cfbaaff938b107efff24ed245e4cd0603730b5 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 24 Mar 2020 12:18:42 +0000 Subject: [PATCH 1/2] ReactDOM.useEvent: Add support for experimental scopes API --- .../react-debug-tools/src/ReactDebugHooks.js | 6 +- .../src/client/ReactDOMHostConfig.js | 37 +++-- .../src/events/DOMModernPluginEventSystem.js | 109 +++++++++++--- ...OMModernPluginEventSystem-test.internal.js | 133 ++++++++++++++++++ .../src/events/accumulateTwoPhaseListeners.js | 40 +++++- .../react-dom/src/shared/ReactDOMTypes.js | 8 +- .../react-reconciler/src/ReactFiberHooks.js | 13 +- 7 files changed, 302 insertions(+), 44 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 175b51485d30c..a338dd0526cf9 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -15,6 +15,7 @@ import type { ReactProviderType, ReactEventResponder, ReactEventResponderListener, + ReactScopeMethods, } from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {Hook, TimeoutConfig} from 'react-reconciler/src/ReactFiberHooks'; @@ -44,7 +45,10 @@ type HookLogEntry = { type ReactDebugListenerMap = {| clear: () => void, - setListener: (target: EventTarget, callback: ?(Event) => void) => void, + setListener: ( + target: EventTarget | ReactScopeMethods, + callback: ?(Event) => void, + ) => void, |}; let hookLog: Array = []; diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 099eded56ebf2..a183b88481e31 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -9,6 +9,15 @@ import type {TopLevelType} from 'legacy-events/TopLevelEventTypes'; import type {RootType} from './ReactDOMRoot'; +import type { + ReactDOMEventResponder, + ReactDOMEventResponderInstance, + ReactDOMFundamentalComponentInstance, + ReactDOMListener, + ReactDOMListenerEvent, + ReactDOMListenerMap, +} from '../shared/ReactDOMTypes'; +import type {ReactScopeMethods} from 'shared/ReactTypes'; import { precacheFiberNode, @@ -49,14 +58,6 @@ import { } from '../shared/HTMLNodeType'; import dangerousStyleValue from '../shared/dangerousStyleValue'; -import type { - ReactDOMEventResponder, - ReactDOMEventResponderInstance, - ReactDOMFundamentalComponentInstance, - ReactDOMListener, - ReactDOMListenerEvent, - ReactDOMListenerMap, -} from '../shared/ReactDOMTypes'; import { mountEventResponder, unmountEventResponder, @@ -69,6 +70,7 @@ import { enableDeprecatedFlareAPI, enableFundamentalAPI, enableUseEventAPI, + enableScopeAPI, } from 'shared/ReactFeatureFlags'; import {HostComponent} from 'react-reconciler/src/ReactWorkTags'; import { @@ -79,10 +81,13 @@ import { isManagedDOMElement, isValidEventTarget, listenToTopLevelEvent, + attachListenerToManagedDOMElement, detachListenerFromManagedDOMElement, - attachListenerFromManagedDOMElement, - detachTargetEventListener, attachTargetEventListener, + detachTargetEventListener, + isReactScope, + attachListenerToReactScope, + detachListenerFromReactScope, } from '../events/DOMModernPluginEventSystem'; import {getListenerMapForElement} from '../events/DOMEventListenerMap'; import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes'; @@ -1159,7 +1164,9 @@ export function mountEventListener(listener: ReactDOMListener): void { if (enableUseEventAPI) { const {target} = listener; if (isManagedDOMElement(target)) { - attachListenerFromManagedDOMElement(listener); + attachListenerToManagedDOMElement(listener); + } else if (enableScopeAPI && isReactScope(target)) { + attachListenerToReactScope(listener); } else { attachTargetEventListener(listener); } @@ -1171,6 +1178,8 @@ export function unmountEventListener(listener: ReactDOMListener): void { const {target} = listener; if (isManagedDOMElement(target)) { detachListenerFromManagedDOMElement(listener); + } else if (enableScopeAPI && isReactScope(target)) { + detachListenerFromReactScope(listener); } else { detachTargetEventListener(listener); } @@ -1178,13 +1187,15 @@ export function unmountEventListener(listener: ReactDOMListener): void { } export function validateEventListenerTarget( - target: EventTarget, + target: EventTarget | ReactScopeMethods, listener: ?(Event) => void, ): boolean { if (enableUseEventAPI) { if ( target != null && - (isManagedDOMElement(target) || isValidEventTarget(target)) + (isManagedDOMElement(target) || + isValidEventTarget(target) || + isReactScope(target)) ) { if (listener == null || typeof listener === 'function') { return true; diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index e550cdb7a3469..a3b60fe7b11b2 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -14,7 +14,7 @@ import type { ElementListenerMapEntry, } from '../events/DOMEventListenerMap'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; -import type {EventPriority} from 'shared/ReactTypes'; +import type {EventPriority, ReactScopeMethods} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {PluginModule} from 'legacy-events/PluginModuleType'; import type { @@ -142,8 +142,11 @@ const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = { const isArray = Array.isArray; -// $FlowFixMe: Flow struggles with this pattern -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +// TODO: we should remove the FlowFixMes and the casting to figure out how to make +// these patterns work properly. +// $FlowFixMe: Flow struggles with this pattern, so we also have to cast it. +const PossiblyWeakMap = ((typeof WeakMap === 'function' ? WeakMap : Map): any); + // $FlowFixMe: Flow cannot handle polymorphic WeakMaps export const eventTargetEventListenerStore: WeakMap< EventTarget, @@ -153,6 +156,15 @@ export const eventTargetEventListenerStore: WeakMap< >, > = new PossiblyWeakMap(); +// $FlowFixMe: Flow cannot handle polymorphic WeakMaps +export const reactScopeListenerStore: WeakMap< + ReactScopeMethods, + Map< + DOMTopLevelEventType, + {bubbled: Set, captured: Set}, + >, +> = new PossiblyWeakMap(); + function dispatchEventsForPlugins( topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, @@ -306,14 +318,18 @@ function isMatchingRootContainer( ); } -export function isManagedDOMElement(target: EventTarget): boolean { +export function isManagedDOMElement(target: Object): boolean { return getClosestInstanceFromNode(((target: any): Node)) !== null; } -export function isValidEventTarget(target: EventTarget): boolean { +export function isValidEventTarget(target: Object): boolean { return typeof target.addEventListener === 'function'; } +export function isReactScope(target: Object): boolean { + return typeof target.getChildContextValues === 'function'; +} + export function dispatchEventForPluginEventSystem( topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, @@ -446,18 +462,16 @@ function addEventTypeToDispatchConfig(type: DOMTopLevelEventType): void { } } -export function attachListenerFromManagedDOMElement( +export function attachListenerToManagedDOMElement( listener: ReactDOMListener, ): void { const {event, target} = listener; const {passive, priority, type} = event; - const possibleManagedTarget = ((target: any): Element); - let containerEventTarget = target; - if (getClosestInstanceFromNode(possibleManagedTarget)) { - containerEventTarget = getNearestRootOrPortalContainer( - possibleManagedTarget, - ); - } + + const managedTargetElement = ((target: any): Element); + const containerEventTarget = getNearestRootOrPortalContainer( + managedTargetElement, + ); const listenerMap = getListenerMapForElement(containerEventTarget); // Add the event listener to the target container (falling back to // the target if we didn't find one). @@ -469,11 +483,11 @@ export function attachListenerFromManagedDOMElement( priority, ); // Get the internal listeners Set from the target instance. - let listeners = getListenersFromTarget(target); + let listeners = getListenersFromTarget(managedTargetElement); // If we don't have any listeners, then we need to init them. if (listeners === null) { listeners = new Set(); - initListenersSet(target, listeners); + initListenersSet(managedTargetElement, listeners); } // Add our listener to the listeners Set. listeners.add(listener); @@ -485,8 +499,9 @@ export function detachListenerFromManagedDOMElement( listener: ReactDOMListener, ): void { const {target} = listener; + const managedTargetElement = ((target: any): Element); // Get the internal listeners Set from the target instance. - const listeners = getListenersFromTarget(target); + const listeners = getListenersFromTarget(managedTargetElement); if (listeners !== null) { // Remove out listener from the listeners Set. listeners.delete(listener); @@ -496,13 +511,21 @@ export function detachListenerFromManagedDOMElement( export function attachTargetEventListener(listener: ReactDOMListener): void { const {event, target} = listener; const {capture, passive, priority, type} = event; - const listenerMap = getListenerMapForElement(target); + const eventTarget = ((target: any): EventTarget); + const listenerMap = getListenerMapForElement(eventTarget); // Add the event listener to the TargetEvent object. - listenToTopLevelEvent(type, target, listenerMap, passive, priority, capture); - let eventTypeMap = eventTargetEventListenerStore.get(target); + listenToTopLevelEvent( + type, + eventTarget, + listenerMap, + passive, + priority, + capture, + ); + let eventTypeMap = eventTargetEventListenerStore.get(eventTarget); if (eventTypeMap === undefined) { eventTypeMap = new Map(); - eventTargetEventListenerStore.set(target, eventTypeMap); + eventTargetEventListenerStore.set(eventTarget, eventTypeMap); } // Get the listeners by the event type let listeners = eventTypeMap.get(type); @@ -523,7 +546,51 @@ export function attachTargetEventListener(listener: ReactDOMListener): void { export function detachTargetEventListener(listener: ReactDOMListener): void { const {event, target} = listener; const {capture, type} = event; - const eventTypeMap = eventTargetEventListenerStore.get(target); + const validEventTarget = ((target: any): EventTarget); + const eventTypeMap = eventTargetEventListenerStore.get(validEventTarget); + if (eventTypeMap !== undefined) { + const listeners = eventTypeMap.get(type); + if (listeners !== undefined) { + // Remove out listener from the listeners Set. + if (capture) { + listeners.captured.delete(listener); + } else { + listeners.bubbled.delete(listener); + } + } + } +} + +export function attachListenerToReactScope(listener: ReactDOMListener): void { + const {event, target} = listener; + const {capture, type} = event; + const reactScope = ((target: any): ReactScopeMethods); + let eventTypeMap = reactScopeListenerStore.get(reactScope); + if (eventTypeMap === undefined) { + eventTypeMap = new Map(); + reactScopeListenerStore.set(reactScope, eventTypeMap); + } + // Get the listeners by the event type + let listeners = eventTypeMap.get(type); + if (listeners === undefined) { + listeners = {captured: new Set(), bubbled: new Set()}; + eventTypeMap.set(type, listeners); + } + // Add our listener to the listeners Set. + if (capture) { + listeners.captured.add(listener); + } else { + listeners.bubbled.add(listener); + } + // Finally, add the event to our known event types list. + addEventTypeToDispatchConfig(type); +} + +export function detachListenerFromReactScope(listener: ReactDOMListener): void { + const {event, target} = listener; + const {capture, type} = event; + const reactScope = ((target: any): ReactScopeMethods); + const eventTypeMap = reactScopeListenerStore.get(reactScope); if (eventTypeMap !== undefined) { const listeners = eventTypeMap.get(type); if (listeners !== undefined) { diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index 0547c66a2adde..3c17d6bf86afb 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -2327,6 +2327,139 @@ describe('DOMModernPluginEventSystem', () => { document.body.removeChild(container2); }, ); + + describe('Compatibility with Scopes API', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableUseEventAPI = true; + ReactFeatureFlags.enableScopeAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + }); + + it('handle propagation of click events on a scope', () => { + const buttonRef = 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 TestScope = React.unstable_createScope(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const scopeRef = React.useRef(null); + + React.useEffect(() => { + click.setListener(scopeRef.current, onClick); + clickCapture.setListener(scopeRef.current, onClickCapture); + }); + + return ( + + + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log).toEqual([ + ['capture', buttonElement], + ['capture', buttonElement], + ['bubble', buttonElement], + ['bubble', buttonElement], + ]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log).toEqual([ + ['capture', buttonElement], + ['capture', buttonElement], + ['capture', divElement], + ['bubble', divElement], + ['bubble', buttonElement], + ['bubble', buttonElement], + ]); + }); + }); }); }, ); diff --git a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js index 3a275eee17f25..8ef02e733cf5f 100644 --- a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js +++ b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js @@ -11,13 +11,19 @@ import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; -import {HostComponent} from 'react-reconciler/src/ReactWorkTags'; -import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; +import { + HostComponent, + ScopeComponent, +} from 'react-reconciler/src/ReactWorkTags'; +import {enableUseEventAPI, enableScopeAPI} from 'shared/ReactFeatureFlags'; import getListener from 'legacy-events/getListener'; import {getListenersFromTarget} from '../client/ReactDOMComponentTree'; import {IS_TARGET_EVENT_ONLY} from 'legacy-events/EventSystemFlags'; -import {eventTargetEventListenerStore} from './DOMModernPluginEventSystem'; +import { + eventTargetEventListenerStore, + reactScopeListenerStore, +} from './DOMModernPluginEventSystem'; export default function accumulateTwoPhaseListeners( event: ReactSyntheticEvent, @@ -76,11 +82,13 @@ export default function accumulateTwoPhaseListeners( // usual two phase accumulation using the React fiber tree to pick up // all relevant useEvent and on* prop events. let node = event._targetInst; + let lastHostComponent; // Accumulate all instances and listeners via the target -> root path. while (node !== null) { - // We only care for listeners that are on HostComponents (i.e.
) + // Handle listeners that are on HostComponents (i.e.
) if (node.tag === HostComponent) { + lastHostComponent = node.stateNode; // For useEvent listenrs if (enableUseEventAPI && accumulateUseEventListeners) { // useEvent event listeners @@ -127,6 +135,30 @@ export default function accumulateTwoPhaseListeners( dispatchInstances.push(node); } } + } else if (enableScopeAPI && node.tag === ScopeComponent) { + const reactScope = node.stateNode.methods; + const eventTypeMap = reactScopeListenerStore.get(reactScope); + if (eventTypeMap !== undefined) { + const type = ((event.type: any): DOMTopLevelEventType); + const listeners = eventTypeMap.get(type); + if (listeners !== undefined) { + const captureListeners = Array.from(listeners.captured); + const bubbleListeners = Array.from(listeners.bubbled); + + for (let i = 0; i < captureListeners.length; i++) { + const listener = captureListeners[i]; + const {callback} = listener; + dispatchListeners.unshift(callback); + dispatchInstances.unshift(((lastHostComponent: any): Element)); + } + for (let i = 0; i < bubbleListeners.length; i++) { + const listener = bubbleListeners[i]; + const {callback} = listener; + dispatchListeners.push(callback); + dispatchInstances.push(((lastHostComponent: any): Element)); + } + } + } } node = node.return; } diff --git a/packages/react-dom/src/shared/ReactDOMTypes.js b/packages/react-dom/src/shared/ReactDOMTypes.js index 53b21bee8c6ef..25dc9b0246049 100644 --- a/packages/react-dom/src/shared/ReactDOMTypes.js +++ b/packages/react-dom/src/shared/ReactDOMTypes.js @@ -12,6 +12,7 @@ import type { ReactEventResponder, ReactEventResponderInstance, EventPriority, + ReactScopeMethods, } from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; @@ -86,12 +87,15 @@ export type ReactDOMListenerEvent = {| export type ReactDOMListenerMap = {| clear: () => void, - setListener: (target: EventTarget, callback: ?(Event) => void) => void, + setListener: ( + target: EventTarget | ReactScopeMethods, + callback: ?(Event) => void, + ) => void, |}; export type ReactDOMListener = {| callback: Event => void, destroy: Node => void, event: ReactDOMListenerEvent, - target: EventTarget, + target: EventTarget | ReactScopeMethods, |}; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 8eefb9dc2f0a3..71b9f75575bd1 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -14,6 +14,7 @@ import type { ReactEventResponder, ReactContext, ReactEventResponderListener, + ReactScopeMethods, } from 'shared/ReactTypes'; import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; @@ -1651,7 +1652,7 @@ function validateNotInFunctionRender(): boolean { function createReactListener( event: ReactListenerEvent, callback: Event => void, - target: EventTarget, + target: EventTarget | ReactScopeMethods, destroy: Node => void, ): ReactListener { return { @@ -1665,7 +1666,10 @@ function createReactListener( function mountEventListener(event: ReactListenerEvent): ReactListenerMap { if (enableUseEventAPI) { const hook = mountWorkInProgressHook(); - const listenerMap: Map = new Map(); + const listenerMap: Map< + EventTarget | ReactScopeMethods, + ReactListener, + > = new Map(); const rootContainerInstance = getRootHostContainer(); // Register the event to the current root to ensure event @@ -1700,7 +1704,10 @@ function mountEventListener(event: ReactListenerEvent): ReactListenerMap { const reactListenerMap: ReactListenerMap = { clear, - setListener(target: EventTarget, callback: ?(Event) => void): void { + setListener( + target: EventTarget | ReactScopeMethods, + callback: ?(Event) => void, + ): void { if ( validateNotInFunctionRender() && validateEventListenerTarget(target, callback) From 2d1b222afebabe7bc014f2037cd0faa90a8e5194 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 26 Mar 2020 13:18:53 +0000 Subject: [PATCH 2/2] Address feedback --- .../src/events/DOMModernPluginEventSystem.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index a3b60fe7b11b2..da24fc82b7b0e 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -318,16 +318,20 @@ function isMatchingRootContainer( ); } -export function isManagedDOMElement(target: Object): boolean { +export function isManagedDOMElement( + target: EventTarget | ReactScopeMethods, +): boolean { return getClosestInstanceFromNode(((target: any): Node)) !== null; } -export function isValidEventTarget(target: Object): boolean { - return typeof target.addEventListener === 'function'; +export function isValidEventTarget( + target: EventTarget | ReactScopeMethods, +): boolean { + return typeof (target: any).addEventListener === 'function'; } -export function isReactScope(target: Object): boolean { - return typeof target.getChildContextValues === 'function'; +export function isReactScope(target: EventTarget | ReactScopeMethods): boolean { + return typeof (target: any).getChildContextValues === 'function'; } export function dispatchEventForPluginEventSystem(