ReactHooksWithNoopRenderer Test
ReactHooksWithNoopRenderer Test
/* 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;
}
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(() => {});
}
// 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" />);
// Resume rendering
await waitForAll(['Total: 11']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Total: 11" />);
});
});
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.',
);
// @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.",
);
expect(firstUpdater).toBe(secondUpdater);
});
ReactNoop.render(<Counter />);
await waitForAll([]);
ReactNoop.render(null);
await waitForAll([]);
await act(() => _updateCount(1));
});
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" />);
function Bar({triggerUpdate}) {
if (triggerUpdate) {
setStep(x => x + 1);
}
return <Text text="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('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} />);
});
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} />);
});
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.
// 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} />);
});
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;
}
await waitForAll(['A:0']);
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
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;
describe('useReducer', () => {
it('simple mount and update', async () => {
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
assertLog(['Count: -2']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: -2" />);
});
assertLog(['Count: 8']);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 8" />);
});
Counter = forwardRef(Counter);
const counter = React.createRef(null);
ReactNoop.render(<Counter ref={counter} />);
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]']);
});
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} />);
});
// 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;
}
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 unmount for the parent that unmounts children with pending
update.
ReactNoop.unstable_runWithPriority(ContinuousEventPriority, () => {
setParentState(false);
});
await waitForPaint(['Parent false render', 'Parent false commit']);
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;
}
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;
}
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;
}
ReactNoop.unmountRootWithID('root');
await waitForAll(['passive destroy']);
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;
}
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;
}
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} />;
}
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)" />);
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',
]);
}
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} />;
}
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} />);
});
ReactNoop.render(null);
await waitForAll(['Did destroy [0]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
assertLog([]);
ReactNoop.render(null);
await waitForAll(['Did destroy [0]']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
assertLog(['Did create']);
ReactNoop.render(null);
await waitForAll(['Did destroy']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Count: 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" />);
});
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]',
'Oops!',
// Clean up effect A. There's no effect B to clean-up, because it
// never mounted.
'Unmount A [0]',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// 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]']);
});
ReactNoop.render(null);
await waitFor(['Unmount: 1']);
expect(ReactNoop).toMatchRenderedOutput(null);
});
beforeEach(() => {
BrokenUseEffectCleanup = function () {
useEffect(() => {
Scheduler.log('BrokenUseEffectCleanup useEffect');
return () => {
Scheduler.log('BrokenUseEffectCleanup useEffect destroy');
throw new Error('Expected error');
};
}, []);
assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect',
]);
assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect destroy',
'LogOnlyErrorBoundary componentDidCatch',
]);
});
assertLog([
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect destroy',
'LogOnlyErrorBoundary componentDidCatch',
]);
});
assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect',
]);
assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect destroy',
'ErrorBoundary static getDerivedStateFromError',
'ErrorBoundary render error',
'ErrorBoundary componentDidCatch',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="ErrorBoundary fallback" />,
);
});
assertLog([
'ErrorBoundary render success',
'BrokenUseEffectCleanup useEffect',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
});
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;
}
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;
}
componentDidUpdate() {}
render() {
return null;
}
}
// Update
await act(async () => {
ReactNoop.render(
<>
<CounterA />
<CounterB />
</>,
);
await waitForAll([
'Get Snapshot',
'Destroy insertion',
'Create insertion',
]);
});
// Unmount everything
await act(async () => {
ReactNoop.render(null);
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');
});
// Unmount everything
await act(async () => {
ReactNoop.render(null);
await waitForPaint([
'Destroy insertion [current: 0]',
'Destroy layout [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;
}
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]',
]);
});
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;
}
// 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('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;
}
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;
}
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} />;
}
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);
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" />);
});
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} />
</>
);
}
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} />;
}
function computeA() {
Scheduler.log('compute A');
return 'A';
}
function computeB() {
Scheduler.log('compute B');
return 'B';
}
function compute(val) {
Scheduler.log('compute ' + val);
return val;
}
describe('useImperativeHandle', () => {
it('does not update when deps are the same', async () => {
const INCREMENT = 'INCREMENT';
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);
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);
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);
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 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);
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>
</>
);
}
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]';
}
// updateC(4);
// expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']);
// expect(ReactNoop).toMatchRenderedOutput(<span prop="A: 2, B: 3, C: 4"
/>]);
});
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]';
}
if (props.showMore) {
useEffect(() => {
Scheduler.log('Mount B');
return () => {
Scheduler.log('Unmount B');
};
}, []);
}
return null;
}
assertLog(['Mount A']);
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;
}
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);
ReactNoop.render(<Counter />);
await waitForAll(['Render disabled: true', '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);
ReactNoop.render(<App />);
await waitForAll(['Render disabled: true', 'Render count: 0']);
expect(ReactNoop).toMatchRenderedOutput('0');
function Counter({disabled}) {
const [count, dispatch] = useReducer((state, action) => {
if (disabled) {
return state;
}
if (action.type === 'increment') {
return state + 1;
}
return state;
}, 0);
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');
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;
}
if (step < 5) {
setStep(step + 1);
}
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');
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'}`;
}
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;
}
function Child({label}) {
useEffect(() => {
Scheduler.log('Mount ' + label);
return () => {
Scheduler.log('Unmount ' + label);
};
}, [label]);
return label;
}
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>
);
}
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);
}
assertLog(['Render: 0']);
assertLog(['Render: 0']);
assertLog(['Render: 0']);
});
});