'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} />;
}
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',
'Count: 2',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 2" />
</>,
);
ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll(['Increment', 'Count: 2']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 2" />
</>,
);
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 12']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 12" />
</>,
);
});
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" />
</>,
);
});
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" />
</>,
);
});
it('throws when called in render', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {
this.props.onClick();
};
render() {
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([]);
});
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',
'Count: 3',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 3" />
</>,
);
await act(() => button.current.increment());
assertLog([
'Increment',
'Count: 4',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 4" />
</>,
);
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" />
</>,
);
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 34" />
</>,
);
});
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',
'Count: 3',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 3" />
</>,
);
await act(() => button.current.increment());
assertLog([
'Increment',
'Count: 4',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 4" />
</>,
);
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" />
</>,
);
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 34" />
</>,
);
});
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',
'Count: 3',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 3" />
</>,
);
await act(() => button.current.increment());
assertLog([
'Increment',
'Count: 4',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 4" />
</>,
);
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" />
</>,
);
await act(() => button.current.increment());
assertLog(['Increment', 'Count: 34']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 34" />
</>,
);
});
it('is mutated before all other effects', async () => {
function Counter({value}) {
useInsertionEffect(() => {
Scheduler.log('Effect value: ' + value);
increment();
}, [value]);
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']);
});
it("doesn't provide a stable identity", async () => {
function Counter({shouldRender, value}) {
const onClick = useEffectEvent(() => {
Scheduler.log(
'onClick, shouldRender=' + shouldRender + ', value=' + value,
);
});
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',
]);
});
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;
});
useEffect(
() => {
Scheduler.log('Commit new event handler');
committedEventHandler = event;
return () => {
committedEventHandler = null;
};
},
[],
);
return 'Latest rendered value ' + value;
}
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');
await act(() => {
root.render(<App value={2} />);
});
assertLog([]);
expect(root).toMatchRenderedOutput('Latest rendered value 2');
expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 2');
});
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" />),
);
assertLog(['Welcome to the general room!', 'Connected! theme: light']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Welcome to the general room!" />,
);
await act(() =>
ReactNoop.render(<ChatRoom roomId="music" theme="light" />),
);
assertLog([
'Welcome to the music room!',
'Connected! theme: light',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Welcome to the music room!" />,
);
await act(() => ReactNoop.render(<ChatRoom roomId="music" theme="dark" />));
assertLog(['Welcome to the music room!']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Welcome to the music room!" />,
);
await act(() =>
ReactNoop.render(<ChatRoom roomId="travel" theme="dark" />),
);
assertLog([
'Welcome to the travel room!',
'Connected! theme: dark',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Welcome to the travel room!" />,
);
});
it('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 (
<AddToCartButton
onClick={() => {
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']);
});
});