/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * Returns an atom, the basic unit of state in Recoil. An atom is a reference to * value that can be read, written, and subscribed to. It has a `key` that is * stable across time so that it can be persisted. * * There are two required options for creating an atom: * * key. This is a string that uniquely identifies the atom. It should be * stable across time so that persisted states remain valid. * * default * If `default` is provided, the atom is initialized to that value. * Or, it may be set to another RecoilValue to use as a fallback. * In that case, the value of the atom will be equal to that of the * fallback, and will remain so until the first time the atom is written * to, at which point it will stop tracking the fallback RecoilValue. * * The `persistence` option specifies that the atom should be saved to storage. * It is an object with two properties: `type` specifies where the atom should * be persisted; its only allowed value is "url"; `backButton` specifies whether * changes to the atom should result in pushes to the browser history stack; if * true, changing the atom and then hitting the Back button will cause the atom's * previous value to be restored. Applications are responsible for implementing * persistence by using the `useTransactionObservation` hook. * * Scoped atoms (DEPRECATED): * =================================================================================== * * The scopeRules_APPEND_ONLY_READ_THE_DOCS option causes the atom be be "scoped". * A scoped atom's value depends on other parts of the application state. * A separate value of the atom is stored for every value of the state that it * depends on. The dependencies may be changed without breaking existing URLs -- * it uses whatever rule was current when the URL was written. Values written * under the newer rules are overlaid atop the previously-written values just for * those states in which the write occurred, with reads falling back to the older * values in other states, and eventually to the default or fallback. * * The scopedRules_APPEND_ONLY_READ_THE_DOCS parameter is a list of rules; * it should start with a single entry. This list must only be appended to: * existing entries must never be deleted or modified. Each rule is an atom * or selector whose value is some arbitrary key. A different value of the * scoped atom will be stored for each key. To change the scope rule, simply add * a new function to the list. Each rule is either an array of atoms of primitives, * or an atom of an array of primitives. * * Ordinary atoms may be upgraded to scoped atoms. To un-scope an atom, add a new * scope rule consisting of a constant. * * @flow strict-local * @format * @oncall recoil */ 'use strict'; import type {Loadable, LoadingLoadableType} from '../adt/Recoil_Loadable'; import type {RecoilValueInfo} from '../core/Recoil_FunctionalCore'; import type {StoreID} from '../core/Recoil_Keys'; import type { PersistenceInfo, ReadWriteNodeOptions, Trigger, } from '../core/Recoil_Node'; import type {RecoilState, RecoilValue} from '../core/Recoil_RecoilValue'; import type {RetainedBy} from '../core/Recoil_RetainedBy'; import type {AtomWrites, NodeKey, Store, TreeState} from '../core/Recoil_State'; // @fb-only: import type {ScopeRules} from 'Recoil_ScopedAtom'; // @fb-only: const {scopedAtom} = require('Recoil_ScopedAtom'); const { isLoadable, loadableWithError, loadableWithPromise, loadableWithValue, } = require('../adt/Recoil_Loadable'); const {WrappedValue} = require('../adt/Recoil_Wrapper'); const {peekNodeInfo} = require('../core/Recoil_FunctionalCore'); const { DEFAULT_VALUE, DefaultValue, getConfigDeletionHandler, registerNode, setConfigDeletionHandler, } = require('../core/Recoil_Node'); const {isRecoilValue} = require('../core/Recoil_RecoilValue'); const { getRecoilValueAsLoadable, markRecoilValueModified, setRecoilValue, setRecoilValueLoadable, } = require('../core/Recoil_RecoilValueInterface'); const {retainedByOptionWithDefault} = require('../core/Recoil_Retention'); const selector = require('./Recoil_selector'); const deepFreezeValue = require('recoil-shared/util/Recoil_deepFreezeValue'); const err = require('recoil-shared/util/Recoil_err'); const expectationViolation = require('recoil-shared/util/Recoil_expectationViolation'); const isPromise = require('recoil-shared/util/Recoil_isPromise'); const nullthrows = require('recoil-shared/util/Recoil_nullthrows'); const recoverableViolation = require('recoil-shared/util/Recoil_recoverableViolation'); export type PersistenceSettings = $ReadOnly<{ ...PersistenceInfo, validator: (mixed, DefaultValue) => Stored | DefaultValue, }>; // TODO Support Loadable type NewValue = | T | DefaultValue | Promise | WrappedValue; type NewValueOrUpdater = | T | DefaultValue | Promise | WrappedValue | ((T | DefaultValue) => T | DefaultValue | WrappedValue); // Effect is called the first time a node is used with a export type AtomEffect = ({ node: RecoilState, storeID: StoreID, parentStoreID_UNSTABLE?: StoreID, trigger: Trigger, // Call synchronously to initialize value or async to change it later setSelf: ( | T | DefaultValue | Promise | WrappedValue | ((T | DefaultValue) => T | DefaultValue | WrappedValue), ) => void, resetSelf: () => void, // Subscribe callbacks to events. // Atom effect observers are called before global transaction observers onSet: ( (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void, ) => void, // Accessors to read other atoms/selectors getPromise: (RecoilValue) => Promise, getLoadable: (RecoilValue) => Loadable, getInfo_UNSTABLE: (RecoilValue) => RecoilValueInfo, }) => void | (() => void); export type AtomOptionsWithoutDefault = $ReadOnly<{ key: NodeKey, effects?: $ReadOnlyArray>, effects_UNSTABLE?: $ReadOnlyArray>, persistence_UNSTABLE?: PersistenceSettings, // @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS?: ScopeRules, dangerouslyAllowMutability?: boolean, retainedBy_UNSTABLE?: RetainedBy, }>; type AtomOptionsWithDefault = $ReadOnly<{ ...AtomOptionsWithoutDefault, default: RecoilValue | Promise | Loadable | WrappedValue | T, }>; export type AtomOptions = | AtomOptionsWithDefault | AtomOptionsWithoutDefault; type BaseAtomOptions = $ReadOnly<{ ...AtomOptions, default: Promise | Loadable | WrappedValue | T, }>; const unwrap = (x: T | S | WrappedValue): T | S => x instanceof WrappedValue ? x.value : x; function baseAtom(options: BaseAtomOptions): RecoilState { const {key, persistence_UNSTABLE: persistence} = options; const retainedBy = retainedByOptionWithDefault(options.retainedBy_UNSTABLE); let liveStoresCount = 0; function unwrapPromise(promise: Promise): Loadable { return loadableWithPromise( promise .then(value => { defaultLoadable = loadableWithValue(value); return value; }) .catch(error => { defaultLoadable = loadableWithError(error); throw error; }), ); } let defaultLoadable: Loadable = isPromise(options.default) ? unwrapPromise(options.default) : isLoadable(options.default) ? options.default.state === 'loading' ? unwrapPromise((options.default: LoadingLoadableType).contents) : options.default : // $FlowFixMe[incompatible-call] loadableWithValue(unwrap(options.default)); maybeFreezeValueOrPromise(defaultLoadable.contents); let cachedAnswerForUnvalidatedValue: void | Loadable = undefined; // Cleanup handlers for this atom // Rely on stable reference equality of the store to use it as a key per const cleanupEffectsByStore: Map void>> = new Map(); function maybeFreezeValueOrPromise(valueOrPromise: mixed) { if (__DEV__) { if (options.dangerouslyAllowMutability !== true) { if (isPromise(valueOrPromise)) { return valueOrPromise.then(value => { deepFreezeValue(value); return value; }); } else { deepFreezeValue(valueOrPromise); return valueOrPromise; } } } return valueOrPromise; } function wrapPendingPromise( store: Store, promise: Promise, ): Promise { const wrappedPromise: Promise = promise .then(value => { const state = store.getState().nextTree ?? store.getState().currentTree; if (state.atomValues.get(key)?.contents === wrappedPromise) { setRecoilValue(store, node, value); } return value; }) .catch(error => { const state = store.getState().nextTree ?? store.getState().currentTree; if (state.atomValues.get(key)?.contents === wrappedPromise) { setRecoilValueLoadable(store, node, loadableWithError(error)); } throw error; }); return wrappedPromise; } function initAtom( store: Store, initState: TreeState, trigger: Trigger, ): () => void { liveStoresCount++; const cleanupAtom = () => { liveStoresCount--; cleanupEffectsByStore.get(store)?.forEach(cleanup => cleanup()); cleanupEffectsByStore.delete(store); }; store.getState().knownAtoms.add(key); // Setup async defaults to notify subscribers when they resolve if (defaultLoadable.state === 'loading') { const notifyDefaultSubscribers = () => { const state = store.getState().nextTree ?? store.getState().currentTree; if (!state.atomValues.has(key)) { markRecoilValueModified(store, node); } }; defaultLoadable.contents.finally(notifyDefaultSubscribers); } /////////////////// // Run Atom Effects /////////////////// const effects = options.effects ?? options.effects_UNSTABLE; if (effects != null) { // This state is scoped by Store, since this is in the initAtom() closure let initValue: NewValue = DEFAULT_VALUE; let isDuringInit = true; let isInitError: boolean = false; let pendingSetSelf: ?{ effect: AtomEffect, value: T | DefaultValue, } = null; function getLoadable(recoilValue: RecoilValue): Loadable { // Normally we can just get the current value of another atom. // But for our own value we need to check if there is a pending // initialized value or get the fallback default value. if (isDuringInit && recoilValue.key === key) { // Cast T to S const retValue: NewValue = (initValue: any); // flowlint-line unclear-type:off return retValue instanceof DefaultValue ? (peekAtom(store, initState): any) // flowlint-line unclear-type:off : isPromise(retValue) ? loadableWithPromise( retValue.then((v: S | DefaultValue): S | Promise => v instanceof DefaultValue ? // Cast T to S (defaultLoadable: any).toPromise() // flowlint-line unclear-type:off : v, ), ) : // $FlowFixMe[incompatible-call] loadableWithValue(retValue); } return getRecoilValueAsLoadable(store, recoilValue); } function getPromise(recoilValue: RecoilValue): Promise { return getLoadable(recoilValue).toPromise(); } function getInfo_UNSTABLE( recoilValue: RecoilValue, ): RecoilValueInfo { const info = peekNodeInfo( store, store.getState().nextTree ?? store.getState().currentTree, recoilValue.key, ); return isDuringInit && recoilValue.key === key && !(initValue instanceof DefaultValue) ? {...info, isSet: true, loadable: getLoadable(recoilValue)} : info; } const setSelf = (effect: AtomEffect) => (valueOrUpdater: NewValueOrUpdater) => { if (isDuringInit) { const currentLoadable = getLoadable(node); const currentValue: T | DefaultValue = currentLoadable.state === 'hasValue' ? currentLoadable.contents : DEFAULT_VALUE; initValue = typeof valueOrUpdater === 'function' ? // cast to any because we can't restrict T from being a function without losing support for opaque types (valueOrUpdater: any)(currentValue) // flowlint-line unclear-type:off : valueOrUpdater; if (isPromise(initValue)) { initValue = initValue.then(value => { // Avoid calling onSet() when setSelf() initializes with a Promise pendingSetSelf = {effect, value}; return value; }); } } else { if (isPromise(valueOrUpdater)) { throw err('Setting atoms to async values is not implemented.'); } if (typeof valueOrUpdater !== 'function') { pendingSetSelf = { effect, value: unwrap(valueOrUpdater), }; } setRecoilValue( store, node, typeof valueOrUpdater === 'function' ? (currentValue: $FlowFixMe) => { const newValue = unwrap( // cast to any because we can't restrict T from being a function without losing support for opaque types (valueOrUpdater: any)(currentValue), // flowlint-line unclear-type:off ); // $FlowFixMe[incompatible-type] pendingSetSelf = {effect, value: newValue}; return newValue; } : unwrap(valueOrUpdater), ); } }; const resetSelf = (effect: AtomEffect) => () => setSelf(effect)(DEFAULT_VALUE); const onSet = (effect: AtomEffect) => (handler: (T, T | DefaultValue, boolean) => void) => { const {release} = store.subscribeToTransactions(currentStore => { // eslint-disable-next-line prefer-const let {currentTree, previousTree} = currentStore.getState(); if (!previousTree) { recoverableViolation( 'Transaction subscribers notified without a next tree being present -- this is a bug in Recoil', 'recoil', ); previousTree = currentTree; // attempt to trundle on } const newLoadable = currentTree.atomValues.get(key) ?? defaultLoadable; if (newLoadable.state === 'hasValue') { const newValue: T = newLoadable.contents; const oldLoadable = previousTree.atomValues.get(key) ?? defaultLoadable; const oldValue: T | DefaultValue = oldLoadable.state === 'hasValue' ? oldLoadable.contents : DEFAULT_VALUE; // TODO This isn't actually valid, use as a placeholder for now. // Ignore atom value changes that were set via setSelf() in the same effect. // We will still properly call the handler if there was a subsequent // set from something other than an atom effect which was batched // with the `setSelf()` call. However, we may incorrectly ignore // the handler if the subsequent batched call happens to set the // atom to the exact same value as the `setSelf()`. But, in that // case, it was kind of a noop, so the semantics are debatable.. if ( pendingSetSelf?.effect !== effect || pendingSetSelf?.value !== newValue ) { handler(newValue, oldValue, !currentTree.atomValues.has(key)); } else if (pendingSetSelf?.effect === effect) { pendingSetSelf = null; } } }, key); cleanupEffectsByStore.set(store, [ ...(cleanupEffectsByStore.get(store) ?? []), release, ]); }; for (const effect of effects) { try { const cleanup = effect({ node, storeID: store.storeID, parentStoreID_UNSTABLE: store.parentStoreID, trigger, setSelf: setSelf(effect), resetSelf: resetSelf(effect), onSet: onSet(effect), getPromise, getLoadable, getInfo_UNSTABLE, }); if (cleanup != null) { cleanupEffectsByStore.set(store, [ ...(cleanupEffectsByStore.get(store) ?? []), cleanup, ]); } } catch (error) { initValue = error; isInitError = true; } } isDuringInit = false; // Mutate initial state in place since we know there are no other subscribers // since we are the ones initializing on first use. if (!(initValue instanceof DefaultValue)) { const initLoadable = isInitError ? loadableWithError<$FlowFixMe>(initValue) : isPromise(initValue) ? loadableWithPromise(wrapPendingPromise(store, initValue)) : loadableWithValue(unwrap(initValue)); maybeFreezeValueOrPromise(initLoadable.contents); initState.atomValues.set(key, initLoadable); // If there is a pending transaction, then also mutate the next state tree. // This could happen if the atom was first initialized in an action that // also updated some other atom's state. store.getState().nextTree?.atomValues.set(key, initLoadable); } } return cleanupAtom; } function peekAtom(_store: Store, state: TreeState): Loadable { return ( state.atomValues.get(key) ?? cachedAnswerForUnvalidatedValue ?? defaultLoadable ); } function getAtom(_store: Store, state: TreeState): Loadable { if (state.atomValues.has(key)) { // Atom value is stored in state: return nullthrows(state.atomValues.get(key)); } else if (state.nonvalidatedAtoms.has(key)) { // Atom value is stored but needs validation before use. // We might have already validated it and have a cached validated value: if (cachedAnswerForUnvalidatedValue != null) { return cachedAnswerForUnvalidatedValue; } if (persistence == null) { expectationViolation( `Tried to restore a persisted value for atom ${key} but it has no persistence settings.`, ); return defaultLoadable; } const nonvalidatedValue = state.nonvalidatedAtoms.get(key); const validatorResult: T | DefaultValue = persistence.validator( nonvalidatedValue, DEFAULT_VALUE, ); const validatedValueLoadable = validatorResult instanceof DefaultValue ? defaultLoadable : loadableWithValue(validatorResult); cachedAnswerForUnvalidatedValue = validatedValueLoadable; return cachedAnswerForUnvalidatedValue; } else { return defaultLoadable; } } function invalidateAtom() { cachedAnswerForUnvalidatedValue = undefined; } function setAtom( _store: Store, state: TreeState, newValue: T | DefaultValue, ): AtomWrites { // Bail out if we're being set to the existing value, or if we're being // reset but have no stored value (validated or unvalidated) to reset from: if (state.atomValues.has(key)) { const existing = nullthrows(state.atomValues.get(key)); if (existing.state === 'hasValue' && newValue === existing.contents) { return new Map(); } } else if ( !state.nonvalidatedAtoms.has(key) && newValue instanceof DefaultValue ) { return new Map(); } maybeFreezeValueOrPromise(newValue); cachedAnswerForUnvalidatedValue = undefined; // can be released now if it was previously in use return new Map>().set( key, loadableWithValue(newValue), ); } function shouldDeleteConfigOnReleaseAtom() { return getConfigDeletionHandler(key) !== undefined && liveStoresCount <= 0; } const node = registerNode( ({ key, nodeType: 'atom', peek: peekAtom, get: getAtom, set: setAtom, init: initAtom, invalidate: invalidateAtom, shouldDeleteConfigOnRelease: shouldDeleteConfigOnReleaseAtom, dangerouslyAllowMutability: options.dangerouslyAllowMutability, persistence_UNSTABLE: options.persistence_UNSTABLE ? { type: options.persistence_UNSTABLE.type, backButton: options.persistence_UNSTABLE.backButton, } : undefined, shouldRestoreFromSnapshots: true, retainedBy, }: ReadWriteNodeOptions), ); return node; } // prettier-ignore function atom(options: AtomOptions): RecoilState { if (__DEV__) { if (typeof options.key !== 'string') { throw err( 'A key option with a unique string value must be provided when creating an atom.', ); } } const { // @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS, ...restOptions } = options; const optionsDefault: RecoilValue | Promise | Loadable | WrappedValue | T = 'default' in options ? // $FlowIssue[incompatible-type] No way to refine in Flow that property is not defined options.default : new Promise(() => {}); if (isRecoilValue(optionsDefault) // Continue to use atomWithFallback for promise defaults for scoped atoms // for now, since scoped atoms don't support async defaults // @fb-only: || (isPromise(optionsDefault) && scopeRules_APPEND_ONLY_READ_THE_DOCS) // @fb-only: || (isLoadable(optionsDefault) && scopeRules_APPEND_ONLY_READ_THE_DOCS) ) { return atomWithFallback({ ...restOptions, default: optionsDefault, // @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS, }); // @fb-only: } else if (scopeRules_APPEND_ONLY_READ_THE_DOCS // @fb-only: && !isPromise(optionsDefault) // @fb-only: && !isLoadable(optionsDefault) // @fb-only: ) { // @fb-only: return scopedAtom({ // @fb-only: ...restOptions, // @fb-only: default: unwrap(optionsDefault), // @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS, // @fb-only: }); } else { return baseAtom({...restOptions, default: optionsDefault}); } } type AtomWithFallbackOptions = $ReadOnly<{ ...AtomOptions, default: RecoilValue | Promise | Loadable, }>; function atomWithFallback( options: AtomWithFallbackOptions, ): RecoilState { const base = atom({ ...options, default: DEFAULT_VALUE, persistence_UNSTABLE: options.persistence_UNSTABLE === undefined ? undefined : { ...options.persistence_UNSTABLE, validator: (storedValue: mixed) => storedValue instanceof DefaultValue ? storedValue : nullthrows(options.persistence_UNSTABLE).validator( storedValue, DEFAULT_VALUE, ), }, // TODO Hack for now. effects: (options.effects: any), // flowlint-line unclear-type: off effects_UNSTABLE: (options.effects_UNSTABLE: any), // flowlint-line unclear-type: off }); // $FlowFixMe[incompatible-call] const sel = selector({ key: `${options.key}__withFallback`, get: ({get}) => { const baseValue = get(base); return baseValue instanceof DefaultValue ? options.default : baseValue; }, // $FlowFixMe[incompatible-call] set: ({set}, newValue) => set(base, newValue), // This selector does not need to cache as it is a wrapper selector // and the selector within the wrapper selector will have a cache // option by default cachePolicy_UNSTABLE: { eviction: 'most-recent', }, dangerouslyAllowMutability: options.dangerouslyAllowMutability, }); setConfigDeletionHandler(sel.key, getConfigDeletionHandler(options.key)); return sel; } // $FlowFixMe[missing-local-annot] atom.value = value => new WrappedValue(value); module.exports = (atom: { (AtomOptions): RecoilState, value: (S) => WrappedValue, });