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..c52b8bb179d51 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -469,3 +469,20 @@ export function getInstanceFromNode(node) { export function beforeRemoveInstance(instance) { // noop } + +export function registerListenerEvent( + event: any, + rootContainerInstance: Container, +): void { + // noop +} + +export function attachListenerToInstance(listener: any): boolean { + // noop + return false; +} + +export function detachListenerFromInstance(listener: any): boolean { + // noop + return false; +} diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 07e94cd720ed1..b72ec29c5a17a 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -237,6 +237,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] { @@ -274,6 +285,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..8d73b4ee48d7f --- /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, + ReactDOMListenerHook, +} 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, +): ReactDOMListenerHook { + 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..81c48285c7fbf 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -27,6 +27,7 @@ import { warnForInsertedHydratedElement, warnForInsertedHydratedText, listenToEventResponderEventTypes, + listenToEventListener, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -50,6 +51,9 @@ import type { ReactDOMEventResponder, ReactDOMEventResponderInstance, ReactDOMFundamentalComponentInstance, + ReactDOMListener, + ReactDOMListenerEvent, + ReactDOMListenerHook, } from 'shared/ReactDOMTypes'; import { mountEventResponder, @@ -58,6 +62,10 @@ import { } from '../events/DeprecatedDOMEventResponderSystem'; import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; +export type ReactListenerEvent = ReactDOMListenerEvent; +export type ReactListenerHook = ReactDOMListenerHook; +export type ReactListener = ReactDOMListener; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -118,6 +126,12 @@ import { RESPONDER_EVENT_SYSTEM, IS_PASSIVE, } from 'legacy-events/EventSystemFlags'; +import { + attachElementListener, + detachElementListener, + attachDocumentListener, + detachDocumentListener, +} from '../events/DOMEventListenerSystem'; let SUPPRESS_HYDRATION_WARNING; if (__DEV__) { @@ -1037,6 +1051,47 @@ 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 { + const {type, passive} = event; + const doc = rootContainerInstance.ownerDocument; + listenToEventListener(type, passive, doc); +} + +export function attachListenerToInstance(listener: ReactDOMListener): boolean { + const {instance} = listener; + if (instance.nodeType === DOCUMENT_NODE) { + attachDocumentListener(listener); + return true; + } + const internalInstanceHandle = getClosestInstanceFromNode(instance); + + if (!internalInstanceHandle) { + return false; + } + attachElementListener(listener); + return true; +} + +export function detachListenerFromInstance( + listener: ReactDOMListener, +): boolean { + const {instance} = listener; + if (instance.nodeType === DOCUMENT_NODE) { + detachDocumentListener(listener); + return true; + } + const internalInstanceHandle = getClosestInstanceFromNode(instance); + + if (!internalInstanceHandle) { + return false; + } + detachElementListener(listener); + return true; +} 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..0a62e31f63d8c --- /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.listen(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.listen(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.listen(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.listen(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.listen(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.listen(buttonRef.current, targetListerner1); + click2.listen(buttonRef.current, targetListerner2); + click3.listen(buttonRef.current, targetListerner3); + click4.listen(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.listen(buttonRef.current, targetListerner1); + click2.listen(buttonRef.current, targetListerner2); + click3.listen(buttonRef.current, targetListerner3); + click4.listen(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.listen(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.listen(document, e => { + log.push({ + bound: false, + delegated: true, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click2.listen(document, e => { + log.push({ + bound: false, + delegated: true, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click3.listen(divRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click4.listen(divRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click5.listen(buttonRef.current, e => { + log.push({ + bound: true, + delegated: false, + eventPhase: e.eventPhase, + currentTarget: e.currentTarget, + target: e.target, + }); + }); + click6.listen(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.listen(buttonRef.current, targetListerner1); + click2.listen(buttonRef.current, targetListerner2); + click3.listen(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.listen(buttonRef.current, clickEvent); + click2.listen(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.listen(buttonRef.current, targetListerner1); + click2.listen(buttonRef.current, targetListerner2); + click3.listen(buttonRef.current, targetListerner3); + click4.listen(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.listen(buttonRef.current, targetListerner1); + click2.listen(buttonRef.current, targetListerner2); + click3.listen(buttonRef.current, targetListerner3); + click4.listen(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.listen(document, rootListerner1); + click2.listen(buttonRef.current, targetListerner1); + click3.listen(document, rootListerner2); + click4.listen(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.listen(document, rootListerner1); + click2.listen(document, rootListerner2); + click3.listen(document, rootListerner3); + click4.listen(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.listen(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..c55213d291f8e 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, + listen: noop, + unlisten: 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..72c89f46621ca 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 ReactListenerHook = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Instance = { @@ -468,3 +473,20 @@ export function getInstanceFromNode(node) { export function beforeRemoveInstance(instance) { // noop } + +export function registerListenerEvent( + event: any, + rootContainerInstance: Container, +): void { + // noop +} + +export function attachListenerToInstance(listener: any): boolean { + // noop + return false; +} + +export function detachListenerFromInstance(listener: any): boolean { + // noop + return false; +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 33ce87ddffe55..9c6f700707821 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 ReactListenerHook = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Container = number; @@ -520,3 +524,20 @@ export function getInstanceFromNode(node) { export function beforeRemoveInstance(instance) { // noop } + +export function registerListenerEvent( + event: any, + rootContainerInstance: Container, +): void { + // noop +} + +export function attachListenerToInstance(listener: any): boolean { + // noop + return false; +} + +export function detachListenerFromInstance(listener: any): boolean { + // noop + return false; +} diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index e927b6898066c..352aaafe9d760 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, + ReactListenerHook, + ReactListener, + Container, +} from './ReactFiberHostConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -56,6 +62,13 @@ import { runWithPriority, getCurrentPriorityLevel, } from './SchedulerWithReactIntegration'; +import { + registerListenerEvent, + attachListenerToInstance, + detachListenerFromInstance, +} from './ReactFiberHostConfig'; +import {getRootHostContainer} from './ReactFiberHostContext'; +import {enableListenerAPI} from 'shared/ReactFeatureFlags'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -99,6 +112,7 @@ export type Dispatcher = { useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useEvent(event: ReactListenerEvent): ReactListenerHook, }; type Update = { @@ -132,7 +146,8 @@ export type HookType = | 'useDebugValue' | 'useResponder' | 'useDeferredValue' - | 'useTransition'; + | 'useTransition' + | 'useEvent'; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -1221,6 +1236,132 @@ function updateTransition( return [start, isPending]; } +function createReactListener( + event: ReactListenerEvent, + callback: Event => void, + instance: Container, +): ReactListener { + return { + callback, + depth: 0, + instance, + event, + }; +} + +const noOpMount = () => {}; + +export function mountEventListener( + event: ReactListenerEvent, +): ReactListenerHook { + if (enableListenerAPI) { + const hook = mountWorkInProgressHook(); + const rootContainerInstance = getRootHostContainer(); + registerListenerEvent(event, rootContainerInstance); + let listenersMap = new Map(); + + const clear = (): void => { + const listeners = Array.from(listenersMap.values()); + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + detachListenerFromInstance(listener); + } + listenersMap.clear(); + }; + + const listenerHook: ReactListenerHook = { + clear, + listen(instance: Container, callback: Event => void): void { + if (instance) { + let listener = listenersMap.get(instance); + if (listener === undefined) { + listener = createReactListener(event, callback, instance); + listenersMap.set(instance, listener); + } else { + listener.callback = callback; + } + if (attachListenerToInstance(listener)) { + return; + } + } + invariant( + false, + 'useEvent() listen() failed where the passed argument was not a valid' + + ' instance that was rendered and managed by React. If this is from a ref, ensure' + + ' the ref value is valid.', + ); + }, + unlisten(instance: Container): void { + if (instance) { + const listener = listenersMap.get(instance); + if (listener !== undefined) { + listenersMap.delete(instance); + if (detachListenerFromInstance(listener)) { + return; + } + } + } + invariant( + false, + 'useEvent() unlisten() failed where the passed argument was not a valid' + + ' instance that was rendered and managed by React. If this is from a ref, ensure' + + ' the ref value is valid.', + ); + }, + }; + // 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 = [listenerHook, event, clear]; + return listenerHook; + } + // To make Flow not complain + return (undefined: any); +} + +export function updateEventListener( + event: ReactListenerEvent, +): ReactListenerHook { + if (enableListenerAPI) { + const hook = updateWorkInProgressHook(); + const [listenerHook, 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 listenerHook; + } + // To make Flow not complain + return (undefined: any); +} + function dispatchAction( fiber: Fiber, queue: UpdateQueue, @@ -1375,6 +1516,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useEvent: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1393,6 +1535,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useEvent: mountEventListener, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1411,6 +1554,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useEvent: updateEventListener, }; let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -1558,6 +1702,11 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerHook { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -1675,6 +1824,11 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerHook { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -1792,6 +1946,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerHook { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEventListener(event); + }, }; InvalidNestedHooksDispatcherOnMountInDEV = { @@ -1923,6 +2082,12 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerHook { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEventListener(event); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2054,5 +2219,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerHook { + 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..c5b8f6f2a1b3b 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 ReactListenerHook = Object; +export type ReactListener = Object; export type EventResponder = any; export const getPublicInstance = $$$hostConfig.getPublicInstance; @@ -73,6 +76,7 @@ export const shouldUpdateFundamentalComponent = $$$hostConfig.shouldUpdateFundamentalComponent; export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance; +export const registerListenerEvent = $$$hostConfig.registerListenerEvent; // ------------------- // Mutation @@ -96,6 +100,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..f2bd0104413c3 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, + listen: noOp, + unlisten: 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..114d3336960e7 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 ReactListenerHook = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Container = {| @@ -373,3 +377,20 @@ export function getInstanceFromNode(mockNode: Object) { export function beforeRemoveInstance(instance) { // noop } + +export function registerListenerEvent( + event: any, + rootContainerInstance: Container, +): void { + // noop +} + +export function attachListenerToInstance(listener: any): boolean { + // noop + return false; +} + +export function detachListenerFromInstance(listener: any): boolean { + // noop + return false; +} diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index fc5673bd11d12..8342156fd9096 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -73,3 +73,25 @@ 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 ReactDOMListenerHook = {| + clear: () => void, + listen: (instance: Document | HTMLElement, listener: (Event) => void) => void, + unlisten: (instance: Document | HTMLElement) => void, +|}; + +export type ReactDOMListener = {| + callback: Event => void, + depth: number, + event: ReactDOMListenerEvent, + instance: Document | Element, +|}; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6ebbd835636fd..53149ca0560ce 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -51,9 +51,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 75beac6858661..f5bea3d77f0ff 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -32,6 +32,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 f53d14ee679b5..9b772533b986e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -26,6 +26,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 a92d85a784490..5c19825a6f4fc 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -26,6 +26,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 270b83b0483b1..6dae8c7f8444a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -26,6 +26,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 6b134a813e453..d1c33f58127a6 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -24,6 +24,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 693ecabf0a252..2844e06d4ddee 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -74,6 +74,8 @@ function updateFlagOutsideOfReactCallStack() { export const enableDeprecatedFlareAPI = true; +export const enableListenerAPI = true; + export const enableFundamentalAPI = false; export const enableScopeAPI = true; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index f0f93e9b6949d..a7ab61b89aa77 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -344,5 +344,9 @@ "343": "ReactDOMServer does not yet support scope components.", "344": "Expected prepareToHydrateHostSuspenseInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.", "345": "Root did not complete. This is a bug in React.", - "346": "An event responder context was used outside of an event cycle." + "346": "An event responder context was used outside of an event cycle.", + "347": "useEffect hook was unable to find a valid managed node to bind to. Check the \"bind\" option is a valid ref created via the useRef hook", + "348": "An object ref (via the useRef hook) is required for the \"bind\" option in useEvent", + "349": "useEvent() listen() failed where the passed argument was not a valid instance that was rendered and managed by React. If this is from a ref, ensure the ref value is valid.", + "350": "useEvent() unlisten() failed where the passed argument was not a valid instance that was rendered and managed by React. If this is from a ref, ensure the ref value is valid." }