UNPKG

jotai

Version:

👻 Next gen state management that will spook you

751 lines (603 loc) • 19.7 kB
import React, { useRef, useState, useCallback, useEffect, useMemo, createElement, useDebugValue } from 'react'; import { createContext, useContextUpdate, useContext, useContextSelector, useBridgeValue, BridgeProvider } from 'use-context-selector'; function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } const hasInitialValue = atom => 'init' in atom; const createState = initialValues => { const state = { a: new WeakMap(), m: new Map(), w: new Map() }; if (initialValues) { for (const [atom, value] of initialValues) { const atomState = { v: value, r: 0, d: new Map() }; if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { Object.freeze(atomState); } state.a.set(atom, atomState); } } return state; }; const getAtomState = (state, atom) => state.w.get(atom) || state.a.get(atom); const copyWip = (state, copyingState) => _extends({}, state, { w: new Map([...state.w, ...copyingState.w]) }); const wipAtomState = (state, atom) => { let atomState = getAtomState(state, atom); if (atomState) { atomState = _extends({}, atomState); // copy } else { atomState = { r: 0, d: new Map() }; if (hasInitialValue(atom)) { atomState.v = atom.init; } } const nextState = _extends({}, state, { w: new Map(state.w).set(atom, atomState) }); return [atomState, nextState]; }; const replaceDependencies = (state, atomState, dependencies) => { if (dependencies) { atomState.d = new Map(Array.from(dependencies).map(a => { var _getAtomState$r, _getAtomState; return [a, (_getAtomState$r = (_getAtomState = getAtomState(state, a)) == null ? void 0 : _getAtomState.r) != null ? _getAtomState$r : 0]; })); } }; const setAtomValue = (state, atom, value, dependencies, promise) => { const [atomState, nextState] = wipAtomState(state, atom); if (promise && promise !== atomState.rp) { return state; } delete atomState.re; delete atomState.rp; if (!('v' in atomState) || !Object.is(atomState.v, value)) { atomState.v = value; atomState.r++; } replaceDependencies(nextState, atomState, dependencies); return nextState; }; const setAtomReadError = (state, atom, error, dependencies, promise) => { const [atomState, nextState] = wipAtomState(state, atom); if (promise && promise !== atomState.rp) { return state; } delete atomState.rp; atomState.re = error; replaceDependencies(nextState, atomState, dependencies); return nextState; }; const setAtomReadPromise = (state, atom, promise, dependencies) => { const [atomState, nextState] = wipAtomState(state, atom); atomState.rp = promise; replaceDependencies(nextState, atomState, dependencies); return nextState; }; const setAtomWritePromise = (state, atom, promise) => { const [atomState, nextState] = wipAtomState(state, atom); if (promise) { atomState.wp = promise; } else { delete atomState.wp; } return nextState; }; const readAtomState = (state, updateState, atom, force) => { if (!force) { const atomState = getAtomState(state, atom); if (atomState && Array.from(atomState.d.entries()).every(([a, r]) => { const aState = getAtomState(state, a); return aState && !aState.re && !aState.rp && aState.r === r; })) { return [atomState, state]; } } let asyncState = _extends({}, state, { w: new Map() }); // empty wip let isSync = true; let nextState = state; let error; let promise; let value; const dependencies = new Set(); try { const promiseOrValue = atom.read(a => { dependencies.add(a); if (a !== atom) { let aState; if (isSync) { ; [aState, nextState] = readAtomState(nextState, updateState, a); } else { ; [aState, asyncState] = readAtomState(asyncState, updateState, a); } if (aState.re) { throw aState.re; // read error } if (aState.rp) { throw aState.rp; // read promise } return aState.v; // value } // a === atom const aState = getAtomState(nextState, a); if (aState) { if (aState.rp) { throw aState.rp; // read promise } return aState.v; // value } if (hasInitialValue(a)) { return a.init; } throw new Error('no atom init'); }); if (promiseOrValue instanceof Promise) { promise = promiseOrValue.then(value => { updateState(prev => setAtomValue(copyWip(prev, asyncState), atom, value, dependencies, promise)); }).catch(e => { updateState(prev => setAtomReadError(copyWip(prev, asyncState), atom, e instanceof Error ? e : new Error(e), dependencies, promise)); }); } else { value = promiseOrValue; } } catch (errorOrPromise) { if (errorOrPromise instanceof Promise) { promise = errorOrPromise.then(() => { updateState(prev => { const [, nextNextState] = readAtomState(prev, updateState, atom, true); if (nextNextState.w.size) { return nextNextState; } return prev; }); }); } else if (errorOrPromise instanceof Error) { error = errorOrPromise; } else { error = new Error(errorOrPromise); } } if (error) { nextState = setAtomReadError(nextState, atom, error, dependencies); } else if (promise) { nextState = setAtomReadPromise(nextState, atom, promise, dependencies); } else { nextState = setAtomValue(nextState, atom, value, dependencies); } isSync = false; return [getAtomState(nextState, atom), nextState]; }; const readAtom = (state, updateState, readingAtom) => { const [atomState, nextState] = readAtomState(state, updateState, readingAtom); // merge back wip nextState.w.forEach((atomState, atom) => { state.w.set(atom, atomState); }); return atomState; }; const addAtom = (state, updateState, addingAtom, useId) => { const mounted = state.m.get(addingAtom); if (mounted) { const [dependents] = mounted; dependents.add(useId); } else { mountAtom(state, updateState, addingAtom, useId); } }; // XXX doesn't work with mutally dependent atoms const canUnmountAtom = (atom, dependents) => !dependents.size || dependents.size === 1 && dependents.has(atom); const delAtom = (state, deletingAtom, useId) => { const mounted = state.m.get(deletingAtom); if (mounted) { const [dependents] = mounted; dependents.delete(useId); if (canUnmountAtom(deletingAtom, dependents)) { unmountAtom(state, deletingAtom); } } }; const getDependents = (state, atom) => { const mounted = state.m.get(atom); const dependents = new Set(mounted == null ? void 0 : mounted[0]); // collecting from wip state.w.forEach((aState, a) => { if (aState.d.has(atom)) { dependents.add(a); } }); return dependents; }; const updateDependentsState = (state, updateState, atom, prevAtomState) => { var _getAtomState2; if (!prevAtomState || prevAtomState.r === ((_getAtomState2 = getAtomState(state, atom)) == null ? void 0 : _getAtomState2.r)) { return state; // bail out } const dependents = getDependents(state, atom); let nextState = state; dependents.forEach(dependent => { if (dependent === atom || typeof dependent === 'symbol') { return; } const dependentState = getAtomState(nextState, dependent); const [nextDependentState, nextNextState] = readAtomState(nextState, updateState, dependent, true); const promise = nextDependentState.rp; if (promise) { promise.then(() => { updateState(prev => updateDependentsState(prev, updateState, dependent, dependentState)); }); nextState = nextNextState; } else { nextState = updateDependentsState(nextNextState, updateState, dependent, dependentState); } }); return nextState; }; const writeAtomState = (state, updateState, atom, update, pendingPromises) => { const atomState = getAtomState(state, atom); if (atomState && atomState.wp) { const promise = atomState.wp.then(() => { updateState(prev => writeAtomState(prev, updateState, atom, update)); }); if (pendingPromises) { pendingPromises.push(promise); } return state; } let nextState = state; let isSync = true; try { const promiseOrVoid = atom.write(a => { const aState = getAtomState(nextState, a); if (!aState) { if (hasInitialValue(a)) { return a.init; } if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { console.warn('Unable to read an atom without initial value in write function. Please useAtom in advance.', a); } throw new Error('uninitialized atom'); } if (aState.rp && typeof process === 'object' && process.env.NODE_ENV !== 'production') { // TODO will try to detect this console.warn('Reading pending atom state in write operation. We need to detect this and fallback. Please file an issue with repro.', a); } return aState.v; }, (a, v) => { if (a === atom) { const aState = getAtomState(nextState, a); if (isSync) { nextState = updateDependentsState(setAtomValue(nextState, a, v), updateState, a, aState); } else { updateState(prev => updateDependentsState(setAtomValue(prev, a, v), updateState, a, aState)); } } else { if (isSync) { nextState = writeAtomState(nextState, updateState, a, v); } else { updateState(prev => writeAtomState(prev, updateState, a, v)); } } }, update); if (promiseOrVoid instanceof Promise) { if (pendingPromises) { pendingPromises.push(promiseOrVoid); } nextState = setAtomWritePromise(nextState, atom, promiseOrVoid.then(() => { updateState(prev => setAtomWritePromise(prev, atom)); })); } } catch (e) { if (pendingPromises && pendingPromises.length) { pendingPromises.push(new Promise((_resolve, reject) => { reject(e); })); } else { throw e; } } isSync = false; return nextState; }; const writeAtom = (updateState, writingAtom, update) => { const pendingPromises = []; updateState(prev => { const nextState = writeAtomState(prev, updateState, writingAtom, update, pendingPromises); return nextState; }); if (pendingPromises.length) { return new Promise((resolve, reject) => { const loop = () => { const len = pendingPromises.length; if (len === 0) { resolve(); } else { Promise.all(pendingPromises).then(() => { pendingPromises.splice(0, len); loop(); }).catch(reject); } }; loop(); }); } }; const isActuallyWritableAtom = atom => !!atom.write; const mountAtom = (state, updateState, atom, initialDependent) => { // mount dependencies beforehand const atomState = getAtomState(state, atom); if (atomState) { atomState.d.forEach((_, a) => { if (a !== atom) { // check if not mounted if (!state.m.has(a)) { mountAtom(state, updateState, a, atom); } } }); } else if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { console.warn('[Bug] could not find atom state to mount', atom); } // mount self let onUmount; if (isActuallyWritableAtom(atom) && atom.onMount) { const setAtom = update => writeAtom(updateState, atom, update); onUmount = atom.onMount(setAtom); } state.m.set(atom, [new Set([initialDependent]), onUmount]); }; const unmountAtom = (state, atom) => { var _state$m$get; // unmount self const onUnmount = (_state$m$get = state.m.get(atom)) == null ? void 0 : _state$m$get[1]; if (onUnmount) { onUnmount(); } state.m.delete(atom); // unmount dependencies afterward const atomState = getAtomState(state, atom); if (atomState) { if (atomState.rp && typeof process === 'object' && process.env.NODE_ENV !== 'production') { console.warn('[Bug] deleting atomState with read promise', atom); } atomState.d.forEach((_, a) => { if (a !== atom) { var _state$m$get2; const dependents = (_state$m$get2 = state.m.get(a)) == null ? void 0 : _state$m$get2[0]; if (dependents) { dependents.delete(atom); if (canUnmountAtom(a, dependents)) { unmountAtom(state, a); } } } }); } else if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { console.warn('[Bug] could not find atom state to unmount', atom); } }; const commitState = (state, updateState) => { if (state.w.size) { // apply wip to MountedMap state.w.forEach((atomState, atom) => { var _state$a$get; const prevDependencies = (_state$a$get = state.a.get(atom)) == null ? void 0 : _state$a$get.d; if (prevDependencies === atomState.d) { return; } const dependencies = new Set(atomState.d.keys()); if (prevDependencies) { prevDependencies.forEach((_, a) => { const mounted = state.m.get(a); if (dependencies.has(a)) { // not changed dependencies.delete(a); } else if (mounted) { const [dependents] = mounted; dependents.delete(atom); if (canUnmountAtom(a, dependents)) { unmountAtom(state, a); } } else if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { console.warn('[Bug] a dependency is not mounted', a); } }); } dependencies.forEach(a => { const mounted = state.m.get(a); if (mounted) { const [dependents] = mounted; dependents.add(atom); } else { mountAtom(state, updateState, a, atom); } }); }); // copy wip to AtomStateMap state.w.forEach((atomState, atom) => { if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { Object.freeze(atomState); } state.a.set(atom, atomState); }); // empty wip state.w.clear(); } }; const ContextsMap = new Map(); const getContexts = scope => { if (!ContextsMap.has(scope)) { ContextsMap.set(scope, [createContext(null), createContext(null)]); } return ContextsMap.get(scope); }; const isReactExperimental = !!(typeof process === 'object' && process.env.IS_REACT_EXPERIMENTAL) || !!React.unstable_useMutableSource; const defaultContextUpdate = f => f(); const InnerProvider = ({ r, c, children }) => { const contextUpdate = useContextUpdate(c); if (isReactExperimental && r.current === defaultContextUpdate) { r.current = f => contextUpdate(f); } return children != null ? children : null; }; const Provider = ({ initialValues, scope, children }) => { const contextUpdateRef = useRef(defaultContextUpdate); const [state, setState] = useState(() => createState(initialValues)); const lastStateRef = useRef(state); const updateState = useCallback(updater => { lastStateRef.current = updater(lastStateRef.current); contextUpdateRef.current(() => { setState(lastStateRef.current); }); }, []); useEffect(() => { commitState(state, updateState); lastStateRef.current = state; }); const actions = useMemo(() => ({ add: (atom, id) => { addAtom(lastStateRef.current, updateState, atom, id); }, del: (atom, id) => { delAtom(lastStateRef.current, atom, id); }, read: (state, atom) => readAtom(state, updateState, atom), write: (atom, update) => writeAtom(updateState, atom, update) }), [updateState]); if (typeof process === 'object' && process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks useDebugState(state); } const [ActionsContext, StateContext] = getContexts(scope); return createElement(ActionsContext.Provider, { value: actions }, createElement(StateContext.Provider, { value: state }, createElement(InnerProvider, { r: contextUpdateRef, c: StateContext }, children))); }; const atomToPrintable = atom => atom.debugLabel || atom.toString(); const isAtom = x => typeof x !== 'symbol'; const stateToPrintable = state => Object.fromEntries(Array.from(state.m.entries()).map(([atom, [dependents]]) => { const atomState = state.a.get(atom) || {}; return [atomToPrintable(atom), { value: atomState.re || atomState.rp || atomState.wp || atomState.v, dependents: Array.from(dependents).filter(isAtom).map(atomToPrintable) }]; })); const useDebugState = state => { useDebugValue(state, stateToPrintable); }; let keyCount = 0; // global key count for all atoms function atom(read, write) { const key = `atom${++keyCount}`; const config = { toString: () => key }; if (typeof read === 'function') { config.read = read; } else { config.init = read; config.read = get => get(config); config.write = (get, set, update) => { set(config, typeof update === 'function' ? update(get(config)) : update); }; } if (write) { config.write = write; } return config; } function assertContextValue(x, scope) { if (!x) { throw new Error(`Please use <Provider${scope ? ` scope=${String(scope)}` : ''}>`); } } const isWritable = atom => !!atom.write; function useAtom(atom) { const [ActionsContext, StateContext] = getContexts(atom.scope); const actions = useContext(ActionsContext); assertContextValue(actions, atom.scope); const value = useContextSelector(StateContext, useCallback(state => { assertContextValue(state); const atomState = actions.read(state, atom); if (atomState.re) { throw atomState.re; // read error } if (atomState.rp) { throw atomState.rp; // read promise } if (atomState.wp) { throw atomState.wp; // write promise } if ('v' in atomState) { return atomState.v; } throw new Error('no atom value'); }, [atom, actions])); useEffect(() => { const id = Symbol(); actions.add(atom, id); return () => { actions.del(atom, id); }; }, [actions, atom]); const setAtom = useCallback(update => { if (isWritable(atom)) { return actions.write(atom, update); } else { throw new Error('not writable atom'); } }, [atom, actions]); useDebugValue(value); return [value, setAtom]; } const useBridge = scope => { const [ActionsContext, StateContext] = getContexts(scope); const actions = useBridgeValue(ActionsContext); const state = useBridgeValue(StateContext); return useMemo(() => [actions, state], [actions, state]); }; const Bridge = ({ value, scope, children }) => { const [actions, state] = value; const [ActionsContext, StateContext] = getContexts(scope); return createElement(BridgeProvider, { context: ActionsContext, value: actions }, createElement(BridgeProvider, { context: StateContext, value: state }, children)); }; export { Bridge, Provider, atom, useAtom, useBridge };