/**
* 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
*/
'use strict';
import type { Snapshot } from '../Recoil_Snapshot';
const {
getRecoilTestFn
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils');
let React, act, useState, useGotoRecoilSnapshot, useRecoilTransactionObserver, atom, constSelector, selector, ReadsAtom, flushPromisesAndTimers, asyncSelector, componentThatReadsAndWritesAtom, renderElements, freshSnapshot, RecoilRoot;
const testRecoil = getRecoilTestFn(() => {
React = require('react');
({
useState
} = React);
({
act
} = require('ReactTestUtils'));
({
useGotoRecoilSnapshot,
useRecoilTransactionObserver
} = require('../../hooks/Recoil_SnapshotHooks'));
atom = require('../../recoil_values/Recoil_atom');
constSelector = require('../../recoil_values/Recoil_constSelector');
selector = require('../../recoil_values/Recoil_selector');
({
ReadsAtom,
flushPromisesAndTimers,
asyncSelector,
componentThatReadsAndWritesAtom,
renderElements
} = require('recoil-shared/__test_utils__/Recoil_TestingUtils'));
({
freshSnapshot
} = require('../Recoil_Snapshot'));
({
RecoilRoot
} = require('../Recoil_RecoilRoot'));
}); // Use this to spread proxy results into an object for Jest's toMatchObject()
declare function getInfo(snapshot: any, node: any): any; // Test first since we are testing all registered nodes
testRecoil('getNodes', () => {
const snapshot = freshSnapshot();
const {
getNodes_UNSTABLE
} = snapshot;
expect(Array.from(getNodes_UNSTABLE()).length).toEqual(0);
expect(Array.from(getNodes_UNSTABLE({
isInitialized: true
})).length).toEqual(0); // expect(Array.from(getNodes_UNSTABLE({isSet: true})).length).toEqual(0);
// Test atoms
const myAtom = atom({
key: 'snapshot getNodes atom',
default: 'DEFAULT'
});
expect(Array.from(getNodes_UNSTABLE()).length).toEqual(1);
expect(Array.from(getNodes_UNSTABLE({
isInitialized: true
})).length).toEqual(0);
expect(snapshot.getLoadable(myAtom).contents).toEqual('DEFAULT');
const nodesAfterGet = Array.from(getNodes_UNSTABLE());
expect(nodesAfterGet.length).toEqual(1);
expect(nodesAfterGet[0]).toBe(myAtom);
expect(snapshot.getLoadable(nodesAfterGet[0]).contents).toEqual('DEFAULT'); // Test selectors
const mySelector = selector({
key: 'snapshot getNodes selector',
get: ({
get
}) => get(myAtom) + '-SELECTOR'
});
expect(Array.from(getNodes_UNSTABLE()).length).toEqual(2);
expect(Array.from(getNodes_UNSTABLE({
isInitialized: true
})).length).toEqual(1);
expect(snapshot.getLoadable(mySelector).contents).toEqual('DEFAULT-SELECTOR');
expect(Array.from(getNodes_UNSTABLE({
isInitialized: true
})).length).toEqual(2); // expect(Array.from(getNodes_UNSTABLE({types: ['atom']})).length).toEqual(1);
// const selectorNodes = Array.from(getNodes_UNSTABLE({types: ['selector']}));
// expect(selectorNodes.length).toEqual(1);
// expect(selectorNodes[0]).toBe(mySelector);
// Test dirty atoms
expect(Array.from(getNodes_UNSTABLE()).length).toEqual(2); // expect(Array.from(getNodes_UNSTABLE({isSet: true})).length).toEqual(0);
expect(Array.from(snapshot.getNodes_UNSTABLE({
isModified: true
})).length).toEqual(0);
const updatedSnapshot = snapshot.map(({
set
}) => set(myAtom, 'SET'));
expect(Array.from(snapshot.getNodes_UNSTABLE({
isModified: true
})).length).toEqual(0);
expect(Array.from(updatedSnapshot.getNodes_UNSTABLE({
isModified: true
})).length).toEqual(1); // expect(
// Array.from(snapshot.getNodes_UNSTABLE({isSet: true})).length,
// ).toEqual(0);
// expect(
// Array.from(updatedSnapshot.getNodes_UNSTABLE({isSet: true})).length,
// ).toEqual(1);
const dirtyAtom = Array.from(updatedSnapshot.getNodes_UNSTABLE({
isModified: true
}))[0];
expect(snapshot.getLoadable(dirtyAtom).contents).toEqual('DEFAULT');
expect(updatedSnapshot.getLoadable(dirtyAtom).contents).toEqual('SET'); // Test reset
const resetSnapshot = updatedSnapshot.map(({
reset
}) => reset(myAtom));
expect(Array.from(resetSnapshot.getNodes_UNSTABLE({
isModified: true
})).length).toEqual(1); // expect(
// Array.from(resetSnapshot.getNodes_UNSTABLE({isSet: true})).length,
// ).toEqual(0);
// TODO Test dirty selectors
});
testRecoil('State ID after going to snapshot matches the ID of the snapshot', () => {
const seenIDs = new Set();
const snapshots = [];
let expectedSnapshotID = null;
const myAtom = atom({
key: 'Snapshot ID atom',
default: 0
}); // $FlowFixMe[incompatible-call] added when improving typing for this parameters
const mySelector = constSelector(myAtom); // For read-only testing below
declare var transactionObserver: (arg0: any) => any;
declare function TransactionObserver(): any;
let gotoSnapshot;
declare function GotoSnapshot(): any;
const [WriteAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom);
const c = renderElements(<>
>);
expect(c.textContent).toBe('00'); // Test changing state produces a new state version
act(() => setAtom(1));
act(() => setAtom(2));
expect(snapshots.length).toBe(2);
expect(seenIDs.size).toBe(2); // Test going to a previous snapshot re-uses the state ID
expectedSnapshotID = snapshots[0].snapshotID;
act(() => gotoSnapshot(snapshots[0].snapshot)); // Test changing state after going to a previous snapshot uses a new version
expectedSnapshotID = null;
act(() => setAtom(3)); // Test mutating a snapshot creates a new version
const transactionSnapshot = snapshots[0].snapshot.map(({
set
}) => {
set(myAtom, 4);
});
act(() => gotoSnapshot(transactionSnapshot));
expect(seenIDs.size).toBe(4);
expect(snapshots.length).toBe(5); // Test that added read-only selector doesn't cause an issue getting the
// current version to see the current deps of the selector since we mutated a
// state after going to a snapshot, so that version may not be known by the store.
// If there was a problem, then the component may throw an error when evaluating the selector.
expect(c.textContent).toBe('44');
});
testRecoil('Read default loadable from snapshot', () => {
const snapshot: Snapshot = freshSnapshot();
const myAtom = atom({
key: 'Snapshot Atom Default',
default: 'DEFAULT'
});
const atomLoadable = snapshot.getLoadable(myAtom);
expect(atomLoadable.state).toEqual('hasValue');
expect(atomLoadable.contents).toEqual('DEFAULT'); // $FlowFixMe[incompatible-call] added when improving typing for this parameters
const mySelector = constSelector(myAtom);
const selectorLoadable = snapshot.getLoadable(mySelector);
expect(selectorLoadable.state).toEqual('hasValue');
expect(selectorLoadable.contents).toEqual('DEFAULT');
});
testRecoil('Read async selector from snapshot', async () => {
const snapshot = freshSnapshot();
const otherA = freshSnapshot();
const otherB = freshSnapshot();
const [asyncSel, resolve] = asyncSelector(); // $FlowFixMe[incompatible-call] added when improving typing for this parameters
const nestSel = constSelector(asyncSel);
expect(snapshot.getLoadable(asyncSel).state).toEqual('loading');
expect(snapshot.getLoadable(nestSel).state).toEqual('loading');
expect(otherA.getLoadable(nestSel).state).toEqual('loading');
const otherC = snapshot.map(() => {}); // eslint-disable-next-line jest/valid-expect
const ptest = expect(snapshot.getPromise(asyncSel)).resolves.toEqual('SET VALUE');
act(() => resolve('SET VALUE'));
await ptest;
await expect(snapshot.getPromise(asyncSel)).resolves.toEqual('SET VALUE');
expect(snapshot.getLoadable(asyncSel).contents).toEqual('SET VALUE');
await expect(snapshot.getPromise(nestSel)).resolves.toEqual('SET VALUE');
await expect(otherA.getPromise(nestSel)).resolves.toEqual('SET VALUE');
await expect(otherB.getPromise(nestSel)).resolves.toEqual('SET VALUE');
await expect(otherC.getPromise(nestSel)).resolves.toEqual('SET VALUE');
});
testRecoil('Sync map of snapshot', () => {
const snapshot = freshSnapshot();
const myAtom = atom({
key: 'Snapshot Map Sync',
default: 'DEFAULT'
}); // $FlowFixMe[incompatible-call] added when improving typing for this parameters
const mySelector = constSelector(myAtom);
const atomLoadable = snapshot.getLoadable(myAtom);
expect(atomLoadable.state).toEqual('hasValue');
expect(atomLoadable.contents).toEqual('DEFAULT');
const selectorLoadable = snapshot.getLoadable(mySelector);
expect(selectorLoadable.state).toEqual('hasValue');
expect(selectorLoadable.contents).toEqual('DEFAULT');
const setSnapshot = snapshot.map(({
set
}) => {
set(myAtom, 'SET');
});
const setAtomLoadable = setSnapshot.getLoadable(myAtom);
expect(setAtomLoadable.state).toEqual('hasValue');
expect(setAtomLoadable.contents).toEqual('SET');
const setSelectorLoadable = setSnapshot.getLoadable(myAtom);
expect(setSelectorLoadable.state).toEqual('hasValue');
expect(setSelectorLoadable.contents).toEqual('SET');
const resetSnapshot = snapshot.map(({
reset
}) => {
reset(myAtom);
});
const resetAtomLoadable = resetSnapshot.getLoadable(myAtom);
expect(resetAtomLoadable.state).toEqual('hasValue');
expect(resetAtomLoadable.contents).toEqual('DEFAULT');
const resetSelectorLoadable = resetSnapshot.getLoadable(myAtom);
expect(resetSelectorLoadable.state).toEqual('hasValue');
expect(resetSelectorLoadable.contents).toEqual('DEFAULT');
});
testRecoil('Async map of snapshot', async () => {
const snapshot = freshSnapshot();
const myAtom = atom({
key: 'Snapshot Map Async',
default: 'DEFAULT'
});
const [beforeAsyncSel, resolveBeforeMap] = asyncSelector();
const [duringAsyncSel, resolveDuringMap] = asyncSelector();
const [afterAsyncSel, resolveAfterMap] = asyncSelector();
const depAsyncSel = selector({
key: 'snapshot asyncMap dep selector',
get: () => afterAsyncSel
});
resolveBeforeMap('BEFORE');
const newSnapshotPromise = snapshot.asyncMap(async ({
getPromise,
set
}) => {
await expect(getPromise(beforeAsyncSel)).resolves.toBe('BEFORE');
await expect(getPromise(duringAsyncSel)).resolves.toBe('DURING'); // Test that depAsyncSel is first used while mapping the snapshot.
// If the snapshot is auto-released too early the async selector will be
// canceled.
getPromise(depAsyncSel); // Test that mapped snapshot is not auto-released too early
await flushPromisesAndTimers();
set(myAtom, 'VALUE');
});
resolveDuringMap('DURING');
const newSnapshot = await newSnapshotPromise;
expect(newSnapshot.isRetained()).toBe(true);
resolveAfterMap('AFTER');
await expect(newSnapshot.getPromise(myAtom)).resolves.toBe('VALUE');
await expect(newSnapshot.getPromise(beforeAsyncSel)).resolves.toBe('BEFORE');
await expect(newSnapshot.getPromise(duringAsyncSel)).resolves.toBe('DURING');
await expect(newSnapshot.getPromise(afterAsyncSel)).resolves.toBe('AFTER');
await expect(newSnapshot.getPromise(depAsyncSel)).resolves.toBe('AFTER');
});
testRecoil('getInfo', () => {
const snapshot = freshSnapshot();
const myAtom = atom({
key: 'snapshot getInfo atom',
default: 'DEFAULT'
});
const selectorA = selector({
key: 'getInfo A',
get: ({
get
}) => get(myAtom)
});
const selectorB = selector({
key: 'getInfo B',
get: ({
get
}) => get(selectorA) + get(myAtom)
}); // Initial status
expect(getInfo(snapshot, myAtom)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULT'
}),
isActive: false,
isSet: false,
isModified: false,
type: 'atom'
});
expect(Array.from(getInfo(snapshot, myAtom).deps)).toEqual([]);
expect(Array.from(getInfo(snapshot, myAtom).subscribers.nodes)).toEqual([]);
expect(getInfo(snapshot, selectorA)).toMatchObject({
loadable: undefined,
isActive: false,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(snapshot, selectorA).deps)).toEqual([]);
expect(Array.from(getInfo(snapshot, selectorA).subscribers.nodes)).toEqual([]);
expect(getInfo(snapshot, selectorB)).toMatchObject({
loadable: undefined,
isActive: false,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(snapshot, selectorB).deps)).toEqual([]);
expect(Array.from(getInfo(snapshot, selectorB).subscribers.nodes)).toEqual([]); // After reading values
snapshot.getLoadable(selectorB);
expect(getInfo(snapshot, myAtom)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULT'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'atom'
});
expect(Array.from(getInfo(snapshot, myAtom).deps)).toEqual([]);
expect(Array.from(getInfo(snapshot, myAtom).subscribers.nodes)).toEqual(expect.arrayContaining([selectorA, selectorB]));
expect(getInfo(snapshot, selectorA)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULT'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(snapshot, selectorA).deps)).toEqual(expect.arrayContaining([myAtom]));
expect(Array.from(getInfo(snapshot, selectorA).subscribers.nodes)).toEqual(expect.arrayContaining([selectorB]));
expect(getInfo(snapshot, selectorB)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULTDEFAULT'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(snapshot, selectorB).deps)).toEqual(expect.arrayContaining([myAtom, selectorA]));
expect(Array.from(getInfo(snapshot, selectorB).subscribers.nodes)).toEqual([]); // After setting a value
const setSnapshot = snapshot.map(({
set
}) => set(myAtom, 'SET'));
setSnapshot.getLoadable(selectorB); // Read value to prime
expect(getInfo(setSnapshot, myAtom)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'SET'
}),
isActive: true,
isSet: true,
isModified: true,
type: 'atom'
});
expect(Array.from(getInfo(setSnapshot, myAtom).deps)).toEqual([]);
expect(Array.from(getInfo(setSnapshot, myAtom).subscribers.nodes)).toEqual(expect.arrayContaining([selectorA, selectorB]));
expect(getInfo(setSnapshot, selectorA)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'SET'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(setSnapshot, selectorA).deps)).toEqual(expect.arrayContaining([myAtom]));
expect(Array.from(getInfo(setSnapshot, selectorA).subscribers.nodes)).toEqual(expect.arrayContaining([selectorB]));
expect(getInfo(setSnapshot, selectorB)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'SETSET'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(setSnapshot, selectorB).deps)).toEqual(expect.arrayContaining([myAtom, selectorA]));
expect(Array.from(getInfo(setSnapshot, selectorB).subscribers.nodes)).toEqual([]); // After reseting a value
const resetSnapshot = setSnapshot.map(({
reset
}) => reset(myAtom));
resetSnapshot.getLoadable(selectorB); // prime snapshot
expect(getInfo(resetSnapshot, myAtom)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULT'
}),
isActive: true,
isSet: false,
isModified: true,
type: 'atom'
});
expect(Array.from(getInfo(resetSnapshot, myAtom).deps)).toEqual([]);
expect(Array.from(getInfo(resetSnapshot, myAtom).subscribers.nodes)).toEqual(expect.arrayContaining([selectorA, selectorB]));
expect(getInfo(resetSnapshot, selectorA)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULT'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(resetSnapshot, selectorA).deps)).toEqual(expect.arrayContaining([myAtom]));
expect(Array.from(getInfo(resetSnapshot, selectorA).subscribers.nodes)).toEqual(expect.arrayContaining([selectorB]));
expect(getInfo(resetSnapshot, selectorB)).toMatchObject({
loadable: expect.objectContaining({
state: 'hasValue',
contents: 'DEFAULTDEFAULT'
}),
isActive: true,
isSet: false,
isModified: false,
type: 'selector'
});
expect(Array.from(getInfo(resetSnapshot, selectorB).deps)).toEqual(expect.arrayContaining([myAtom, selectorA]));
expect(Array.from(getInfo(resetSnapshot, selectorB).subscribers.nodes)).toEqual([]);
});
describe('Retention', () => {
testRecoil('auto-release', async () => {
const snapshot = freshSnapshot();
expect(snapshot.isRetained()).toBe(true);
await flushPromisesAndTimers();
expect(snapshot.isRetained()).toBe(false);
const devStatus = window.__DEV__;
window.__DEV__ = true;
expect(() => snapshot.retain()).toThrow('released');
window.__DEV__ = false;
expect(() => snapshot.retain()).not.toThrow('released');
window.__DEV__ = devStatus; // TODO enable when recoil_memory_managament_2020 is enforced
// expect(() => snapshot.getID()).toThrow('release');
});
testRecoil('retain()', async () => {
const snapshot = freshSnapshot();
expect(snapshot.isRetained()).toBe(true);
const release2 = snapshot.retain();
await flushPromisesAndTimers();
expect(snapshot.isRetained()).toBe(true);
release2();
expect(snapshot.isRetained()).toBe(false);
});
});
describe('Atom effects', () => {
testRecoil('Standalone snapshot', async ({
gks
}) => {
let effectsRefCount = 0;
const myAtom = atom({
key: 'snapshot effects',
default: 'DEFAULT',
effects: [({
setSelf
}) => {
effectsRefCount++;
setSelf('INIT');
return () => {
effectsRefCount--;
};
}]
});
expect(effectsRefCount).toBe(0);
const fresh = freshSnapshot();
expect(fresh.getLoadable(myAtom).getValue()).toBe('INIT');
expect(effectsRefCount).toBe(1); // Auto-release snapshot
await flushPromisesAndTimers();
expect(effectsRefCount).toBe(0);
if (gks.includes('recoil_memory_management_2020')) {// TODO Enable when this is an error
// expect(() => fresh.getLoadable(myAtom)).toThrow();
}
});
testRecoil('RecoilRoot Snapshot', () => {
let effectsRefCount = 0;
const myAtom = atom({
key: 'snapshot effects',
default: 'DEFAULT',
effects: [({
setSelf
}) => {
effectsRefCount++;
setSelf('INIT');
return () => {
effectsRefCount--;
};
}]
});
declare var setMount: (_: any) => any;
declare function Component(): any;
const c = renderElements();
expect(c.textContent).toBe('UNMOUNTED');
expect(effectsRefCount).toBe(0);
act(() => setMount(true));
expect(c.textContent).toBe('"INIT"');
expect(effectsRefCount).toBe(1);
act(() => setMount(false));
expect(c.textContent).toBe('UNMOUNTED');
expect(effectsRefCount).toBe(0);
});
});