/** * Copyright (c) Facebook, Inc. and its affiliates. Confidential and proprietary. * * @emails oncall+recoil * @flow strict-local * @format */ 'use strict'; const { getRecoilTestFn } = require('../../testing/Recoil_TestingUtils'); let React, useState, Profiler, ReactDOM, act, DEFAULT_VALUE, DefaultValue, RecoilRoot, getRecoilValueAsLoadable, setRecoilValue, useRecoilState, useRecoilTransactionObserver, useResetRecoilState, ReadsAtom, componentThatReadsAndWritesAtom, flushPromisesAndTimers, renderElements, atom, immutable, store; const testRecoil = getRecoilTestFn(() => { const { makeStore } = require('../../testing/Recoil_TestingUtils'); React = require('react'); ({ useState, Profiler } = require('react')); ReactDOM = require('ReactDOM'); ({ act } = require('ReactTestUtils')); ({ DEFAULT_VALUE, DefaultValue } = require('../../core/Recoil_Node')); ({ RecoilRoot } = require('../../core/Recoil_RecoilRoot.react')); ({ getRecoilValueAsLoadable, setRecoilValue } = require('../../core/Recoil_RecoilValueInterface')); ({ useRecoilState, useRecoilTransactionObserver, useResetRecoilState } = require('../../hooks/Recoil_Hooks')); ({ ReadsAtom, componentThatReadsAndWritesAtom, flushPromisesAndTimers, renderElements } = require('../../testing/Recoil_TestingUtils')); atom = require('../Recoil_atom'); immutable = require('immutable'); store = makeStore(); }); declare function get(recoilValue: any): any; declare function set(recoilValue: any, value: mixed): any; declare function reset(recoilValue: any): any; testRecoil('atom can read and write value', () => { const myAtom = atom({ key: 'atom with default', default: 'DEFAULT' }); expect(get(myAtom)).toBe('DEFAULT'); act(() => set(myAtom, 'VALUE')); expect(get(myAtom)).toBe('VALUE'); }); describe('Valid values', () => { testRecoil('atom can store null and undefined', () => { const myAtom = atom({ key: 'atom with default for null and undefined', default: 'DEFAULT' }); expect(get(myAtom)).toBe('DEFAULT'); act(() => set(myAtom, 'VALUE')); expect(get(myAtom)).toBe('VALUE'); act(() => set(myAtom, null)); expect(get(myAtom)).toBe(null); act(() => set(myAtom, undefined)); expect(get(myAtom)).toBe(undefined); act(() => set(myAtom, 'VALUE')); expect(get(myAtom)).toBe('VALUE'); }); testRecoil('atom can store a circular reference object', () => { declare class Circular { self: Circular, constructor(): any, } const circular = new Circular(); const myAtom = atom({ key: 'atom', default: undefined }); expect(get(myAtom)).toBe(undefined); act(() => set(myAtom, circular)); expect(get(myAtom)).toBe(circular); }); }); describe('Async Defaults', () => { testRecoil('default promise', async () => { const myAtom = atom({ key: 'atom async default', default: Promise.resolve('RESOLVE') }); const container = renderElements(); 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({ key: 'atom async default overwritten', default: new Promise(resolve => { resolveAtom = resolve; }) }); const [ReadsWritesAtom, setAtom, resetAtom] = componentThatReadsAndWritesAtom(myAtom); const container = renderElements(); 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({ key: 'atom async default', default: Promise.reject('REJECT') }); const container = renderElements(); expect(container.textContent).toEqual('loading'); act(() => jest.runAllTimers()); await flushPromisesAndTimers(); expect(container.textContent).toEqual('error'); }); }); 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; declare function AtomComponent(): any; expect(renders).toEqual(0); const c = renderElements( { renders++; }}> ); // 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('initialization', () => { let inited = false; const myAtom = atom({ key: 'atom effect', default: 'DEFAULT', effects_UNSTABLE: [({ node, trigger, setSelf }) => { inited = true; expect(trigger).toEqual('get'); expect(node).toBe(myAtom); setSelf('INIT'); }] }); expect(get(myAtom)).toEqual('INIT'); expect(inited).toEqual(true); }); testRecoil('async default', () => { let inited = false; const myAtom = atom({ key: 'atom effect async default', default: Promise.resolve('RESOLVE'), effects_UNSTABLE: [({ setSelf, onSet }) => { inited = true; setSelf('INIT'); // This only fires on the reset action, not the default promise resolving onSet(newValue => { expect(newValue).toBeInstanceOf(DefaultValue); }); }] }); expect(inited).toEqual(false); const [ReadsWritesAtom, _, reset] = componentThatReadsAndWritesAtom(myAtom); const c = renderElements(); expect(inited).toEqual(true); expect(c.textContent).toEqual('"INIT"'); act(reset); expect(c.textContent).toEqual('loading'); act(() => jest.runAllTimers()); expect(c.textContent).toEqual('"RESOLVE"'); }); testRecoil('order of effects', () => { const myAtom = atom({ key: 'atom effect order', default: 'DEFAULT', effects_UNSTABLE: [({ 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(get(myAtom)).toEqual('EFFECT 2'); }); testRecoil('reset during init', () => { const myAtom = atom({ key: 'atom effect reset', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf }) => setSelf('INIT'), ({ resetSelf }) => resetSelf()] }); expect(get(myAtom)).toEqual('DEFAULT'); }); testRecoil('init to undefined', () => { const myAtom = atom({ key: 'atom effect init undefined', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf }) => setSelf('INIT'), ({ setSelf }) => setSelf()] }); expect(get(myAtom)).toEqual(undefined); }); testRecoil('init on set', () => { let inited = 0; const myAtom = atom({ key: 'atom effect - init on set', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf, trigger }) => { inited++; setSelf('INIT'); expect(trigger).toEqual('set'); }] }); set(myAtom, 'SET'); expect(get(myAtom)).toEqual('SET'); expect(inited).toEqual(1); reset(myAtom); expect(get(myAtom)).toEqual('DEFAULT'); expect(inited).toEqual(1); }); testRecoil('async set', () => { let setAtom, resetAtom; const myAtom = atom({ key: 'atom effect init set', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf, resetSelf }) => { setAtom = setSelf; resetAtom = resetSelf; setSelf(x => { expect(x).toEqual('DEFAULT'); return 'INIT'; }); }] }); const c = renderElements(); 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(() => 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_UNSTABLE: [({ setSelf, onSet }) => { setSelf(new Promise(resolve => { resolveAtom = resolve; })); onSet(onSetForSameEffect); }, ({ onSet }) => { onSet(value => { expect(value).toEqual('RESOLVE'); validated = true; }); }] }); const c = renderElements(); 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('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_UNSTABLE: [({ setSelf, onSet }) => { onSet(newValue => { expect(set1).toBe(false); if (newValue === valueToSet1) { setSelf(transformedBySetSelf); set1 = true; } }); }] }); const [ReadsWritesAtom, set] = componentThatReadsAndWritesAtom(myAtom); const c = renderElements(); expect(c.textContent).toEqual('"DEFAULT"'); act(() => set(valueToSet1)); expect(c.textContent).toEqual(`"${transformedBySetSelf}"`); }); // 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_UNSTABLE: [({ setSelf, onSet }) => { setSelf(new Promise((_resolve, reject) => { rejectAtom = reject; })); onSet(() => { validated = true; }); }] }); const c = renderElements(); 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_UNSTABLE: [({ setSelf, onSet }) => { setSelf(new Promise(resolve => { resolveAtom = resolve; })); onSet(value => { expect(value).toEqual('OVERWRITE'); validated = true; }); }] }); const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom); const c = renderElements(); 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_UNSTABLE: [({ setSelf, onSet }) => { setSelf(new Promise(resolve => { resolveAtom = resolve; })); onSet(value => { expect(value).toBeInstanceOf(DefaultValue); validated = true; }); }] }); const c = renderElements(); 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', () => { let inited = 0; const myAtom = atom({ key: 'atom effect once per root', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf }) => { inited++; setSelf('INIT'); }] }); const [ReadsWritesAtom, setAtom] = componentThatReadsAndWritesAtom(myAtom); // effect is called once per const c1 = renderElements(); const c2 = renderElements(); 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(2); }); testRecoil('onSet', () => { const sets = { a: 0, b: 0 }; declare var observer: (key: any) => any; const atomA = atom({ key: 'atom effect onSet A', default: 0, effects_UNSTABLE: [({ onSet }) => onSet(observer('a'))] }); const atomB = atom({ key: 'atom effect onSet B', default: 0, effects_UNSTABLE: [({ onSet }) => onSet(observer('b'))] }); expect(sets).toEqual({ a: 0, b: 0 }); const [AtomA, setA] = componentThatReadsAndWritesAtom(atomA); const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB); const c = renderElements(<> ); act(() => setA(1)); expect(sets).toEqual({ a: 1, b: 0 }); act(() => setA(2)); expect(sets).toEqual({ a: 2, b: 0 }); act(() => setB(1)); expect(sets).toEqual({ a: 2, b: 1 }); expect(c.textContent).toEqual('21'); }); testRecoil('onSet ordering', () => { let set1 = false; let set2 = false; let globalObserver = false; const myAtom = atom({ key: 'atom effect onSet ordering', default: 'DEFAULT', effects_UNSTABLE: [({ onSet }) => { onSet(() => { expect(set2).toBe(false); set1 = true; }); onSet(() => { expect(set1).toBe(true); set2 = true; }); }] }); declare function TransactionObserver(arg0: any): any; const [AtomA, setA] = componentThatReadsAndWritesAtom(myAtom); const c = renderElements(<> { expect(set1).toBe(true); expect(set2).toBe(true); globalObserver = true; }} /> ); expect(set1).toEqual(false); expect(set2).toEqual(false); 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 declare function historyEffect(arg0: any): any; const atomA = atom({ key: 'atom effect onSte history A', default: 'DEFAULT_A', effects_UNSTABLE: [historyEffect] }); const atomB = atom({ key: 'atom effect onSte history B', default: 'DEFAULT_B', effects_UNSTABLE: [historyEffect] }); const [AtomA, setA, resetA] = componentThatReadsAndWritesAtom(atomA); const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB); const c = renderElements(<> ); 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 refCounts = [0, 0]; const atomA = atom({ key: 'atom effect cleanup - A', default: 'A', effects_UNSTABLE: [() => { refCounts[0]++; return () => { refCounts[0]--; }; }] }); const atomB = atom({ key: 'atom effect cleanup - B', default: 'B', effects_UNSTABLE: [() => { refCounts[1]++; return () => { refCounts[1]--; }; }] }); let setNumRoots; declare function App(): any; const c = document.createElement('div'); act(() => { ReactDOM.render(, c); }); expect(c.textContent).toBe(''); expect(refCounts).toEqual([0, 0]); act(() => setNumRoots(1)); expect(c.textContent).toBe('"A""B"'); expect(refCounts).toEqual([1, 1]); act(() => setNumRoots(2)); expect(c.textContent).toBe('"A""B""A""B"'); expect(refCounts).toEqual([2, 2]); act(() => setNumRoots(1)); expect(c.textContent).toBe('"A""B"'); expect(refCounts).toEqual([1, 1]); act(() => setNumRoots(0)); expect(c.textContent).toBe(''); expect(refCounts).toEqual([0, 0]); }); // 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('initialze concurrent with state update', () => { const myAtom = atom({ key: 'atom effect - concurrent update', default: 'DEFAULT', effects_UNSTABLE: [({ setSelf }) => setSelf('INIT')] }); const otherAtom = atom({ key: 'atom effect - concurrent update / other atom', default: 'OTHER_DEFAULT' }); const [OtherAtom, setOtherAtom] = componentThatReadsAndWritesAtom(otherAtom); declare function NewPage(): any; let renderPage; declare function App(): any; const c = renderElements(); // is not yet rendered expect(c.textContent).toEqual('"OTHER_DEFAULT"'); // Render which initializes myAtom via effect while also // updating an unrelated atom. act(() => { renderPage(); setOtherAtom('OTHER'); }); expect(c.textContent).toEqual('"OTHER""INIT"'); }); }); testRecoil('object is frozen when stored in atom', () => { const anAtom = atom<{ x: mixed, ... }>({ key: 'anAtom', default: { x: 0 } }); declare function valueAfterSettingInAtom(value: T): T; declare function isFrozen(value: any, getter: any): any; expect(isFrozen({ y: 0 })).toBe(true); // React elements are not deep-frozen (they are already shallow-frozen on creation): const element = { ...
, _owner: { ifThisWereAReactFiberItShouldNotBeFrozen: true } }; expect(isFrozen(element, x => (x: any)._owner)).toBe(false); // flowlint-line unclear-type:off // Immutable stuff is not frozen: expect(isFrozen(immutable.List())).toBe(false); expect(isFrozen(immutable.Map())).toBe(false); expect(isFrozen(immutable.OrderedMap())).toBe(false); expect(isFrozen(immutable.Set())).toBe(false); expect(isFrozen(immutable.OrderedSet())).toBe(false); expect(isFrozen(immutable.Seq())).toBe(false); expect(isFrozen(immutable.Stack())).toBe(false); expect(isFrozen(immutable.Range())).toBe(false); expect(isFrozen(immutable.Repeat())).toBe(false); expect(isFrozen(new (immutable.Record({}))())).toBe(false); });