From 7e652a236ebabe8a41278d63290c56a0397a19d8 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 18 Dec 2019 18:03:25 +0000 Subject: [PATCH 1/5] [react-interactions] Add Listener API + useEvent hook --- packages/legacy-events/EventSystemFlags.js | 11 +- packages/legacy-events/PluginModuleType.js | 2 +- .../legacy-events/ReactGenericBatching.js | 10 +- packages/react-art/src/ReactARTHostConfig.js | 23 + .../react-debug-tools/src/ReactDebugHooks.js | 12 + packages/react-dom/src/client/ReactDOM.js | 6 + .../react-dom/src/client/ReactDOMComponent.js | 39 + .../src/client/ReactDOMComponentTree.js | 9 + .../src/client/ReactDOMEventListenerHooks.js | 74 ++ .../src/client/ReactDOMHostConfig.js | 112 ++- .../src/events/DOMEventListenerSystem.js | 353 ++++++++ .../src/events/DOMEventProperties.js | 14 + .../src/events/ReactDOMEventListener.js | 83 +- .../DOMEventListenerSystem-test.internal.js | 752 ++++++++++++++++++ .../src/server/ReactPartialRendererHooks.js | 9 + .../src/ReactFabricHostConfig.js | 25 + .../src/ReactNativeHostConfig.js | 24 + .../react-reconciler/src/ReactFiberHooks.js | 186 ++++- .../src/forks/ReactFiberHostConfig.custom.js | 11 + .../src/ReactShallowRenderer.js | 9 + .../src/ReactTestHostConfig.js | 24 + packages/shared/ReactDOMTypes.js | 26 + packages/shared/ReactFeatureFlags.js | 5 +- .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 2 + 29 files changed, 1810 insertions(+), 16 deletions(-) create mode 100644 packages/react-dom/src/client/ReactDOMEventListenerHooks.js create mode 100644 packages/react-dom/src/events/DOMEventListenerSystem.js create mode 100644 packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js diff --git a/packages/legacy-events/EventSystemFlags.js b/packages/legacy-events/EventSystemFlags.js index a3d51f3197b..f5b24a896b9 100644 --- a/packages/legacy-events/EventSystemFlags.js +++ b/packages/legacy-events/EventSystemFlags.js @@ -11,8 +11,9 @@ export type EventSystemFlags = number; export const PLUGIN_EVENT_SYSTEM = 1; export const RESPONDER_EVENT_SYSTEM = 1 << 1; -export const IS_PASSIVE = 1 << 2; -export const IS_ACTIVE = 1 << 3; -export const PASSIVE_NOT_SUPPORTED = 1 << 4; -export const IS_REPLAYED = 1 << 5; -export const IS_FIRST_ANCESTOR = 1 << 6; +export const LISTENER_EVENT_SYSTEM = 1 << 2; +export const IS_PASSIVE = 1 << 3; +export const IS_ACTIVE = 1 << 4; +export const PASSIVE_NOT_SUPPORTED = 1 << 5; +export const IS_REPLAYED = 1 << 6; +export const IS_FIRST_ANCESTOR = 1 << 7; diff --git a/packages/legacy-events/PluginModuleType.js b/packages/legacy-events/PluginModuleType.js index 04ec9e67357..f53d167030d 100644 --- a/packages/legacy-events/PluginModuleType.js +++ b/packages/legacy-events/PluginModuleType.js @@ -17,7 +17,7 @@ import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; export type EventTypes = {[key: string]: DispatchConfig, ...}; -export type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | Touch; +export type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | TouchEvent; export type PluginName = string; diff --git a/packages/legacy-events/ReactGenericBatching.js b/packages/legacy-events/ReactGenericBatching.js index e5f536ee15c..9fa36762275 100644 --- a/packages/legacy-events/ReactGenericBatching.js +++ b/packages/legacy-events/ReactGenericBatching.js @@ -10,7 +10,10 @@ import { restoreStateIfNeeded, } from './ReactControlledComponent'; -import {enableDeprecatedFlareAPI} from 'shared/ReactFeatureFlags'; +import { + enableDeprecatedFlareAPI, + enableListenerAPI, +} from 'shared/ReactFeatureFlags'; import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; // Used as a way to call batchedUpdates when we don't have a reference to @@ -118,9 +121,8 @@ export function flushDiscreteUpdatesIfNeeded(timeStamp: number) { // behaviour as we had before this change, so the risks are low. if ( !isInsideEventHandler && - (!enableDeprecatedFlareAPI || - timeStamp === 0 || - lastFlushedEventTimeStamp !== timeStamp) + ((!enableDeprecatedFlareAPI && !enableListenerAPI) || + (timeStamp === 0 || lastFlushedEventTimeStamp !== timeStamp)) ) { lastFlushedEventTimeStamp = timeStamp; flushDiscreteUpdatesImpl(); diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 62bdb339866..dcab883992a 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -469,3 +469,26 @@ export function getInstanceFromNode(node) { export function beforeRemoveInstance(instance) { // noop } + +export function registerListenerEvent( + event: any, + rootContainerInstance: Container, +): void { + // noop +} + +export function attachListenerToInstance(listener: any): any { + // noop +} + +export function detachListenerFromInstance(listener: any): any { + // noop +} + +export function validateReactListenerDeleteListener(instance): void { + // noop +} + +export function validateReactListenerMapListener(instance, listener): void { + // noop +} diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 99f7ae89056..3b54f7ee5be 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -240,6 +240,17 @@ function useResponder( }; } +const noOp = () => {}; + +function useEvent(options: any): any { + hookLog.push({primitive: 'Event', stackError: new Error(), value: options}); + return { + clear: noOp, + listen: noOp, + unlisten: noOp, + }; +} + function useTransition( config: SuspenseConfig | null | void, ): [(() => void) => void, boolean] { @@ -277,6 +288,7 @@ const Dispatcher: DispatcherType = { useResponder, useTransition, useDeferredValue, + useEvent, }; // Inspect diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index ee54497f232..33ec514b1de 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -60,6 +60,7 @@ import { disableUnstableRenderSubtreeIntoContainer, warnUnstableRenderSubtreeIntoContainer, isTestEnvironment, + enableListenerAPI, } from 'shared/ReactFeatureFlags'; import { @@ -77,6 +78,7 @@ import { setAttemptHydrationAtCurrentPriority, queueExplicitHydrationTarget, } from '../events/ReactDOMEventReplaying'; +import {useEvent} from './ReactDOMEventListenerHooks'; setAttemptSynchronousHydration(attemptSynchronousHydration); setAttemptUserBlockingHydration(attemptUserBlockingHydration); @@ -219,6 +221,10 @@ if (!disableUnstableCreatePortal) { }; } +if (enableListenerAPI) { + ReactDOM.unstable_useEvent = useEvent; +} + const foundDevTools = injectIntoDevTools({ findFiberByHostInstance: getClosestInstanceFromNode, bundleType: __DEV__ ? 1 : 0, diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index e5111ae6035..7f788e309b4 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -65,6 +65,8 @@ import { import { addResponderEventSystemEvent, removeActiveResponderEventSystemEvent, + addListenerSystemEvent, + removeListenerSystemEvent, } from '../events/ReactDOMEventListener.js'; import {mediaEventTypes} from '../events/DOMTopLevelEventTypes'; import { @@ -89,6 +91,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO import { enableDeprecatedFlareAPI, enableTrustedTypesIntegration, + enableListenerAPI, } from 'shared/ReactFeatureFlags'; let didWarnInvalidHydration = false; @@ -1341,6 +1344,42 @@ export function listenToEventResponderEventTypes( } } +export function listenToEventListener( + type: string, + passive: boolean, + document: Document, +): void { + if (enableListenerAPI) { + // Get the listening Map for this element. We use this to track + // what events we're listening to. + const listenerMap = getListenerMapForElement(document); + const passiveKey = type + '_passive'; + const activeKey = type + '_active'; + const eventKey = passive ? passiveKey : activeKey; + + if (!listenerMap.has(eventKey)) { + if (passive) { + if (listenerMap.has(activeKey)) { + // If we have an active event listener, do not register + // a passive event listener. We use the same active event + // listener. + return; + } else { + // If we have a passive event listener, remove the + // existing passive event listener before we add the + // active event listener. + const passiveListener = listenerMap.get(passiveKey); + if (passiveListener != null) { + removeListenerSystemEvent(document, type, passiveListener); + } + } + } + const eventListener = addListenerSystemEvent(document, type, passive); + listenerMap.set(eventKey, eventListener); + } + } +} + // We can remove this once the event API is stable and out of a flag if (enableDeprecatedFlareAPI) { setListenToResponderEventTypes(listenToEventResponderEventTypes); diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index 575cd3860a6..7aff3df2e44 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -20,6 +20,7 @@ const randomKey = Math.random() .slice(2); const internalInstanceKey = '__reactInternalInstance$' + randomKey; const internalEventHandlersKey = '__reactEventHandlers$' + randomKey; +const internalEventListenersKey = '__reactEventListeners$' + randomKey; const internalContainerInstanceKey = '__reactContainere$' + randomKey; export function precacheFiberNode(hostInst, node) { @@ -164,3 +165,11 @@ export function getFiberCurrentPropsFromNode(node) { export function updateFiberProps(node, props) { node[internalEventHandlersKey] = props; } + +export function getListenersFromNode(node) { + return node[internalEventListenersKey] || null; +} + +export function initListenersSet(node, value) { + node[internalEventListenersKey] = value; +} diff --git a/packages/react-dom/src/client/ReactDOMEventListenerHooks.js b/packages/react-dom/src/client/ReactDOMEventListenerHooks.js new file mode 100644 index 00000000000..406b84c492c --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMEventListenerHooks.js @@ -0,0 +1,74 @@ +/** + * 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 { + ReactDOMListenerEvent, + ReactDOMListenerMap, +} from 'shared/ReactDOMTypes'; + +import React from 'react'; +import invariant from 'shared/invariant'; +import {getEventPriorityForListenerSystem} from '../events/DOMEventProperties'; + +const ReactCurrentDispatcher = + React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED + .ReactCurrentDispatcher; + +type EventOptions = {| + capture?: boolean, + passive?: boolean, + priority?: number, +|}; + +function resolveDispatcher() { + const dispatcher = ReactCurrentDispatcher.current; + invariant( + dispatcher !== null, + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ); + return dispatcher; +} + +export function useEvent( + type: string, + options?: EventOptions, +): ReactDOMListenerMap { + const dispatcher = resolveDispatcher(); + let capture = false; + let passive = false; + let priority = getEventPriorityForListenerSystem((type: any)); + + if (options != null) { + const optionsCapture = options && options.capture; + const optionsPassive = options && options.passive; + const optionsPriority = options && options.priority; + + if (typeof optionsCapture === 'boolean') { + capture = optionsCapture; + } + if (typeof optionsPassive === 'boolean') { + passive = optionsPassive; + } + if (typeof optionsPriority === 'number') { + priority = optionsPriority; + } + } + const event: ReactDOMListenerEvent = { + capture, + passive, + priority, + type, + }; + return dispatcher.useEvent(event); +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 393f9f033f7..6afe4aedb15 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -11,6 +11,7 @@ import { precacheFiberNode, updateFiberProps, getClosestInstanceFromNode, + getListenersFromNode, } from './ReactDOMComponentTree'; import { createElement, @@ -27,6 +28,7 @@ import { warnForInsertedHydratedElement, warnForInsertedHydratedText, listenToEventResponderEventTypes, + listenToEventListener, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -50,6 +52,9 @@ import type { ReactDOMEventResponder, ReactDOMEventResponderInstance, ReactDOMFundamentalComponentInstance, + ReactDOMListener, + ReactDOMListenerEvent, + ReactDOMListenerMap, } from 'shared/ReactDOMTypes'; import { mountEventResponder, @@ -58,6 +63,10 @@ import { } from '../events/DeprecatedDOMEventResponderSystem'; import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; +export type ReactListenerEvent = ReactDOMListenerEvent; +export type ReactListenerMap = ReactDOMListenerMap; +export type ReactListener = ReactDOMListener; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -116,11 +125,18 @@ import { enableSuspenseServerRenderer, enableDeprecatedFlareAPI, enableFundamentalAPI, + enableListenerAPI, } from 'shared/ReactFeatureFlags'; import { RESPONDER_EVENT_SYSTEM, IS_PASSIVE, } from 'legacy-events/EventSystemFlags'; +import { + attachElementListener, + detachElementListener, + attachDocumentListener, + detachDocumentListener, +} from '../events/DOMEventListenerSystem'; let SUPPRESS_HYDRATION_WARNING; if (__DEV__) { @@ -511,6 +527,22 @@ export function beforeRemoveInstance( ) { dispatchBeforeDetachedBlur(((instance: any): HTMLElement)); } + if (enableListenerAPI) { + // It's unfortunate that we have to do this cleanup, but + // it's necessary otherwise we will leak the host instances + // from the useEvent hook instances Map. We call destroy + // on each listener to ensure we properly remove the instance + // from the instances Map. Note: we have this Map so that we + // can properly unmount instances when the function component + // that the hook is attached to gets unmounted. + const listenersSet = getListenersFromNode(instance); + if (listenersSet !== null) { + const listeners = Array.from(listenersSet); + for (let i = 0; i < listeners.length; i++) { + listeners[i].destroy(); + } + } + } } export function removeChild( @@ -1040,6 +1072,84 @@ export function unmountFundamentalComponent( } } -export function getInstanceFromNode(node: HTMLElement): null | Object { +export function getInstanceFromNode(node: Instance): null | Object { return getClosestInstanceFromNode(node) || null; } + +export function registerListenerEvent( + event: ReactDOMListenerEvent, + rootContainerInstance: Container, +): void { + if (enableListenerAPI) { + const {type, passive} = event; + const doc = rootContainerInstance.ownerDocument; + listenToEventListener(type, passive, doc); + } +} + +export function attachListenerToInstance(listener: ReactDOMListener): void { + if (enableListenerAPI) { + const {instance} = listener; + if (instance.nodeType === DOCUMENT_NODE) { + attachDocumentListener(listener); + } else { + attachElementListener(listener); + } + } +} + +export function detachListenerFromInstance(listener: ReactDOMListener): void { + if (enableListenerAPI) { + const {instance} = listener; + if (instance.nodeType === DOCUMENT_NODE) { + detachDocumentListener(listener); + } else { + detachElementListener(listener); + } + } +} + +function validateListenerInstance(instance, methodString): boolean { + if ( + instance && + (instance.nodeType === DOCUMENT_NODE || + getClosestInstanceFromNode(instance)) + ) { + return true; + } + if (__DEV__) { + console.warn( + 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + + ' DOM node that was rendered and managed by React. If this is from a ref, ensure' + + ' the ref value has been set before attaching.', + methodString, + ); + } + return false; +} + +export function validateReactListenerDeleteListener( + instance: Container, +): boolean { + return validateListenerInstance(instance, 'deleteListener'); +} + +export function validateReactListenerMapListener( + instance: Container, + listener: Event => void, +): boolean { + if (enableListenerAPI) { + if (validateListenerInstance(instance, 'setListener')) { + if (typeof listener === 'function') { + return true; + } + if (__DEV__) { + console.warn( + 'Event listener method setListener() from useEvent() hook requires the second argument' + + ' to be valid function callback.', + ); + } + } + } + return false; +} diff --git a/packages/react-dom/src/events/DOMEventListenerSystem.js b/packages/react-dom/src/events/DOMEventListenerSystem.js new file mode 100644 index 00000000000..a652452dc7c --- /dev/null +++ b/packages/react-dom/src/events/DOMEventListenerSystem.js @@ -0,0 +1,353 @@ +/** + * 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 {Fiber} from 'react-reconciler/src/ReactFiber'; +import type {ReactDOMListener} from 'shared/ReactDOMTypes'; +import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; + +import { + ContinuousEvent, + UserBlockingEvent, + DiscreteEvent, +} from 'shared/ReactTypes'; +import {HostComponent} from 'shared/ReactWorkTags'; +import { + batchedEventUpdates, + discreteUpdates, + flushDiscreteUpdatesIfNeeded, + executeUserEventHandler, +} from 'legacy-events/ReactGenericBatching'; + +// Intentionally not named imports because Rollup would use dynamic dispatch for +// CommonJS interop named imports. +import * as Scheduler from 'scheduler'; +import {enableListenerAPI} from 'shared/ReactFeatureFlags'; +import { + initListenersSet, + getListenersFromNode, +} from '../client/ReactDOMComponentTree'; + +const { + unstable_UserBlockingPriority: UserBlockingPriority, + unstable_runWithPriority: runWithPriority, +} = Scheduler; +const arrayFrom = Array.from; + +type EventProperties = {| + currentTarget: null | Document | Element, + eventPhase: number, + stopImmediatePropagation: boolean, + stopPropagation: boolean, +|}; + +const documentCaptureListeners = new Map(); +const documentBubbleListeners = new Map(); + +function monkeyPatchNativeEvent(nativeEvent: any): EventProperties { + if (nativeEvent._reactEventProperties) { + const eventProperties = nativeEvent._reactEventProperties; + eventProperties.stopImmediatePropagation = false; + eventProperties.stopPropagation = false; + return eventProperties; + } + const eventProperties = { + currentTarget: null, + eventPhase: 0, + stopImmediatePropagation: false, + stopPropagation: false, + }; + // $FlowFixMe: prevent Flow complaining about needing a value + Object.defineProperty(nativeEvent, 'currentTarget', { + get() { + return eventProperties.currentTarget; + }, + }); + // $FlowFixMe: prevent Flow complaning about needing a value + Object.defineProperty(nativeEvent, 'eventPhase', { + get() { + return eventProperties.eventPhase; + }, + }); + nativeEvent.stopPropagation = () => { + eventProperties.stopPropagation = true; + }; + nativeEvent.stopImmediatePropagation = () => { + eventProperties.stopImmediatePropagation = true; + eventProperties.stopPropagation = true; + }; + nativeEvent._reactEventProperties = eventProperties; + return eventProperties; +} + +function getElementListeners( + eventType: string, + target: null | Fiber, +): [Array, Array] { + const captureListeners = []; + const bubbleListeners = []; + let propagationDepth = 0; + + let currentFiber = target; + while (currentFiber !== null) { + const {tag} = currentFiber; + if (tag === HostComponent) { + const hostInstance = currentFiber.stateNode; + const listenersSet = getListenersFromNode(hostInstance); + + if (listenersSet !== null) { + const listeners = Array.from(listenersSet); + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + const {capture, type} = listener.event; + if (type === eventType) { + listener.depth = propagationDepth; + if (capture === true) { + captureListeners.push(listener); + } else { + bubbleListeners.push(listener); + } + } + } + propagationDepth++; + } + } + currentFiber = currentFiber.return; + } + return [captureListeners, bubbleListeners]; +} + +function getDocumentListenerSet( + type: string, + capture: boolean, +): Set { + const delegatedEventListeners = capture + ? documentCaptureListeners + : documentBubbleListeners; + let listenersSet = delegatedEventListeners.get(type); + + if (listenersSet === undefined) { + listenersSet = new Set(); + delegatedEventListeners.set(type, listenersSet); + } + return listenersSet; +} + +function dispatchListener( + listener: ReactDOMListener, + eventProperties: EventProperties, + nativeEvent: AnyNativeEvent, +): void { + const callback = listener.callback; + eventProperties.currentTarget = listener.instance; + executeUserEventHandler(callback, nativeEvent); +} + +function dispatchListenerAtPriority( + listener: ReactDOMListener, + eventProperties: EventProperties, + nativeEvent: AnyNativeEvent, +) { + // The callback can either null or undefined, if so we skip dispatching it + if (listener.callback == null) { + return; + } + switch (listener.event.priority) { + case DiscreteEvent: { + flushDiscreteUpdatesIfNeeded(nativeEvent.timeStamp); + discreteUpdates(() => + dispatchListener(listener, eventProperties, nativeEvent), + ); + break; + } + case UserBlockingEvent: { + runWithPriority(UserBlockingPriority, () => + dispatchListener(listener, eventProperties, nativeEvent), + ); + break; + } + case ContinuousEvent: { + dispatchListener(listener, eventProperties, nativeEvent); + break; + } + } +} + +function shouldStopPropagation( + eventProperties: EventProperties, + lastPropagationDepth: void | number, + propagationDepth: number, +): boolean { + return ( + (eventProperties.stopPropagation === true && + lastPropagationDepth !== propagationDepth) || + eventProperties.stopImmediatePropagation === true + ); +} + +function dispatchCaptureListeners( + eventProperties: EventProperties, + listeners: Array, + nativeEvent: AnyNativeEvent, + isDocumentListener: boolean, +) { + const end = listeners.length - 1; + let lastPropagationDepth; + for (let i = end; i >= 0; i--) { + const listener = listeners[i]; + const {depth} = listener; + if ( + (!isDocumentListener || i === end) && + shouldStopPropagation(eventProperties, lastPropagationDepth, depth) + ) { + return; + } + dispatchListenerAtPriority(listener, eventProperties, nativeEvent); + lastPropagationDepth = depth; + } +} + +function dispatchBubbleListeners( + eventProperties: EventProperties, + listeners: Array, + nativeEvent: AnyNativeEvent, + isDocumentListener: boolean, +) { + const length = listeners.length; + let lastPropagationDepth; + for (let i = 0; i < length; i++) { + const listener = listeners[i]; + const {depth} = listener; + if ( + // When document is not null, we know its a delegated event + (!isDocumentListener || i === 0) && + shouldStopPropagation(eventProperties, lastPropagationDepth, depth) + ) { + return; + } + dispatchListenerAtPriority(listener, eventProperties, nativeEvent); + lastPropagationDepth = depth; + } +} + +function dispatchListenersByPhase( + captureElementListeners: Array, + bubbleElementListeners: Array, + captureDocumentListeners: Array, + bubbleDocumentListeners: Array, + nativeEvent: AnyNativeEvent, +): void { + const eventProperties = monkeyPatchNativeEvent(nativeEvent); + // Capture phase + eventProperties.eventPhase = 1; + // Dispatch capture delegated event listeners + dispatchCaptureListeners( + eventProperties, + captureDocumentListeners, + nativeEvent, + true, + ); + // Dispatch capture target event listeners + dispatchCaptureListeners( + eventProperties, + captureElementListeners, + nativeEvent, + false, + ); + eventProperties.stopPropagation = false; + eventProperties.stopImmediatePropagation = false; + // Bubble phase + eventProperties.eventPhase = 3; + // Dispatch bubble target event listeners + dispatchBubbleListeners( + eventProperties, + bubbleElementListeners, + nativeEvent, + false, + ); + // Dispatch bubble delegated event listeners + dispatchBubbleListeners( + eventProperties, + bubbleDocumentListeners, + nativeEvent, + true, + ); +} + +export function dispatchEventForListenerEventSystem( + eventType: string, + targetFiber: null | Fiber, + nativeEvent: AnyNativeEvent, +): void { + if (enableListenerAPI) { + // Get target event listeners in their propagation order (non delegated events) + const [ + captureElementListeners, + bubbleElementListeners, + ] = getElementListeners(eventType, targetFiber); + const captureDocumentListeners = arrayFrom( + getDocumentListenerSet(eventType, true), + ); + const bubbleDocumentListeners = arrayFrom( + getDocumentListenerSet(eventType, false), + ); + + if ( + captureElementListeners.length !== 0 || + bubbleElementListeners.length !== 0 || + captureDocumentListeners.length !== 0 || + bubbleDocumentListeners.length !== 0 + ) { + batchedEventUpdates(() => + dispatchListenersByPhase( + captureElementListeners, + bubbleElementListeners, + captureDocumentListeners, + bubbleDocumentListeners, + nativeEvent, + ), + ); + } + } +} + +function getDocumentListenerSetForListener( + listener: ReactDOMListener, +): Set { + const {capture, type} = listener.event; + return getDocumentListenerSet(type, capture); +} + +export function attachDocumentListener(listener: ReactDOMListener): void { + const documentListenersSet = getDocumentListenerSetForListener(listener); + documentListenersSet.add(listener); +} + +export function detachDocumentListener(listener: ReactDOMListener): void { + const documentListenersSet = getDocumentListenerSetForListener(listener); + documentListenersSet.delete(listener); +} + +export function attachElementListener(listener: ReactDOMListener): void { + const {instance} = listener; + let listeners = getListenersFromNode(instance); + + if (listeners === null) { + listeners = new Set(); + initListenersSet(instance, listeners); + } + listeners.add(listener); +} + +export function detachElementListener(listener: ReactDOMListener): void { + const {instance} = listener; + const listeners = getListenersFromNode(instance); + + if (listeners !== null) { + listeners.delete(listener); + } +} diff --git a/packages/react-dom/src/events/DOMEventProperties.js b/packages/react-dom/src/events/DOMEventProperties.js index a9d56d304e2..d5ca3e81fb3 100644 --- a/packages/react-dom/src/events/DOMEventProperties.js +++ b/packages/react-dom/src/events/DOMEventProperties.js @@ -223,3 +223,17 @@ export function getEventPriorityForPluginSystem( // for the event. return priority === undefined ? ContinuousEvent : priority; } + +export function getEventPriorityForListenerSystem(type: string): EventPriority { + const priority = eventPriorities.get(((type: any): TopLevelType)); + if (priority !== undefined) { + return priority; + } + if (__DEV__) { + console.warn( + 'The event "type" provided to useEffect() does not have a known priority type.' + + ' It is recommended to provide a "priority" option to specify a priority.', + ); + } + return ContinuousEvent; +} diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index d43c2011685..1d6980ccf64 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -46,6 +46,7 @@ import { type EventSystemFlags, PLUGIN_EVENT_SYSTEM, RESPONDER_EVENT_SYSTEM, + LISTENER_EVENT_SYSTEM, IS_PASSIVE, IS_ACTIVE, PASSIVE_NOT_SUPPORTED, @@ -62,13 +63,17 @@ import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; import {getRawEventName} from './DOMTopLevelEventTypes'; import {passiveBrowserEventsSupported} from './checkPassiveEvents'; -import {enableDeprecatedFlareAPI} from 'shared/ReactFeatureFlags'; +import { + enableDeprecatedFlareAPI, + enableListenerAPI, +} from 'shared/ReactFeatureFlags'; import { UserBlockingEvent, ContinuousEvent, DiscreteEvent, } from 'shared/ReactTypes'; import {getEventPriorityForPluginSystem} from './DOMEventProperties'; +import {dispatchEventForListenerEventSystem} from './DOMEventListenerSystem'; const { unstable_UserBlockingPriority: UserBlockingPriority, @@ -272,6 +277,62 @@ export function removeActiveResponderEventSystemEvent( } } +export function addListenerSystemEvent( + document: Document, + topLevelType: string, + passive: boolean, +): any => void { + let eventFlags = RESPONDER_EVENT_SYSTEM | LISTENER_EVENT_SYSTEM; + + // If passive option is not supported, then the event will be + // active and not passive, but we flag it as using not being + // supported too. This way the responder event plugins know, + // and can provide polyfills if needed. + if (passive) { + if (passiveBrowserEventsSupported) { + eventFlags |= IS_PASSIVE; + } else { + eventFlags |= IS_ACTIVE; + eventFlags |= PASSIVE_NOT_SUPPORTED; + passive = false; + } + } else { + eventFlags |= IS_ACTIVE; + } + // Check if interactive and wrap in discreteUpdates + const listener = dispatchEvent.bind( + null, + ((topLevelType: any): DOMTopLevelEventType), + eventFlags, + ); + if (passiveBrowserEventsSupported) { + addEventCaptureListenerWithPassiveFlag( + document, + topLevelType, + listener, + passive, + ); + } else { + addEventCaptureListener(document, topLevelType, listener); + } + return listener; +} + +export function removeListenerSystemEvent( + document: Document, + topLevelType: string, + listener: any => void, +) { + if (passiveBrowserEventsSupported) { + document.removeEventListener(topLevelType, listener, { + capture: true, + passive: false, + }); + } else { + document.removeEventListener(topLevelType, listener, true); + } +} + function trapEventForPluginEventSystem( element: Document | Element | Node, topLevelType: DOMTopLevelEventType, @@ -401,7 +462,7 @@ export function dispatchEvent( // This is not replayable so we'll invoke it but without a target, // in case the event system needs to trace it. - if (enableDeprecatedFlareAPI) { + if (enableDeprecatedFlareAPI || enableListenerAPI) { if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, @@ -420,6 +481,14 @@ export function dispatchEvent( eventSystemFlags, ); } + if (eventSystemFlags & LISTENER_EVENT_SYSTEM) { + // React Listener event system + dispatchEventForListenerEventSystem( + (topLevelType: any), + null, + nativeEvent, + ); + } } else { dispatchEventForPluginEventSystem( topLevelType, @@ -479,7 +548,7 @@ export function attemptToDispatchEvent( } } - if (enableDeprecatedFlareAPI) { + if (enableDeprecatedFlareAPI || enableListenerAPI) { if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, @@ -498,6 +567,14 @@ export function attemptToDispatchEvent( eventSystemFlags, ); } + if (eventSystemFlags & LISTENER_EVENT_SYSTEM) { + // React Listener event system + dispatchEventForListenerEventSystem( + (topLevelType: any), + targetInst, + nativeEvent, + ); + } } else { dispatchEventForPluginEventSystem( topLevelType, diff --git a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js new file mode 100644 index 00000000000..b5bdb9d1cf4 --- /dev/null +++ b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js @@ -0,0 +1,752 @@ +/** + * 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('DOMEventListenerSystem', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableListenerAPI = true; + ReactFeatureFlags.enableScopeAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('can render correctly with the ReactDOMServer', () => { + const clickEvent = jest.fn(); + + function Test() { + const divRef = React.useRef(null); + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return
Hello world
; + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe(`
Hello world
`); + }); + + it('can render correctly with the ReactDOMServer hydration', () => { + const clickEvent = jest.fn(); + const spanRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(spanRef.current, clickEvent); + }); + + return ( +
+ Hello world +
+ ); + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe( + `
Hello world
`, + ); + container.innerHTML = output; + ReactDOM.hydrate(, container); + Scheduler.unstable_flushAll(); + dispatchClickEvent(spanRef.current); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); + + it('should correctly work for a basic "click" listener', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe(''); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log[0]).toEqual({ + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: divRef.current, + }); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering the container and clicking should work + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(2); + + // Clicking the button should also work + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(log[2]).toEqual({ + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: buttonRef.current, + }); + + function Test2({clickEvent2}) { + const click = ReactDOM.unstable_useEvent('click', clickEvent2); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent2); + }); + + return ( + + ); + } + + let clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + + // Reset the function we pass in, so it's different + clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + }); + + it('should correctly work for a basic "click" listener on the outer target', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe(''); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log[0]).toEqual({ + eventPhase: 3, + type: 'click', + currentTarget: divRef.current, + target: divRef.current, + }); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering the container and clicking should work + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(2); + + // Clicking the button should not work + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(clickEvent).toBeCalledTimes(2); + }); + + it('should correctly handle many nested target listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(); + const targetListerner2 = jest.fn(); + const targetListerner3 = jest.fn(); + const targetListerner4 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + + function Test2() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(2); + expect(targetListerner2).toHaveBeenCalledTimes(2); + expect(targetListerner3).toHaveBeenCalledTimes(2); + expect(targetListerner4).toHaveBeenCalledTimes(2); + }); + + it('should correctly work for a basic "click" document listener', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(document, clickEvent); + }); + + return ; + } + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe(''); + + // Clicking outside the button should trigger the event callback + dispatchClickEvent(document.body); + expect(log[0]).toEqual({ + eventPhase: 3, + type: 'click', + currentTarget: document, + target: document.body, + }); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(document.body); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering and clicking the body should work again + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(document.body); + expect(clickEvent).toBeCalledTimes(2); + }); + + it('should correctly handle event propagation in the correct order', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + + function Test() { + // Document + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click'); + // Div + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click', {capture: true}); + // Button + const click5 = ReactDOM.unstable_useEvent('click'); + const click6 = ReactDOM.unstable_useEvent('click', {capture: true}); + + React.useEffect(() => { + click1.setListener(document, e => { + log.push({ + bound: false, + delegated: true, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click2.setListener(document, e => { + log.push({ + bound: false, + delegated: true, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click3.setListener(divRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click4.setListener(divRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click5.setListener(buttonRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click6.setListener(buttonRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + + expect(log).toEqual([ + { + bound: false, + delegated: true, + eventPhase: 1, + currentTarget: document, + target: divRef.current, + }, + { + bound: true, + delegated: false, + eventPhase: 1, + currentTarget: buttonRef.current, + target: divRef.current, + }, + { + bound: true, + delegated: false, + eventPhase: 1, + currentTarget: divRef.current, + target: divRef.current, + }, + { + bound: true, + delegated: false, + eventPhase: 3, + currentTarget: divRef.current, + target: divRef.current, + }, + { + bound: true, + delegated: false, + eventPhase: 3, + currentTarget: buttonRef.current, + target: divRef.current, + }, + { + bound: false, + delegated: true, + eventPhase: 3, + currentTarget: document, + target: divRef.current, + }, + ]); + }); + + it('should correctly handle stopImmediatePropagation for mixed listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopImmediatePropagation()); + const targetListerner2 = jest.fn(e => e.stopImmediatePropagation()); + const rootListerner1 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(document, targetListerner1); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(rootListerner1).toHaveBeenCalledTimes(0); + }); + + it('should correctly handle stopPropagation for based target events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + let clickEvent = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + bind: buttonRef, + }); + const click2 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, clickEvent); + click2.setListener(divRef.current, e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toHaveBeenCalledTimes(0); + }); + + it('should correctly handle stopPropagation for mixed capture/bubbling target listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopPropagation()); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + const targetListerner3 = jest.fn(e => e.stopPropagation()); + const targetListerner4 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); + + it('should correctly handle stopPropagation for target listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopPropagation()); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + const targetListerner3 = jest.fn(e => e.stopPropagation()); + const targetListerner4 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); + + it('should correctly handle stopPropagation for mixed listeners', () => { + const buttonRef = React.createRef(); + const rootListerner1 = jest.fn(e => e.stopPropagation()); + const rootListerner2 = jest.fn(); + const targetListerner1 = jest.fn(); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(document, rootListerner1); + click2.setListener(buttonRef.current, targetListerner1); + click3.setListener(document, rootListerner2); + click4.setListener(buttonRef.current, targetListerner2); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner1).toHaveBeenCalledTimes(0); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(rootListerner2).toHaveBeenCalledTimes(0); + }); + + it('should correctly handle stopPropagation for delegated listeners', () => { + const buttonRef = React.createRef(); + const rootListerner1 = jest.fn(e => e.stopPropagation()); + const rootListerner2 = jest.fn(); + const rootListerner3 = jest.fn(e => e.stopPropagation()); + const rootListerner4 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(document, rootListerner1); + click2.setListener(document, rootListerner2); + click3.setListener(document, rootListerner3); + click4.setListener(document, rootListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(rootListerner2).toHaveBeenCalledTimes(1); + expect(rootListerner3).toHaveBeenCalledTimes(1); + expect(rootListerner4).toHaveBeenCalledTimes(1); + }); + + it.experimental('should work with concurrent mode updates', async () => { + const log = []; + const ref = React.createRef(); + + function Test({counter}) { + const click = ReactDOM.unstable_useEvent('click'); + + React.useLayoutEffect(() => { + click.setListener(ref.current, () => { + log.push({counter}); + }); + }); + + Scheduler.unstable_yieldValue('Test'); + return ; + } + + let root = ReactDOM.createRoot(container); + root.render(); + + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYield(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYield(['Test']); + } + + // Click the button + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Increase counter + root.render(); + // Yield before committing + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYieldThrough(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYieldThrough(['Test']); + } + + // Click the button again + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Commit + expect(Scheduler).toFlushAndYield([]); + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 1}]); + }); +}); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index fddf54ac9c0..bef880ae28e 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -474,6 +474,14 @@ function useTransition( return [startTransition, false]; } +function useEvent(options: any): any { + return { + clear: noop, + deleteListener: noop, + setListener: noop, + }; +} + function noop(): void {} export let currentThreadID: ThreadID = 0; @@ -500,4 +508,5 @@ export const Dispatcher: DispatcherType = { useResponder, useDeferredValue, useTransition, + useEvent, }; diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 959860bcf2c..674e5232ee6 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -54,6 +54,11 @@ const {get: getViewConfigForType} = ReactNativeViewConfigRegistry; let nextReactTag = 2; type Node = Object; + +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Instance = { @@ -467,3 +472,23 @@ export function getInstanceFromNode(node: any) { export function beforeRemoveInstance(instance: any) { // noop } + +export function registerListenerEvent(instance, event, callback): void { + // noop +} + +export function attachListenerToInstance(linstance, event, callback): any { + // noop +} + +export function detachListenerFromInstance(instance, event, callback): any { + // noop +} + +export function validateReactListenerDeleteListener(instance): void { + // noop +} + +export function validateReactListenerMapListener(instance, listener): void { + // noop +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index e91c16a4909..bfb6e037026 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -28,6 +28,10 @@ import ReactNativeFiberHostComponent from './ReactNativeFiberHostComponent'; const {get: getViewConfigForType} = ReactNativeViewConfigRegistry; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Container = number; @@ -520,3 +524,23 @@ export function getInstanceFromNode(node: any) { export function beforeRemoveInstance(instance: any) { // noop } + +export function registerListenerEvent(instance, event, callback): void { + // noop +} + +export function attachListenerToInstance(linstance, event, callback): any { + // noop +} + +export function detachListenerFromInstance(instance, event, callback): any { + // noop +} + +export function validateReactListenerDeleteListener(instance): void { + // noop +} + +export function validateReactListenerMapListener(instance, listener): void { + // noop +} diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index fdb0875f8bb..c48def3847b 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -17,6 +17,12 @@ import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import type { + ReactListenerEvent, + ReactListenerMap, + ReactListener, + Container, +} from './ReactFiberHostConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -54,6 +60,15 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import { + registerListenerEvent, + attachListenerToInstance, + detachListenerFromInstance, + validateReactListenerMapListener, + validateReactListenerDeleteListener, +} from './ReactFiberHostConfig'; +import {getRootHostContainer} from './ReactFiberHostContext'; +import {enableListenerAPI} from 'shared/ReactFeatureFlags'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -97,6 +112,7 @@ export type Dispatcher = {| useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useEvent(event: ReactListenerEvent): ReactListenerMap, |}; type Update = {| @@ -129,7 +145,8 @@ export type HookType = | 'useDebugValue' | 'useResponder' | 'useDeferredValue' - | 'useTransition'; + | 'useTransition' + | 'useEvent'; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -1260,6 +1277,143 @@ function rerenderTransition( return [start, isPending]; } +function createReactListener( + event: ReactListenerEvent, + callback: Event => void, + instance: Container, + destroy: Container => void, +): ReactListener { + return { + callback, + depth: 0, + destroy, + instance, + event, + }; +} + +const noOpMount = () => {}; + +function validateNotInFunctionRender(): boolean { + if (currentlyRenderingFiber === null) { + return true; + } + if (__DEV__) { + console.warn( + 'Event listener methods from useEvent() cannot be used during render.' + + ' These methods should be called in an effect or event callback outside the render.', + ); + } + return false; +} + +export function mountEventListener( + event: ReactListenerEvent, +): ReactListenerMap { + if (enableListenerAPI) { + const hook = mountWorkInProgressHook(); + const rootContainerInstance = getRootHostContainer(); + registerListenerEvent(event, rootContainerInstance); + + let listenerMap: Map = new Map(); + + const clear = (): void => { + if (validateNotInFunctionRender()) { + const listeners = Array.from(listenerMap.values()); + for (let i = 0; i < listeners.length; i++) { + detachListenerFromInstance(listeners[i]); + } + listenerMap.clear(); + } + }; + + const destroy = (instance: Container) => { + listenerMap.delete(instance); + }; + + const reactListenerMap: ReactListenerMap = { + clear, + deleteListener(instance: Container): void { + if ( + validateNotInFunctionRender() && + validateReactListenerDeleteListener(instance) + ) { + const listener = listenerMap.get(instance); + if (listener !== undefined) { + listenerMap.delete(instance); + detachListenerFromInstance(listener); + } + } + }, + setListener(instance: Container, callback: Event => void): void { + if ( + validateNotInFunctionRender() && + validateReactListenerMapListener(instance, callback) + ) { + let listener = listenerMap.get(instance); + if (listener === undefined) { + listener = createReactListener(event, callback, instance, destroy); + listenerMap.set(instance, listener); + } else { + listener.callback = callback; + } + attachListenerToInstance(listener); + } + }, + }; + // In order to clear up upon the hook unmounting, + /// we ensure we push an effect that handles the use-case. + currentlyRenderingFiber.effectTag |= UpdateEffect; + pushEffect(NoHookEffect, noOpMount, clear, null); + hook.memoizedState = [reactListenerMap, event, clear]; + return reactListenerMap; + } + // To make Flow not complain + return (undefined: any); +} + +export function updateEventListener( + event: ReactListenerEvent, +): ReactListenerMap { + if (enableListenerAPI) { + const hook = updateWorkInProgressHook(); + const [reactListenerMap, memoizedEvent, clear] = hook.memoizedState; + if (__DEV__) { + if (memoizedEvent.type !== event.type) { + console.warn( + 'The event type argument passed to the useEvent() hook was different between renders.' + + ' The event type is static and should never change between renders.', + ); + } + if (memoizedEvent.capture !== event.capture) { + console.warn( + 'The "capture" option passed to the useEvent() hook was different between renders.' + + ' The "capture" option is static and should never change between renders.', + ); + } + if (memoizedEvent.priority !== event.priority) { + console.warn( + 'The "priority" option passed to the useEvent() hook was different between renders.' + + ' The "priority" option is static and should never change between renders.', + ); + } + if (memoizedEvent.passive !== event.passive) { + console.warn( + 'The "passive" option passed to the useEvent() hook was different between renders.' + + ' The "passive" option is static and should never change between renders.', + ); + } + } + // In order to clear up upon the hook unmounting, + /// we ensure we push an effect that handles the use-case. + currentlyRenderingFiber.effectTag |= UpdateEffect; + pushEffect(NoHookEffect, noOpMount, clear, null); + return reactListenerMap; + } + // To make Flow not complain + return (undefined: any); +} + function dispatchAction( fiber: Fiber, queue: UpdateQueue, @@ -1385,6 +1539,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useEvent: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1403,6 +1558,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useEvent: mountEventListener, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1421,6 +1577,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useEvent: updateEventListener, }; const HooksDispatcherOnRerender: Dispatcher = { @@ -1588,6 +1745,11 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -1705,6 +1867,11 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -1822,6 +1989,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEventListener(event); + }, }; HooksDispatcherOnRerenderInDEV = { @@ -2070,6 +2242,12 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEventListener(event); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2201,6 +2379,12 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return updateEventListener(event); + }, }; InvalidNestedHooksDispatcherOnRerenderInDEV = { diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index ae619a24daa..46fdcb44bd6 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -37,6 +37,9 @@ export opaque type UpdatePayload = mixed; // eslint-disable-line no-undef export opaque type ChildSet = mixed; // eslint-disable-line no-undef export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef export opaque type NoTimeout = mixed; // eslint-disable-line no-undef +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; export type EventResponder = any; export const getPublicInstance = $$$hostConfig.getPublicInstance; @@ -73,6 +76,11 @@ export const shouldUpdateFundamentalComponent = $$$hostConfig.shouldUpdateFundamentalComponent; export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance; +export const registerListenerEvent = $$$hostConfig.registerListenerEvent; +export const validateReactListenerDeleteListener = + $$$hostConfig.validateReactListenerDeleteListener; +export const validateReactListenerMapListener = + $$$hostConfig.validateReactListenerMapListener; // ------------------- // Mutation @@ -96,6 +104,9 @@ export const updateFundamentalComponent = $$$hostConfig.updateFundamentalComponent; export const unmountFundamentalComponent = $$$hostConfig.unmountFundamentalComponent; +export const attachListenerToInstance = $$$hostConfig.attachListenerToInstance; +export const detachListenerFromInstance = + $$$hostConfig.detachListenerFromInstance; // ------------------- // Persistence diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 6c20c5700d9..d98eff265dd 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -397,6 +397,14 @@ class ReactShallowRenderer { return value; }; + const useEvent = () => { + return { + clear: noOp, + deleteListener: noOp, + setListener: noOp, + }; + }; + return { readContext, useCallback: (identity: any), @@ -415,6 +423,7 @@ class ReactShallowRenderer { useResponder, useTransition, useDeferredValue, + useEvent, }; } diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 58ea9749a1a..119ed7a8a5f 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -15,6 +15,10 @@ import type { import {enableDeprecatedFlareAPI} from 'shared/ReactFeatureFlags'; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Container = {| @@ -375,3 +379,23 @@ export function getInstanceFromNode(mockNode: Object) { export function beforeRemoveInstance(instance: any) { // noop } + +export function registerListenerEvent(instance, event, callback): void { + // noop +} + +export function attachListenerToInstance(linstance, event, callback): any { + // noop +} + +export function detachListenerFromInstance(instance, event, callback): any { + // noop +} + +export function validateReactListenerDeleteListener(instance): void { + // noop +} + +export function validateReactListenerMapListener(instance, listener): void { + // noop +} diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index 19cd62793b1..de99d1dcafd 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -75,3 +75,29 @@ export type ReactDOMResponderContext = { getResponderNode(): Element | null, ... }; + +export type RefObject = {current: null | mixed}; + +export type ReactDOMListenerEvent = {| + capture: boolean, + passive: boolean, + priority: number, + type: string, +|}; + +export type ReactDOMListenerMap = {| + clear: () => void, + setListener: ( + instance: Document | HTMLElement, + callback: (Event) => void, + ) => void, + deleteListener: (instance: Document | HTMLElement) => void, +|}; + +export type ReactDOMListener = {| + callback: Event => void, + depth: number, + destroy: Document | (Element => void), + event: ReactDOMListenerEvent, + instance: Document | Element, +|}; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 7a3b8880ac8..b6b36cdefc0 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -50,9 +50,12 @@ export const exposeConcurrentModeAPIs = __EXPERIMENTAL__; export const warnAboutShorthandPropertyCollision = false; -// Experimental React Flare event system and event components support. +// Experimental React Flare event system. export const enableDeprecatedFlareAPI = false; +// Experimental Listener system. +export const enableListenerAPI = false; + // Experimental Host Component support. export const enableFundamentalAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 22df10b4287..99f9d8341b0 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -33,6 +33,7 @@ export const disableInputAttributeSyncing = false; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const warnAboutDeprecatedLifecycles = true; export const enableDeprecatedFlareAPI = false; +export const enableListenerAPI = false; export const enableFundamentalAPI = false; export const enableScopeAPI = false; export const enableJSXTransformAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index e1b4b6b4c5d..1d01e76cf53 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -27,6 +27,7 @@ export const exposeConcurrentModeAPIs = __EXPERIMENTAL__; export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const enableDeprecatedFlareAPI = false; +export const enableListenerAPI = false; export const enableFundamentalAPI = false; export const enableScopeAPI = false; export const enableJSXTransformAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index b454217e3d7..422cc441cb8 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -27,6 +27,7 @@ export const exposeConcurrentModeAPIs = __EXPERIMENTAL__; export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const enableDeprecatedFlareAPI = false; +export const enableListenerAPI = false; export const enableFundamentalAPI = false; export const enableScopeAPI = false; export const enableJSXTransformAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index da4da062980..f7324f6df77 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -27,6 +27,7 @@ export const exposeConcurrentModeAPIs = __EXPERIMENTAL__; export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const enableDeprecatedFlareAPI = false; +export const enableListenerAPI = false; export const enableFundamentalAPI = false; export const enableScopeAPI = false; export const enableJSXTransformAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 5ca946cd5ca..49e2556d32d 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -25,6 +25,7 @@ export const exposeConcurrentModeAPIs = __EXPERIMENTAL__; export const enableSchedulerDebugging = false; export const disableJavaScriptURLs = false; export const enableDeprecatedFlareAPI = true; +export const enableListenerAPI = false; export const enableFundamentalAPI = false; export const enableScopeAPI = true; export const enableJSXTransformAPI = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 9a00f62a4af..de66e0c0c02 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -78,6 +78,8 @@ function updateFlagOutsideOfReactCallStack() { export const enableDeprecatedFlareAPI = true; +export const enableListenerAPI = true; + export const enableFundamentalAPI = false; export const enableScopeAPI = true; From 8c8338c00a726b170fb0ab778f43dc40f6014ad0 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 21 Dec 2019 18:40:22 +0000 Subject: [PATCH 2/5] Address feedback --- .../react-debug-tools/src/ReactDebugHooks.js | 4 +- .../react-dom/src/client/ReactDOMComponent.js | 7 +- .../src/client/ReactDOMComponentTree.js | 11 +- .../src/client/ReactDOMEventListenerHooks.js | 9 +- .../src/client/ReactDOMHostConfig.js | 50 +++--- .../src/events/DOMEventListenerSystem.js | 150 +++++++----------- .../src/events/DOMEventProperties.js | 2 +- .../src/events/ReactDOMEventListener.js | 10 +- .../DOMEventListenerSystem-test.internal.js | 30 ++-- .../src/server/ReactPartialRendererHooks.js | 2 +- .../src/ReactFabricHostConfig.js | 2 +- .../src/ReactNativeHostConfig.js | 2 +- .../src/ReactFiberCommitWork.js | 3 + .../react-reconciler/src/ReactFiberHooks.js | 31 ++-- .../src/ReactTestHostConfig.js | 2 +- packages/shared/ReactDOMTypes.js | 11 +- 16 files changed, 157 insertions(+), 169 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 3b54f7ee5be..4f50fce91b2 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -242,8 +242,8 @@ function useResponder( const noOp = () => {}; -function useEvent(options: any): any { - hookLog.push({primitive: 'Event', stackError: new Error(), value: options}); +function useEvent(event: any): any { + hookLog.push({primitive: 'Event', stackError: new Error(), value: event}); return { clear: noOp, listen: noOp, diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 7f788e309b4..aee915dfd2f 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -1344,15 +1344,14 @@ export function listenToEventResponderEventTypes( } } -export function listenToEventListener( +export function listenToListenerSystemEvent( type: string, passive: boolean, - document: Document, ): void { if (enableListenerAPI) { // Get the listening Map for this element. We use this to track // what events we're listening to. - const listenerMap = getListenerMapForElement(document); + const listenerMap = getListenerMapForElement(window); const passiveKey = type + '_passive'; const activeKey = type + '_active'; const eventKey = passive ? passiveKey : activeKey; @@ -1374,7 +1373,7 @@ export function listenToEventListener( } } } - const eventListener = addListenerSystemEvent(document, type, passive); + const eventListener = addListenerSystemEvent(window, type, passive); listenerMap.set(eventKey, eventListener); } } diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index 7aff3df2e44..00d7d25e98f 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import type {ReactDOMListener} from 'shared/ReactDOMTypes'; + import { HostComponent, HostText, @@ -166,10 +168,15 @@ export function updateFiberProps(node, props) { node[internalEventHandlersKey] = props; } -export function getListenersFromNode(node) { +export function getListenersFromNode( + node: HTMLElement | Document, +): null | ReactDOMListener { return node[internalEventListenersKey] || null; } -export function initListenersSet(node, value) { +export function initListenersSet( + node: HTMLElement | Document, + value: Set, +): void { node[internalEventListenersKey] = value; } diff --git a/packages/react-dom/src/client/ReactDOMEventListenerHooks.js b/packages/react-dom/src/client/ReactDOMEventListenerHooks.js index 406b84c492c..3d855e6e9dc 100644 --- a/packages/react-dom/src/client/ReactDOMEventListenerHooks.js +++ b/packages/react-dom/src/client/ReactDOMEventListenerHooks.js @@ -7,6 +7,7 @@ * @flow */ +import type {EventPriority} from 'shared/ReactTypes'; import type { ReactDOMListenerEvent, ReactDOMListenerMap, @@ -23,7 +24,7 @@ const ReactCurrentDispatcher = type EventOptions = {| capture?: boolean, passive?: boolean, - priority?: number, + priority?: EventPriority, |}; function resolveDispatcher() { @@ -50,9 +51,9 @@ export function useEvent( let priority = getEventPriorityForListenerSystem((type: any)); if (options != null) { - const optionsCapture = options && options.capture; - const optionsPassive = options && options.passive; - const optionsPriority = options && options.priority; + const optionsCapture = options.capture; + const optionsPassive = options.passive; + const optionsPriority = options.priority; if (typeof optionsCapture === 'boolean') { capture = optionsCapture; diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 6afe4aedb15..6b96d420dd1 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -28,7 +28,7 @@ import { warnForInsertedHydratedElement, warnForInsertedHydratedText, listenToEventResponderEventTypes, - listenToEventListener, + listenToListenerSystemEvent, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -134,8 +134,8 @@ import { import { attachElementListener, detachElementListener, - attachDocumentListener, - detachDocumentListener, + attachWindowListener, + detachWindowListener, } from '../events/DOMEventListenerSystem'; let SUPPRESS_HYDRATION_WARNING; @@ -1076,22 +1076,18 @@ export function getInstanceFromNode(node: Instance): null | Object { return getClosestInstanceFromNode(node) || null; } -export function registerListenerEvent( - event: ReactDOMListenerEvent, - rootContainerInstance: Container, -): void { +export function registerListenerEvent(event: ReactDOMListenerEvent): void { if (enableListenerAPI) { const {type, passive} = event; - const doc = rootContainerInstance.ownerDocument; - listenToEventListener(type, passive, doc); + listenToListenerSystemEvent(type, passive); } } export function attachListenerToInstance(listener: ReactDOMListener): void { if (enableListenerAPI) { const {instance} = listener; - if (instance.nodeType === DOCUMENT_NODE) { - attachDocumentListener(listener); + if (instance === window) { + attachWindowListener(listener); } else { attachElementListener(listener); } @@ -1101,8 +1097,8 @@ export function attachListenerToInstance(listener: ReactDOMListener): void { export function detachListenerFromInstance(listener: ReactDOMListener): void { if (enableListenerAPI) { const {instance} = listener; - if (instance.nodeType === DOCUMENT_NODE) { - detachDocumentListener(listener); + if (instance === window) { + detachWindowListener(listener); } else { detachElementListener(listener); } @@ -1112,30 +1108,38 @@ export function detachListenerFromInstance(listener: ReactDOMListener): void { function validateListenerInstance(instance, methodString): boolean { if ( instance && - (instance.nodeType === DOCUMENT_NODE || - getClosestInstanceFromNode(instance)) + (instance === window || getClosestInstanceFromNode(instance)) ) { return true; } if (__DEV__) { - console.warn( - 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + - ' DOM node that was rendered and managed by React. If this is from a ref, ensure' + - ' the ref value has been set before attaching.', - methodString, - ); + if (instance && (instance: any).nodeType === DOCUMENT_NODE) { + console.warn( + 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + + ' DOM node that was rendered and managed by React or a "window" object. It looks like' + + ' you supplied a "document" node, instead use the "window" object.', + methodString, + ); + } else { + console.warn( + 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + + ' DOM node that was rendered and managed by React or a "window" object. If this is' + + ' from a ref, ensure the ref value has been set before attaching.', + methodString, + ); + } } return false; } export function validateReactListenerDeleteListener( - instance: Container, + instance: EventTarget, ): boolean { return validateListenerInstance(instance, 'deleteListener'); } export function validateReactListenerMapListener( - instance: Container, + instance: EventTarget, listener: Event => void, ): boolean { if (enableListenerAPI) { diff --git a/packages/react-dom/src/events/DOMEventListenerSystem.js b/packages/react-dom/src/events/DOMEventListenerSystem.js index a652452dc7c..14eb0bcf532 100644 --- a/packages/react-dom/src/events/DOMEventListenerSystem.js +++ b/packages/react-dom/src/events/DOMEventListenerSystem.js @@ -10,11 +10,7 @@ import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {ReactDOMListener} from 'shared/ReactDOMTypes'; import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; -import { - ContinuousEvent, - UserBlockingEvent, - DiscreteEvent, -} from 'shared/ReactTypes'; +import {UserBlockingEvent, DiscreteEvent} from 'shared/ReactTypes'; import {HostComponent} from 'shared/ReactWorkTags'; import { batchedEventUpdates, @@ -39,14 +35,14 @@ const { const arrayFrom = Array.from; type EventProperties = {| - currentTarget: null | Document | Element, + currentTarget: null | EventTarget, eventPhase: number, stopImmediatePropagation: boolean, stopPropagation: boolean, |}; -const documentCaptureListeners = new Map(); -const documentBubbleListeners = new Map(); +const windowCaptureListeners = new Map(); +const windowBubbleListeners = new Map(); function monkeyPatchNativeEvent(nativeEvent: any): EventProperties { if (nativeEvent._reactEventProperties) { @@ -121,23 +117,23 @@ function getElementListeners( return [captureListeners, bubbleListeners]; } -function getDocumentListenerSet( +function getWindowListenerSet( type: string, capture: boolean, ): Set { - const delegatedEventListeners = capture - ? documentCaptureListeners - : documentBubbleListeners; - let listenersSet = delegatedEventListeners.get(type); + const windowEventListeners = capture + ? windowCaptureListeners + : windowBubbleListeners; + let listenersSet = windowEventListeners.get(type); if (listenersSet === undefined) { listenersSet = new Set(); - delegatedEventListeners.set(type, listenersSet); + windowEventListeners.set(type, listenersSet); } return listenersSet; } -function dispatchListener( +function processListener( listener: ReactDOMListener, eventProperties: EventProperties, nativeEvent: AnyNativeEvent, @@ -147,7 +143,7 @@ function dispatchListener( executeUserEventHandler(callback, nativeEvent); } -function dispatchListenerAtPriority( +function processListenerAtPriority( listener: ReactDOMListener, eventProperties: EventProperties, nativeEvent: AnyNativeEvent, @@ -156,24 +152,21 @@ function dispatchListenerAtPriority( if (listener.callback == null) { return; } - switch (listener.event.priority) { - case DiscreteEvent: { - flushDiscreteUpdatesIfNeeded(nativeEvent.timeStamp); - discreteUpdates(() => - dispatchListener(listener, eventProperties, nativeEvent), - ); - break; - } - case UserBlockingEvent: { - runWithPriority(UserBlockingPriority, () => - dispatchListener(listener, eventProperties, nativeEvent), - ); - break; - } - case ContinuousEvent: { - dispatchListener(listener, eventProperties, nativeEvent); - break; - } + const priority = listener.event.priority; + + if (priority === DiscreteEvent) { + flushDiscreteUpdatesIfNeeded(nativeEvent.timeStamp); + discreteUpdates(() => + processListener(listener, eventProperties, nativeEvent), + ); + } else if (priority === UserBlockingEvent) { + runWithPriority(UserBlockingPriority, () => + processListener(listener, eventProperties, nativeEvent), + ); + } else { + // Otherwise it is a ContinuousEvent or a prioriy we do not + // know, which means we should fallback to this anyway. + processListener(listener, eventProperties, nativeEvent); } } @@ -189,93 +182,68 @@ function shouldStopPropagation( ); } -function dispatchCaptureListeners( +function processCaptureListeners( eventProperties: EventProperties, listeners: Array, nativeEvent: AnyNativeEvent, - isDocumentListener: boolean, ) { const end = listeners.length - 1; let lastPropagationDepth; for (let i = end; i >= 0; i--) { const listener = listeners[i]; const {depth} = listener; - if ( - (!isDocumentListener || i === end) && - shouldStopPropagation(eventProperties, lastPropagationDepth, depth) - ) { + if (shouldStopPropagation(eventProperties, lastPropagationDepth, depth)) { return; } - dispatchListenerAtPriority(listener, eventProperties, nativeEvent); + processListenerAtPriority(listener, eventProperties, nativeEvent); lastPropagationDepth = depth; } } -function dispatchBubbleListeners( +function processBubbleListeners( eventProperties: EventProperties, listeners: Array, nativeEvent: AnyNativeEvent, - isDocumentListener: boolean, ) { const length = listeners.length; let lastPropagationDepth; for (let i = 0; i < length; i++) { const listener = listeners[i]; const {depth} = listener; - if ( - // When document is not null, we know its a delegated event - (!isDocumentListener || i === 0) && - shouldStopPropagation(eventProperties, lastPropagationDepth, depth) - ) { + if (shouldStopPropagation(eventProperties, lastPropagationDepth, depth)) { return; } - dispatchListenerAtPriority(listener, eventProperties, nativeEvent); + processListenerAtPriority(listener, eventProperties, nativeEvent); lastPropagationDepth = depth; } } -function dispatchListenersByPhase( +function processListenersByPhase( captureElementListeners: Array, bubbleElementListeners: Array, - captureDocumentListeners: Array, - bubbleDocumentListeners: Array, + captureWindowListeners: Array, + bubbleWindowListeners: Array, nativeEvent: AnyNativeEvent, ): void { const eventProperties = monkeyPatchNativeEvent(nativeEvent); // Capture phase eventProperties.eventPhase = 1; - // Dispatch capture delegated event listeners - dispatchCaptureListeners( - eventProperties, - captureDocumentListeners, - nativeEvent, - true, - ); + // Dispatch capture window event listeners + processCaptureListeners(eventProperties, captureWindowListeners, nativeEvent); // Dispatch capture target event listeners - dispatchCaptureListeners( + processCaptureListeners( eventProperties, captureElementListeners, nativeEvent, - false, ); eventProperties.stopPropagation = false; eventProperties.stopImmediatePropagation = false; // Bubble phase eventProperties.eventPhase = 3; // Dispatch bubble target event listeners - dispatchBubbleListeners( - eventProperties, - bubbleElementListeners, - nativeEvent, - false, - ); - // Dispatch bubble delegated event listeners - dispatchBubbleListeners( - eventProperties, - bubbleDocumentListeners, - nativeEvent, - true, - ); + processBubbleListeners(eventProperties, bubbleElementListeners, nativeEvent); + // Dispatch bubble window event listeners + processBubbleListeners(eventProperties, bubbleWindowListeners, nativeEvent); } export function dispatchEventForListenerEventSystem( @@ -289,25 +257,25 @@ export function dispatchEventForListenerEventSystem( captureElementListeners, bubbleElementListeners, ] = getElementListeners(eventType, targetFiber); - const captureDocumentListeners = arrayFrom( - getDocumentListenerSet(eventType, true), + const captureWindowListeners = arrayFrom( + getWindowListenerSet(eventType, true), ); - const bubbleDocumentListeners = arrayFrom( - getDocumentListenerSet(eventType, false), + const bubbleWindowListeners = arrayFrom( + getWindowListenerSet(eventType, false), ); if ( captureElementListeners.length !== 0 || bubbleElementListeners.length !== 0 || - captureDocumentListeners.length !== 0 || - bubbleDocumentListeners.length !== 0 + captureWindowListeners.length !== 0 || + bubbleWindowListeners.length !== 0 ) { batchedEventUpdates(() => - dispatchListenersByPhase( + processListenersByPhase( captureElementListeners, bubbleElementListeners, - captureDocumentListeners, - bubbleDocumentListeners, + captureWindowListeners, + bubbleWindowListeners, nativeEvent, ), ); @@ -315,21 +283,21 @@ export function dispatchEventForListenerEventSystem( } } -function getDocumentListenerSetForListener( +function getWindowListenerSetForListener( listener: ReactDOMListener, ): Set { const {capture, type} = listener.event; - return getDocumentListenerSet(type, capture); + return getWindowListenerSet(type, capture); } -export function attachDocumentListener(listener: ReactDOMListener): void { - const documentListenersSet = getDocumentListenerSetForListener(listener); - documentListenersSet.add(listener); +export function attachWindowListener(listener: ReactDOMListener): void { + const windowListenersSet = getWindowListenerSetForListener(listener); + windowListenersSet.add(listener); } -export function detachDocumentListener(listener: ReactDOMListener): void { - const documentListenersSet = getDocumentListenerSetForListener(listener); - documentListenersSet.delete(listener); +export function detachWindowListener(listener: ReactDOMListener): void { + const windowListenersSet = getWindowListenerSetForListener(listener); + windowListenersSet.delete(listener); } export function attachElementListener(listener: ReactDOMListener): void { diff --git a/packages/react-dom/src/events/DOMEventProperties.js b/packages/react-dom/src/events/DOMEventProperties.js index d5ca3e81fb3..a2db9cc16b6 100644 --- a/packages/react-dom/src/events/DOMEventProperties.js +++ b/packages/react-dom/src/events/DOMEventProperties.js @@ -231,7 +231,7 @@ export function getEventPriorityForListenerSystem(type: string): EventPriority { } if (__DEV__) { console.warn( - 'The event "type" provided to useEffect() does not have a known priority type.' + + 'The event "type" provided to useEvent() does not have a known priority type.' + ' It is recommended to provide a "priority" option to specify a priority.', ); } diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 1d6980ccf64..27bbf7b82b4 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -282,7 +282,7 @@ export function addListenerSystemEvent( topLevelType: string, passive: boolean, ): any => void { - let eventFlags = RESPONDER_EVENT_SYSTEM | LISTENER_EVENT_SYSTEM; + let eventFlags = LISTENER_EVENT_SYSTEM; // If passive option is not supported, then the event will be // active and not passive, but we flag it as using not being @@ -307,13 +307,13 @@ export function addListenerSystemEvent( ); if (passiveBrowserEventsSupported) { addEventCaptureListenerWithPassiveFlag( - document, + window, topLevelType, listener, passive, ); } else { - addEventCaptureListener(document, topLevelType, listener); + addEventCaptureListener(window, topLevelType, listener); } return listener; } @@ -324,12 +324,12 @@ export function removeListenerSystemEvent( listener: any => void, ) { if (passiveBrowserEventsSupported) { - document.removeEventListener(topLevelType, listener, { + window.removeEventListener(topLevelType, listener, { capture: true, passive: false, }); } else { - document.removeEventListener(topLevelType, listener, true); + window.removeEventListener(topLevelType, listener, true); } } diff --git a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js index b5bdb9d1cf4..adb2815722f 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js @@ -312,7 +312,7 @@ describe('DOMEventListenerSystem', () => { expect(targetListerner4).toHaveBeenCalledTimes(2); }); - it('should correctly work for a basic "click" document listener', () => { + it('should correctly work for a basic "click" window listener', () => { const log = []; const clickEvent = jest.fn(event => { log.push({ @@ -327,7 +327,7 @@ describe('DOMEventListenerSystem', () => { const click = ReactDOM.unstable_useEvent('click'); React.useEffect(() => { - click.setListener(document, clickEvent); + click.setListener(window, clickEvent); }); return ; @@ -342,7 +342,7 @@ describe('DOMEventListenerSystem', () => { expect(log[0]).toEqual({ eventPhase: 3, type: 'click', - currentTarget: document, + currentTarget: window, target: document.body, }); @@ -367,7 +367,7 @@ describe('DOMEventListenerSystem', () => { const log = []; function Test() { - // Document + // Window const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); const click2 = ReactDOM.unstable_useEvent('click'); // Div @@ -378,7 +378,7 @@ describe('DOMEventListenerSystem', () => { const click6 = ReactDOM.unstable_useEvent('click', {capture: true}); React.useEffect(() => { - click1.setListener(document, e => { + click1.setListener(window, e => { log.push({ bound: false, delegated: true, @@ -387,7 +387,7 @@ describe('DOMEventListenerSystem', () => { target: e.target, }); }); - click2.setListener(document, e => { + click2.setListener(window, e => { log.push({ bound: false, delegated: true, @@ -452,7 +452,7 @@ describe('DOMEventListenerSystem', () => { bound: false, delegated: true, eventPhase: 1, - currentTarget: document, + currentTarget: window, target: divRef.current, }, { @@ -487,7 +487,7 @@ describe('DOMEventListenerSystem', () => { bound: false, delegated: true, eventPhase: 3, - currentTarget: document, + currentTarget: window, target: divRef.current, }, ]); @@ -507,7 +507,7 @@ describe('DOMEventListenerSystem', () => { React.useEffect(() => { click1.setListener(buttonRef.current, targetListerner1); click2.setListener(buttonRef.current, targetListerner2); - click3.setListener(document, targetListerner1); + click3.setListener(window, targetListerner1); }); return ; @@ -638,9 +638,9 @@ describe('DOMEventListenerSystem', () => { const click4 = ReactDOM.unstable_useEvent('click'); React.useEffect(() => { - click1.setListener(document, rootListerner1); + click1.setListener(window, rootListerner1); click2.setListener(buttonRef.current, targetListerner1); - click3.setListener(document, rootListerner2); + click3.setListener(window, rootListerner2); click4.setListener(buttonRef.current, targetListerner2); }); @@ -672,10 +672,10 @@ describe('DOMEventListenerSystem', () => { const click4 = ReactDOM.unstable_useEvent('click'); React.useEffect(() => { - click1.setListener(document, rootListerner1); - click2.setListener(document, rootListerner2); - click3.setListener(document, rootListerner3); - click4.setListener(document, rootListerner4); + click1.setListener(window, rootListerner1); + click2.setListener(window, rootListerner2); + click3.setListener(window, rootListerner3); + click4.setListener(window, rootListerner4); }); return ; diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index bef880ae28e..6e387a10388 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -474,7 +474,7 @@ function useTransition( return [startTransition, false]; } -function useEvent(options: any): any { +function useEvent(event: any): any { return { clear: noop, deleteListener: noop, diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 674e5232ee6..484e9d2c36d 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -473,7 +473,7 @@ export function beforeRemoveInstance(instance: any) { // noop } -export function registerListenerEvent(instance, event, callback): void { +export function registerListenerEvent(event): void { // noop } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index bfb6e037026..277ebf68ff5 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -525,7 +525,7 @@ export function beforeRemoveInstance(instance: any) { // noop } -export function registerListenerEvent(instance, event, callback): void { +export function registerListenerEvent(event): void { // noop } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index f4282ac4fa5..1f3b985c7c1 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -31,6 +31,7 @@ import { enableProfilerTimer, enableSuspenseServerRenderer, enableDeprecatedFlareAPI, + enableListenerAPI, enableFundamentalAPI, enableSuspenseCallback, enableScopeAPI, @@ -824,6 +825,8 @@ function commitUnmount( case HostComponent: { if (enableDeprecatedFlareAPI) { unmountDeprecatedResponderListeners(current); + } + if (enableDeprecatedFlareAPI || enableListenerAPI) { beforeRemoveInstance(current.stateNode); } safelyDetachRef(current); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index c48def3847b..1c56329eeb6 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -21,7 +21,6 @@ import type { ReactListenerEvent, ReactListenerMap, ReactListener, - Container, } from './ReactFiberHostConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -67,7 +66,6 @@ import { validateReactListenerMapListener, validateReactListenerDeleteListener, } from './ReactFiberHostConfig'; -import {getRootHostContainer} from './ReactFiberHostContext'; import {enableListenerAPI} from 'shared/ReactFeatureFlags'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -1280,8 +1278,8 @@ function rerenderTransition( function createReactListener( event: ReactListenerEvent, callback: Event => void, - instance: Container, - destroy: Container => void, + instance: EventTarget, + destroy: EventTarget => void, ): ReactListener { return { callback, @@ -1312,10 +1310,9 @@ export function mountEventListener( ): ReactListenerMap { if (enableListenerAPI) { const hook = mountWorkInProgressHook(); - const rootContainerInstance = getRootHostContainer(); - registerListenerEvent(event, rootContainerInstance); + registerListenerEvent(event); - let listenerMap: Map = new Map(); + let listenerMap: Map = new Map(); const clear = (): void => { if (validateNotInFunctionRender()) { @@ -1327,13 +1324,25 @@ export function mountEventListener( } }; - const destroy = (instance: Container) => { + const destroy = (instance: EventTarget) => { + // We don't need to call detachListenerFromInstance + // here as this method should only ever be called + // from renderers that need to remove the instance + // from the map representing an instance that still + // holds a reference to the listenerMap. This means + // things like "window" listeners on ReactDOM should + // never enter this call path as the the instance in + // those cases would be that of "window", which + // should be handled via an optimized route in the + // renderer, making less overhead here. If we change + // this heuristic we should update this path to make + // sure we call detachListenerFromInstance. listenerMap.delete(instance); }; const reactListenerMap: ReactListenerMap = { clear, - deleteListener(instance: Container): void { + deleteListener(instance: EventTarget): void { if ( validateNotInFunctionRender() && validateReactListenerDeleteListener(instance) @@ -1345,7 +1354,7 @@ export function mountEventListener( } } }, - setListener(instance: Container, callback: Event => void): void { + setListener(instance: EventTarget, callback: Event => void): void { if ( validateNotInFunctionRender() && validateReactListenerMapListener(instance, callback) @@ -2382,7 +2391,7 @@ if (__DEV__) { useEvent(event: ReactListenerEvent): ReactListenerMap { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); - mountHookTypesDev(); + updateHookTypesDev(); return updateEventListener(event); }, }; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 119ed7a8a5f..09c2d357c05 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -380,7 +380,7 @@ export function beforeRemoveInstance(instance: any) { // noop } -export function registerListenerEvent(instance, event, callback): void { +export function registerListenerEvent(event): void { // noop } diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index de99d1dcafd..6e71923baec 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -81,17 +81,14 @@ export type RefObject = {current: null | mixed}; export type ReactDOMListenerEvent = {| capture: boolean, passive: boolean, - priority: number, + priority: EventPriority, type: string, |}; export type ReactDOMListenerMap = {| clear: () => void, - setListener: ( - instance: Document | HTMLElement, - callback: (Event) => void, - ) => void, - deleteListener: (instance: Document | HTMLElement) => void, + setListener: (instance: EventTarget, callback: (Event) => void) => void, + deleteListener: (instance: EventTarget) => void, |}; export type ReactDOMListener = {| @@ -99,5 +96,5 @@ export type ReactDOMListener = {| depth: number, destroy: Document | (Element => void), event: ReactDOMListenerEvent, - instance: Document | Element, + instance: EventTarget, |}; From 8617f08ffaf3495b508b4b3f56ce93ccc3ba9e53 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sun, 22 Dec 2019 12:38:57 +0000 Subject: [PATCH 3/5] Remove deleteListener, make setListener take null/void to clear --- packages/react-art/src/ReactARTHostConfig.js | 6 +- .../src/client/ReactDOMHostConfig.js | 61 +++++++------------ .../DOMEventListenerSystem-test.internal.js | 52 ++++++++++++++++ .../src/server/ReactPartialRendererHooks.js | 1 - .../src/ReactFabricHostConfig.js | 6 +- .../src/ReactNativeHostConfig.js | 6 +- .../react-reconciler/src/ReactFiberHooks.js | 27 ++++---- .../src/forks/ReactFiberHostConfig.custom.js | 6 +- .../src/ReactShallowRenderer.js | 1 - .../src/ReactTestHostConfig.js | 6 +- packages/shared/ReactDOMTypes.js | 3 +- 11 files changed, 93 insertions(+), 82 deletions(-) diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index dcab883992a..b0e2a4d41cb 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -485,10 +485,6 @@ export function detachListenerFromInstance(listener: any): any { // noop } -export function validateReactListenerDeleteListener(instance): void { - // noop -} - -export function validateReactListenerMapListener(instance, listener): void { +export function validateReactListenerMapSetListener(instance, listener): void { // noop } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 6b96d420dd1..951dee22051 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -1105,52 +1105,37 @@ export function detachListenerFromInstance(listener: ReactDOMListener): void { } } -function validateListenerInstance(instance, methodString): boolean { - if ( - instance && - (instance === window || getClosestInstanceFromNode(instance)) - ) { - return true; - } - if (__DEV__) { - if (instance && (instance: any).nodeType === DOCUMENT_NODE) { - console.warn( - 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + - ' DOM node that was rendered and managed by React or a "window" object. It looks like' + - ' you supplied a "document" node, instead use the "window" object.', - methodString, - ); - } else { - console.warn( - 'Event listener method %s() from useEvent() hook requires the first argument to be a valid' + - ' DOM node that was rendered and managed by React or a "window" object. If this is' + - ' from a ref, ensure the ref value has been set before attaching.', - methodString, - ); - } - } - return false; -} - -export function validateReactListenerDeleteListener( +export function validateReactListenerMapSetListener( instance: EventTarget, -): boolean { - return validateListenerInstance(instance, 'deleteListener'); -} - -export function validateReactListenerMapListener( - instance: EventTarget, - listener: Event => void, + listener: ?(Event) => void, ): boolean { if (enableListenerAPI) { - if (validateListenerInstance(instance, 'setListener')) { - if (typeof listener === 'function') { + if ( + instance && + (instance === window || getClosestInstanceFromNode(instance)) + ) { + if (listener == null || typeof listener === 'function') { return true; } if (__DEV__) { console.warn( 'Event listener method setListener() from useEvent() hook requires the second argument' + - ' to be valid function callback.', + ' to be either a valid function callback or null/undefined.', + ); + } + } + if (__DEV__) { + if (instance && (instance: any).nodeType === DOCUMENT_NODE) { + console.warn( + 'Event listener method setListener() from useEvent() hook requires the first argument to be a valid' + + ' DOM node that was rendered and managed by React or a "window" object. It looks like' + + ' you supplied a "document" node, instead use the "window" object.', + ); + } else { + console.warn( + 'Event listener method setListener() from useEvent() hook requires the first argument to be a valid' + + ' DOM node that was rendered and managed by React or a "window" object. If this is' + + ' from a ref, ensure the ref value has been set before attaching.', ); } } diff --git a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js index adb2815722f..4e9e4646215 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js @@ -190,6 +190,58 @@ describe('DOMEventListenerSystem', () => { expect(clickEvent2).toBeCalledTimes(1); }); + it('should correctly work for setting and clearing a basic "click" listener', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test({off}) { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + React.useEffect( + () => { + if (off) { + click.setListener(buttonRef.current, null); + } + }, + [off], + ); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // The listener should get unmounted in the second effect + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + }); + it('should correctly work for a basic "click" listener on the outer target', () => { const log = []; const clickEvent = jest.fn(event => { diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 6e387a10388..4dbf11ad2ba 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -477,7 +477,6 @@ function useTransition( function useEvent(event: any): any { return { clear: noop, - deleteListener: noop, setListener: noop, }; } diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 484e9d2c36d..681296eb8e0 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -485,10 +485,6 @@ export function detachListenerFromInstance(instance, event, callback): any { // noop } -export function validateReactListenerDeleteListener(instance): void { - // noop -} - -export function validateReactListenerMapListener(instance, listener): void { +export function validateReactListenerMapSetListener(instance, listener): void { // noop } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 277ebf68ff5..d398e19cb19 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -537,10 +537,6 @@ export function detachListenerFromInstance(instance, event, callback): any { // noop } -export function validateReactListenerDeleteListener(instance): void { - // noop -} - -export function validateReactListenerMapListener(instance, listener): void { +export function validateReactListenerMapSetListener(instance, listener): void { // noop } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 1c56329eeb6..e2aa4b2b35e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -63,8 +63,7 @@ import { registerListenerEvent, attachListenerToInstance, detachListenerFromInstance, - validateReactListenerMapListener, - validateReactListenerDeleteListener, + validateReactListenerMapSetListener, } from './ReactFiberHostConfig'; import {enableListenerAPI} from 'shared/ReactFeatureFlags'; @@ -1342,28 +1341,24 @@ export function mountEventListener( const reactListenerMap: ReactListenerMap = { clear, - deleteListener(instance: EventTarget): void { + setListener(instance: EventTarget, callback: ?(Event) => void): void { if ( validateNotInFunctionRender() && - validateReactListenerDeleteListener(instance) - ) { - const listener = listenerMap.get(instance); - if (listener !== undefined) { - listenerMap.delete(instance); - detachListenerFromInstance(listener); - } - } - }, - setListener(instance: EventTarget, callback: Event => void): void { - if ( - validateNotInFunctionRender() && - validateReactListenerMapListener(instance, callback) + validateReactListenerMapSetListener(instance, callback) ) { let listener = listenerMap.get(instance); if (listener === undefined) { + if (callback == null) { + return; + } listener = createReactListener(event, callback, instance, destroy); listenerMap.set(instance, listener); } else { + if (callback == null) { + listenerMap.delete(instance); + detachListenerFromInstance(listener); + return; + } listener.callback = callback; } attachListenerToInstance(listener); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 46fdcb44bd6..1c1a5c51bee 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -77,10 +77,8 @@ export const shouldUpdateFundamentalComponent = export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance; export const registerListenerEvent = $$$hostConfig.registerListenerEvent; -export const validateReactListenerDeleteListener = - $$$hostConfig.validateReactListenerDeleteListener; -export const validateReactListenerMapListener = - $$$hostConfig.validateReactListenerMapListener; +export const validateReactListenerMapSetListener = + $$$hostConfig.validateReactListenerMapSetListener; // ------------------- // Mutation diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index d98eff265dd..baa18d9dd15 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -400,7 +400,6 @@ class ReactShallowRenderer { const useEvent = () => { return { clear: noOp, - deleteListener: noOp, setListener: noOp, }; }; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 09c2d357c05..382f0604d11 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -392,10 +392,6 @@ export function detachListenerFromInstance(instance, event, callback): any { // noop } -export function validateReactListenerDeleteListener(instance): void { - // noop -} - -export function validateReactListenerMapListener(instance, listener): void { +export function validateReactListenerMapSetListener(instance, listener): void { // noop } diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index 6e71923baec..2527e71c53d 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -87,8 +87,7 @@ export type ReactDOMListenerEvent = {| export type ReactDOMListenerMap = {| clear: () => void, - setListener: (instance: EventTarget, callback: (Event) => void) => void, - deleteListener: (instance: EventTarget) => void, + setListener: (instance: EventTarget, callback: ?(Event) => void) => void, |}; export type ReactDOMListener = {| From 8b9df74f4b1e67125526be6d6a1a9c349ff61a5d Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 10 Jan 2020 23:11:56 +0000 Subject: [PATCH 4/5] Fix prettier --- packages/legacy-events/ReactGenericBatching.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/legacy-events/ReactGenericBatching.js b/packages/legacy-events/ReactGenericBatching.js index 9fa36762275..510d40b00d9 100644 --- a/packages/legacy-events/ReactGenericBatching.js +++ b/packages/legacy-events/ReactGenericBatching.js @@ -122,7 +122,12 @@ export function flushDiscreteUpdatesIfNeeded(timeStamp: number) { if ( !isInsideEventHandler && ((!enableDeprecatedFlareAPI && !enableListenerAPI) || +<<<<<<< HEAD (timeStamp === 0 || lastFlushedEventTimeStamp !== timeStamp)) +======= + timeStamp === 0 || + lastFlushedEventTimeStamp !== timeStamp) +>>>>>>> Fix prettier ) { lastFlushedEventTimeStamp = timeStamp; flushDiscreteUpdatesImpl(); From 416ee3446ab22838eeb9569ad759db216d8ef182 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 17 Jan 2020 21:46:53 +0000 Subject: [PATCH 5/5] Fix conflicts Fix flow fix conflict Add missing flag --- .../legacy-events/ReactGenericBatching.js | 4 ---- .../DOMEventListenerSystem-test.internal.js | 13 +++++-------- .../src/ReactFabricHostConfig.js | 19 +++++++++++++++---- .../src/ReactNativeHostConfig.js | 19 +++++++++++++++---- .../react-reconciler/src/ReactFiberHooks.js | 13 +++++++++++++ .../src/ReactTestHostConfig.js | 19 +++++++++++++++---- .../shared/forks/ReactFeatureFlags.testing.js | 1 + 7 files changed, 64 insertions(+), 24 deletions(-) diff --git a/packages/legacy-events/ReactGenericBatching.js b/packages/legacy-events/ReactGenericBatching.js index 510d40b00d9..4703fa8dded 100644 --- a/packages/legacy-events/ReactGenericBatching.js +++ b/packages/legacy-events/ReactGenericBatching.js @@ -122,12 +122,8 @@ export function flushDiscreteUpdatesIfNeeded(timeStamp: number) { if ( !isInsideEventHandler && ((!enableDeprecatedFlareAPI && !enableListenerAPI) || -<<<<<<< HEAD - (timeStamp === 0 || lastFlushedEventTimeStamp !== timeStamp)) -======= timeStamp === 0 || lastFlushedEventTimeStamp !== timeStamp) ->>>>>>> Fix prettier ) { lastFlushedEventTimeStamp = timeStamp; flushDiscreteUpdatesImpl(); diff --git a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js index 4e9e4646215..c52943a9267 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventListenerSystem-test.internal.js @@ -210,14 +210,11 @@ describe('DOMEventListenerSystem', () => { click.setListener(buttonRef.current, clickEvent); }); - React.useEffect( - () => { - if (off) { - click.setListener(buttonRef.current, null); - } - }, - [off], - ); + React.useEffect(() => { + if (off) { + click.setListener(buttonRef.current, null); + } + }, [off]); return (