From 8244805dde803bd9717f28a332a6d246b68b8359 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 10 Mar 2020 16:28:20 +0000 Subject: [PATCH] ReactDOM.useEvent: Add more scaffolding for useEvent hook Add test Fix Address feedback fix Fix flow types Fix Fix Wtf? Fix path Fix Fix bundles... Fix CI tests, by adding to experimental build Attempt #2 at fixing CI Revert Fix Fix lint --- .../react-debug-tools/src/ReactDebugHooks.js | 16 +++ .../src/client/ReactDOMHostConfig.js | 7 + .../src/events/DOMModernPluginEventSystem.js | 9 ++ ...OMModernPluginEventSystem-test.internal.js | 36 +++++ .../src/server/ReactPartialRendererHooks.js | 10 ++ .../src/ReactFabricHostConfig.js | 4 + .../src/ReactNativeHostConfig.js | 4 + .../react-reconciler/src/ReactFiberHooks.js | 123 +++++++++++++++++- .../src/forks/ReactFiberHostConfig.custom.js | 3 + .../src/ReactTestHostConfig.js | 4 + 10 files changed, 215 insertions(+), 1 deletion(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index c2d3ed0a2ac..db93a50e6ff 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -39,6 +39,11 @@ type HookLogEntry = { ... }; +type ReactDebugListenerMap = {| + clear: () => void, + setListener: (instance: EventTarget, callback: ?(Event) => void) => void, +|}; + let hookLog: Array = []; // Primitives @@ -256,6 +261,16 @@ function useTransition( return [callback => {}, false]; } +const noOp = () => {}; + +function useEvent(event: any): ReactDebugListenerMap { + hookLog.push({primitive: 'Event', stackError: new Error(), value: event}); + return { + clear: noOp, + setListener: noOp, + }; +} + function useDeferredValue(value: T, config: TimeoutConfig | null | void): T { // useDeferredValue() composes multiple hooks internally. // Advance the current hook index the same number of times @@ -285,6 +300,7 @@ const Dispatcher: DispatcherType = { useResponder, useTransition, useDeferredValue, + useEvent, }; // Inspect diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 23ca369f4b1..c115cd5a07b 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -51,6 +51,9 @@ import type { ReactDOMEventResponder, ReactDOMEventResponderInstance, ReactDOMFundamentalComponentInstance, + ReactDOMListener, + ReactDOMListenerEvent, + ReactDOMListenerMap, } from 'shared/ReactDOMTypes'; import { mountEventResponder, @@ -70,6 +73,10 @@ import { IS_PASSIVE, } from 'legacy-events/EventSystemFlags'; +export type ReactListenerEvent = ReactDOMListenerEvent; +export type ReactListenerMap = ReactDOMListenerMap; +export type ReactListener = ReactDOMListener; + export type Type = string; export type Props = { autoFocus?: boolean, diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index ab2de7672c2..368edd6440b 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -13,6 +13,7 @@ import type {EventSystemFlags} from 'legacy-events/EventSystemFlags'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {PluginModule} from 'legacy-events/PluginModuleType'; import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType'; +import type {ReactDOMListener} from 'shared/ReactDOMTypes'; import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching'; @@ -296,3 +297,11 @@ export function dispatchEventForPluginEventSystem( ), ); } + +export function attachElementListener(listener: ReactDOMListener): void { + // TODO +} + +export function detachElementListener(listener: ReactDOMListener): void { + // TODO +} 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 31ab9e26d5d..4e686ce84fb 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -1039,4 +1039,40 @@ describe('DOMModernPluginEventSystem', () => { expect(log).toEqual([]); expect(onDivClick).toHaveBeenCalledTimes(0); }); + + describe('ReactDOM.useEvent', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableUseEventAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + }); + + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + return; + } + + it('should create the same event listener map', () => { + let listenerMaps = []; + + function Test() { + const listenerMap = ReactDOM.unstable_useEvent('click'); + + listenerMaps.push(listenerMap); + + return
; + } + + ReactDOM.render(, container); + ReactDOM.render(, container); + expect(listenerMaps.length).toEqual(2); + expect(listenerMaps[0]).toEqual(listenerMaps[1]); + }); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index fddf54ac9c0..932ea3cef35 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -17,6 +17,8 @@ import type { ReactEventResponderListener, } from 'shared/ReactTypes'; import type {SuspenseConfig} from 'react-reconciler/src/ReactFiberSuspenseConfig'; +import type {ReactDOMListenerMap} from 'shared/ReactDOMTypes'; + import {validateContextBounds} from './ReactPartialRendererContext'; import invariant from 'shared/invariant'; @@ -474,6 +476,13 @@ function useTransition( return [startTransition, false]; } +function useEvent(event: any): ReactDOMListenerMap { + return { + clear: noop, + setListener: noop, + }; +} + function noop(): void {} export let currentThreadID: ThreadID = 0; @@ -500,4 +509,5 @@ export const Dispatcher: DispatcherType = { useResponder, useDeferredValue, useTransition, + useEvent, }; diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 4f1b140f8f8..5b6c1dfdd03 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -76,6 +76,10 @@ export type UpdatePayload = Object; export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + // TODO: Remove this conditional once all changes have propagated. if (registerEventHandler) { /** diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 0349591a993..86f3e92bb38 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -26,6 +26,10 @@ import ReactNativeFiberHostComponent from './ReactNativeFiberHostComponent'; const {get: getViewConfigForType} = ReactNativeViewConfigRegistry; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + export type Type = string; export type Props = Object; export type Container = number; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 52021ec288c..b0c843269c9 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -17,8 +17,13 @@ import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; +import type { + ReactListenerEvent, + ReactListenerMap, +} from './ReactFiberHostConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {enableUseEventAPI} from 'shared/ReactFeatureFlags'; import {NoWork, Sync} from './ReactFiberExpirationTime'; import {readContext} from './ReactFiberNewContext'; @@ -28,6 +33,7 @@ import { Passive as PassiveEffect, } from 'shared/ReactSideEffectTags'; import { + NoEffect as NoHookEffect, HasEffect as HookHasEffect, Layout as HookLayout, Passive as HookPassive, @@ -97,6 +103,7 @@ export type Dispatcher = {| useTransition( config: SuspenseConfig | void | null, ): [(() => void) => void, boolean], + useEvent(event: ReactListenerEvent): ReactListenerMap, |}; type Update = {| @@ -129,7 +136,8 @@ export type HookType = | 'useDebugValue' | 'useResponder' | 'useDeferredValue' - | 'useTransition'; + | 'useTransition' + | 'useEvent'; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -1369,6 +1377,77 @@ function dispatchAction( } } +const noOpMount = () => {}; + +function mountEventListener(event: ReactListenerEvent): ReactListenerMap { + if (enableUseEventAPI) { + const hook = mountWorkInProgressHook(); + + const clear = () => { + // TODO + }; + + const reactListenerMap: ReactListenerMap = { + clear, + setListener(instance: EventTarget, callback: ?(Event) => void): void { + // TODO + }, + }; + // In order to clear up upon the hook unmounting, + // we ensure we set the effecrt tag so that we visit + // this effect in the commit phase, so we can handle + // clean-up accordingly. + currentlyRenderingFiber.effectTag |= UpdateEffect; + pushEffect(NoHookEffect, noOpMount, clear, null); + hook.memoizedState = [reactListenerMap, event, clear]; + return reactListenerMap; + } + // To make Flow not complain + return (undefined: any); +} + +function updateEventListener(event: ReactListenerEvent): ReactListenerMap { + if (enableUseEventAPI) { + const hook = updateWorkInProgressHook(); + const [reactListenerMap, memoizedEvent, clear] = hook.memoizedState; + if (__DEV__) { + if (memoizedEvent.type !== event.type) { + console.warn( + 'The event type argument passed to the useEvent() hook was different between renders.' + + ' The event type is static and should never change between renders.', + ); + } + if (memoizedEvent.capture !== event.capture) { + console.warn( + 'The "capture" option passed to the useEvent() hook was different between renders.' + + ' The "capture" option is static and should never change between renders.', + ); + } + if (memoizedEvent.priority !== event.priority) { + console.warn( + 'The "priority" option passed to the useEvent() hook was different between renders.' + + ' The "priority" option is static and should never change between renders.', + ); + } + if (memoizedEvent.passive !== event.passive) { + console.warn( + 'The "passive" option passed to the useEvent() hook was different between renders.' + + ' The "passive" option is static and should never change between renders.', + ); + } + } + // In order to clear up upon the hook unmounting, + // we ensure we set the effecrt tag so that we visit + // this effect in the commit phase, so we can handle + // clean-up accordingly. + currentlyRenderingFiber.effectTag |= UpdateEffect; + pushEffect(NoHookEffect, noOpMount, clear, null); + return reactListenerMap; + } + // To make Flow not complain + return (undefined: any); +} + export const ContextOnlyDispatcher: Dispatcher = { readContext, @@ -1385,6 +1464,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useResponder: throwInvalidHookError, useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, + useEvent: throwInvalidHookError, }; const HooksDispatcherOnMount: Dispatcher = { @@ -1403,6 +1483,7 @@ const HooksDispatcherOnMount: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: mountDeferredValue, useTransition: mountTransition, + useEvent: mountEventListener, }; const HooksDispatcherOnUpdate: Dispatcher = { @@ -1421,6 +1502,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: updateDeferredValue, useTransition: updateTransition, + useEvent: updateEventListener, }; const HooksDispatcherOnRerender: Dispatcher = { @@ -1439,6 +1521,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useResponder: createDeprecatedResponderListener, useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, + useEvent: updateEventListener, }; let HooksDispatcherOnMountInDEV: Dispatcher | null = null; @@ -1588,6 +1671,11 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnMountWithHookTypesInDEV = { @@ -1705,6 +1793,11 @@ if (__DEV__) { updateHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEventListener(event); + }, }; HooksDispatcherOnUpdateInDEV = { @@ -1822,6 +1915,11 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEventListener(event); + }, }; HooksDispatcherOnRerenderInDEV = { @@ -1939,6 +2037,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEventListener(event); + }, }; InvalidNestedHooksDispatcherOnMountInDEV = { @@ -2070,6 +2173,12 @@ if (__DEV__) { mountHookTypesDev(); return mountTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEventListener(event); + }, }; InvalidNestedHooksDispatcherOnUpdateInDEV = { @@ -2201,6 +2310,12 @@ if (__DEV__) { updateHookTypesDev(); return updateTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEventListener(event); + }, }; InvalidNestedHooksDispatcherOnRerenderInDEV = { @@ -2332,5 +2447,11 @@ if (__DEV__) { updateHookTypesDev(); return rerenderTransition(config); }, + useEvent(event: ReactListenerEvent): ReactListenerMap { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEventListener(event); + }, }; } diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index ae619a24daa..197289d1f03 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -38,6 +38,9 @@ export opaque type ChildSet = mixed; // eslint-disable-line no-undef export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef export opaque type NoTimeout = mixed; // eslint-disable-line no-undef export type EventResponder = any; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; export const getPublicInstance = $$$hostConfig.getPublicInstance; export const getRootHostContext = $$$hostConfig.getRootHostContext; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 58ea9749a1a..219d9059b45 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -45,6 +45,10 @@ export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; export type EventResponder = any; +export type ReactListenerEvent = Object; +export type ReactListenerMap = Object; +export type ReactListener = Object; + export * from 'shared/HostConfigWithNoPersistence'; export * from 'shared/HostConfigWithNoHydration';