packages/recoil-sync/RecoilSync.js (505 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 {AtomEffect, Loadable, RecoilState, StoreID} from 'Recoil';
import type {Checker} from 'refine';
const {
DefaultValue,
RecoilLoadable,
useRecoilSnapshot,
useRecoilStoreID,
useRecoilTransaction_UNSTABLE,
} = require('Recoil');
const React = require('react');
const {useCallback, useEffect, useRef} = require('react');
const err = require('recoil-shared/util/Recoil_err');
const lazyProxy = require('recoil-shared/util/Recoil_lazyProxy');
type NodeKey = string;
export type ItemKey = string;
export type StoreKey = string | void;
type EffectKey = number;
// $FlowIssue[unclear-type]
export type ItemDiff = Map<ItemKey, DefaultValue | any>;
export type ItemSnapshot = Map<ItemKey, DefaultValue | mixed>;
export type WriteInterface = {
diff: ItemDiff,
allItems: ItemSnapshot,
};
export type WriteItem = <T>(ItemKey, DefaultValue | T) => void;
export type WriteItems = WriteInterface => void;
export type ResetItem = ItemKey => void;
export type ReadItem = ItemKey =>
| DefaultValue
| Promise<DefaultValue | mixed>
| Loadable<DefaultValue | mixed>
| mixed;
export type UpdateItem = <T>(ItemKey, DefaultValue | T) => void;
export type UpdateAllKnownItems = ItemSnapshot => void;
export type ListenInterface = {
updateItem: UpdateItem,
updateAllKnownItems: UpdateAllKnownItems,
};
export type ListenToItems = ListenInterface => void | (() => void);
type ActionOnFailure = 'errorState' | 'defaultValue';
const DEFAULT_VALUE = new DefaultValue();
function setIntersectsMap<U, V>(a: Set<U>, b: Map<U, V>): boolean {
if (a.size <= b.size) {
for (const x of a) {
if (b.has(x)) {
return true;
}
}
} else {
for (const x of b.keys()) {
if (a.has(x)) {
return true;
}
}
}
return false;
}
type AtomSyncOptions<T> = {
...SyncEffectOptions<T>,
// Mark some items as required
itemKey: ItemKey,
read: ReadAtom,
write: WriteAtom<T>,
};
type EffectRegistration<T> = {
options: AtomSyncOptions<T>,
subscribedItemKeys: Set<ItemKey>,
};
type AtomRegistration<T> = {
atom: RecoilState<T>,
effects: Map<EffectKey, EffectRegistration<T>>,
// In-flight updates to avoid feedback loops
pendingUpdate?: {value: mixed | DefaultValue},
};
type Storage = {
write?: WriteItems,
read?: ReadItem,
};
class Registries {
atomRegistries: Map<
StoreID,
Map<
StoreKey,
Map<NodeKey, AtomRegistration<any>>, // flowlint-line unclear-type:off
>,
> = new Map();
nextEffectKey: EffectKey = 0;
getAtomRegistry(
recoilStoreID: StoreID,
externalStoreKey: StoreKey,
// flowlint-next-line unclear-type:off
): Map<NodeKey, AtomRegistration<any>> {
if (!this.atomRegistries.has(recoilStoreID)) {
this.atomRegistries.set(recoilStoreID, new Map());
}
const storeRegistries = this.atomRegistries.get(recoilStoreID);
const registry = storeRegistries?.get(externalStoreKey);
if (registry != null) {
return registry;
}
const newRegistry = new Map();
storeRegistries?.set(externalStoreKey, newRegistry);
return newRegistry;
}
setAtomEffect<T>(
recoilStoreID: StoreID,
externalStoreKey: StoreKey,
node: RecoilState<T>,
options: AtomSyncOptions<T>,
): {effectRegistration: EffectRegistration<T>, unregisterEffect: () => void} {
const atomRegistry = this.getAtomRegistry(recoilStoreID, externalStoreKey);
if (!atomRegistry.has(node.key)) {
atomRegistry.set(node.key, {atom: node, effects: new Map()});
}
const effectKey = this.nextEffectKey++;
const effectRegistration = {
options,
subscribedItemKeys: new Set([options.itemKey]),
};
atomRegistry.get(node.key)?.effects.set(effectKey, effectRegistration);
return {
effectRegistration,
unregisterEffect: () =>
void atomRegistry.get(node.key)?.effects.delete(effectKey),
};
}
storageRegistries: Map<StoreID, Map<StoreKey, Storage>> = new Map();
getStorage(recoilStoreID: StoreID, externalStoreKey: StoreKey): ?Storage {
return this.storageRegistries.get(recoilStoreID)?.get(externalStoreKey);
}
setStorage(
recoilStoreID: StoreID,
externalStoreKey: StoreKey,
storage: Storage,
): () => void {
if (!this.storageRegistries.has(recoilStoreID)) {
this.storageRegistries.set(recoilStoreID, new Map());
}
this.storageRegistries.get(recoilStoreID)?.set(externalStoreKey, storage);
return () =>
void this.storageRegistries.get(recoilStoreID)?.delete(externalStoreKey);
}
}
const registries: Registries = new Registries();
function validateLoadable<T>(
input:
| DefaultValue
| Promise<mixed | DefaultValue>
| Loadable<mixed | DefaultValue>
| mixed,
{refine, actionOnFailure_UNSTABLE}: AtomSyncOptions<T>,
): Loadable<T | DefaultValue> {
return RecoilLoadable.of(input).map<T | DefaultValue>(x => {
if (x instanceof DefaultValue) {
return x;
}
const result = refine(x);
if (result.type === 'success') {
return result.value;
}
if (actionOnFailure_UNSTABLE === 'defaultValue') {
return new DefaultValue();
}
throw err(`[${result.path.toString()}]: ${result.message}`);
});
}
function readAtomItems<T>(
effectRegistration: EffectRegistration<T>,
readFromStorage?: ReadItem,
diff?: ItemDiff,
): ?Loadable<T | DefaultValue> {
const {options} = effectRegistration;
const readFromStorageRequired =
readFromStorage ??
(itemKey =>
RecoilLoadable.error(
`Read functionality not provided for ${
options.storeKey != null ? `"${options.storeKey}" ` : ''
}store in useRecoilSync() hook while updating item "${itemKey}".`,
));
effectRegistration.subscribedItemKeys = new Set();
const read: ReadItem = itemKey => {
effectRegistration.subscribedItemKeys.add(itemKey);
const value = diff?.has(itemKey)
? diff?.get(itemKey)
: readFromStorageRequired(itemKey);
if (RecoilLoadable.isLoadable(value)) {
// $FlowIssue[incompatible-type]
const loadable: Loadable<mixed> = value;
if (loadable.state === 'hasError') {
throw loadable.contents;
}
}
return value;
};
let value;
try {
value = options.read({read});
} catch (error) {
return RecoilLoadable.error(error);
}
return value instanceof DefaultValue
? null
: validateLoadable(value, options);
}
function writeAtomItemsToDiff<T>(
diff: ItemDiff,
options: AtomSyncOptions<T>,
readFromStorage?: ReadItem,
loadable: ?Loadable<T>,
): ItemDiff {
if (loadable != null && loadable?.state !== 'hasValue') {
return diff;
}
const readFromStorageRequired =
readFromStorage ??
(_ => {
throw err(
`Read functionality not provided for ${
options.storeKey != null ? `"${options.storeKey}" ` : ''
}store in useRecoilSync() hook while writing item "${
options.itemKey
}".`,
);
});
const read = itemKey =>
diff.has(itemKey) ? diff.get(itemKey) : readFromStorageRequired(itemKey);
const write = <S>(k, l: DefaultValue | S) => void diff.set(k, l);
const reset = k => void diff.set(k, DEFAULT_VALUE);
options.write(
{write, reset, read},
loadable == null ? DEFAULT_VALUE : loadable.contents,
);
return diff;
}
const itemsFromSnapshot = (
recoilStoreID: StoreID,
storeKey: StoreKey,
getInfo,
): ItemSnapshot => {
const items: ItemSnapshot = new Map();
for (const [, {atom, effects}] of registries.getAtomRegistry(
recoilStoreID,
storeKey,
)) {
for (const [, {options}] of effects) {
const atomInfo = getInfo(atom);
writeAtomItemsToDiff(
items,
options,
registries.getStorage(recoilStoreID, storeKey)?.read,
atomInfo.isSet || options.syncDefault === true
? atomInfo.loadable
: null,
);
}
}
return items;
};
function getWriteInterface(
recoilStoreID: StoreID,
storeKey: StoreKey,
diff: ItemDiff,
getInfo,
): WriteInterface {
// Use a Proxy so we only generate `allItems` if it's actually used.
return lazyProxy(
{diff},
{allItems: () => itemsFromSnapshot(recoilStoreID, storeKey, getInfo)},
);
}
///////////////////////
// useRecoilSync()
///////////////////////
export type RecoilSyncOptions = {
storeKey?: StoreKey,
write?: WriteItems,
read?: ReadItem,
listen?: ListenToItems,
};
function useRecoilSync({
storeKey,
write,
read,
listen,
}: RecoilSyncOptions): void {
const recoilStoreID = useRecoilStoreID();
// Subscribe to Recoil state changes
const snapshot = useRecoilSnapshot();
const previousSnapshotRef = useRef(snapshot);
useEffect(() => {
if (write != null && snapshot !== previousSnapshotRef.current) {
previousSnapshotRef.current = snapshot;
const diff: ItemDiff = new Map();
const atomRegistry = registries.getAtomRegistry(recoilStoreID, storeKey);
const modifiedAtoms = snapshot.getNodes_UNSTABLE({isModified: true});
for (const atom of modifiedAtoms) {
const registration = atomRegistry.get(atom.key);
if (registration != null) {
const atomInfo = snapshot.getInfo_UNSTABLE(registration.atom);
// Avoid feedback loops:
// Don't write to storage updates that came from listening to storage
if (
(atomInfo.isSet &&
atomInfo.loadable?.contents !==
registration.pendingUpdate?.value) ||
(!atomInfo.isSet &&
!(registration.pendingUpdate?.value instanceof DefaultValue))
) {
for (const [, {options}] of registration.effects) {
writeAtomItemsToDiff(
diff,
options,
read,
atomInfo.isSet || options.syncDefault === true
? atomInfo.loadable
: null,
);
}
}
delete registration.pendingUpdate;
}
}
if (diff.size) {
write(
getWriteInterface(
recoilStoreID,
storeKey,
diff,
snapshot.getInfo_UNSTABLE,
),
);
}
}
}, [read, recoilStoreID, snapshot, storeKey, write]);
const updateItems = useRecoilTransaction_UNSTABLE(
({set, reset}) =>
(diff: ItemDiff) => {
const atomRegistry = registries.getAtomRegistry(
recoilStoreID,
storeKey,
);
// TODO iterating over all atoms registered with the store could be
// optimized if we maintain a reverse look-up map of subscriptions.
for (const [, atomRegistration] of atomRegistry) {
// Iterate through the effects for this storage in reverse order as
// the last effect takes priority.
for (const [, effectRegistration] of Array.from(
atomRegistration.effects,
).reverse()) {
const {options, subscribedItemKeys} = effectRegistration;
// Only consider updating this atom if it subscribes to any items
// specified in the diff.
if (setIntersectsMap(subscribedItemKeys, diff)) {
const loadable = readAtomItems(effectRegistration, read, diff);
if (loadable != null) {
switch (loadable.state) {
case 'hasValue':
if (loadable.contents instanceof DefaultValue) {
atomRegistration.pendingUpdate = {value: DEFAULT_VALUE};
reset(atomRegistration.atom);
} else {
atomRegistration.pendingUpdate = {
value: loadable.contents,
};
set(atomRegistration.atom, loadable.contents);
}
break;
case 'hasError':
if (options.actionOnFailure_UNSTABLE === 'errorState') {
// TODO Async atom support to allow setting atom to error state
// in the meantime we can just reset it to default value...
atomRegistration.pendingUpdate = {value: DEFAULT_VALUE};
reset(atomRegistration.atom);
}
break;
case 'loading':
// TODO Async atom support
throw err(
'Recoil does not yet support setting atoms to an asynchronous state',
);
}
// If this effect set the atom, don't bother with lower-priority
// effects. But, if the item didn't have a value then reset
// below but ontinue falling back on other effects for the same
// storage. This can happen if multiple effects are used to
// migrate to a new itemKey and we want to read from the
// older key as a fallback.
break;
} else {
atomRegistration.pendingUpdate = {value: DEFAULT_VALUE};
reset(atomRegistration.atom);
}
}
}
}
},
[recoilStoreID, storeKey, read],
);
const updateItem = useCallback(
<T>(itemKey: ItemKey, newValue: DefaultValue | T) => {
updateItems(new Map([[itemKey, newValue]]));
},
[updateItems],
);
const updateAllKnownItems = useCallback(
itemSnapshot => {
// Reset the value of any items that are registered and not included in
// the user-provided snapshot.
const atomRegistry = registries.getAtomRegistry(recoilStoreID, storeKey);
for (const [, registration] of atomRegistry) {
for (const [, {subscribedItemKeys}] of registration.effects) {
for (const itemKey of subscribedItemKeys) {
if (!itemSnapshot.has(itemKey)) {
itemSnapshot.set(itemKey, DEFAULT_VALUE);
}
}
}
}
updateItems(itemSnapshot);
},
[recoilStoreID, storeKey, updateItems],
);
useEffect(
() =>
// TODO try/catch errors and set atom to error state if actionOnFailure is errorState
listen?.({updateItem, updateAllKnownItems}),
[updateItem, updateAllKnownItems, listen],
);
// Register Storage
// Save before effects so that we can initialize atoms for initial render
registries.setStorage(recoilStoreID, storeKey, {write, read});
useEffect(
() => registries.setStorage(recoilStoreID, storeKey, {write, read}),
[recoilStoreID, storeKey, read, write],
);
}
function RecoilSync(props: RecoilSyncOptions): React.Node {
useRecoilSync(props);
return null;
}
///////////////////////
// syncEffect()
///////////////////////
export type ReadAtomInterface = {read: ReadItem};
export type ReadAtom = ReadAtomInterface =>
| DefaultValue
| Promise<DefaultValue | mixed>
| Loadable<DefaultValue | mixed>
| mixed;
export type WriteAtomInterface = {
write: WriteItem,
reset: ResetItem,
read: ReadItem,
};
export type WriteAtom<T> = (WriteAtomInterface, DefaultValue | T) => void;
export type SyncEffectOptions<T> = {
storeKey?: StoreKey,
itemKey?: ItemKey,
refine: Checker<T>,
read?: ReadAtom,
write?: WriteAtom<T>,
// Sync actual default value instead of empty when atom is in default state
syncDefault?: boolean,
// If there is a failure reading or refining the value, should the atom
// silently use the default value or be put in an error state
actionOnFailure_UNSTABLE?: ActionOnFailure,
};
function syncEffect<T>(opt: SyncEffectOptions<T>): AtomEffect<T> {
return ({node, trigger, storeID, setSelf, getLoadable, getInfo_UNSTABLE}) => {
// Get options with defaults
const itemKey = opt.itemKey ?? node.key;
const options: AtomSyncOptions<T> = {
itemKey,
read: ({read}) => read(itemKey),
write: ({write}, loadable) => write(itemKey, loadable),
syncDefault: false,
actionOnFailure_UNSTABLE: 'errorState',
...opt,
};
const {storeKey} = options;
const storage = registries.getStorage(storeID, storeKey);
// Register Atom
const {effectRegistration, unregisterEffect} = registries.setAtomEffect(
storeID,
storeKey,
node,
options,
);
if (trigger === 'get') {
// Initialize Atom value
const readFromStorage = storage?.read;
if (readFromStorage != null) {
try {
const loadable = readAtomItems(effectRegistration, readFromStorage);
if (loadable != null) {
switch (loadable.state) {
case 'hasValue':
if (!(loadable.contents instanceof DefaultValue)) {
setSelf(loadable.contents);
}
break;
case 'hasError':
if (options.actionOnFailure_UNSTABLE === 'errorState') {
throw loadable.contents;
}
break;
case 'loading':
setSelf(loadable.toPromise());
break;
}
}
} catch (error) {
if (options.actionOnFailure_UNSTABLE === 'errorState') {
throw error;
}
}
}
// Persist on Initial Read
const writeToStorage = storage?.write;
if (options.syncDefault === true && writeToStorage != null) {
setImmediate(() => {
const loadable = getLoadable(node);
if (loadable.state === 'hasValue') {
const diff = writeAtomItemsToDiff(
new Map(),
options,
storage?.read,
loadable,
);
writeToStorage(
getWriteInterface(storeID, storeKey, diff, getInfo_UNSTABLE),
);
}
});
}
}
// Cleanup atom effect registration
return unregisterEffect;
};
}
module.exports = {
useRecoilSync,
RecoilSync,
syncEffect,
registries_FOR_TESTING: registries,
};