recoil
Version:
Recoil - A state management library for React
314 lines (290 loc) • 8.62 kB
Flow
/**
* 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.
*
* A type that represents a value that may or may not be loaded. It differs from
* LoadObject in that the `loading` state has a Promise that is meant to resolve
* when the value is available (but as with LoadObject, an individual Loadable
* is a value type and is not mutated when the status of a request changes).
*
* @flow strict
* @format
* @oncall recoil
*/
;
const err = require('recoil-shared/util/Recoil_err');
const isPromise = require('recoil-shared/util/Recoil_isPromise');
const nullthrows = require('recoil-shared/util/Recoil_nullthrows');
class BaseLoadable<T> {
getValue(): T {
throw err('BaseLoadable');
}
toPromise(): Promise<T> {
throw err('BaseLoadable');
}
valueMaybe(): T | void {
throw err('BaseLoadable');
}
valueOrThrow(): T {
// $FlowFixMe[prop-missing]
throw err(`Loadable expected value, but in "${this.state}" state`);
}
promiseMaybe(): Promise<T> | void {
throw err('BaseLoadable');
}
promiseOrThrow(): Promise<T> {
// $FlowFixMe[prop-missing]
throw err(`Loadable expected promise, but in "${this.state}" state`);
}
errorMaybe(): mixed | void {
throw err('BaseLoadable');
}
errorOrThrow(): mixed {
// $FlowFixMe[prop-missing]
throw err(`Loadable expected error, but in "${this.state}" state`);
}
is(other: Loadable<mixed>): boolean {
// $FlowFixMe[prop-missing]
return other.state === this.state && other.contents === this.contents;
}
map<S>(_map: T => Promise<S> | Loadable<S> | S): Loadable<S> {
throw err('BaseLoadable');
}
}
class ValueLoadable<T> extends BaseLoadable<T> {
state: 'hasValue' = 'hasValue';
contents: T;
constructor(value: T) {
super();
this.contents = value;
}
getValue(): T {
return this.contents;
}
toPromise(): Promise<T> {
return Promise.resolve(this.contents);
}
valueMaybe(): T {
return this.contents;
}
valueOrThrow(): T {
return this.contents;
}
promiseMaybe(): void {
return undefined;
}
errorMaybe(): void {
return undefined;
}
map<S>(map: T => Promise<S> | Loadable<S> | S): Loadable<S> {
try {
const next = map(this.contents);
return isPromise(next)
? loadableWithPromise(next)
: isLoadable(next)
? next
: loadableWithValue(next);
} catch (e) {
return isPromise(e)
? // If we "suspended", then try again.
// errors and subsequent retries will be handled in 'loading' case
// $FlowFixMe[prop-missing]
loadableWithPromise(e.next(() => this.map(map)))
: loadableWithError(e);
}
}
}
class ErrorLoadable<T> extends BaseLoadable<T> {
state: 'hasError' = 'hasError';
contents: mixed;
constructor(error: mixed) {
super();
this.contents = error;
}
getValue(): T {
throw this.contents;
}
toPromise(): Promise<T> {
return Promise.reject(this.contents);
}
valueMaybe(): void {
return undefined;
}
promiseMaybe(): void {
return undefined;
}
errorMaybe(): mixed {
return this.contents;
}
errorOrThrow(): mixed {
return this.contents;
}
map<S>(_map: T => Promise<S> | Loadable<S> | S): $ReadOnly<ErrorLoadable<S>> {
// $FlowIssue[incompatible-return]
return this;
}
}
class LoadingLoadable<T> extends BaseLoadable<T> {
state: 'loading' = 'loading';
contents: Promise<T>;
constructor(promise: Promise<T>) {
super();
this.contents = promise;
}
getValue(): T {
throw this.contents;
}
toPromise(): Promise<T> {
return this.contents;
}
valueMaybe(): void {
return undefined;
}
promiseMaybe(): Promise<T> {
return this.contents;
}
promiseOrThrow(): Promise<T> {
return this.contents;
}
errorMaybe(): void {
return undefined;
}
map<S>(
map: T => Promise<S> | Loadable<S> | S,
): $ReadOnly<LoadingLoadable<S>> {
return loadableWithPromise(
this.contents
.then(value => {
const next = map(value);
if (isLoadable(next)) {
const nextLoadable: Loadable<S> = next;
switch (nextLoadable.state) {
case 'hasValue':
return nextLoadable.contents;
case 'hasError':
throw nextLoadable.contents;
case 'loading':
return nextLoadable.contents;
}
}
// $FlowIssue[incompatible-return]
return next;
})
// $FlowFixMe[incompatible-call]
.catch(e => {
if (isPromise(e)) {
// we were "suspended," try again
return e.then(() => this.map(map).contents);
}
throw e;
}),
);
}
}
export type Loadable<+T> =
| $ReadOnly<ValueLoadable<T>>
| $ReadOnly<ErrorLoadable<T>>
| $ReadOnly<LoadingLoadable<T>>;
export type ValueLoadableType<+T> = $ReadOnly<ValueLoadable<T>>;
export type ErrorLoadableType<+T> = $ReadOnly<ErrorLoadable<T>>;
export type LoadingLoadableType<+T> = $ReadOnly<LoadingLoadable<T>>;
function loadableWithValue<+T>(value: T): $ReadOnly<ValueLoadable<T>> {
return Object.freeze(new ValueLoadable(value));
}
function loadableWithError<+T>(error: mixed): $ReadOnly<ErrorLoadable<T>> {
return Object.freeze(new ErrorLoadable(error));
}
function loadableWithPromise<+T>(
promise: Promise<T>,
): $ReadOnly<LoadingLoadable<T>> {
return Object.freeze(new LoadingLoadable(promise));
}
function loadableLoading<+T>(): $ReadOnly<LoadingLoadable<T>> {
return Object.freeze(new LoadingLoadable(new Promise(() => {})));
}
type UnwrapLoadables<Loadables> = $TupleMap<Loadables, <T>(Loadable<T>) => T>;
type LoadableAllOfTuple = <
Tuple: $ReadOnlyArray<Loadable<mixed> | Promise<mixed> | mixed>,
>(
tuple: Tuple,
) => Loadable<$TupleMap<Tuple, <V>(Loadable<V> | Promise<V> | V) => V>>;
type LoadableAllOfObj = <
Obj: $ReadOnly<{[string]: Loadable<mixed> | Promise<mixed> | mixed, ...}>,
>(
obj: Obj,
) => Loadable<$ObjMap<Obj, <V>(Loadable<V> | Promise<V> | V) => V>>;
type LoadableAll = LoadableAllOfTuple & LoadableAllOfObj;
function loadableAllArray<Inputs: $ReadOnlyArray<Loadable<mixed>>>(
inputs: Inputs,
): Loadable<UnwrapLoadables<Inputs>> {
return inputs.every(i => i.state === 'hasValue')
? loadableWithValue(inputs.map(i => i.contents))
: inputs.some(i => i.state === 'hasError')
? loadableWithError(
nullthrows(
inputs.find(i => i.state === 'hasError'),
'Invalid loadable passed to loadableAll',
).contents,
)
: loadableWithPromise(Promise.all(inputs.map(i => i.contents)));
}
function loadableAll<
Inputs:
| $ReadOnlyArray<Loadable<mixed> | Promise<mixed> | mixed>
| $ReadOnly<{[string]: Loadable<mixed> | Promise<mixed> | mixed, ...}>,
>(
inputs: Inputs,
): Loadable<$ReadOnlyArray<mixed> | $ReadOnly<{[string]: mixed, ...}>> {
const unwrapedInputs = Array.isArray(inputs)
? inputs
: Object.getOwnPropertyNames(inputs).map(key => inputs[key]);
const normalizedInputs = unwrapedInputs.map(x =>
isLoadable(x)
? x
: isPromise(x)
? loadableWithPromise(x)
: loadableWithValue(x),
);
const output = loadableAllArray(normalizedInputs);
return Array.isArray(inputs)
? // $FlowIssue[incompatible-return]
output
: // Object.getOwnPropertyNames() has consistent key ordering with ES6
// $FlowIssue[incompatible-call]
output.map(outputs =>
Object.getOwnPropertyNames(inputs).reduce(
// $FlowFixMe[invalid-computed-prop]
(out, key, idx) => ({...out, [key]: outputs[idx]}),
{},
),
);
}
function isLoadable(x: mixed): boolean %checks {
return x instanceof BaseLoadable;
}
const LoadableStaticInterface = {
of: <T>(value: Promise<T> | Loadable<T> | T): Loadable<T> =>
isPromise(value)
? loadableWithPromise(value)
: isLoadable(value)
? value
: loadableWithValue(value),
error: <T>(error: mixed): $ReadOnly<ErrorLoadable<T>> =>
loadableWithError(error),
// $FlowIssue[incompatible-return]
loading: <T>(): LoadingLoadable<T> => loadableLoading<T>(),
// $FlowIssue[unclear-type]
all: ((loadableAll: any): LoadableAll),
isLoadable,
};
module.exports = {
loadableWithValue,
loadableWithError,
loadableWithPromise,
loadableLoading,
loadableAll,
isLoadable,
RecoilLoadable: LoadableStaticInterface,
};