recoil
Version:
Recoil - A state management library for React
671 lines (607 loc) • 24.5 kB
Flow
/**
* 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.
*
* 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.
*
* @emails oncall+recoil
* @flow strict-local
* @format
*/
;
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<Stored> = $ReadOnly<{
...PersistenceInfo,
validator: (mixed, DefaultValue) => Stored | DefaultValue,
}>;
// TODO Support Loadable<T> and WrappedValue<T>
type NewValue<T> = T | DefaultValue | Promise<T | DefaultValue>;
type NewValueOrUpdater<T> =
| T
| DefaultValue
| Promise<T | DefaultValue>
| ((T | DefaultValue) => T | DefaultValue);
// Effect is called the first time a node is used with a <RecoilRoot>
export type AtomEffect<T> = ({
node: RecoilState<T>,
storeID: StoreID,
trigger: Trigger,
// Call synchronously to initialize value or async to change it later
setSelf: (
| T
| DefaultValue
| Promise<T | DefaultValue>
| ((T | DefaultValue) => T | DefaultValue),
) => 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: <S>(RecoilValue<S>) => Promise<S>,
getLoadable: <S>(RecoilValue<S>) => Loadable<S>,
getInfo_UNSTABLE: <S>(RecoilValue<S>) => RecoilValueInfo<S>,
}) => void | (() => void);
export type AtomOptionsWithoutDefault<T> = $ReadOnly<{
key: NodeKey,
effects?: $ReadOnlyArray<AtomEffect<T>>,
effects_UNSTABLE?: $ReadOnlyArray<AtomEffect<T>>,
persistence_UNSTABLE?: PersistenceSettings<T>,
// @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS?: ScopeRules,
dangerouslyAllowMutability?: boolean,
retainedBy_UNSTABLE?: RetainedBy,
}>;
type AtomOptionsWithDefault<T> = $ReadOnly<{
...AtomOptionsWithoutDefault<T>,
default: RecoilValue<T> | Promise<T> | Loadable<T> | WrappedValue<T> | T,
}>;
export type AtomOptions<T> =
| AtomOptionsWithDefault<T>
| AtomOptionsWithoutDefault<T>;
type BaseAtomOptions<T> = $ReadOnly<{
...AtomOptions<T>,
default: Promise<T> | Loadable<T> | WrappedValue<T> | T,
}>;
function baseAtom<T>(options: BaseAtomOptions<T>): RecoilState<T> {
const {key, persistence_UNSTABLE: persistence} = options;
const retainedBy = retainedByOptionWithDefault(options.retainedBy_UNSTABLE);
let liveStoresCount = 0;
function unwrapPromise(promise: Promise<T>): Loadable<T> {
return loadableWithPromise(
promise
.then(value => {
defaultLoadable = loadableWithValue(value);
return value;
})
.catch(error => {
defaultLoadable = loadableWithError(error);
throw error;
}),
);
}
let defaultLoadable: Loadable<T> = isPromise(options.default)
? unwrapPromise(options.default)
: isLoadable(options.default)
? options.default.state === 'loading'
? unwrapPromise((options.default: LoadingLoadableType<T>).contents)
: options.default
: loadableWithValue(
options.default instanceof WrappedValue
? options.default.value
: options.default,
);
maybeFreezeValueOrPromise(defaultLoadable.contents);
let cachedAnswerForUnvalidatedValue: void | Loadable<T> = undefined;
// Cleanup handlers for this atom
// Rely on stable reference equality of the store to use it as a key per <RecoilRoot>
const cleanupEffectsByStore: Map<Store, Array<() => void>> = new Map();
function maybeFreezeValueOrPromise(valueOrPromise) {
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<T | DefaultValue>,
): Promise<T | DefaultValue> {
const wrappedPromise = 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 duringInit = true;
let initValue: NewValue<T> = DEFAULT_VALUE;
let isInitError: boolean = false;
let pendingSetSelf: ?{
effect: AtomEffect<T>,
value: T | DefaultValue,
} = null;
function getLoadable<S>(recoilValue: RecoilValue<S>): Loadable<S> {
// 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 (duringInit && recoilValue.key === key) {
// Cast T to S
const retValue: NewValue<S> = (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<S> =>
v instanceof DefaultValue
? // Cast T to S
(defaultLoadable: any).toPromise() // flowlint-line unclear-type:off
: v,
),
)
: loadableWithValue(retValue);
}
return getRecoilValueAsLoadable(store, recoilValue);
}
function getPromise<S>(recoilValue: RecoilValue<S>): Promise<S> {
return getLoadable(recoilValue).toPromise();
}
function getInfo_UNSTABLE<S>(
recoilValue: RecoilValue<S>,
): RecoilValueInfo<S> {
const info = peekNodeInfo(
store,
store.getState().nextTree ?? store.getState().currentTree,
recoilValue.key,
);
return duringInit &&
recoilValue.key === key &&
!(initValue instanceof DefaultValue)
? {...info, isSet: true, loadable: getLoadable(recoilValue)}
: info;
}
const setSelf =
(effect: AtomEffect<T>) => (valueOrUpdater: NewValueOrUpdater<T>) => {
if (duringInit) {
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: valueOrUpdater};
}
setRecoilValue(
store,
node,
typeof valueOrUpdater === 'function'
? currentValue => {
const newValue =
// 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
pendingSetSelf = {effect, value: newValue};
return newValue;
}
: valueOrUpdater,
);
}
};
const resetSelf = effect => () => setSelf(effect)(DEFAULT_VALUE);
const onSet =
effect => (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,
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;
}
}
duringInit = 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 frozenInitValue = maybeFreezeValueOrPromise(initValue);
const initLoadable = isInitError
? loadableWithError(initValue)
: isPromise(frozenInitValue)
? loadableWithPromise(wrapPendingPromise(store, frozenInitValue))
: loadableWithValue(frozenInitValue);
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, state: TreeState): Loadable<T> {
return (
state.atomValues.get(key) ??
cachedAnswerForUnvalidatedValue ??
defaultLoadable
);
}
function getAtom(_store: Store, state: TreeState): Loadable<T> {
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<T>),
);
return node;
}
// prettier-ignore
function atom<T>(options: AtomOptions<T>): RecoilState<T> {
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<T> | Promise<T> | Loadable<T> | WrappedValue<T> | T =
'default' in options
? // $FlowIssue[prop-missing] No way to refine in Flow that property is not defined
// $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<T>({
...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<T>({
// @fb-only: ...restOptions,
// @fb-only: default: optionsDefault instanceof WrappedValue
// @fb-only: ? optionsDefault.value
// @fb-only: : optionsDefault,
// @fb-only: scopeRules_APPEND_ONLY_READ_THE_DOCS,
// @fb-only: });
} else {
return baseAtom<T>({...restOptions, default: optionsDefault});
}
}
type AtomWithFallbackOptions<T> = $ReadOnly<{
...AtomOptions<T>,
default: RecoilValue<T> | Promise<T> | Loadable<T>,
}>;
function atomWithFallback<T>(
options: AtomWithFallbackOptions<T>,
): RecoilState<T> {
const base = atom<T | DefaultValue>({
...options,
default: DEFAULT_VALUE,
persistence_UNSTABLE:
options.persistence_UNSTABLE === undefined
? undefined
: {
...options.persistence_UNSTABLE,
validator: storedValue =>
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
});
const sel = selector<T>({
key: `${options.key}__withFallback`,
get: ({get}) => {
const baseValue = get(base);
return baseValue instanceof DefaultValue ? options.default : baseValue;
},
set: ({set}, newValue) => set(base, newValue),
dangerouslyAllowMutability: options.dangerouslyAllowMutability,
});
setConfigDeletionHandler(sel.key, getConfigDeletionHandler(options.key));
return sel;
}
atom.value = value => new WrappedValue(value);
module.exports = (atom: {
<T>(AtomOptions<T>): RecoilState<T>,
value: <S>(S) => WrappedValue<S>,
});