diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 62ffd2aa24ee5..bace5e2a34fbb 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -16,7 +16,7 @@ import type { UpdatePayload, } from './ReactFiberHostConfig'; import type {Fiber} from './ReactInternalTypes'; -import type {FiberRoot} from './ReactInternalTypes'; +import type {FiberRoot, EventFunctionWrapper} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; @@ -164,7 +164,6 @@ import { Layout as HookLayout, Insertion as HookInsertion, Passive as HookPassive, - Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.new'; import {doesFiberContain} from './ReactFiberTreeReflection'; @@ -416,8 +415,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { case FunctionComponent: { if (enableUseEventHook) { if ((flags & Update) !== NoFlags) { - // useEvent doesn't need to be cleaned up - commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); + commitUseEventMount(finishedWork); } } break; @@ -665,6 +663,21 @@ function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) { } } +function commitUseEventMount(finishedWork: Fiber) { + const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); + const eventPayloads = updateQueue !== null ? updateQueue.events : null; + if (eventPayloads !== null) { + // FunctionComponentUpdateQueue.events is a flat array of + // [EventFunctionWrapper, EventFunction, ...], so increment by 2 each iteration to find the next + // pair. + for (let ii = 0; ii < eventPayloads.length; ii += 2) { + const eventFn: EventFunctionWrapper = eventPayloads[ii]; + const nextImpl = eventPayloads[ii + 1]; + eventFn._impl = nextImpl; + } + } +} + export function commitPassiveEffectDurations( finishedRoot: FiberRoot, finishedWork: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 58512e23d00f6..1e14b8ac4161f 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -16,7 +16,7 @@ import type { UpdatePayload, } from './ReactFiberHostConfig'; import type {Fiber} from './ReactInternalTypes'; -import type {FiberRoot} from './ReactInternalTypes'; +import type {FiberRoot, EventFunctionWrapper} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; @@ -164,7 +164,6 @@ import { Layout as HookLayout, Insertion as HookInsertion, Passive as HookPassive, - Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.old'; import {doesFiberContain} from './ReactFiberTreeReflection'; @@ -416,8 +415,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { case FunctionComponent: { if (enableUseEventHook) { if ((flags & Update) !== NoFlags) { - // useEvent doesn't need to be cleaned up - commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); + commitUseEventMount(finishedWork); } } break; @@ -665,6 +663,21 @@ function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) { } } +function commitUseEventMount(finishedWork: Fiber) { + const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); + const eventPayloads = updateQueue !== null ? updateQueue.events : null; + if (eventPayloads !== null) { + // FunctionComponentUpdateQueue.events is a flat array of + // [EventFunctionWrapper, EventFunction, ...], so increment by 2 each iteration to find the next + // pair. + for (let ii = 0; ii < eventPayloads.length; ii += 2) { + const eventFn: EventFunctionWrapper = eventPayloads[ii]; + const nextImpl = eventPayloads[ii + 1]; + eventFn._impl = nextImpl; + } + } +} + export function commitPassiveEffectDurations( finishedRoot: FiberRoot, finishedWork: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 20fb29016eaf9..f5956e27255fc 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -21,6 +21,7 @@ import type { Dispatcher, HookType, MemoCache, + EventFunctionWrapper, } from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.new'; import type {HookFlags} from './ReactHookEffectTags'; @@ -86,7 +87,6 @@ import { Layout as HookLayout, Passive as HookPassive, Insertion as HookInsertion, - Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -184,6 +184,7 @@ type StoreConsistencyCheck = { export type FunctionComponentUpdateQueue = { lastEffect: Effect | null, + events: Array<() => mixed> | null, stores: Array> | null, // NOTE: optional, only set when enableUseMemoCacheHook is enabled memoCache?: MemoCache | null, @@ -727,6 +728,7 @@ if (enableUseMemoCacheHook) { createFunctionComponentUpdateQueue = () => { return { lastEffect: null, + events: null, stores: null, memoCache: null, }; @@ -735,6 +737,7 @@ if (enableUseMemoCacheHook) { createFunctionComponentUpdateQueue = () => { return { lastEffect: null, + events: null, stores: null, }; }; @@ -1871,49 +1874,52 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } -function mountEvent(callback: () => T): () => T { - const hook = mountWorkInProgressHook(); - const ref = {current: callback}; +function useEventImpl) => Return>( + event: EventFunctionWrapper, + nextImpl: F, +) { + currentlyRenderingFiber.flags |= UpdateEffect; + let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any); + componentUpdateQueue.events = [event, nextImpl]; + } else { + const events = componentUpdateQueue.events; + if (events === null) { + componentUpdateQueue.events = [event, nextImpl]; + } else { + events.push(event, nextImpl); + } + } +} - function event() { +function mountEvent) => Return>( + callback: F, +): EventFunctionWrapper { + const hook = mountWorkInProgressHook(); + const eventFn: EventFunctionWrapper = function eventFn() { if (isInvalidExecutionContextForEventFunction()) { throw new Error( "A function wrapped in useEvent can't be called during rendering.", ); } - return ref.current.apply(undefined, arguments); - } - - // TODO: We don't need all the overhead of an effect object since there are no deps and no - // clean up functions. - mountEffectImpl( - UpdateEffect, - HookSnapshot, - () => { - ref.current = callback; - }, - [ref, callback], - ); - - hook.memoizedState = [ref, event]; + return eventFn._impl.apply(undefined, arguments); + }; + eventFn._impl = callback; - return event; + useEventImpl(eventFn, callback); + hook.memoizedState = eventFn; + return eventFn; } -function updateEvent(callback: () => T): () => T { +function updateEvent) => Return>( + callback: F, +): EventFunctionWrapper { const hook = updateWorkInProgressHook(); - const ref = hook.memoizedState[0]; - - updateEffectImpl( - UpdateEffect, - HookSnapshot, - () => { - ref.current = callback; - }, - [ref, callback], - ); - - return hook.memoizedState[1]; + const eventFn = hook.memoizedState; + useEventImpl(eventFn, callback); + return eventFn; } function mountInsertionEffect( @@ -2890,9 +2896,11 @@ if (__DEV__) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } if (enableUseEventHook) { - (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent( - callback: () => T, - ): () => T { + (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; mountHookTypesDev(); return mountEvent(callback); @@ -3048,8 +3056,10 @@ if (__DEV__) { } if (enableUseEventHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return mountEvent(callback); @@ -3204,9 +3214,11 @@ if (__DEV__) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } if (enableUseEventHook) { - (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent( - callback: () => T, - ): () => T { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return updateEvent(callback); @@ -3363,8 +3375,10 @@ if (__DEV__) { } if (enableUseEventHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return updateEvent(callback); @@ -3547,8 +3561,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); mountHookTypesDev(); @@ -3732,8 +3748,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); updateHookTypesDev(); @@ -3918,8 +3936,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); updateHookTypesDev(); diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 28584bf13114f..88b608694bcac 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -21,6 +21,7 @@ import type { Dispatcher, HookType, MemoCache, + EventFunctionWrapper, } from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane.old'; import type {HookFlags} from './ReactHookEffectTags'; @@ -86,7 +87,6 @@ import { Layout as HookLayout, Passive as HookPassive, Insertion as HookInsertion, - Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -184,6 +184,7 @@ type StoreConsistencyCheck = { export type FunctionComponentUpdateQueue = { lastEffect: Effect | null, + events: Array<() => mixed> | null, stores: Array> | null, // NOTE: optional, only set when enableUseMemoCacheHook is enabled memoCache?: MemoCache | null, @@ -727,6 +728,7 @@ if (enableUseMemoCacheHook) { createFunctionComponentUpdateQueue = () => { return { lastEffect: null, + events: null, stores: null, memoCache: null, }; @@ -735,6 +737,7 @@ if (enableUseMemoCacheHook) { createFunctionComponentUpdateQueue = () => { return { lastEffect: null, + events: null, stores: null, }; }; @@ -1871,49 +1874,52 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } -function mountEvent(callback: () => T): () => T { - const hook = mountWorkInProgressHook(); - const ref = {current: callback}; +function useEventImpl) => Return>( + event: EventFunctionWrapper, + nextImpl: F, +) { + currentlyRenderingFiber.flags |= UpdateEffect; + let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any); + componentUpdateQueue.events = [event, nextImpl]; + } else { + const events = componentUpdateQueue.events; + if (events === null) { + componentUpdateQueue.events = [event, nextImpl]; + } else { + events.push(event, nextImpl); + } + } +} - function event() { +function mountEvent) => Return>( + callback: F, +): EventFunctionWrapper { + const hook = mountWorkInProgressHook(); + const eventFn: EventFunctionWrapper = function eventFn() { if (isInvalidExecutionContextForEventFunction()) { throw new Error( "A function wrapped in useEvent can't be called during rendering.", ); } - return ref.current.apply(undefined, arguments); - } - - // TODO: We don't need all the overhead of an effect object since there are no deps and no - // clean up functions. - mountEffectImpl( - UpdateEffect, - HookSnapshot, - () => { - ref.current = callback; - }, - [ref, callback], - ); - - hook.memoizedState = [ref, event]; + return eventFn._impl.apply(undefined, arguments); + }; + eventFn._impl = callback; - return event; + useEventImpl(eventFn, callback); + hook.memoizedState = eventFn; + return eventFn; } -function updateEvent(callback: () => T): () => T { +function updateEvent) => Return>( + callback: F, +): EventFunctionWrapper { const hook = updateWorkInProgressHook(); - const ref = hook.memoizedState[0]; - - updateEffectImpl( - UpdateEffect, - HookSnapshot, - () => { - ref.current = callback; - }, - [ref, callback], - ); - - return hook.memoizedState[1]; + const eventFn = hook.memoizedState; + useEventImpl(eventFn, callback); + return eventFn; } function mountInsertionEffect( @@ -2890,9 +2896,11 @@ if (__DEV__) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } if (enableUseEventHook) { - (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent( - callback: () => T, - ): () => T { + (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; mountHookTypesDev(); return mountEvent(callback); @@ -3048,8 +3056,10 @@ if (__DEV__) { } if (enableUseEventHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return mountEvent(callback); @@ -3204,9 +3214,11 @@ if (__DEV__) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } if (enableUseEventHook) { - (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent( - callback: () => T, - ): () => T { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return updateEvent(callback); @@ -3363,8 +3375,10 @@ if (__DEV__) { } if (enableUseEventHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; updateHookTypesDev(); return updateEvent(callback); @@ -3547,8 +3561,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); mountHookTypesDev(); @@ -3732,8 +3748,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); updateHookTypesDev(); @@ -3918,8 +3936,10 @@ if (__DEV__) { } if (enableUseEventHook) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< - T, - >(callback: () => T): () => T { + Args, + Return, + F: (...Array) => Return, + >(callback: F): EventFunctionWrapper { currentHookNameInDev = 'useEvent'; warnInvalidHookAccess(); updateHookTypesDev(); diff --git a/packages/react-reconciler/src/ReactHookEffectTags.js b/packages/react-reconciler/src/ReactHookEffectTags.js index 41b3d6be761ee..5ace5bf920f77 100644 --- a/packages/react-reconciler/src/ReactHookEffectTags.js +++ b/packages/react-reconciler/src/ReactHookEffectTags.js @@ -9,13 +9,12 @@ export type HookFlags = number; -export const NoFlags = /* */ 0b00000; +export const NoFlags = /* */ 0b0000; // Represents whether effect should fire. -export const HasEffect = /* */ 0b00001; +export const HasEffect = /* */ 0b0001; // Represents the phase in which the effect (not the clean-up) fires. -export const Snapshot = /* */ 0b00010; -export const Insertion = /* */ 0b00100; -export const Layout = /* */ 0b01000; -export const Passive = /* */ 0b10000; +export const Insertion = /* */ 0b0010; +export const Layout = /* */ 0b0100; +export const Passive = /* */ 0b1000; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index c7ee97c69c9e3..9d23bc43763c2 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -286,6 +286,17 @@ type SuspenseCallbackOnlyFiberRootProperties = { hydrationCallbacks: null | SuspenseHydrationCallbacks, }; +// A wrapper callable object around a useEvent callback that throws if the callback is called during +// rendering. The _impl property points to the actual implementation. +export type EventFunctionWrapper< + Args, + Return, + F: (...Array) => Return, +> = { + (): F, + _impl: F, +}; + export type TransitionTracingCallbacks = { onTransitionStart?: (transitionName: string, startTime: number) => void, onTransitionProgress?: ( @@ -377,7 +388,9 @@ export type Dispatcher = { create: () => (() => void) | void, deps: Array | void | null, ): void, - useEvent?: (callback: () => T) => () => T, + useEvent?: ) => Return>( + callback: F, + ) => EventFunctionWrapper, useInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index eaa6746acc605..8ae869840b89f 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -117,6 +117,62 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook + it('can be defined more than once', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + multiply = () => { + this.props.onMouseEnter(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useEvent(() => updateCount(c => c + incrementBy)); + const onMouseEnter = useEvent(() => { + updateCount(c => c * incrementBy); + }); + + return ( + <> + onClick()} + onMouseEnter={() => onMouseEnter()} + ref={button} + /> + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 5']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 5'), + ]); + + act(button.current.multiply); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 25']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 25'), + ]); + }); + // @gate enableUseEventHook it('does not preserve `this` in event functions', () => { class GreetButton extends React.PureComponent { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index db406632a80e6..cfa5808609eb6 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -7,7 +7,10 @@ * @flow */ -import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes'; +import type { + Dispatcher as DispatcherType, + EventFunctionWrapper, +} from 'react-reconciler/src/ReactInternalTypes'; import type { MutableSource, @@ -509,7 +512,10 @@ function throwOnUseEventCall() { ); } -export function useEvent(callback: () => T): () => T { +export function useEvent) => Return>( + callback: F, +): EventFunctionWrapper { + // $FlowIgnore[incompatible-return] useEvent doesn't work in Fizz return throwOnUseEventCall; }