UNPKG

@sourcebug/amos

Version:

A decentralized state manager for react

588 lines (577 loc) 19.9 kB
import React, { createContext, useState, useEffect, useContext, useReducer, useDebugValue, useLayoutEffect } from 'react'; /* * @since 2020-11-03 16:25:01 * @author acrazing <joking.young@gmail.com> */ /** * A `Box` is an object to keep the information of a state node, includes * the `key`, `initialState`, and the state transformer. * * `Box` is selectable, the select result is the state of the box in the store. * * A `Box` could subscribe one or more events by calling `box.subscribe()` * method, which will mutate the state of the box when the event is dispatched. * * @stable */ class Box { /** * Create a box. * * @param key the key of the box, it is used for keeping the relation of the box * and its state in a store, it **SHOULD** be unique in your project. * @param initialState the initial state of the box, the state of a box **SHOULD** * be immutable, which means the mutators (include the mutations * and event subscribers) should return a new state if the updates * the state. * @param preload a function to transform the preloaded state to the state of the box * * @stable */ constructor(key, initialState, preload) { this.key = key; this.initialState = initialState; this.preload = preload; this.listeners = {}; } /** * subscribe an `event`, the `fn` will be called when you call * `store.dispatch(event(data))`, the first parameter of `fn` is * the state of the box, and the second one is the data of the * event, and its return value will be set as the new state of * the box. * * @param event the event factory * @param fn the callback */ subscribe(event, fn) { this.listeners[typeof event === 'string' ? event : event.type] = fn; } mutation(mutator, type) { return (...args) => ({ object: 'mutation', type, box: this, args, result: args[0], mutator }); } } /* * @since 2020-11-04 11:12:36 * @author acrazing <joking.young@gmail.com> */ /** * Returns the first argument * @param v */ const identity = (v) => v; /** * Check two objects is shallow equal or not * @param a * @param b */ function shallowEqual(a, b) { if (a === b) { return true; } const ka = Object.keys(a); if (ka.length !== Object.keys(b).length) { return false; } for (let i = 0; i < ka.length; i++) { if (!b.hasOwnProperty(ka[i]) || a[ka[i]] !== b[ka[i]]) { return false; } } return true; } /** * Copy properties from src function to dst function, and returns dst * * @param src * @param dst */ function hoistMethod(src, dst) { const copy = (name) => { if (dst.hasOwnProperty(name)) { return; } Object.defineProperty(dst, name, Object.getOwnPropertyDescriptor(src, name)); }; Object.getOwnPropertyNames(src).forEach(copy); Object.getOwnPropertySymbols?.(src).forEach(copy); return dst; } const kAmosObject = typeof Symbol === 'function' ? Symbol.for('AMOS_OBJECT') : 'Symbol(AMOS_OBJECT)'; /** @internal */ function defineAmosObject(key, obj) { if (!obj.hasOwnProperty(kAmosObject)) { Object.defineProperty(obj, kAmosObject, { value: key }); } return obj; } /** * Check an object is an amos object or not * * @param key * @param o */ function isAmosObject(key, o) { return !!o && o[kAmosObject] === key; } /** @internal */ function strictEqual(a, b) { return a === b; } /** * Check two array is shallow equal or not * @param a * @param b */ function arrayEqual(a, b) { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } /** @internal */ const isArray = Array.isArray; function config(options) { return Object.assign(this, { options }); } /* * @since 2020-11-03 13:23:14 * @author acrazing <joking.young@gmail.com> */ /** * `action` is the recommended way to create an `ActionFactory` to create * actions which depends on some dynamic parameters. For example, fetch the * specified user's profile with `id`. * * @param actor The function to be called with internal parameters and dynamicly * injected parameters by calling the `ActionFactory`. * @param type An optional string to identify the type of the created action. * * @stable */ function action(actor, type) { const factory = Object.assign((...args) => ({ object: 'action', type, args, actor, options: factory.options, }), { type, options: {}, config, }); return factory; } /* * @since 2020-11-05 15:24:04 * @author acrazing <joking.young@gmail.com> */ function signal(type, creator = identity) { return Object.assign((...args) => ({ object: 'signal', type, data: creator(...args) }), { type }); } /* * @since 2020-11-03 13:23:14 * @author acrazing <joking.young@gmail.com> */ /** * Create a `SelectorFactory`. * * @param fn the select function to select state * @param deps the deps function. If you do not specify a deps function, the * `useSelector` (or `connect` in class) will automatically collect * the state of the boxes that the selector depends on. If these * states and the parameters of the selector have not changed, then * the selector will not be recomputed. On the contrary, if you * specify the deps function, then if the return value of this * function does not change, the selector will not be executed. In * most cases, you don’t need to specify the deps function, but if * the state of a box that a selector depends on has attributes that * are easy to update and are not dependent on this selector, and * the execution of this selector takes a long time, then you can * specify the deps function. In addition, if you set deps function * as `false`, the selector will always be recomputed, ignores the * args and dependents states. * @param compare the compare function, determines the selected result is * updated or not, if it returns true, the component will * rerender. The default compare function is strict equal (`===`). * @param type the optional type for display in react devtools * * @stable */ function selector(fn, deps, compare = strictEqual, type) { const factory = Object.assign((...args) => { const a0 = args[0]; if (isAmosObject('store.select', a0)) { return fn(...args); } const selector = (select) => fn(select, ...args); selector.factory = factory; selector.args = args; return selector; }, { deps, compare, type }); return factory; } /* * @since 2020-11-03 13:31:31 * @author acrazing <joking.young@gmail.com> */ let keydownFlag = false; if (document) { document.addEventListener('keydown', () => (keydownFlag = true)); document.addEventListener('keyup', () => (keydownFlag = false)); } /** * create a store * @param preloadedState * @param enhancers * * @stable */ function createStore(preloadedState, ...enhancers) { const state = {}; const boxes = []; const listeners = new Set(); const dispatchingListeners = new Set(); const ensure = (box) => { if (state.hasOwnProperty(box.key)) { return; } let boxState = box.initialState; if (preloadedState?.hasOwnProperty(box.key)) { boxState = box.preload(preloadedState[box.key], boxState); } state[box.key] = boxState; boxes.push(box); }; let dispatchDepth = 0; let dispatchingSnapshot = {}; let store; function flushDispatchQueue() { if (--dispatchDepth === 0) { if (Object.keys(dispatchingSnapshot).length > 0) { const resultSnapshot = { ...dispatchingSnapshot }; store.batchedUpdates(() => { [...listeners].forEach((fn) => fn(resultSnapshot)); const baseDispatchingListeners = [...dispatchingListeners]; baseDispatchingListeners.forEach((fn) => { dispatchingListeners.delete(fn); return fn(resultSnapshot); }); }); } } } const record = (key, newState) => { if (newState !== state[key] || dispatchingSnapshot.hasOwnProperty(key)) { dispatchingSnapshot[key] = newState; state[key] = newState; } }; const exec = (dispatchable) => { switch (dispatchable.object) { case 'action': return dispatchable.actor(store.dispatch, store.select, ...dispatchable.args); case 'mutation': ensure(dispatchable.box); record(dispatchable.box.key, dispatchable.mutator(state[dispatchable.box.key], ...dispatchable.args)); return dispatchable.result; case 'signal': for (const box of boxes) { const fn = box.listeners[dispatchable.type]; fn && record(box.key, fn(state[box.key], dispatchable.data)); } return dispatchable.data; } }; let selectingSnapshot; store = { snapshot: () => state, isAutoBatch: false, subscribe: function subscribe(fn) { if (dispatchDepth > 0) { dispatchingListeners.add(fn); } listeners.add(fn); return () => { listeners.delete(fn); }; }, dispatch: defineAmosObject('store.dispatch', function dispatch(tasks) { if (++dispatchDepth === 1) { dispatchingSnapshot = {}; } let res; try { if (isArray(tasks)) { res = tasks.map(exec); } else { res = exec(tasks); } } catch { } if (store.isAutoBatch && !keydownFlag) { Promise.resolve().then(flushDispatchQueue); } else { flushDispatchQueue(); } return res; }), select: defineAmosObject('store.select', function select(selectable, snapshot) { if (typeof selectable === 'function') { if (snapshot) { if (selectingSnapshot) { throw new Error(`[Amos] recursive snapshot collection is not supported.`); } selectingSnapshot = snapshot; try { return selectable(store.select); } finally { selectingSnapshot = void 0; } } else { return selectable(store.select); } } else { ensure(selectable); if (selectingSnapshot) { selectingSnapshot[selectable.key] = state[selectable.key]; } return state[selectable.key]; } }), batchedUpdates: (cb) => cb(), }; store = enhancers.reduce((previousValue, currentValue) => currentValue(previousValue), store); return store; } /* * @since 2020-11-03 13:42:04 * @author acrazing <joking.young@gmail.com> */ /** @internal */ const __Context = createContext(null); /** * A component to inject amos context * * @stable */ const Provider = ({ store, children }) => { const [state, setState] = useState({ store }); useEffect(() => { state.store !== store && setState({ store }); }, [store]); return React.createElement(__Context.Provider, { value: state }, children); }; /** * A component to subscribe the amos context * * @stable */ const Consumer = ({ children }) => { return (React.createElement(__Context.Consumer, null, (value) => { if (!value) { throw new Error('[Amos] <Consumer /> should use inside <Provider />.'); } return children(value.store); })); }; /* * @since 2020-11-04 12:43:17 * @author acrazing <joking.young@gmail.com> */ const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? useLayoutEffect : useEffect; /** * use context's store * * @stable */ function useStore() { const state = useContext(__Context); if (!state) { throw new Error('[Amos] you are using hooks without <Provider />.'); } return state.store; } function useDispatch() { const store = useStore(); return store.dispatch; } const defaultSelectorRef = { selectors: [], deps: [], snapshots: [], results: [] }; function hasSame(master, slave) { for (const k in master) { if (master.hasOwnProperty(k) && slave.hasOwnProperty(k)) { return true; } } return false; } function shouldSelectorRecompute(selector, store, deps, index) { if (!selector.factory?.deps || !deps[index]) { return true; } const newDeps = selector.factory.deps(store.select, ...(selector.args || [])); const isEqual = arrayEqual(deps[index] || [], newDeps); deps[index] = newDeps; return !isEqual; } function compare(selector, a, b) { return selector.factory ? selector.factory.compare(a, b) : strictEqual(a, b); } function selectorChanged(old, newly, snapshot, store, deps) { if (!old || typeof old !== 'function' || !snapshot || !old.args || !newly.args) { return true; } if (!(old === newly || (newly.factory && newly.factory === old.factory))) { return true; } if (newly.factory?.deps === void 0) { return !arrayEqual(old.args, newly.args); } const newDeps = newly.factory.deps(store.select, ...newly.args); const isEqual = arrayEqual(deps || [], newDeps); return isEqual ? false : newDeps; } function selectorReducer(state) { return { ...state, updateCount: state.updateCount + 1 }; } function useSelector(...selectors) { const store = useStore(); const [state, update] = useReducer(selectorReducer, { selectorRef: defaultSelectorRef, storeRef: undefined, lastState: [], updateCount: 0, }); if (state.storeRef?.store !== store) { state.selectorRef = defaultSelectorRef; } if (state.storeRef?.error) { const error = state.storeRef.error; state.storeRef.error = void 0; throw error; } const resolveState = () => { if (state.storeRef?.updated) { state.storeRef.updated = false; return state.selectorRef.results; } else { if (state.selectorRef === defaultSelectorRef) { state.selectorRef = { selectors: [], deps: [], snapshots: [], results: [] }; } // updates from outside const { selectors: oldSelectors, deps, snapshots, results } = state.selectorRef; for (let i = 0; i < selectors.length; i++) { const old = oldSelectors[i]; const newly = selectors[i]; if (typeof newly === 'object') { results[i] = store.select(newly); oldSelectors[i] = newly; } else { const newDeps = selectorChanged(old, newly, snapshots[i], store, deps[i]); if (newDeps) { snapshots[i] = void 0; const newSnapshot = {}; results[i] = store.select(newly, newSnapshot); deps[i] = newDeps === true ? void 0 : newDeps; snapshots[i] = newSnapshot; oldSelectors[i] = newly; } } } results.length = selectors.length; return results; } }; let selectedState = resolveState(); useIsomorphicLayoutEffect(() => { state.lastState = [...selectedState]; }); useIsomorphicLayoutEffect(() => { state.storeRef = { store, updated: false, error: void 0, disposer: store.subscribe((updatedState) => { var _a, _b; let i = 0; const { selectors, snapshots, results, deps } = state.selectorRef; const max = selectors.length; try { for (; i < max; i++) { const selector = selectors[i]; const snapshot = snapshots[i]; if (typeof selector === 'function') { if (!snapshot || hasSame(snapshot, updatedState)) { if (shouldSelectorRecompute(selector, store, deps, i)) { const newSnapshot = {}; const newResult = store.select(selector, newSnapshot); (_a = state.storeRef).updated || (_a.updated = !compare(selector, results[i], newResult)); snapshots[i] = newSnapshot; results[i] = newResult; } } } else if (updatedState.hasOwnProperty(selector.key)) { const newState = store.select(selector); (_b = state.storeRef).updated || (_b.updated = newState !== results[i]); results[i] = newState; } } state.storeRef.updated && update(); } catch (e) { snapshots.length = results.length = i; state.storeRef.error = typeof e === 'object' && e && 'message' in e ? Object.assign(e, { message: '[Amos] selector throws error: ' + e.message }) : new Error('[Amos] selector throws falsy error: ' + e); update(); } }), }; // if something change between render and the effect. eg. dispatch when render if (!arrayEqual(state.lastState, resolveState())) { update(); } return () => state.storeRef?.disposer(); }, [store]); useDebugValue(selectedState, (value) => { return value.reduce((map, value, index) => { const s = selectors[index]; let type = typeof s === 'function' ? s.type ?? s.factory?.type ?? s.name : s.key; if (!type) { type = `anonymous`; } if (map.hasOwnProperty(type)) { type = type + '_' + index; } map[type] = value; return map; }, {}); }); return selectedState; } /* * @since 2020-11-03 13:22:41 * @author acrazing <joking.young@gmail.com> */ const VERSION = '0.2.28'; export { Box, Consumer, Provider, VERSION, action, arrayEqual, createStore, hoistMethod, identity, isAmosObject, kAmosObject, selector, shallowEqual, signal, useDispatch, useSelector, useStore }; //# sourceMappingURL=amos-alter.es.js.map