From 94c4f8afa13b9b7922a98cd9cc63ff9091a48ccd Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Thu, 5 May 2022 13:47:37 -0400 Subject: [PATCH 1/2] Add useEvent --- .../src/ReactFiberHooks.new.js | 84 ++++ .../src/ReactFiberHooks.old.js | 84 ++++ .../src/ReactFiberWorkLoop.new.js | 9 + .../src/ReactFiberWorkLoop.old.js | 9 + .../src/__tests__/useEvent-test.js | 406 ++++++++++++++++++ packages/react/index.classic.fb.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/index.modern.fb.js | 1 + packages/react/index.stable.js | 1 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 5 + scripts/error-codes/codes.json | 3 +- 13 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 packages/react-reconciler/src/__tests__/useEvent-test.js diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 1742d00ae1580..18c4575286029 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -88,6 +88,7 @@ import { requestEventTime, markSkippedUpdateLanes, isInterleavedUpdate, + isAlreadyRenderingProd, } from './ReactFiberWorkLoop.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1730,6 +1731,47 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountEvent(callback: T): T { + const hook = mountWorkInProgressHook(); + const ref = {current: callback}; + + function event(...args) { + if (isAlreadyRenderingProd()) { + throw new Error('An event from useEvent was called during render.'); + } + return ref.current.apply(this, args); + } + + mountEffectImpl( + UpdateEffect, + HookInsertion, + () => { + ref.current = callback; + }, + [ref, callback], + ); + + hook.memoizedState = [ref, event]; + + return event; +} + +function updateEvent(callback: T): T { + const hook = updateWorkInProgressHook(); + const ref = hook.memoizedState[0]; + + updateEffectImpl( + UpdateEffect, + HookInsertion, + () => { + ref.current = callback; + }, + [ref, callback], + ); + + return hook.memoizedState[1]; +} + function mountInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2430,6 +2472,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, + useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2458,6 +2501,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, + useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2485,6 +2529,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2513,6 +2558,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2586,6 +2632,11 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -2732,6 +2783,11 @@ if (__DEV__) { updateHookTypesDev(); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -2874,6 +2930,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3017,6 +3078,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3163,6 +3229,12 @@ if (__DEV__) { mountHookTypesDev(); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3322,6 +3394,12 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3482,6 +3560,12 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 20717bde1cd74..d62926a0615b2 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -88,6 +88,7 @@ import { requestEventTime, markSkippedUpdateLanes, isInterleavedUpdate, + isAlreadyRenderingProd, } from './ReactFiberWorkLoop.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1730,6 +1731,47 @@ function updateEffect( return updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountEvent(callback: T): T { + const hook = mountWorkInProgressHook(); + const ref = {current: callback}; + + function event(...args) { + if (isAlreadyRenderingProd()) { + throw new Error('An event from useEvent was called during render.'); + } + return ref.current.apply(this, args); + } + + mountEffectImpl( + UpdateEffect, + HookInsertion, + () => { + ref.current = callback; + }, + [ref, callback], + ); + + hook.memoizedState = [ref, event]; + + return event; +} + +function updateEvent(callback: T): T { + const hook = updateWorkInProgressHook(); + const ref = hook.memoizedState[0]; + + updateEffectImpl( + UpdateEffect, + HookInsertion, + () => { + ref.current = callback; + }, + [ref, callback], + ); + + return hook.memoizedState[1]; +} + function mountInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, @@ -2430,6 +2472,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, + useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2458,6 +2501,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, + useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2485,6 +2529,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2513,6 +2558,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2586,6 +2632,11 @@ if (__DEV__) { checkDepsAreArrayDev(deps); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -2732,6 +2783,11 @@ if (__DEV__) { updateHookTypesDev(); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -2874,6 +2930,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3017,6 +3078,11 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3163,6 +3229,12 @@ if (__DEV__) { mountHookTypesDev(); return mountEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3322,6 +3394,12 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, @@ -3482,6 +3560,12 @@ if (__DEV__) { updateHookTypesDev(); return updateEffect(create, deps); }, + useEvent(callback: T): T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }, useImperativeHandle( ref: {|current: T | null|} | ((inst: T | null) => mixed) | null | void, create: () => T, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index b4343485259ae..8e2c30ff6e1af 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1354,6 +1354,10 @@ export function getExecutionContext(): ExecutionContext { return executionContext; } +export function isCurrentlyRendering(): boolean { + return (executionContext & (RenderContext | CommitContext)) !== NoContext; +} + export function deferredUpdates(fn: () => A): A { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; @@ -1464,6 +1468,11 @@ export function isAlreadyRendering() { ); } +export function isAlreadyRenderingProd() { + // Used to throw if certain APIs are called from the wrong context. + return (executionContext & RenderContext) !== NoContext; +} + export function flushControlled(fn: () => mixed): void { const prevExecutionContext = executionContext; executionContext |= BatchedContext; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 58757a37367ce..5dd2775a8046b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1354,6 +1354,10 @@ export function getExecutionContext(): ExecutionContext { return executionContext; } +export function isCurrentlyRendering(): boolean { + return (executionContext & (RenderContext | CommitContext)) !== NoContext; +} + export function deferredUpdates(fn: () => A): A { const previousPriority = getCurrentUpdatePriority(); const prevTransition = ReactCurrentBatchConfig.transition; @@ -1464,6 +1468,11 @@ export function isAlreadyRendering() { ); } +export function isAlreadyRenderingProd() { + // Used to throw if certain APIs are called from the wrong context. + return (executionContext & RenderContext) !== NoContext; +} + export function flushControlled(fn: () => mixed): void { const prevExecutionContext = executionContext; executionContext |= BatchedContext; diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js new file mode 100644 index 0000000000000..bdc00a43dc7e2 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -0,0 +1,406 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +/* eslint-disable no-func-assign */ + +'use strict'; + +describe('useRef', () => { + let React; + let ReactNoop; + let Scheduler; + let act; + let useState; + let useEvent; + let useEffect; + let useLayoutEffect; + + beforeEach(() => { + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + + // const ReactFeatureFlags = require('shared/ReactFeatureFlags'); + // ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + + act = require('jest-react').act; + useState = React.useState; + useEvent = React.useEvent; + useEffect = React.useEffect; + useLayoutEffect = React.useLayoutEffect; + }); + + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; + } + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; + } + + it('memoizes basic case correctly', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useEvent(() => updateCount(c => c + incrementBy)); + + return ( + <> + + + + ); + } + + 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([ + // Button should not re-render, because its props haven't changed + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Event should use the updated callback function closed over the new value. + 'Count: 2', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 2'), + ]); + + // Increase the increment prop amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 2']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 2'), + ]); + + // Event uses the new prop + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 12']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 12'), + ]); + }); + + it('throws when called in render', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + + render() { + // Will throw. + this.props.onClick(); + + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const onClick = useEvent(() => updateCount(c => c + incrementBy)); + + return ( + <> + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'An event from useEvent was called during render', + ); + + // TODO: Why? + expect(Scheduler).toHaveYielded(['Count: 0', 'Count: 0']); + }); + + it('useLayoutEffect shouldn’t re-fire when event handlers change', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useEvent(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toHaveYielded([]); + expect(Scheduler).toFlushAndYield([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Count: 2', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 2'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 3'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 4'), + ]); + + // Increase the increment prop amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Count: 4', + 'Effect: by 20', + 'Count: 24', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 24'), + ]); + + // Event uses the new prop + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 34']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 34'), + ]); + }); + + it('useEffect shouldn’t re-fire when event handlers change', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useEvent(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + useEffect(() => { + Scheduler.unstable_yieldValue('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Count: 2', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 2'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 3'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 4'), + ]); + + // Increase the increment prop amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Count: 4', + 'Effect: by 20', + 'Count: 24', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 24'), + ]); + + // Event uses the new prop + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 34']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 34'), + ]); + }); + + it('is stable in a custom hook', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + + function useCount(incrementBy) { + const [count, updateCount] = useState(0); + const increment = useEvent(amount => + updateCount(c => c + (amount || incrementBy)), + ); + + return [count, increment]; + } + + function Counter({incrementBy}) { + const [count, increment] = useCount(incrementBy); + + useEffect(() => { + Scheduler.unstable_yieldValue('Effect: by ' + incrementBy * 2); + increment(incrementBy * 2); + }, [incrementBy]); + + return ( + <> + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Increment', + 'Count: 0', + 'Effect: by 2', + 'Count: 2', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 2'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Effect should not re-run because the dependency hasn't changed. + 'Count: 3', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 3'), + ]); + + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Event should use the updated callback function closed over the new value. + 'Count: 4', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 4'), + ]); + + // Increase the increment prop amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Count: 4', + 'Effect: by 20', + 'Count: 24', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 24'), + ]); + + // Event uses the new prop + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 34']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 34'), + ]); + }); +}); diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 76326a0fe59d0..124708e29f264 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -48,6 +48,7 @@ export { useDeferredValue, useDeferredValue as unstable_useDeferredValue, // TODO: Remove once call sights updated to useDeferredValue useEffect, + useEvent, useImperativeHandle, useLayoutEffect, useInsertionEffect, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 287c137298815..af27cb83b986d 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -41,6 +41,7 @@ export { useDebugValue, useDeferredValue, useEffect, + useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.js b/packages/react/index.js index 084aabb53c6bf..24a7ad704786b 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -69,6 +69,7 @@ export { useDebugValue, useDeferredValue, useEffect, + useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e9f80ade06105..77fa263f8f936 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -47,6 +47,7 @@ export { useDeferredValue, useDeferredValue as unstable_useDeferredValue, // TODO: Remove once call sights updated to useDeferredValue useEffect, + useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index 3ed868197b6f8..c32d56601d39a 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -33,6 +33,7 @@ export { useDebugValue, useDeferredValue, useEffect, + useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 264c1e1dc56d0..8e2250c6e07b5 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -41,6 +41,7 @@ import { useCallback, useContext, useEffect, + useEvent, useImperativeHandle, useDebugValue, useInsertionEffect, @@ -94,6 +95,7 @@ export { useCallback, useContext, useEffect, + useEvent, useImperativeHandle, useDebugValue, useInsertionEffect, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 9dc7a98589e4e..eae6b581592bb 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -106,6 +106,11 @@ export function useEffect( return dispatcher.useEffect(create, deps); } +export function useEvent(callback: T): void { + const dispatcher = resolveDispatcher(); + return dispatcher.useEvent(callback); +} + export function useInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 92d3bfd128c8a..a4238f72b7889 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -416,5 +416,6 @@ "428": "useServerContext is only supported while rendering.", "429": "ServerContext: %s already defined", "430": "ServerContext can only have a value prop and children. Found: %s", - "431": "React elements are not allowed in ServerContext" + "431": "React elements are not allowed in ServerContext", + "432": "An event from useEvent was called during render." } From 4aabc5788eed5011ba2c377a776e74f8918a1b48 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Thu, 8 Sep 2022 22:42:31 -0400 Subject: [PATCH 2/2] Add failing test --- .../src/__tests__/useEvent-test.js | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index bdc00a43dc7e2..04f2d4cbd1de8 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -12,7 +12,9 @@ 'use strict'; -describe('useRef', () => { +import {useInsertionEffect} from 'react'; + +describe('useEvent', () => { let React; let ReactNoop; let Scheduler; @@ -403,4 +405,37 @@ describe('useRef', () => { span('Count: 34'), ]); }); + + it('is mutated before all other effects', () => { + function Counter({value}) { + useInsertionEffect(() => { + Scheduler.unstable_yieldValue('Effect value: ' + value); + increment(); + }, [value]); + + // This is defined after the insertion effect, but it should + // update the event fn _before_ the insertion effect fires. + const increment = useEvent(() => { + Scheduler.unstable_yieldValue('Event value: ' + value); + }); + + return ( + <> + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Effect value: 1', + 'Event value: 1', + ]); + + + act(() => ReactNoop.render()); + expect(Scheduler).toHaveYielded([ + 'Effect value: 2', + 'Event value: 2', + ]); + }); });