/*** Copyright (c) Meta Platforms, Inc. and 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';
import {useInsertionEffect} from 'react';
describe('useEffectEvent', () => {
let React;
let ReactNoop;
let Scheduler;
let act;
let createContext;
let useContext;
let useState;
let useEffectEvent;
let useEffect;
let useLayoutEffect;
let useMemo;
let waitForAll;
let assertLog;
let waitForThrow;
beforeEach(() => {
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
createContext = React.createContext;
useContext = React.useContext;
useState = React.useState;
useEffectEvent = React.experimental_useEffectEvent;
useEffect = React.useEffect;
useLayoutEffect = React.useLayoutEffect;
useMemo = React.useMemo;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
waitForThrow = InternalTestUtils.waitForThrow;
});function Text(props) {
Scheduler.log(props.text);
return <span prop={props.text} />;
}// @gate enableUseEffectEventHook
it('memoizes basic case correctly', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
render() {
return <Text text="Increment" />;
}}function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
return (
<><IncrementButton onClick={() => onClick()} ref={button} />
<Text text={'Count: ' + count} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Counter incrementBy={1} />);
await waitForAll(['Increment', 'Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 0" /></>,);await act(() => button.current.increment());
assertLog(['Increment', 'Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 1" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Event should use the updated callback function closed over the new value.'Count: 2',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 2" /></>,);// Increase the increment prop amount
ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll(['Increment', 'Count: 2']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 2" />
</>,
);// Event uses the new prop
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 12']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 12" />
</>,
);});// @gate enableUseEffectEventHook
it('can be defined more than once', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
multiply = () => {
this.props.onMouseEnter();
};render() {
return <Text text="Increment" />;
}}function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
const onMouseEnter = useEffectEvent(() => {
updateCount(c => c * incrementBy);
});return (
<><IncrementButton
onClick={() => onClick()}
onMouseEnter={() => onMouseEnter()}
ref={button}
/><Text text={'Count: ' + count} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Counter incrementBy={5} />);
await waitForAll(['Increment', 'Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 0" /></>,);await act(() => button.current.increment());
assertLog(['Increment', 'Count: 5']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 5" /></>,);await act(() => button.current.multiply());
assertLog(['Increment', 'Count: 25']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 25" /></>,);});// @gate enableUseEffectEventHook
it('does not preserve `this` in event functions', async () => {
class GreetButton extends React.PureComponent {
greet = () => {this.props.onClick();
};
render() {
return <Text text={'Say ' + this.props.hello} />;
}}function Greeter({hello}) {
const person = {
toString() {
return 'Jane';
},greet() {
return updateGreeting(this + ' says ' + hello);
},};const [greeting, updateGreeting] = useState('Seb says ' + hello);
const onClick = useEffectEvent(person.greet);
return (
<><GreetButton hello={hello} onClick={() => onClick()} ref={button} />
<Text text={'Greeting: ' + greeting} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Greeter hello={'hej'} />);
await waitForAll(['Say hej', 'Greeting: Seb says hej']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Say hej" /><span prop="Greeting: Seb says hej" /></>,);await act(() => button.current.greet());
assertLog(['Say hej', 'Greeting: undefined says hej']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Say hej" /><span prop="Greeting: undefined says hej" /></>,);});// @gate enableUseEffectEventHook
it('throws when called in render', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
render() {
// Will throw.
this.props.onClick();
return <Text text="Increment" />;
}}function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const onClick = useEffectEvent(() => updateCount(c => c + incrementBy));
return (
<><IncrementButton onClick={() => onClick()} />
<Text text={'Count: ' + count} />
</>
);}ReactNoop.render(<Counter incrementBy={1} />);
await waitForThrow("A function wrapped in useEffectEvent can't be called during rendering.",
);assertLog([]);
});// @gate enableUseEffectEventHook
it("useLayoutEffect shouldn't re-fire when event handlers change", async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
render() {
return <Text text="Increment" />;
}}function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const increment = useEffectEvent(amount =>
updateCount(c => c + (amount || incrementBy)),
);useLayoutEffect(() => {
Scheduler.log('Effect: by ' + incrementBy * 2);
increment(incrementBy * 2);
}, [incrementBy]);
return (
<><IncrementButton onClick={() => increment()} ref={button} />
<Text text={'Count: ' + count} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Counter incrementBy={1} />);
assertLog([]);
await waitForAll([
'Increment','Count: 0','Effect: by 2','Increment','Count: 2',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 2" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Effect should not re-run because the dependency hasn't changed.'Count: 3',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 3" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Event should use the updated callback function closed over the new value.'Count: 4',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 4" /></>,);// Increase the increment prop amount
ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll([
'Increment',
'Count: 4',
'Effect: by 20',
'Increment',
'Count: 24',
]);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 24" />
</>,
);// Event uses the new prop
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 34" />
</>,
);});// @gate enableUseEffectEventHook
it("useEffect shouldn't re-fire when event handlers change", async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
render() {
return <Text text="Increment" />;
}}function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const increment = useEffectEvent(amount =>
updateCount(c => c + (amount || incrementBy)),
);useEffect(() => {
Scheduler.log('Effect: by ' + incrementBy * 2);
increment(incrementBy * 2);
}, [incrementBy]);
return (
<><IncrementButton onClick={() => increment()} ref={button} />
<Text text={'Count: ' + count} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Counter incrementBy={1} />);
await waitForAll([
'Increment','Count: 0','Effect: by 2','Increment','Count: 2',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 2" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Effect should not re-run because the dependency hasn't changed.'Count: 3',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 3" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Event should use the updated callback function closed over the new value.'Count: 4',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 4" /></>,);// Increase the increment prop amount
ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll([
'Increment',
'Count: 4',
'Effect: by 20',
'Increment',
'Count: 24',
]);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 24" />
</>,
);// Event uses the new prop
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 34" />
</>,
);});// @gate enableUseEffectEventHook
it('is stable in a custom hook', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {this.props.onClick();
};
render() {
return <Text text="Increment" />;
}}function useCount(incrementBy) {
const [count, updateCount] = useState(0);
const increment = useEffectEvent(amount =>
updateCount(c => c + (amount || incrementBy)),
);return [count, increment];
}function Counter({incrementBy}) {
const [count, increment] = useCount(incrementBy);
useEffect(() => {
Scheduler.log('Effect: by ' + incrementBy * 2);
increment(incrementBy * 2);
}, [incrementBy]);
return (
<><IncrementButton onClick={() => increment()} ref={button} />
<Text text={'Count: ' + count} />
</>
);}const button = React.createRef(null);
ReactNoop.render(<Counter incrementBy={1} />);
await waitForAll([
'Increment','Count: 0','Effect: by 2','Increment','Count: 2',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 2" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Effect should not re-run because the dependency hasn't changed.'Count: 3',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 3" /></>,);await act(() => button.current.increment());
assertLog([
'Increment',// Event should use the updated callback function closed over the new value.'Count: 4',]);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" /><span prop="Count: 4" /></>,);// Increase the increment prop amount
ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll([
'Increment',
'Count: 4',
'Effect: by 20',
'Increment',
'Count: 24',
]);expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 24" />
</>,
);// Event uses the new prop
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<><span prop="Increment" />
<span prop="Count: 34" />
</>,
);});// @gate enableUseEffectEventHook
it('is mutated before all other effects', async () => {
function Counter({value}) {
useInsertionEffect(() => {
Scheduler.log('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 = useEffectEvent(() => {
Scheduler.log('Event value: ' + value);
});return <></>;
}ReactNoop.render(<Counter value={1} />);
await waitForAll(['Effect value: 1', 'Event value: 1']);
await act(() => ReactNoop.render(<Counter value={2} />));
assertLog(['Effect value: 2', 'Event value: 2']);
});// @gate enableUseEffectEventHook
it("doesn't provide a stable identity", async () => {
function Counter({shouldRender, value}) {
const onClick = useEffectEvent(() => {
Scheduler.log(
'onClick, shouldRender=' + shouldRender + ', value=' + value,
);});// onClick doesn't have a stable function identity so this effect will fire on every render.
// In a real app useEffectEvent functions should *not* be passed as a dependency, this is for
// testing purposes only.
useEffect(() => {
onClick();
}, [onClick]);
useEffect(() => {
onClick();
}, [shouldRender]);
return <></>;
}ReactNoop.render(<Counter shouldRender={true} value={0} />);
await waitForAll([
'onClick, shouldRender=true, value=0','onClick, shouldRender=true, value=0',]);
ReactNoop.render(<Counter shouldRender={true} value={1} />);
await waitForAll(['onClick, shouldRender=true, value=1']);
ReactNoop.render(<Counter shouldRender={false} value={2} />);
await waitForAll([
'onClick, shouldRender=false, value=2','onClick, shouldRender=false, value=2',]);
});// @gate enableUseEffectEventHook
it('event handlers always see the latest committed value', async () => {
let committedEventHandler = null;
function App({value}) {
const event = useEffectEvent(() => {
return 'Value seen by useEffectEvent: ' + value;
});// Set up an effect that registers the event handler with an external
// event system (e.g. addEventListener).
useEffect(
() => {
// Log when the effect fires. In the test below, we'll assert that this
// only happens during initial render, not during updates.
Scheduler.log('Commit new event handler');
committedEventHandler = event;
return () => {
committedEventHandler = null;
};},// Note that we've intentionally omitted the event from the dependency
// array. But it will still be able to see the latest `value`. This is the
// key feature of useEffectEvent that makes it different from a regular closure.
[],);return 'Latest rendered value ' + value;
}// Initial render
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App value={1} />);
});assertLog(['Commit new event handler']);
expect(root).toMatchRenderedOutput('Latest rendered value 1');
expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 1');
// Update
await act(() => {
root.render(<App value={2} />);
});// No new event handler should be committed, because it was omitted from
// the dependency array.
assertLog([]);
// But the event handler should still be able to see the latest value.
expect(root).toMatchRenderedOutput('Latest rendered value 2');
expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 2');
});// @gate enableUseEffectEventHook
it('integration: implements docs chat room example', async () => {
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 = useEffectEvent(() => {
Scheduler.log('Connected! theme: ' + theme);
});useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected();
});connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <Text text={`Welcome to the ${roomId} room!`} />;
}await act(() =>ReactNoop.render(<ChatRoom roomId="general" theme="light" />),);await act(() => jest.runAllTimers());assertLog(['Welcome to the general room!', 'Connected! theme: light']);expect(ReactNoop).toMatchRenderedOutput(<span prop="Welcome to the general room!" />,);// change roomId onlyawait act(() =>ReactNoop.render(<ChatRoom roomId="music" theme="light" />),);await act(() => jest.runAllTimers());assertLog(['Welcome to the music room!',// should trigger a reconnect'Connected! theme: light',]);expect(ReactNoop).toMatchRenderedOutput(<span prop="Welcome to the music room!" />,);// change theme onlyawait act(() => ReactNoop.render(<ChatRoom roomId="music" theme="dark" />));await act(() => jest.runAllTimers());// should not trigger a reconnectassertLog(['Welcome to the music room!']);expect(ReactNoop).toMatchRenderedOutput(<span prop="Welcome to the music room!" />,);// change roomId onlyawait act(() =>ReactNoop.render(<ChatRoom roomId="travel" theme="dark" />),);await act(() => jest.runAllTimers());assertLog(['Welcome to the travel room!',// should trigger a reconnect'Connected! theme: dark',]);expect(ReactNoop).toMatchRenderedOutput(<span prop="Welcome to the travel room!" />,);});// @gate enableUseEffectEventHookit('integration: implements the docs logVisit example', async () => {class AddToCartButton extends React.PureComponent {addToCart = () => {this.props.onClick();};render() {return <Text text="Add to cart" />;}}const ShoppingCartContext = createContext(null);function AppShell({children}) {const [items, updateItems] = useState([]);const value = useMemo(() => ({items, updateItems}), [items, updateItems]);return (<ShoppingCartContext.Provider value={value}>{children}</ShoppingCartContext.Provider>);}function Page({url}) {const {items, updateItems} = useContext(ShoppingCartContext);const onClick = useEffectEvent(() => updateItems([...items, 1]));const numberOfItems = items.length;const onVisit = useEffectEvent(visitedUrl => {Scheduler.log('url: ' + visitedUrl + ', numberOfItems: ' + numberOfItems,);});useEffect(() => {onVisit(url);}, [url]);return (<AddToCartButtononClick={() => {onClick();}}ref={button}/>);}const button = React.createRef(null);await act(() =>ReactNoop.render(<AppShell><Page url="/shop/1" /></AppShell>,),);assertLog(['Add to cart', 'url: /shop/1, numberOfItems: 0']);await act(() => button.current.addToCart());assertLog(['Add to cart']);await act(() =>ReactNoop.render(<AppShell><Page url="/shop/2" /></AppShell>,),);assertLog(['Add to cart', 'url: /shop/2, numberOfItems: 1']);});});