recoil
Version:
Recoil - A state management library for React
1,724 lines (1,501 loc) • 53.4 kB
Flow
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* @flow strict-local
* @format
* @oncall recoil
*/
'use strict';
import type {Loadable} from '../../adt/Recoil_Loadable';
import type {RecoilValue} from '../../core/Recoil_RecoilValue';
import type {DefaultValue as $IMPORTED_TYPE$_DefaultValue} from 'Recoil_Node';
import type {RecoilState} from 'Recoil_RecoilValue';
const {
getRecoilTestFn,
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils');
let React,
useState,
Profiler,
act,
DEFAULT_VALUE,
DefaultValue,
RecoilRoot,
RecoilEnv,
isRecoilValue,
RecoilLoadable,
isLoadable,
getRecoilValueAsLoadable,
setRecoilValue,
useRecoilState,
useRecoilCallback,
useRecoilValue,
useRecoilStoreID,
selector,
useRecoilTransactionObserver,
useResetRecoilState,
ReadsAtom,
stringAtom,
componentThatReadsAndWritesAtom,
flushPromisesAndTimers,
renderElements,
atom,
immutable,
store;
const testRecoil = getRecoilTestFn(() => {
const {
makeStore,
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils');
React = require('react');
({useState, Profiler} = require('react'));
({act} = require('ReactTestUtils'));
({DEFAULT_VALUE, DefaultValue} = require('../../core/Recoil_Node'));
({RecoilRoot, useRecoilStoreID} = require('../../core/Recoil_RecoilRoot'));
RecoilEnv = require('recoil-shared/util/Recoil_RecoilEnv');
({isRecoilValue} = require('../../core/Recoil_RecoilValue'));
({RecoilLoadable, isLoadable} = require('../../adt/Recoil_Loadable'));
({
getRecoilValueAsLoadable,
setRecoilValue,
} = require('../../core/Recoil_RecoilValueInterface'));
({
useRecoilState,
useResetRecoilState,
useRecoilValue,
} = require('../../hooks/Recoil_Hooks'));
({
useRecoilTransactionObserver,
} = require('../../hooks/Recoil_SnapshotHooks'));
({useRecoilCallback} = require('../../hooks/Recoil_useRecoilCallback'));
({
ReadsAtom,
stringAtom,
componentThatReadsAndWritesAtom,
flushPromisesAndTimers,
renderElements,
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils'));
atom = require('../Recoil_atom');
selector = require('../Recoil_selector');
immutable = require('immutable');
store = makeStore();
});
function getValue<T>(recoilValue: RecoilValue<T>): T {
return getRecoilValueAsLoadable(store, recoilValue).valueOrThrow();
}
function getError<T>(recoilValue: RecoilValue<T>): mixed {
return getRecoilValueAsLoadable(store, recoilValue).errorOrThrow();
}
function getRecoilStateLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T> {
return getRecoilValueAsLoadable(store, recoilValue);
}
function getRecoilStatePromise<T>(recoilValue: RecoilValue<T>): Promise<T> {
return getRecoilStateLoadable(recoilValue).promiseOrThrow();
}
function set(recoilValue: RecoilState<$FlowFixMe>, value: mixed) {
setRecoilValue(store, recoilValue, value);
}
function reset(
recoilValue: RecoilState<
$TEMPORARY$string<'DEFAULT'> | $TEMPORARY$string<'INIT'>,
>,
) {
setRecoilValue(store, recoilValue, DEFAULT_VALUE);
}
testRecoil('Key is required when creating atoms', () => {
const devStatus = window.__DEV__;
window.__DEV__ = true;
// $FlowExpectedError[incompatible-call]
expect(() => atom({default: undefined})).toThrow();
window.__DEV__ = devStatus;
});
testRecoil('atom can read and write value', () => {
const myAtom = atom<string>({
key: 'atom with default',
default: 'DEFAULT',
});
expect(getValue(myAtom)).toBe('DEFAULT');
act(() => set(myAtom, 'VALUE'));
expect(getValue(myAtom)).toBe('VALUE');
});
describe('creating two atoms with the same key', () => {
let consoleErrorSpy, consoleWarnSpy;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error');
consoleWarnSpy = jest.spyOn(console, 'warn');
// squelch output from the actual consoles
consoleErrorSpy.mockImplementation(() => undefined);
consoleWarnSpy.mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks(); // spys are mocks, now unmock them
});
const createAtomsWithDuplicateKeys = () => {
// Create two atoms with the same key
const _myAtom = atom<string>({
key: 'an atom',
default: 'DEFAULT',
});
const _myAtom2 = atom<string>({
key: 'an atom', // with the same key!
default: 'DEFAULT 2',
});
};
describe('log behavior with __DEV__ setting', () => {
const originalDEV = window.__DEV__;
beforeEach(() => {
window.__DEV__ = true;
});
afterEach(() => {
window.__DEV__ = originalDEV;
});
testRecoil('logs to error and warning in development mode', () => {
__DEV__ = true;
createAtomsWithDuplicateKeys();
const loggedError = consoleErrorSpy.mock.calls[0]?.[0];
const loggedWarning = consoleWarnSpy.mock.calls[0]?.[0];
// either is ok, implementation difference between fb and oss
expect(loggedError ?? loggedWarning).toBeDefined();
});
testRecoil('logs to error only in production mode', () => {
__DEV__ = false;
createAtomsWithDuplicateKeys();
const loggedError = consoleErrorSpy.mock.calls[0]?.[0];
const loggedWarning = consoleWarnSpy.mock.calls[0]?.[0];
// either is ok, implementation difference between fb and oss
expect(loggedError ?? loggedWarning).toBeDefined();
});
});
testRecoil(
'disabling the duplicate checking flag stops console output ',
() => {
RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = false;
createAtomsWithDuplicateKeys();
const loggedError = consoleErrorSpy.mock.calls[0]?.[0];
const loggedWarning = consoleWarnSpy.mock.calls[0]?.[0];
expect(loggedError).toBeUndefined();
expect(loggedWarning).toBeUndefined();
},
);
describe('support for process.env.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED if present (workaround for NextJS)', () => {
const originalProcessEnv = process.env;
beforeEach(() => {
process.env = {...originalProcessEnv};
process.env.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED = 'false';
});
afterEach(() => {
process.env = originalProcessEnv;
});
testRecoil('duplicate checking is disabled when true', () => {
createAtomsWithDuplicateKeys();
expect(RecoilEnv.RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED).toBe(false);
const loggedError = consoleErrorSpy.mock.calls[0]?.[0];
const loggedWarning = consoleWarnSpy.mock.calls[0]?.[0];
expect(loggedError).toBeUndefined();
expect(loggedWarning).toBeUndefined();
});
});
});
describe('Valid values', () => {
testRecoil('atom can store null and undefined', () => {
const myAtom = atom<?string>({
key: 'atom with default for null and undefined',
default: 'DEFAULT',
});
expect(getValue(myAtom)).toBe('DEFAULT');
act(() => set(myAtom, 'VALUE'));
expect(getValue(myAtom)).toBe('VALUE');
act(() => set(myAtom, null));
expect(getValue(myAtom)).toBe(null);
act(() => set(myAtom, undefined));
expect(getValue(myAtom)).toBe(undefined);
act(() => set(myAtom, 'VALUE'));
expect(getValue(myAtom)).toBe('VALUE');
});
testRecoil('atom can store a circular reference object', () => {
class Circular {
self: Circular;
constructor() {
this.self = this;
}
}
const circular = new Circular();
const myAtom = atom<?Circular>({
key: 'atom',
default: undefined,
});
expect(getValue(myAtom)).toBe(undefined);
act(() => set(myAtom, circular));
expect(getValue(myAtom)).toBe(circular);
});
});
describe('Defaults', () => {
testRecoil('default is optional', () => {
const myAtom = atom<$FlowFixMe>({key: 'atom without default'});
expect(getRecoilStateLoadable(myAtom).state).toBe('loading');
act(() => set(myAtom, 'VALUE'));
expect(getValue(myAtom)).toBe('VALUE');
});
testRecoil('default promise', async () => {
const myAtom = atom<string>({
key: 'atom async default',
default: Promise.resolve('RESOLVE'),
});
const container = renderElements(<ReadsAtom atom={myAtom} />);
expect(container.textContent).toEqual('loading');
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(container.textContent).toEqual('"RESOLVE"');
});
testRecoil('default promise overwritten before resolution', () => {
let resolveAtom;
const myAtom = atom<string>({
key: 'atom async default overwritten',
default: new Promise(resolve => {
resolveAtom = resolve;
}),
});
const [ReadsWritesAtom, setAtom, resetAtom] =
componentThatReadsAndWritesAtom(myAtom);
const container = renderElements(<ReadsWritesAtom />);
expect(container.textContent).toEqual('loading');
act(() => setAtom('SET'));
act(() => jest.runAllTimers());
expect(container.textContent).toEqual('"SET"');
act(() => resolveAtom('RESOLVE'));
expect(container.textContent).toEqual('"SET"');
act(() => resetAtom());
act(() => jest.runAllTimers());
expect(container.textContent).toEqual('"RESOLVE"');
});
// NOTE: This test intentionally throws an error
testRecoil('default promise rejection', async () => {
const myAtom = atom<string>({
key: 'atom async default',
default: Promise.reject(new Error('REJECT')),
});
const container = renderElements(<ReadsAtom atom={myAtom} />);
expect(container.textContent).toEqual('loading');
act(() => jest.runAllTimers());
await flushPromisesAndTimers();
expect(container.textContent).toEqual('error');
});
testRecoil('atom default ValueLoadable', () => {
const myAtom = atom<string>({
key: 'atom default ValueLoadable',
default: RecoilLoadable.of('VALUE'),
});
expect(getValue(myAtom)).toBe('VALUE');
});
testRecoil('atom default ErrorLoadable', () => {
const myAtom = atom<string>({
key: 'atom default ErrorLoadable',
default: RecoilLoadable.error(new Error('ERROR')),
});
expect(getError(myAtom)).toBeInstanceOf(Error);
// $FlowExpectedError[incompatible-use]
expect(getError(myAtom).message).toBe('ERROR');
});
testRecoil('atom default LoadingLoadable', async () => {
const myAtom = atom<string>({
key: 'atom default LoadingLoadable',
default: RecoilLoadable.of(Promise.resolve('VALUE')),
});
await expect(getRecoilStatePromise(myAtom)).resolves.toBe('VALUE');
});
testRecoil('atom default derived Loadable', () => {
const myAtom = atom<string>({
key: 'atom default Loadable derived',
default: RecoilLoadable.of('A').map(x => x + 'B'),
});
expect(getValue(myAtom)).toBe('AB');
});
testRecoil('atom default AtomValue', () => {
const myAtom = atom<string>({
key: 'atom default AtomValue',
default: atom.value('VALUE'),
});
expect(getValue(myAtom)).toBe('VALUE');
});
testRecoil('atom default AtomValue Loadable', async () => {
const myAtom = atom<Loadable<string>>({
key: 'atom default AtomValue Loadable',
default: atom.value(RecoilLoadable.of('VALUE')),
});
expect(isLoadable(getValue(myAtom))).toBe(true);
expect(getValue(myAtom).valueOrThrow()).toBe('VALUE');
});
testRecoil('atom default AtomValue ErrorLoadable', () => {
const myAtom = atom({
key: 'atom default AtomValue Loadable Error',
default: atom.value(RecoilLoadable.error<boolean>('ERROR')),
});
expect(isLoadable(getValue(myAtom))).toBe(true);
expect(getValue(myAtom).errorOrThrow()).toBe('ERROR');
});
testRecoil('atom default AtomValue Atom', () => {
const otherAtom = stringAtom();
const myAtom = atom({
key: 'atom default AtomValue Loadable Error',
default: atom.value(otherAtom),
});
expect(isRecoilValue(getValue(myAtom))).toBe(true);
});
});
testRecoil("Updating with same value doesn't rerender", () => {
const myAtom = atom({key: 'atom same value rerender', default: 'DEFAULT'});
let setAtom;
let resetAtom;
let renders = 0;
function AtomComponent() {
const [value, setValue] = useRecoilState(myAtom);
const resetValue = useResetRecoilState(myAtom);
setAtom = setValue;
resetAtom = resetValue;
return value;
}
expect(renders).toEqual(0);
const c = renderElements(
<Profiler
id="test"
onRender={() => {
renders++;
}}>
<AtomComponent />
</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(renders).toEqual(2);
expect(c.textContent).toEqual('SET');
act(() => setAtom('SET'));
expect(renders).toEqual(2);
expect(c.textContent).toEqual('SET');
act(() => setAtom('CHANGE'));
expect(renders).toEqual(3);
expect(c.textContent).toEqual('CHANGE');
act(resetAtom);
expect(renders).toEqual(4);
expect(c.textContent).toEqual('DEFAULT');
act(resetAtom);
expect(renders).toEqual(4);
expect(c.textContent).toEqual('DEFAULT');
});
describe('Effects', () => {
testRecoil('effect error', () => {
const ERROR = new Error('ERROR');
const myAtom = atom({
key: 'atom effect error',
default: 'DEFAULT',
effects: [
() => {
throw ERROR;
},
],
});
// $FlowFixMe[incompatible-call]
const mySelector = selector<string>({
key: 'atom effect error selector',
get: ({get}) => {
try {
return get(myAtom);
} catch (e) {
return e.message;
}
},
});
const container = renderElements(<ReadsAtom atom={mySelector} />);
expect(container.textContent).toEqual('"ERROR"');
});
testRecoil('initialization', () => {
let inited = false;
const myAtom: RecoilState<string> = atom({
key: 'atom effect init',
default: 'DEFAULT',
effects: [
({node, trigger, setSelf}) => {
inited = true;
expect(trigger).toEqual('get');
expect(node).toBe(myAtom);
setSelf('INIT');
},
],
});
expect(getValue(myAtom)).toEqual('INIT');
expect(inited).toEqual(true);
});
testRecoil('async default', () => {
let inited = false;
const myAtom = atom<string>({
key: 'atom effect async default',
default: Promise.resolve('RESOLVE'),
effects: [
({setSelf, onSet}) => {
inited = true;
setSelf('INIT');
onSet(newValue => {
expect(newValue).toBe('RESOLVE');
});
},
],
});
expect(inited).toEqual(false);
const [ReadsWritesAtom, _, resetAtom] =
componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(inited).toEqual(true);
expect(c.textContent).toEqual('"INIT"');
act(resetAtom);
expect(c.textContent).toEqual('loading');
act(() => jest.runAllTimers());
expect(c.textContent).toEqual('"RESOLVE"');
});
testRecoil('set to Promise', async () => {
let setLater;
const myAtom = atom({
key: 'atom effect set promise',
default: 'DEFAULT',
effects: [
({setSelf}) => {
// $FlowFixMe[incompatible-call]
setSelf(atom.value(Promise.resolve('INIT_PROMISE')));
setLater = setSelf;
},
],
});
expect(getRecoilStateLoadable(myAtom).state).toBe('hasValue');
await expect(getRecoilStateLoadable(myAtom).contents).resolves.toBe(
'INIT_PROMISE',
);
// $FlowFixMe[incompatible-call]
act(() => setLater(atom.value(Promise.resolve('LATER_PROMISE'))));
expect(getRecoilStateLoadable(myAtom).state).toBe('hasValue');
await expect(getRecoilStateLoadable(myAtom).contents).resolves.toBe(
'LATER_PROMISE',
);
// $FlowFixMe[incompatible-call]
act(() => setLater(() => atom.value(Promise.resolve('UPDATER_PROMISE'))));
expect(getRecoilStateLoadable(myAtom).state).toBe('hasValue');
await expect(getRecoilStateLoadable(myAtom).contents).resolves.toBe(
'UPDATER_PROMISE',
);
});
testRecoil('order of effects', () => {
const myAtom = atom({
key: 'atom effect order',
default: 'DEFAULT',
effects: [
({setSelf}) => {
setSelf(x => {
expect(x).toEqual('DEFAULT');
return 'EFFECT 1a';
});
setSelf(x => {
expect(x).toEqual('EFFECT 1a');
return 'EFFECT 1b';
});
},
({setSelf}) => {
setSelf(x => {
expect(x).toEqual('EFFECT 1b');
return 'EFFECT 2';
});
},
() => {},
],
});
expect(getValue(myAtom)).toEqual('EFFECT 2');
});
testRecoil('reset during init', () => {
const myAtom = atom({
key: 'atom effect reset',
default: 'DEFAULT',
effects: [({setSelf}) => setSelf('INIT'), ({resetSelf}) => resetSelf()],
});
expect(getValue(myAtom)).toEqual('DEFAULT');
});
testRecoil('init to undefined', () => {
const myAtom = atom<?string>({
key: 'atom effect init undefined',
default: 'DEFAULT',
effects: [({setSelf}) => setSelf('INIT'), ({setSelf}) => setSelf()],
});
expect(getValue(myAtom)).toEqual(undefined);
});
testRecoil('init on set', () => {
let inited = 0;
const myAtom = atom({
key: 'atom effect - init on set',
default: 'DEFAULT',
effects: [
({setSelf, trigger}) => {
inited++;
setSelf('INIT');
expect(trigger).toEqual('set');
},
],
});
set(myAtom, 'SET');
expect(getValue(myAtom)).toEqual('SET');
expect(inited).toEqual(1);
reset(myAtom);
expect(getValue(myAtom)).toEqual('DEFAULT');
expect(inited).toEqual(1);
});
testRecoil('async set', () => {
let setAtom, resetAtom;
let effectRan = false;
const myAtom = atom({
key: 'atom effect init set',
default: 'DEFAULT',
effects: [
({setSelf, resetSelf}) => {
setAtom = setSelf;
resetAtom = resetSelf;
setSelf(x => {
expect(x).toEqual(effectRan ? 'INIT' : 'DEFAULT');
effectRan = true;
return 'INIT';
});
},
],
});
const c = renderElements(<ReadsAtom atom={myAtom} />);
expect(c.textContent).toEqual('"INIT"');
// Test async set
act(() =>
setAtom(value => {
expect(value).toEqual('INIT');
return 'SET';
}),
);
expect(c.textContent).toEqual('"SET"');
// Test async change
act(() =>
setAtom(value => {
expect(value).toEqual('SET');
return 'CHANGE';
}),
);
expect(c.textContent).toEqual('"CHANGE"');
// Test reset
act(resetAtom);
expect(c.textContent).toEqual('"DEFAULT"');
// Test setting to undefined
act(() =>
// $FlowFixMe[incompatible-call]
setAtom(value => {
expect(value).toEqual('DEFAULT');
return undefined;
}),
);
expect(c.textContent).toEqual('');
});
testRecoil('set promise', async () => {
let resolveAtom;
let validated;
const onSetForSameEffect = jest.fn(() => {});
const myAtom = atom({
key: 'atom effect init set promise',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
setSelf(
new Promise(resolve => {
resolveAtom = resolve;
}),
);
// $FlowFixMe[invalid-tuple-arity]
onSet(onSetForSameEffect);
},
({onSet}) => {
onSet(value => {
expect(value).toEqual('RESOLVE');
validated = true;
});
},
],
});
const c = renderElements(<ReadsAtom atom={myAtom} />);
expect(c.textContent).toEqual('loading');
act(() => resolveAtom?.('RESOLVE'));
await flushPromisesAndTimers();
act(() => undefined);
expect(c.textContent).toEqual('"RESOLVE"');
expect(validated).toEqual(true);
// onSet() should not be called for this hook's setSelf()
expect(onSetForSameEffect).toHaveBeenCalledTimes(0);
});
testRecoil('set default promise', async () => {
let setValue = 'RESOLVE_DEFAULT';
const onSetHandler = jest.fn(newValue => {
expect(newValue).toBe(setValue);
});
let resolveDefault;
const myAtom = atom({
key: 'atom effect default promise',
default: new Promise(resolve => {
resolveDefault = resolve;
}),
effects: [
({onSet}) => {
// $FlowFixMe[invalid-tuple-arity]
onSet(onSetHandler);
},
],
});
const [ReadsWritesAtom, setAtom, resetAtom] =
componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(c.textContent).toEqual('loading');
act(() => resolveDefault?.('RESOLVE_DEFAULT'));
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"RESOLVE_DEFAULT"');
expect(onSetHandler).toHaveBeenCalledTimes(1);
setValue = 'SET';
act(() => setAtom('SET'));
expect(c.textContent).toEqual('"SET"');
expect(onSetHandler).toHaveBeenCalledTimes(2);
setValue = 'RESOLVE_DEFAULT';
act(resetAtom);
expect(c.textContent).toEqual('"RESOLVE_DEFAULT"');
expect(onSetHandler).toHaveBeenCalledTimes(3);
});
testRecoil(
'when setSelf is called in onSet, then onSet is not triggered again',
() => {
let set1 = false;
const valueToSet1 = 'value#1';
const transformedBySetSelf = 'transformed after value#1';
const myAtom = atom({
key: 'atom setSelf with set-updater',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
onSet(newValue => {
expect(set1).toBe(false);
if (newValue === valueToSet1) {
setSelf(transformedBySetSelf);
set1 = true;
}
});
},
],
});
const [ReadsWritesAtom, setAtom] =
componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(c.textContent).toEqual('"DEFAULT"');
act(() => setAtom(valueToSet1));
expect(c.textContent).toEqual(`"${transformedBySetSelf}"`);
},
);
testRecoil('Always call setSelf() in onSet() handler', () => {
const myAtom = atom({
key: 'atom setSelf in onSet',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
onSet(newValue => {
setSelf('TRANSFORM ' + newValue);
});
},
],
});
const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(c.textContent).toEqual('"DEFAULT"');
act(() => setAtom('SET'));
expect(c.textContent).toEqual('"TRANSFORM SET"');
act(() => setAtom('SET2'));
expect(c.textContent).toEqual('"TRANSFORM SET2"');
});
testRecoil('Patch value using setSelf() in onSet() handler', () => {
let patch = 'PATCH';
const myAtom = atom({
key: 'atom patch setSelf in onSet',
default: {value: 'DEFAULT', patch},
effects: [
({setSelf, onSet}) => {
onSet(newValue => {
if (
!(newValue instanceof DefaultValue) &&
newValue.patch != patch
) {
// $FlowFixMe[prop-missing]
setSelf({value: 'TRANSFORM_ALT ' + newValue.value, patch});
}
});
},
({setSelf, onSet}) => {
onSet(newValue => {
if (
!(newValue instanceof DefaultValue) &&
newValue.patch != patch
) {
// $FlowFixMe[prop-missing]
setSelf({value: 'TRANSFORM ' + newValue.value, patch});
}
});
},
],
});
const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(c.textContent).toEqual('{"patch":"PATCH","value":"DEFAULT"}');
// $FlowFixMe[missing-local-annot]
// $FlowFixMe[incompatible-exact]
act(() => setAtom(x => ({...x, value: 'SET'})));
expect(c.textContent).toEqual('{"patch":"PATCH","value":"SET"}');
// $FlowFixMe[missing-local-annot]
// $FlowFixMe[incompatible-exact]
act(() => setAtom(x => ({...x, value: 'SET2'})));
expect(c.textContent).toEqual('{"patch":"PATCH","value":"SET2"}');
patch = 'PATCHB';
// $FlowFixMe[missing-local-annot]
// $FlowFixMe[incompatible-exact]
act(() => setAtom(x => ({...x, value: 'SET3'})));
expect(c.textContent).toEqual(
'{"patch":"PATCHB","value":"TRANSFORM SET3"}',
);
// $FlowFixMe[missing-local-annot]
// $FlowFixMe[incompatible-exact]
act(() => setAtom(x => ({...x, value: 'SET4'})));
expect(c.textContent).toEqual('{"patch":"PATCHB","value":"SET4"}');
});
// NOTE: This test throws an expected error
testRecoil('reject promise', async () => {
let rejectAtom;
let validated = false;
const myAtom = atom({
key: 'atom effect init reject promise',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
setSelf(
new Promise((_resolve, reject) => {
rejectAtom = reject;
}),
);
onSet(() => {
validated = true;
});
},
],
});
const c = renderElements(<ReadsAtom atom={myAtom} />);
expect(c.textContent).toEqual('loading');
act(() => rejectAtom?.(new Error('REJECT')));
await flushPromisesAndTimers();
act(() => undefined);
expect(c.textContent).toEqual('error');
expect(validated).toEqual(false);
});
testRecoil('overwrite promise', async () => {
let resolveAtom;
let validated;
const myAtom = atom({
key: 'atom effect init overwrite promise',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
setSelf(
new Promise(resolve => {
resolveAtom = resolve;
}),
);
onSet(value => {
expect(value).toEqual('OVERWRITE');
validated = true;
});
},
],
});
const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<ReadsWritesAtom />);
expect(c.textContent).toEqual('loading');
act(() => setAtom('OVERWRITE'));
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"OVERWRITE"');
// Resolving after atom is set to another value will be ignored.
act(() => resolveAtom?.('RESOLVE'));
await flushPromisesAndTimers();
expect(c.textContent).toEqual('"OVERWRITE"');
expect(validated).toEqual(true);
});
testRecoil('abort promise init', async () => {
let resolveAtom;
let validated;
const myAtom = atom({
key: 'atom effect abort promise init',
default: 'DEFAULT',
effects: [
({setSelf, onSet}) => {
setSelf(
new Promise(resolve => {
resolveAtom = resolve;
}),
);
onSet(value => {
expect(value).toBe('DEFAULT');
validated = true;
});
},
],
});
const c = renderElements(<ReadsAtom atom={myAtom} />);
expect(c.textContent).toEqual('loading');
act(() => resolveAtom?.(new DefaultValue()));
await flushPromisesAndTimers();
act(() => undefined);
expect(c.textContent).toEqual('"DEFAULT"');
expect(validated).toEqual(true);
});
testRecoil('once per root', ({strictMode, concurrentMode}) => {
let inited = 0;
const myAtom = atom({
key: 'atom effect once per root',
default: 'DEFAULT',
effects: [
({setSelf}) => {
inited++;
setSelf('INIT');
},
],
});
const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
// effect is called once per <RecoilRoot>
const c1 = renderElements(<ReadsWritesAtom />);
const c2 = renderElements(<ReadsAtom atom={myAtom} />);
expect(c1.textContent).toEqual('"INIT"');
expect(c2.textContent).toEqual('"INIT"');
act(() => setAtom('SET'));
expect(c1.textContent).toEqual('"SET"');
expect(c2.textContent).toEqual('"INIT"');
expect(inited).toEqual(strictMode && concurrentMode ? 4 : 2);
});
testRecoil('onSet', () => {
const oldSets = {a: 0, b: 0};
const newSets = {a: 0, b: 0};
const observer =
(key: $TEMPORARY$string<'a'> | $TEMPORARY$string<'b'>) =>
(
newValue: number,
oldValue: number | $IMPORTED_TYPE$_DefaultValue,
isReset: boolean,
) => {
expect(oldValue).toEqual(oldSets[key]);
expect(newValue).toEqual(newSets[key]);
expect(isReset).toEqual(newValue === 0);
oldSets[key] = newValue;
};
const atomA = atom({
key: 'atom effect onSet A',
default: 0,
effects: [({onSet}) => onSet(observer('a'))],
});
const atomB = atom({
key: 'atom effect onSet B',
default: 0,
effects: [({onSet}) => onSet(observer('b'))],
});
const [AtomA, setA, resetA] = componentThatReadsAndWritesAtom(atomA);
const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB);
const c = renderElements(
<>
<AtomA />
<AtomB />
</>,
);
expect(oldSets).toEqual({a: 0, b: 0});
expect(c.textContent).toEqual('00');
newSets.a = 1;
act(() => setA(1));
expect(c.textContent).toEqual('10');
newSets.a = 2;
act(() => setA(2));
expect(c.textContent).toEqual('20');
newSets.b = 1;
act(() => setB(1));
expect(c.textContent).toEqual('21');
newSets.a = 0;
act(() => resetA());
expect(c.textContent).toEqual('01');
});
testRecoil('onSet ordering', () => {
let set1 = false;
let set2 = false;
let globalObserver = false;
const myAtom = atom({
key: 'atom effect onSet ordering',
default: 'DEFAULT',
effects: [
({onSet}) => {
onSet(() => {
expect(set2).toBe(false);
set1 = true;
});
onSet(() => {
expect(set1).toBe(true);
set2 = true;
});
},
],
});
function TransactionObserver({
callback,
}: $TEMPORARY$object<{callback: () => void}>) {
useRecoilTransactionObserver(callback);
return null;
}
const [AtomA, setA] = componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(
<>
<AtomA />
<TransactionObserver
callback={() => {
expect(set1).toBe(true);
expect(set2).toBe(true);
globalObserver = true;
}}
/>
</>,
);
expect(set1).toEqual(false);
expect(set2).toEqual(false);
// $FlowFixMe[incompatible-call]
act(() => setA(1));
expect(set1).toEqual(true);
expect(set2).toEqual(true);
expect(globalObserver).toEqual(true);
expect(c.textContent).toEqual('1');
});
testRecoil('onSet History', () => {
const history: Array<() => void> = []; // Array of undo functions
/* $FlowFixMe[missing-local-annot] The type annotation(s) required by
* Flow's LTI update could not be added via codemod */
function historyEffect({setSelf, onSet}) {
onSet((_, oldValue) => {
history.push(() => {
setSelf(oldValue);
});
});
}
const atomA = atom({
key: 'atom effect onSte history A',
default: 'DEFAULT_A',
effects: [historyEffect],
});
const atomB = atom({
key: 'atom effect onSte history B',
default: 'DEFAULT_B',
effects: [historyEffect],
});
const [AtomA, setA, resetA] = componentThatReadsAndWritesAtom(atomA);
const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB);
const c = renderElements(
<>
<AtomA />
<AtomB />
</>,
);
expect(c.textContent).toEqual('"DEFAULT_A""DEFAULT_B"');
act(() => setA('SET_A'));
expect(c.textContent).toEqual('"SET_A""DEFAULT_B"');
act(() => setB('SET_B'));
expect(c.textContent).toEqual('"SET_A""SET_B"');
act(() => setB('CHANGE_B'));
expect(c.textContent).toEqual('"SET_A""CHANGE_B"');
act(resetA);
expect(c.textContent).toEqual('"DEFAULT_A""CHANGE_B"');
expect(history.length).toEqual(4);
act(() => history.pop()());
expect(c.textContent).toEqual('"SET_A""CHANGE_B"');
act(() => history.pop()());
expect(c.textContent).toEqual('"SET_A""SET_B"');
act(() => history.pop()());
expect(c.textContent).toEqual('"SET_A""DEFAULT_B"');
act(() => history.pop()());
expect(c.textContent).toEqual('"DEFAULT_A""DEFAULT_B"');
});
testRecoil('Cleanup Handlers - when root unmounted', () => {
const refCountsA = [0, 0];
const refCountsB = [0, 0];
const atomA = atom({
key: 'atom effect cleanup - A',
default: 'A',
effects: [
() => {
refCountsA[0]++;
return () => {
refCountsA[0]--;
};
},
() => {
refCountsA[1]++;
return () => {
refCountsA[1]--;
};
},
],
});
const atomB = atom({
key: 'atom effect cleanup - B',
default: 'B',
effects: [
() => {
refCountsB[0]++;
return () => {
refCountsB[0]--;
};
},
() => {
refCountsB[1]++;
return () => {
refCountsB[1]--;
};
},
],
});
let setNumRoots;
function App() {
const [numRoots, _setNumRoots] = useState(0);
setNumRoots = _setNumRoots;
return (
<div>
{Array(numRoots)
.fill(null)
.map((_, idx) => (
<RecoilRoot key={idx}>
<ReadsAtom atom={atomA} />
<ReadsAtom atom={atomB} />
</RecoilRoot>
))}
</div>
);
}
const c = renderElements(<App />);
expect(c.textContent).toBe('');
expect(refCountsA).toEqual([0, 0]);
expect(refCountsB).toEqual([0, 0]);
act(() => setNumRoots(1));
expect(c.textContent).toBe('"A""B"');
expect(refCountsA).toEqual([1, 1]);
expect(refCountsB).toEqual([1, 1]);
act(() => setNumRoots(2));
expect(c.textContent).toBe('"A""B""A""B"');
expect(refCountsA).toEqual([2, 2]);
expect(refCountsB).toEqual([2, 2]);
act(() => setNumRoots(1));
expect(c.textContent).toBe('"A""B"');
expect(refCountsA).toEqual([1, 1]);
expect(refCountsB).toEqual([1, 1]);
act(() => setNumRoots(0));
expect(c.textContent).toBe('');
expect(refCountsA).toEqual([0, 0]);
expect(refCountsB).toEqual([0, 0]);
act(() => setNumRoots(1));
expect(c.textContent).toBe('"A""B"');
expect(refCountsA).toEqual([1, 1]);
expect(refCountsB).toEqual([1, 1]);
act(() => setNumRoots(0));
expect(c.textContent).toBe('');
expect(refCountsA).toEqual([0, 0]);
expect(refCountsB).toEqual([0, 0]);
});
testRecoil('onSet unsubscribes', () => {
let onSetRan = 0;
const myAtom = atom({
key: 'atom effects onSet unsubscribe',
default: 'DEFAULT',
effects: [
({onSet}) => {
onSet(() => {
onSetRan++;
});
},
],
});
let setMount: boolean => void = _ => {
throw new Error('Test Error');
};
const [ReadWriteAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
function Component() {
const [mount, setState] = useState(false);
setMount = setState;
return mount ? (
<RecoilRoot>
<ReadWriteAtom />
</RecoilRoot>
) : (
'UNMOUNTED'
);
}
const c = renderElements(<Component />);
expect(c.textContent).toBe('UNMOUNTED');
expect(onSetRan).toBe(0);
act(() => setMount(true));
expect(c.textContent).toBe('"DEFAULT"');
expect(onSetRan).toBe(0);
act(() => setAtom('SET'));
expect(c.textContent).toBe('"SET"');
expect(onSetRan).toBe(1);
act(() => setMount(false));
expect(c.textContent).toBe('UNMOUNTED');
expect(onSetRan).toBe(1);
// onSet() handler not called after store is unmounted and effects cleanedup
act(() => setAtom('SET INVALID'));
expect(c.textContent).toBe('UNMOUNTED');
expect(onSetRan).toBe(1);
act(() => setMount(true));
expect(c.textContent).toBe('"DEFAULT"');
expect(onSetRan).toBe(1);
act(() => setAtom('SET2'));
expect(c.textContent).toBe('"SET2"');
expect(onSetRan).toBe(2);
});
// Test that effects can initialize state when an atom is first used after an
// action that also updated another atom's state.
// This corner case was reported by multiple customers.
testRecoil('initialize concurrent with state update', () => {
const myAtom = atom({
key: 'atom effect - concurrent update',
default: 'DEFAULT',
effects: [({setSelf}) => setSelf('INIT')],
});
const otherAtom = atom({
key: 'atom effect - concurrent update / other atom',
default: 'OTHER_DEFAULT',
});
const [OtherAtom, setOtherAtom] =
componentThatReadsAndWritesAtom(otherAtom);
function NewPage() {
return <ReadsAtom atom={myAtom} />;
}
let renderPage;
function App() {
const [showPage, setShowPage] = useState(false);
renderPage = () => setShowPage(true);
return (
<>
<OtherAtom />
{showPage && <NewPage />}
</>
);
}
const c = renderElements(<App />);
// <NewPage> is not yet rendered
expect(c.textContent).toEqual('"OTHER_DEFAULT"');
// Render <NewPage> which initializes myAtom via effect while also
// updating an unrelated atom.
act(() => {
renderPage();
setOtherAtom('OTHER');
});
expect(c.textContent).toEqual('"OTHER""INIT"');
});
testRecoil(
'atom effect runs twice when atom is read from a snapshot and the atom is read for first time in that snapshot',
({strictMode, concurrentMode}) => {
let numTimesEffectInit = 0;
let latestSetSelf = (a: number) => a;
const atomWithEffect = atom({
key: 'atomWithEffect',
default: 0,
effects: [
({setSelf}) => {
// $FlowFixMe[incompatible-type]
latestSetSelf = setSelf;
setSelf(1); // to accurately reproduce minimal reproducible example based on GitHub #1107 issue
numTimesEffectInit++;
},
],
});
const Component = () => {
const readSelFromSnapshot = useRecoilCallback(({snapshot}) => () => {
snapshot.getLoadable(atomWithEffect);
});
readSelFromSnapshot(); // first initialization;
return useRecoilValue(atomWithEffect); // second initialization;
};
const c = renderElements(<Component />);
expect(c.textContent).toBe('1');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
act(() => latestSetSelf(100));
expect(c.textContent).toBe('100');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
act(() => latestSetSelf(200));
expect(c.textContent).toBe('200');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
},
);
/**
* See github issue #1107 item #1
*/
testRecoil(
'atom effect runs twice when selector that depends on that atom is read from a snapshot and the atom is read for first time in that snapshot',
({strictMode, concurrentMode}) => {
let numTimesEffectInit = 0;
let latestSetSelf = (a: number) => a;
const atomWithEffect = atom({
key: 'atomWithEffect',
default: 0,
effects: [
({setSelf}) => {
// $FlowFixMe[incompatible-type]
latestSetSelf = setSelf;
setSelf(1); // to accurately reproduce minimal reproducible example based on GitHub #1107 issue
numTimesEffectInit++;
},
],
});
// $FlowFixMe[incompatible-call]
const selThatDependsOnAtom = selector({
key: 'selThatDependsOnAtom',
get: ({get}) => get(atomWithEffect),
});
const Component = () => {
const readSelFromSnapshot = useRecoilCallback(({snapshot}) => () => {
snapshot.getLoadable(selThatDependsOnAtom);
});
readSelFromSnapshot(); // first initialization;
return useRecoilValue(selThatDependsOnAtom); // second initialization;
};
const c = renderElements(<Component />);
expect(c.textContent).toBe('1');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
act(() => latestSetSelf(100));
expect(c.textContent).toBe('100');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
act(() => latestSetSelf(200));
expect(c.textContent).toBe('200');
expect(numTimesEffectInit).toBe(strictMode && concurrentMode ? 3 : 2);
},
);
describe('Other Atoms', () => {
testRecoil('init from other atom', () => {
const myAtom = atom({
key: 'atom effect - init from other atom',
default: 'DEFAULT',
effects: [
({node, setSelf, getLoadable, getInfo_UNSTABLE}) => {
const otherValue = getLoadable(otherAtom).contents;
expect(otherValue).toEqual('OTHER');
expect(getInfo_UNSTABLE(node).isSet).toBe(false);
expect(getInfo_UNSTABLE(otherAtom).isSet).toBe(false);
expect(getInfo_UNSTABLE(otherAtom).loadable?.contents).toBe(
'OTHER',
);
// $FlowFixMe[incompatible-call]
setSelf(otherValue);
},
],
});
const otherAtom = atom({
key: 'atom effect - other atom',
default: 'OTHER',
});
expect(getValue(myAtom)).toEqual('OTHER');
});
testRecoil('init from other atom async', async () => {
const myAtom = atom({
key: 'atom effect - init from other atom async',
default: 'DEFAULT',
effects: [
({setSelf, getPromise}) => {
const otherValue = getPromise(otherAtom);
setSelf(otherValue);
},
],
});
const otherAtom = atom({
key: 'atom effect - other atom async',
default: Promise.resolve('OTHER'),
});
await expect(
getRecoilStateLoadable(myAtom).promiseOrThrow(),
).resolves.toEqual('OTHER');
});
testRecoil('async get other atoms', async () => {
let initTest1 = null;
let initTest2: null | void = null;
let initTest3: null | void = null;
let initTest4: null | void = null;
let initTest5: null | void = null;
let initTest6: null | void = null;
let setTest = null;
// StrictMode will render twice
let firstRender = true;
const myAtom: RecoilState<string> = atom({
key: 'atom effect - async get',
default: 'DEFAULT',
effects: [
// Test we can get default values
({node, getLoadable, getPromise, getInfo_UNSTABLE}) => {
expect(getLoadable(node).contents).toEqual(
firstRender ? 'DEFAULT' : 'INIT',
);
expect(getInfo_UNSTABLE(node).isSet).toBe(!firstRender);
expect(getInfo_UNSTABLE(node).loadable?.contents).toBe(
firstRender ? 'DEFAULT' : 'INIT',
);
// eslint-disable-next-line jest/valid-expect
initTest1 = expect(getPromise(asyncAtom)).resolves.toEqual('ASYNC');
},
({setSelf}) => {
setSelf('INIT');
},
// Test we can get value from previous initialization
({node, getLoadable, getInfo_UNSTABLE}) => {
expect(getLoadable(node).contents).toEqual('INIT');
expect(getInfo_UNSTABLE(node).isSet).toBe(true);
expect(getInfo_UNSTABLE(node).loadable?.contents).toBe('INIT');
},
// Test we can asynchronously get "current" values of both self and other atoms
// This will be executed when myAtom is set, but checks both atoms.
({onSet, getLoadable, getPromise, getInfo_UNSTABLE}) => {
onSet(x => {
expect(x).toEqual('SET_ATOM');
expect(getLoadable(myAtom).contents).toEqual(x);
expect(getInfo_UNSTABLE(myAtom).isSet).toBe(true);
expect(getInfo_UNSTABLE(myAtom).loadable?.contents).toBe(
'SET_ATOM',
);
// eslint-disable-next-line jest/valid-expect
setTest = expect(getPromise(asyncAtom)).resolves.toEqual(
'SET_OTHER',
);
});
},
() => {
firstRender = false;
},
],
});
const asyncAtom: RecoilState<
Promise<$IMPORTED_TYPE$_DefaultValue> | Promise<string> | string,
> = atom({
key: 'atom effect - other atom async get',
default: Promise.resolve('ASYNC_DEFAULT'),
effects: [
({setSelf}) => void setSelf(Promise.resolve('ASYNC')),
({getPromise, getInfo_UNSTABLE}) => {
expect(getInfo_UNSTABLE(asyncAtom).isSet).toBe(true);
// eslint-disable-next-line jest/valid-expect
initTest2 = expect(
getInfo_UNSTABLE(asyncAtom).loadable?.toPromise(),
).resolves.toBe('ASYNC');
// eslint-disable-next-line jest/valid-expect
initTest3 = expect(getPromise(asyncAtom)).resolves.toEqual('ASYNC');
},
// Test that we can read default for an aborted initialization
({setSelf}) => void setSelf(Promise.resolve(new DefaultValue())),
({getPromise, getInfo_UNSTABLE}) => {
expect(getInfo_UNSTABLE(asyncAtom).isSet).toBe(true); // TODO sketchy...
// eslint-disable-next-line jest/valid-expect
initTest4 = expect(
getInfo_UNSTABLE(asyncAtom).loadable?.toPromise(),
).resolves.toBe('ASYNC_DEFAULT');
// eslint-disable-next-line jest/valid-expect
initTest5 = expect(getPromise(asyncAtom)).resolves.toEqual(
'ASYNC_DEFAULT',
);
},
// Test initializing to async value and other atom can read it
({setSelf}) => void setSelf(Promise.resolve('ASYNC')),
// Test we can also read it ourselves
({getInfo_UNSTABLE}) => {
expect(getInfo_UNSTABLE(asyncAtom).isSet).toBe(true);
// eslint-disable-next-line jest/valid-expect
initTest6 = expect(
getInfo_UNSTABLE(asyncAtom).loadable?.toPromise(),
).resolves.toBe('ASYNC');
},
],
});
const [MyAtom, setMyAtom] = componentThatReadsAndWritesAtom(myAtom);
const [AsyncAtom, setAsyncAtom] =
componentThatReadsAndWritesAtom(asyncAtom);
const c = renderElements(
<>
<MyAtom />
<AsyncAtom />
</>,
);
await flushPromisesAndTimers();
expect(c.textContent).toBe('"INIT""ASYNC"');
expect(initTest1).not.toBe(null);
await initTest1;
expect(initTest2).not.toBe(null);
await initTest2;
expect(initTest3).not.toBe(null);
await initTest3;
expect(initTest4).not.toBe(null);
await initTest4;
expect(initTest5).not.toBe(null);
await initTest5;
expect(initTest6).not.toBe(null);
await initTest6;
act(() => setAsyncAtom('SET_OTHER'));
act(() => setMyAtom('SET_ATOM'));
expect(setTest).not.toBe(null);
await setTest;
});
});
testRecoil('storeID matches <RecoilRoot>', async () => {
let effectStoreID;
const myAtom = atom({
key: 'atom effect - storeID',
default: 'DEFAULT',
effects: [
({storeID, setSelf}) => {
effectStoreID = storeID;
setSelf('INIT');
},
],
});
let rootStoreID;
function StoreID() {
rootStoreID = useRecoilStoreID();
return null;
}
const c = renderElements(
<div>
<StoreID />
<ReadsAtom atom={myAtom} />
</div>,
);
expect(c.textContent).toEqual('"INIT"');
expect(effectStoreID).not.toEqual(undefined);
expect(effectStoreID).toEqual(rootStoreID);
});
testRecoil('parentStoreID matches <RecoilRoot>', async () => {
const myAtom = atom({
key: 'atom effect - parentStoreID',
effects: [
// $FlowFixMe[missing-local-annot]
({parentStoreID_UNSTABLE, setSelf}) => {
setSelf(parentStoreID_UNSTABLE);
},
],
});
let prefetch;
function PrefetchComponent() {
const storeID = useRecoilStoreID();
prefetch = useRecoilCallback(({snapshot}) => () => {
const parentStoreID = snapshot.getLoadable(myAtom).getValue();
expect(storeID).toBe(parentStoreID);
});
}
renderElements(<PrefetchComponent />);
act(prefetch);
});
});
testRecoil('object is frozen when stored in atom', async () => {
const devStatus = window.__DEV__;
window.__DEV__ = true;
const anAtom = atom<{x: mixed, ...}>({key: 'atom frozen', default: {x: 0}});
function valueAfterSettingInAtom<T>(value: T): T {
act(() => set(anAtom, va