@phnq/state
Version:
State management for React
396 lines (395 loc) • 18.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createState = void 0;
const log_1 = require("@phnq/log");
const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
const react_1 = __importStar(require("react"));
const log = (0, log_1.createLogger)('@phnq/state');
const lastRenderDurations = new Map();
function createState(name, ...args) {
const [options, defaultState, getActions] = (args.length === 2 ? [{}, ...args] : args);
const { imported: importedStates, mapProvider, deepCompare = [] } = options;
const { stateDerivers, initialState } = processDefaultState(defaultState);
const derivedProperties = stateDerivers.map(({ key }) => key);
const MAX_CONCURRENT_ON_CHANGE_COUNT = 5;
/**
* State Provider
*/
const provider = (Wrapped) => (props) => {
const listenersRef = (0, react_1.useRef)([]);
const onChangeCount = (0, react_1.useRef)(0);
const numSetStateCalls = (0, react_1.useRef)(0);
const isInitializedRef = (0, react_1.useRef)(false);
function getState(extStateName) {
if (extStateName) {
return Object.assign(Object.assign({}, extStateBrokers[extStateName].state), extStateBrokers[extStateName].actions);
}
return Object.assign({}, stateBrokerRef.current.state);
}
const resetState = (reinitialize = true) => {
setState(initialState, { incremental: false });
const { init } = actions;
if (reinitialize && init) {
init();
}
};
// const setState: StateBroker<S, A>['setState'] = (stateChanges, incremental) => {
const setState = (stateChanges, options = {}) => {
const { incremental = true, source } = options;
// function setState<T>(stateChanges: SubState<S, T>, incremental = true) {
if (onChangeCount.current > MAX_CONCURRENT_ON_CHANGE_COUNT) {
throw new Error('Too many setState() calls from onChange(). Make sure to wrap setState() calls in a condition when called from onChange().');
}
numSetStateCalls.current += 1;
const currentState = incremental ? stateBrokerRef.current.state : {};
if (stateChanges !== initialState && Object.keys(stateChanges).some(k => derivedProperties.includes(k))) {
throw new Error(`Derived properties may not be set explicitly: ${derivedProperties.join(', ')}`);
}
// Calculate the derived state from current state plus the incoming changes.
const derivedState = stateDerivers.reduce((d, { key, derive }) => (Object.assign(Object.assign({}, d), { [key]: derive(Object.assign(Object.assign({}, currentState), stateChanges)) })), {});
// The effective state change is the incoming changes plus the some subset of the derived state.
const deltaState = Object.assign(Object.assign({}, stateChanges), derivedState);
// Affect the internal state change immediately.
const prevState = stateBrokerRef.current.state;
stateBrokerRef.current.state = Object.assign(Object.assign({}, currentState), deltaState);
if (Object.keys(deltaState).some(k => deltaState[k] !== prevState[k])) {
stateBrokerRef.current.version += 1;
}
const { onChange } = actions;
if (onChange && isInitializedRef.current) {
try {
onChangeCount.current += 1;
const changedKeys = Object.keys(deltaState).filter(k => deepCompare.includes(k) ? !(0, fast_deep_equal_1.default)(deltaState[k], currentState[k]) : deltaState[k] !== currentState[k]);
onChange(changedKeys, { prevState, source, viaExternal: !!source });
}
finally {
onChangeCount.current -= 1;
}
}
const changes = Object.entries(deltaState)
.filter(([k]) => {
const key = k;
return deltaState[key] !== currentState[key];
})
.reduce((s, [k, v]) => (Object.assign(Object.assign({}, s), { [k]: v })), {});
const changedKeys = Object.keys(changes);
if (changedKeys.length > 0) {
const colorCat = colorize(name);
log(`${colorCat.text} %cSTATE Δ%c - %o`, ...colorCat.args, 'font-weight:bold', 'font-weight:normal', Object.entries(deltaState)
.filter(([k]) => changedKeys.includes(k))
.reduce((obj, [k, v]) => (Object.assign(Object.assign({}, obj), { [k]: v })), {}));
/**
* Sort the listeners before notifying them. The order is:
* 1. by listener order attribute
* 2. by previous render duration, faster ones first
* 3. by listener id descending -- i.e. newer listeners first
*
* This prevents slower listeners from blocking faster ones. Also, for
* yet-to-be-rendered listeners (i.e. default prev render duration of 0),
* newer ones are rendered first.
*/
[...listenersRef.current]
.sort((l1, l2) => {
const dur1 = lastRenderDurations.get(l1.id) || 0;
const dur2 = lastRenderDurations.get(l2.id) || 0;
return l1.order - l2.order || dur1 - dur2 || l2.id - l1.id;
})
.forEach(({ onChangeInternal }) => {
setTimeout(() => {
onChangeInternal({
changedKeys,
stateChanges: changes,
newState: stateBrokerRef.current.state,
version: stateBrokerRef.current.version,
});
}, 0);
});
}
};
/**
* Set up the actions for the current provider. Most of the behaviour is in the actions, especially
* in `setState()`.
*/
const actions = (0, react_1.useMemo)(() => {
const implicitActionNames = ['destroy', 'init', 'onChange', 'onError'];
const calculateDerivedStateIfNeeded = (actionName, numSetStateCallsBefore) => {
if (numSetStateCalls.current === numSetStateCallsBefore && !implicitActionNames.includes(actionName)) {
setState({});
}
};
/**
* Bind the action functions to the enclosing object so other sibling actions may
* be called by using `this`. For example:
*
* someAction() {
* doSomething();
* },
*
* someOtherAction() {
* this.someAction();
* doSomeOtherThing();
* }
*/
const unboundActions = getActions(Object.assign(Object.assign({}, props), { getState, setState, resetState }));
const { onError = (err, k) => log.error(`Error handling action [${String(k)}]`).stack(err) } = unboundActions;
const actionNames = [...Object.keys(unboundActions)];
const boundActions = {};
actionNames.forEach(k => {
const action = unboundActions[k];
boundActions[k] = ((...args) => new Promise(resolve => {
const colorCat = colorize(name);
log(`${colorCat.text} %cACTION%c - %s`, ...colorCat.args, 'font-weight:bold', 'font-weight:normal', k, ...args);
const invokeAction = () => __awaiter(this, void 0, void 0, function* () {
try {
const numSetStateCallsBefore = numSetStateCalls.current;
const result = action.apply(boundActions, args);
if (result instanceof Promise) {
yield result;
}
resolve();
calculateDerivedStateIfNeeded(k, numSetStateCallsBefore);
}
catch (err) {
if (k === 'onError') {
throw err;
}
else {
onError(err, k);
}
}
});
invokeAction();
}));
});
return Object.freeze(boundActions);
}, []);
// Call init() on mount, destroy() on unmount.
(0, react_1.useEffect)(() => {
const { init, destroy } = actions;
if (init) {
init();
}
isInitializedRef.current = true;
return () => {
isInitializedRef.current = false;
if (destroy) {
destroy();
}
};
}, []);
// Set up the StateBroker for the current provider.
const stateBrokerRef = (0, react_1.useRef)({
found: true,
version: 0,
state: initialState,
setState,
actions,
addListener(listener) {
listenersRef.current = [...listenersRef.current, listener].sort((l1, l2) => l1.order - l2.order);
},
removeListener(theId) {
listenersRef.current = listenersRef.current.filter(({ id }) => id !== theId);
},
});
// Obtain references to external StateBroker instances.
const extStateBrokers = {};
if (importedStates) {
let k;
for (k in importedStates) {
extStateBrokers[k] = importedStates[k].useStateBroker();
}
}
// Add/remove current StateBroker on mount/unmount.
(0, react_1.useEffect)(() => {
allStateBrokers = [...allStateBrokers, [name, stateBrokerRef.current]];
log('Mounted provider: ', name);
return () => {
allStateBrokers = allStateBrokers.filter(([, stateBroker]) => stateBroker !== stateBrokerRef.current);
log('Unmounted provider: ', name);
};
}, []);
return (react_1.default.createElement(Context.Provider, { value: stateBrokerRef.current },
react_1.default.createElement(Wrapped, Object.assign({}, props))));
};
const Context = (0, react_1.createContext)({
found: false,
version: 0,
state: initialState,
setState: () => undefined,
actions: {},
addListener: () => undefined,
removeListener: () => undefined,
});
const useSync = onStateChange => {
const idRef = (0, react_1.useRef)(idIter.next().value);
const { found, addListener, removeListener, setState } = (0, react_1.useContext)(Context);
if (!found) {
throw new Error(`No provider found for state '${name}'. The current component must be a descendent of a '${name}' state provider.`);
}
(0, react_1.useEffect)(() => {
addListener({
id: idRef.current,
order: 10,
onChangeInternal({ stateChanges, newState }) {
onStateChange({ changes: stateChanges, state: newState });
},
});
return () => {
removeListener(idRef.current);
};
});
return (s, options) => setState(s, Object.assign(Object.assign({}, options), { source: (options === null || options === void 0 ? void 0 : options.source) || 'external' }));
};
const useStateFn = (alwaysRenderOnChange = false) => {
const idRef = (0, react_1.useRef)(idIter.next().value);
const { found, version, state, actions, addListener, removeListener } = (0, react_1.useContext)(Context);
const [, render] = (0, react_1.useState)(-1);
if (!found) {
throw new Error(`No provider found for state '${name}'. The current component must be a descendent of a '${name}' state provider.`);
}
const referencedKeys = new Set();
(0, react_1.useEffect)(() => {
lastRenderDurations.set(idRef.current, 0);
return () => {
lastRenderDurations.delete(idRef.current);
};
}, []);
(0, react_1.useEffect)(() => {
addListener({
id: idRef.current,
order: 0,
onChangeInternal({ changedKeys, version: newVersion }) {
/**
* A state change will cause a re-render if:
* 1. The `alwaysRenderOnChange` flag is set to true.
* 2. A state key referenced by the enclosing component has changed and the new state version has changed.
*
* Note: The purpose of the `version` check is to prevent an unnecessary re-render. It is possible that
* the enclosing component may have rendered independently of the current state changing, which would
* yield a fresh state before the change notification was able to run.
*/
if (alwaysRenderOnChange || (changedKeys.some(k => referencedKeys.has(k)) && newVersion > version)) {
const start = performance.now();
render(newVersion);
lastRenderDurations.set(idRef.current, performance.now() - start);
}
},
});
return () => {
removeListener(idRef.current);
};
}, [version]);
const stateCopy = Object.assign(Object.assign({}, state), actions);
const stateProxy = new Proxy(stateCopy, {
get(target, prop) {
referencedKeys.add(prop);
return target[prop];
},
});
return stateProxy;
};
const consumer = function (Wrapped) {
return function (props) {
const stateAndActions = useStateFn(true);
return react_1.default.createElement(Wrapped, Object.assign({}, props, stateAndActions));
};
};
function map(mapFn) {
return function (Wrapped) {
return function (props) {
const stateAndActions = useStateFn();
return react_1.default.createElement(Wrapped, Object.assign({}, props, mapFn(Object.assign({}, stateAndActions))));
};
};
}
return {
consumer,
provider: (mapProvider
? (Wrapped) => mapProvider(provider(props => react_1.default.createElement(Wrapped, Object.assign({}, props))))
: provider),
map,
useSync,
useState: useStateFn,
useStateBroker: () => {
const stateBroker = (0, react_1.useContext)(Context);
if (!stateBroker.found) {
throw new Error(`No provider found for state '${name}'. The current component must be a descendent of a '${name}' state provider.`);
}
return stateBroker;
},
};
}
exports.createState = createState;
const idIter = (function* idGen() {
let i = 0;
while (true) {
i += 1;
yield i;
}
})();
const processDefaultState = (defaultState) => ({
stateDerivers: Object.keys(defaultState).reduce((derivers, key) => typeof defaultState[key] === 'function'
? [...derivers, { key, derive: defaultState[key] }]
: derivers, []),
initialState: Object.keys(defaultState).reduce((s, key) => typeof defaultState[key] === 'function'
? Object.assign(Object.assign({}, s), { [key]: defaultState[key](defaultState) }) : Object.assign(Object.assign({}, s), { [key]: defaultState[key] }), {}),
});
let allStateBrokers = [];
if (!window.getAllStates) {
window.getAllStates = () => allStateBrokers.map(([name, stateBroker]) => [name, stateBroker.state]);
}
if (!window.logAllStates) {
window.logAllStates = () => {
allStateBrokers.forEach(([name, stateBroker]) => {
console.groupCollapsed(`${name} %c${Object.keys(stateBroker.state).join()}`, 'font-weight: normal; color: #666');
console.log(stateBroker.state);
console.groupEnd();
});
};
}
const colorize = (s) => {
const charCodeSum = s
.split('')
.map((char) => char.charCodeAt(0))
.reduce((sum, charCode) => sum + charCode, 0);
const hue = charCodeSum % 360;
return {
args: [`font-weight: bold; color: hsl(${hue}, 100%, 30%)`, 'font-weight: normal; color: inherit'],
text: `%c${s}%c`,
};
};