packages/shared/__test_utils__/Recoil_TestingUtils.js (399 lines of code) (raw):

/** * 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 {Store} from '../../recoil/core/Recoil_State'; import type {RecoilState, RecoilValue, RecoilValueReadOnly} from 'Recoil'; // @fb-only: const ReactDOMComet = require('ReactDOMComet'); // @fb-only: const ReactDOM = require('ReactDOMLegacy_DEPRECATED'); const {act} = require('ReactTestUtils'); const { RecoilRoot, atom, selector, useRecoilValue, useResetRecoilState, useSetRecoilState, } = require('Recoil'); // @fb-only: const StrictMode = require('StrictMode'); const {graph} = require('../../recoil/core/Recoil_Graph'); const {getNextStoreID} = require('../../recoil/core/Recoil_Keys'); const { notifyComponents_FOR_TESTING, sendEndOfBatchNotifications_FOR_TESTING, } = require('../../recoil/core/Recoil_RecoilRoot'); const { invalidateDownstreams, } = require('../../recoil/core/Recoil_RecoilValueInterface'); const {makeEmptyStoreState} = require('../../recoil/core/Recoil_State'); const invariant = require('../util/Recoil_invariant'); const nullthrows = require('../util/Recoil_nullthrows'); const stableStringify = require('../util/Recoil_stableStringify'); const { isConcurrentModeEnabled, isStrictModeEnabled, } = require('./Recoil_ReactRenderModes'); const React = require('react'); const {useEffect} = require('react'); const err = require('recoil-shared/util/Recoil_err'); const ReactDOM = require('react-dom'); // @oss-only const StrictMode = React.StrictMode; // @oss-only const QUICK_TEST = false; // @fb-only: const IS_INTERNAL = true; const IS_INTERNAL = false; // @oss-only // TODO Use Snapshots for testing instead of this thunk? function makeStore(): Store { const storeState = makeEmptyStoreState(); const store: Store = { storeID: getNextStoreID(), getState: () => storeState, replaceState: replacer => { const currentStoreState = store.getState(); // FIXME: does not increment state version number currentStoreState.currentTree = replacer(currentStoreState.currentTree); // no batching so nextTree is never active invalidateDownstreams(store, currentStoreState.currentTree); const {reactMode} = require('../../recoil/core/Recoil_ReactMode'); if (reactMode().early) { notifyComponents_FOR_TESTING( store, currentStoreState, currentStoreState.currentTree, ); } sendEndOfBatchNotifications_FOR_TESTING(store); }, getGraph: version => { const graphs = storeState.graphsByVersion; if (graphs.has(version)) { return nullthrows(graphs.get(version)); } const newGraph = graph(); graphs.set(version, newGraph); return newGraph; }, subscribeToTransactions: () => { throw new Error( 'This functionality, should not tested at this level. Use a component to test this functionality: e.g. componentThatReadsAndWritesAtom', ); }, addTransactionMetadata: () => { throw new Error('not implemented'); }, }; return store; } class ErrorBoundary extends React.Component< {children: React.Node | null, fallback?: Error => React.Node}, {hasError: boolean, error?: ?Error}, > { state: {hasError: boolean, error?: ?Error} = {hasError: false}; static getDerivedStateFromError(error: Error): { hasError: boolean, error?: ?Error, } { return {hasError: true, error}; } render(): React.Node { return this.state.hasError ? this.props.fallback != null && this.state.error != null ? this.props.fallback(this.state.error) : 'error' : this.props.children; } } type ReactAbstractElement<Props> = React.Element< React.AbstractComponent<Props>, >; function renderLegacyReactRoot<Props>( container: HTMLElement, contents: ReactAbstractElement<Props>, ) { ReactDOM.render(contents, container); } // @fb-only: const createRoot = ReactDOMComet.createRoot; // $FlowFixMe[prop-missing] unstable_createRoot is not part of react-dom typing // @oss-only const createRoot = ReactDOM.createRoot ?? ReactDOM.unstable_createRoot; // @oss-only function isConcurrentModeAvailable(): boolean { return createRoot != null; } function renderConcurrentReactRoot<Props>( container: HTMLElement, contents: ReactAbstractElement<Props>, ) { if (!isConcurrentModeAvailable()) { throw err( 'Concurrent rendering is not available with the current version of React.', ); } createRoot(container).render(contents); } function renderUnwrappedElements( elements: ?React.Node, container?: ?HTMLDivElement, ): HTMLDivElement { const div = container ?? document.createElement('div'); const renderReactRoot = isConcurrentModeEnabled() ? renderConcurrentReactRoot : renderLegacyReactRoot; act(() => { renderReactRoot( div, isStrictModeEnabled() ? ( <StrictMode>{elements}</StrictMode> ) : ( <>{elements}</> ), ); }); return div; } function renderElements( elements: ?React.Node, container?: ?HTMLDivElement, ): HTMLDivElement { return renderUnwrappedElements( <RecoilRoot> {/* eslint-disable-next-line fb-www/no-null-fallback-for-error-boundary */} <ErrorBoundary> <React.Suspense fallback="loading">{elements}</React.Suspense> </ErrorBoundary> </RecoilRoot>, container, ); } function renderElementsWithSuspenseCount( elements: React.Node, ): [HTMLDivElement, JestMockFn<[], void>] { const suspenseCommit = jest.fn(() => {}); function Fallback() { useEffect(suspenseCommit); return 'loading'; } const container = renderUnwrappedElements( <RecoilRoot> {/* eslint-disable-next-line fb-www/no-null-fallback-for-error-boundary */} <ErrorBoundary> <React.Suspense fallback={<Fallback />}>{elements}</React.Suspense> </ErrorBoundary> </RecoilRoot>, ); return [container, suspenseCommit]; } //////////////////////////////////////// // Useful RecoilValue nodes for testing //////////////////////////////////////// let id = 0; function stringAtom(): RecoilState<string> { return atom({key: `StringAtom-${id++}`, default: 'DEFAULT'}); } const errorThrowingAsyncSelector: <T, S>( string, ?RecoilValue<S>, ) => RecoilValue<T> = <T, S>( msg, dep: ?RecoilValue<S>, ): RecoilValueReadOnly<T> => selector<T>({ key: `AsyncErrorThrowingSelector${id++}`, get: ({get}) => { if (dep != null) { get(dep); } return Promise.reject(new Error(msg)); }, }); const resolvingAsyncSelector: <T>(T) => RecoilValue<T> = <T>( value: T, // $FlowFixMe[incompatible-type] ): RecoilValueReadOnly<T> | RecoilValueReadOnly<mixed> => selector({ key: `ResolvingSelector${id++}`, get: () => Promise.resolve(value), }); const loadingAsyncSelector: () => RecoilValueReadOnly<void> = () => selector({ key: `LoadingSelector${id++}`, get: () => new Promise(() => {}), }); function asyncSelector<T, S>( dep?: RecoilValue<S>, ): [RecoilValue<T>, (T) => void, (Error) => void] { let resolve = () => invariant(false, 'bug in test code'); // make flow happy with initialization let reject = () => invariant(false, 'bug in test code'); const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const sel = selector({ key: `AsyncSelector${id++}`, get: ({get}) => { if (dep != null) { get(dep); } return promise; }, }); return [sel, resolve, reject]; } ////////////////////////////////// // Useful Components for testing ////////////////////////////////// function ReadsAtom<T>({ atom, // eslint-disable-line no-shadow }: { atom: RecoilValue<T>, }): React.Node { return stableStringify(useRecoilValue(atom)); } // Returns a tuple: [ // Component, // setValue(T), // resetValue() // ] function componentThatReadsAndWritesAtom<T>( recoilState: RecoilState<T>, ): [() => React.Node, (T) => void, () => void] { let setValue; let resetValue; const ReadsAndWritesAtom = (): React.Node => { setValue = useSetRecoilState(recoilState); resetValue = useResetRecoilState(recoilState); return stableStringify(useRecoilValue(recoilState)); }; return [ ReadsAndWritesAtom, (value: T) => setValue(value), () => resetValue(), ]; } function flushPromisesAndTimers(): Promise<void> { // Wrap flush with act() to avoid warning that only shows up in OSS environment return act( () => new Promise(resolve => { setTimeout(resolve, 100); jest.runAllTimers(); }), ); } type ReloadImports = () => void | (() => void); type AssertionsFn = ({ gks: Array<string>, strictMode: boolean, concurrentMode: boolean, }) => ?Promise<mixed>; type TestOptions = { gks?: Array<Array<string>>, }; type TestFn = (string, AssertionsFn, TestOptions | void) => void; const testGKs = (reloadImports: ReloadImports, gks: Array<Array<string>>): TestFn => ( testDescription: string, assertionsFn: AssertionsFn, {gks: additionalGKs = []}: TestOptions = {}, ) => { function runTests({ strictMode, concurrentMode, }: { strictMode: boolean, concurrentMode: boolean, }) { test.each([ ...[...gks, ...additionalGKs].map(gksToTest => [ (!gksToTest.length ? testDescription : `${testDescription} [${gksToTest.join(', ')}]`) + (strictMode || concurrentMode ? ` [${[ strictMode ? 'StrictMode' : null, concurrentMode ? 'ConcurrentMode' : null, ] .filter(x => x != null) .join(', ')}]` : ''), gksToTest, ]), ])('%s', async (_title, gksToTest) => { jest.resetModules(); const gkx = require('recoil-shared/util/Recoil_gkx'); gkx.clear(); // @oss-only const { setStrictMode, setConcurrentMode, } = require('./Recoil_ReactRenderModes'); // Setup test environment setStrictMode(strictMode); setConcurrentMode(concurrentMode); // See: https://github.com/reactwg/react-18/discussions/102 const prevReactActEnvironment = global.IS_REACT_ACT_ENVIRONMENT; global.IS_REACT_ACT_ENVIRONMENT = true; gksToTest.forEach(gkx.setPass); const after = reloadImports(); try { await assertionsFn({gks: gksToTest, strictMode, concurrentMode}); } finally { global.IS_REACT_ACT_ENVIRONMENT = prevReactActEnvironment; gksToTest.forEach(gkx.setFail); after?.(); setStrictMode(false); setConcurrentMode(false); } }); } if (QUICK_TEST) { runTests({strictMode: false, concurrentMode: true}); } else { runTests({strictMode: false, concurrentMode: false}); runTests({strictMode: true, concurrentMode: false}); if (isConcurrentModeAvailable()) { runTests({strictMode: false, concurrentMode: true}); // 2020-12-20: The internal <StrictMode> isn't yet enabled to run effects // multiple times. So, rely on GitHub CI actions to test this for now. if (!IS_INTERNAL) { runTests({strictMode: true, concurrentMode: true}); } } } }; const WWW_GKS_TO_TEST = QUICK_TEST ? [ [ 'recoil_hamt_2020', 'recoil_sync_external_store', 'recoil_memory_managament_2020', ], ] : [ // OSS for React <18: ['recoil_hamt_2020', 'recoil_suppress_rerender_in_callback'], // Also enables early rendering // Current internal default: ['recoil_hamt_2020', 'recoil_mutable_source'], // Internal with suppress, early rendering, and useTransition() support: [ 'recoil_hamt_2020', 'recoil_mutable_source', 'recoil_suppress_rerender_in_callback', // Also enables early rendering ], // OSS for React 18, test internally: [ 'recoil_hamt_2020', 'recoil_sync_external_store', 'recoil_suppress_rerender_in_callback', // Only used for fallback if no useSyncExternalStore() ], // Latest with GC: [ 'recoil_hamt_2020', 'recoil_sync_external_store', 'recoil_suppress_rerender_in_callback', 'recoil_memory_managament_2020', 'recoil_release_on_cascading_update_killswitch_2021', ], // Experimental mode for useTransition() support: ['recoil_hamt_2020', 'recoil_transition_support'], ]; /** * GK combinations to exclude in OSS, presumably because these combinations pass * in FB internally but not in OSS. Ideally this array would be empty. */ const OSS_GK_COMBINATION_EXCLUSIONS = [ ['recoil_hamt_2020', 'recoil_mutable_source'], [ 'recoil_hamt_2020', 'recoil_mutable_source', 'recoil_suppress_rerender_in_callback', ], ]; // eslint-disable-next-line no-unused-vars const OSS_GKS_TO_TEST = WWW_GKS_TO_TEST.filter( gkCombination => !OSS_GK_COMBINATION_EXCLUSIONS.some( exclusion => exclusion.every(gk => gkCombination.includes(gk)) && gkCombination.every(gk => exclusion.includes(gk)), ), ); const getRecoilTestFn = (reloadImports: ReloadImports): TestFn => testGKs( reloadImports, // @fb-only: WWW_GKS_TO_TEST, OSS_GKS_TO_TEST, // @oss-only ); module.exports = { makeStore, renderUnwrappedElements, renderElements, renderElementsWithSuspenseCount, ErrorBoundary, ReadsAtom, componentThatReadsAndWritesAtom, stringAtom, errorThrowingAsyncSelector, resolvingAsyncSelector, loadingAsyncSelector, asyncSelector, flushPromisesAndTimers, getRecoilTestFn, IS_INTERNAL, };