From 61f2a560e0c5a0a7d218c52a1b74b3f3592acb9b Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 12 May 2020 20:24:25 +0100 Subject: [PATCH] Add experimental ReactDOM.createEventHandle (#18756) --- packages/react-dom/index.classic.fb.js | 1 + packages/react-dom/index.js | 1 + packages/react-dom/index.modern.fb.js | 1 + packages/react-dom/src/client/ReactDOM.js | 3 + .../src/client/ReactDOMEventHandle.js | 247 +++ .../src/client/ReactDOMHostConfig.js | 19 +- .../src/events/DOMModernPluginEventSystem.js | 187 +- ...OMModernPluginEventSystem-test.internal.js | 1582 +++++++++++++++++ .../events/plugins/ModernSimpleEventPlugin.js | 10 +- packages/react-interactions/events/focus.js | 10 + .../src/dom/create-event-handle/Focus.js | 439 +++++ .../__tests__/useFocus-test.internal.js | 327 ++++ .../__tests__/useFocusWithin-test.internal.js | 579 ++++++ .../src/dom/create-event-handle/useEvent.js | 49 + .../shared/forks/ReactFeatureFlags.www.js | 2 +- scripts/error-codes/codes.json | 5 +- 16 files changed, 3449 insertions(+), 13 deletions(-) create mode 100644 packages/react-dom/src/client/ReactDOMEventHandle.js create mode 100644 packages/react-interactions/events/focus.js create mode 100644 packages/react-interactions/events/src/dom/create-event-handle/Focus.js create mode 100644 packages/react-interactions/events/src/dom/create-event-handle/__tests__/useFocus-test.internal.js create mode 100644 packages/react-interactions/events/src/dom/create-event-handle/__tests__/useFocusWithin-test.internal.js create mode 100644 packages/react-interactions/events/src/dom/create-event-handle/useEvent.js diff --git a/packages/react-dom/index.classic.fb.js b/packages/react-dom/index.classic.fb.js index 04c59dbc55b38..f89aab4fb580b 100644 --- a/packages/react-dom/index.classic.fb.js +++ b/packages/react-dom/index.classic.fb.js @@ -36,4 +36,5 @@ export { unstable_scheduleHydration, unstable_renderSubtreeIntoContainer, unstable_createPortal, + unstable_createEventHandle, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.js b/packages/react-dom/index.js index 2487bf5e49807..d16ef8090ec8d 100644 --- a/packages/react-dom/index.js +++ b/packages/react-dom/index.js @@ -27,4 +27,5 @@ export { unstable_scheduleHydration, unstable_renderSubtreeIntoContainer, unstable_createPortal, + unstable_createEventHandle, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.modern.fb.js b/packages/react-dom/index.modern.fb.js index ae9262a3801f4..b32ff105ce8ed 100644 --- a/packages/react-dom/index.modern.fb.js +++ b/packages/react-dom/index.modern.fb.js @@ -19,4 +19,5 @@ export { createBlockingRoot as unstable_createBlockingRoot, unstable_flushControlled, unstable_scheduleHydration, + unstable_createEventHandle, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 741d24f731815..9aaa87514e681 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -20,6 +20,7 @@ import { unmountComponentAtNode, } from './ReactDOMLegacy'; import {createRoot, createBlockingRoot, isValidContainer} from './ReactDOMRoot'; +import {createEventHandle} from './ReactDOMEventHandle'; import { batchedEventUpdates, @@ -208,6 +209,8 @@ export { // Temporary alias since we already shipped React 16 RC with it. // TODO: remove in React 17. unstable_createPortal, + // enableCreateEventHandleAPI + createEventHandle as unstable_createEventHandle, }; const foundDevTools = injectIntoDevTools({ diff --git a/packages/react-dom/src/client/ReactDOMEventHandle.js b/packages/react-dom/src/client/ReactDOMEventHandle.js new file mode 100644 index 0000000000000..379b4c414490c --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMEventHandle.js @@ -0,0 +1,247 @@ +/** + * 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 {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; +import type {EventPriority, ReactScopeInstance} from 'shared/ReactTypes'; +import type { + ReactDOMEventHandle, + ReactDOMEventHandleListener, +} from '../shared/ReactDOMTypes'; + +import {getEventPriorityForListenerSystem} from '../events/DOMEventProperties'; +import { + getClosestInstanceFromNode, + getEventHandlerListeners, + setEventHandlerListeners, + getEventListenerMap, + getFiberFromScopeInstance, +} from './ReactDOMComponentTree'; +import {ELEMENT_NODE} from '../shared/HTMLNodeType'; +import { + listenToTopLevelEvent, + addEventTypeToDispatchConfig, +} from '../events/DOMModernPluginEventSystem'; + +import {HostRoot, HostPortal} from 'react-reconciler/src/ReactWorkTags'; +import { + PLUGIN_EVENT_SYSTEM, + IS_TARGET_PHASE_ONLY, +} from '../events/EventSystemFlags'; + +import { + enableScopeAPI, + enableCreateEventHandleAPI, +} from 'shared/ReactFeatureFlags'; +import invariant from 'shared/invariant'; + +type EventHandleOptions = {| + capture?: boolean, + passive?: boolean, + priority?: EventPriority, +|}; + +function getNearestRootOrPortalContainer(node: Fiber): null | Element { + while (node !== null) { + const tag = node.tag; + // Once we encounter a host container or root container + // we can return their DOM instance. + if (tag === HostRoot || tag === HostPortal) { + return node.stateNode.containerInfo; + } + node = node.return; + } + return null; +} + +function isValidEventTarget(target: EventTarget | ReactScopeInstance): boolean { + return typeof (target: Object).addEventListener === 'function'; +} + +function isReactScope(target: EventTarget | ReactScopeInstance): boolean { + return typeof (target: Object).getChildContextValues === 'function'; +} + +function createEventHandleListener( + type: DOMTopLevelEventType, + capture: boolean, + callback: (SyntheticEvent) => void, + destroy: (target: EventTarget | ReactScopeInstance) => void, +): ReactDOMEventHandleListener { + return { + callback, + capture, + destroy, + type, + }; +} + +function registerEventOnNearestTargetContainer( + targetFiber: Fiber, + topLevelType: DOMTopLevelEventType, + passive: boolean | void, + priority: EventPriority | void, +): void { + // If it is, find the nearest root or portal and make it + // our event handle target container. + const targetContainer = getNearestRootOrPortalContainer(targetFiber); + if (targetContainer === null) { + invariant( + false, + 'ReactDOM.createEventHandle: setListener called on an target ' + + 'that did not have a corresponding root. This is likely a bug in React.', + ); + } + const listenerMap = getEventListenerMap(targetContainer); + listenToTopLevelEvent( + topLevelType, + targetContainer, + listenerMap, + PLUGIN_EVENT_SYSTEM, + passive, + priority, + ); +} + +export function createEventHandle( + type: string, + options?: EventHandleOptions, +): ReactDOMEventHandle { + if (enableCreateEventHandleAPI) { + const topLevelType = ((type: any): DOMTopLevelEventType); + let capture = false; + let passive = undefined; // Undefined means to use the browser default + let priority; + + if (options != null) { + const optionsCapture = options.capture; + const optionsPassive = options.passive; + const optionsPriority = options.priority; + + if (typeof optionsCapture === 'boolean') { + capture = optionsCapture; + } + if (typeof optionsPassive === 'boolean') { + passive = optionsPassive; + } + if (typeof optionsPriority === 'number') { + priority = optionsPriority; + } + } + if (priority === undefined) { + priority = getEventPriorityForListenerSystem(topLevelType); + } + + const listeners = new Map(); + + const destroy = (target: EventTarget | ReactScopeInstance): void => { + const listener = listeners.get(target); + if (listener !== undefined) { + listeners.delete(target); + const targetListeners = getEventHandlerListeners(target); + if (targetListeners !== null) { + targetListeners.delete(listener); + } + } + }; + + const clear = (): void => { + const eventTargetsArr = Array.from(listeners.keys()); + for (let i = 0; i < eventTargetsArr.length; i++) { + destroy(eventTargetsArr[i]); + } + }; + + return { + setListener( + target: EventTarget | ReactScopeInstance, + callback: null | ((SyntheticEvent) => void), + ): void { + // Check if the target is a DOM element. + if ((target: any).nodeType === ELEMENT_NODE) { + const targetElement = ((target: any): Element); + // Check if the DOM element is managed by React. + const targetFiber = getClosestInstanceFromNode(targetElement); + if (targetFiber === null) { + invariant( + false, + 'ReactDOM.createEventHandle: setListener called on an element ' + + 'target that is not managed by React. Ensure React rendered the DOM element.', + ); + } + registerEventOnNearestTargetContainer( + targetFiber, + topLevelType, + passive, + priority, + ); + } else if (enableScopeAPI && isReactScope(target)) { + const scopeTarget = ((target: any): ReactScopeInstance); + const targetFiber = getFiberFromScopeInstance(scopeTarget); + if (targetFiber === null) { + // Scope is unmounted, do not proceed. + return; + } + registerEventOnNearestTargetContainer( + targetFiber, + topLevelType, + passive, + priority, + ); + } else if (isValidEventTarget(target)) { + const eventTarget = ((target: any): EventTarget); + const listenerMap = getEventListenerMap(eventTarget); + listenToTopLevelEvent( + topLevelType, + eventTarget, + listenerMap, + PLUGIN_EVENT_SYSTEM | IS_TARGET_PHASE_ONLY, + passive, + priority, + capture, + ); + } else { + invariant( + false, + 'ReactDOM.createEventHandle: setListener called on an invalid ' + + 'target. Provide a vaid EventTarget or an element managed by React.', + ); + } + let listener = listeners.get(target); + if (listener === undefined) { + if (callback === null) { + return; + } + listener = createEventHandleListener( + topLevelType, + capture, + callback, + destroy, + ); + listeners.set(target, listener); + + let targetListeners = getEventHandlerListeners(target); + if (targetListeners === null) { + targetListeners = new Set(); + setEventHandlerListeners(target, targetListeners); + } + targetListeners.add(listener); + // Finally, add the event to our known event types list. + addEventTypeToDispatchConfig(topLevelType); + } else if (callback !== null) { + listener.callback = callback; + } else { + // Remove listener + destroy(target); + } + }, + clear, + }; + } + return (null: any); +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 9ce395eb36bd6..ee8995931785b 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -76,12 +76,15 @@ import { enableDeprecatedFlareAPI, enableFundamentalAPI, enableModernEventSystem, - enableScopeAPI, enableCreateEventHandleAPI, + enableScopeAPI, } from 'shared/ReactFeatureFlags'; import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes'; -import {listenToEvent} from '../events/DOMModernPluginEventSystem'; +import { + listenToEvent, + clearEventHandleListenersForTarget, +} from '../events/DOMModernPluginEventSystem'; export type Type = string; export type Props = { @@ -539,7 +542,9 @@ function dispatchAfterDetachedBlur(target: HTMLElement): void { export function removeInstanceEventHandles( instance: Instance | TextInstance | SuspenseInstance, ) { - // TODO for ReactDOM.createEventInstance + if (enableCreateEventHandleAPI) { + clearEventHandleListenersForTarget(instance); + } } export function removeChild( @@ -1136,8 +1141,12 @@ export function prepareScopeUpdate( } } -export function removeScopeEventHandles(scopeInstance: Object): void { - // TODO when we add createEventHandle +export function removeScopeEventHandles( + scopeInstance: ReactScopeInstance, +): void { + if (enableScopeAPI && enableCreateEventHandleAPI) { + clearEventHandleListenersForTarget(scopeInstance); + } } export function getInstanceFromScope( diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index 3dce7bde12f99..48f2db1b4e45e 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -14,7 +14,7 @@ import type { ElementListenerMapEntry, } from '../client/ReactDOMComponentTree'; import type {EventSystemFlags} from './EventSystemFlags'; -import type {EventPriority} from 'shared/ReactTypes'; +import type {EventPriority, ReactScopeInstance} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type { ModernPluginModule, @@ -23,7 +23,10 @@ import type { DispatchQueueItemPhase, DispatchQueueItemPhaseEntry, } from 'legacy-events/PluginModuleType'; -import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; +import type { + ReactSyntheticEvent, + CustomDispatchConfig, +} from 'legacy-events/ReactSyntheticEventType'; import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; import {plugins} from 'legacy-events/EventPluginRegistry'; @@ -39,6 +42,7 @@ import { HostPortal, HostComponent, HostText, + ScopeComponent, } from 'react-reconciler/src/ReactWorkTags'; import getEventTarget from './getEventTarget'; @@ -84,6 +88,7 @@ import { import { getClosestInstanceFromNode, getEventListenerMap, + getEventHandlerListeners, } from '../client/ReactDOMComponentTree'; import {COMMENT_NODE} from '../shared/HTMLNodeType'; import {batchedEventUpdates} from './ReactDOMUpdateBatching'; @@ -93,6 +98,7 @@ import {passiveBrowserEventsSupported} from './checkPassiveEvents'; import { enableLegacyFBSupport, enableCreateEventHandleAPI, + enableScopeAPI, } from 'shared/ReactFeatureFlags'; import { invokeGuardedCallbackAndCatchFirstError, @@ -106,6 +112,8 @@ import { addEventBubbleListenerWithPassiveFlag, addEventCaptureListenerWithPassiveFlag, } from './EventListener'; +import {removeTrappedEventListener} from './DeprecatedDOMEventResponderSystem'; +import {topLevelEventsToDispatchConfig} from './DOMEventProperties'; export const capturePhaseEvents: Set = new Set([ TOP_FOCUS, @@ -148,6 +156,14 @@ if (enableCreateEventHandleAPI) { capturePhaseEvents.add(TOP_AFTER_BLUR); } +const emptyDispatchConfigForCustomEvents: CustomDispatchConfig = { + customEvent: true, + phasedRegistrationNames: { + bubbled: null, + captured: null, + }, +}; + function executeDispatch( event: ReactSyntheticEvent, listener: Function, @@ -226,6 +242,15 @@ function dispatchEventsForPlugins( dispatchEventsInBatch(dispatchQueue); } +function shouldUpgradeListener( + listenerEntry: void | ElementListenerMapEntry, + passive: void | boolean, +): boolean { + return ( + listenerEntry !== undefined && listenerEntry.passive === true && !passive + ); +} + export function listenToTopLevelEvent( topLevelType: DOMTopLevelEventType, target: EventTarget, @@ -248,7 +273,21 @@ export function listenToTopLevelEvent( const listenerEntry: ElementListenerMapEntry | void = listenerMap.get( listenerMapKey, ); - if (listenerEntry === undefined) { + const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive); + + // If the listener entry is empty or we should upgrade, then + // we need to trap an event listener onto the target. + if (listenerEntry === undefined || shouldUpgrade) { + // If we should upgrade, then we need to remove the existing trapped + // event listener for the target container. + if (shouldUpgrade) { + removeTrappedEventListener( + target, + topLevelType, + capture, + ((listenerEntry: any): ElementListenerMapEntry).listener, + ); + } const listener = addTrappedEventListener( target, topLevelType, @@ -539,6 +578,7 @@ export function accumulateTwoPhaseListeners( targetFiber: Fiber | null, dispatchQueue: DispatchQueue, event: ReactSyntheticEvent, + accumulateEventHandleListeners?: boolean, ): void { const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; const capturePhase: DispatchQueueItemPhase = []; @@ -549,6 +589,8 @@ export function accumulateTwoPhaseListeners( // usual two phase accumulation using the React fiber tree to pick up // all relevant useEvent and on* prop events. let instance = targetFiber; + let lastHostComponent = null; + const targetType = event.type; // Accumulate all instances and listeners via the target -> root path. while (instance !== null) { @@ -556,6 +598,38 @@ export function accumulateTwoPhaseListeners( // Handle listeners that are on HostComponents (i.e.
) if (tag === HostComponent && stateNode !== null) { const currentTarget = stateNode; + lastHostComponent = currentTarget; + // For Event Handle listeners + if (enableCreateEventHandleAPI && accumulateEventHandleListeners) { + const listeners = getEventHandlerListeners(currentTarget); + + if (listeners !== null) { + const listenersArr = Array.from(listeners); + for (let i = 0; i < listenersArr.length; i++) { + const listener = listenersArr[i]; + const {callback, capture, type} = listener; + if (type === targetType) { + if (capture === true) { + capturePhase.push( + createDispatchQueueItemPhaseEntry( + instance, + callback, + currentTarget, + ), + ); + } else { + bubblePhase.push( + createDispatchQueueItemPhaseEntry( + instance, + callback, + currentTarget, + ), + ); + } + } + } + } + } // Standard React on* listeners, i.e. onClick prop if (captured !== null) { const captureListener = getListener(instance, captured); @@ -581,6 +655,43 @@ export function accumulateTwoPhaseListeners( ); } } + } else if ( + enableCreateEventHandleAPI && + enableScopeAPI && + accumulateEventHandleListeners && + tag === ScopeComponent && + lastHostComponent !== null + ) { + const reactScopeInstance = stateNode; + const listeners = getEventHandlerListeners(reactScopeInstance); + const lastCurrentTarget = ((lastHostComponent: any): Element); + + if (listeners !== null) { + const listenersArr = Array.from(listeners); + for (let i = 0; i < listenersArr.length; i++) { + const listener = listenersArr[i]; + const {callback, capture, type} = listener; + if (type === targetType) { + if (capture === true) { + capturePhase.push( + createDispatchQueueItemPhaseEntry( + instance, + callback, + lastCurrentTarget, + ), + ); + } else { + bubblePhase.push( + createDispatchQueueItemPhaseEntry( + instance, + callback, + lastCurrentTarget, + ), + ); + } + } + } + } } instance = instance.return; } @@ -736,6 +847,76 @@ export function accumulateEnterLeaveListeners( } } +export function accumulateEventTargetListeners( + dispatchQueue: DispatchQueue, + event: ReactSyntheticEvent, + currentTarget: EventTarget, +): void { + const capturePhase: DispatchQueueItemPhase = []; + const bubblePhase: DispatchQueueItemPhase = []; + + const eventListeners = getEventHandlerListeners(currentTarget); + if (eventListeners !== null) { + const listenersArr = Array.from(eventListeners); + const targetType = ((event.type: any): DOMTopLevelEventType); + const isCapturePhase = (event: any).eventPhase === 1; + + for (let i = 0; i < listenersArr.length; i++) { + const listener = listenersArr[i]; + const {callback, capture, type} = listener; + if (type === targetType) { + if (isCapturePhase && capture) { + capturePhase.push( + createDispatchQueueItemPhaseEntry(null, callback, currentTarget), + ); + } else if (!isCapturePhase && !capture) { + bubblePhase.push( + createDispatchQueueItemPhaseEntry(null, callback, currentTarget), + ); + } + } + } + } + if (capturePhase.length !== 0 || bubblePhase.length !== 0) { + dispatchQueue.push( + createDispatchQueueItem(event, capturePhase, bubblePhase), + ); + } +} + +export function addEventTypeToDispatchConfig(type: DOMTopLevelEventType): void { + const dispatchConfig = topLevelEventsToDispatchConfig.get(type); + // If we don't have a dispatchConfig, then we're dealing with + // an event type that React does not know about (i.e. a custom event). + // We need to register an event config for this or the SimpleEventPlugin + // will not appropriately provide a SyntheticEvent, so we use out empty + // dispatch config for custom events. + if (dispatchConfig === undefined) { + topLevelEventsToDispatchConfig.set( + type, + emptyDispatchConfigForCustomEvents, + ); + } +} + +export function clearEventHandleListenersForTarget( + target: EventTarget | ReactScopeInstance, +): void { + // It's unfortunate that we have to do this cleanup, but + // it's necessary otherwise we will leak the host instances + // on the createEventHandle API "listeners" Map. We call destroy + // on each listener to ensure we properly remove the instance + // from the listeners Map. Note: we have this Map so that we + // can track listeners for the handle.clear() API call. + const listeners = getEventHandlerListeners(target); + if (listeners !== null) { + const listenersArr = Array.from(listeners); + for (let i = 0; i < listenersArr.length; i++) { + listenersArr[i].destroy(target); + } + } +} + export function getListenerMapKey( topLevelType: DOMTopLevelEventType, capture: boolean, diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index a690b574e9e51..7eeac6c7b2c96 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -9,11 +9,14 @@ 'use strict'; +import {createEventTarget} from 'dom-event-testing-library'; + let React; let ReactFeatureFlags; let ReactDOM; let ReactDOMServer; let Scheduler; +let ReactTestUtils; function dispatchEvent(element, type) { const event = document.createEvent('Event'); @@ -1167,6 +1170,1585 @@ describe('DOMModernPluginEventSystem', () => { ]); } }); + + describe('ReactDOM.createEventHandle', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableLegacyFBSupport = enableLegacyFBSupport; + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableCreateEventHandleAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + ReactTestUtils = require('react-dom/test-utils'); + }); + + // @gate experimental + it('can render correctly with the ReactDOMServer', () => { + const clickEvent = jest.fn(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + const divRef = React.useRef(null); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return
Hello world
; + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe(`
Hello world
`); + }); + + // @gate experimental + it('can render correctly with the ReactDOMServer hydration', () => { + const clickEvent = jest.fn(); + const spanRef = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + 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); + }); + + // @gate experimental + it('should correctly work for a basic "click" listener', () => { + let 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(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + 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).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: divRef.current, + }, + ]); + expect(clickEvent).toBeCalledTimes(1); + + // 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); + + log = []; + + // Clicking the button should also work + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(log).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: buttonRef.current, + }, + ]); + + const click2 = ReactDOM.unstable_createEventHandle('click'); + + function Test2({clickEvent2}) { + React.useEffect(() => { + click2.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); + }); + + // @gate experimental + it('should correctly work for setting and clearing a basic "click" listener', () => { + const clickEvent = jest.fn(); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test({off}) { + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + React.useEffect(() => { + if (off) { + click.setListener(buttonRef.current, null); + } + }, [off]); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // The listener should get unmounted in the second effect + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + clickEvent.mockClear(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(0); + }); + + // @gate experimental + it('should handle the target being a text node', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const textNode = buttonRef.current.firstChild; + dispatchClickEvent(textNode); + expect(clickEvent).toBeCalledTimes(1); + }); + + // @gate experimental + it('handle propagation of click events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + const click = ReactDOM.unstable_createEventHandle('click'); + const clickCapture = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + + function Test() { + React.useEffect(() => { + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['capture', divElement]); + expect(log[2]).toEqual(['bubble', divElement]); + expect(log[3]).toEqual(['bubble', buttonElement]); + }); + + // @gate experimental + it('handle propagation of click events mixed with onClick events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + const click = ReactDOM.unstable_createEventHandle('click'); + const clickCapture = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + + function Test() { + React.useEffect(() => { + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); + + // @gate experimental + 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(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + 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).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 + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(clickEvent).toBeCalledTimes(2); + }); + + // @gate experimental + it('should correctly handle many nested target listeners', () => { + const buttonRef = React.createRef(); + const targetListener1 = jest.fn(); + const targetListener2 = jest.fn(); + const targetListener3 = jest.fn(); + const targetListener4 = jest.fn(); + let click1 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + let click2 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + let click3 = ReactDOM.unstable_createEventHandle('click'); + let click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListener1); + click2.setListener(buttonRef.current, targetListener2); + click3.setListener(buttonRef.current, targetListener3); + click4.setListener(buttonRef.current, targetListener4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(targetListener1).toHaveBeenCalledTimes(1); + expect(targetListener2).toHaveBeenCalledTimes(1); + expect(targetListener3).toHaveBeenCalledTimes(1); + expect(targetListener4).toHaveBeenCalledTimes(1); + + click1 = ReactDOM.unstable_createEventHandle('click'); + click2 = ReactDOM.unstable_createEventHandle('click'); + click3 = ReactDOM.unstable_createEventHandle('click'); + click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test2() { + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListener1); + click2.setListener(buttonRef.current, targetListener2); + click3.setListener(buttonRef.current, targetListener3); + click4.setListener(buttonRef.current, targetListener4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListener1).toHaveBeenCalledTimes(2); + expect(targetListener2).toHaveBeenCalledTimes(2); + expect(targetListener3).toHaveBeenCalledTimes(2); + expect(targetListener4).toHaveBeenCalledTimes(2); + }); + + // @gate experimental + it('should correctly handle stopPropagation corrrectly for target events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const clickEvent = jest.fn(); + const click1 = ReactDOM.unstable_createEventHandle('click', { + bind: buttonRef, + }); + const click2 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click1.setListener(buttonRef.current, clickEvent); + click2.setListener(divRef.current, e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toHaveBeenCalledTimes(0); + }); + + // @gate experimental + it('should correctly handle stopPropagation corrrectly for many target events', () => { + 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()); + const click1 = ReactDOM.unstable_createEventHandle('click'); + const click2 = ReactDOM.unstable_createEventHandle('click'); + const click3 = ReactDOM.unstable_createEventHandle('click'); + const click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + 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(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); + + // @gate experimental + 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()); + const click1 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_createEventHandle('click'); + const click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + 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(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(0); + expect(targetListerner4).toHaveBeenCalledTimes(0); + }); + + // @gate experimental + it('should work with concurrent mode updates', async () => { + const log = []; + const ref = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test({counter}) { + React.useLayoutEffect(() => { + click.setListener(ref.current, () => { + log.push({counter}); + }); + }); + + Scheduler.unstable_yieldValue('Test'); + return ; + } + + const root = ReactDOM.createRoot(container); + root.render(); + + 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 + 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}]); + }); + + // @gate experimental + it('should correctly work for a basic "click" listener that upgrades', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const button2Ref = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click', { + passive: false, + }); + const click2 = ReactDOM.unstable_createEventHandle('click', { + passive: true, + }); + + function Test2() { + React.useEffect(() => { + click.setListener(button2Ref.current, clickEvent); + }); + + return ; + } + + function Test({extra}) { + React.useEffect(() => { + click2.setListener(buttonRef.current, clickEvent); + }); + + return ( + <> + + {extra && } + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let button = buttonRef.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + clickEvent.mockClear(); + + button = button2Ref.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); + + // @gate experimental + it('should correctly work for a basic "click" listener that upgrades #2', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const button2Ref = React.createRef(); + const click = ReactDOM.unstable_createEventHandle('click', { + passive: false, + }); + const click2 = ReactDOM.unstable_createEventHandle('click', { + passive: undefined, + }); + + function Test2() { + React.useEffect(() => { + click.setListener(button2Ref.current, clickEvent); + }); + + return ; + } + + function Test({extra}) { + React.useEffect(() => { + click2.setListener(buttonRef.current, clickEvent); + }); + + return ( + <> + + {extra && } + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let button = buttonRef.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + clickEvent.mockClear(); + + button = button2Ref.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); + + // @gate experimental + it('should correctly work for a basic "click" window listener', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click.setListener(window, clickEvent); + + return () => { + click.setListener(window, null); + }; + }); + + 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: window, + 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); + }); + + // @gate experimental + it('handle propagation of click events on the window', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + const click = ReactDOM.unstable_createEventHandle('click'); + const clickCapture = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + + function Test() { + React.useEffect(() => { + click.setListener(window, onClick); + clickCapture.setListener(window, onClickCapture); + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + + return () => { + click.setListener(window, null); + clickCapture.setListener(window, null); + }; + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['bubble', buttonElement]); + expect(log[3]).toEqual(['bubble', window]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', buttonElement]); + expect(log[2]).toEqual(['capture', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['bubble', buttonElement]); + expect(log[5]).toEqual(['bubble', window]); + }); + + // @gate experimental + 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(); + const click1 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_createEventHandle('click'); + const click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click1.setListener(window, rootListerner1); + click2.setListener(buttonRef.current, targetListerner1); + click3.setListener(window, rootListerner2); + click4.setListener(buttonRef.current, targetListerner2); + + return () => { + click1.setListener(window, null); + click3.setListener(window, null); + }; + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner1).toHaveBeenCalledTimes(0); + expect(targetListerner2).toHaveBeenCalledTimes(0); + expect(rootListerner2).toHaveBeenCalledTimes(0); + }); + + // @gate experimental + 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(); + const click1 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_createEventHandle('click'); + const click4 = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + React.useEffect(() => { + click1.setListener(window, rootListerner1); + click2.setListener(window, rootListerner2); + click3.setListener(window, rootListerner3); + click4.setListener(window, rootListerner4); + + return () => { + click1.setListener(window, null); + click2.setListener(window, null); + click3.setListener(window, null); + click4.setListener(window, null); + }; + }); + + return ; + } + + ReactDOM.render(, container); + + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(rootListerner1).toHaveBeenCalledTimes(1); + expect(rootListerner2).toHaveBeenCalledTimes(1); + expect(rootListerner3).toHaveBeenCalledTimes(0); + expect(rootListerner4).toHaveBeenCalledTimes(0); + }); + + // @gate experimental + it('handle propagation of click events on the window and document', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + const click = ReactDOM.unstable_createEventHandle('click'); + const clickCapture = ReactDOM.unstable_createEventHandle('click', { + capture: true, + }); + + function Test() { + React.useEffect(() => { + click.setListener(window, onClick); + clickCapture.setListener(window, onClickCapture); + click.setListener(document, onClick); + clickCapture.setListener(document, onClickCapture); + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + + return () => { + click.setListener(window, null); + clickCapture.setListener(window, null); + click.setListener(document, null); + clickCapture.setListener(document, null); + }; + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + + if (enableLegacyFBSupport) { + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['bubble', document]); + expect(log[3]).toEqual(['capture', buttonElement]); + expect(log[4]).toEqual(['bubble', buttonElement]); + expect(log[5]).toEqual(['bubble', window]); + } else { + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['bubble', buttonElement]); + expect(log[4]).toEqual(['bubble', document]); + expect(log[5]).toEqual(['bubble', window]); + } + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(4); + expect(onClickCapture).toHaveBeenCalledTimes(4); + + if (enableLegacyFBSupport) { + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['bubble', document]); + expect(log[3]).toEqual(['capture', buttonElement]); + expect(log[4]).toEqual(['capture', divElement]); + expect(log[5]).toEqual(['bubble', divElement]); + expect(log[6]).toEqual(['bubble', buttonElement]); + expect(log[7]).toEqual(['bubble', window]); + } else { + expect(log[0]).toEqual(['capture', window]); + expect(log[1]).toEqual(['capture', document]); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + expect(log[6]).toEqual(['bubble', document]); + expect(log[7]).toEqual(['bubble', window]); + } + }); + + // @gate experimental + it('handles propagation of custom user events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onCustomEvent = jest.fn(e => + log.push(['bubble', e.currentTarget]), + ); + const onCustomEventCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + let customEventHandle; + + // Test that we get a warning when we don't provide an explicit priortiy + expect(() => { + customEventHandle = ReactDOM.unstable_createEventHandle( + 'custom-event', + ); + }).toWarnDev( + 'Warning: The event "type" provided to createEventHandle() does not have a known priority type. ' + + 'It is recommended to provide a "priority" option to specify a priority.', + {withoutStack: true}, + ); + + customEventHandle = ReactDOM.unstable_createEventHandle( + 'custom-event', + { + priority: 0, // Discrete + }, + ); + + const customCaptureHandle = ReactDOM.unstable_createEventHandle( + 'custom-event', + { + capture: true, + priority: 0, // Discrete + }, + ); + + function Test() { + React.useEffect(() => { + customEventHandle.setListener(buttonRef.current, onCustomEvent); + customCaptureHandle.setListener( + buttonRef.current, + onCustomEventCapture, + ); + customEventHandle.setListener(divRef.current, onCustomEvent); + customCaptureHandle.setListener( + divRef.current, + onCustomEventCapture, + ); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchEvent(buttonElement, 'custom-event'); + expect(onCustomEvent).toHaveBeenCalledTimes(1); + expect(onCustomEventCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + const divElement = divRef.current; + dispatchEvent(divElement, 'custom-event'); + expect(onCustomEvent).toHaveBeenCalledTimes(3); + expect(onCustomEventCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); + + // @gate experimental + it('beforeblur and afterblur are called after a focused element is unmounted', () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + const afterBlurHandle = ReactDOM.unstable_createEventHandle( + 'afterblur', + ); + const beforeBlurHandle = ReactDOM.unstable_createEventHandle( + 'beforeblur', + ); + + const Component = ({show}) => { + const ref = React.useRef(null); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ {show && } +
+
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + expect(log).toEqual(['beforeblur', 'afterblur']); + }); + + // @gate experimental + it('beforeblur and afterblur are called after a nested focused element is unmounted', () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const innerRef2 = React.createRef(); + const afterBlurHandle = ReactDOM.unstable_createEventHandle( + 'afterblur', + ); + const beforeBlurHandle = ReactDOM.unstable_createEventHandle( + 'beforeblur', + ); + + const Component = ({show}) => { + const ref = React.useRef(null); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ {show && ( +
+ +
+ )} +
+
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + expect(log).toEqual(['beforeblur', 'afterblur']); + }); + + // @gate experimental + it('beforeblur and afterblur are called after a focused element is suspended', () => { + const log = []; + // We have to persist here because we want to read relatedTarget later. + const onAfterBlur = jest.fn(e => { + e.persist(); + log.push(e.type); + }); + const onBeforeBlur = jest.fn(e => log.push(e.type)); + const innerRef = React.createRef(); + const Suspense = React.Suspense; + let suspend = false; + let resolve; + const promise = new Promise( + resolvePromise => (resolve = resolvePromise), + ); + const afterBlurHandle = ReactDOM.unstable_createEventHandle( + 'afterblur', + ); + const beforeBlurHandle = ReactDOM.unstable_createEventHandle( + 'beforeblur', + ); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = () => { + const ref = React.useRef(null); + + React.useEffect(() => { + afterBlurHandle.setListener(document, onAfterBlur); + beforeBlurHandle.setListener(ref.current, onBeforeBlur); + }); + + return ( +
+ + + +
+ ); + }; + + const container2 = document.createElement('div'); + document.body.appendChild(container2); + + const root = ReactDOM.createRoot(container2); + + ReactTestUtils.act(() => { + root.render(); + }); + jest.runAllTimers(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.focus(); + expect(onBeforeBlur).toHaveBeenCalledTimes(0); + expect(onAfterBlur).toHaveBeenCalledTimes(0); + + suspend = true; + ReactTestUtils.act(() => { + root.render(); + }); + jest.runAllTimers(); + + expect(onBeforeBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledTimes(1); + expect(onAfterBlur).toHaveBeenCalledWith( + expect.objectContaining({relatedTarget: inner}), + ); + resolve(); + expect(log).toEqual(['beforeblur', 'afterblur']); + + document.body.removeChild(container2); + }); + + describe('Compatibility with Scopes API', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableCreateEventHandleAPI = true; + ReactFeatureFlags.enableScopeAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + }); + + // @gate experimental + it('handle propagation of click events on a scope', () => { + const buttonRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => + log.push(['bubble', e.currentTarget]), + ); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + const TestScope = React.unstable_createScope(); + const click = ReactDOM.unstable_createEventHandle('click'); + const clickCapture = ReactDOM.unstable_createEventHandle( + 'click', + { + capture: true, + }, + ); + + function Test() { + const scopeRef = React.useRef(null); + + React.useEffect(() => { + click.setListener(scopeRef.current, onClick); + clickCapture.setListener(scopeRef.current, onClickCapture); + }); + + return ( + + + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(onClick).toHaveBeenCalledTimes(2); + expect(onClickCapture).toHaveBeenCalledTimes(2); + expect(log).toEqual([ + ['capture', buttonElement], + ['capture', buttonElement], + ['bubble', buttonElement], + ['bubble', buttonElement], + ]); + + log.length = 0; + onClick.mockClear(); + onClickCapture.mockClear(); + + const divElement = divRef.current; + dispatchClickEvent(divElement); + + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log).toEqual([ + ['capture', buttonElement], + ['capture', buttonElement], + ['capture', divElement], + ['bubble', divElement], + ['bubble', buttonElement], + ['bubble', buttonElement], + ]); + }); + + // @gate experimental + it('should not handle the target being a dangling text node within a scope', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const TestScope = React.unstable_createScope(); + const click = ReactDOM.unstable_createEventHandle('click'); + + function Test() { + const scopeRef = React.useRef(null); + + React.useEffect(() => { + click.setListener(scopeRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const textNode = buttonRef.current.firstChild; + dispatchClickEvent(textNode); + // This should not work, as the target instance will be the + // } + {show && } + {show && } + {show && } + {!show && } + {show && } + + +
+ ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + inputRef.current.focus(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(0); + ReactDOM.render(, container); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(1); + }); + + // @gate experimental + it('is called after a nested focused element is unmounted (with scope query)', () => { + const TestScope = React.unstable_createScope(); + const testScopeQuery = (type, props) => true; + let targetNodes; + let targetNode; + + const Component = ({show}) => { + const scopeRef = React.useRef(null); + useFocusWithin(scopeRef, { + onBeforeBlurWithin(event) { + const scope = scopeRef.current; + targetNode = innerRef.current; + targetNodes = scope.DO_NOT_USE_queryAllNodes(testScopeQuery); + }, + }); + + return ( + + {show && } + + ); + }; + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.keydown({key: 'Tab'}); + target.focus(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + expect(targetNodes).toEqual([targetNode]); + }); + + // @gate experimental + it('is called after a focused suspended element is hidden', () => { + const Suspense = React.Suspense; + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = ({show}) => { + useFocusWithin(ref, { + onBeforeBlurWithin, + onAfterBlurWithin, + }); + + return ( +
+ + + +
+ ); + }; + + const root = ReactDOM.createRoot(container2); + + act(() => { + root.render(); + }); + jest.runAllTimers(); + expect(container2.innerHTML).toBe('
'); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.keydown({key: 'Tab'}); + target.focus(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(0); + + suspend = true; + act(() => { + root.render(); + }); + jest.runAllTimers(); + expect(container2.innerHTML).toBe( + '
Loading...
', + ); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(1); + resolve(); + }); + + // @gate experimental + it('is called after a focused suspended element is hidden then shown', () => { + const Suspense = React.Suspense; + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + const buttonRef = React.createRef(); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = ({show}) => { + useFocusWithin(ref, { + onBeforeBlurWithin, + onAfterBlurWithin, + }); + + return ( +
+ Loading...}> + + +
+ ); + }; + + const root = ReactDOM.createRoot(container2); + + act(() => { + root.render(); + }); + jest.runAllTimers(); + + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(0); + + suspend = true; + act(() => { + root.render(); + }); + jest.runAllTimers(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(0); + + act(() => { + root.render(); + }); + jest.runAllTimers(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(0); + + buttonRef.current.focus(); + suspend = false; + act(() => { + root.render(); + }); + jest.runAllTimers(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1); + expect(onAfterBlurWithin).toHaveBeenCalledTimes(1); + + resolve(); + }); + }); +}); diff --git a/packages/react-interactions/events/src/dom/create-event-handle/useEvent.js b/packages/react-interactions/events/src/dom/create-event-handle/useEvent.js new file mode 100644 index 0000000000000..ef9a0d17a2a28 --- /dev/null +++ b/packages/react-interactions/events/src/dom/create-event-handle/useEvent.js @@ -0,0 +1,49 @@ +/** + * 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 * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +const {useEffect, useRef} = React; +const {unstable_createEventHandle: createEventHandle} = ReactDOM; + +type UseEventHandle = {| + setListener: ( + target: EventTarget, + null | ((SyntheticEvent) => void), + ) => void, + clear: () => void, +|}; + +export default function useEvent( + event: string, + options?: {| + capture?: boolean, + passive?: boolean, + priority?: 0 | 1 | 2, + |}, +): UseEventHandle { + const handleRef = useRef(null); + + if (handleRef.current == null) { + handleRef.current = createEventHandle(event, options); + } + + useEffect(() => { + const handle = handleRef.current; + return () => { + if (handle !== null) { + handle.clear(); + } + handleRef.current = null; + }; + }, []); + + return ((handleRef.current: any): UseEventHandle); +} diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 19380fac7f4d4..4f49471f1b7a5 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -58,7 +58,7 @@ export const disableModulePatternComponents = true; export const enableDeprecatedFlareAPI = true; -export const enableCreateEventHandleAPI = false; +export const enableCreateEventHandleAPI = true; export const enableFundamentalAPI = false; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 2577bac33772a..e999140a0ee7a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -363,5 +363,8 @@ "362": "Could not find React container within specified host subtree.", "363": "Test selector API is not supported by this renderer.", "364": "Invalid host root specified. Should be either a React container or a node with a testname attribute.", - "365": "Invalid selector type %s specified." + "365": "Invalid selector type %s specified.", + "366": "ReactDOM.createEventHandle: setListener called on an target that did not have a corresponding root. This is likely a bug in React.", + "367": "ReactDOM.createEventHandle: setListener called on an element target that is not managed by React. Ensure React rendered the DOM element.", + "368": "ReactDOM.createEventHandle: setListener called on an invalid target. Provide a vaid EventTarget or an element managed by React." }