/** * 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 */ /* eslint-disable fb-www/react-no-useless-fragment */ 'use strict'; import type { RecoilState, RecoilValue, RecoilValueReadOnly } from '../../core/Recoil_RecoilValue'; import type { PersistenceSettings } from '../../recoil_values/Recoil_atom'; const { getRecoilTestFn } = require('recoil-shared/__test_utils__/Recoil_TestingUtils'); let React, useEffect, useState, Profiler, act, Queue, batchUpdates, atom, selector, selectorFamily, ReadsAtom, renderElements, renderUnwrappedElements, recoilComponentGetRecoilValueCount_FOR_TESTING, useRecoilState, useRecoilStateLoadable, useRecoilValue, useSetRecoilState, reactMode, invariant; const testRecoil = getRecoilTestFn(() => { React = require('react'); ({ useEffect, useState, Profiler } = require('react')); ({ act } = require('ReactTestUtils')); Queue = require('../../adt/Recoil_Queue'); ({ batchUpdates } = require('../../core/Recoil_Batching')); atom = require('../../recoil_values/Recoil_atom'); selector = require('../../recoil_values/Recoil_selector'); selectorFamily = require('../../recoil_values/Recoil_selectorFamily'); ({ ReadsAtom, renderElements, renderUnwrappedElements } = require('recoil-shared/__test_utils__/Recoil_TestingUtils')); ({ reactMode } = require('../../core/Recoil_ReactMode')); ({ recoilComponentGetRecoilValueCount_FOR_TESTING, useRecoilState, useRecoilStateLoadable, useRecoilValue, useSetRecoilState } = require('../Recoil_Hooks')); invariant = require('recoil-shared/util/Recoil_invariant'); }); let nextID = 0; declare function counterAtom(persistence?: PersistenceSettings): any; declare function plusOneSelector(dep: RecoilValue): any; declare function plusOneAsyncSelector(dep: RecoilValue): [RecoilValueReadOnly, (number) => void]; declare function additionSelector(depA: RecoilValue, depB: RecoilValue): any; declare function componentThatReadsAndWritesAtom(recoilState: RecoilState): [React.AbstractComponent<{...}>, (((T) => T) | T) => void]; declare function componentThatWritesAtom(recoilState: RecoilState): [any, (((T) => T) | T) => void]; declare function componentThatReadsTwoAtoms(one: any, two: any): any; declare function componentThatReadsAtomWithCommitCount(recoilState: any): any; declare function componentThatToggles(a: any, b: any): any; declare function baseRenderCount(gks: any): number; testRecoil('Component throws error when passing invalid node', async () => { declare function Component(): any; const container = renderElements(); expect(container.textContent).toEqual('CAUGHT'); }); testRecoil('Components are re-rendered when atoms change', async () => { const anAtom = counterAtom(); const [Component, updateValue] = componentThatReadsAndWritesAtom(anAtom); const container = renderElements(); expect(container.textContent).toEqual('0'); act(() => updateValue(1)); expect(container.textContent).toEqual('1'); }); describe('Render counts', () => { testRecoil('Component subscribed to atom is rendered just once', ({ gks, strictMode }) => { const BASE_CALLS = baseRenderCount(gks); const sm = strictMode ? 2 : 1; const anAtom = counterAtom(); const [Component, updateValue] = componentThatReadsAndWritesAtom(anAtom); renderElements(<> ); expect(Component).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm); act(() => updateValue(1)); expect(Component).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm); }); testRecoil('Write-only components are not subscribed', ({ strictMode }) => { const anAtom = counterAtom(); const [Component, updateValue] = componentThatWritesAtom(anAtom); renderElements(<> ); expect(Component).toHaveBeenCalledTimes(strictMode ? 2 : 1); act(() => updateValue(1)); expect(Component).toHaveBeenCalledTimes(strictMode ? 2 : 1); }); testRecoil('Component that depends on atom in multiple ways is rendered just once', ({ gks, strictMode }) => { const BASE_CALLS = baseRenderCount(gks); const sm = strictMode ? 2 : 1; const anAtom = counterAtom(); const [aSelector, _] = plusOneSelector(anAtom); const [WriteComp, updateValue] = componentThatWritesAtom(anAtom); const ReadComp = componentThatReadsTwoAtoms(anAtom, aSelector); renderElements(<> ); expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm); act(() => updateValue(1)); expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm); }); testRecoil('Component that depends on multiple atoms via selector is rendered just once', ({ gks }) => { const BASE_CALLS = baseRenderCount(gks); const atomA = counterAtom(); const atomB = counterAtom(); const [aSelector, _] = additionSelector(atomA, atomB); const [ComponentA, updateValueA] = componentThatWritesAtom(atomA); const [ComponentB, updateValueB] = componentThatWritesAtom(atomB); const [ReadComp, commit] = componentThatReadsAtomWithCommitCount(aSelector); renderElements(<> ); expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 1); act(() => { batchUpdates(() => { updateValueA(1); updateValueB(1); }); }); expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 2); }); testRecoil('Component that depends on multiple atoms directly is rendered just once', ({ gks, strictMode }) => { const BASE_CALLS = baseRenderCount(gks); const sm = strictMode ? 2 : 1; const atomA = counterAtom(); const atomB = counterAtom(); const [ComponentA, updateValueA] = componentThatWritesAtom(atomA); const [ComponentB, updateValueB] = componentThatWritesAtom(atomB); const ReadComp = componentThatReadsTwoAtoms(atomA, atomB); renderElements(<> ); expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 1) * sm); act(() => { batchUpdates(() => { updateValueA(1); updateValueB(1); }); }); expect(ReadComp).toHaveBeenCalledTimes((BASE_CALLS + 2) * sm); }); testRecoil('Component is rendered just once when atom is changed twice', ({ gks }) => { const BASE_CALLS = baseRenderCount(gks); const atomA = counterAtom(); const [ComponentA, updateValueA] = componentThatWritesAtom(atomA); const [ReadComp, commit] = componentThatReadsAtomWithCommitCount(atomA); renderElements(<> ); expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 1); act(() => { batchUpdates(() => { updateValueA(1); updateValueA(2); }); }); expect(commit).toHaveBeenCalledTimes(BASE_CALLS + 2); }); testRecoil('Component does not re-read atom when rendered due to another atom changing, parent re-render, or other state change', () => { // useSyncExternalStore() will always call getSnapshot() to see if it has // mutated between render and commit. if (reactMode().mode === 'LEGACY' || reactMode().mode === 'SYNC_EXTERNAL_STORE') { return; } const atomA = counterAtom(); const atomB = counterAtom(); let _, setLocal; let _a, setA; let _b, _setB; declare function Component(): any; let __, setParentLocal; declare function Parent(): any; renderElements(); const initialCalls = recoilComponentGetRecoilValueCount_FOR_TESTING.current; expect(initialCalls).toBeGreaterThan(0); // No re-read when setting local state on the component: act(() => { setLocal(1); }); expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(initialCalls); // No re-read when setting local state on its parent causing it to re-render: act(() => { setParentLocal(1); }); expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(initialCalls); // Setting an atom causes a re-read for that atom only, not others: act(() => { setA(1); }); expect(recoilComponentGetRecoilValueCount_FOR_TESTING.current).toBe(initialCalls + 1); }); testRecoil('Components re-render only one time if selectorFamily changed', ({ gks, strictMode }) => { const BASE_CALLS = baseRenderCount(gks); const sm = strictMode ? 2 : 1; const atomA = counterAtom(); const selectAFakeId = selectorFamily({ key: 'selectItem', get: _id => ({ get }) => get(atomA) }); const Component = (jest.fn(function ReadFromSelector({ id }) { return useRecoilValue(selectAFakeId(id)); // $FlowFixMe[unclear-type] }): Function); let increment; declare var App: () => any; const container = renderElements(); let baseCalls = BASE_CALLS; expect(container.textContent).toEqual('0'); expect(Component).toHaveBeenCalledTimes((baseCalls + 1) * sm); act(() => increment()); if (reactMode().mode === 'LEGACY' && !gks.includes('recoil_suppress_rerender_in_callback') || reactMode().mode === 'TRANSITION_SUPPORT') { baseCalls += 1; } expect(container.textContent).toEqual('1'); expect(Component).toHaveBeenCalledTimes((baseCalls + 2) * sm); }); }); describe('Component Subscriptions', () => { testRecoil('Can subscribe to and also change an atom in the same batch', () => { const anAtom = counterAtom(); let setVisible; declare function Switch(arg0: any): any; const [Component, updateValue] = componentThatWritesAtom(anAtom); const container = renderElements(<> ); expect(container.textContent).toEqual(''); act(() => { batchUpdates(() => { setVisible(true); updateValue(1337); }); }); expect(container.textContent).toEqual('1337'); }); testRecoil('Atom values are retained when atom has no subscribers', () => { const anAtom = counterAtom(); let setVisible; declare function Switch(arg0: any): any; const [Component, updateValue] = componentThatWritesAtom(anAtom); const container = renderElements(<> ); act(() => updateValue(1337)); expect(container.textContent).toEqual('1337'); act(() => setVisible(false)); expect(container.textContent).toEqual(''); act(() => setVisible(true)); expect(container.textContent).toEqual('1337'); }); testRecoil('Components unsubscribe from atoms when rendered without using them', ({ gks, strictMode }) => { const BASE_CALLS = baseRenderCount(gks); const sm = strictMode ? 2 : 1; const atomA = counterAtom(); const atomB = counterAtom(); const [WriteA, updateValueA] = componentThatWritesAtom(atomA); const [WriteB, updateValueB] = componentThatWritesAtom(atomB); const Component = (jest.fn(function Read({ state }) { const [value] = useRecoilState(state); return value; }): any); // flowlint-line unclear-type:off let toggleSwitch; declare var Switch: () => any; const container = renderElements(<> ); let baseCalls = BASE_CALLS; expect(container.textContent).toEqual('0'); expect(Component).toHaveBeenCalledTimes((baseCalls + 1) * sm); act(() => updateValueA(1)); expect(container.textContent).toEqual('1'); expect(Component).toHaveBeenCalledTimes((baseCalls + 2) * sm); if (reactMode().mode === 'LEGACY' && !gks.includes('recoil_suppress_rerender_in_callback') || reactMode().mode === 'TRANSITION_SUPPORT') { baseCalls += 1; } act(() => toggleSwitch()); expect(container.textContent).toEqual('0'); expect(Component).toHaveBeenCalledTimes((baseCalls + 3) * sm); // Now update the atom that it used to be subscribed to but should be no longer: act(() => updateValueA(2)); expect(container.textContent).toEqual('0'); // TODO: find out why OSS has additional render if (reactMode().mode === 'LEGACY' && !gks.includes('recoil_suppress_rerender_in_callback')) { baseCalls += 1; // @oss-only } expect(Component).toHaveBeenCalledTimes((baseCalls + 3) * sm); // Important part: same as before // It is subscribed to the atom that it switched to: act(() => updateValueB(3)); expect(container.textContent).toEqual('3'); expect(Component).toHaveBeenCalledTimes((baseCalls + 4) * sm); }); testRecoil('Selectors unsubscribe from upstream when they have no subscribers', () => { const atomA = counterAtom(); const atomB = counterAtom(); const [WriteA, updateValueA] = componentThatWritesAtom(atomA); // Do two layers of selectors to test that the unsubscribing is recursive: const selectorMapFn1 = jest.fn(x => x); const sel1 = selector({ key: 'selUpstream', get: ({ get }) => selectorMapFn1(get(atomA)) }); const selectorMapFn2 = jest.fn(x => x); const sel2 = selector({ key: 'selDownstream', get: ({ get }) => selectorMapFn2(get(sel1)) }); let toggleSwitch; declare var Switch: () => any; const container = renderElements(<> ); expect(container.textContent).toEqual('0'); expect(selectorMapFn1).toHaveBeenCalledTimes(1); expect(selectorMapFn2).toHaveBeenCalledTimes(1); act(() => updateValueA(1)); expect(container.textContent).toEqual('1'); expect(selectorMapFn1).toHaveBeenCalledTimes(2); expect(selectorMapFn2).toHaveBeenCalledTimes(2); act(() => toggleSwitch()); expect(container.textContent).toEqual('0'); expect(selectorMapFn1).toHaveBeenCalledTimes(2); expect(selectorMapFn2).toHaveBeenCalledTimes(2); act(() => updateValueA(2)); expect(container.textContent).toEqual('0'); expect(selectorMapFn1).toHaveBeenCalledTimes(2); expect(selectorMapFn2).toHaveBeenCalledTimes(2); }); testRecoil('Unsubscribes happen in case of unmounting of a suspended component', () => { const anAtom = counterAtom(); const [aSelector, _selFn] = plusOneSelector(anAtom); const [_asyncSel, _adjustTimeout] = plusOneAsyncSelector(aSelector); // FIXME to implement }); testRecoil('Selectors stay up to date if deps are changed while they have no subscribers', () => { const anAtom = counterAtom(); const [aSelector, _] = plusOneSelector(anAtom); let setVisible; declare function Switch(arg0: any): any; const [Component, updateValue] = componentThatWritesAtom(anAtom); const container = renderElements(<> ); act(() => updateValue(1)); expect(container.textContent).toEqual('2'); act(() => setVisible(false)); expect(container.textContent).toEqual(''); act(() => updateValue(2)); expect(container.textContent).toEqual(''); act(() => setVisible(true)); expect(container.textContent).toEqual('3'); }); testRecoil('Selector subscriptions are correct when a selector is unsubscribed the second time', async () => { // This regression test would fail by an exception being thrown because subscription refcounts // would would fall below zero. const anAtom = counterAtom(); const [sel, _] = plusOneSelector(anAtom); const [Toggle, toggle] = componentThatToggles(, null); const container = renderElements(<> ); expect(container.textContent).toEqual('1'); act(() => toggle.current()); expect(container.textContent).toEqual(''); act(() => toggle.current()); expect(container.textContent).toEqual('1'); act(() => toggle.current()); expect(container.textContent).toEqual(''); }); }); testRecoil('Can set an atom during rendering', () => { const anAtom = counterAtom(); declare function SetsDuringRendering(): any; const container = renderElements(<> ); expect(container.textContent).toEqual('1'); }); testRecoil('Does not re-create "setter" function after setting a value', ({ strictMode, concurrentMode }) => { const sm = strictMode && concurrentMode ? 2 : 1; const anAtom = counterAtom(); const anotherAtom = counterAtom(); let useRecoilStateCounter = 0; let useRecoilStateErrorStatesCounter = 0; let useTwoAtomsCounter = 0; declare function Component1(): any; declare function Component2(): any; // It is important to test here that the component will re-render with the // new setValue() function for a new atom, even if the value of the new // atom is the same as the previous value of the previous atom. declare function Component3(): any; renderElements(<> ); expect(useRecoilStateCounter).toBe(1 * sm); expect(useRecoilStateErrorStatesCounter).toBe(1 * sm); // Component3's effect is ran twice because the atom changes and we get a new setter. // StrictMode renders twice, but we only change atoms once. So, only one extra count. expect(useTwoAtomsCounter).toBe(strictMode && concurrentMode ? 3 : 2); }); testRecoil('Can set atom during post-atom-setting effect (NOT during initial render)', async () => { const anAtom = counterAtom(); let done = false; declare function SetsDuringEffect(): any; const container = renderElements(<> ); expect(container.textContent).toEqual('1'); }); testRecoil('Can set atom during post-atom-setting effect regardless of effect order', async ({ concurrentMode }) => { // TODO Test doesn't work in ConcurrentMode. Haven't investigated why, // but it seems fragile with the Queue for enforcing order. if (concurrentMode) { return; } declare function testWithOrder(order: any): any; testWithOrder(['SetsDuringEffect', 'Batcher']); testWithOrder(['Batcher', 'SetsDuringEffect']); }); testRecoil('Sync React and Recoil state changes', ({ gks }) => { if (reactMode().mode === 'MUTABLE_SOURCE' && !gks.includes('recoil_suppress_rerender_in_callback')) { return; } const myAtom = atom({ key: 'sync react recoil', default: 0 }); let setReact, setRecoil; declare function Component(): any; const c = renderElements(); expect(c.textContent).toBe('0 - 0'); // Set both React and Recoil state in the same batch and ensure the component // render always seems consistent picture of both state changes. act(() => { setReact(1); setRecoil(1); }); expect(c.textContent).toBe('1 - 1'); }); testRecoil('Hooks cannot be used outside of RecoilRoot', () => { const myAtom = atom({ key: 'hook outside RecoilRoot', default: 'INVALID' }); declare function Test(): any; // Make sure there is a friendly error message mentioning expect(() => renderUnwrappedElements()).toThrow(''); });