recoil
Version:
Recoil - A state management library for React
1,459 lines (1,400 loc) • 45.2 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
*/
;
import type { Loadable } from '../../adt/Recoil_Loadable';
import type { RecoilValue } from '../../core/Recoil_RecoilValue';
const {
getRecoilTestFn
} = require('../../__test_utils__/Recoil_TestingUtils');
let React, store, atom, nullthrows, useEffect, useState, Profiler, noWait, act, useRecoilCallback, useRecoilState, useRecoilValue, useRecoilValueLoadable, useSetRecoilState, useResetRecoilState, constSelector, errorSelector, getRecoilValueAsLoadable, setRecoilValue, selector, asyncSelector, ReadsAtom, renderElements, resolvingAsyncSelector, loadingAsyncSelector, flushPromisesAndTimers, DefaultValue, mutableSourceExists, freshSnapshot;
const testRecoil = getRecoilTestFn(() => {
const {
makeStore
} = require('../../__test_utils__/Recoil_TestingUtils');
React = require('react');
({
useEffect,
useState,
Profiler
} = require('react'));
({
act
} = require('ReactTestUtils'));
atom = require('../Recoil_atom');
({
useRecoilState,
useRecoilValue,
useRecoilValueLoadable,
useSetRecoilState,
useResetRecoilState
} = require('../../hooks/Recoil_Hooks'));
useRecoilCallback = require('../../hooks/Recoil_useRecoilCallback');
constSelector = require('../Recoil_constSelector');
errorSelector = require('../Recoil_errorSelector');
nullthrows = require('../../util/Recoil_nullthrows');
({
getRecoilValueAsLoadable,
setRecoilValue
} = require('../../core/Recoil_RecoilValueInterface'));
selector = require('../Recoil_selector');
({
freshSnapshot
} = require('../../core/Recoil_Snapshot'));
({
asyncSelector,
ReadsAtom,
renderElements,
resolvingAsyncSelector,
loadingAsyncSelector,
flushPromisesAndTimers
} = require('../../__test_utils__/Recoil_TestingUtils'));
({
noWait
} = require('../Recoil_WaitFor'));
({
DefaultValue
} = require('../../core/Recoil_Node'));
({
mutableSourceExists
} = require('../../util/Recoil_mutableSource'));
store = makeStore();
});
declare function getLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T>;
declare function getValue<T>(recoilValue: RecoilValue<T>): T;
declare function getPromise<T>(recoilValue: RecoilValue<T>): Promise<T>;
declare function getError(recoilValue: any): Error;
declare function setValue(recoilState: any, value: any): any;
declare function resetValue(recoilState: any): any;
testRecoil('useRecoilState - static selector', () => {
const staticSel = constSelector('HELLO');
const c = renderElements(<ReadsAtom atom={staticSel} />);
expect(c.textContent).toEqual('"HELLO"');
});
testRecoil('selector get', () => {
const staticSel = constSelector('HELLO');
const selectorRO = selector({
key: 'selector/get',
get: ({
get
}) => get(staticSel)
});
expect(getValue(selectorRO)).toEqual('HELLO');
});
testRecoil('selector set', () => {
const myAtom = atom({
key: 'selector/set/atom',
default: 'DEFAULT'
});
const selectorRW = selector({
key: 'selector/set',
get: ({
get
}) => get(myAtom),
set: ({
set
}, newValue) => set(myAtom, newValue instanceof DefaultValue ? newValue : 'SET: ' + newValue)
});
expect(getValue(selectorRW)).toEqual('DEFAULT');
setValue(myAtom, 'SET ATOM');
expect(getValue(selectorRW)).toEqual('SET ATOM');
setValue(selectorRW, 'SET SELECTOR');
expect(getValue(selectorRW)).toEqual('SET: SET SELECTOR');
resetValue(selectorRW);
expect(getValue(selectorRW)).toEqual('DEFAULT');
});
testRecoil('selector reset', () => {
const myAtom = atom({
key: 'selector/reset/atom',
default: 'DEFAULT'
});
const selectorRW = selector({
key: 'selector/reset',
get: ({
get
}) => get(myAtom),
set: ({
reset
}) => reset(myAtom)
});
expect(getValue(selectorRW)).toEqual('DEFAULT');
setValue(myAtom, 'SET ATOM');
expect(getValue(selectorRW)).toEqual('SET ATOM');
setValue(selectorRW, 'SET SELECTOR');
expect(getValue(selectorRW)).toEqual('DEFAULT');
});
testRecoil('useRecoilState - resolved async selector', async () => {
const resolvingSel = resolvingAsyncSelector('HELLO');
const c = renderElements(<ReadsAtom atom={resolvingSel} />);
expect(c.textContent).toEqual('loading');
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"HELLO"');
});
testRecoil('selector - evaluate to RecoilValue', () => {
const atomA = atom({
key: 'selector/const atom A',
default: 'A'
});
const atomB = atom({
key: 'selector/const atom B',
default: 'B'
});
const inputAtom = atom({
key: 'selector/input',
default: 'a'
});
const mySelector = selector<string>({
key: 'selector/output recoil value',
get: ({
get
}) => get(inputAtom) === 'a' ? atomA : atomB
});
expect(getValue(mySelector)).toEqual('A');
setValue(inputAtom, 'b');
expect(getValue(mySelector)).toEqual('B');
});
testRecoil('selector - catching exceptions', () => {
const throwingSel = errorSelector('MY ERROR');
expect(getValue(throwingSel)).toBeInstanceOf(Error);
const catchingSelector = selector({
key: 'selector/catching selector',
get: ({
get
}) => {
try {
return get(throwingSel);
} catch (e) {
expect(e instanceof Error).toBe(true);
expect(e.message).toContain('MY ERROR');
return 'CAUGHT';
}
}
});
expect(getValue(catchingSelector)).toEqual('CAUGHT');
});
testRecoil('selector - catching exception (non Error)', () => {
const throwingSel = selector({
key: '__error/non Error thrown',
get: () => {
// eslint-disable-next-line no-throw-literal
throw 'MY ERROR';
}
});
const catchingSelector = selector({
key: 'selector/catching selector',
get: ({
get
}) => {
try {
return get(throwingSel);
} catch (e) {
expect(e).toBe('MY ERROR');
return 'CAUGHT';
}
}
});
expect(getValue(catchingSelector)).toEqual('CAUGHT');
});
testRecoil('selector - catching loads', () => {
const loadingSel = resolvingAsyncSelector('READY');
expect(getValue(loadingSel) instanceof Promise).toBe(true);
const blockedSelector = selector({
key: 'selector/blocked selector',
get: ({
get
}) => get(loadingSel)
});
expect(getValue(blockedSelector) instanceof Promise).toBe(true);
const bypassSelector = selector({
key: 'selector/bypassing selector',
get: ({
get
}) => {
try {
return get(loadingSel);
} catch (promise) {
expect(promise instanceof Promise).toBe(true);
return 'BYPASS';
}
}
});
expect(getValue(bypassSelector)).toBe('BYPASS');
act(() => jest.runAllTimers());
expect(getValue(bypassSelector)).toEqual('READY');
});
testRecoil('useRecoilState - selector catching exceptions', () => {
const throwingSel = errorSelector('MY ERROR');
const c1 = renderElements(<ReadsAtom atom={throwingSel} />);
expect(c1.textContent).toEqual('error');
const catchingSelector = selector({
key: 'useRecoilState/catching selector',
get: ({
get
}) => {
try {
return get(throwingSel);
} catch (e) {
expect(e instanceof Error).toBe(true);
expect(e.message).toContain('MY ERROR');
return 'CAUGHT';
}
}
});
const c2 = renderElements(<ReadsAtom atom={catchingSelector} />);
expect(c2.textContent).toEqual('"CAUGHT"');
});
testRecoil('useRecoilState - selector catching exceptions (non Errors)', () => {
const throwingSel = selector({
key: '__error/non Error thrown',
get: () => {
// eslint-disable-next-line no-throw-literal
throw 'MY ERROR';
}
});
const c1 = renderElements(<ReadsAtom atom={throwingSel} />);
expect(c1.textContent).toEqual('error');
const catchingSelector = selector({
key: 'useRecoilState/catching selector',
get: ({
get
}) => {
try {
return get(throwingSel);
} catch (e) {
expect(e).toBe('MY ERROR');
return 'CAUGHT';
}
}
});
const c2 = renderElements(<ReadsAtom atom={catchingSelector} />);
expect(c2.textContent).toEqual('"CAUGHT"');
});
testRecoil('useRecoilState - async selector', async () => {
const resolvingSel = resolvingAsyncSelector('READY'); // On first read it is blocked on the async selector
const c1 = renderElements(<ReadsAtom atom={resolvingSel} />);
expect(c1.textContent).toEqual('loading'); // When that resolves the data is ready
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(c1.textContent).toEqual('"READY"');
});
testRecoil('useRecoilState - selector blocked on dependency', async () => {
const resolvingSel = resolvingAsyncSelector('READY');
const blockedSelector = selector({
key: 'useRecoilState/blocked selector',
get: ({
get
}) => get(resolvingSel)
}); // On first read, the selectors dependency is still loading
const c2 = renderElements(<ReadsAtom atom={blockedSelector} />);
expect(c2.textContent).toEqual('loading'); // When the dependency resolves, the data is ready
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(c2.textContent).toEqual('"READY"');
});
testRecoil('useRecoilState - selector catching loads', async () => {
const resolvingSel = resolvingAsyncSelector('READY');
const bypassSelector = selector({
key: 'useRecoilState/bypassing selector',
get: ({
get
}) => {
try {
const value = get(resolvingSel);
expect(value).toBe('READY');
return value;
} catch (promise) {
expect(promise instanceof Promise).toBe(true);
return 'BYPASS';
}
}
}); // On first read the dependency is not yet available, but the
// selector catches and bypasses it.
const c3 = renderElements(<ReadsAtom atom={bypassSelector} />);
expect(c3.textContent).toEqual('"BYPASS"'); // When the dependency does resolve, the selector re-evaluates
// with the new data.
act(() => jest.runAllTimers());
expect(c3.textContent).toEqual('"READY"');
});
testRecoil('useRecoilState - selector catching all of 2 loads', async () => {
const [resolvingSel1, res1] = asyncSelector();
const [resolvingSel2, res2] = asyncSelector();
const bypassSelector = selector({
key: 'useRecoilState/bypassing selector all',
get: ({
get
}) => {
let ready = 0;
try {
const value1 = get(resolvingSel1);
expect(value1).toBe('READY1');
ready++;
const value2 = get(resolvingSel2);
expect(value2).toBe('READY2');
ready++;
return ready;
} catch (promise) {
expect(promise instanceof Promise).toBe(true);
return ready;
}
}
}); // On first read the dependency is not yet available, but the
// selector catches and bypasses it.
const c3 = renderElements(<ReadsAtom atom={bypassSelector} />);
expect(c3.textContent).toEqual('0'); // After the first resolution, we're still waiting on the second
res1('READY1');
act(() => jest.runAllTimers());
expect(c3.textContent).toEqual('1'); // When both are available, we are done!
res2('READY2');
act(() => jest.runAllTimers());
expect(c3.textContent).toEqual('2');
});
testRecoil('useRecoilState - selector catching any of 2 loads', async () => {
const resolvingSel1 = resolvingAsyncSelector('READY');
const resolvingSel2 = resolvingAsyncSelector('READY');
const bypassSelector = selector({
key: 'useRecoilState/bypassing selector any',
get: ({
get
}) => {
let ready = 0;
for (const resolvingSel of [resolvingSel1, resolvingSel2]) {
try {
const value = get(resolvingSel);
expect(value).toBe('READY');
ready++;
} catch (promise) {
expect(promise instanceof Promise).toBe(true);
ready = ready;
}
}
return ready;
}
}); // On first read the dependency is not yet available, but the
// selector catches and bypasses it.
const c3 = renderElements(<ReadsAtom atom={bypassSelector} />);
expect(c3.textContent).toEqual('0'); // Because both dependencies are tried, they should both resolve
// in parallel after one event loop.
act(() => jest.runAllTimers());
expect(c3.textContent).toEqual('2');
}); // Test the ability to catch a promise for a pending dependency that we can
// then handle by returning an async promise.
testRecoil('useRecoilState - selector catching promise and resolving asynchronously', async () => {
const [originalDep, resolveOriginal] = asyncSelector();
const [bypassDep, resolveBypass] = asyncSelector();
const catchPromiseSelector = selector({
key: 'useRecoilState/catch then async',
get: ({
get
}) => {
try {
return get(originalDep);
} catch (promise) {
expect(promise instanceof Promise).toBe(true);
return bypassDep;
}
}
});
const c = renderElements(<ReadsAtom atom={catchPromiseSelector} />);
expect(c.textContent).toEqual('loading');
act(() => jest.runAllTimers());
expect(c.textContent).toEqual('loading');
resolveBypass('BYPASS');
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"BYPASS"');
resolveOriginal('READY');
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"READY"');
}); // This tests ability to catch a pending result as a promise and
// that the promise resolves to the dependency's value and it is handled
// as an asynchronous selector
testRecoil('useRecoilState - selector catching promise 2', async () => {
let dependencyPromiseTest;
const resolvingSel = resolvingAsyncSelector('READY');
const catchPromiseSelector = selector({
key: 'useRecoilState/catch then async 2',
get: ({
get
}) => {
try {
return get(resolvingSel);
} catch (promise) {
expect(promise instanceof Promise).toBe(true); // eslint-disable-next-line jest/valid-expect
dependencyPromiseTest = expect(promise).resolves.toBe('READY');
return promise.then(pending => {
const result = pending.value;
expect(result).toBe('READY');
return result.value + ' NOW';
});
}
}
});
const c = renderElements(<ReadsAtom atom={catchPromiseSelector} />);
expect(c.textContent).toEqual('loading');
await flushPromisesAndTimers(); // NOTE!!!
// The output here may be "READY NOW" if we optimize the selector to
// cache the result of the async evaluation when the dependency is available
// in the cache key with the dependency being available. Currently it does not
// So, when the dependency is ready and the component re-renders it will
// re-evaluate. At that point the dependency is now READY and thus we only
// get READY and not READY NOW.
// expect(c.textContent).toEqual('"READY NOW"');
expect(c.textContent).toEqual('"READY"'); // Test that the promise for the dependency that we got actually resolved
// to the dependency's value.
await dependencyPromiseTest;
}); // Test that Recoil will throw an error with a useful debug message instead of
// infinite recurssion when there is a circular dependency
testRecoil('Detect circular dependencies', () => {
const selectorA = selector({
key: 'circular/A',
get: ({
get
}) => get(selectorC)
});
const selectorB = selector({
key: 'circular/B',
get: ({
get
}) => get(selectorA)
});
const selectorC = selector({
key: 'circular/C',
get: ({
get
}) => get(selectorB)
});
const devStatus = window.__DEV__;
window.__DEV__ = true;
expect(getValue(selectorC)).toBeInstanceOf(Error);
expect(getError(selectorC).message).toEqual(expect.stringContaining('circular/A'));
window.__DEV__ = devStatus;
});
testRecoil('selector is able to track dependencies discovered asynchronously', async () => {
const anAtom = atom({
key: 'atomTrackedAsync',
default: 'Async Dep Value'
});
const selectorWithAsyncDeps = selector({
key: 'selectorTrackDepsIncrementally',
get: async ({
get
}) => {
await Promise.resolve(); // needed to simulate discovering a dependency asynchronously
return get(anAtom);
}
});
let setAtom;
declare function Component(): any;
const container = renderElements(<Component />);
expect(container.textContent).toEqual('loading');
await flushPromisesAndTimers();
await flushPromisesAndTimers(); // HACK: not sure why but this is needed in OSS
expect(container.textContent).toEqual('Async Dep Value');
act(() => setAtom('CHANGED Async Dep'));
expect(container.textContent).toEqual('loading');
await flushPromisesAndTimers();
expect(container.textContent).toEqual('CHANGED Async Dep');
});
/**
* This test is an extension of the 'selector is able to track dependencies
* discovered asynchronously' test: in addition to testing that a selector
* responds to changes in dependencies that were discovered asynchronously, the
* selector should run through the entire selector in response to those changes.
*/
testRecoil('selector should rerun entire selector when a dep changes', async () => {
const resolvingSel1 = resolvingAsyncSelector(1);
const resolvingSel2 = resolvingAsyncSelector(2);
const anAtom3 = atom({
key: 'atomTrackedAsync3',
default: 3
});
const selectorWithAsyncDeps = selector({
key: 'selectorNotCacheIncDeps',
get: async ({
get
}) => {
const val1 = get(resolvingSel1);
await Promise.resolve();
const val2 = get(resolvingSel2);
await Promise.resolve();
const val3 = get(anAtom3);
return val1 + val2 + val3;
}
});
let setAtom;
declare function Component(): any;
const container = renderElements(<Component />);
expect(container.textContent).toEqual('loading');
await flushPromisesAndTimers(); // HACK: not sure why but these are needed in OSS
await flushPromisesAndTimers();
await flushPromisesAndTimers();
await flushPromisesAndTimers();
await flushPromisesAndTimers();
expect(container.textContent).toEqual('6');
act(() => setAtom(4));
expect(container.textContent).toEqual('loading');
await flushPromisesAndTimers();
await flushPromisesAndTimers();
await flushPromisesAndTimers();
expect(container.textContent).toEqual('7');
});
testRecoil('Selector getCallback', async () => {
const myAtom = atom({
key: 'selector - getCallback atom',
default: 'DEFAULT'
});
const mySelector = selector({
key: 'selector - getCallback',
get: ({
getCallback
}) => {
return {
onClick: getCallback(({
snapshot
}) => async () => await snapshot.getPromise(myAtom))
};
}
});
const menuItem = getValue(mySelector);
await expect(menuItem.onClick()).resolves.toEqual('DEFAULT');
act(() => setValue(myAtom, 'SET'));
await expect(menuItem.onClick()).resolves.toEqual('SET');
});
testRecoil("Selector can't call getCallback during evaluation", () => {
const mySelector = selector({
key: 'selector - getCallback throws',
get: ({
getCallback
}) => {
const callback = getCallback(() => () => {});
callback();
}
});
getError(mySelector);
});
testRecoil("Updating with same value doesn't rerender", gks => {
if (!gks.includes('recoil_suppress_rerender_in_callback')) {
return;
}
const myAtom = atom({
key: 'selector same value rerender / atom',
default: {
value: 'DEFAULT'
}
});
const mySelector = selector({
key: 'selector - same value rerender',
get: ({
get
}) => get(myAtom).value
});
let setAtom;
let resetAtom;
let renders = 0;
declare function SelectorComponent(): any;
expect(renders).toEqual(0);
const c = renderElements(<Profiler id="test" onRender={() => {
renders++;
}}>
<SelectorComponent />
</Profiler>); // Initial render happens one time in www and 2 times in oss.
// resetting the counter to 1 after the initial render to make them
// the same in both repos. 2 renders probably need to be looked into.
renders = 1;
expect(c.textContent).toEqual('DEFAULT');
act(() => setAtom('SET'));
expect(c.textContent).toEqual('SET');
expect(renders).toEqual(2);
act(() => setAtom('SET'));
expect(c.textContent).toEqual('SET');
expect(renders).toEqual(2);
act(() => setAtom('CHANGE'));
expect(c.textContent).toEqual('CHANGE');
expect(renders).toEqual(3);
act(resetAtom);
expect(c.textContent).toEqual('DEFAULT');
expect(renders).toEqual(4);
act(resetAtom);
expect(c.textContent).toEqual('DEFAULT');
expect(renders).toEqual(4);
}); // Test the following scenario:
// 0. Recoil state version 1 with A=FOO and B=BAR
// 1. Component renders with A for a value of FOO
// 2. Component renders with B for a value of BAR
// 3. Recoil state updated to version 2 with A=FOO and B=FOO
//
// Step 2 may be problematic if we attempt to suppress re-renders and don't
// properly keep track of previous component values when the mutable source changes.
testRecoil('Updating with changed selector', gks => {
if (!gks.includes('recoil_suppress_rerender_in_callback')) {
return;
}
const atomA = atom({
key: 'selector change rerender / atomA',
default: {
value: 'FOO'
}
});
const atomB = atom({
key: 'selector change rerender / atomB',
default: {
value: 'BAR'
}
});
const selectorA = selector({
key: 'selector change rerender / selectorA',
get: ({
get
}) => get(atomA).value
});
const selectorB = selector({
key: 'selector change rerender / selectorB',
get: ({
get
}) => get(atomB).value
});
let setSide;
let setB;
declare function SelectorComponent(): any;
const c = renderElements(<SelectorComponent />);
expect(c.textContent).toEqual('FOO'); // When we change the selector we are looking up it will render other atom's value
act(() => setSide('B'));
expect(c.textContent).toEqual('BAR'); // When we change Recoil state the component should re-render with new value.
// True even if we keep track of previous renders values to suppress re-renders when they don't change.
// If we don't keep track properly when the atom changes, this may break.
act(() => setB('FOO'));
expect(c.textContent).toEqual('FOO'); // When we swap back to atomA it now has the same value as atomB.
act(() => setSide('A'));
expect(c.textContent).toEqual('FOO');
});
testRecoil('Change component prop to suspend and wake', () => {
const awakeSelector = constSelector('WAKE');
const suspendedSelector = loadingAsyncSelector();
declare function TestComponent(arg0: any): any;
let setSide;
const SelectorComponent = function () {
const [side, setSideState] = useState('AWAKE');
setSide = setSideState;
return <TestComponent side={side} />;
};
const c = renderElements(<SelectorComponent />);
expect(c.textContent).toEqual('WAKE');
act(() => setSide('SLEEP'));
expect(c.textContent).toEqual('loading');
act(() => setSide('AWAKE'));
expect(c.textContent).toEqual('WAKE');
});
/**
* This test ensures that we are not running the selector's get() an unnecessary
* number of times in response to async selectors resolving (i.e. by retrying
* more times than we have to or creating numerous promises that retry).
*/
testRecoil('async selector runs the minimum number of times required', async () => {
const [asyncDep1, resolveAsyncDep1] = asyncSelector();
const [asyncDep2, resolveAsyncDep2] = asyncSelector();
let numTimesRan = 0;
const selectorWithAsyncDeps = selector({
key: 'selectorRunsMinTimes',
get: async ({
get
}) => {
numTimesRan++;
return get(asyncDep1) + get(asyncDep2);
}
});
const container = renderElements(<ReadsAtom atom={selectorWithAsyncDeps} />);
expect(numTimesRan).toBe(1);
act(() => resolveAsyncDep1('a'));
await flushPromisesAndTimers();
expect(numTimesRan).toBe(2);
act(() => resolveAsyncDep2('b'));
await flushPromisesAndTimers();
expect(numTimesRan).toBe(3);
await flushPromisesAndTimers();
expect(container.textContent).toEqual('"ab"');
});
testRecoil('async selector with changing dependencies finishes execution using original state', async () => {
const [asyncDep, resolveAsyncDep] = asyncSelector();
const anAtom = atom({
key: 'atomChangingDeps',
default: 3
});
const anAsyncSelector = selector({
key: 'selectorWithChangingDeps',
get: ({
get
}) => {
const atomValueBefore = get(anAtom);
get(asyncDep);
const atomValueAfter = get(anAtom);
expect(atomValueBefore).toBe(atomValueAfter);
return atomValueBefore + atomValueAfter;
}
});
let loadableSoFar;
let setAtom;
declare var MyComponent: () => any;
renderElements(<MyComponent />);
const loadableBeforeChangingAnything = nullthrows(loadableSoFar);
expect(loadableBeforeChangingAnything.contents).toBeInstanceOf(Promise);
act(() => setAtom(10));
const loadableAfterChangingAtom = nullthrows(loadableSoFar);
expect(loadableAfterChangingAtom.contents).toBeInstanceOf(Promise);
expect(loadableBeforeChangingAnything.contents).not.toBe(loadableAfterChangingAtom.contents);
act(() => resolveAsyncDep(''));
await flushPromisesAndTimers();
await Promise.all([expect(loadableBeforeChangingAnything.toPromise()).resolves.toBe(3 + 3), expect(loadableAfterChangingAtom.toPromise()).resolves.toBe(10 + 10)]);
});
testRecoil('selector - dynamic getRecoilValue()', async () => {
const sel2 = selector({
key: 'MySelector2',
get: async () => 'READY'
});
const sel1 = selector({
key: 'MySelector',
get: async ({
get
}) => {
await Promise.resolve();
return get(sel2);
}
});
const el = renderElements(<ReadsAtom atom={sel1} />);
expect(el.textContent).toEqual('loading');
await act(() => getValue(sel1));
act(() => jest.runAllTimers());
expect(el.textContent).toEqual('"READY"');
});
testRecoil('distinct loading dependencies are treated as distinct', async () => {
const upstreamAtom = atom({
key: 'distinct loading dependencies/upstreamAtom',
default: 0
});
const upstreamAsyncSelector = selector({
key: 'distinct loading dependencies/upstreamAsyncSelector',
get: ({
get
}) => Promise.resolve(get(upstreamAtom))
});
const directSelector = selector({
key: 'distinct loading dependencies/directSelector',
get: ({
get
}) => get(upstreamAsyncSelector)
});
expect(getValue(directSelector) instanceof Promise).toBe(true);
act(() => jest.runAllTimers());
expect(getValue(directSelector)).toEqual(0);
setValue(upstreamAtom, 1);
expect(getValue(directSelector) instanceof Promise).toBe(true);
act(() => jest.runAllTimers());
expect(getValue(directSelector)).toEqual(1);
});
testRecoil('Selector deps are saved when a component mounts due to a non-recoil change at the same time that a selector is first read', () => {
// Regression test for an issue where selector dependencies were not saved
// in this circumstance. In this situation dependencies are discovered for
// a selector when reading from a non-latest graph. This tests that these deps
// are carried forward instead of being forgotten.
let show, setShow, setAnotherAtom;
declare function Parent(): any;
const anAtom = atom<number>({
key: 'anAtom',
default: 0
});
const anotherAtom = atom<number>({
key: 'anotherAtom',
default: 0
});
const aSelector = selector({
key: 'aSelector',
get: ({
get
}) => {
return get(anAtom);
}
});
declare function SelectorUser(): any;
const c = renderElements(<Parent />);
expect(c.textContent).toEqual('');
act(() => {
setShow(true);
setAnotherAtom(1);
});
expect(c.textContent).toEqual('1');
});
describe('Async selector resolution notifies all stores that read pending', () => {
// Regression tests for #534: selectors used to only notify whichever store
// originally caused a promise to be returned, not any stores that also read
// the selector in that pending state.
testRecoil('Selectors read in a snapshot notify all stores', async () => {
// This version of the test uses the store inside of a Snapshot as its second store.
const switchAtom = atom({
key: 'notifiesAllStores/snapshots/switch',
default: false
});
const selectorA = selector({
key: 'notifiesAllStores/snapshots/a',
get: () => 'foo'
});
declare var resolve: (_: any) => any;
const selectorB = selector({
key: 'notifiesAllStores/snapshots/b',
get: async () => new Promise(r => {
resolve = r;
})
});
let doIt;
declare function TestComponent(): any;
const c = renderElements(<TestComponent />);
expect(c.textContent).toEqual('foo');
act(doIt);
expect(c.textContent).toEqual('loading');
act(() => resolve('bar'));
await act(flushPromisesAndTimers);
await act(flushPromisesAndTimers);
expect(c.textContent).toEqual('bar');
});
testRecoil('Selectors read in a another root notify all roots', async () => {
// This version of the test uses another RecoilRoot as its second store
const switchAtom = atom({
key: 'notifiesAllStores/twoRoots/switch',
default: false
});
const selectorA = selector({
key: 'notifiesAllStores/twoRoots/a',
get: () => 'SELECTOR A'
});
declare var resolve: (_: any) => any;
const selectorB = selector({
key: 'notifiesAllStores/twoRoots/b',
get: async () => new Promise(r => {
resolve = r;
})
});
declare function TestComponent(arg0: {
setSwitch: ((boolean) => void) => void
}): any;
let setRootASelector;
const rootA = renderElements(<TestComponent setSwitch={setSelector => {
setRootASelector = setSelector;
}} />);
let setRootBSelector;
const rootB = renderElements(<TestComponent setSwitch={setSelector => {
setRootBSelector = setSelector;
}} />);
if (mutableSourceExists()) {
expect(rootA.textContent).toEqual('SELECTOR A');
expect(rootB.textContent).toEqual('SELECTOR A');
act(() => setRootASelector(true)); // cause rootA to read the selector
expect(rootA.textContent).toEqual('loading');
expect(rootB.textContent).toEqual('SELECTOR A');
act(() => setRootBSelector(true)); // cause rootB to read the selector
expect(rootA.textContent).toEqual('loading');
expect(rootB.textContent).toEqual('loading');
act(() => resolve('SELECTOR B'));
await flushPromisesAndTimers();
expect(rootA.textContent).toEqual('SELECTOR B');
expect(rootB.textContent).toEqual('SELECTOR B');
}
});
});
testRecoil('selector - kite pattern runs only necessary selectors', async () => {
const aNode = atom({
key: 'aNode',
default: true
});
let bNodeRunCount = 0;
const bNode = selector({
key: 'bNode',
get: ({
get
}) => {
bNodeRunCount++;
const a = get(aNode);
return String(a);
}
});
let cNodeRunCount = 0;
const cNode = selector({
key: 'cNode',
get: ({
get
}) => {
cNodeRunCount++;
const a = get(aNode);
return String(Number(a));
}
});
let dNodeRunCount = 0;
const dNode = selector({
key: 'dNode',
get: ({
get
}) => {
dNodeRunCount++;
const value = get(aNode) ? get(bNode) : get(cNode);
return value.toUpperCase();
}
});
let dNodeValue = getValue(dNode);
expect(dNodeValue).toEqual('TRUE');
expect(bNodeRunCount).toEqual(1);
expect(cNodeRunCount).toEqual(0);
expect(dNodeRunCount).toEqual(1);
setValue(aNode, false);
dNodeValue = getValue(dNode);
expect(dNodeValue).toEqual('0');
expect(bNodeRunCount).toEqual(1);
expect(cNodeRunCount).toEqual(1);
expect(dNodeRunCount).toEqual(2);
});
testRecoil('async set not supported', async () => {
const myAtom = atom({
key: 'selector / async not supported / other atom',
default: 'DEFAULT'
});
const mySelector = selector({
key: 'selector / async set not supported / async set method',
get: () => myAtom,
// Set should not be async, this test checks that it throws an error.
// $FlowExpectedError
set: async ({
set,
reset
}, newVal) => {
await Promise.resolve();
newVal instanceof DefaultValue ? reset(myAtom) : set(myAtom, 'SET');
}
});
let setAttempt, resetAttempt;
const mySelector2 = selector({
key: 'selector / async set not supported / async upstream call',
get: () => myAtom,
set: ({
set,
reset
}, newVal) => {
if (newVal instanceof DefaultValue) {
resetAttempt = new Promise.resolve().then(() => {
reset(myAtom);
});
} else {
setAttempt = new Promise.resolve().then(() => {
set(myAtom, 'SET');
});
}
}
});
const testSnapshot = freshSnapshot();
testSnapshot.retain();
expect(() => testSnapshot.map(({
set
}) => {
set(mySelector, 'SET');
})).toThrow();
expect(() => testSnapshot.map(({
reset
}) => {
reset(mySelector);
})).toThrow();
const setSnapshot = testSnapshot.map(({
set,
reset
}) => {
set(mySelector2, 'SET');
reset(mySelector2);
});
setSnapshot.retain();
await flushPromisesAndTimers();
expect(setSnapshot.getLoadable(mySelector2).contents).toEqual('DEFAULT');
await expect(setAttempt).rejects.toThrowError();
await expect(resetAttempt).rejects.toThrowError();
});
testRecoil('selectors with user-thrown loadable promises execute to completion as expected', async () => {
const [asyncDep, resolveAsyncDep] = asyncSelector<string, void>();
const selWithUserThrownPromise = selector({
key: 'selWithUserThrownPromise',
get: ({
get
}) => {
const loadable = get(noWait(asyncDep));
if (loadable.state === 'loading') {
throw loadable.toPromise();
}
return loadable.valueOrThrow();
}
});
const loadable = getLoadable(selWithUserThrownPromise);
const promise = loadable.toPromise();
expect(loadable.state).toBe('loading');
resolveAsyncDep('RESOLVED');
await flushPromisesAndTimers();
const val: mixed = await promise;
expect(val).toBe('RESOLVED');
});
testRecoil('selectors with user-thrown loadable promises execute to completion as expected', async () => {
const myAtomA = atom({
key: 'myatoma selectors user-thrown promise',
default: 'A'
});
const myAtomB = atom({
key: 'myatomb selectors user-thrown promise',
default: 'B'
});
let isResolved = false;
declare var resolve: () => any;
const myPromise = new Promise(localResolve => {
resolve = () => {
isResolved = true;
localResolve();
};
});
const selWithUserThrownPromise = selector({
key: 'selWithUserThrownPromise',
get: ({
get
}) => {
const a = get(myAtomA);
if (!isResolved) {
throw myPromise;
}
const b = get(myAtomB);
return `${a}${b}`;
}
});
const loadable = getLoadable(selWithUserThrownPromise);
const promise = loadable.toPromise();
expect(loadable.state).toBe('loading');
resolve();
await flushPromisesAndTimers();
const val: mixed = await promise;
expect(val).toBe('AB');
});
testRecoil('selectors with nested user-thrown loadable promises execute to completion as expected', async () => {
const [asyncDep, resolveAsyncDep] = asyncSelector<string, void>();
const selWithUserThrownPromise = selector({
key: 'selWithUserThrownPromise',
get: ({
get
}) => {
const loadable = get(noWait(asyncDep));
if (loadable.state === 'loading') {
throw loadable.toPromise();
}
return loadable.valueOrThrow();
}
});
const selThatDependsOnSelWithUserThrownPromise = selector({
key: 'selThatDependsOnSelWithUserThrownPromise',
get: ({
get
}) => get(selWithUserThrownPromise)
});
const loadable = getLoadable(selThatDependsOnSelWithUserThrownPromise);
const promise = loadable.toPromise();
expect(loadable.state).toBe('loading');
resolveAsyncDep('RESOLVED');
await flushPromisesAndTimers();
const val: mixed = await promise;
expect(val).toBe('RESOLVED');
});
testRecoil('selectors cannot mutate values in get() or set()', () => {
const devStatus = window.__DEV__;
window.__DEV__ = true;
const userState = atom({
key: 'userState',
default: {
name: 'john',
address: {
road: '103 road',
nested: {
value: 'someNestedValue'
}
}
}
});
const userSelector = selector({
key: 'userSelector',
get: ({
get
}) => {
const user = get(userState);
user.address.road = '301 road';
return user;
},
set: ({
set,
get
}) => {
const user = get(userState);
user.address.road = 'narrow road';
return set(userState, user);
}
});
const testSnapshot = freshSnapshot();
testSnapshot.retain();
expect(() => testSnapshot.map(({
set
}) => {
set(userSelector, {
name: 'matt',
address: {
road: '103 road',
nested: {
value: 'someNestedValue'
}
}
});
})).toThrow();
expect(testSnapshot.getLoadable(userSelector).state).toBe('hasError');
window.__DEV__ = devStatus;
});
testRecoil('selector does not re-run to completion when one of its async deps resolves to a previously cached value', async () => {
const testSnapshot = freshSnapshot();
testSnapshot.retain();
const atomA = atom({
key: 'atomc-rerun-opt-test',
default: -3
});
const selectorB = selector({
key: 'selb-rerun-opt-test',
get: async ({
get
}) => {
await Promise.resolve();
return Math.abs(get(atomA));
}
});
let numTimesCStartedToRun = 0;
let numTimesCRanToCompletion = 0;
const selectorC = selector({
key: 'sela-rerun-opt-test',
get: ({
get
}) => {
numTimesCStartedToRun++;
const ret = get(selectorB);
/**
* The placement of numTimesCRan is important as this optimization
* prevents the execution of selectorC _after_ the point where the
* selector execution hits a known path of dependencies. In other words,
* the lines prior to the get(selectorB) will run twice, but the lines
* following get(selectorB) should only run once given that we are
* setting up this test so that selectorB resolves to a previously seen
* value the second time that it runs.
*/
numTimesCRanToCompletion++;
return ret;
}
});
testSnapshot.getLoadable(selectorC);
/**
* Run selector chain so that selectorC is cached with a dep of selectorB
* set to "3"
*/
await flushPromisesAndTimers();
const loadableA = testSnapshot.getLoadable(selectorC);
expect(loadableA.contents).toBe(3);
expect(numTimesCRanToCompletion).toBe(1);
/**
* It's expected that C started to run twice so far (the first is the first
* time that the selector was called and suspended, the second was when B
* resolved and C re-ran because of the async dep resolution)
*/
expect(numTimesCStartedToRun).toBe(2);
const mappedSnapshot = testSnapshot.map(({
set
}) => {
set(atomA, 3);
});
mappedSnapshot.getLoadable(selectorC);
/**
* Run selector chain so that selectorB recalculates as a result of atomA
* being changed to "3"
*/
await flushPromisesAndTimers();
const loadableB = mappedSnapshot.getLoadable(selectorC);
expect(loadableB.contents).toBe(3);
/**
* If selectors are correctly optimized, selectorC will not re-run because
* selectorB resolved to "3", which is a value that selectorC has previously
* cached for its selectorB dependency.
*/
expect(numTimesCRanToCompletion).toBe(1);
/**
* TODO:
* in the ideal case this should be:
*
* expect(numTimesCStartedToRun).toBe(2);
*/
expect(numTimesCStartedToRun).toBe(3);
});
testRecoil('async dep that changes from loading to value triggers re-execution', async () => {
const baseAtom = atom({
key: 'baseAtom',
default: 0
});
const asyncSel = selector({
key: 'asyncSel',
get: ({
get
}) => {
const atomVal = get(baseAtom);
if (atomVal === 0) {
return new Promise(() => {});
}
return atomVal;
}
});
const selWithAsyncDep = selector({
key: 'selWithAsyncDep',
get: ({
get
}) => {
return get(asyncSel);
}
});
const snapshot = freshSnapshot();
snapshot.retain();
const loadingValLoadable = snapshot.getLoadable(selWithAsyncDep);
expect(loadingValLoadable.state).toBe('loading');
const mappedSnapshot = snapshot.map(({
set
}) => {
set(baseAtom, 10);
});
const newAtomVal = mappedSnapshot.getLoadable(baseAtom);
expect(newAtomVal.valueMaybe()).toBe(10);
const valLoadable = mappedSnapshot.getLoadable(selWithAsyncDep);
expect(valLoadable.valueMaybe()).toBe(10);
});
testRecoil('Selector values are frozen', async () => {
const devStatus = window.__DEV__;
window.__DEV__ = true;
const frozenSelector = selector({
key: 'selector frozen',
get: () => ({
state: 'frozen',
nested: {
state: 'frozen'
}
})
});
expect(Object.isFrozen(getValue(frozenSelector))).toBe(true);
expect(Object.isFrozen(getValue(frozenSelector).nested)).toBe(true);
const thawedSelector = selector({
key: 'selector frozen thawed',
get: () => ({
state: 'thawed',
nested: {
state: 'thawed'
}
}),
dangerouslyAllowMutability: true
});
expect(Object.isFrozen(getValue(thawedSelector))).toBe(false);
expect(Object.isFrozen(getValue(thawedSelector).nested)).toBe(false);
const asyncFrozenSelector = selector({
key: 'selector frozen async',
get: () => Promise.resolve({
state: 'frozen',
nested: {
state: 'frozen'
}
})
});
await expect(getPromise(asyncFrozenSelector).then(x => Object.isFrozen(x))).resolves.toBe(true);
expect(Object.isFrozen(getValue(asyncFrozenSelector).nested)).toBe(true);
const asyncThawedSelector = selector({
key: 'selector frozen thawed async',
get: () => Promise.resolve({
state: 'thawed',
nested: {
state: 'thawed'
}
}),
dangerouslyAllowMutability: true
});
await expect(getPromise(asyncThawedSelector).then(x => Object.isFrozen(x))).resolves.toBe(false);
expect(Object.isFrozen(getValue(asyncThawedSelector).nested)).toBe(false);
const upstreamFrozenSelector = selector({
key: 'selector frozen upstream',
get: () => ({
state: 'frozen',
nested: {
state: 'frozen'
}
})
});
const fwdFrozenSelector = selector({
key: 'selector frozen fwd',
get: () => upstreamFrozenSelector
});
expect(Object.isFrozen(getValue(fwdFrozenSelector))).toBe(true);
expect(Object.isFrozen(getValue(fwdFrozenSelector).nested)).toBe(true);
const upstreamThawedSelector = selector({
key: 'selector frozen thawed upstream',
get: () => ({
state: 'thawed',
nested: {
state: 'thawed'
}
}),
dangerouslyAllowMutability: true
});
const fwdThawedSelector = selector({
key: 'selector frozen thawed fwd',
get: () => upstreamThawedSelector,
dangerouslyAllowMutability: true
});
expect(Object.isFrozen(getValue(fwdThawedSelector))).toBe(false);
expect(Object.isFrozen(getValue(fwdThawedSelector).nested)).toBe(false); // Selectors should not freeze their upstream dependencies
const upstreamMixedSelector = selector({
key: 'selector frozen mixed upstream',
get: () => ({
state: 'thawed',
nested: {
state: 'thawed'
}
}),
dangerouslyAllowMutability: true
});
const fwdMixedSelector = selector({
key: 'selector frozen mixed fwd',
get: ({
get
}) => {
get(upstreamMixedSelector);
return {
state: 'frozen'
};
}
});
expect(Object.isFrozen(getValue(fwdMixedSelector))).toBe(true);
expect(Object.isFrozen(getValue(upstreamMixedSelector))).toBe(false);
expect(Object.isFrozen(getValue(upstreamMixedSelector).nested)).toBe(false);
window.__DEV__ = devStatus;
});
testRecoil('Required options are provided when creating selectors', () => {
const devStatus = window.__DEV__;
window.__DEV__ = true; // $FlowExpectedError[incompatible-call]
expect(() => selector({
get: () => {}
})).toThrow(); // $FlowExpectedError[incompatible-call]
expect(() => selector({
get: false
})).toThrow(); // $FlowExpectedError[incompatible-call]
expect(() => selector({
key: 'MISSING GET'
})).toThrow();
window.__DEV__ = devStatus;
});