diff --git a/packages/legacy-events/EventSystemFlags.js b/packages/legacy-events/EventSystemFlags.js index a3d51f3197b75..f5b24a896b9a6 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 1e7635114f779..49a246de19a20 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 765e44d257cad..9fa36762275a5 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,7 +121,7 @@ export function flushDiscreteUpdatesIfNeeded(timeStamp: number) { // behaviour as we had before this change, so the risks are low. if ( !isInsideEventHandler && - (!enableDeprecatedFlareAPI || + ((!enableDeprecatedFlareAPI && !enableListenerAPI) || (timeStamp === 0 || lastFlushedEventTimeStamp !== timeStamp)) ) { lastFlushedEventTimeStamp = timeStamp; diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 62bdb3398664b..dcab883992a0f 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 34c3e43b81edc..97b9e1c54d9b8 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -238,6 +238,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] { @@ -275,6 +286,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 818d0eef3a94f..d85c3bc2a4eb4 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -53,7 +53,10 @@ import { } from 'legacy-events/EventPropagators'; import ReactVersion from 'shared/ReactVersion'; import invariant from 'shared/invariant'; -import {exposeConcurrentModeAPIs} from 'shared/ReactFeatureFlags'; +import { + exposeConcurrentModeAPIs, + enableListenerAPI, +} from 'shared/ReactFeatureFlags'; import { getInstanceFromNode, @@ -70,6 +73,7 @@ import { setAttemptHydrationAtCurrentPriority, queueExplicitHydrationTarget, } from '../events/ReactDOMEventReplaying'; +import {useEvent} from './ReactDOMEventListenerHooks'; setAttemptSynchronousHydration(attemptSynchronousHydration); setAttemptUserBlockingHydration(attemptUserBlockingHydration); @@ -193,6 +197,10 @@ if (exposeConcurrentModeAPIs) { }; } +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 670d5a70e6f38..462bcddbab837 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 { @@ -90,6 +92,7 @@ import {toStringOrTrustedType} from './ToStringValue'; import { enableDeprecatedFlareAPI, enableTrustedTypesIntegration, + enableListenerAPI, } from 'shared/ReactFeatureFlags'; let didWarnInvalidHydration = false; @@ -1345,6 +1348,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 575cd3860a683..7aff3df2e44bd 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 0000000000000..94668bcd27051 --- /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 {getEventPriority} from '../events/SimpleEventPlugin'; + +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 = getEventPriority((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 96498935a5a0d..bf0d95d52e3a3 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, @@ -113,11 +122,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__) { @@ -508,6 +524,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( @@ -1037,6 +1069,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 0000000000000..a652452dc7c31 --- /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/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 1bc5373e83350..87405d800ce50 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, @@ -59,24 +60,26 @@ import { } from './EventListener'; import getEventTarget from './getEventTarget'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; -import SimpleEventPlugin from './SimpleEventPlugin'; +import {getEventPriority} from './SimpleEventPlugin'; 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 {dispatchEventForListenerEventSystem} from './DOMEventListenerSystem'; const { unstable_UserBlockingPriority: UserBlockingPriority, unstable_runWithPriority: runWithPriority, } = Scheduler; -const {getEventPriority} = SimpleEventPlugin; - const CALLBACK_BOOKKEEPING_POOL_SIZE = 10; const callbackBookkeepingPool = []; @@ -274,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, @@ -403,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, @@ -422,6 +481,14 @@ export function dispatchEvent( eventSystemFlags, ); } + if (eventSystemFlags & LISTENER_EVENT_SYSTEM) { + // React Listener event system + dispatchEventForListenerEventSystem( + (topLevelType: any), + null, + nativeEvent, + ); + } } else { dispatchEventForPluginEventSystem( topLevelType, @@ -481,7 +548,7 @@ export function attemptToDispatchEvent( } } - if (enableDeprecatedFlareAPI) { + if (enableDeprecatedFlareAPI || enableListenerAPI) { if (eventSystemFlags & PLUGIN_EVENT_SYSTEM) { dispatchEventForPluginEventSystem( topLevelType, @@ -500,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/SimpleEventPlugin.js b/packages/react-dom/src/events/SimpleEventPlugin.js index a8f0ff6ef1785..62c076cd47efb 100644 --- a/packages/react-dom/src/events/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/SimpleEventPlugin.js @@ -240,10 +240,7 @@ const SimpleEventPlugin: PluginModule & { } = { eventTypes: eventTypes, - getEventPriority(topLevelType: TopLevelType): EventPriority { - const config = topLevelEventsToDispatchConfig[topLevelType]; - return config !== undefined ? config.eventPriority : ContinuousEvent; - }, + getEventPriority, extractEvents: function( topLevelType: TopLevelType, @@ -364,4 +361,9 @@ const SimpleEventPlugin: PluginModule & { }, }; +export function getEventPriority(topLevelType: TopLevelType): EventPriority { + const config = topLevelEventsToDispatchConfig[topLevelType]; + return config !== undefined ? config.eventPriority : ContinuousEvent; +} + export default SimpleEventPlugin; 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 0000000000000..b5bdb9d1cf474 --- /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 bcdc14397b2a0..b2b4d44bfe760 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -473,6 +473,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; @@ -499,4 +507,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 2badfedd63e86..6581d6c7c3c6c 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 = { @@ -468,3 +473,23 @@ export function getInstanceFromNode(node) { export function beforeRemoveInstance(instance) { // 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 33ce87ddffe55..e6f2dd8888ba9 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) { export function beforeRemoveInstance(instance) { // 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 03711d756a26e..ddc9cd0acfae7 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'; @@ -56,6 +62,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; @@ -99,6 +114,7 @@ export type Dispatcher = { useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useEvent(event: ReactListenerEvent): ReactListenerMap, }; type Update = { @@ -132,7 +148,8 @@ export type HookType = | 'useDebugValue' | 'useResponder' | 'useDeferredValue' - | 'useTransition'; + | 'useTransition' + | 'useEvent'; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -1221,6 +1238,143 @@ function updateTransition( 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, @@ -1375,6 +1529,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useEvent: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1393,6 +1548,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useEvent: mountEventListener, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1411,6 +1567,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useEvent: updateEventListener, }; let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -1558,6 +1715,11 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -1675,6 +1837,11 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -1792,6 +1959,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEventListener(event); + }, }; InvalidNestedHooksDispatcherOnMountInDEV = { @@ -1923,6 +2095,12 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEventListener(event); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2054,5 +2232,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return updateEventListener(event); + }, }; } diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index ae619a24daa56..46fdcb44bd670 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 df3f93575287b..8f8ca203bbf85 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -395,6 +395,14 @@ class ReactShallowRenderer { return value; }; + const useEvent = () => { + return { + clear: noOp, + deleteListener: noOp, + setListener: noOp, + }; + }; + return { readContext, useCallback: (identity: any), @@ -413,6 +421,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 d3a2bbf7bd117..04684ba5ba520 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 = {| @@ -373,3 +377,23 @@ export function getInstanceFromNode(mockNode: Object) { export function beforeRemoveInstance(instance) { // 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 fc5673bd11d12..0ed5f3b95bc65 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -73,3 +73,29 @@ export type ReactDOMResponderContext = { enqueueStateRestore(Element | Document): void, 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 70d46bc0734bc..a990c53820744 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -54,9 +54,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 4ebed5ecb744d..330dfa54b5972 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 8b4b894aae2ad..b9f69d7a83609 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 b4d5489328a9c..f92f088f1edb0 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 848305f9ab271..1603e7f8c190d 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 92743530e4a18..984e284ea0597 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 6696c8e05ebab..d995055711ee9 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -76,6 +76,8 @@ function updateFlagOutsideOfReactCallStack() { export const enableDeprecatedFlareAPI = true; +export const enableListenerAPI = true; + export const enableFundamentalAPI = false; export const enableScopeAPI = true;