UNPKG

@phnq/state

Version:
396 lines (395 loc) 18.9 kB
"use strict"; 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`, }; };