[go: up one dir, main page]

0% found this document useful (0 votes)
51 views75 pages

ReactHooksWithNoopRenderer Test

The document describes tests for React hooks using a noop renderer. It tests basic usage of state, effect, and imperative handles. It also tests invalid usage like inside classes or outside render.

Uploaded by

mahoraga002
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
51 views75 pages

ReactHooksWithNoopRenderer Test

The document describes tests for React hooks using a noop renderer. It tests basic usage of state, effect, and imperative handles. It also tests invalid usage like inside classes or outside render.

Uploaded by

mahoraga002
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 75

/**

* 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';

let React;
let textCache;
let readText;
let resolveText;
let ReactNoop;
let Scheduler;
let Suspense;
let useState;
let useReducer;
let useEffect;
let useInsertionEffect;
let useLayoutEffect;
let useCallback;
let useMemo;
let useRef;
let useImperativeHandle;
let useTransition;
let useDeferredValue;
let forwardRef;
let memo;
let act;
let ContinuousEventPriority;
let SuspenseList;
let waitForAll;
let waitFor;
let waitForThrow;
let waitForPaint;
let assertLog;

describe('ReactHooksWithNoopRenderer', () => {
beforeEach(() => {
jest.resetModules();
jest.useFakeTimers();

React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
useState = React.useState;
useReducer = React.useReducer;
useEffect = React.useEffect;
useInsertionEffect = React.useInsertionEffect;
useLayoutEffect = React.useLayoutEffect;
useCallback = React.useCallback;
useMemo = React.useMemo;
useRef = React.useRef;
useImperativeHandle = React.useImperativeHandle;
forwardRef = React.forwardRef;
memo = React.memo;
useTransition = React.useTransition;
useDeferredValue = React.useDeferredValue;
Suspense = React.Suspense;
ContinuousEventPriority =
require('react-reconciler/constants').ContinuousEventPriority;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}

const InternalTestUtils = require('internal-test-utils');


waitForAll = InternalTestUtils.waitForAll;
waitFor = InternalTestUtils.waitFor;
waitForThrow = InternalTestUtils.waitForThrow;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;

textCache = new Map();

readText = text => {


const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
throw record.promise;
case 'rejected':
throw Error('Failed to load: ' + text);
case 'resolved':
return text;
}
} else {
let ping;
const promise = new Promise(resolve => (ping = resolve));
const newRecord = {
status: 'pending',
ping: ping,
promise,
};
textCache.set(text, newRecord);
throw promise;
}
};

resolveText = text => {


const record = textCache.get(text);
if (record !== undefined) {
if (record.status === 'pending') {
Scheduler.log(`Promise resolved [${text}]`);
record.ping();
record.ping = null;
record.status = 'resolved';
clearTimeout(record.promise._timer);
record.promise = null;
}
} else {
const newRecord = {
ping: null,
status: 'resolved',
promise: null,
};
textCache.set(text, newRecord);
}
};
});

function Text(props) {
Scheduler.log(props.text);
return <span prop={props.text} />;
}

function AsyncText(props) {
const text = props.text;
try {
readText(text);
Scheduler.log(text);
return <span prop={text} />;
} catch (promise) {
if (typeof promise.then === 'function') {
Scheduler.log(`Suspend! [${text}]`);
if (typeof props.ms === 'number' && promise._timer === undefined) {
promise._timer = setTimeout(() => {
resolveText(text);
}, props.ms);
}
} else {
Scheduler.log(`Error! [${text}]`);
}
throw promise;
}
}

function advanceTimers(ms) {
// Note: This advances Jest's virtual time but not React's. Use
// ReactNoop.expire for that.
if (typeof ms !== 'number') {
throw new Error('Must specify ms');
}
jest.advanceTimersByTime(ms);
// Wait until the end of the current tick
// We cannot use a timer since we're faking them
return Promise.resolve().then(() => {});
}

it('resumes after an interruption', async () => {


function Counter(props, ref) {
const [count, updateCount] = useState(0);
useImperativeHandle(ref, () => ({updateCount}));
return <Text text={props.label + ': ' + count} />;
}
Counter = forwardRef(Counter);

// Initial mount
const counter = React.createRef(null);
ReactNoop.render(<Counter label="Count" ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

// Schedule some updates


await act(async () => {
React.startTransition(() => {
counter.current.updateCount(1);
counter.current.updateCount(count => count + 10);
});

// Partially flush without committing


await waitFor(['Count: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

// Interrupt with a high priority update


ReactNoop.flushSync(() => {
ReactNoop.render(<Counter label="Total" />);
});
assertLog(['Total: 0']);

// Resume rendering
await waitForAll(['Total: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Total: 11" />);
});
});

it('throws inside class components', async () => {


class BadCounter extends React.Component {
render() {
const [count] = useState(0);
return <Text text={this.props.label + ': ' + count} />;
}
}
ReactNoop.render(<BadCounter />);

await waitForThrow(
'Invalid hook call. Hooks can only be called inside of the body of a function
component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as
React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug
and fix this problem.',
);

// Confirm that a subsequent hook works properly.


function GoodCounter(props, ref) {
const [count] = useState(props.initialCount);
return <Text text={count} />;
}
ReactNoop.render(<GoodCounter initialCount={10} />);
await waitForAll([10]);
});

// @gate !disableModulePatternComponents
it('throws inside module-style components', async () => {
function Counter() {
return {
render() {
const [count] = useState(0);
return <Text text={this.props.label + ': ' + count} />;
},
};
}
ReactNoop.render(<Counter />);
await expect(
async () =>
await waitForThrow(
'Invalid hook call. Hooks can only be called inside of the body of a
function component. This could happen ' +
'for one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such
as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to
debug and fix this problem.',
),
).toErrorDev(
'Warning: The <Counter /> component appears to be a function component that
returns a class instance. ' +
'Change Counter to a class that extends React.Component instead. ' +
"If you can't use a class try assigning the prototype on the function as a
workaround. " +
'`Counter.prototype = React.Component.prototype`. ' +
"Don't use an arrow function since it cannot be called with `new` by
React.",
);

// Confirm that a subsequent hook works properly.


function GoodCounter(props) {
const [count] = useState(props.initialCount);
return <Text text={count} />;
}
ReactNoop.render(<GoodCounter initialCount={10} />);
await waitForAll([10]);
});

it('throws when called outside the render phase', async () => {


expect(() => {
expect(() => useState(0)).toThrow(
"Cannot read property 'useState' of null",
);
}).toErrorDev(
'Invalid hook call. Hooks can only be called inside of the body of a function
component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as
React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug
and fix this problem.',
{withoutStack: true},
);
});
describe('useState', () => {
it('simple mount and update', async () => {
function Counter(props, ref) {
const [count, updateCount] = useState(0);
useImperativeHandle(ref, () => ({updateCount}));
return <Text text={'Count: ' + count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

await act(() => counter.current.updateCount(1));


assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);

await act(() => counter.current.updateCount(count => count + 10));


assertLog(['Count: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);
});

it('lazy state initializer', async () => {


function Counter(props, ref) {
const [count, updateCount] = useState(() => {
Scheduler.log('getInitialState');
return props.initialState;
});
useImperativeHandle(ref, () => ({updateCount}));
return <Text text={'Count: ' + count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter initialState={42} ref={counter} />);
await waitForAll(['getInitialState', 'Count: 42']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 42" />);

await act(() => counter.current.updateCount(7));


assertLog(['Count: 7']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 7" />);
});

it('multiple states', async () => {


function Counter(props, ref) {
const [count, updateCount] = useState(0);
const [label, updateLabel] = useState('Count');
useImperativeHandle(ref, () => ({updateCount, updateLabel}));
return <Text text={label + ': ' + count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

await act(() => counter.current.updateCount(7));


assertLog(['Count: 7']);

await act(() => counter.current.updateLabel('Total'));


assertLog(['Total: 7']);
});

it('returns the same updater function every time', async () => {


let updater = null;
function Counter() {
const [count, updateCount] = useState(0);
updater = updateCount;
return <Text text={'Count: ' + count} />;
}
ReactNoop.render(<Counter />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

const firstUpdater = updater;

await act(() => firstUpdater(1));


assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);

const secondUpdater = updater;

await act(() => firstUpdater(count => count + 10));


assertLog(['Count: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);

expect(firstUpdater).toBe(secondUpdater);
});

it('does not warn on set after unmount', async () => {


let _updateCount;
function Counter(props, ref) {
const [, updateCount] = useState(0);
_updateCount = updateCount;
return null;
}

ReactNoop.render(<Counter />);
await waitForAll([]);
ReactNoop.render(null);
await waitForAll([]);
await act(() => _updateCount(1));
});

it('works with memo', async () => {


let _updateCount;
function Counter(props) {
const [count, updateCount] = useState(0);
_updateCount = updateCount;
return <Text text={'Count: ' + count} />;
}
Counter = memo(Counter);

ReactNoop.render(<Counter />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

ReactNoop.render(<Counter />);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

await act(() => _updateCount(1));


assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});
});

describe('updates during the render phase', () => {


it('restarts the render function and applies the new updates on top', async ()
=> {
function ScrollView({row: newRow}) {
const [isScrollingDown, setIsScrollingDown] = useState(false);
const [row, setRow] = useState(null);

if (row !== newRow) {


// Row changed since last render. Update isScrollingDown.
setIsScrollingDown(row !== null && newRow > row);
setRow(newRow);
}

return <Text text={`Scrolling down: ${isScrollingDown}`} />;


}

ReactNoop.render(<ScrollView row={1} />);


await waitForAll(['Scrolling down: false']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: false" />,
);

ReactNoop.render(<ScrollView row={5} />);


await waitForAll(['Scrolling down: true']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: true" />,
);

ReactNoop.render(<ScrollView row={5} />);


await waitForAll(['Scrolling down: true']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: true" />,
);

ReactNoop.render(<ScrollView row={10} />);


await waitForAll(['Scrolling down: true']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: true" />,
);

ReactNoop.render(<ScrollView row={2} />);


await waitForAll(['Scrolling down: false']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: false" />,
);

ReactNoop.render(<ScrollView row={2} />);


await waitForAll(['Scrolling down: false']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Scrolling down: false" />,
);
});

it('warns about render phase update on a different component', async () => {


let setStep;
function Foo() {
const [step, _setStep] = useState(0);
setStep = _setStep;
return <Text text={`Foo [${step}]`} />;
}

function Bar({triggerUpdate}) {
if (triggerUpdate) {
setStep(x => x + 1);
}
return <Text text="Bar" />;
}

const root = ReactNoop.createRoot();

await act(() => {


root.render(
<>
<Foo />
<Bar />
</>,
);
});
assertLog(['Foo [0]', 'Bar']);

// Bar will update Foo during its render phase. React should warn.
root.render(
<>
<Foo />
<Bar triggerUpdate={true} />
</>,
);
await expect(
async () => await waitForAll(['Foo [0]', 'Bar', 'Foo [1]']),
).toErrorDev([
'Cannot update a component (`Foo`) while rendering a ' +
'different component (`Bar`). To locate the bad setState() call inside
`Bar`',
]);

// It should not warn again (deduplication).


await act(async () => {
root.render(
<>
<Foo />
<Bar triggerUpdate={true} />
</>,
);
await waitForAll(['Foo [1]', 'Bar', 'Foo [2]']);
});
});

it('keeps restarting until there are no more new updates', async () => {
function Counter({row: newRow}) {
const [count, setCount] = useState(0);
if (count < 3) {
setCount(count + 1);
}
Scheduler.log('Render: ' + count);
return <Text text={count} />;
}

ReactNoop.render(<Counter />);
await waitForAll(['Render: 0', 'Render: 1', 'Render: 2', 'Render: 3', 3]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={3} />);
});

it('updates multiple times within same render function', async () => {


function Counter({row: newRow}) {
const [count, setCount] = useState(0);
if (count < 12) {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
Scheduler.log('Render: ' + count);
return <Text text={count} />;
}

ReactNoop.render(<Counter />);
await waitForAll([
// Should increase by three each time
'Render: 0',
'Render: 3',
'Render: 6',
'Render: 9',
'Render: 12',
12,
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={12} />);
});

it('throws after too many iterations', async () => {


function Counter({row: newRow}) {
const [count, setCount] = useState(0);
setCount(count + 1);
Scheduler.log('Render: ' + count);
return <Text text={count} />;
}
ReactNoop.render(<Counter />);
await waitForThrow(
'Too many re-renders. React limits the number of renders to prevent ' +
'an infinite loop.',
);
});

it('works with useReducer', async () => {


function reducer(state, action) {
return action === 'increment' ? state + 1 : state;
}
function Counter({row: newRow}) {
const [count, dispatch] = useReducer(reducer, 0);
if (count < 3) {
dispatch('increment');
}
Scheduler.log('Render: ' + count);
return <Text text={count} />;
}

ReactNoop.render(<Counter />);
await waitForAll(['Render: 0', 'Render: 1', 'Render: 2', 'Render: 3', 3]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={3} />);
});

it('uses reducer passed at time of render, not time of dispatch', async () => {
// This test is a bit contrived but it demonstrates a subtle edge case.

// Reducer A increments by 1. Reducer B increments by 10.


function reducerA(state, action) {
switch (action) {
case 'increment':
return state + 1;
case 'reset':
return 0;
}
}
function reducerB(state, action) {
switch (action) {
case 'increment':
return state + 10;
case 'reset':
return 0;
}
}

function Counter({row: newRow}, ref) {


const [reducer, setReducer] = useState(() => reducerA);
const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(ref, () => ({dispatch}));
if (count < 20) {
dispatch('increment');
// Swap reducers each time we increment
if (reducer === reducerA) {
setReducer(() => reducerB);
} else {
setReducer(() => reducerA);
}
}
Scheduler.log('Render: ' + count);
return <Text text={count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll([
// The count should increase by alternating amounts of 10 and 1
// until we reach 21.
'Render: 0',
'Render: 10',
'Render: 11',
'Render: 21',
21,
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={21} />);

// Test that it works on update, too. This time the log is a bit different
// because we started with reducerB instead of reducerA.
await act(() => {
counter.current.dispatch('reset');
});
ReactNoop.render(<Counter ref={counter} />);
assertLog([
'Render: 0',
'Render: 1',
'Render: 11',
'Render: 12',
'Render: 22',
22,
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={22} />);
});

it('discards render phase updates if something suspends', async () => {


const thenable = {then() {}};
function Foo({signal}) {
return (
<Suspense fallback="Loading...">
<Bar signal={signal} />
</Suspense>
);
}

function Bar({signal: newSignal}) {


const [counter, setCounter] = useState(0);
const [signal, setSignal] = useState(true);

// Increment a counter every time the signal changes


if (signal !== newSignal) {
setCounter(c => c + 1);
setSignal(newSignal);
if (counter === 0) {
// We're suspending during a render that includes render phase
// updates. Those updates should not persist to the next render.
Scheduler.log('Suspend!');
throw thenable;
}
}

return <Text text={counter} />;


}

const root = ReactNoop.createRoot();


root.render(<Foo signal={true} />);

await waitForAll([0]);
expect(root).toMatchRenderedOutput(<span prop={0} />);

React.startTransition(() => {
root.render(<Foo signal={false} />);
});
await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput(<span prop={0} />);
// Rendering again should suspend again.
React.startTransition(() => {
root.render(<Foo signal={false} />);
});
await waitForAll(['Suspend!']);
});

it('discards render phase updates if something suspends, but not other updates
in the same component', async () => {
const thenable = {then() {}};
function Foo({signal}) {
return (
<Suspense fallback="Loading...">
<Bar signal={signal} />
</Suspense>
);
}

let setLabel;
function Bar({signal: newSignal}) {
const [counter, setCounter] = useState(0);

if (counter === 1) {
// We're suspending during a render that includes render phase
// updates. Those updates should not persist to the next render.
Scheduler.log('Suspend!');
throw thenable;
}

const [signal, setSignal] = useState(true);

// Increment a counter every time the signal changes


if (signal !== newSignal) {
setCounter(c => c + 1);
setSignal(newSignal);
}

const [label, _setLabel] = useState('A');


setLabel = _setLabel;

return <Text text={`${label}:${counter}`} />;


}

const root = ReactNoop.createRoot();


root.render(<Foo signal={true} />);

await waitForAll(['A:0']);
expect(root).toMatchRenderedOutput(<span prop="A:0" />);

await act(async () => {


React.startTransition(() => {
root.render(<Foo signal={false} />);
setLabel('B');
});

await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
// Rendering again should suspend again.
React.startTransition(() => {
root.render(<Foo signal={false} />);
});
await waitForAll(['Suspend!']);

// Flip the signal back to "cancel" the update. However, the update to
// label should still proceed. It shouldn't have been dropped.
React.startTransition(() => {
root.render(<Foo signal={true} />);
});
await waitForAll(['B:0']);
expect(root).toMatchRenderedOutput(<span prop="B:0" />);
});
});

it('regression: render phase updates cause lower pri work to be dropped', async
() => {
let setRow;
function ScrollView() {
const [row, _setRow] = useState(10);
setRow = _setRow;

const [scrollDirection, setScrollDirection] = useState('Up');


const [prevRow, setPrevRow] = useState(null);

if (prevRow !== row) {


setScrollDirection(prevRow !== null && row > prevRow ? 'Down' : 'Up');
setPrevRow(row);
}

return <Text text={scrollDirection} />;


}

const root = ReactNoop.createRoot();

await act(() => {


root.render(<ScrollView row={10} />);
});
assertLog(['Up']);
expect(root).toMatchRenderedOutput(<span prop="Up" />);

await act(() => {


ReactNoop.discreteUpdates(() => {
setRow(5);
});
React.startTransition(() => {
setRow(20);
});
});
assertLog(['Up', 'Down']);
expect(root).toMatchRenderedOutput(<span prop="Down" />);
});

// TODO: This should probably warn


it('calling startTransition inside render phase', async () => {
function App() {
const [counter, setCounter] = useState(0);
if (counter === 0) {
React.startTransition(() => {
setCounter(c => c + 1);
});
}

return <Text text={counter} />;


}

const root = ReactNoop.createRoot();


root.render(<App />);
await waitForAll([1]);
expect(root).toMatchRenderedOutput(<span prop={1} />);
});
});

describe('useReducer', () => {
it('simple mount and update', async () => {
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

function reducer(state, action) {


switch (action) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}

function Counter(props, ref) {


const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(ref, () => ({dispatch}));
return <Text text={'Count: ' + count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

await act(() => counter.current.dispatch(INCREMENT));


assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
await act(() => {
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
});

assertLog(['Count: -2']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: -2" />);
});

it('lazy init', async () => {


const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
function reducer(state, action) {
switch (action) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}

function Counter(props, ref) {


const [count, dispatch] = useReducer(reducer, props, p => {
Scheduler.log('Init');
return p.initialCount;
});
useImperativeHandle(ref, () => ({dispatch}));
return <Text text={'Count: ' + count} />;
}
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter initialCount={10} ref={counter} />);
await waitForAll(['Init', 'Count: 10']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 10" />);

await act(() => counter.current.dispatch(INCREMENT));


assertLog(['Count: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 11" />);

await act(() => {


counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
});

assertLog(['Count: 8']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 8" />);
});

// Regression test for https://github.com/facebook/react/issues/14360


it('handles dispatches with mixed priorities', async () => {
const INCREMENT = 'INCREMENT';

function reducer(state, action) {


return action === INCREMENT ? state + 1 : state;
}

function Counter(props, ref) {


const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(ref, () => ({dispatch}));
return <Text text={'Count: ' + count} />;
}

Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);

await waitForAll(['Count: 0']);


expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

ReactNoop.batchedUpdates(() => {
counter.current.dispatch(INCREMENT);
counter.current.dispatch(INCREMENT);
counter.current.dispatch(INCREMENT);
});

ReactNoop.flushSync(() => {
counter.current.dispatch(INCREMENT);
});
if (gate(flags => flags.enableUnifiedSyncLane)) {
assertLog(['Count: 4']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 4" />);
} else {
assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
await waitForAll(['Count: 4']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 4" />);
}
});
});

describe('useEffect', () => {
it('simple mount and update', async () => {
function Counter(props) {
useEffect(() => {
Scheduler.log(`Passive effect [${props.count}]`);
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
// Effects are deferred until after the commit
await waitForAll(['Passive effect [0]']);
});

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
// Effects are deferred until after the commit
await waitForAll(['Passive effect [1]']);
});
});

it('flushes passive effects even with sibling deletions', async () => {


function LayoutEffect(props) {
useLayoutEffect(() => {
Scheduler.log(`Layout effect`);
});
return <Text text="Layout" />;
}
function PassiveEffect(props) {
useEffect(() => {
Scheduler.log(`Passive effect`);
}, []);
return <Text text="Passive" />;
}
const passive = <PassiveEffect key="p" />;
await act(async () => {
ReactNoop.render([<LayoutEffect key="l" />, passive]);
await waitFor(['Layout', 'Passive', 'Layout effect']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Layout" />
<span prop="Passive" />
</>,
);
// Destroying the first child shouldn't prevent the passive effect from
// being executed
ReactNoop.render([passive]);
await waitForAll(['Passive effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Passive" />);
});
// exiting act calls flushPassiveEffects(), but there are none left to flush.
assertLog([]);
});

it('flushes passive effects even if siblings schedule an update', async () => {


function PassiveEffect(props) {
useEffect(() => {
Scheduler.log('Passive effect');
});
return <Text text="Passive" />;
}
function LayoutEffect(props) {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
// Scheduling work shouldn't interfere with the queued passive effect
if (count === 0) {
setCount(1);
}
Scheduler.log('Layout effect ' + count);
});
return <Text text="Layout" />;
}

ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);

await act(async () => {


await waitForAll([
'Passive',
'Layout',
'Layout effect 0',
'Passive effect',
'Layout',
'Layout effect 1',
]);
});

expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Passive" />
<span prop="Layout" />
</>,
);
});

it('flushes passive effects even if siblings schedule a new root', async () =>
{
function PassiveEffect(props) {
useEffect(() => {
Scheduler.log('Passive effect');
}, []);
return <Text text="Passive" />;
}
function LayoutEffect(props) {
useLayoutEffect(() => {
Scheduler.log('Layout effect');
// Scheduling work shouldn't interfere with the queued passive effect
ReactNoop.renderToRootWithID(<Text text="New Root" />, 'root2');
});
return <Text text="Layout" />;
}
await act(async () => {
ReactNoop.render([<PassiveEffect key="p" />, <LayoutEffect key="l" />]);
await waitForAll([
'Passive',
'Layout',
'Layout effect',
'Passive effect',
'New Root',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Passive" />
<span prop="Layout" />
</>,
);
});
});

it(
'flushes effects serially by flushing old effects before flushing ' +
"new ones, if they haven't already fired",
async () => {
function getCommittedText() {
const children = ReactNoop.getChildrenAsJSX();
if (children === null) {
return null;
}
return children.props.prop;
}

function Counter(props) {
useEffect(() => {
Scheduler.log(
`Committed state when effect was fired: ${getCommittedText()}`,
);
});
return <Text text={props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor([0, 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);
// Before the effects have a chance to flush, schedule another update
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor([
// The previous effect flushes before the reconciliation
'Committed state when effect was fired: 0',
1,
'Sync effect',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
});

assertLog(['Committed state when effect was fired: 1']);


},
);

it('defers passive effect destroy functions during unmount', async () => {


function Child({bar, foo}) {
React.useEffect(() => {
Scheduler.log('passive bar create');
return () => {
Scheduler.log('passive bar destroy');
};
}, [bar]);
React.useLayoutEffect(() => {
Scheduler.log('layout bar create');
return () => {
Scheduler.log('layout bar destroy');
};
}, [bar]);
React.useEffect(() => {
Scheduler.log('passive foo create');
return () => {
Scheduler.log('passive foo destroy');
};
}, [foo]);
React.useLayoutEffect(() => {
Scheduler.log('layout foo create');
return () => {
Scheduler.log('layout foo destroy');
};
}, [foo]);
Scheduler.log('render');
return null;
}

await act(async () => {


ReactNoop.render(<Child bar={1} foo={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor([
'render',
'layout bar create',
'layout foo create',
'Sync effect',
]);
// Effects are deferred until after the commit
await waitForAll(['passive bar create', 'passive foo create']);
});

// This update exists to test an internal implementation detail:


// Effects without updating dependencies lose their layout/passive tag during
an update.
await act(async () => {
ReactNoop.render(<Child bar={1} foo={2} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor([
'render',
'layout foo destroy',
'layout foo create',
'Sync effect',
]);
// Effects are deferred until after the commit
await waitForAll(['passive foo destroy', 'passive foo create']);
});

// Unmount the component and verify that passive destroy functions are
deferred until post-commit.
await act(async () => {
ReactNoop.render(null, () => Scheduler.log('Sync effect'));
await waitFor([
'layout bar destroy',
'layout foo destroy',
'Sync effect',
]);
// Effects are deferred until after the commit
await waitForAll(['passive bar destroy', 'passive foo destroy']);
});
});

it('does not warn about state updates for unmounted components with pending
passive unmounts', async () => {
let completePendingRequest = null;
function Component() {
Scheduler.log('Component');
const [didLoad, setDidLoad] = React.useState(false);
React.useLayoutEffect(() => {
Scheduler.log('layout create');
return () => {
Scheduler.log('layout destroy');
};
}, []);
React.useEffect(() => {
Scheduler.log('passive create');
// Mimic an XHR request with a complete handler that updates state.
completePendingRequest = () => setDidLoad(true);
return () => {
Scheduler.log('passive destroy');
};
}, []);
return didLoad;
}

await act(async () => {


ReactNoop.renderToRootWithID(<Component />, 'root', () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Component', 'layout create', 'Sync effect']);
ReactNoop.flushPassiveEffects();
assertLog(['passive create']);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitFor(['layout destroy']);

// Simulate an XHR completing, which will cause a state update-


// but should not log a warning.
completePendingRequest();

ReactNoop.flushPassiveEffects();
assertLog(['passive destroy']);
});
});

it('does not warn about state updates for unmounted components with pending
passive unmounts for alternates', async () => {
let setParentState = null;
const setChildStates = [];

function Parent() {
const [state, setState] = useState(true);
setParentState = setState;
Scheduler.log(`Parent ${state} render`);
useLayoutEffect(() => {
Scheduler.log(`Parent ${state} commit`);
});
if (state) {
return (
<>
<Child label="one" />
<Child label="two" />
</>
);
} else {
return null;
}
}

function Child({label}) {
const [state, setState] = useState(0);
useLayoutEffect(() => {
Scheduler.log(`Child ${label} commit`);
});
useEffect(() => {
setChildStates.push(setState);
Scheduler.log(`Child ${label} passive create`);
return () => {
Scheduler.log(`Child ${label} passive destroy`);
};
}, []);
Scheduler.log(`Child ${label} render`);
return state;
}

// Schedule debounced state update for child (prob a no-op for this test)
// later tick: schedule unmount for parent
// start process unmount (but don't flush passive effectS)
// State update on child
await act(async () => {
ReactNoop.render(<Parent />);
await waitFor([
'Parent true render',
'Child one render',
'Child two render',
'Child one commit',
'Child two commit',
'Parent true commit',
'Child one passive create',
'Child two passive create',
]);

// Update children.
setChildStates.forEach(setChildState => setChildState(1));
await waitFor([
'Child one render',
'Child two render',
'Child one commit',
'Child two commit',
]);

// Schedule another update for children, and partially process it.


React.startTransition(() => {
setChildStates.forEach(setChildState => setChildState(2));
});
await waitFor(['Child one render']);

// Schedule unmount for the parent that unmounts children with pending
update.
ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
setParentState(false);
});
await waitForPaint(['Parent false render', 'Parent false commit']);

// Schedule updates for children too (which should be ignored)


setChildStates.forEach(setChildState => setChildState(2));
await waitForAll([
'Child one passive destroy',
'Child two passive destroy',
]);
});
});

it('does not warn about state updates for unmounted components with no pending
passive unmounts', async () => {
let completePendingRequest = null;
function Component() {
Scheduler.log('Component');
const [didLoad, setDidLoad] = React.useState(false);
React.useLayoutEffect(() => {
Scheduler.log('layout create');
// Mimic an XHR request with a complete handler that updates state.
completePendingRequest = () => setDidLoad(true);
return () => {
Scheduler.log('layout destroy');
};
}, []);
return didLoad;
}

await act(async () => {


ReactNoop.renderToRootWithID(<Component />, 'root', () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Component', 'layout create', 'Sync effect']);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitFor(['layout destroy']);

// Simulate an XHR completing.


completePendingRequest();
});
});

it('does not warn if there are pending passive unmount effects but not for the
current fiber', async () => {
let completePendingRequest = null;
function ComponentWithXHR() {
Scheduler.log('Component');
const [didLoad, setDidLoad] = React.useState(false);
React.useLayoutEffect(() => {
Scheduler.log('a:layout create');
return () => {
Scheduler.log('a:layout destroy');
};
}, []);
React.useEffect(() => {
Scheduler.log('a:passive create');
// Mimic an XHR request with a complete handler that updates state.
completePendingRequest = () => setDidLoad(true);
}, []);
return didLoad;
}

function ComponentWithPendingPassiveUnmount() {
React.useEffect(() => {
Scheduler.log('b:passive create');
return () => {
Scheduler.log('b:passive destroy');
};
}, []);
return null;
}

await act(async () => {


ReactNoop.renderToRootWithID(
<>
<ComponentWithXHR />
<ComponentWithPendingPassiveUnmount />
</>,
'root',
() => Scheduler.log('Sync effect'),
);
await waitFor(['Component', 'a:layout create', 'Sync effect']);
ReactNoop.flushPassiveEffects();
assertLog(['a:passive create', 'b:passive create']);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitFor(['a:layout destroy']);

// Simulate an XHR completing in the component without a pending passive


effect..
completePendingRequest();
});
});

it('does not warn if there are updates after pending passive unmount effects
have been flushed', async () => {
let updaterFunction;

function Component() {
Scheduler.log('Component');
const [state, setState] = React.useState(false);
updaterFunction = setState;
React.useEffect(() => {
Scheduler.log('passive create');
return () => {
Scheduler.log('passive destroy');
};
}, []);
return state;
}

await act(() => {


ReactNoop.renderToRootWithID(<Component />, 'root', () =>
Scheduler.log('Sync effect'),
);
});
assertLog(['Component', 'Sync effect', 'passive create']);

ReactNoop.unmountRootWithID('root');
await waitForAll(['passive destroy']);

await act(() => {


updaterFunction(true);
});
});

it('does not show a warning when a component updates its own state from within
passive unmount function', async () => {
function Component() {
Scheduler.log('Component');
const [didLoad, setDidLoad] = React.useState(false);
React.useEffect(() => {
Scheduler.log('passive create');
return () => {
setDidLoad(true);
Scheduler.log('passive destroy');
};
}, []);
return didLoad;
}

await act(async () => {


ReactNoop.renderToRootWithID(<Component />, 'root', () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Component', 'Sync effect', 'passive create']);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitForAll(['passive destroy']);
});
});

it('does not show a warning when a component updates a child state from within
passive unmount function', async () => {
function Parent() {
Scheduler.log('Parent');
const updaterRef = useRef(null);
React.useEffect(() => {
Scheduler.log('Parent passive create');
return () => {
updaterRef.current(true);
Scheduler.log('Parent passive destroy');
};
}, []);
return <Child updaterRef={updaterRef} />;
}

function Child({updaterRef}) {
Scheduler.log('Child');
const [state, setState] = React.useState(false);
React.useEffect(() => {
Scheduler.log('Child passive create');
updaterRef.current = setState;
}, []);
return state;
}

await act(async () => {


ReactNoop.renderToRootWithID(<Parent />, 'root');
await waitFor([
'Parent',
'Child',
'Child passive create',
'Parent passive create',
]);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitForAll(['Parent passive destroy']);
});
});

it('does not show a warning when a component updates a parents state from
within passive unmount function', async () => {
function Parent() {
const [state, setState] = React.useState(false);
Scheduler.log('Parent');
return <Child setState={setState} state={state} />;
}

function Child({setState, state}) {


Scheduler.log('Child');
React.useEffect(() => {
Scheduler.log('Child passive create');
return () => {
Scheduler.log('Child passive destroy');
setState(true);
};
}, []);
return state;
}

await act(async () => {


ReactNoop.renderToRootWithID(<Parent />, 'root');
await waitFor(['Parent', 'Child', 'Child passive create']);

// Unmount but don't process pending passive destroy function


ReactNoop.unmountRootWithID('root');
await waitForAll(['Child passive destroy']);
});
});

it('updates have async priority', async () => {


function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(() => {
Scheduler.log(`Schedule update [${props.count}]`);
updateCount(props.count);
}, [props.count]);
return <Text text={'Count: ' + count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: (empty)', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: (empty)" />);
ReactNoop.flushPassiveEffects();
assertLog(['Schedule update [0]']);
await waitForAll(['Count: 0']);
});

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
ReactNoop.flushPassiveEffects();
assertLog(['Schedule update [1]']);
await waitForAll(['Count: 1']);
});
});

it('updates have async priority even if effects are flushed early', async () =>
{
function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(() => {
Scheduler.log(`Schedule update [${props.count}]`);
updateCount(props.count);
}, [props.count]);
return <Text text={'Count: ' + count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: (empty)', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: (empty)" />);

// Rendering again should flush the previous commit's effects


if (gate(flags => flags.forceConcurrentByDefaultForTesting)) {
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
} else {
React.startTransition(() => {
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
});
}

await waitFor(['Schedule update [0]', 'Count: 0']);

if (gate(flags => flags.forceConcurrentByDefaultForTesting)) {


expect(ReactNoop).toMatchRenderedOutput(
<span prop="Count: (empty)" />,
);
await waitFor(['Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

ReactNoop.flushPassiveEffects();
assertLog(['Schedule update [1]']);
await waitForAll(['Count: 1']);
} else {
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
await waitFor([
'Count: 0',
'Sync effect',
'Schedule update [1]',
'Count: 1',
]);
}

expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);


});
});

it('does not flush non-discrete passive effects when flushing sync', async ()
=> {
let _updateCount;
function Counter(props) {
const [count, updateCount] = useState(0);
_updateCount = updateCount;
useEffect(() => {
Scheduler.log(`Will set count to 1`);
updateCount(1);
}, []);
return <Text text={'Count: ' + count} />;
}

ReactNoop.render(<Counter count={0} />, () =>


Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
// A flush sync doesn't cause the passive effects to fire.
// So we haven't added the other update yet.
await act(() => {
ReactNoop.flushSync(() => {
_updateCount(2);
});
});

// As a result we, somewhat surprisingly, commit them in the opposite order.


// This should be fine because any non-discrete set of work doesn't guarantee
order
// and easily could've happened slightly later too.
if (gate(flags => flags.enableUnifiedSyncLane)) {
assertLog(['Will set count to 1', 'Count: 1']);
} else {
assertLog(['Will set count to 1', 'Count: 2', 'Count: 1']);
}

expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);


});

it(
'in legacy mode, useEffect is deferred and updates finish synchronously ' +
'(in a single batch)',
async () => {
function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(() => {
// Update multiple times. These should all be batched together in
// a single render.
updateCount(props.count);
updateCount(props.count);
updateCount(props.count);
updateCount(props.count);
updateCount(props.count);
updateCount(props.count);
}, [props.count]);
return <Text text={'Count: ' + count} />;
}
await act(() => {
ReactNoop.flushSync(() => {
ReactNoop.renderLegacySyncRoot(<Counter count={0} />);
});

// Even in legacy mode, effects are deferred until after paint


assertLog(['Count: (empty)']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Count: (empty)" />,
);
});

// effects get forced on exiting act()


// There were multiple updates, but there should only be a
// single render
assertLog(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
},
);

it('flushSync is not allowed', async () => {


function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(() => {
Scheduler.log(`Schedule update [${props.count}]`);
ReactNoop.flushSync(() => {
updateCount(props.count);
});
assertLog([`Schedule update [${props.count}]`]);
// This shouldn't flush synchronously.
expect(ReactNoop).not.toMatchRenderedOutput(
<span prop={`Count: ${props.count}`} />,
);
}, [props.count]);
return <Text text={'Count: ' + count} />;
}
await expect(async () => {
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: (empty)', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Count: (empty)" />,
);
});
}).toErrorDev('flushSync was called from inside a lifecycle method');
assertLog([`Count: 0`]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

it('unmounts previous effect', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Did create [${props.count}]`);
return () => {
Scheduler.log(`Did destroy [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Did create [0]']);

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});

assertLog(['Did destroy [0]', 'Did create [1]']);


});

it('unmounts on deletion', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Did create [${props.count}]`);
return () => {
Scheduler.log(`Did destroy [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Did create [0]']);

ReactNoop.render(null);
await waitForAll(['Did destroy [0]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('unmounts on deletion after skipped effect', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Did create [${props.count}]`);
return () => {
Scheduler.log(`Did destroy [${props.count}]`);
};
}, []);
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Did create [0]']);

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});

assertLog([]);

ReactNoop.render(null);
await waitForAll(['Did destroy [0]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('always fires effects if no dependencies are provided', async () => {


function effect() {
Scheduler.log(`Did create`);
return () => {
Scheduler.log(`Did destroy`);
};
}
function Counter(props) {
useEffect(effect);
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Did create']);

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});

assertLog(['Did destroy', 'Did create']);

ReactNoop.render(null);
await waitForAll(['Did destroy']);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('skips effect if inputs have not changed', async () => {


function Counter(props) {
const text = `${props.label}: ${props.count}`;
useEffect(() => {
Scheduler.log(`Did create [${text}]`);
return () => {
Scheduler.log(`Did destroy [${text}]`);
};
}, [props.label, props.count]);
return <Text text={text} />;
}
await act(async () => {
ReactNoop.render(<Counter label="Count" count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
});

assertLog(['Did create [Count: 0]']);


expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

await act(async () => {


ReactNoop.render(<Counter label="Count" count={1} />, () =>
Scheduler.log('Sync effect'),
);
// Count changed
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});

assertLog(['Did destroy [Count: 0]', 'Did create [Count: 1]']);

await act(async () => {


ReactNoop.render(<Counter label="Count" count={1} />, () =>
Scheduler.log('Sync effect'),
);
// Nothing changed, so no effect should have fired
await waitFor(['Count: 1', 'Sync effect']);
});

assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);

await act(async () => {


ReactNoop.render(<Counter label="Total" count={1} />, () =>
Scheduler.log('Sync effect'),
);
// Label changed
await waitFor(['Total: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Total: 1" />);
});

assertLog(['Did destroy [Count: 1]', 'Did create [Total: 1]']);


});

it('multiple effects', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Did commit 1 [${props.count}]`);
});
useEffect(() => {
Scheduler.log(`Did commit 2 [${props.count}]`);
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Did commit 1 [0]', 'Did commit 2 [0]']);

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});
assertLog(['Did commit 1 [1]', 'Did commit 2 [1]']);
});

it('unmounts all previous effects before creating any new ones', async () => {
function Counter(props) {
useEffect(() => {
Scheduler.log(`Mount A [${props.count}]`);
return () => {
Scheduler.log(`Unmount A [${props.count}]`);
};
});
useEffect(() => {
Scheduler.log(`Mount B [${props.count}]`);
return () => {
Scheduler.log(`Unmount B [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
});

assertLog(['Mount A [0]', 'Mount B [0]']);

await act(async () => {


ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
});
assertLog([
'Unmount A [0]',
'Unmount B [0]',
'Mount A [1]',
'Mount B [1]',
]);
});

it('unmounts all previous effects between siblings before creating any new
ones', async () => {
function Counter({count, label}) {
useEffect(() => {
Scheduler.log(`Mount ${label} [${count}]`);
return () => {
Scheduler.log(`Unmount ${label} [${count}]`);
};
});
return <Text text={`${label} ${count}`} />;
}
await act(async () => {
ReactNoop.render(
<>
<Counter label="A" count={0} />
<Counter label="B" count={0} />
</>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['A 0', 'B 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A 0" />
<span prop="B 0" />
</>,
);
});

assertLog(['Mount A [0]', 'Mount B [0]']);

await act(async () => {


ReactNoop.render(
<>
<Counter label="A" count={1} />
<Counter label="B" count={1} />
</>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['A 1', 'B 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A 1" />
<span prop="B 1" />
</>,
);
});
assertLog([
'Unmount A [0]',
'Unmount B [0]',
'Mount A [1]',
'Mount B [1]',
]);

await act(async () => {


ReactNoop.render(
<>
<Counter label="B" count={2} />
<Counter label="C" count={0} />
</>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['B 2', 'C 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B 2" />
<span prop="C 0" />
</>,
);
});
assertLog([
'Unmount A [1]',
'Unmount B [1]',
'Mount B [2]',
'Mount C [0]',
]);
});

it('handles errors in create on mount', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Mount A [${props.count}]`);
return () => {
Scheduler.log(`Unmount A [${props.count}]`);
};
});
useEffect(() => {
Scheduler.log('Oops!');
throw new Error('Oops!');
// eslint-disable-next-line no-unreachable
Scheduler.log(`Mount B [${props.count}]`);
return () => {
Scheduler.log(`Unmount B [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
});

assertLog([
'Mount A [0]',
'Oops!',
// Clean up effect A. There's no effect B to clean-up, because it
// never mounted.
'Unmount A [0]',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('handles errors in create on update', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Mount A [${props.count}]`);
return () => {
Scheduler.log(`Unmount A [${props.count}]`);
};
});
useEffect(() => {
if (props.count === 1) {
Scheduler.log('Oops!');
throw new Error('Oops!');
}
Scheduler.log(`Mount B [${props.count}]`);
return () => {
Scheduler.log(`Unmount B [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
ReactNoop.flushPassiveEffects();
assertLog(['Mount A [0]', 'Mount B [0]']);
});

await act(async () => {


// This update will trigger an error
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');
assertLog(['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
assertLog([
// Clean up effect A runs passively on unmount.
// There's no effect B to clean-up, because it never mounted.
'Unmount A [1]',
]);
});

it('handles errors in destroy on update', async () => {


function Counter(props) {
useEffect(() => {
Scheduler.log(`Mount A [${props.count}]`);
return () => {
Scheduler.log('Oops!');
if (props.count === 0) {
throw new Error('Oops!');
}
};
});
useEffect(() => {
Scheduler.log(`Mount B [${props.count}]`);
return () => {
Scheduler.log(`Unmount B [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}

await act(async () => {


ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
ReactNoop.flushPassiveEffects();
assertLog(['Mount A [0]', 'Mount B [0]']);
});

await act(async () => {


// This update will trigger an error during passive effect unmount
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops');

// This branch enables a feature flag that flushes all passive destroys in
a
// separate pass before flushing any passive creates.
// A result of this two-pass flush is that an error thrown from unmount
does
// not block the subsequent create functions from being run.
assertLog(['Oops!', 'Unmount B [0]', 'Mount A [1]', 'Mount B [1]']);
});

// <Counter> gets unmounted because an error is thrown above.


// The remaining destroy functions are run later on unmount, since they're
passive.
// In this case, one of them throws again (because of how the test is
written).
assertLog(['Oops!', 'Unmount B [1]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});

it('works with memo', async () => {


function Counter({count}) {
useLayoutEffect(() => {
Scheduler.log('Mount: ' + count);
return () => Scheduler.log('Unmount: ' + count);
});
return <Text text={'Count: ' + count} />;
}
Counter = memo(Counter);

ReactNoop.render(<Counter count={0} />, () =>


Scheduler.log('Sync effect'),
);
await waitFor(['Count: 0', 'Mount: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);

ReactNoop.render(<Counter count={1} />, () =>


Scheduler.log('Sync effect'),
);
await waitFor(['Count: 1', 'Unmount: 0', 'Mount: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);

ReactNoop.render(null);
await waitFor(['Unmount: 1']);
expect(ReactNoop).toMatchRenderedOutput(null);
});

describe('errors thrown in passive destroy function within unmounted trees', ()


=> {
let BrokenUseEffectCleanup;
let ErrorBoundary;
let LogOnlyErrorBoundary;

beforeEach(() => {
BrokenUseEffectCleanup = function () {
useEffect(() => {
Scheduler.log('BrokenUseEffectCleanup useEffect');
return () => {
Scheduler.log('BrokenUseEffectCleanup useEffect destroy');
throw new Error('Expected error');
};
}, []);

return 'inner child';


};

ErrorBoundary = class extends React.Component {


state = {error: null};
static getDerivedStateFromError(error) {
Scheduler.log(`ErrorBoundary static getDerivedStateFromError`);
return {error};
}
componentDidCatch(error, info) {
Scheduler.log(`ErrorBoundary componentDidCatch`);
}
render() {
if (this.state.error) {
Scheduler.log('ErrorBoundary render error');
return <span prop="ErrorBoundary fallback" />;
}
Scheduler.log('ErrorBoundary render success');
return this.props.children || null;
}
};
LogOnlyErrorBoundary = class extends React.Component {
componentDidCatch(error, info) {
Scheduler.log(`LogOnlyErrorBoundary componentDidCatch`);
}
render() {
Scheduler.log(`LogOnlyErrorBoundary render`);
return this.props.children || null;
}
};
});

it('should use the nearest still-mounted boundary if there are no unmounted


boundaries', async () => {
await act(() => {
ReactNoop.render(
<LogOnlyErrorBoundary>
<BrokenUseEffectCleanup />
</LogOnlyErrorBoundary>,
);
});

assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect',
]);

await act(() => {


ReactNoop.render(<LogOnlyErrorBoundary />);
});

assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect destroy',
'LogOnlyErrorBoundary componentDidCatch',
]);
});

it('should skip unmounted boundaries and use the nearest still-mounted


boundary', async () => {
function Conditional({showChildren}) {
if (showChildren) {
return (
<ErrorBoundary>
<BrokenUseEffectCleanup />
</ErrorBoundary>
);
} else {
return null;
}
}

await act(() => {


ReactNoop.render(
<LogOnlyErrorBoundary>
<Conditional showChildren={true} />
</LogOnlyErrorBoundary>,
);
});
assertLog([
'LogOnlyErrorBoundary render',
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect',
]);

await act(() => {


ReactNoop.render(
<LogOnlyErrorBoundary>
<Conditional showChildren={false} />
</LogOnlyErrorBoundary>,
);
});

assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect destroy',
'LogOnlyErrorBoundary componentDidCatch',
]);
});

it('should call getDerivedStateFromError in the nearest still-mounted


boundary', async () => {
function Conditional({showChildren}) {
if (showChildren) {
return <BrokenUseEffectCleanup />;
} else {
return null;
}
}

await act(() => {


ReactNoop.render(
<ErrorBoundary>
<Conditional showChildren={true} />
</ErrorBoundary>,
);
});

assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect',
]);

await act(() => {


ReactNoop.render(
<ErrorBoundary>
<Conditional showChildren={false} />
</ErrorBoundary>,
);
});

assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect destroy',
'ErrorBoundary static getDerivedStateFromError',
'ErrorBoundary render error',
'ErrorBoundary componentDidCatch',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="ErrorBoundary fallback" />,
);
});

it('should rethrow error if there are no still-mounted boundaries', async ()


=> {
function Conditional({showChildren}) {
if (showChildren) {
return (
<ErrorBoundary>
<BrokenUseEffectCleanup />
</ErrorBoundary>
);
} else {
return null;
}
}

await act(() => {


ReactNoop.render(<Conditional showChildren={true} />);
});

assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect',
]);

await act(async () => {


ReactNoop.render(<Conditional showChildren={false} />);
await waitForThrow('Expected error');
});

assertLog(['BrokenUseEffectCleanup useEffect destroy']);

expect(ReactNoop).toMatchRenderedOutput(null);
});
});

it('calls passive effect destroy functions for memoized components', async ()


=> {
const Wrapper = ({children}) => children;
function Child() {
React.useEffect(() => {
Scheduler.log('passive create');
return () => {
Scheduler.log('passive destroy');
};
}, []);
React.useLayoutEffect(() => {
Scheduler.log('layout create');
return () => {
Scheduler.log('layout destroy');
};
}, []);
Scheduler.log('render');
return null;
}
const isEqual = (prevProps, nextProps) =>
prevProps.prop === nextProps.prop;
const MemoizedChild = React.memo(Child, isEqual);

await act(() => {


ReactNoop.render(
<Wrapper>
<MemoizedChild key={1} />
</Wrapper>,
);
});
assertLog(['render', 'layout create', 'passive create']);

// Include at least one no-op (memoized) update to trigger original bug.


await act(() => {
ReactNoop.render(
<Wrapper>
<MemoizedChild key={1} />
</Wrapper>,
);
});
assertLog([]);

await act(() => {


ReactNoop.render(
<Wrapper>
<MemoizedChild key={2} />
</Wrapper>,
);
});
assertLog([
'render',
'layout destroy',
'layout create',
'passive destroy',
'passive create',
]);

await act(() => {


ReactNoop.render(null);
});
assertLog(['layout destroy', 'passive destroy']);
});

it('calls passive effect destroy functions for descendants of memoized


components', async () => {
const Wrapper = ({children}) => children;
function Child() {
return <Grandchild />;
}

function Grandchild() {
React.useEffect(() => {
Scheduler.log('passive create');
return () => {
Scheduler.log('passive destroy');
};
}, []);
React.useLayoutEffect(() => {
Scheduler.log('layout create');
return () => {
Scheduler.log('layout destroy');
};
}, []);
Scheduler.log('render');
return null;
}

const isEqual = (prevProps, nextProps) =>


prevProps.prop === nextProps.prop;
const MemoizedChild = React.memo(Child, isEqual);

await act(() => {


ReactNoop.render(
<Wrapper>
<MemoizedChild key={1} />
</Wrapper>,
);
});
assertLog(['render', 'layout create', 'passive create']);

// Include at least one no-op (memoized) update to trigger original bug.


await act(() => {
ReactNoop.render(
<Wrapper>
<MemoizedChild key={1} />
</Wrapper>,
);
});
assertLog([]);

await act(() => {


ReactNoop.render(
<Wrapper>
<MemoizedChild key={2} />
</Wrapper>,
);
});
assertLog([
'render',
'layout destroy',
'layout create',
'passive destroy',
'passive create',
]);

await act(() => {


ReactNoop.render(null);
});
assertLog(['layout destroy', 'passive destroy']);
});

it('assumes passive effect destroy function is either a function or undefined',


async () => {
function App(props) {
useEffect(() => {
return props.return;
});
return null;
}

const root1 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root1.render(<App return={17} />);
});
}).toErrorDev([
'Warning: useEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned: 17',
]);

const root2 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root2.render(<App return={null} />);
});
}).toErrorDev([
'Warning: useEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned null. If your ' +
'effect does not require clean up, return undefined (or nothing).',
]);

const root3 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root3.render(<App return={Promise.resolve()} />);
});
}).toErrorDev([
'Warning: useEffect must not return anything besides a ' +
'function, which is used for clean-up.\n\n' +
'It looks like you wrote useEffect(async () => ...) or returned a
Promise.',
]);

// Error on unmount because React assumes the value is a function


await act(async () => {
root3.render(null);
await waitForThrow('is not a function');
});
});
});

describe('useInsertionEffect', () => {
it('fires insertion effects after snapshots on update', async () => {
function CounterA(props) {
useInsertionEffect(() => {
Scheduler.log(`Create insertion`);
return () => {
Scheduler.log(`Destroy insertion`);
};
});
return null;
}

class CounterB extends React.Component {


getSnapshotBeforeUpdate(prevProps, prevState) {
Scheduler.log(`Get Snapshot`);
return null;
}

componentDidUpdate() {}

render() {
return null;
}
}

await act(async () => {


ReactNoop.render(
<>
<CounterA />
<CounterB />
</>,
);

await waitForAll(['Create insertion']);


});

// Update
await act(async () => {
ReactNoop.render(
<>
<CounterA />
<CounterB />
</>,
);

await waitForAll([
'Get Snapshot',
'Destroy insertion',
'Create insertion',
]);
});

// Unmount everything
await act(async () => {
ReactNoop.render(null);

await waitForAll(['Destroy insertion']);


});
});

it('fires insertion effects before layout effects', async () => {


let committedText = '(empty)';

function Counter(props) {
useInsertionEffect(() => {
Scheduler.log(`Create insertion [current: ${committedText}]`);
committedText = String(props.count);
return () => {
Scheduler.log(`Destroy insertion [current: ${committedText}]`);
};
});
useLayoutEffect(() => {
Scheduler.log(`Create layout [current: ${committedText}]`);
return () => {
Scheduler.log(`Destroy layout [current: ${committedText}]`);
};
});
useEffect(() => {
Scheduler.log(`Create passive [current: ${committedText}]`);
return () => {
Scheduler.log(`Destroy passive [current: ${committedText}]`);
};
});
return null;
}
await act(async () => {
ReactNoop.render(<Counter count={0} />);

await waitForPaint([
'Create insertion [current: (empty)]',
'Create layout [current: 0]',
]);
expect(committedText).toEqual('0');
});

assertLog(['Create passive [current: 0]']);

// Unmount everything
await act(async () => {
ReactNoop.render(null);

await waitForPaint([
'Destroy insertion [current: 0]',
'Destroy layout [current: 0]',
]);
});

assertLog(['Destroy passive [current: 0]']);


});

it('force flushes passive effects before firing new insertion effects', async
() => {
let committedText = '(empty)';

function Counter(props) {
useInsertionEffect(() => {
Scheduler.log(`Create insertion [current: ${committedText}]`);
committedText = String(props.count);
return () => {
Scheduler.log(`Destroy insertion [current: ${committedText}]`);
};
});
useLayoutEffect(() => {
Scheduler.log(`Create layout [current: ${committedText}]`);
committedText = String(props.count);
return () => {
Scheduler.log(`Destroy layout [current: ${committedText}]`);
};
});
useEffect(() => {
Scheduler.log(`Create passive [current: ${committedText}]`);
return () => {
Scheduler.log(`Destroy passive [current: ${committedText}]`);
};
});
return null;
}

await act(async () => {


React.startTransition(() => {
ReactNoop.render(<Counter count={0} />);
});
await waitForPaint([
'Create insertion [current: (empty)]',
'Create layout [current: 0]',
]);
expect(committedText).toEqual('0');

React.startTransition(() => {
ReactNoop.render(<Counter count={1} />);
});
await waitForPaint([
'Create passive [current: 0]',
'Destroy insertion [current: 0]',
'Create insertion [current: 0]',
'Destroy layout [current: 1]',
'Create layout [current: 1]',
]);
expect(committedText).toEqual('1');
});
assertLog([
'Destroy passive [current: 1]',
'Create passive [current: 1]',
]);
});

it('fires all insertion effects (interleaved) before firing any layout


effects', async () => {
let committedA = '(empty)';
let committedB = '(empty)';

function CounterA(props) {
useInsertionEffect(() => {
Scheduler.log(
`Create Insertion 1 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
committedA = String(props.count);
return () => {
Scheduler.log(
`Destroy Insertion 1 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
};
});
useInsertionEffect(() => {
Scheduler.log(
`Create Insertion 2 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
committedA = String(props.count);
return () => {
Scheduler.log(
`Destroy Insertion 2 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
};
});

useLayoutEffect(() => {
Scheduler.log(
`Create Layout 1 for Component A [A: ${committedA}, B: ${committedB}]`,
);
return () => {
Scheduler.log(
`Destroy Layout 1 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
};
});

useLayoutEffect(() => {
Scheduler.log(
`Create Layout 2 for Component A [A: ${committedA}, B: ${committedB}]`,
);
return () => {
Scheduler.log(
`Destroy Layout 2 for Component A [A: ${committedA}, B: $
{committedB}]`,
);
};
});
return null;
}

function CounterB(props) {
useInsertionEffect(() => {
Scheduler.log(
`Create Insertion 1 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
committedB = String(props.count);
return () => {
Scheduler.log(
`Destroy Insertion 1 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
};
});
useInsertionEffect(() => {
Scheduler.log(
`Create Insertion 2 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
committedB = String(props.count);
return () => {
Scheduler.log(
`Destroy Insertion 2 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
};
});

useLayoutEffect(() => {
Scheduler.log(
`Create Layout 1 for Component B [A: ${committedA}, B: ${committedB}]`,
);
return () => {
Scheduler.log(
`Destroy Layout 1 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
};
});

useLayoutEffect(() => {
Scheduler.log(
`Create Layout 2 for Component B [A: ${committedA}, B: ${committedB}]`,
);
return () => {
Scheduler.log(
`Destroy Layout 2 for Component B [A: ${committedA}, B: $
{committedB}]`,
);
};
});
return null;
}

await act(async () => {


ReactNoop.render(
<React.Fragment>
<CounterA count={0} />
<CounterB count={0} />
</React.Fragment>,
);
await waitForAll([
// All insertion effects fire before all layout effects
'Create Insertion 1 for Component A [A: (empty), B: (empty)]',
'Create Insertion 2 for Component A [A: 0, B: (empty)]',
'Create Insertion 1 for Component B [A: 0, B: (empty)]',
'Create Insertion 2 for Component B [A: 0, B: 0]',
'Create Layout 1 for Component A [A: 0, B: 0]',
'Create Layout 2 for Component A [A: 0, B: 0]',
'Create Layout 1 for Component B [A: 0, B: 0]',
'Create Layout 2 for Component B [A: 0, B: 0]',
]);
expect([committedA, committedB]).toEqual(['0', '0']);
});

await act(async () => {


ReactNoop.render(
<React.Fragment>
<CounterA count={1} />
<CounterB count={1} />
</React.Fragment>,
);
await waitForAll([
'Destroy Insertion 1 for Component A [A: 0, B: 0]',
'Destroy Insertion 2 for Component A [A: 0, B: 0]',
'Create Insertion 1 for Component A [A: 0, B: 0]',
'Create Insertion 2 for Component A [A: 1, B: 0]',
'Destroy Layout 1 for Component A [A: 1, B: 0]',
'Destroy Layout 2 for Component A [A: 1, B: 0]',
'Destroy Insertion 1 for Component B [A: 1, B: 0]',
'Destroy Insertion 2 for Component B [A: 1, B: 0]',
'Create Insertion 1 for Component B [A: 1, B: 0]',
'Create Insertion 2 for Component B [A: 1, B: 1]',
'Destroy Layout 1 for Component B [A: 1, B: 1]',
'Destroy Layout 2 for Component B [A: 1, B: 1]',
'Create Layout 1 for Component A [A: 1, B: 1]',
'Create Layout 2 for Component A [A: 1, B: 1]',
'Create Layout 1 for Component B [A: 1, B: 1]',
'Create Layout 2 for Component B [A: 1, B: 1]',
]);
expect([committedA, committedB]).toEqual(['1', '1']);

// Unmount everything
await act(async () => {
ReactNoop.render(null);

await waitForAll([
'Destroy Insertion 1 for Component A [A: 1, B: 1]',
'Destroy Insertion 2 for Component A [A: 1, B: 1]',
'Destroy Layout 1 for Component A [A: 1, B: 1]',
'Destroy Layout 2 for Component A [A: 1, B: 1]',
'Destroy Insertion 1 for Component B [A: 1, B: 1]',
'Destroy Insertion 2 for Component B [A: 1, B: 1]',
'Destroy Layout 1 for Component B [A: 1, B: 1]',
'Destroy Layout 2 for Component B [A: 1, B: 1]',
]);
});
});
});

it('assumes insertion effect destroy function is either a function or


undefined', async () => {
function App(props) {
useInsertionEffect(() => {
return props.return;
});
return null;
}

const root1 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root1.render(<App return={17} />);
});
}).toErrorDev([
'Warning: useInsertionEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned: 17',
]);

const root2 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root2.render(<App return={null} />);
});
}).toErrorDev([
'Warning: useInsertionEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned null. If your ' +
'effect does not require clean up, return undefined (or nothing).',
]);

const root3 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root3.render(<App return={Promise.resolve()} />);
});
}).toErrorDev([
'Warning: useInsertionEffect must not return anything besides a ' +
'function, which is used for clean-up.\n\n' +
'It looks like you wrote useInsertionEffect(async () => ...) or returned
a Promise.',
]);

// Error on unmount because React assumes the value is a function


await act(async () => {
root3.render(null);
await waitForThrow('is not a function');
});
});

it('warns when setState is called from insertion effect setup', async () => {
function App(props) {
const [, setX] = useState(0);
useInsertionEffect(() => {
setX(1);
if (props.throw) {
throw Error('No');
}
}, [props.throw]);
return null;
}

const root = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root.render(<App />);
});
}).toErrorDev(['Warning: useInsertionEffect must not schedule updates.']);

await act(async () => {


root.render(<App throw={true} />);
await waitForThrow('No');
});

// Should not warn for regular effects after throw.


function NotInsertion() {
const [, setX] = useState(0);
useEffect(() => {
setX(1);
}, []);
return null;
}
await act(() => {
root.render(<NotInsertion />);
});
});

it('warns when setState is called from insertion effect cleanup', async () => {
function App(props) {
const [, setX] = useState(0);
useInsertionEffect(() => {
if (props.throw) {
throw Error('No');
}
return () => {
setX(1);
};
}, [props.throw, props.foo]);
return null;
}

const root = ReactNoop.createRoot();


await act(() => {
root.render(<App foo="hello" />);
});
await expect(async () => {
await act(() => {
root.render(<App foo="goodbye" />);
});
}).toErrorDev(['Warning: useInsertionEffect must not schedule updates.']);

await act(async () => {


root.render(<App throw={true} />);
await waitForThrow('No');
});

// Should not warn for regular effects after throw.


function NotInsertion() {
const [, setX] = useState(0);
useEffect(() => {
setX(1);
}, []);
return null;
}
await act(() => {
root.render(<NotInsertion />);
});
});
});

describe('useLayoutEffect', () => {
it('fires layout effects after the host has been mutated', async () => {
function getCommittedText() {
const yields = Scheduler.unstable_clearLog();
const children = ReactNoop.getChildrenAsJSX();
Scheduler.log(yields);
if (children === null) {
return null;
}
return children.props.prop;
}
function Counter(props) {
useLayoutEffect(() => {
Scheduler.log(`Current: ${getCommittedText()}`);
});
return <Text text={props.count} />;
}

ReactNoop.render(<Counter count={0} />, () =>


Scheduler.log('Sync effect'),
);
await waitFor([[0], 'Current: 0', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop={0} />);

ReactNoop.render(<Counter count={1} />, () =>


Scheduler.log('Sync effect'),
);
await waitFor([[1], 'Current: 1', 'Sync effect']);
expect(ReactNoop).toMatchRenderedOutput(<span prop={1} />);
});

it('force flushes passive effects before firing new layout effects', async ()
=> {
let committedText = '(empty)';

function Counter(props) {
useLayoutEffect(() => {
// Normally this would go in a mutation effect, but this test
// intentionally omits a mutation effect.
committedText = String(props.count);

Scheduler.log(`Mount layout [current: ${committedText}]`);


return () => {
Scheduler.log(`Unmount layout [current: ${committedText}]`);
};
});
useEffect(() => {
Scheduler.log(`Mount normal [current: ${committedText}]`);
return () => {
Scheduler.log(`Unmount normal [current: ${committedText}]`);
};
});
return null;
}

await act(async () => {


ReactNoop.render(<Counter count={0} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Mount layout [current: 0]', 'Sync effect']);
expect(committedText).toEqual('0');
ReactNoop.render(<Counter count={1} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor([
'Mount normal [current: 0]',
'Unmount layout [current: 0]',
'Mount layout [current: 1]',
'Sync effect',
]);
expect(committedText).toEqual('1');
});

assertLog(['Unmount normal [current: 1]', 'Mount normal [current: 1]']);


});

it('catches errors thrown in useLayoutEffect', async () => {


class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
Scheduler.log(`ErrorBoundary static getDerivedStateFromError`);
return {error};
}
render() {
const {children, id, fallbackID} = this.props;
const {error} = this.state;
if (error) {
Scheduler.log(`${id} render error`);
return <Component id={fallbackID} />;
}
Scheduler.log(`${id} render success`);
return children || null;
}
}

function Component({id}) {
Scheduler.log('Component render ' + id);
return <span prop={id} />;
}

function BrokenLayoutEffectDestroy() {
useLayoutEffect(() => {
return () => {
Scheduler.log('BrokenLayoutEffectDestroy useLayoutEffect destroy');
throw Error('Expected');
};
}, []);

Scheduler.log('BrokenLayoutEffectDestroy render');
return <span prop="broken" />;
}

ReactNoop.render(
<ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
<Component id="sibling" />
<ErrorBoundary id="InnerBoundary" fallbackID="InnerFallback">
<BrokenLayoutEffectDestroy />
</ErrorBoundary>
</ErrorBoundary>,
);

await waitForAll([
'OuterBoundary render success',
'Component render sibling',
'InnerBoundary render success',
'BrokenLayoutEffectDestroy render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="sibling" />
<span prop="broken" />
</>,
);

ReactNoop.render(
<ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
<Component id="sibling" />
</ErrorBoundary>,
);

// React should skip over the unmounting boundary and find the nearest still-
mounted boundary.
await waitForAll([
'OuterBoundary render success',
'Component render sibling',
'BrokenLayoutEffectDestroy useLayoutEffect destroy',
'ErrorBoundary static getDerivedStateFromError',
'OuterBoundary render error',
'Component render OuterFallback',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="OuterFallback" />);
});

it('assumes layout effect destroy function is either a function or undefined',


async () => {
function App(props) {
useLayoutEffect(() => {
return props.return;
});
return null;
}

const root1 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root1.render(<App return={17} />);
});
}).toErrorDev([
'Warning: useLayoutEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned: 17',
]);

const root2 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root2.render(<App return={null} />);
});
}).toErrorDev([
'Warning: useLayoutEffect must not return anything besides a ' +
'function, which is used for clean-up. You returned null. If your ' +
'effect does not require clean up, return undefined (or nothing).',
]);

const root3 = ReactNoop.createRoot();


await expect(async () => {
await act(() => {
root3.render(<App return={Promise.resolve()} />);
});
}).toErrorDev([
'Warning: useLayoutEffect must not return anything besides a ' +
'function, which is used for clean-up.\n\n' +
'It looks like you wrote useLayoutEffect(async () => ...) or returned a
Promise.',
]);

// Error on unmount because React assumes the value is a function


await act(async () => {
root3.render(null);
await waitForThrow('is not a function');
});
});
});

describe('useCallback', () => {
it('memoizes callback by comparing inputs', async () => {
class IncrementButton extends React.PureComponent {
increment = () => {
this.props.increment();
};
render() {
return <Text text="Increment" />;
}
}

function Counter({incrementBy}) {
const [count, updateCount] = useState(0);
const increment = useCallback(
() => updateCount(c => c + incrementBy),
[incrementBy],
);
return (
<>
<IncrementButton increment={increment} 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([
// Button should not re-render, because its props haven't changed
// 'Increment',
'Count: 1',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 1" />
</>,
);

// Increase the increment amount


ReactNoop.render(<Counter incrementBy={10} />);
await waitForAll([
// Inputs did change this time
'Increment',
'Count: 1',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 1" />
</>,
);

// Callback should have updated


await act(() => button.current.increment());
assertLog(['Count: 11']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Increment" />
<span prop="Count: 11" />
</>,
);
});
});

describe('useMemo', () => {
it('memoizes value by comparing to previous inputs', async () => {
function CapitalizedText(props) {
const text = props.text;
const capitalizedText = useMemo(() => {
Scheduler.log(`Capitalize '${text}'`);
return text.toUpperCase();
}, [text]);
return <Text text={capitalizedText} />;
}

ReactNoop.render(<CapitalizedText text="hello" />);


await waitForAll(["Capitalize 'hello'", 'HELLO']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="HELLO" />);

ReactNoop.render(<CapitalizedText text="hi" />);


await waitForAll(["Capitalize 'hi'", 'HI']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="HI" />);

ReactNoop.render(<CapitalizedText text="hi" />);


await waitForAll(['HI']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="HI" />);

ReactNoop.render(<CapitalizedText text="goodbye" />);


await waitForAll(["Capitalize 'goodbye'", 'GOODBYE']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="GOODBYE" />);
});

it('always re-computes if no inputs are provided', async () => {


function LazyCompute(props) {
const computed = useMemo(props.compute);
return <Text text={computed} />;
}

function computeA() {
Scheduler.log('compute A');
return 'A';
}

function computeB() {
Scheduler.log('compute B');
return 'B';
}

ReactNoop.render(<LazyCompute compute={computeA} />);


await waitForAll(['compute A', 'A']);

ReactNoop.render(<LazyCompute compute={computeA} />);


await waitForAll(['compute A', 'A']);

ReactNoop.render(<LazyCompute compute={computeA} />);


await waitForAll(['compute A', 'A']);

ReactNoop.render(<LazyCompute compute={computeB} />);


await waitForAll(['compute B', 'B']);
});

it('should not invoke memoized function during re-renders unless inputs


change', async () => {
function LazyCompute(props) {
const computed = useMemo(
() => props.compute(props.input),
[props.input],
);
const [count, setCount] = useState(0);
if (count < 3) {
setCount(count + 1);
}
return <Text text={computed} />;
}

function compute(val) {
Scheduler.log('compute ' + val);
return val;
}

ReactNoop.render(<LazyCompute compute={compute} input="A" />);


await waitForAll(['compute A', 'A']);

ReactNoop.render(<LazyCompute compute={compute} input="A" />);


await waitForAll(['A']);

ReactNoop.render(<LazyCompute compute={compute} input="B" />);


await waitForAll(['compute B', 'B']);
});
});

describe('useImperativeHandle', () => {
it('does not update when deps are the same', async () => {
const INCREMENT = 'INCREMENT';

function reducer(state, action) {


return action === INCREMENT ? state + 1 : state;
}

function Counter(props, ref) {


const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(ref, () => ({count, dispatch}), []);
return <Text text={'Count: ' + count} />;
}

Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
expect(counter.current.count).toBe(0);

await act(() => {


counter.current.dispatch(INCREMENT);
});
assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
// Intentionally not updated because of [] deps:
expect(counter.current.count).toBe(0);
});

// Regression test for https://github.com/facebook/react/issues/14782


it('automatically updates when deps are not specified', async () => {
const INCREMENT = 'INCREMENT';

function reducer(state, action) {


return action === INCREMENT ? state + 1 : state;
}

function Counter(props, ref) {


const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(ref, () => ({count, dispatch}));
return <Text text={'Count: ' + count} />;
}

Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
expect(counter.current.count).toBe(0);

await act(() => {


counter.current.dispatch(INCREMENT);
});
assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(counter.current.count).toBe(1);
});

it('updates when deps are different', async () => {


const INCREMENT = 'INCREMENT';

function reducer(state, action) {


return action === INCREMENT ? state + 1 : state;
}

let totalRefUpdates = 0;
function Counter(props, ref) {
const [count, dispatch] = useReducer(reducer, 0);
useImperativeHandle(
ref,
() => {
totalRefUpdates++;
return {count, dispatch};
},
[count],
);
return <Text text={'Count: ' + count} />;
}

Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 0" />);
expect(counter.current.count).toBe(0);
expect(totalRefUpdates).toBe(1);

await act(() => {


counter.current.dispatch(INCREMENT);
});
assertLog(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(counter.current.count).toBe(1);
expect(totalRefUpdates).toBe(2);

// Update that doesn't change the ref dependencies


ReactNoop.render(<Counter ref={counter} />);
await waitForAll(['Count: 1']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 1" />);
expect(counter.current.count).toBe(1);
expect(totalRefUpdates).toBe(2); // Should not increase since last time
});
});

describe('useTransition', () => {
it('delays showing loading state until after timeout', async () => {
let transition;
function App() {
const [show, setShow] = useState(false);
const [isPending, startTransition] = useTransition();
transition = () => {
startTransition(() => {
setShow(true);
});
};
return (
<Suspense
fallback={<Text text={`Loading... Pending: ${isPending}`} />}>
{show ? (
<AsyncText text={`After... Pending: ${isPending}`} />
) : (
<Text text={`Before... Pending: ${isPending}`} />
)}
</Suspense>
);
}
ReactNoop.render(<App />);
await waitForAll(['Before... Pending: false']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Before... Pending: false" />,
);

await act(async () => {


transition();

await waitForAll([
'Before... Pending: true',
'Suspend! [After... Pending: false]',
'Loading... Pending: false',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Before... Pending: true" />,
);
Scheduler.unstable_advanceTime(500);
await advanceTimers(500);

// Even after a long amount of time, we still don't show a placeholder.


Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Before... Pending: true" />,
);

await resolveText('After... Pending: false');


assertLog(['Promise resolved [After... Pending: false]']);
await waitForAll(['After... Pending: false']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="After... Pending: false" />,
);
});
});
});

describe('useDeferredValue', () => {
it('defers text value', async () => {
function TextBox({text}) {
return <AsyncText text={text} />;
}

let _setText;
function App() {
const [text, setText] = useState('A');
const deferredText = useDeferredValue(text);
_setText = setText;
return (
<>
<Text text={text} />
<Suspense fallback={<Text text={'Loading'} />}>
<TextBox text={deferredText} />
</Suspense>
</>
);
}

await act(() => {


ReactNoop.render(<App />);
});

assertLog(['A', 'Suspend! [A]', 'Loading']);


expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="Loading" />
</>,
);

await act(() => resolveText('A'));


assertLog(['Promise resolved [A]', 'A']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="A" />
<span prop="A" />
</>,
);

await act(async () => {


_setText('B');
await waitForAll(['B', 'A', 'B', 'Suspend! [B]', 'Loading']);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="A" />
</>,
);
});

await act(async () => {


Scheduler.unstable_advanceTime(250);
await advanceTimers(250);
});
assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="A" />
</>,
);

// Even after a long amount of time, we don't show a fallback


Scheduler.unstable_advanceTime(100000);
await advanceTimers(100000);
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="A" />
</>,
);

await act(async () => {


await resolveText('B');
});
assertLog(['Promise resolved [B]', 'B', 'B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="B" />
<span prop="B" />
</>,
);
});
});

describe('progressive enhancement (not supported)', () => {


it('mount additional state', async () => {
let updateA;
let updateB;
// let updateC;

function App(props) {
const [A, _updateA] = useState(0);
const [B, _updateB] = useState(0);
updateA = _updateA;
updateB = _updateB;

let C;
if (props.loadC) {
useState(0);
} else {
C = '[not loaded]';
}

return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;


}

ReactNoop.render(<App loadC={false} />);


await waitForAll(['A: 0, B: 0, C: [not loaded]']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="A: 0, B: 0, C: [not loaded]" />,
);

await act(() => {


updateA(2);
updateB(3);
});

assertLog(['A: 2, B: 3, C: [not loaded]']);


expect(ReactNoop).toMatchRenderedOutput(
<span prop="A: 2, B: 3, C: [not loaded]" />,
);

ReactNoop.render(<App loadC={true} />);


await expect(async () => {
await waitForThrow(
'Rendered more hooks than during the previous render.',
);
assertLog([]);
}).toErrorDev([
'Warning: React has detected a change in the order of Hooks called by App.
' +
'This will lead to bugs and errors if not fixed. For more information, '
+
'read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n' +
' Previous render Next render\n' +
' ------------------------------------------------------\n' +
'1. useState useState\n' +
'2. useState useState\n' +
'3. undefined useState\n' +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n',
]);

// Uncomment if/when we support this again


// expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 0"
/>]);

// updateC(4);
// expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']);
// expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 4"
/>]);
});

it('unmount state', async () => {


let updateA;
let updateB;
let updateC;

function App(props) {
const [A, _updateA] = useState(0);
const [B, _updateB] = useState(0);
updateA = _updateA;
updateB = _updateB;

let C;
if (props.loadC) {
const [_C, _updateC] = useState(0);
C = _C;
updateC = _updateC;
} else {
C = '[not loaded]';
}

return <Text text={`A: ${A}, B: ${B}, C: ${C}`} />;


}

ReactNoop.render(<App loadC={true} />);


await waitForAll(['A: 0, B: 0, C: 0']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 0, B: 0, C: 0" />);
await act(() => {
updateA(2);
updateB(3);
updateC(4);
});
assertLog(['A: 2, B: 3, C: 4']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 4" />);
ReactNoop.render(<App loadC={false} />);
await waitForThrow(
'Rendered fewer hooks than expected. This may be caused by an ' +
'accidental early return statement.',
);
});

it('unmount effects', async () => {


function App(props) {
useEffect(() => {
Scheduler.log('Mount A');
return () => {
Scheduler.log('Unmount A');
};
}, []);

if (props.showMore) {
useEffect(() => {
Scheduler.log('Mount B');
return () => {
Scheduler.log('Unmount B');
};
}, []);
}

return null;
}

await act(async () => {


ReactNoop.render(<App showMore={false} />, () =>
Scheduler.log('Sync effect'),
);
await waitFor(['Sync effect']);
});

assertLog(['Mount A']);

await act(async () => {


ReactNoop.render(<App showMore={true} />);
await expect(async () => {
await waitForThrow(
'Rendered more hooks than during the previous render.',
);
assertLog([]);
}).toErrorDev([
'Warning: React has detected a change in the order of Hooks called by
App. ' +
'This will lead to bugs and errors if not fixed. For more information,
' +
'read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n'
+
' Previous render Next render\n' +
' ------------------------------------------------------\n' +
'1. useEffect useEffect\n' +
'2. undefined useEffect\n' +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n',
]);
});
// Uncomment if/when we support this again
// ReactNoop.flushPassiveEffects();
// expect(Scheduler).toHaveYielded(['Mount B']);

// ReactNoop.render(<App showMore={false} />);


// expect(Scheduler).toFlushAndThrow(
// 'Rendered fewer hooks than expected. This may be caused by an ' +
// 'accidental early return statement.',
// );
});
});

it('useReducer does not eagerly bail out of state updates', async () => {
// Edge case based on a bug report
let setCounter;
function App() {
const [counter, _setCounter] = useState(1);
setCounter = _setCounter;
return <Component count={counter} />;
}

function Component({count}) {
const [state, dispatch] = useReducer(() => {
// This reducer closes over a value from props. If the reducer is not
// properly updated, the eager reducer will compare to an old value
// and bail out incorrectly.
Scheduler.log('Reducer: ' + count);
return count;
}, -1);
useEffect(() => {
Scheduler.log('Effect: ' + count);
dispatch();
}, [count]);
Scheduler.log('Render: ' + state);
return count;
}

await act(async () => {


ReactNoop.render(<App />);
await waitForAll(['Render: -1', 'Effect: 1', 'Reducer: 1', 'Render: 1']);
expect(ReactNoop).toMatchRenderedOutput('1');
});

await act(() => {


setCounter(2);
});
assertLog(['Render: 1', 'Effect: 2', 'Reducer: 2', 'Render: 2']);
expect(ReactNoop).toMatchRenderedOutput('2');
});

it('useReducer does not replay previous no-op actions when other state changes',
async () => {
let increment;
let setDisabled;

function Counter() {
const [disabled, _setDisabled] = useState(true);
const [count, dispatch] = useReducer((state, action) => {
if (disabled) {
return state;
}
if (action.type === 'increment') {
return state + 1;
}
return state;
}, 0);

increment = () => dispatch({type: 'increment'});


setDisabled = _setDisabled;

Scheduler.log('Render disabled: ' + disabled);


Scheduler.log('Render count: ' + count);
return count;
}

ReactNoop.render(<Counter />);
await waitForAll(['Render disabled: true', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => {


// These increments should have no effect, since disabled=true
increment();
increment();
increment();
});
assertLog(['Render disabled: true', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => {


// Enabling the updater should *not* replay the previous increment() actions
setDisabled(false);
});
assertLog(['Render disabled: false', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');
});

it('useReducer does not replay previous no-op actions when props change', async
() => {
let setDisabled;
let increment;

function Counter({disabled}) {
const [count, dispatch] = useReducer((state, action) => {
if (disabled) {
return state;
}
if (action.type === 'increment') {
return state + 1;
}
return state;
}, 0);

increment = () => dispatch({type: 'increment'});

Scheduler.log('Render count: ' + count);


return count;
}
function App() {
const [disabled, _setDisabled] = useState(true);
setDisabled = _setDisabled;
Scheduler.log('Render disabled: ' + disabled);
return <Counter disabled={disabled} />;
}

ReactNoop.render(<App />);
await waitForAll(['Render disabled: true', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => {


// These increments should have no effect, since disabled=true
increment();
increment();
increment();
});
assertLog(['Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => {


// Enabling the updater should *not* replay the previous increment() actions
setDisabled(false);
});
assertLog(['Render disabled: false', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');
});

it('useReducer applies potential no-op changes if made relevant by other updates


in the batch', async () => {
let setDisabled;
let increment;

function Counter({disabled}) {
const [count, dispatch] = useReducer((state, action) => {
if (disabled) {
return state;
}
if (action.type === 'increment') {
return state + 1;
}
return state;
}, 0);

increment = () => dispatch({type: 'increment'});

Scheduler.log('Render count: ' + count);


return count;
}

function App() {
const [disabled, _setDisabled] = useState(true);
setDisabled = _setDisabled;
Scheduler.log('Render disabled: ' + disabled);
return <Counter disabled={disabled} />;
}

ReactNoop.render(<App />);
await waitForAll(['Render disabled: true', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => {


// Although the increment happens first (and would seem to do nothing since
disabled=true),
// because these calls are in a batch the parent updates first. This should
cause the child
// to re-render with disabled=false and *then* process the increment action,
which now
// increments the count and causes the component output to change.
increment();
setDisabled(false);
});
assertLog(['Render disabled: false', 'Render count: 1']);
expect(ReactNoop).toMatchRenderedOutput('1');
});

// Regression test. Covers a case where an internal state variable


// (`didReceiveUpdate`) is not reset properly.
it('state bail out edge case (#16359)', async () => {
let setCounterA;
let setCounterB;

function CounterA() {
const [counter, setCounter] = useState(0);
setCounterA = setCounter;
Scheduler.log('Render A: ' + counter);
useEffect(() => {
Scheduler.log('Commit A: ' + counter);
});
return counter;
}

function CounterB() {
const [counter, setCounter] = useState(0);
setCounterB = setCounter;
Scheduler.log('Render B: ' + counter);
useEffect(() => {
Scheduler.log('Commit B: ' + counter);
});
return counter;
}

const root = ReactNoop.createRoot(null);


await act(() => {
root.render(
<>
<CounterA />
<CounterB />
</>,
);
});
assertLog(['Render A: 0', 'Render B: 0', 'Commit A: 0', 'Commit B: 0']);

await act(() => {


setCounterA(1);

// In the same batch, update B twice. To trigger the condition we're


// testing, the first update is necessary to bypass the early
// bailout optimization.
setCounterB(1);
setCounterB(0);
});
assertLog([
'Render A: 1',
'Render B: 0',
'Commit A: 1',
// B should not fire an effect because the update bailed out
// 'Commit B: 0',
]);
});

it('should update latest rendered reducer when a preceding state receives a


render phase update', async () => {
// Similar to previous test, except using a preceding render phase update
// instead of new props.
let dispatch;
function App() {
const [step, setStep] = useState(0);
const [shadow, _dispatch] = useReducer(() => step, step);
dispatch = _dispatch;

if (step < 5) {
setStep(step + 1);
}

Scheduler.log(`Step: ${step}, Shadow: ${shadow}`);


return shadow;
}

ReactNoop.render(<App />);
await waitForAll([
'Step: 0, Shadow: 0',
'Step: 1, Shadow: 0',
'Step: 2, Shadow: 0',
'Step: 3, Shadow: 0',
'Step: 4, Shadow: 0',
'Step: 5, Shadow: 0',
]);
expect(ReactNoop).toMatchRenderedOutput('0');

await act(() => dispatch());


assertLog(['Step: 5, Shadow: 5']);
expect(ReactNoop).toMatchRenderedOutput('5');
});

it('should process the rest pending updates after a render phase update', async
() => {
// Similar to previous test, except using a preceding render phase update
// instead of new props.
let updateA;
let updateC;
function App() {
const [a, setA] = useState(false);
const [b, setB] = useState(false);
if (a !== b) {
setB(a);
}
// Even though we called setB above,
// we should still apply the changes to C,
// during this render pass.
const [c, setC] = useState(false);
updateA = setA;
updateC = setC;
return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`;
}

await act(() => ReactNoop.render(<App />));


expect(ReactNoop).toMatchRenderedOutput('abc');

await act(() => {


updateA(true);
// This update should not get dropped.
updateC(true);
});
expect(ReactNoop).toMatchRenderedOutput('ABC');
});

it("regression test: don't unmount effects on siblings of deleted nodes", async


() => {
const root = ReactNoop.createRoot();

function Child({label}) {
useLayoutEffect(() => {
Scheduler.log('Mount layout ' + label);
return () => {
Scheduler.log('Unmount layout ' + label);
};
}, [label]);
useEffect(() => {
Scheduler.log('Mount passive ' + label);
return () => {
Scheduler.log('Unmount passive ' + label);
};
}, [label]);
return label;
}

await act(() => {


root.render(
<>
<Child key="A" label="A" />
<Child key="B" label="B" />
</>,
);
});
assertLog([
'Mount layout A',
'Mount layout B',
'Mount passive A',
'Mount passive B',
]);

// Delete A. This should only unmount the effect on A. In the regression,


// B's effect would also unmount.
await act(() => {
root.render(
<>
<Child key="B" label="B" />
</>,
);
});
assertLog(['Unmount layout A', 'Unmount passive A']);

// Now delete and unmount B.


await act(() => {
root.render(null);
});
assertLog(['Unmount layout B', 'Unmount passive B']);
});

it('regression: deleting a tree and unmounting its effects after a reorder',


async () => {
const root = ReactNoop.createRoot();

function Child({label}) {
useEffect(() => {
Scheduler.log('Mount ' + label);
return () => {
Scheduler.log('Unmount ' + label);
};
}, [label]);
return label;
}

await act(() => {


root.render(
<>
<Child key="A" label="A" />
<Child key="B" label="B" />
</>,
);
});
assertLog(['Mount A', 'Mount B']);

await act(() => {


root.render(
<>
<Child key="B" label="B" />
<Child key="A" label="A" />
</>,
);
});
assertLog([]);

await act(() => {


root.render(null);
});

assertLog([
'Unmount B',
// In the regression, the reorder would cause Child A to "forget" that it
// contains passive effects. Then when we deleted the tree, A's unmount
// effect would not fire.
'Unmount A',
]);
});

// @gate enableSuspenseList
it('regression: SuspenseList causes unmounts to be dropped on deletion', async ()
=> {
function Row({label}) {
useEffect(() => {
Scheduler.log('Mount ' + label);
return () => {
Scheduler.log('Unmount ' + label);
};
}, [label]);
return (
<Suspense fallback="Loading...">
<AsyncText text={label} />
</Suspense>
);
}

function App() {
return (
<SuspenseList revealOrder="together">
<Row label="A" />
<Row label="B" />
</SuspenseList>
);
}

const root = ReactNoop.createRoot();


await act(() => {
root.render(<App />);
});
assertLog(['Suspend! [A]', 'Suspend! [B]', 'Mount A', 'Mount B']);

await act(async () => {


await resolveText('A');
});
assertLog(['Promise resolved [A]', 'A', 'Suspend! [B]']);

await act(() => {


root.render(null);
});
// In the regression, SuspenseList would cause the children to "forget" that
// it contains passive effects. Then when we deleted the tree, these unmount
// effects would not fire.
assertLog(['Unmount A', 'Unmount B']);
});

it('effect dependencies are persisted after a render phase update', async () => {
let handleClick;
function Test() {
const [count, setCount] = useState(0);

useEffect(() => {
Scheduler.log(`Effect: ${count}`);
}, [count]);

if (count > 0) {
setCount(0);
}

handleClick = () => setCount(2);

return <Text text={`Render: ${count}`} />;


}

await act(() => {


ReactNoop.render(<Test />);
});

assertLog(['Render: 0', 'Effect: 0']);

await act(() => {


handleClick();
});

assertLog(['Render: 0']);

await act(() => {


handleClick();
});

assertLog(['Render: 0']);

await act(() => {


handleClick();
});

assertLog(['Render: 0']);
});
});

You might also like