From c804f9aebb3c951ec6148bd7004e6a6683d53b09 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Mon, 16 Mar 2020 21:46:17 +0000 Subject: [PATCH] ReactDOM.useEvent: wire to event system to the hook (#18304) --- packages/legacy-events/EventPluginUtils.js | 10 +- packages/react-art/src/ReactARTHostConfig.js | 4 + .../src/client/ReactDOMHostConfig.js | 37 ++ .../react-dom/src/events/SimpleEventPlugin.js | 2 +- ...OMModernPluginEventSystem-test.internal.js | 525 ++++++++++++++++++ .../src/events/accumulateTwoPhaseListeners.js | 33 +- .../src/ReactFabricHostConfig.js | 4 + .../src/ReactNativeHostConfig.js | 4 + .../src/ReactFiberCommitWork.js | 3 + .../react-reconciler/src/ReactFiberHooks.js | 89 ++- .../src/forks/ReactFiberHostConfig.custom.js | 1 + .../src/ReactTestHostConfig.js | 4 + 12 files changed, 710 insertions(+), 6 deletions(-) diff --git a/packages/legacy-events/EventPluginUtils.js b/packages/legacy-events/EventPluginUtils.js index abba8e9a3d..e9081e3c99 100644 --- a/packages/legacy-events/EventPluginUtils.js +++ b/packages/legacy-events/EventPluginUtils.js @@ -79,12 +79,20 @@ export function executeDispatchesInOrder(event) { validateEventDispatches(event); } if (Array.isArray(dispatchListeners)) { + let previousInstance; for (let i = 0; i < dispatchListeners.length; i++) { - if (event.isPropagationStopped()) { + const instance = dispatchInstances[i]; + // We check if the instance was the same as the last one, + // if it was, then we're still on the same instance thus + // propagation should not stop. If we add support for + // stopImmediatePropagation at some point, then we'll + // need to handle that case here differently. + if (instance !== previousInstance && event.isPropagationStopped()) { break; } // Listeners and Instances are two parallel arrays that are always in sync. executeDispatch(event, dispatchListeners[i], dispatchInstances[i]); + previousInstance = instance; } } else if (dispatchListeners) { executeDispatch(event, dispatchListeners, dispatchInstances); diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 4a6211e709..41f544810f 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -470,6 +470,10 @@ export function beforeRemoveInstance(instance) { // noop } +export function registerEvent(event: any, rootContainerInstance: any) { + throw new Error('Not yet implemented.'); +} + export function mountEventListener(listener: any) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 56eab95607..d3e49e974f 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -7,12 +7,14 @@ * @flow */ +import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; import type {RootType} from './ReactDOMRoot'; import { precacheFiberNode, updateFiberProps, getClosestInstanceFromNode, + getListenersFromTarget, } from './ReactDOMComponentTree'; import { createElement, @@ -78,7 +80,9 @@ import { detachElementListener, isDOMDocument, isDOMElement, + listenToTopLevelEvent, } from '../events/DOMModernPluginEventSystem'; +import {getListenerMapForElement} from '../events/DOMEventListenerMap'; export type ReactListenerEvent = ReactDOMListenerEvent; export type ReactListenerMap = ReactDOMListenerMap; @@ -529,6 +533,22 @@ export function beforeRemoveInstance( ) { dispatchBeforeDetachedBlur(((instance: any): HTMLElement)); } + if (enableUseEventAPI) { + // It's unfortunate that we have to do this cleanup, but + // it's necessary otherwise we will leak the host instances + // from the useEvent hook instances Map. We call destroy + // on each listener to ensure we properly remove the instance + // from the instances Map. Note: we have this Map so that we + // can properly unmount instances when the function component + // that the hook is attached to gets unmounted. + const listenersSet = getListenersFromTarget(instance); + if (listenersSet !== null) { + const listeners = Array.from(listenersSet); + for (let i = 0; i < listeners.length; i++) { + listeners[i].destroy(instance); + } + } + } } export function removeChild( @@ -1083,6 +1103,23 @@ export function getInstanceFromNode(node: HTMLElement): null | Object { return getClosestInstanceFromNode(node) || null; } +export function registerEvent( + event: ReactDOMListenerEvent, + rootContainerInstance: Container, +): void { + const {passive, priority, type} = event; + const listenerMap = getListenerMapForElement(rootContainerInstance); + // Add the event listener to the target container (falling back to + // the target if we didn't find one). + listenToTopLevelEvent( + ((type: any): DOMTopLevelEventType), + rootContainerInstance, + listenerMap, + passive, + priority, + ); +} + export function mountEventListener(listener: ReactDOMListener): void { if (enableUseEventAPI) { const {target} = listener; diff --git a/packages/react-dom/src/events/SimpleEventPlugin.js b/packages/react-dom/src/events/SimpleEventPlugin.js index bcfdd9fd77..1c9cc0ecb0 100644 --- a/packages/react-dom/src/events/SimpleEventPlugin.js +++ b/packages/react-dom/src/events/SimpleEventPlugin.js @@ -191,7 +191,7 @@ const SimpleEventPlugin: PluginModule = { nativeEvent, nativeEventTarget, ); - accumulateTwoPhaseListeners(event); + accumulateTwoPhaseListeners(event, true); return event; }, }; 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 4e686ce84f..3df6dbbc92 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -1074,5 +1074,530 @@ describe('DOMModernPluginEventSystem', () => { expect(listenerMaps.length).toEqual(2); expect(listenerMaps[0]).toEqual(listenerMaps[1]); }); + + it('can render correctly with the ReactDOMServer', () => { + const clickEvent = jest.fn(); + + function Test() { + const divRef = React.useRef(null); + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return
Hello world
; + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe(`
Hello world
`); + }); + + it('can render correctly with the ReactDOMServer hydration', () => { + const clickEvent = jest.fn(); + const spanRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(spanRef.current, clickEvent); + }); + + return ( +
+ Hello world +
+ ); + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe( + `
Hello world
`, + ); + container.innerHTML = output; + ReactDOM.hydrate(, container); + Scheduler.unstable_flushAll(); + dispatchClickEvent(spanRef.current); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); + + it('should correctly work for a basic "click" listener', () => { + 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(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe(''); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log).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 + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(log).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: buttonRef.current, + }, + ]); + + function Test2({clickEvent2}) { + const click = ReactDOM.unstable_useEvent('click', clickEvent2); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent2); + }); + + return ( + + ); + } + + let clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + + // Reset the function we pass in, so it's different + clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + }); + + it('should correctly work for setting and clearing a basic "click" listener', () => { + const clickEvent = jest.fn(); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test({off}) { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + React.useEffect(() => { + if (off) { + click.setListener(buttonRef.current, null); + } + }, [off]); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // The listener should get unmounted in the second effect + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + clickEvent.mockClear(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(0); + }); + + 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]), + ); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + + 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(); + + let 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]); + + let 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]); + }); + + it('should correctly work for a basic "click" listener on the outer target', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe(''); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log).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 targetListener1 = jest.fn(); + const targetListener2 = jest.fn(); + const targetListener3 = jest.fn(); + const targetListener4 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, 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); + + function Test2() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, 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); + }); + + it('should correctly handle stopPropagation corrrectly for target events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + let clickEvent = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + bind: buttonRef, + }); + const click2 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, clickEvent); + click2.setListener(divRef.current, e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toHaveBeenCalledTimes(0); + }); + + it('should correctly handle stopPropagation 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()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); + + it('should correctly handle stopPropagation for mixed capture/bubbling target listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopPropagation()); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + const targetListerner3 = jest.fn(e => e.stopPropagation()); + const targetListerner4 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); + + it.experimental('should work with concurrent mode updates', async () => { + const log = []; + const ref = React.createRef(); + + function Test({counter}) { + const click = ReactDOM.unstable_useEvent('click'); + + React.useLayoutEffect(() => { + click.setListener(ref.current, () => { + log.push({counter}); + }); + }); + + Scheduler.unstable_yieldValue('Test'); + return ; + } + + let root = ReactDOM.createRoot(container); + root.render(); + + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYield(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYield(['Test']); + } + + // Click the button + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Increase counter + root.render(); + // Yield before committing + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYieldThrough(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYieldThrough(['Test']); + } + + // Click the button again + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Commit + expect(Scheduler).toFlushAndYield([]); + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 1}]); + }); }); }); diff --git a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js index 48350b5084..ae10f68dc4 100644 --- a/packages/react-dom/src/events/accumulateTwoPhaseListeners.js +++ b/packages/react-dom/src/events/accumulateTwoPhaseListeners.js @@ -9,11 +9,15 @@ import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; -import getListener from 'legacy-events/getListener'; import {HostComponent} from 'shared/ReactWorkTags'; +import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; + +import getListener from 'legacy-events/getListener'; +import {getListenersFromTarget} from '../client/ReactDOMComponentTree'; export default function accumulateTwoPhaseListeners( event: ReactSyntheticEvent, + accumulateUseEventListeners?: boolean, ): void { const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; if (phasedRegistrationNames == null) { @@ -28,6 +32,33 @@ export default function accumulateTwoPhaseListeners( while (node !== null) { // We only care for listeners that are on HostComponents (i.e.
) if (node.tag === HostComponent) { + // For useEvent listenrs + if (enableUseEventAPI && accumulateUseEventListeners) { + // useEvent event listeners + const instance = node.stateNode; + const targetType = event.type; + const listeners = getListenersFromTarget(instance); + + if (listeners !== null) { + const listenersArr = Array.from(listeners); + for (let i = 0; i < listenersArr.length; i++) { + const listener = listenersArr[i]; + const { + callback, + event: {capture, type}, + } = listener; + if (type === targetType) { + if (capture === true) { + dispatchListeners.unshift(callback); + dispatchInstances.unshift(node); + } else { + dispatchListeners.push(callback); + dispatchInstances.push(node); + } + } + } + } + } // Standard React on* listeners, i.e. onClick prop const captureListener = getListener(node, captured); if (captureListener != null) { diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 48e5f9b9ed..d0d3900930 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -474,6 +474,10 @@ export function beforeRemoveInstance(instance: any) { // noop } +export function registerEvent(event: any, rootContainerInstance: Container) { + throw new Error('Not yet implemented.'); +} + export function mountEventListener(listener: any) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 31e4813a24..13ef9878dd 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -518,6 +518,10 @@ export function beforeRemoveInstance(instance: any) { // noop } +export function registerEvent(event: any, rootContainerInstance: Container) { + throw new Error('Not yet implemented.'); +} + export function mountEventListener(listener: any) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 4cab1c81b7..2ed7ac9c25 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -36,6 +36,7 @@ import { enableSuspenseCallback, enableScopeAPI, runAllPassiveEffectDestroysBeforeCreates, + enableUseEventAPI, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -1053,6 +1054,8 @@ function commitUnmount( case HostComponent: { if (enableDeprecatedFlareAPI) { unmountDeprecatedResponderListeners(current); + } + if (enableDeprecatedFlareAPI || enableUseEventAPI) { beforeRemoveInstance(current.stateNode); } safelyDetachRef(current); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index a9a7b5a9b5..9cc65ae0b5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -24,6 +24,7 @@ import type {FiberRoot} from './ReactFiberRoot'; import type { ReactListenerEvent, ReactListenerMap, + ReactListener, } from './ReactFiberHostConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -53,6 +54,12 @@ import { markRenderEventTimeAndConfig, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; +import { + registerEvent, + mountEventListener as mountHostEventListener, + unmountEventListener as unmountHostEventListener, + validateEventListenerTarget, +} from './ReactFiberHostConfig'; import invariant from 'shared/invariant'; import getComponentName from 'shared/getComponentName'; @@ -73,6 +80,7 @@ import { setWorkInProgressVersion, warnAboutMultipleRenderersDEV, } from './ReactMutableSource'; +import {getRootHostContainer} from './ReactFiberHostContext'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -1627,18 +1635,93 @@ function dispatchAction( const noOpMount = () => {}; +function validateNotInFunctionRender(): boolean { + if (currentlyRenderingFiber === null) { + return true; + } + if (__DEV__) { + console.warn( + 'Event listener methods from useEvent() cannot be called during render.' + + ' These methods should be called in an effect or event callback outside the render.', + ); + } + return false; +} + +function createReactListener( + event: ReactListenerEvent, + callback: Event => void, + target: EventTarget, + destroy: Node => void, +): ReactListener { + return { + callback, + destroy, + event, + target, + }; +} + function mountEventListener(event: ReactListenerEvent): ReactListenerMap { if (enableUseEventAPI) { const hook = mountWorkInProgressHook(); + const listenerMap: Map = new Map(); + const rootContainerInstance = getRootHostContainer(); + + // Register the event to the current root to ensure event + // replaying can pick up the event ahead of time. + registerEvent(event, rootContainerInstance); const clear = () => { - // TODO + if (validateNotInFunctionRender()) { + const listeners = Array.from(listenerMap.values()); + for (let i = 0; i < listeners.length; i++) { + unmountHostEventListener(listeners[i]); + } + listenerMap.clear(); + } + }; + + const destroy = (target: Node) => { + // We don't need to call detachListenerFromInstance + // here as this method should only ever be called + // from renderers that need to remove the instance + // from the map representing an instance that still + // holds a reference to the listenerMap. This means + // things like "window" listeners on ReactDOM should + // never enter this call path as the the instance in + // those cases would be that of "window", which + // should be handled via an optimized route in the + // renderer, making less overhead here. If we change + // this heuristic we should update this path to make + // sure we call detachListenerFromInstance. + listenerMap.delete(target); }; const reactListenerMap: ReactListenerMap = { clear, - setListener(instance: EventTarget, callback: ?(Event) => void): void { - // TODO + setListener(target: EventTarget, callback: ?(Event) => void): void { + if ( + validateNotInFunctionRender() && + validateEventListenerTarget(target, callback) + ) { + let listener = listenerMap.get(target); + if (listener === undefined) { + if (callback == null) { + return; + } + listener = createReactListener(event, callback, target, destroy); + listenerMap.set(target, listener); + } else { + if (callback == null) { + listenerMap.delete(target); + unmountHostEventListener(listener); + return; + } + listener.callback = callback; + } + mountHostEventListener(listener); + } }, }; // In order to clear up upon the hook unmounting, diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 984002950a..9649c1aacd 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -76,6 +76,7 @@ export const shouldUpdateFundamentalComponent = $$$hostConfig.shouldUpdateFundamentalComponent; export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance; +export const registerEvent = $$$hostConfig.registerEvent; export const mountEventListener = $$$hostConfig.mountEventListener; export const unmountEventListener = $$$hostConfig.unmountEventListener; export const validateEventListenerTarget = diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index d8ba570860..2cf8de7acc 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -380,6 +380,10 @@ export function beforeRemoveInstance(instance: any) { // noop } +export function registerEvent(event: any, rootContainerInstance: Container) { + throw new Error('Not yet implemented.'); +} + export function mountEventListener(listener: any) { throw new Error('Not yet implemented.'); }