recoil
Version:
Recoil - A state management library for React
537 lines (507 loc) • 19.7 kB
Flow
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails oncall+recoil
* @flow strict-local
* @format
*/
/* 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';
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;
declare function counterAtom(persistence?: PersistenceSettings<number>): any;
declare function plusOneSelector(dep: RecoilValue<number>): any;
declare function plusOneAsyncSelector(dep: RecoilValue<number>): [RecoilValueReadOnly<number>, (number) => void];
declare function additionSelector(depA: RecoilValue<number>, depB: RecoilValue<number>): any;
declare function componentThatReadsAndWritesAtom<T>(recoilState: RecoilState<T>): [React.AbstractComponent<{...}>, (((T) => T) | T) => void];
declare function componentThatWritesAtom<T>(recoilState: RecoilState<T>): [any, (((T) => T) | T) => void];
declare function componentThatReadsTwoAtoms(one: any, two: any): any;
declare function componentThatReadsAtomWithCommitCount(recoilState: any): any;
declare function componentThatToggles(a: any, b: any): any;
declare function baseRenderCount(gks: any): number;
testRecoil('Component throws error when passing invalid node', async () => {
declare function Component(): any;
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);
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] = 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;
declare function Component(): any;
let __, setParentLocal;
declare function Parent(): any;
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 => ({
get
}) => get(atomA)
});
const Component = (jest.fn(function ReadFromSelector({
id
}) {
return useRecoilValue(selectAFakeId(id)); // $FlowFixMe[unclear-type]
}): Function);
let increment;
declare var App: () => any;
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;
declare function Switch(arg0: any): any;
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;
declare function Switch(arg0: any): any;
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(state);
return value;
}): any); // flowlint-line unclear-type:off
let toggleSwitch;
declare var Switch: () => any;
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'); // TODO: find out why OSS has additional render
if (reactMode().mode === 'LEGACY' && !gks.includes('recoil_suppress_rerender_in_callback')) {
baseCalls += 1; // @oss-only
}
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;
declare var Switch: () => any;
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);
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;
declare function Switch(arg0: any): any;
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();
declare function SetsDuringRendering(): any;
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;
declare function Component1(): any;
declare function Component2(): any; // 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.
declare function Component3(): any;
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;
declare function SetsDuringEffect(): any;
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;
}
declare function testWithOrder(order: any): any;
testWithOrder(['SetsDuringEffect', 'Batcher']);
testWithOrder(['Batcher', 'SetsDuringEffect']);
});
testRecoil('Sync React and Recoil state changes', ({
gks
}) => {
if (reactMode().mode === 'MUTABLE_SOURCE' && !gks.includes('recoil_suppress_rerender_in_callback')) {
return;
}
const myAtom = atom({
key: 'sync react recoil',
default: 0
});
let setReact, setRecoil;
declare function Component(): any;
const c = renderElements(<Component />);
expect(c.textContent).toBe('0 - 0'); // Set both React and Recoil state in the same batch and ensure the component
// render always seems consistent picture of both state changes.
act(() => {
setReact(1);
setRecoil(1);
});
expect(c.textContent).toBe('1 - 1');
});
testRecoil('Hooks cannot be used outside of RecoilRoot', () => {
const myAtom = atom({
key: 'hook outside RecoilRoot',
default: 'INVALID'
});
declare function Test(): any; // Make sure there is a friendly error message mentioning <RecoilRoot>
expect(() => renderUnwrappedElements(<Test />)).toThrow('<RecoilRoot>');
});