From 7797d83bac161f498b804780ee8e3c022dddfad3 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Thu, 5 May 2022 13:47:37 -0400 Subject: [PATCH 1/9] Add useEvent --- .../src/ReactFiberCommitWork.new.js | 18 + .../src/ReactFiberCommitWork.old.js | 18 + .../src/ReactFiberHooks.new.js | 85 ++++ .../src/ReactFiberHooks.old.js | 85 ++++ .../src/ReactFiberWorkLoop.new.js | 5 + .../src/ReactFiberWorkLoop.old.js | 5 + .../src/ReactHookEffectTags.js | 11 +- .../src/ReactInternalTypes.js | 2 + .../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 +- 17 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/useEvent-test.js diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 1710bcfc861e7..88fefc10eaaa7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -162,6 +162,7 @@ import { Layout as HookLayout, Insertion as HookInsertion, Passive as HookPassive, + Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.new'; import {doesFiberContain} from './ReactFiberTreeReflection'; @@ -410,6 +411,23 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: + // TODO: swap ref.current for useEvent; + if (flags & Update) { + try { + commitHookEffectListUnmount( + HookSnapshot | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount( + HookSnapshot | HookHasEffect, + finishedWork, + ); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + break; case ForwardRef: case SimpleMemoComponent: { break; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index a7a8929d2b68c..0e80fbf7f9261 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -162,6 +162,7 @@ import { Layout as HookLayout, Insertion as HookInsertion, Passive as HookPassive, + Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork.old'; import {doesFiberContain} from './ReactFiberTreeReflection'; @@ -410,6 +411,23 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: + // TODO: swap ref.current for useEvent; + if (flags & Update) { + try { + commitHookEffectListUnmount( + HookSnapshot | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount( + HookSnapshot | HookHasEffect, + finishedWork, + ); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); + } + } + break; case ForwardRef: case SimpleMemoComponent: { break; diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index b602c3691471e..7e66e581ba4e9 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -85,6 +85,7 @@ import { Layout as HookLayout, Passive as HookPassive, Insertion as HookInsertion, + Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -93,6 +94,7 @@ import { requestUpdateLane, requestEventTime, markSkippedUpdateLanes, + isAlreadyRenderingProd, } from './ReactFiberWorkLoop.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1868,6 +1870,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, + HookSnapshot, + () => { + 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, @@ -2519,6 +2562,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, + useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2553,6 +2597,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, + useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2587,6 +2632,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2621,6 +2667,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2700,6 +2747,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, @@ -2852,6 +2904,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, @@ -3000,6 +3057,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, @@ -3149,6 +3211,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, @@ -3301,6 +3368,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, @@ -3476,6 +3549,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, @@ -3652,6 +3731,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 9f0cb1914581f..28a8ff6b432de 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -85,6 +85,7 @@ import { Layout as HookLayout, Passive as HookPassive, Insertion as HookInsertion, + Snapshot as HookSnapshot, } from './ReactHookEffectTags'; import { getWorkInProgressRoot, @@ -93,6 +94,7 @@ import { requestUpdateLane, requestEventTime, markSkippedUpdateLanes, + isAlreadyRenderingProd, } from './ReactFiberWorkLoop.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1868,6 +1870,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, + HookSnapshot, + () => { + 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, @@ -2519,6 +2562,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, + useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2553,6 +2597,7 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, + useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2587,6 +2632,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2621,6 +2667,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, + useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2700,6 +2747,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, @@ -2852,6 +2904,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, @@ -3000,6 +3057,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, @@ -3149,6 +3211,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, @@ -3301,6 +3368,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, @@ -3476,6 +3549,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, @@ -3652,6 +3731,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 ccd1821db2c90..0b539599ac7b2 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1605,6 +1605,11 @@ export function isAlreadyRendering(): boolean { ); } +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 dc592a912e3a1..60f64ccb34c1a 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1605,6 +1605,11 @@ export function isAlreadyRendering(): boolean { ); } +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/ReactHookEffectTags.js b/packages/react-reconciler/src/ReactHookEffectTags.js index 54be635a623a9..41b3d6be761ee 100644 --- a/packages/react-reconciler/src/ReactHookEffectTags.js +++ b/packages/react-reconciler/src/ReactHookEffectTags.js @@ -9,12 +9,13 @@ export type HookFlags = number; -export const NoFlags = /* */ 0b0000; +export const NoFlags = /* */ 0b00000; // Represents whether effect should fire. -export const HasEffect = /* */ 0b0001; +export const HasEffect = /* */ 0b00001; // Represents the phase in which the effect (not the clean-up) fires. -export const Insertion = /* */ 0b0010; -export const Layout = /* */ 0b0100; -export const Passive = /* */ 0b1000; +export const Snapshot = /* */ 0b00010; +export const Insertion = /* */ 0b00100; +export const Layout = /* */ 0b01000; +export const Passive = /* */ 0b10000; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 004aa17e56ad8..bf76811422981 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -42,6 +42,7 @@ export type HookType = | 'useContext' | 'useRef' | 'useEffect' + | 'useEvent' | 'useInsertionEffect' | 'useLayoutEffect' | 'useCallback' @@ -376,6 +377,7 @@ export type Dispatcher = { create: () => (() => void) | void, deps: Array | void | null, ): void, + useEvent(callback: () => T): () => T, 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 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 0bc75a3531681..ffac0178d19b4 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -51,6 +51,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 d60351e263981..1716f2d3f1c92 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -43,6 +43,7 @@ export { useDebugValue, useDeferredValue, useEffect, + useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.js b/packages/react/index.js index d0628ab003a79..04e62d1f26db6 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -71,6 +71,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 24de7511daed5..e606d62d662a0 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -49,6 +49,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 fae7ee56b758e..1e5c825f425c3 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, @@ -96,6 +97,7 @@ export { useCallback, useContext, useEffect, + useEvent, useImperativeHandle, useDebugValue, useInsertionEffect, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 7192f9aea5e39..013b15aefccfb 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -107,6 +107,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 4817eadd99031..7a23215cc332a 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -424,5 +424,6 @@ "436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".", "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", "438": "An unsupported type was passed to use(): %s", - "439": "We didn't expect to see a forward reference. This is a bug in the React Server." + "439": "We didn't expect to see a forward reference. This is a bug in the React Server.", + "440": "An event from useEvent was called during render." } From c00ffe82b2395c69fb0b7a3e985c1e3c8b5013ea Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Thu, 8 Sep 2022 22:42:31 -0400 Subject: [PATCH 2/9] Add test checking that useEvent is updated in hook snapshot phase Co-authored-by: Lauren Tan --- .../src/ReactFiberCommitWork.new.js | 69 ++++++++++--------- .../src/ReactFiberCommitWork.old.js | 69 ++++++++++--------- .../src/ReactFiberHooks.new.js | 2 +- .../src/ReactFiberHooks.old.js | 2 +- .../src/__tests__/useEvent-test.js | 27 +++++++- 5 files changed, 102 insertions(+), 67 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 88fefc10eaaa7..1280becf9cad4 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -408,31 +408,30 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { if ((flags & Snapshot) !== NoFlags) { setCurrentDebugFiberInDEV(finishedWork); + } - switch (finishedWork.tag) { - case FunctionComponent: - // TODO: swap ref.current for useEvent; - if (flags & Update) { - try { - commitHookEffectListUnmount( - HookSnapshot | HookHasEffect, - finishedWork, - finishedWork.return, - ); - commitHookEffectListMount( - HookSnapshot | HookHasEffect, - finishedWork, - ); - } catch (error) { - captureCommitPhaseError(finishedWork, finishedWork.return, error); - } + switch (finishedWork.tag) { + case FunctionComponent: { + if ((flags & Update) !== NoFlags) { + try { + commitHookEffectListUnmount( + HookSnapshot | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); } - break; - case ForwardRef: - case SimpleMemoComponent: { - break; } - case ClassComponent: { + break; + } + case ForwardRef: + case SimpleMemoComponent: { + break; + } + case ClassComponent: { + if ((flags & Snapshot) !== NoFlags) { if (current !== null) { const prevProps = current.memoizedProps; const prevState = current.memoizedState; @@ -486,29 +485,35 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { } instance.__reactInternalSnapshotBeforeUpdate = snapshot; } - break; } - case HostRoot: { + break; + } + case HostRoot: { + if ((flags & Snapshot) !== NoFlags) { if (supportsMutation) { const root = finishedWork.stateNode; clearContainer(root.containerInfo); } - break; } - case HostComponent: - case HostText: - case HostPortal: - case IncompleteClassComponent: - // Nothing to do for these component types - break; - default: { + break; + } + case HostComponent: + case HostText: + case HostPortal: + case IncompleteClassComponent: + // Nothing to do for these component types + break; + default: { + if ((flags & Snapshot) !== NoFlags) { throw new Error( 'This unit of work tag should not have side-effects. This error is ' + 'likely caused by a bug in React. Please file an issue.', ); } } + } + if ((flags & Snapshot) !== NoFlags) { resetCurrentDebugFiberInDEV(); } } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 0e80fbf7f9261..6f37430e013db 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -408,31 +408,30 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { if ((flags & Snapshot) !== NoFlags) { setCurrentDebugFiberInDEV(finishedWork); + } - switch (finishedWork.tag) { - case FunctionComponent: - // TODO: swap ref.current for useEvent; - if (flags & Update) { - try { - commitHookEffectListUnmount( - HookSnapshot | HookHasEffect, - finishedWork, - finishedWork.return, - ); - commitHookEffectListMount( - HookSnapshot | HookHasEffect, - finishedWork, - ); - } catch (error) { - captureCommitPhaseError(finishedWork, finishedWork.return, error); - } + switch (finishedWork.tag) { + case FunctionComponent: { + if ((flags & Update) !== NoFlags) { + try { + commitHookEffectListUnmount( + HookSnapshot | HookHasEffect, + finishedWork, + finishedWork.return, + ); + commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); + } catch (error) { + captureCommitPhaseError(finishedWork, finishedWork.return, error); } - break; - case ForwardRef: - case SimpleMemoComponent: { - break; } - case ClassComponent: { + break; + } + case ForwardRef: + case SimpleMemoComponent: { + break; + } + case ClassComponent: { + if ((flags & Snapshot) !== NoFlags) { if (current !== null) { const prevProps = current.memoizedProps; const prevState = current.memoizedState; @@ -486,29 +485,35 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { } instance.__reactInternalSnapshotBeforeUpdate = snapshot; } - break; } - case HostRoot: { + break; + } + case HostRoot: { + if ((flags & Snapshot) !== NoFlags) { if (supportsMutation) { const root = finishedWork.stateNode; clearContainer(root.containerInfo); } - break; } - case HostComponent: - case HostText: - case HostPortal: - case IncompleteClassComponent: - // Nothing to do for these component types - break; - default: { + break; + } + case HostComponent: + case HostText: + case HostPortal: + case IncompleteClassComponent: + // Nothing to do for these component types + break; + default: { + if ((flags & Snapshot) !== NoFlags) { throw new Error( 'This unit of work tag should not have side-effects. This error is ' + 'likely caused by a bug in React. Please file an issue.', ); } } + } + if ((flags & Snapshot) !== NoFlags) { resetCurrentDebugFiberInDEV(); } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 7e66e581ba4e9..43872d79f16a5 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -1901,7 +1901,7 @@ function updateEvent(callback: () => T): () => T { updateEffectImpl( UpdateEffect, - HookInsertion, + HookSnapshot, () => { ref.current = callback; }, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 28a8ff6b432de..0ad1fd5543c1f 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -1901,7 +1901,7 @@ function updateEvent(callback: () => T): () => T { updateEffectImpl( UpdateEffect, - HookInsertion, + HookSnapshot, () => { ref.current = callback; }, diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index bdc00a43dc7e2..79392a114f93a 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,27 @@ 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']); + }); }); From deb67f31b86ee93fbaa4d4be60a7b08116369bad Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Fri, 9 Sep 2022 16:07:24 -0400 Subject: [PATCH 3/9] Make useEvent gated as experimental --- .../src/ReactFiberHooks.new.js | 121 ++++++++++++------ .../src/ReactFiberHooks.old.js | 121 ++++++++++++------ .../src/ReactInternalTypes.js | 2 +- ...vent-test.js => useEvent-test.internal.js} | 11 +- packages/react/index.classic.fb.js | 2 +- packages/react/index.experimental.js | 2 +- packages/react/index.js | 2 +- packages/react/index.modern.fb.js | 2 +- packages/react/index.stable.js | 2 +- packages/react/src/React.js | 2 +- packages/react/src/ReactHooks.js | 11 +- packages/shared/ReactFeatureFlags.js | 2 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../forks/ReactFeatureFlags.testing.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 20 files changed, 188 insertions(+), 100 deletions(-) rename packages/react-reconciler/src/__tests__/{useEvent-test.js => useEvent-test.internal.js} (98%) diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 43872d79f16a5..b49cb3f2b01fd 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -40,6 +40,7 @@ import { enableTransitionTracing, enableUseHook, enableUseMemoCacheHook, + enableUseEventHook, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -2562,7 +2563,6 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, - useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2590,6 +2590,9 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } +if (enableUseEventHook) { + (ContextOnlyDispatcher: Dispatcher).useEvent = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -2597,7 +2600,6 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, - useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2626,13 +2628,15 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseEventHook) { + (HooksDispatcherOnMount: Dispatcher).useEvent = mountEvent; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, - useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2660,6 +2664,9 @@ if (enableUseMemoCacheHook) { if (enableUseHook) { (HooksDispatcherOnUpdate: Dispatcher).use = use; } +if (enableUseEventHook) { + (HooksDispatcherOnUpdate: Dispatcher).useEvent = updateEvent; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2667,7 +2674,6 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, - useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2695,6 +2701,9 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseEventHook) { + (HooksDispatcherOnRerender: Dispatcher).useEvent = updateEvent; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -2747,11 +2756,6 @@ 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, @@ -2881,6 +2885,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent( + callback: () => T, + ): () => T { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEvent(callback); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -2904,11 +2917,6 @@ 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, @@ -3034,6 +3042,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEvent(callback); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3057,11 +3074,6 @@ 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, @@ -3187,6 +3199,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent( + callback: () => T, + ): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3211,11 +3232,6 @@ 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, @@ -3341,6 +3357,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3368,12 +3393,6 @@ 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, @@ -3522,6 +3541,16 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEvent(callback); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3549,12 +3578,6 @@ 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, @@ -3703,6 +3726,16 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3731,12 +3764,6 @@ 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, @@ -3885,4 +3912,14 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 0ad1fd5543c1f..cd136cb5e2320 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -40,6 +40,7 @@ import { enableTransitionTracing, enableUseHook, enableUseMemoCacheHook, + enableUseEventHook, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -2562,7 +2563,6 @@ export const ContextOnlyDispatcher: Dispatcher = { useCallback: throwInvalidHookError, useContext: throwInvalidHookError, useEffect: throwInvalidHookError, - useEvent: throwInvalidHookError, useImperativeHandle: throwInvalidHookError, useInsertionEffect: throwInvalidHookError, useLayoutEffect: throwInvalidHookError, @@ -2590,6 +2590,9 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (ContextOnlyDispatcher: Dispatcher).useMemoCache = throwInvalidHookError; } +if (enableUseEventHook) { + (ContextOnlyDispatcher: Dispatcher).useEvent = throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -2597,7 +2600,6 @@ const HooksDispatcherOnMount: Dispatcher = { useCallback: mountCallback, useContext: readContext, useEffect: mountEffect, - useEvent: mountEvent, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, useInsertionEffect: mountInsertionEffect, @@ -2626,13 +2628,15 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMount: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseEventHook) { + (HooksDispatcherOnMount: Dispatcher).useEvent = mountEvent; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, - useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2660,6 +2664,9 @@ if (enableUseMemoCacheHook) { if (enableUseHook) { (HooksDispatcherOnUpdate: Dispatcher).use = use; } +if (enableUseEventHook) { + (HooksDispatcherOnUpdate: Dispatcher).useEvent = updateEvent; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -2667,7 +2674,6 @@ const HooksDispatcherOnRerender: Dispatcher = { useCallback: updateCallback, useContext: readContext, useEffect: updateEffect, - useEvent: updateEvent, useImperativeHandle: updateImperativeHandle, useInsertionEffect: updateInsertionEffect, useLayoutEffect: updateLayoutEffect, @@ -2695,6 +2701,9 @@ if (enableUseHook) { if (enableUseMemoCacheHook) { (HooksDispatcherOnRerender: Dispatcher).useMemoCache = useMemoCache; } +if (enableUseEventHook) { + (HooksDispatcherOnRerender: Dispatcher).useEvent = updateEvent; +} let HooksDispatcherOnMountInDEV: Dispatcher | null = null; let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null; @@ -2747,11 +2756,6 @@ 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, @@ -2881,6 +2885,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMountInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent( + callback: () => T, + ): () => T { + currentHookNameInDev = 'useEvent'; + mountHookTypesDev(); + return mountEvent(callback); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -2904,11 +2917,6 @@ 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, @@ -3034,6 +3042,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return mountEvent(callback); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3057,11 +3074,6 @@ 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, @@ -3187,6 +3199,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent( + callback: () => T, + ): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3211,11 +3232,6 @@ 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, @@ -3341,6 +3357,15 @@ if (__DEV__) { if (enableUseMemoCacheHook) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useMemoCache = useMemoCache; } + if (enableUseEventHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + updateHookTypesDev(); + return updateEvent(callback); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3368,12 +3393,6 @@ 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, @@ -3522,6 +3541,16 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountEvent(callback); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3549,12 +3578,6 @@ 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, @@ -3703,6 +3726,16 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3731,12 +3764,6 @@ 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, @@ -3885,4 +3912,14 @@ if (__DEV__) { return useMemoCache(size); }; } + if (enableUseEventHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useEvent = function useEvent< + T, + >(callback: () => T): () => T { + currentHookNameInDev = 'useEvent'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateEvent(callback); + }; + } } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index bf76811422981..c7ee97c69c9e3 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -377,7 +377,7 @@ export type Dispatcher = { create: () => (() => void) | void, deps: Array | void | null, ): void, - useEvent(callback: () => T): () => T, + useEvent?: (callback: () => T) => () => T, 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.internal.js similarity index 98% rename from packages/react-reconciler/src/__tests__/useEvent-test.js rename to packages/react-reconciler/src/__tests__/useEvent-test.internal.js index 79392a114f93a..65f266846b3cc 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.internal.js @@ -29,12 +29,9 @@ describe('useEvent', () => { 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; + useEvent = React.experimental_useEvent; useEffect = React.useEffect; useLayoutEffect = React.useLayoutEffect; }); @@ -48,6 +45,7 @@ describe('useEvent', () => { return ; } + // @gate enableUseEventHook it('memoizes basic case correctly', () => { class IncrementButton extends React.PureComponent { increment = () => { @@ -115,6 +113,7 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook it('throws when called in render', () => { class IncrementButton extends React.PureComponent { increment = () => { @@ -150,6 +149,7 @@ describe('useEvent', () => { expect(Scheduler).toHaveYielded(['Count: 0', 'Count: 0']); }); + // @gate enableUseEventHook it('useLayoutEffect shouldn’t re-fire when event handlers change', () => { class IncrementButton extends React.PureComponent { increment = () => { @@ -234,6 +234,7 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook it('useEffect shouldn’t re-fire when event handlers change', () => { class IncrementButton extends React.PureComponent { increment = () => { @@ -317,6 +318,7 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook it('is stable in a custom hook', () => { class IncrementButton extends React.PureComponent { increment = () => { @@ -406,6 +408,7 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook it('is mutated before all other effects', () => { function Counter({value}) { useInsertionEffect(() => { diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index ffac0178d19b4..b18fc3d7ad30f 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -51,7 +51,7 @@ export { useDeferredValue, useDeferredValue as unstable_useDeferredValue, // TODO: Remove once call sights updated to useDeferredValue useEffect, - useEvent, + experimental_useEvent, useImperativeHandle, useLayoutEffect, useInsertionEffect, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 1716f2d3f1c92..2d36e38836144 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -43,7 +43,7 @@ export { useDebugValue, useDeferredValue, useEffect, - useEvent, + experimental_useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.js b/packages/react/index.js index 04e62d1f26db6..9db87fa8921ab 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -71,7 +71,7 @@ export { useDebugValue, useDeferredValue, useEffect, - useEvent, + experimental_useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index e606d62d662a0..ad440565309f8 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -49,7 +49,7 @@ export { useDeferredValue, useDeferredValue as unstable_useDeferredValue, // TODO: Remove once call sights updated to useDeferredValue useEffect, - useEvent, + experimental_useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/index.stable.js b/packages/react/index.stable.js index c32d56601d39a..ea6691f68f8ca 100644 --- a/packages/react/index.stable.js +++ b/packages/react/index.stable.js @@ -33,7 +33,7 @@ export { useDebugValue, useDeferredValue, useEffect, - useEvent, + experimental_useEvent, useImperativeHandle, useInsertionEffect, useLayoutEffect, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 1e5c825f425c3..fc6c77cbb6539 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -97,7 +97,7 @@ export { useCallback, useContext, useEffect, - useEvent, + useEvent as experimental_useEvent, useImperativeHandle, useDebugValue, useInsertionEffect, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 013b15aefccfb..2e5046353a3a4 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -107,11 +107,6 @@ 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, @@ -222,3 +217,9 @@ export function useMemoCache(size: number): Array { // $FlowFixMe This is unstable, thus optional return dispatcher.useMemoCache(size); } + +export function useEvent(callback: T): void { + const dispatcher = resolveDispatcher(); + // $FlowFixMe This is unstable, thus optional + return dispatcher.useEvent(callback); +} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index ba7fcb61a613c..447d25542536e 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -120,6 +120,8 @@ export const enableUseHook = __EXPERIMENTAL__; // auto-memoization. export const enableUseMemoCacheHook = __EXPERIMENTAL__; +export const enableUseEventHook = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 76f19551875ba..500c4bde865fa 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -52,6 +52,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 6de6388896c23..fb889cc65e736 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -42,6 +42,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 1ac2ff4f2acf0..53ed7ca5a14c6 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -42,6 +42,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index 8b3d4e230d438..034a605979ab1 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -51,6 +51,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableStrictEffects = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index e414784563f3c..239f67b9d54ae 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -42,6 +42,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 0d719ca3523d2..6b078b93c26f7 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -42,6 +42,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = false; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 6d2cd32d9f86b..b16916483b3fd 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -42,6 +42,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false; export const enableCPUSuspense = true; export const enableUseHook = false; export const enableUseMemoCacheHook = false; +export const enableUseEventHook = false; export const enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay = true; export const enableClientRenderFallbackOnTextMismatch = true; export const enableComponentStackLocations = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 18a333e40f769..605282c150c9b 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -55,6 +55,7 @@ export const enableCPUSuspense = true; export const enableFloat = false; export const enableUseHook = true; export const enableUseMemoCacheHook = true; +export const enableUseEventHook = true; // Logs additional User Timing API marks for use with an experimental profiling tool. export const enableSchedulingProfiler: boolean = From 38c2b011567a7756261721f95011fa6589d02744 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Fri, 9 Sep 2022 18:04:46 -0400 Subject: [PATCH 4/9] Add tests from docs examples --- .../src/__tests__/useEvent-test.internal.js | 165 +++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.internal.js b/packages/react-reconciler/src/__tests__/useEvent-test.internal.js index 65f266846b3cc..379d22e12ad06 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.internal.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.internal.js @@ -19,10 +19,13 @@ describe('useEvent', () => { let ReactNoop; let Scheduler; let act; + let createContext; + let useContext; let useState; let useEvent; let useEffect; let useLayoutEffect; + let useMemo; beforeEach(() => { React = require('react'); @@ -30,10 +33,13 @@ describe('useEvent', () => { Scheduler = require('scheduler'); act = require('jest-react').act; + createContext = React.createContext; + useContext = React.useContext; useState = React.useState; useEvent = React.experimental_useEvent; useEffect = React.useEffect; useLayoutEffect = React.useLayoutEffect; + useMemo = React.useMemo; }); function span(prop) { @@ -150,7 +156,7 @@ describe('useEvent', () => { }); // @gate enableUseEventHook - it('useLayoutEffect shouldn’t re-fire when event handlers change', () => { + it("useLayoutEffect shouldn't re-fire when event handlers change", () => { class IncrementButton extends React.PureComponent { increment = () => { this.props.onClick(); @@ -235,7 +241,7 @@ describe('useEvent', () => { }); // @gate enableUseEventHook - it('useEffect shouldn’t re-fire when event handlers change', () => { + it("useEffect shouldn't re-fire when event handlers change", () => { class IncrementButton extends React.PureComponent { increment = () => { this.props.onClick(); @@ -431,4 +437,159 @@ describe('useEvent', () => { act(() => ReactNoop.render()); expect(Scheduler).toHaveYielded(['Effect value: 2', 'Event value: 2']); }); + + // @gate enableUseEventHook + it('integration: implements docs chat room example', () => { + function createConnection() { + let connectedCallback; + let timeout; + return { + connect() { + timeout = setTimeout(() => { + if (connectedCallback) { + connectedCallback(); + } + }, 100); + }, + on(event, callback) { + if (connectedCallback) { + throw Error('Cannot add the handler twice.'); + } + if (event !== 'connected') { + throw Error('Only "connected" event is supported.'); + } + connectedCallback = callback; + }, + disconnect() { + clearTimeout(timeout); + }, + }; + } + + function ChatRoom({roomId, theme}) { + const onConnected = useEvent(() => { + Scheduler.unstable_yieldValue('Connected! theme: ' + theme); + }); + + useEffect(() => { + const connection = createConnection(roomId); + connection.on('connected', () => { + onConnected(); + }); + connection.connect(); + return () => connection.disconnect(); + }, [roomId, onConnected]); + + return ; + } + + act(() => ReactNoop.render()); + expect(Scheduler).toHaveYielded(['Welcome to the general room!']); + expect(ReactNoop.getChildren()).toEqual([ + span('Welcome to the general room!'), + ]); + + jest.advanceTimersByTime(100); + Scheduler.unstable_advanceTime(100); + expect(Scheduler).toHaveYielded(['Connected! theme: light']); + + // change roomId only + act(() => ReactNoop.render()); + expect(Scheduler).toHaveYielded(['Welcome to the music room!']); + expect(ReactNoop.getChildren()).toEqual([ + span('Welcome to the music room!'), + ]); + jest.advanceTimersByTime(100); + Scheduler.unstable_advanceTime(100); + // should trigger a reconnect + expect(Scheduler).toHaveYielded(['Connected! theme: light']); + + // change theme only + act(() => ReactNoop.render()); + expect(Scheduler).toHaveYielded(['Welcome to the music room!']); + expect(ReactNoop.getChildren()).toEqual([ + span('Welcome to the music room!'), + ]); + jest.advanceTimersByTime(100); + Scheduler.unstable_advanceTime(100); + // should not trigger a reconnect + expect(Scheduler).toFlushWithoutYielding(); + + // change roomId only + act(() => ReactNoop.render()); + expect(Scheduler).toHaveYielded(['Welcome to the travel room!']); + expect(ReactNoop.getChildren()).toEqual([ + span('Welcome to the travel room!'), + ]); + jest.advanceTimersByTime(100); + Scheduler.unstable_advanceTime(100); + // should trigger a reconnect + expect(Scheduler).toHaveYielded(['Connected! theme: dark']); + }); + + // @gate enableUseEventHook + it('integration: implements the docs logVisit example', () => { + class AddToCartButton extends React.PureComponent { + addToCart = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + const ShoppingCartContext = createContext(null); + + function AppShell({children}) { + const [items, updateItems] = useState([]); + const value = useMemo(() => ({items, updateItems}), [items, updateItems]); + + return ( + + {children} + + ); + } + + function Page({url}) { + const {items, updateItems} = useContext(ShoppingCartContext); + const onClick = useEvent(() => updateItems([...items, 1])); + const numberOfItems = items.length; + + const onVisit = useEvent(visitedUrl => { + Scheduler.unstable_yieldValue( + 'url: ' + url + ', numberOfItems: ' + numberOfItems, + ); + }); + + useEffect(() => { + onVisit(url); + }, [url]); + + return ; + } + + const button = React.createRef(null); + act(() => + ReactNoop.render( + + + , + ), + ); + expect(Scheduler).toHaveYielded([ + 'Add to cart', + 'url: /shop/1, numberOfItems: 0', + ]); + act(button.current.addToCart); + expect(Scheduler).toFlushWithoutYielding(); + + act(() => + ReactNoop.render( + + + , + ), + ); + expect(Scheduler).toHaveYielded(['url: /shop/2, numberOfItems: 1']); + }); }); From ba846ba520909205c401a5c454ef43c6ed20e18c Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 13 Sep 2022 14:46:19 -0400 Subject: [PATCH 5/9] Address feedback (will be squashed) --- .../src/ReactFiberCommitWork.new.js | 13 ++++--------- .../src/ReactFiberCommitWork.old.js | 13 ++++--------- .../react-reconciler/src/ReactFiberHooks.new.js | 10 ++++++---- .../react-reconciler/src/ReactFiberHooks.old.js | 10 ++++++---- .../react-reconciler/src/ReactFiberWorkLoop.new.js | 2 +- .../react-reconciler/src/ReactFiberWorkLoop.old.js | 2 +- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 1280becf9cad4..825652939f5e4 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -48,6 +48,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableUseEventHook, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -412,16 +413,10 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: { - if ((flags & Update) !== NoFlags) { - try { - commitHookEffectListUnmount( - HookSnapshot | HookHasEffect, - finishedWork, - finishedWork.return, - ); + if (enableUseEventHook) { + if ((flags & Update) !== NoFlags) { + // useEvent doesn't need to be cleaned up commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); - } catch (error) { - captureCommitPhaseError(finishedWork, finishedWork.return, error); } } break; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 6f37430e013db..57233404d6ff9 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -48,6 +48,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableUseEventHook, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -412,16 +413,10 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { switch (finishedWork.tag) { case FunctionComponent: { - if ((flags & Update) !== NoFlags) { - try { - commitHookEffectListUnmount( - HookSnapshot | HookHasEffect, - finishedWork, - finishedWork.return, - ); + if (enableUseEventHook) { + if ((flags & Update) !== NoFlags) { + // useEvent doesn't need to be cleaned up commitHookEffectListMount(HookSnapshot | HookHasEffect, finishedWork); - } catch (error) { - captureCommitPhaseError(finishedWork, finishedWork.return, error); } } break; diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index b49cb3f2b01fd..7d3c867077e97 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -95,7 +95,7 @@ import { requestUpdateLane, requestEventTime, markSkippedUpdateLanes, - isAlreadyRenderingProd, + isInvalidExecutionContextForEventFunction, } from './ReactFiberWorkLoop.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1875,13 +1875,15 @@ function mountEvent(callback: () => T): () => T { const hook = mountWorkInProgressHook(); const ref = {current: callback}; - function event(...args) { - if (isAlreadyRenderingProd()) { + function event() { + if (isInvalidExecutionContextForEventFunction()) { throw new Error('An event from useEvent was called during render.'); } - return ref.current.apply(this, args); + return ref.current.apply(this, 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, diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index cd136cb5e2320..1cce640736920 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -95,7 +95,7 @@ import { requestUpdateLane, requestEventTime, markSkippedUpdateLanes, - isAlreadyRenderingProd, + isInvalidExecutionContextForEventFunction, } from './ReactFiberWorkLoop.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -1875,13 +1875,15 @@ function mountEvent(callback: () => T): () => T { const hook = mountWorkInProgressHook(); const ref = {current: callback}; - function event(...args) { - if (isAlreadyRenderingProd()) { + function event() { + if (isInvalidExecutionContextForEventFunction()) { throw new Error('An event from useEvent was called during render.'); } - return ref.current.apply(this, args); + return ref.current.apply(this, 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, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 0b539599ac7b2..dc5efade1120d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1605,7 +1605,7 @@ export function isAlreadyRendering(): boolean { ); } -export function isAlreadyRenderingProd() { +export function isInvalidExecutionContextForEventFunction() { // Used to throw if certain APIs are called from the wrong context. return (executionContext & RenderContext) !== NoContext; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 60f64ccb34c1a..d8d29c496495e 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1605,7 +1605,7 @@ export function isAlreadyRendering(): boolean { ); } -export function isAlreadyRenderingProd() { +export function isInvalidExecutionContextForEventFunction() { // Used to throw if certain APIs are called from the wrong context. return (executionContext & RenderContext) !== NoContext; } From eaa47a706ba277a0604cd73cd87de1623a578a6d Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 13 Sep 2022 14:48:10 -0400 Subject: [PATCH 6/9] Drop .internal from useEvent-test.js --- .../src/__tests__/{useEvent-test.internal.js => useEvent-test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-reconciler/src/__tests__/{useEvent-test.internal.js => useEvent-test.js} (100%) diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.internal.js b/packages/react-reconciler/src/__tests__/useEvent-test.js similarity index 100% rename from packages/react-reconciler/src/__tests__/useEvent-test.internal.js rename to packages/react-reconciler/src/__tests__/useEvent-test.js From a4af79723c44790172cf20c9e6fe3b172dd56eea Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 13 Sep 2022 16:30:43 -0400 Subject: [PATCH 7/9] Add test for `this` context --- .../src/ReactFiberHooks.new.js | 2 +- .../src/ReactFiberHooks.old.js | 2 +- .../src/__tests__/useEvent-test.js | 46 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 7d3c867077e97..2f3f9a4761b66 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -1879,7 +1879,7 @@ function mountEvent(callback: () => T): () => T { if (isInvalidExecutionContextForEventFunction()) { throw new Error('An event from useEvent was called during render.'); } - return ref.current.apply(this, arguments); + 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 diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 1cce640736920..1b41711186626 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -1879,7 +1879,7 @@ function mountEvent(callback: () => T): () => T { if (isInvalidExecutionContextForEventFunction()) { throw new Error('An event from useEvent was called during render.'); } - return ref.current.apply(this, arguments); + 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 diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index 379d22e12ad06..87f178ca86da8 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -119,6 +119,52 @@ describe('useEvent', () => { ]); }); + // @gate enableUseEventHook + it('does not preserve `this` in event functions', () => { + class GreetButton extends React.PureComponent { + greet = () => { + this.props.onClick(); + }; + render() { + return ; + } + } + function Greeter({hello}) { + const person = { + toString() { + return 'Jane'; + }, + greet() { + return updateGreeting(this + ' says ' + hello); + }, + }; + const [greeting, updateGreeting] = useState('Seb says ' + hello); + const onClick = useEvent(person.greet); + + return ( + <> + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Say hej', 'Greeting: Seb says hej']); + expect(ReactNoop.getChildren()).toEqual([ + span('Say hej'), + span('Greeting: Seb says hej'), + ]); + + act(button.current.greet); + expect(Scheduler).toHaveYielded(['Greeting: undefined says hej']); + expect(ReactNoop.getChildren()).toEqual([ + span('Say hej'), + span('Greeting: undefined says hej'), + ]); + }); + // @gate enableUseEventHook it('throws when called in render', () => { class IncrementButton extends React.PureComponent { From 691b1bd2a585d82898291808dba9954c9ee89f41 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Tue, 13 Sep 2022 16:32:16 -0400 Subject: [PATCH 8/9] Remove todo comment --- packages/react-reconciler/src/__tests__/useEvent-test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index 87f178ca86da8..375c0d914f5d6 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -197,7 +197,6 @@ describe('useEvent', () => { 'An event from useEvent was called during render', ); - // TODO: Why? expect(Scheduler).toHaveYielded(['Count: 0', 'Count: 0']); }); From 7583f13ef6a1db8ab6f714c7d1e16fb56eba7f08 Mon Sep 17 00:00:00 2001 From: Lauren Tan Date: Wed, 14 Sep 2022 14:26:30 -0400 Subject: [PATCH 9/9] Update tests to pass event function via a closure instead --- .../src/__tests__/useEvent-test.js | 66 +++++++++++++------ 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/useEvent-test.js b/packages/react-reconciler/src/__tests__/useEvent-test.js index 375c0d914f5d6..39c0f9f316ef3 100644 --- a/packages/react-reconciler/src/__tests__/useEvent-test.js +++ b/packages/react-reconciler/src/__tests__/useEvent-test.js @@ -68,7 +68,7 @@ describe('useEvent', () => { return ( <> - + onClick()} ref={button} /> ); @@ -83,10 +83,7 @@ describe('useEvent', () => { ]); act(button.current.increment); - expect(Scheduler).toHaveYielded([ - // Button should not re-render, because its props haven't changed - 'Count: 1', - ]); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 1']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 1'), @@ -94,6 +91,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Event should use the updated callback function closed over the new value. 'Count: 2', ]); @@ -104,7 +102,7 @@ describe('useEvent', () => { // Increase the increment prop amount ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 2']); + expect(Scheduler).toFlushAndYield(['Increment', 'Count: 2']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 2'), @@ -112,7 +110,7 @@ describe('useEvent', () => { // Event uses the new prop act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 12']); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 12']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 12'), @@ -143,7 +141,7 @@ describe('useEvent', () => { return ( <> - + onClick()} ref={button} /> ); @@ -158,7 +156,10 @@ describe('useEvent', () => { ]); act(button.current.greet); - expect(Scheduler).toHaveYielded(['Greeting: undefined says hej']); + expect(Scheduler).toHaveYielded([ + 'Say hej', + 'Greeting: undefined says hej', + ]); expect(ReactNoop.getChildren()).toEqual([ span('Say hej'), span('Greeting: undefined says hej'), @@ -186,7 +187,7 @@ describe('useEvent', () => { return ( <> - + onClick()} /> ); @@ -197,6 +198,8 @@ describe('useEvent', () => { 'An event from useEvent was called during render', ); + // If something throws, we try one more time synchronously in case the error was + // caused by a data race. See recoverFromConcurrentError expect(Scheduler).toHaveYielded(['Count: 0', 'Count: 0']); }); @@ -224,7 +227,7 @@ describe('useEvent', () => { return ( <> - + increment()} ref={button} /> ); @@ -237,6 +240,7 @@ describe('useEvent', () => { 'Increment', 'Count: 0', 'Effect: by 2', + 'Increment', 'Count: 2', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -246,6 +250,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Effect should not re-run because the dependency hasn't changed. 'Count: 3', ]); @@ -256,6 +261,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Event should use the updated callback function closed over the new value. 'Count: 4', ]); @@ -267,8 +273,10 @@ describe('useEvent', () => { // Increase the increment prop amount ReactNoop.render(); expect(Scheduler).toFlushAndYield([ + 'Increment', 'Count: 4', 'Effect: by 20', + 'Increment', 'Count: 24', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -278,7 +286,7 @@ describe('useEvent', () => { // Event uses the new prop act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 34']); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 34']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 34'), @@ -309,7 +317,7 @@ describe('useEvent', () => { return ( <> - + increment()} ref={button} /> ); @@ -321,6 +329,7 @@ describe('useEvent', () => { 'Increment', 'Count: 0', 'Effect: by 2', + 'Increment', 'Count: 2', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -330,6 +339,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Effect should not re-run because the dependency hasn't changed. 'Count: 3', ]); @@ -340,6 +350,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Event should use the updated callback function closed over the new value. 'Count: 4', ]); @@ -351,8 +362,10 @@ describe('useEvent', () => { // Increase the increment prop amount ReactNoop.render(); expect(Scheduler).toFlushAndYield([ + 'Increment', 'Count: 4', 'Effect: by 20', + 'Increment', 'Count: 24', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -362,7 +375,7 @@ describe('useEvent', () => { // Event uses the new prop act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 34']); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 34']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 34'), @@ -399,7 +412,7 @@ describe('useEvent', () => { return ( <> - + increment()} ref={button} /> ); @@ -411,6 +424,7 @@ describe('useEvent', () => { 'Increment', 'Count: 0', 'Effect: by 2', + 'Increment', 'Count: 2', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -420,6 +434,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Effect should not re-run because the dependency hasn't changed. 'Count: 3', ]); @@ -430,6 +445,7 @@ describe('useEvent', () => { act(button.current.increment); expect(Scheduler).toHaveYielded([ + 'Increment', // Event should use the updated callback function closed over the new value. 'Count: 4', ]); @@ -441,8 +457,10 @@ describe('useEvent', () => { // Increase the increment prop amount ReactNoop.render(); expect(Scheduler).toFlushAndYield([ + 'Increment', 'Count: 4', 'Effect: by 20', + 'Increment', 'Count: 24', ]); expect(ReactNoop.getChildren()).toEqual([ @@ -452,7 +470,7 @@ describe('useEvent', () => { // Event uses the new prop act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 34']); + expect(Scheduler).toHaveYielded(['Increment', 'Count: 34']); expect(ReactNoop.getChildren()).toEqual([ span('Increment'), span('Count: 34'), @@ -610,7 +628,14 @@ describe('useEvent', () => { onVisit(url); }, [url]); - return ; + return ( + { + onClick(); + }} + ref={button} + /> + ); } const button = React.createRef(null); @@ -626,7 +651,7 @@ describe('useEvent', () => { 'url: /shop/1, numberOfItems: 0', ]); act(button.current.addToCart); - expect(Scheduler).toFlushWithoutYielding(); + expect(Scheduler).toHaveYielded(['Add to cart']); act(() => ReactNoop.render( @@ -635,6 +660,9 @@ describe('useEvent', () => { , ), ); - expect(Scheduler).toHaveYielded(['url: /shop/2, numberOfItems: 1']); + expect(Scheduler).toHaveYielded([ + 'Add to cart', + 'url: /shop/2, numberOfItems: 1', + ]); }); });