@sourcebug/amos
Version:
A decentralized state manager for react
636 lines (625 loc) • 21.7 kB
JavaScript
import React, { createContext, useState, useEffect, useContext, useReducer, useRef, 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>
*/
/**
* 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) {
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;
}
/**
* Get the selected states according to the selectors, and rerender the
* component when the selected states updated.
*
* A selector is a selectable thing, it could be one of this:
*
* 1. A pure function accepts `store.select` as the only one parameter
* 2. A `Selector` which is created by `SelectorFactory`
* 3. A `Box` instance
*
* If the selector is a function or a `Selector`, the selected state is its
* return value, otherwise, when the selector is a `Box`, the selected state is
* the state of the `Box`.
*
* `useSelector` accepts multiple selectors, and returns an array of the
* selected states of the selectors.
*
* @example
* ```typescript
* const [
* count, // 1
* doubleCount, // 2
* tripleCount, // 3
* ] = useSelector(
* countBox, // A Box
* selectDoubleCount, // A pure function
* selectMultipleCount(3), // A Selector
* );
* ```
*
* The selectors' result is cached, which means:
*
* 1. If a selector's dependencies is not updated, it will not be recomputed.
* 2. If all the results of the selectors are not changed, the component will
* not rerender.
*
* If the selector is a `Selector`, it will be recomputed:
*
* 1. if it has no `deps` function, when its parameters changes, or the state
* of the boxes it depends on changes
* 2. else, when the return value of the deps function changes. The return
* value should always be an array, and the compare method is compare each
* element of it.
*
* and it will be marked as changed:
*
* 1. if it has no `compare` function, when the result is not strict equals to
* the previous result.
* 2. else if the compare function returns `false`.
*
* If the selector is a pure function, the cache strategy is same to a
* `Selector` without parameter and without `deps` and `compare` function. If
* the selector is a `Box`, the cache strategy is same to a `Selector` without
* parameter and with `deps` as `false` and without `compare` function.
*
* @param selectors a selectable array
*/
function useSelector(...selectors) {
const store = useStore();
const [, update] = useReducer((s) => s + 1, 0);
const lastSelector = useRef(defaultSelectorRef);
const lastStore = useRef();
const lastState = useRef([]);
if (lastStore.current?.store !== store) {
lastSelector.current = defaultSelectorRef;
}
if (lastStore.current?.error) {
const error = lastStore.current.error;
lastStore.current.error = void 0;
throw error;
}
const resolveState = () => {
if (lastStore.current?.updated) {
lastStore.current.updated = false;
return lastSelector.current.results;
}
else {
if (lastSelector.current === defaultSelectorRef) {
lastSelector.current = { selectors: [], deps: [], snapshots: [], results: [] };
}
// updates from outside
const { selectors: oldSelectors, deps, snapshots, results } = lastSelector.current;
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(() => {
lastState.current = [...selectedState];
});
useIsomorphicLayoutEffect(() => {
lastStore.current = {
store,
updated: false,
error: void 0,
disposer: store.subscribe((updatedState) => {
var _a, _b;
let i = 0;
const { selectors, snapshots, results, deps } = lastSelector.current;
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 = lastStore.current).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 = lastStore.current).updated || (_b.updated = newState !== results[i]);
results[i] = newState;
}
}
lastStore.current.updated && update();
}
catch (e) {
snapshots.length = results.length = i;
lastStore.current.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(lastState.current, resolveState())) {
update();
}
return () => lastStore.current?.disposer();
}, [store]);
// TODO: print friendly with selector names
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.21';
export { Box, Consumer, Provider, VERSION, action, arrayEqual, createStore, hoistMethod, identity, isAmosObject, kAmosObject, selector, shallowEqual, signal, useDispatch, useSelector, useStore };
//# sourceMappingURL=amos-alter.es.js.map