recoil
Version:
Recoil - A state management library for React
936 lines (811 loc) • 26.3 kB
Flow
/**
* 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.
*
* @flow strict-local
* @format
* @oncall recoil
*/
/* eslint-disable fb-www/react-no-useless-fragment */
;
import type {
RecoilState,
RecoilValue,
RecoilValueReadOnly,
} from '../../core/Recoil_RecoilValue';
import type {PersistenceSettings} from '../../recoil_values/Recoil_atom';
import type {Node} from 'react';
const {
getRecoilTestFn,
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils');
let React,
useEffect,
useState,
Profiler,
act,
Queue,
batchUpdates,
atom,
selector,
selectorFamily,
ReadsAtom,
renderElements,
renderUnwrappedElements,
recoilComponentGetRecoilValueCount_FOR_TESTING,
useRecoilState,
useRecoilStateLoadable,
useRecoilValue,
useSetRecoilState,
reactMode,
invariant;
const testRecoil = getRecoilTestFn(() => {
React = require('react');
({useEffect, useState, Profiler} = require('react'));
({act} = require('ReactTestUtils'));
Queue = require('../../adt/Recoil_Queue');
({batchUpdates} = require('../../core/Recoil_Batching'));
atom = require('../../recoil_values/Recoil_atom');
selector = require('../../recoil_values/Recoil_selector');
selectorFamily = require('../../recoil_values/Recoil_selectorFamily');
({
ReadsAtom,
renderElements,
renderUnwrappedElements,
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils'));
({reactMode} = require('../../core/Recoil_ReactMode'));
({
recoilComponentGetRecoilValueCount_FOR_TESTING,
useRecoilState,
useRecoilStateLoadable,
useRecoilValue,
useSetRecoilState,
} = require('../Recoil_Hooks'));
invariant = require('recoil-shared/util/Recoil_invariant');
});
let nextID = 0;
function counterAtom(persistence?: PersistenceSettings<number>) {
return atom({
key: `atom${nextID++}`,
default: 0,
persistence_UNSTABLE: persistence,
});
}
function plusOneSelector(dep: RecoilValue<number>) {
const fn = jest.fn(x => x + 1);
const sel = selector({
key: `selector${nextID++}`,
get: ({get}) => fn(get(dep)),
});
return [sel, fn];
}
function plusOneAsyncSelector(
dep: RecoilValue<number>,
): [RecoilValueReadOnly<number>, (number) => void] {
let nextTimeoutAmount = 100;
const fn = jest.fn(x => {
return new Promise(resolve => {
setTimeout(() => {
resolve(x + 1);
}, nextTimeoutAmount);
});
});
const sel = selector({
key: `selector${nextID++}`,
get: ({get}) => fn(get(dep)),
});
return [
// $FlowFixMe[incompatible-return]
sel,
x => {
nextTimeoutAmount = x;
},
];
}
function additionSelector(
depA: RecoilValue<number>,
depB: RecoilValue<number>,
) {
const fn = jest.fn((a, b) => a + b);
const sel = selector({
key: `selector${nextID++}`,
get: ({get}) => fn(get(depA), get(depB)),
});
return [sel, fn];
}
function componentThatReadsAndWritesAtom<T>(
recoilState: RecoilState<T>,
): [React.AbstractComponent<{...}>, ((T => T) | T) => void] {
let updateValue;
const Component = jest.fn(() => {
const [value, _updateValue] = useRecoilState(recoilState);
updateValue = _updateValue;
return value;
});
// flowlint-next-line unclear-type:off
return [(Component: any), (...args) => updateValue(...args)];
}
function componentThatWritesAtom<T>(
recoilState: RecoilState<T>,
// flowlint-next-line unclear-type:off
): [any, ((T => T) | T) => void] {
let updateValue;
const Component = jest.fn(() => {
updateValue = useSetRecoilState(recoilState);
return null;
});
// flowlint-next-line unclear-type:off
return [(Component: any), x => updateValue(x)];
}
function componentThatReadsTwoAtoms(
one: RecoilState<number>,
two: RecoilState<number> | RecoilValueReadOnly<number>,
) {
return (jest.fn(function ReadTwoAtoms() {
return `${useRecoilValue(one)},${useRecoilValue(two)}`;
}): any); // flowlint-line unclear-type:off
}
function componentThatReadsAtomWithCommitCount(
recoilState: RecoilState<number> | RecoilValueReadOnly<number>,
) {
const commit = jest.fn(() => {});
function ReadAtom() {
return (
// $FlowFixMe[invalid-tuple-arity]
<Profiler id="test" onRender={commit}>
{useRecoilValue(recoilState)}
</Profiler>
);
}
return [ReadAtom, commit];
}
function componentThatToggles(a: Node, b: null) {
const toggle = {current: () => invariant(false, 'bug in test code')};
const Toggle = () => {
const [value, setValue] = useState(false);
// $FlowFixMe[incompatible-type]
toggle.current = () => setValue(v => !v);
return value ? b : a;
};
return [Toggle, toggle];
}
function baseRenderCount(gks: Array<string>): number {
return reactMode().mode === 'LEGACY' &&
!gks.includes('recoil_suppress_rerender_in_callback')
? 1
: 0;
}
testRecoil('Component throws error when passing invalid node', async () => {
function Component() {
try {
// $FlowExpectedError[incompatible-call]
useRecoilValue('foo');
} catch (error) {
expect(error.message).toEqual(expect.stringContaining('useRecoilValue'));
return 'CAUGHT';
}
return 'INVALID';
}
const container = renderElements(<Component />);
expect(container.textContent).toEqual('CAUGHT');
});
testRecoil('Components are re-rendered when atoms change', async () => {
const anAtom = counterAtom();
const [Component, updateValue] = componentThatReadsAndWritesAtom(anAtom);
const container = renderElements(<Component />);
expect(container.textContent).toEqual('0');
act(() => updateValue(1));
expect(container.textContent).toEqual('1');
});
describe('Render counts', () => {
testRecoil(
'Component subscribed to atom is rendered just once',
({gks, strictMode}) => {
const BASE_CALLS = baseRenderCount(gks);
const sm = strictMode ? 2 : 1;
const anAtom = counterAtom();
const [Component, updateValue] = componentThatReadsAndWritesAtom(anAtom);
renderElements(
<>
<Component />
</>,
);
expect(Component).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm);
act(() => updateValue(1));
expect(Component).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm);
},
);
testRecoil('Write-only components are not subscribed', ({strictMode}) => {
const anAtom = counterAtom();
const [Component, updateValue] = componentThatWritesAtom(anAtom);
renderElements(
<>
<Component />
</>,
);
expect(Component).toHaveBeenCalledTimes(strictMode ? 2 : 1);
act(() => updateValue(1));
expect(Component).toHaveBeenCalledTimes(strictMode ? 2 : 1);
});
testRecoil(
'Component that depends on atom in multiple ways is rendered just once',
({gks, strictMode}) => {
const BASE_CALLS = baseRenderCount(gks);
const sm = strictMode ? 2 : 1;
const anAtom = counterAtom();
const [aSelector, _] = plusOneSelector(anAtom);
const [WriteComp, updateValue] = componentThatWritesAtom(anAtom);
// $FlowFixMe[incompatible-call]
const ReadComp = componentThatReadsTwoAtoms(anAtom, aSelector);
renderElements(
<>
<WriteComp />
<ReadComp />
</>,
);
expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm);
act(() => updateValue(1));
expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm);
},
);
testRecoil(
'Component that depends on multiple atoms via selector is rendered just once',
({gks}) => {
const BASE_CALLS = baseRenderCount(gks);
const atomA = counterAtom();
const atomB = counterAtom();
const [aSelector, _] = additionSelector(atomA, atomB);
const [ComponentA, updateValueA] = componentThatWritesAtom(atomA);
const [ComponentB, updateValueB] = componentThatWritesAtom(atomB);
const [ReadComp, commit] =
// $FlowFixMe[incompatible-call]
componentThatReadsAtomWithCommitCount(aSelector);
renderElements(
<>
<ComponentA />
<ComponentB />
<ReadComp />
</>,
);
expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 1);
act(() => {
batchUpdates(() => {
updateValueA(1);
updateValueB(1);
});
});
expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 2);
},
);
testRecoil(
'Component that depends on multiple atoms directly is rendered just once',
({gks, strictMode}) => {
const BASE_CALLS = baseRenderCount(gks);
const sm = strictMode ? 2 : 1;
const atomA = counterAtom();
const atomB = counterAtom();
const [ComponentA, updateValueA] = componentThatWritesAtom(atomA);
const [ComponentB, updateValueB] = componentThatWritesAtom(atomB);
const ReadComp = componentThatReadsTwoAtoms(atomA, atomB);
renderElements(
<>
<ComponentA />
<ComponentB />
<ReadComp />
</>,
);
expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm);
act(() => {
batchUpdates(() => {
updateValueA(1);
updateValueB(1);
});
});
expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm);
},
);
testRecoil(
'Component is rendered just once when atom is changed twice',
({gks}) => {
const BASE_CALLS = baseRenderCount(gks);
const atomA = counterAtom();
const [ComponentA, updateValueA] = componentThatWritesAtom(atomA);
const [ReadComp, commit] = componentThatReadsAtomWithCommitCount(atomA);
renderElements(
<>
<ComponentA />
<ReadComp />
</>,
);
expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 1);
act(() => {
batchUpdates(() => {
updateValueA(1);
updateValueA(2);
});
});
expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 2);
},
);
testRecoil(
'Component does not re-read atom when rendered due to another atom changing, parent re-render, or other state change',
() => {
// useSyncExternalStore() will always call getSnapshot() to see if it has
// mutated between render and commit.
if (
reactMode().mode === 'LEGACY' ||
reactMode().mode === 'SYNC_EXTERNAL_STORE'
) {
return;
}
const atomA = counterAtom();
const atomB = counterAtom();
let _, setLocal;
let _a, setA;
let _b, _setB;
function Component() {
[_, setLocal] = useState(0);
[_a, setA] = useRecoilState(atomA);
[_b, _setB] = useRecoilState(atomB);
return null;
}
let __, setParentLocal;
function Parent() {
[__, setParentLocal] = useState(0);
return <Component />;
}
renderElements(<Parent />);
const initialCalls =
recoilComponentGetRecoilValueCount_FOR_TESTING.current;
expect(initialCalls).toBeGreaterThan(0);
// No re-read when setting local state on the component:
act(() => {
setLocal(1);
});
expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(
initialCalls,
);
// No re-read when setting local state on its parent causing it to re-render:
act(() => {
setParentLocal(1);
});
expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(
initialCalls,
);
// Setting an atom causes a re-read for that atom only, not others:
act(() => {
setA(1);
});
expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(
initialCalls + 1,
);
},
);
testRecoil(
'Components re-render only one time if selectorFamily changed',
({gks, strictMode}) => {
const BASE_CALLS = baseRenderCount(gks);
const sm = strictMode ? 2 : 1;
const atomA = counterAtom();
const selectAFakeId = selectorFamily({
key: 'selectItem',
get:
(_id: number) =>
// $FlowFixMe[missing-local-annot]
({get}) =>
get(atomA),
});
const Component = (jest.fn(function ReadFromSelector({id}) {
return useRecoilValue(selectAFakeId(id));
}): ({id: number}) => React.Node);
let increment;
const App = () => {
const [state, setState] = useRecoilState(atomA);
increment = () => setState(s => s + 1);
return <Component id={state} />;
};
const container = renderElements(<App />);
let baseCalls = BASE_CALLS;
expect(container.textContent).toEqual('0');
expect(Component).toHaveBeenCalledTimes((baseCalls + 1) * sm);
act(() => increment());
if (
(reactMode().mode === 'LEGACY' &&
!gks.includes('recoil_suppress_rerender_in_callback')) ||
reactMode().mode === 'TRANSITION_SUPPORT'
) {
baseCalls += 1;
}
expect(container.textContent).toEqual('1');
expect(Component).toHaveBeenCalledTimes((baseCalls + 2) * sm);
},
);
});
describe('Component Subscriptions', () => {
testRecoil(
'Can subscribe to and also change an atom in the same batch',
() => {
const anAtom = counterAtom();
let setVisible;
function Switch({children}: $TEMPORARY$object<{children: Node}>) {
const [visible, mySetVisible] = useState(false);
setVisible = mySetVisible;
return visible ? children : null;
}
const [Component, updateValue] = componentThatWritesAtom(anAtom);
const container = renderElements(
<>
<Component />
<Switch>
<ReadsAtom atom={anAtom} />
</Switch>
</>,
);
expect(container.textContent).toEqual('');
act(() => {
batchUpdates(() => {
setVisible(true);
updateValue(1337);
});
});
expect(container.textContent).toEqual('1337');
},
);
testRecoil('Atom values are retained when atom has no subscribers', () => {
const anAtom = counterAtom();
let setVisible;
function Switch({children}: $TEMPORARY$object<{children: Node}>) {
const [visible, mySetVisible] = useState(true);
setVisible = mySetVisible;
return visible ? children : null;
}
const [Component, updateValue] = componentThatWritesAtom(anAtom);
const container = renderElements(
<>
<Component />
<Switch>
<ReadsAtom atom={anAtom} />
</Switch>
</>,
);
act(() => updateValue(1337));
expect(container.textContent).toEqual('1337');
act(() => setVisible(false));
expect(container.textContent).toEqual('');
act(() => setVisible(true));
expect(container.textContent).toEqual('1337');
});
testRecoil(
'Components unsubscribe from atoms when rendered without using them',
({gks, strictMode}) => {
const BASE_CALLS = baseRenderCount(gks);
const sm = strictMode ? 2 : 1;
const atomA = counterAtom();
const atomB = counterAtom();
const [WriteA, updateValueA] = componentThatWritesAtom(atomA);
const [WriteB, updateValueB] = componentThatWritesAtom(atomB);
const Component = (jest.fn(function Read({state}) {
const [value] = useRecoilState<mixed>(state);
return value;
}): any); // flowlint-line unclear-type:off
let toggleSwitch;
const Switch = () => {
const [value, setValue] = useState(false);
toggleSwitch = () => setValue(true);
return value ? (
<Component state={atomB} />
) : (
<Component state={atomA} />
);
};
const container = renderElements(
<>
<Switch />
<WriteA />
<WriteB />
</>,
);
let baseCalls = BASE_CALLS;
expect(container.textContent).toEqual('0');
expect(Component).toHaveBeenCalledTimes((baseCalls + 1) * sm);
act(() => updateValueA(1));
expect(container.textContent).toEqual('1');
expect(Component).toHaveBeenCalledTimes((baseCalls + 2) * sm);
if (
(reactMode().mode === 'LEGACY' &&
!gks.includes('recoil_suppress_rerender_in_callback')) ||
reactMode().mode === 'TRANSITION_SUPPORT'
) {
baseCalls += 1;
}
act(() => toggleSwitch());
expect(container.textContent).toEqual('0');
expect(Component).toHaveBeenCalledTimes((baseCalls + 3) * sm);
// Now update the atom that it used to be subscribed to but should be no longer:
act(() => updateValueA(2));
expect(container.textContent).toEqual('0');
expect(Component).toHaveBeenCalledTimes((baseCalls + 3) * sm); // Important part: same as before
// It is subscribed to the atom that it switched to:
act(() => updateValueB(3));
expect(container.textContent).toEqual('3');
expect(Component).toHaveBeenCalledTimes((baseCalls + 4) * sm);
},
);
testRecoil(
'Selectors unsubscribe from upstream when they have no subscribers',
() => {
const atomA = counterAtom();
const atomB = counterAtom();
const [WriteA, updateValueA] = componentThatWritesAtom(atomA);
// Do two layers of selectors to test that the unsubscribing is recursive:
const selectorMapFn1 = jest.fn(x => x);
const sel1 = selector({
key: 'selUpstream',
get: ({get}) => selectorMapFn1(get(atomA)),
});
const selectorMapFn2 = jest.fn(x => x);
const sel2 = selector({
key: 'selDownstream',
get: ({get}) => selectorMapFn2(get(sel1)),
});
let toggleSwitch;
const Switch = () => {
const [value, setValue] = useState(false);
toggleSwitch = () => setValue(true);
return value ? <ReadsAtom atom={atomB} /> : <ReadsAtom atom={sel2} />;
};
const container = renderElements(
<>
<Switch />
<WriteA />
</>,
);
expect(container.textContent).toEqual('0');
expect(selectorMapFn1).toHaveBeenCalledTimes(1);
expect(selectorMapFn2).toHaveBeenCalledTimes(1);
act(() => updateValueA(1));
expect(container.textContent).toEqual('1');
expect(selectorMapFn1).toHaveBeenCalledTimes(2);
expect(selectorMapFn2).toHaveBeenCalledTimes(2);
act(() => toggleSwitch());
expect(container.textContent).toEqual('0');
expect(selectorMapFn1).toHaveBeenCalledTimes(2);
expect(selectorMapFn2).toHaveBeenCalledTimes(2);
act(() => updateValueA(2));
expect(container.textContent).toEqual('0');
expect(selectorMapFn1).toHaveBeenCalledTimes(2);
expect(selectorMapFn2).toHaveBeenCalledTimes(2);
},
);
testRecoil(
'Unsubscribes happen in case of unmounting of a suspended component',
() => {
const anAtom = counterAtom();
const [aSelector, _selFn] = plusOneSelector(anAtom);
// $FlowFixMe[incompatible-call]
const [_asyncSel, _adjustTimeout] = plusOneAsyncSelector(aSelector);
// FIXME to implement
},
);
testRecoil(
'Selectors stay up to date if deps are changed while they have no subscribers',
() => {
const anAtom = counterAtom();
const [aSelector, _] = plusOneSelector(anAtom);
let setVisible;
function Switch({children}: $TEMPORARY$object<{children: Node}>) {
const [visible, mySetVisible] = useState(true);
setVisible = mySetVisible;
return visible ? children : null;
}
const [Component, updateValue] = componentThatWritesAtom(anAtom);
const container = renderElements(
<>
<Component />
<Switch>
<ReadsAtom atom={aSelector} />
</Switch>
</>,
);
act(() => updateValue(1));
expect(container.textContent).toEqual('2');
act(() => setVisible(false));
expect(container.textContent).toEqual('');
act(() => updateValue(2));
expect(container.textContent).toEqual('');
act(() => setVisible(true));
expect(container.textContent).toEqual('3');
},
);
testRecoil(
'Selector subscriptions are correct when a selector is unsubscribed the second time',
async () => {
// This regression test would fail by an exception being thrown because subscription refcounts
// would would fall below zero.
const anAtom = counterAtom();
const [sel, _] = plusOneSelector(anAtom);
const [Toggle, toggle] = componentThatToggles(
<ReadsAtom atom={sel} />,
null,
);
const container = renderElements(
<>
<Toggle />
</>,
);
expect(container.textContent).toEqual('1');
act(() => toggle.current());
expect(container.textContent).toEqual('');
act(() => toggle.current());
expect(container.textContent).toEqual('1');
act(() => toggle.current());
expect(container.textContent).toEqual('');
},
);
});
testRecoil('Can set an atom during rendering', () => {
const anAtom = counterAtom();
function SetsDuringRendering() {
const [value, setValue] = useRecoilState(anAtom);
if (value !== 1) {
setValue(1);
}
return null;
}
const container = renderElements(
<>
<SetsDuringRendering />
<ReadsAtom atom={anAtom} />
</>,
);
expect(container.textContent).toEqual('1');
});
testRecoil(
'Does not re-create "setter" function after setting a value',
({strictMode, concurrentMode}) => {
const sm = strictMode && concurrentMode ? 2 : 1;
const anAtom = counterAtom();
const anotherAtom = counterAtom();
let useRecoilStateCounter = 0;
let useRecoilStateErrorStatesCounter = 0;
let useTwoAtomsCounter = 0;
function Component1() {
const [_, setValue] = useRecoilState(anAtom);
useEffect(() => {
setValue(1);
useRecoilStateCounter += 1;
}, [setValue]);
return null;
}
function Component2() {
const [_, setValue] = useRecoilStateLoadable(anAtom);
useEffect(() => {
setValue(2);
useRecoilStateErrorStatesCounter += 1;
}, [setValue]);
return null;
}
// It is important to test here that the component will re-render with the
// new setValue() function for a new atom, even if the value of the new
// atom is the same as the previous value of the previous atom.
function Component3() {
const a = useTwoAtomsCounter > 0 ? anotherAtom : anAtom;
// setValue fn should change when we use a different atom.
const [, setValue] = useRecoilState(a);
useEffect(() => {
setValue(1);
useTwoAtomsCounter += 1;
}, [setValue]);
return null;
}
renderElements(
<>
<Component1 />
<Component2 />
<Component3 />
</>,
);
expect(useRecoilStateCounter).toBe(1 * sm);
expect(useRecoilStateErrorStatesCounter).toBe(1 * sm);
// Component3's effect is ran twice because the atom changes and we get a new setter.
// StrictMode renders twice, but we only change atoms once. So, only one extra count.
expect(useTwoAtomsCounter).toBe(strictMode && concurrentMode ? 3 : 2);
},
);
testRecoil(
'Can set atom during post-atom-setting effect (NOT during initial render)',
async () => {
const anAtom = counterAtom();
let done = false;
function SetsDuringEffect() {
const setValue = useSetRecoilState(anAtom);
useEffect(() => {
Queue.enqueueExecution('SetsDuringEffect', () => {
if (!done) {
setValue(1);
done = true;
}
});
});
return null;
}
const container = renderElements(
<>
<SetsDuringEffect />
<ReadsAtom atom={anAtom} />
</>,
);
expect(container.textContent).toEqual('1');
},
);
testRecoil(
'Can set atom during post-atom-setting effect regardless of effect order',
async ({concurrentMode}) => {
// TODO Test doesn't work in ConcurrentMode. Haven't investigated why,
// but it seems fragile with the Queue for enforcing order.
if (concurrentMode) {
return;
}
function testWithOrder(
order: $TEMPORARY$array<
$TEMPORARY$string<'Batcher'> | $TEMPORARY$string<'SetsDuringEffect'>,
>,
) {
const anAtom = counterAtom();
let q: Array<[string, () => mixed]> = [];
let seen = false;
const original = Queue.enqueueExecution;
try {
Queue.enqueueExecution = (s, f) => {
if (s === order[0] || seen) {
seen = true;
f();
q.forEach(([_, g]) => g());
} else {
q.push([s, f]);
}
};
function SetsDuringEffect() {
const [value, setValue] = useRecoilState(anAtom);
useEffect(() => {
Queue.enqueueExecution('SetsDuringEffect', () => {
if (value !== 1) {
setValue(1);
}
});
});
return null;
}
const [Comp, updateValue] = componentThatWritesAtom(anAtom);
const container = renderElements(
<>
<SetsDuringEffect />
<ReadsAtom atom={anAtom} />
<Comp />
</>,
);
q = [];
seen = false;
// Thus it appears that it only breaks on the initial render.
act(() => {
updateValue(0);
});
expect(container.textContent).toEqual('1');
} finally {
Queue.enqueueExecution = original;
}
}
testWithOrder(['SetsDuringEffect', 'Batcher']);
testWithOrder(['Batcher', 'SetsDuringEffect']);
},
);
testRecoil('Hooks cannot be used outside of RecoilRoot', () => {
const myAtom = atom({key: 'hook outside RecoilRoot', default: 'INVALID'});
function Test() {
useRecoilValue(myAtom);
return 'TEST';
}
// Make sure there is a friendly error message mentioning <RecoilRoot>
expect(() => renderUnwrappedElements(<Test />)).toThrow('<RecoilRoot>');
});