rax
Version:
A universal React-compatible render engine.
1,697 lines (1,492 loc) • 61.2 kB
JavaScript
/* @jsx createElement */
import createElement from '../createElement';
import Host from '../vdom/host';
import render from '../render';
import ServerDriver from 'driver-server';
import createContext from '../createContext';
import {useState, useContext, useEffect, useLayoutEffect, useRef, useReducer, useImperativeHandle, useMemo} from '../hooks';
import forwardRef from '../forwardRef';
import createRef from '../createRef';
import memo from '../memo';
import Component from '../vdom/component';
describe('hooks', () => {
function createNodeElement(tagName) {
return {
nodeType: 1,
tagName: tagName.toUpperCase(),
attributes: {},
style: {},
childNodes: [],
parentNode: null
};
}
beforeEach(function() {
Host.driver = ServerDriver;
jest.useFakeTimers();
});
afterEach(function() {
Host.driver = null;
jest.useRealTimers();
});
it('works inside a function component with useState', () => {
const container = createNodeElement('div');
function App(props) {
const [value, setValue] = useState(props.value);
return (
<span key={value}>{value}</span>
);
}
render(<App value={2} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('2');
});
it('lazy state initializer', () => {
const container = createNodeElement('div');
let stateUpdater = null;
function Counter(props) {
const [count, updateCount] = useState(() => {
return props.initialState + 1;
});
stateUpdater = updateCount;
return <span>{count}</span>;
}
render(<Counter initialState={1} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('2');
stateUpdater(10);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('10');
});
it('returns the same updater function every time', () => {
const container = createNodeElement('div');
let updaters = [];
function Counter() {
const [count, updateCount] = useState(0);
updaters.push(updateCount);
return <span>{count}</span>;
}
render(<Counter />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('0');
updaters[0](1);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('1');
updaters[0](count => count + 10);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('11');
expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]);
});
it('mount and update a function component with useLayoutEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter(props) {
useLayoutEffect(
() => {
++effectCounter;
return () => {
++cleanupCounter;
};
}
);
++renderCounter;
return <span>{props.count}</span>;
}
render(<Counter count={0} />, container);
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(1);
expect(cleanupCounter).toEqual(0);
render(<Counter count={1} />, container);
expect(renderCounter).toEqual(2);
expect(effectCounter).toEqual(2);
expect(cleanupCounter).toEqual(1);
render(<Counter count={2} />, container);
expect(renderCounter).toEqual(3);
expect(effectCounter).toEqual(3);
expect(cleanupCounter).toEqual(2);
});
it('mount and update a function component with useLayout and useLayoutEffect', () => {
const container = createNodeElement('div');
let logs = [];
function Counter(props) {
useEffect(
() => {
logs.push('create1');
return () => {
logs.push('destory1');
};
}
);
useLayoutEffect(
() => {
logs.push('create2');
return () => {
logs.push('destory2');
};
}
);
logs.push('render');
return <span>{props.count}</span>;
}
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(logs).toEqual([
'render', 'create2', 'create1'
]);
render(<Counter count={1} />, container);
jest.runAllTimers();
expect(logs).toEqual([
'render', 'create2', 'create1',
'render', 'destory2', 'create2', 'destory1', 'create1']);
render(<Counter count={2} />, container);
jest.runAllTimers();
expect(logs).toEqual([
'render', 'create2', 'create1',
'render', 'destory2', 'create2', 'destory1', 'create1',
'render', 'destory2', 'create2', 'destory1', 'create1']);
});
it('mount and update a function component with useEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter(props) {
useEffect(
() => {
++effectCounter;
return () => {
++cleanupCounter;
};
}
);
++renderCounter;
return <span>{props.count}</span>;
}
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(1);
expect(cleanupCounter).toEqual(0);
render(<Counter count={1} />, container);
jest.runAllTimers();
expect(renderCounter).toEqual(2);
expect(effectCounter).toEqual(2);
expect(cleanupCounter).toEqual(1);
render(<Counter count={2} />, container);
jest.runAllTimers();
expect(renderCounter).toEqual(3);
expect(effectCounter).toEqual(3);
expect(cleanupCounter).toEqual(2);
});
it('only update if the inputs has changed with useLayoutEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter(props) {
const [text, udpateText] = useState('foo');
useLayoutEffect(
() => {
++effectCounter;
udpateText('bar');
return () => {
++cleanupCounter;
};
},
[props.count]
);
++renderCounter;
return <span>{text}</span>;
}
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(2);
expect(cleanupCounter).toEqual(0);
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(3);
expect(cleanupCounter).toEqual(0);
render(<Counter count={1} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(2);
expect(renderCounter).toEqual(4);
expect(cleanupCounter).toEqual(1);
});
it('only update if the inputs has changed with useEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter(props) {
const [text, udpateText] = useState('foo');
useEffect(
() => {
++effectCounter;
udpateText('bar');
return () => {
++cleanupCounter;
};
},
[props.count]
);
++renderCounter;
return <span>{text}</span>;
}
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(2);
expect(cleanupCounter).toEqual(0);
render(<Counter count={0} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(3);
expect(cleanupCounter).toEqual(0);
render(<Counter count={1} />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(2);
expect(renderCounter).toEqual(4);
expect(cleanupCounter).toEqual(1);
});
it('update when the inputs has changed with useLayoutEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter(props) {
const [count, updateCount] = useState(0);
useLayoutEffect(
() => {
++effectCounter;
updateCount(1);
return () => {
++cleanupCounter;
};
},
[count]
);
++renderCounter;
return <span>{count}</span>;
}
render(<Counter />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(2);
expect(renderCounter).toEqual(2);
expect(cleanupCounter).toEqual(1);
});
it('would run only on mount and clean up on unmount with useLayoutEffect', () => {
const container = createNodeElement('div');
let renderCounter = 0;
let effectCounter = 0;
let cleanupCounter = 0;
function Counter() {
const [count, updateCount] = useState(0);
useLayoutEffect(
() => {
++effectCounter;
updateCount(count + 1);
return () => {
++cleanupCounter;
};
},
[]
);
++renderCounter;
return <span>{count}</span>;
}
render(<Counter />, container);
jest.runAllTimers();
expect(effectCounter).toEqual(1);
expect(renderCounter).toEqual(2);
expect(cleanupCounter).toEqual(0);
});
it('works inside a function component with useContext', () => {
const container = createNodeElement('div');
const Context = createContext(1);
function Consumer(props) {
const value = useContext(Context);
return (
<span>{value}</span>
);
}
function App(props) {
return (
<Context.Provider value={props.value}>
<Consumer />
</Context.Provider>
);
}
render(<App value={2} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('2');
// Update
render(<App value={3} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('3');
});
it('should return the same ref during re-renders', () => {
const container = createNodeElement('div');
let renderCounter = 0;
function Counter() {
const ref = useRef('val');
const [firstRef] = useState(ref);
if (firstRef !== ref) {
throw new Error('should never change');
}
renderCounter++;
return <span>{ref.current}</span>;
}
render(<Counter />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('val');
expect(renderCounter).toEqual(1);
render(<Counter foo="bar" />, container);
expect(renderCounter).toEqual(2);
expect(container.childNodes[0].childNodes[0].data).toEqual('val');
});
it('bails out in the render phase if all of the state is the same', () => {
const container = createNodeElement('div');
const logs = [];
logs.flush = function() {
const result = [...logs];
logs.length = 0;
return result;
};
function Child({text}) {
logs.push('Child: ' + text);
return text;
}
let setCounter1;
let setCounter2;
function Parent() {
const [counter1, _setCounter1] = useState(0);
setCounter1 = _setCounter1;
const [counter2, _setCounter2] = useState(0);
setCounter2 = _setCounter2;
const text = `${counter1}, ${counter2}`;
logs.push(`Parent: ${text}`);
useLayoutEffect(() => {
logs.push(`Effect: ${text}`);
});
return <Child text={text} />;
}
const root = render(<Parent />, container);
expect(logs.flush()).toEqual([
'Parent: 0, 0',
'Child: 0, 0',
'Effect: 0, 0',
]);
expect(container.childNodes[0].data).toEqual('0, 0');
// Normal update
setCounter1(1);
setCounter2(1);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 1, 1',
'Child: 1, 1',
'Effect: 1, 1',
]);
// This time, one of the state updates but the other one doesn't. So we
// can't bail out.
setCounter1(1);
setCounter2(2);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 1, 2',
'Child: 1, 2',
'Effect: 1, 2',
]);
// Lots of updates that eventually resolve to the current values.
setCounter1(9);
setCounter2(3);
setCounter1(4);
setCounter2(7);
setCounter1(1);
setCounter2(2);
jest.runAllTimers();
// Because the final values are the same as the current values, the
// component bails out.
expect(logs.flush()).toEqual(['Parent: 1, 2', 'Effect: 1, 2']);
// prepare to check SameValue
setCounter1(0 / -1);
setCounter2(NaN);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 0, NaN',
'Child: 0, NaN',
'Effect: 0, NaN',
]);
// check if re-setting to negative 0 / NaN still bails out
setCounter1(0 / -1);
setCounter2(NaN);
setCounter2(Infinity);
setCounter2(NaN);
jest.runAllTimers();
expect(logs.flush()).toEqual(['Parent: 0, NaN', 'Effect: 0, NaN']);
// check if changing negative 0 to positive 0 does not bail out
setCounter1(0);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 0, NaN',
'Child: 0, NaN',
'Effect: 0, NaN',
]);
});
it('bails out in render phase if all the state is the same and props bail out with memo', () => {
const container = createNodeElement('div');
const logs = [];
logs.flush = function() {
const result = [...logs];
logs.length = 0;
return result;
};
function Child({text}) {
logs.push('Child: ' + text);
return text;
}
let setCounter1;
let setCounter2;
function Parent({theme}) {
const [counter1, _setCounter1] = useState(0);
setCounter1 = _setCounter1;
const [counter2, _setCounter2] = useState(0);
setCounter2 = _setCounter2;
const text = `${counter1}, ${counter2} (${theme})`;
logs.push(`Parent: ${text}`);
return <Child text={text} />;
}
Parent = memo(Parent);
render(<Parent theme="light" />, container);
expect(logs.flush()).toEqual([
'Parent: 0, 0 (light)',
'Child: 0, 0 (light)',
]);
expect(container.childNodes[0].data).toEqual('0, 0 (light)');
setCounter1(1);
setCounter2(1);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 1, 1 (light)',
'Child: 1, 1 (light)',
]);
// This time, one of the state updates but the other one doesn't. So we
// can't bail out.
setCounter1(1);
setCounter2(2);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 1, 2 (light)',
'Child: 1, 2 (light)',
]);
// Updates bail out, but component still renders because props
// have changed
setCounter1(1);
setCounter2(2);
render(<Parent theme="dark" />, container);
jest.runAllTimers();
expect(logs.flush()).toEqual(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']);
// Both props and state bail out
setCounter1(1);
setCounter2(2);
render(<Parent theme="dark" />, container);
jest.runAllTimers();
expect(logs.flush()).toEqual([]);
});
it('never bails out if context has changed', () => {
const container = createNodeElement('div');
const logs = [];
logs.flush = function() {
const result = [...logs];
logs.length = 0;
return result;
};
const ThemeContext = createContext('light');
let setTheme;
function ThemeProvider({children}) {
const [theme, _setTheme] = useState('light');
logs.push('Theme: ' + theme);
setTheme = _setTheme;
return (
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
);
}
function Child({text}) {
logs.push('Child: ' + text);
return text;
}
let setCounter;
function Parent() {
const [counter, _setCounter] = useState(0);
setCounter = _setCounter;
const theme = useContext(ThemeContext);
const text = `${counter} (${theme})`;
logs.push(`Parent: ${text}`);
useLayoutEffect(() => {
logs.push(`Effect: ${text}`);
});
return <Child text={text} />;
}
const root = render(
<ThemeProvider>
<Parent />
</ThemeProvider>
, container);
expect(logs.flush()).toEqual([
'Theme: light',
'Parent: 0 (light)',
'Child: 0 (light)',
'Effect: 0 (light)',
]);
expect(container.childNodes[0].data).toEqual('0 (light)');
// Updating the theme to the same value doesn't cause the consumers
// to re-render.
setTheme('light');
expect(logs.flush()).toEqual([]);
expect(container.childNodes[0].data).toEqual('0 (light)');
// Normal update
setCounter(1);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 1 (light)',
'Child: 1 (light)',
'Effect: 1 (light)',
]);
expect(container.childNodes[0].data).toEqual('1 (light)');
// Update that doesn't change state, but the context changes, too, so it
// can't bail out
setCounter(1);
setTheme('dark');
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Theme: dark',
'Parent: 1 (dark)',
'Child: 1 (dark)',
'Effect: 1 (dark)',
]);
expect(container.childNodes[0].data).toEqual('1 (dark)');
});
it('rerender only once when context changes', () => {
const container = createNodeElement('div');
const context = createContext(4);
let logs = [];
function Parent() {
return <context.Provider value={this.props.value}>{this.props.children}</context.Provider>;
}
function Child() {
logs.push('Child');
const val = useContext(context);
return val;
}
let setValue;
function App() {
const [val, setVal] = useState(1);
setValue = setVal;
return <Parent value={val}><Child /></Parent>;
}
render(<App />, container);
expect(logs).toEqual([
'Child'
]);
expect(container.childNodes[0].data).toEqual('1');
logs = [];
setValue(2);
jest.runAllTimers();
expect(logs).toEqual([
'Child'
]);
expect(container.childNodes[0].data).toEqual('2');
});
it('destory function of a passive effect should call synchronously', () => {
const container = createNodeElement('div');
const event = {
listeners: [],
emit: () => event.listeners.forEach(f => f()),
off: (f) => event.listeners = event.listeners.filter(_f => _f !== f),
on: (f) => event.listeners.push(f)
};
function useForceUpdate() {
const [, setCount] = useState(0);
return () => setCount(count => count + 1);
}
function Child() {
const forceUpdate = useForceUpdate();
useEffect(() => {
event.on(forceUpdate);
return () => {
event.off(forceUpdate);
};
});
return <div>child</div>;
}
function App(props) {
useLayoutEffect(() => {
event.emit();
}, [props.type]);
return props.type === 1 ? <Child /> : null;
}
render(<App type={1} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('child');
render(<App type={2} />, container);
expect(container.childNodes[0].nodeType).toBe(8);
});
describe('updates during the render phase', () => {
it('restarts the render function and applies the new updates on top', () => {
const container = createNodeElement('div');
function ScrollView({row: newRow}) {
let [isScrollingDown, setIsScrollingDown] = useState(false);
let [row, setRow] = useState(null);
if (row !== newRow) {
// Row changed since last render. Update isScrollingDown.
setIsScrollingDown(row !== null && newRow > row);
setRow(newRow);
}
return <div>{`Scrolling down: ${isScrollingDown}`}</div>;
}
render(<ScrollView row={1} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: false');
render(<ScrollView row={5} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: true');
render(<ScrollView row={5} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: true');
render(<ScrollView row={10} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: true');
render(<ScrollView row={2} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: false');
render(<ScrollView row={2} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('Scrolling down: false');
});
it('updates multiple times within same render function', () => {
const container = createNodeElement('div');
let logs = [];
function Counter({row: newRow}) {
let [count, setCount] = useState(0);
if (count < 12) {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
logs.push('Render: ' + count);
return <span>{count}</span>;
}
render(<Counter />, container);
expect(logs).toEqual([
// Should increase by three each time
'Render: 0',
'Render: 3',
'Render: 6',
'Render: 9',
'Render: 12',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('12');
});
it('throws after too many iterations', () => {
const container = createNodeElement('div');
let logs = [];
function Counter({row: newRow}) {
let [count, setCount] = useState(0);
setCount(count + 1);
logs.push('Render: ' + count);
return <span>{count}</span>;
}
// render(<Counter />, container);
expect(() => {
render(<Counter />, container);
jest.runAllTimers();
}).toThrowError(
'Too many re-renders, the number of renders is limited to prevent an infinite loop.'
);
});
it('works with useReducer', () => {
const container = createNodeElement('div');
let logs = [];
function reducer(state, action) {
return action === 'increment' ? state + 1 : state;
}
function Counter({row: newRow}) {
let [count, dispatch] = useReducer(reducer, 0);
if (count < 3) {
dispatch('increment');
}
logs.push('Render: ' + count);
return <span>{count}</span>;
}
render(<Counter />, container);
expect(logs).toEqual([
'Render: 0',
'Render: 1',
'Render: 2',
'Render: 3',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('3');
});
it('uses reducer passed at time of render, not time of dispatch', () => {
const container = createNodeElement('div');
let logs = [];
// 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) {
let [reducer, setReducer] = useState(() => reducerA);
let [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);
}
}
logs.push('Render: ' + count);
return <span>{count}</span>;
}
Counter = forwardRef(Counter);
const counter = createRef(null);
render(<Counter ref={counter} />, container);
expect(logs).toEqual([
// The count should increase by alternating amounts of 10 and 1
// until we reach 21.
'Render: 0',
'Render: 10',
'Render: 11',
'Render: 21',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('21');
logs = [];
// Test that it works on update, too. This time the log is a bit different
// because we started with reducerB instead of reducerA.
counter.current.dispatch('reset');
jest.runAllTimers();
expect(logs).toEqual([
'Render: 0',
'Render: 1',
'Render: 11',
'Render: 12',
'Render: 22',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('22');
});
});
describe('useReducer', () => {
it('simple mount and update', () => {
const container = createNodeElement('div');
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 <span>{count}</span>;
}
Counter = forwardRef(Counter);
const counter = createRef(null);
render(<Counter ref={counter} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('0');
counter.current.dispatch(INCREMENT);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('1');
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('-2');
counter.current.dispatch(DECREMENT);
counter.current.dispatch(INCREMENT);
counter.current.dispatch(INCREMENT);
counter.current.dispatch(INCREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(INCREMENT);
jest.runAllTimers();
expect(container.childNodes[0].childNodes[0].data).toEqual('-2');
});
it('lazy init', () => {
const container = createNodeElement('div');
const logs = [];
logs.flush = function() {
const result = [...logs];
logs.length = 0;
return result;
};
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 Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props, ref) {
const [count, dispatch] = useReducer(reducer, props, p => {
logs.push('Init');
return p.initialCount;
});
useImperativeHandle(ref, () => ({dispatch}));
return <Text text={'Count: ' + count} />;
}
Counter = forwardRef(Counter);
const counter = createRef(null);
render(<Counter initialCount={10} ref={counter} />, container);
expect(logs.flush()).toEqual(['Init', 'Count: 10']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 10');
counter.current.dispatch(INCREMENT);
jest.runAllTimers();
expect(logs.flush()).toEqual(['Count: 11']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 11');
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
counter.current.dispatch(DECREMENT);
jest.runAllTimers();
expect(logs.flush()).toEqual(['Count: 8']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 8');
});
it('works with effect', () => {
const container = createNodeElement('div');
const logs = [];
logs.flush = function() {
const result = [...logs];
logs.length = 0;
return result;
};
function Child({text}) {
logs.push('Child: ' + text);
return text;
}
function reducer(state, action) {
return action;
}
let setCounter1;
let setCounter2;
function Parent() {
const [counter1, _setCounter1] = useReducer(reducer, 0);
setCounter1 = _setCounter1;
const [counter2, _setCounter2] = useReducer(reducer, 0);
setCounter2 = _setCounter2;
const text = `${counter1}, ${counter2}`;
logs.push(`Parent: ${text}`);
useLayoutEffect(() => {
logs.push(`Effect: ${text}`);
});
return <Child text={text} />;
}
const root = render(<Parent />, container);
expect(logs.flush()).toEqual([
'Parent: 0, 0',
'Child: 0, 0',
'Effect: 0, 0',
]);
expect(container.childNodes[0].data).toEqual('0, 0');
// Normal update
setCounter1(1);
setCounter1(2);
setCounter1(2);
setCounter1(3);
setCounter2(2);
setCounter1(3);
setCounter1(3);
setCounter2(4);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 3, 4',
'Child: 3, 4',
'Effect: 3, 4',
]);
setCounter1(2);
setCounter2(2);
jest.runAllTimers();
expect(logs.flush()).toEqual([
'Parent: 2, 2',
'Child: 2, 2',
'Effect: 2, 2',
]);
setCounter1(2);
setCounter2(2);
jest.runAllTimers();
expect(logs.flush()).toEqual([]);
});
});
describe('useEffect', () => {
it('simple mount and update', () => {
const container = createNodeElement('div');
let logs = [];
function Counter(props) {
useEffect(() => {
logs.push(`Did commit [${props.count}]`);
});
return <span>{props.count}</span>;
}
render(<Counter count={0} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('0');
jest.runAllTimers();
expect(logs).toEqual(['Did commit [0]']);
logs = [];
render(<Counter count={1} />, container);
expect(container.childNodes[0].childNodes[0].data).toEqual('1');
// Effects are deferred until after the commit
jest.runAllTimers();
expect(logs).toEqual(['Did commit [1]']);
});
it('flushes passive effects even with sibling deletions', () => {
const container = createNodeElement('div');
let logs = [];
function LayoutEffect(props) {
useLayoutEffect(() => {
logs.push('Layout effect');
});
logs.push('Layout');
return <span>Layout</span>;
}
function PassiveEffect(props) {
useEffect(() => {
logs.push('Passive effect');
}, []);
logs.push('Passive');
return <span>Passive</span>;
}
let passive = <PassiveEffect key="p" />;
render([<LayoutEffect key="l" />, passive], container);
expect(logs).toEqual(['Layout', 'Passive', 'Layout effect']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Layout');
expect(container.childNodes[1].childNodes[0].data).toEqual('Passive');
logs = [];
// Destroying the first child shouldn't prevent the passive effect from
// being executed
render([passive], container);
jest.runAllTimers();
expect(logs).toEqual(['Passive effect']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Passive');
// (No effects are left to flush.)
logs = [];
jest.runAllTimers();
expect(logs).toEqual([]);
});
it('flushes passive effects even if siblings schedule an update', () => {
const container = createNodeElement('div');
let logs = [];
function PassiveEffect(props) {
useEffect(() => {
logs.push('Passive effect');
});
logs.push('Passive');
return <span>Passive</span>;
}
function LayoutEffect(props) {
let [count, setCount] = useState(0);
useLayoutEffect(() => {
// Scheduling work shouldn't interfere with the queued passive effect
if (count === 0) {
setCount(1);
}
logs.push('Layout effect ' + count);
});
logs.push('Layout');
return <span>Layout</span>;
}
render([<PassiveEffect key="p" />, <LayoutEffect key="l" />], container);
jest.runAllTimers();
expect(logs).toEqual([
'Passive',
'Layout',
'Layout effect 0',
'Passive effect',
'Layout',
'Layout effect 1',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('Passive');
expect(container.childNodes[1].childNodes[0].data).toEqual('Layout');
});
it('flushes passive effects even if siblings schedule a new root', () => {
const container = createNodeElement('div');
const container2 = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function PassiveEffect(props) {
useEffect(() => {
logs.push('Passive effect');
}, []);
return <Text text="Passive" />;
}
function LayoutEffect(props) {
useLayoutEffect(() => {
logs.push('Layout effect');
// Scheduling work shouldn't interfere with the queued passive effect
render(<Text text="New Root" />, container2);
});
return <Text text="Layout" />;
}
render([<PassiveEffect key="p" />, <LayoutEffect key="l" />], container);
jest.runAllTimers();
expect(logs).toEqual([
'Passive',
'Layout',
'Layout effect',
'Passive effect',
'New Root',
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('Passive');
expect(container.childNodes[1].childNodes[0].data).toEqual('Layout');
});
it(
'flushes effects serially by flushing old effects before flushing ' +
"new ones, if they haven't already fired",
() => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function getCommittedText() {
return container.childNodes[0].childNodes[0].data;
}
function Counter(props) {
useEffect(() => {
logs.push(
`Committed state when effect was fired: ${getCommittedText()}`,
);
});
return <Text text={props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual([0]);
expect(container.childNodes[0].childNodes[0].data).toEqual('0');
// Before the effects have a chance to flush, schedule another update
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual([
// The previous effect flushes before the reconciliation
'Committed state when effect was fired: 0',
1,
]);
expect(container.childNodes[0].childNodes[0].data).toEqual('1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([
'Committed state when effect was fired: 1',
]);
},
);
it('updates have async priority', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(
() => {
logs.push(`Schedule update [${props.count}]`);
updateCount(props.count);
},
[props.count],
);
return <Text text={'Count: ' + count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: (empty)']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: (empty)');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Schedule update [0]', 'Count: 0']);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Schedule update [1]', 'Count: 1']);
});
it('updates have async priority even if effects are flushed early', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(
() => {
logs.push(`Schedule update [${props.count}]`);
updateCount(props.count);
},
[props.count],
);
return <Text text={'Count: ' + count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: (empty)']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: (empty)');
logs = [];
// Rendering again should flush the previous commit's effects
jest.runAllTimers();
render(<Counter count={1} />, container);
expect(logs).toEqual(['Schedule update [0]', 'Count: 0', 'Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Schedule update [1]', 'Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
});
it('flushes serial effects before enqueueing work', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
let _updateCount;
function Counter(props) {
const [count, updateCount] = useState(0);
_updateCount = updateCount;
useEffect(() => {
logs.push('Will set count to 1');
updateCount(1);
}, []);
return <Text text={'Count: ' + count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
// Enqueuing this update forces the passive effect to be flushed --
// updateCount(1) happens first, so 2 wins.
_updateCount(2);
jest.runAllTimers();
expect(logs).toEqual(['Will set count to 1', 'Count: 2']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 2');
});
it(
'in sync mode, useEffect is deferred and updates finish synchronously ' +
'(in a single batch)',
() => {
const container = createNodeElement('div');
let logs = [];
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],
);
logs.push('Count: ' + count);
return <span>{'Count: ' + count}</span>;
}
render(<Counter count={0} />, container);
// Even in sync mode, effects are deferred until after paint
expect(logs).toEqual(['Count: (empty)']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: (empty)');
// Now fire the effects
logs = [];
jest.runAllTimers();
// There were multiple updates, but there should only be a
// single render
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
},
);
it(
'in sync mode, useEffect is deferred and updates finish synchronously ' +
'(in a single batch with different state)',
() => {
const container = createNodeElement('div');
let logs = [];
function Counter(props) {
const [count, updateCount] = useState('(empty)');
useEffect(
() => {
// Update multiple times. These should all be batched together in
// a single render.
updateCount(2);
updateCount(3);
updateCount(4);
updateCount(5);
updateCount(6);
updateCount(7);
},
[props.count],
);
logs.push('Count: ' + count);
return <span>{'Count: ' + count}</span>;
}
render(<Counter count={0} />, container);
// Even in sync mode, effects are deferred until after paint
expect(logs).toEqual(['Count: (empty)']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: (empty)');
// Now fire the effects
logs = [];
jest.runAllTimers();
// There were multiple updates, but there should only be a
// single render
expect(logs).toEqual(['Count: 7']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 7');
},
);
it('unmounts previous effect', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
useEffect(() => {
logs.push(`Did create [${props.count}]`);
return () => {
logs.push(`Did destroy [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Did create [0]']);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([
'Did destroy [0]',
'Did create [1]',
]);
});
it('unmounts on deletion', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
useEffect(() => {
logs.push(`Did create [${props.count}]`);
return () => {
logs.push(`Did destroy [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Did create [0]']);
logs = [];
render(<div />, container);
expect(logs).toEqual(['Did destroy [0]']);
expect(container.childNodes[0].tagName).toEqual('DIV');
});
it('unmounts on deletion after skipped effect', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
useEffect(() => {
logs.push(`Did create [${props.count}]`);
return () => {
logs.push(`Did destroy [${props.count}]`);
};
}, []);
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Did create [0]']);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([]);
logs = [];
render([], container);
expect(logs).toEqual(['Did destroy [0]']);
expect(container.childNodes).toEqual([]);
});
it('always fires effects if no dependencies are provided', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function effect() {
logs.push('Did create');
return () => {
logs.push('Did destroy');
};
}
function Counter(props) {
useEffect(effect);
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Did create']);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Did destroy', 'Did create']);
logs = [];
render([], container);
expect(logs).toEqual(['Did destroy']);
expect(container.childNodes).toEqual([]);
});
it('multiple effects', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
useEffect(() => {
logs.push(`Did commit 1 [${props.count}]`);
});
useEffect(() => {
logs.push(`Did commit 2 [${props.count}]`);
});
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([
'Did commit 1 [0]',
'Did commit 2 [0]',
]);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([
'Did commit 1 [1]',
'Did commit 2 [1]',
]);
});
it('unmounts all previous effects before creating any new ones', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter(props) {
useEffect(() => {
logs.push(`Mount A [${props.count}]`);
return () => {
logs.push(`Unmount A [${props.count}]`);
};
});
useEffect(() => {
logs.push(`Mount B [${props.count}]`);
return () => {
logs.push(`Unmount B [${props.count}]`);
};
});
return <Text text={'Count: ' + props.count} />;
}
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 0');
logs = [];
jest.runAllTimers();
expect(logs).toEqual(['Mount A [0]', 'Mount B [0]']);
logs = [];
render(<Counter count={1} />, container);
expect(logs).toEqual(['Count: 1']);
expect(container.childNodes[0].childNodes[0].data).toEqual('Count: 1');
logs = [];
jest.runAllTimers();
expect(logs).toEqual([
'Unmount A [0]',
'Mount A [1]',
'Unmount B [0]',
'Mount B [1]',
]);
});
it('works with memo', () => {
const container = createNodeElement('div');
let logs = [];
function Text(props) {
logs.push(props.text);
return <span>{props.text}</span>;
}
function Counter({count}) {
useLayoutEffect(() => {
logs.push('Mount: ' + count);
return () => logs.push('Unmount: ' + count);
});
return <Text text={'Count: ' + count} />;
}
Counter = memo(Counter);
render(<Counter count={0} />, container);
expect(logs).toEqual(['Count: 0', 'Mount: 0']);
expect(