UNPKG

recoil

Version:

Recoil - A state management library for React

546 lines (514 loc) 19.8 kB
/** * 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(<> <TransactionObserver /> <GotoSnapshot /> <WriteAtom /> <ReadsAtom atom={mySelector} /> </>); 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<string>({ 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(<Component />); 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); }); });