UNPKG

recoil

Version:

Recoil - A state management library for React

1,335 lines (1,128 loc) 39 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format * @oncall recoil */ 'use strict'; import type {Loadable} from '../../adt/Recoil_Loadable'; import type {RecoilValue} from '../../core/Recoil_RecoilValue'; import type {RecoilState} from 'Recoil'; import type {RecoilValueReadOnly} from 'Recoil_RecoilValue'; import type {WrappedValue} from 'Recoil_Wrapper'; const { getRecoilTestFn, } = require('recoil-shared/__test_utils__/Recoil_TestingUtils'); let store, atom, noWait, act, isRecoilValue, constSelector, errorSelector, getRecoilValueAsLoadable, setRecoilValue, selector, asyncSelector, resolvingAsyncSelector, stringAtom, loadingAsyncSelector, flushPromisesAndTimers, DefaultValue, RecoilLoadable, isLoadable, freshSnapshot; const testRecoil = getRecoilTestFn(() => { const { makeStore, } = require('recoil-shared/__test_utils__/Recoil_TestingUtils'); ({act} = require('ReactTestUtils')); ({isRecoilValue} = require('../../core/Recoil_RecoilValue')); atom = require('../Recoil_atom'); constSelector = require('../Recoil_constSelector'); errorSelector = require('../Recoil_errorSelector'); ({ getRecoilValueAsLoadable, setRecoilValue, } = require('../../core/Recoil_RecoilValueInterface')); selector = require('../Recoil_selector'); ({freshSnapshot} = require('../../core/Recoil_Snapshot')); ({ asyncSelector, resolvingAsyncSelector, stringAtom, loadingAsyncSelector, flushPromisesAndTimers, } = require('recoil-shared/__test_utils__/Recoil_TestingUtils')); ({noWait} = require('../Recoil_WaitFor')); ({RecoilLoadable, isLoadable} = require('../../adt/Recoil_Loadable')); ({DefaultValue} = require('../../core/Recoil_Node')); store = makeStore(); }); function getLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T> { return getRecoilValueAsLoadable(store, recoilValue); } function getValue<T>(recoilValue: RecoilValue<T>): T { return (getLoadable(recoilValue).contents: any); // flowlint-line unclear-type:off } function getPromise<T>(recoilValue: RecoilValue<T>): Promise<T> { return getLoadable(recoilValue).promiseOrThrow(); } /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's * LTI update could not be added via codemod */ function getError(recoilValue): Error { const error = getLoadable(recoilValue).errorOrThrow(); if (!(error instanceof Error)) { throw new Error('Expected error to be instance of Error'); } return error; } function setValue<T>(recoilState: RecoilState<T>, value: T) { setRecoilValue(store, recoilState, value); // $FlowExpectedError[cannot-write] // $FlowFixMe[unsafe-arithmetic] store.getState().currentTree.version++; } function resetValue<T>(recoilState: RecoilState<T>) { setRecoilValue(store, recoilState, new DefaultValue()); // $FlowExpectedError[cannot-write] // $FlowFixMe[unsafe-arithmetic] store.getState().currentTree.version++; } 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; }); testRecoil('selector get', () => { const staticSel = constSelector('HELLO'); // $FlowFixMe[incompatible-call] 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', }); // $FlowFixMe[incompatible-call] const selectorRW = selector({ key: 'selector/set', get: ({get}) => get(myAtom), set: ({set}, newValue) => set( myAtom, // $FlowFixMe[unsafe-addition] 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', }); // $FlowFixMe[incompatible-call] 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'); }); describe('get return types', () => { testRecoil('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'}); // $FlowFixMe[incompatible-call] 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('evaluate to ValueLoadable', () => { // $FlowFixMe[incompatible-call] const mySelector = selector<string>({ key: 'selector/output loadable value', // $FlowFixMe[incompatible-call] get: () => RecoilLoadable.of('VALUE'), }); expect(getValue(mySelector)).toEqual('VALUE'); }); testRecoil('evaluate to ErrorLoadable', () => { // $FlowFixMe[incompatible-call] const mySelector = selector<string>({ key: 'selector/output loadable error', get: () => RecoilLoadable.error(new Error('ERROR')), }); expect(getError(mySelector)).toBeInstanceOf(Error); expect(getError(mySelector).message).toBe('ERROR'); }); testRecoil('evaluate to LoadingLoadable', async () => { // $FlowFixMe[incompatible-call] const mySelector = selector<string>({ key: 'selector/output loadable loading', // $FlowFixMe[incompatible-call] get: () => RecoilLoadable.of(Promise.resolve('ASYNC')), }); await expect(getPromise(mySelector)).resolves.toEqual('ASYNC'); }); testRecoil('evaluate to derived Loadable', async () => { const myAtom = stringAtom(); // $FlowFixMe[incompatible-call] const mySelector = selector<string>({ key: 'selector/output loadable derived', get: ({get}) => // $FlowFixMe[incompatible-call] get(noWait(myAtom)).map(x => Promise.resolve(`DERIVE-${x}`)), }); await expect(getPromise(mySelector)).resolves.toEqual('DERIVE-DEFAULT'); }); testRecoil('evaluate to SelectorValue value', () => { const mySelector = selector<string>({ key: 'selector/output SelectorValue value', // $FlowFixMe[incompatible-call] get: () => selector.value('VALUE'), }); expect(getValue(mySelector)).toEqual('VALUE'); }); testRecoil('evaluate to SelectorValue Promise', async () => { const mySelector = selector<Promise<string>>({ key: 'selector/output SelectorValue promise', // $FlowFixMe[incompatible-call] get: () => selector.value(Promise.resolve('ASYNC')), }); expect(getValue(mySelector)).toBeInstanceOf(Promise); await expect(getValue(mySelector)).resolves.toBe('ASYNC'); }); testRecoil('evaluate to SelectorValue Loadable', () => { const mySelector = selector<Loadable<string>>({ key: 'selector/output SelectorValue loadable', // $FlowFixMe[incompatible-call] get: () => selector.value(RecoilLoadable.of('VALUE')), }); expect(isLoadable(getValue(mySelector))).toBe(true); expect(getValue(mySelector).getValue()).toBe('VALUE'); }); testRecoil('evaluate to SelectorValue ErrorLoadable', () => { const mySelector = selector({ key: 'selector/output SelectorValue loadable error', // $FlowFixMe[incompatible-call] get: () => selector.value(RecoilLoadable.error('ERROR')), }); expect(isLoadable(getValue(mySelector))).toBe(true); expect(getValue(mySelector).errorOrThrow()).toBe('ERROR'); }); testRecoil('evaluate to SelectorValue Atom', () => { const myAtom = stringAtom(); const mySelector = selector({ key: 'selector/output SelectorValue loadable error', // $FlowFixMe[incompatible-call] get: () => selector.value(myAtom), }); expect(isRecoilValue(getValue(mySelector))).toBe(true); }); }); describe('Catching Deps', () => { testRecoil('selector - catching exceptions', () => { const throwingSel = errorSelector<$FlowFixMe>('MY ERROR'); expect(getValue(throwingSel)).toBeInstanceOf(Error); // $FlowFixMe[incompatible-call] 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< | RecoilValue<string> | Promise<string> | Loadable<string> | WrappedValue<string> | string, >({ key: '__error/non Error thrown', get: () => { // eslint-disable-next-line no-throw-literal throw 'MY ERROR'; }, }); // $FlowFixMe[incompatible-call] 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); // $FlowFixMe[incompatible-call] const blockedSelector = selector({ key: 'selector/blocked selector', get: ({get}) => get(loadingSel), }); expect(getValue(blockedSelector) instanceof Promise).toBe(true); // $FlowFixMe[incompatible-call] 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'); }); }); describe('Dependencies', () => { // 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: RecoilValueReadOnly<$FlowFixMe> = selector({ key: 'circular/A', get: ({get}) => get(selectorC), }); const selectorB: RecoilValueReadOnly<$FlowFixMe> = selector({ key: 'circular/B', get: ({get}) => get(selectorA), }); const selectorC: RecoilValueReadOnly<$FlowFixMe> = 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( 'distinct loading dependencies are treated as distinct', async () => { const upstreamAtom = atom({ key: 'distinct loading dependencies/upstreamAtom', default: 0, }); // $FlowFixMe[incompatible-call] 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 - kite pattern runs only necessary selectors', async () => { const aNode = atom({ key: 'aNode', default: true, }); let bNodeRunCount = 0; // $FlowFixMe[incompatible-call] const bNode = selector({ key: 'bNode', get: ({get}) => { bNodeRunCount++; const a = get(aNode); return String(a); }, }); let cNodeRunCount = 0; // $FlowFixMe[incompatible-call] 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( '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, }); // $FlowFixMe[incompatible-call] 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" */ mappedSnapshot.retain(); 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('Dynamic deps discovered after await', async () => { const testSnapshot = freshSnapshot(); testSnapshot.retain(); const myAtom = atom<number>({ key: 'selector dynamic dep after await atom', default: 0, }); let selectorRunCount = 0; let selectorRunCompleteCount = 0; // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector dynamic dep after await selector', get: async ({get}) => { await Promise.resolve(); get(myAtom); selectorRunCount++; await new Promise(() => {}); selectorRunCompleteCount++; }, }); testSnapshot.getLoadable(mySelector); expect(testSnapshot.getLoadable(mySelector).state).toBe('loading'); await flushPromisesAndTimers(); expect(selectorRunCount).toBe(1); expect(selectorRunCompleteCount).toBe(0); const mappedSnapshot = testSnapshot.map(({set}) => set(myAtom, prev => prev + 1), ); expect(mappedSnapshot.getLoadable(mySelector).state).toBe('loading'); await flushPromisesAndTimers(); expect(selectorRunCount).toBe(2); expect(selectorRunCompleteCount).toBe(0); }); testRecoil('Dynamic deps discovered after pending', async () => { const pendingSelector = loadingAsyncSelector(); const testSnapshot = freshSnapshot(); testSnapshot.retain(); const myAtom = atom<number>({ key: 'selector dynamic dep after pending atom', default: 0, }); let selectorRunCount = 0; let selectorRunCompleteCount = 0; // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector dynamic dep after pending selector', get: async ({get}) => { await Promise.resolve(); get(myAtom); selectorRunCount++; get(pendingSelector); selectorRunCompleteCount++; }, }); testSnapshot.getLoadable(mySelector); expect(testSnapshot.getLoadable(mySelector).state).toBe('loading'); await flushPromisesAndTimers(); expect(selectorRunCount).toBe(1); expect(selectorRunCompleteCount).toBe(0); const mappedSnapshot = testSnapshot.map(({set}) => set(myAtom, prev => prev + 1), ); expect(mappedSnapshot.getLoadable(mySelector).state).toBe('loading'); await flushPromisesAndTimers(); expect(selectorRunCount).toBe(2); expect(selectorRunCompleteCount).toBe(0); }); }); describe('Async Selector Set', () => { testRecoil('async set not supported', () => { const myAtom = stringAtom(); // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector async set', get: () => null, // $FlowExpectedError[incompatible-call] set: async ({set}, x) => set(myAtom, x), }); expect(() => setValue(mySelector, 'ERROR')).toThrow('Async'); }); testRecoil('async set call not supported', async () => { const myAtom = atom({ key: 'selector / async not supported / other atom', default: 'DEFAULT', }); // $FlowFixMe[incompatible-call] 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. set: async ({set, reset}, newVal) => { await Promise.resolve(); newVal instanceof DefaultValue ? reset(myAtom) : set(myAtom, 'SET'); }, }); let setAttempt, resetAttempt; // $FlowFixMe[incompatible-call] const mySelector2 = selector({ key: 'selector / async set not supported / async upstream call', get: () => myAtom, set: ({set, reset}, newVal) => { if (newVal instanceof DefaultValue) { resetAttempt = Promise.resolve().then(() => { reset(myAtom); }); } else { setAttempt = 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('set tries to get async value', () => { const myAtom = atom<string>({key: 'selector set get async atom'}); const mySelector = selector({ key: 'selector set get async selector', get: () => myAtom, set: ({get}) => { get(myAtom); }, }); expect(() => setValue(mySelector, 'ERROR')).toThrow( 'selector set get async', ); }); }); describe('User-thrown promises', () => { testRecoil( 'selectors with user-thrown loadable promises execute to completion as expected', async () => { const [asyncDep, resolveAsyncDep] = asyncSelector<string, void>(); // $FlowFixMe[incompatible-call] 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; let resolve = () => {}; const myPromise = new Promise(localResolve => { resolve = () => { isResolved = true; localResolve(); }; }); // $FlowFixMe[incompatible-call] 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>(); // $FlowFixMe[incompatible-call] 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', }, }, }, }); // $FlowFixMe[incompatible-call] 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; }); describe('getCallback', () => { testRecoil('Selector getCallback', async () => { const myAtom = atom({ key: 'selector - getCallback atom', default: 'DEFAULT', }); // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector - getCallback', get: ({getCallback}) => { return { onClick: getCallback( ({snapshot}) => async () => await snapshot.getPromise(myAtom), ), }; }, }); const menuItem = getValue(mySelector); expect(getValue(myAtom)).toEqual('DEFAULT'); await expect(menuItem.onClick()).resolves.toEqual('DEFAULT'); act(() => setValue(myAtom, 'SET')); expect(getValue(myAtom)).toEqual('SET'); await expect(menuItem.onClick()).resolves.toEqual('SET'); act(() => setValue(myAtom, 'SET2')); expect(getValue(myAtom)).toEqual('SET2'); await expect(menuItem.onClick()).resolves.toEqual('SET2'); }); testRecoil('snapshot', async () => { const otherSelector = constSelector('VALUE'); // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector getCallback snapshot', get: ({getCallback}) => // $FlowFixMe[missing-local-annot] getCallback(({snapshot}) => param => ({ param, loadable: snapshot.getLoadable(otherSelector), promise: snapshot.getPromise(otherSelector), })), }); expect(getValue(mySelector)(123).param).toBe(123); expect(getValue(mySelector)(123).loadable.getValue()).toBe('VALUE'); await expect(getValue(mySelector)(123).promise).resolves.toBe('VALUE'); }); testRecoil('set', () => { const myAtom = atom({ key: 'selector getCallback set atom', default: 'DEFAULT', }); // $FlowFixMe[incompatible-call] const setSelector = selector({ key: 'selector getCallback set', get: ({getCallback}) => // $FlowFixMe[missing-local-annot] getCallback(({set}) => param => { set(myAtom, param); }), }); // $FlowFixMe[incompatible-call] const resetSelector = selector({ key: 'selector getCallback reset', get: ({getCallback}) => getCallback(({reset}) => () => { reset(myAtom); }), }); expect(getValue(myAtom)).toBe('DEFAULT'); getValue(setSelector)('SET'); expect(getValue(myAtom)).toBe('SET'); getValue(resetSelector)(); expect(getValue(myAtom)).toBe('DEFAULT'); }); testRecoil('transaction', () => { const myAtom = atom({ key: 'selector getCallback transact atom', default: 'DEFAULT', }); // $FlowFixMe[incompatible-call] const setSelector = selector({ key: 'selector getCallback transact set', get: ({getCallback}) => // $FlowFixMe[missing-local-annot] getCallback(({transact_UNSTABLE}) => param => { transact_UNSTABLE(({set, get}) => { expect(get(myAtom)).toBe('DEFAULT'); set(myAtom, 'TMP'); expect(get(myAtom)).toBe('TMP'); set(myAtom, param); }); }), }); // $FlowFixMe[incompatible-call] const resetSelector = selector({ key: 'selector getCallback transact', get: ({getCallback}) => getCallback(({transact_UNSTABLE}) => () => { transact_UNSTABLE(({reset}) => reset(myAtom)); }), }); expect(getValue(myAtom)).toBe('DEFAULT'); getValue(setSelector)('SET'); expect(getValue(myAtom)).toBe('SET'); getValue(resetSelector)(); expect(getValue(myAtom)).toBe('DEFAULT'); }); testRecoil('node', () => { // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector getCallback node', get: ({getCallback}) => // $FlowFixMe[missing-local-annot] getCallback(({node, snapshot}) => param => ({ param, loadable: snapshot.getLoadable(node), promise: snapshot.getPromise(node), })), }); expect(getValue(mySelector)(123).param).toBe(123); expect(getValue(mySelector)(123).loadable.getValue()(456).param).toBe(456); }); testRecoil('refresh', async () => { let externalValue = 0; // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector getCallback node refresh', get: ({getCallback}) => { const cachedExternalValue = externalValue; return getCallback(({node, refresh}) => () => ({ cached: cachedExternalValue, current: externalValue, refresh: () => refresh(node), })); }, }); expect(getValue(mySelector)().current).toBe(0); expect(getValue(mySelector)().cached).toBe(0); externalValue = 1; expect(getValue(mySelector)().current).toBe(1); expect(getValue(mySelector)().cached).toBe(0); getValue(mySelector)().refresh(); expect(getValue(mySelector)().current).toBe(1); expect(getValue(mySelector)().cached).toBe(1); }); testRecoil('Guard against calling during selector evaluation', async () => { // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector getCallback guard', get: ({getCallback}) => { const callback = getCallback(() => () => {}); expect(() => callback()).toThrow(); return 'THROW'; }, }); expect(getValue(mySelector)).toBe('THROW'); // $FlowFixMe[incompatible-call] const myAsyncSelector = selector({ key: 'selector getCallback guard async', get: async ({getCallback}) => { const callback = getCallback(() => () => {}); await Promise.resolve(); expect(() => callback()).toThrow(); return 'THROW'; }, }); await expect(getPromise(myAsyncSelector)).resolves.toBe('THROW'); }); testRecoil('Callback can be used from thrown error', async () => { const mySelector = selector({ key: 'selector getCallback from error', get: ({getCallback}) => { // eslint-disable-next-line no-throw-literal // $FlowFixMe[missing-local-annot] throw {callback: getCallback(() => x => x)}; }, }); // $FlowExpectedError[incompatible-use]] expect(getLoadable(mySelector).errorOrThrow().callback(123)).toEqual(123); const myAsyncSelector = selector({ key: 'selector getCallback from error async', get: ({getCallback}) => { return Promise.reject({callback: getCallback(() => x => x)}); }, }); await expect( getPromise(myAsyncSelector).catch(({callback}) => callback(123)), ).resolves.toEqual(123); }); }); testRecoil('Report error with inconsistent values', () => { const depA = stringAtom(); const depB = stringAtom(); // NOTE This is an illegal selector because it can provide different values // with the same input dependency values. let invalidInput = null; // $FlowFixMe[incompatible-call] const mySelector = selector({ key: 'selector report invalid change', get: ({get}) => { get(depA); if (invalidInput) { return invalidInput; } return get(depB); }, }); expect(getValue(mySelector)).toBe('DEFAULT'); const DEV = window.__DEV__; let msg; const consoleError = console.error; // $FlowIssue[cannot-write] console.error = (...args) => { msg = args[0]; consoleError(...args); }; window.__DEV__ = true; invalidInput = 'INVALID'; setValue(depB, 'SET'); // Reset logic will still allow selector to work by resetting cache. expect(getValue(mySelector)).toBe('INVALID'); expect(msg).toEqual(expect.stringContaining('consistent values')); // $FlowIssue[cannot-write] console.error = consoleError; window.__DEV__ = DEV; }); 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, }); // $FlowFixMe[incompatible-call] 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; });