UNPKG

@dr.pogodin/react-global-state

Version:
289 lines (262 loc) 10.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _lodash = require("lodash"); var _utils = require("./utils"); const ERR_NO_SSR_WATCH = 'GlobalState must not be watched at server side'; class GlobalState { #asyncDataAbortCallbacks = {}; #dependencies = {}; #initialState; // TODO: It is tempting to replace watchers here by // Emitter from @dr.pogodin/js-utils, but we need to clone // current watchers for emitting later, and this is not something // Emitter supports right now. #watchers = []; #nextNotifierId; #currentState; /** * Creates a new global state object. * @param initialState Intial global state content. * @param ssrContext Server-side rendering context. */ constructor(initialState, ssrContext) { this.#currentState = initialState; this.#initialState = initialState; if (ssrContext) { /* eslint-disable no-param-reassign */ ssrContext.dirty = false; ssrContext.pending = []; ssrContext.state = this.#currentState; /* eslint-enable no-param-reassign */ this.ssrContext = ssrContext; } if (process.env.NODE_ENV !== 'production' && (0, _utils.isDebugMode)()) { /* eslint-disable no-console */ let msg = 'New ReactGlobalState created'; if (ssrContext) msg += ' (SSR mode)'; console.groupCollapsed(msg); console.log('Initial state:', (0, _utils.cloneDeepForLog)(initialState)); console.groupEnd(); /* eslint-enable no-console */ } } /** * Returns the number of currently registered async data abort callbacks, * just for the sake of testing the library. */ get numAsyncDataAbortCallbacks() { return Object.keys(this.#asyncDataAbortCallbacks).length; } /** * If `aborted` is "true" and there is an abort callback registered for * the specified operation, it triggers the callback. Then, in any case, * it drops the callback. */ asyncDataLoadDone(opid, aborted) { if (aborted) this.#asyncDataAbortCallbacks[opid]?.(); delete this.#asyncDataAbortCallbacks[opid]; } /** * Drops the record of dependencies, if any, for the given path. */ dropDependencies(path) { delete this.#dependencies[path]; } /** * Checks if given `deps` are different from previously recorded ones for * the given `path`. If they are, `deps` are recorded as the new deps for * the `path`, and also the array is frozen, to prevent it from being * modified. * * TODO: This may not work as expected if path string is not normalized, * and the for the same path different alternative ways to spell it down * are used. We should normalize given path here, I guess, or on a higher * level in the logic? */ hasChangedDependencies(path, deps) { const prevDeps = this.#dependencies[path]; let changed = !prevDeps || prevDeps.length !== deps.length; for (let i = 0; !changed && i < deps.length; ++i) { changed = prevDeps[i] !== deps[i]; } this.#dependencies[path] = Object.freeze(deps); return changed; } /** * Gets entire state, the same way as .get(null, opts) would do. * @param opts.initialState * @param opts.initialValue */ getEntireState(opts) { let state = opts?.initialState ? this.#initialState : this.#currentState; if (state !== undefined || opts?.initialValue === undefined) return state; const iv = opts.initialValue; state = (0, _lodash.isFunction)(iv) ? iv() : iv; if (this.#currentState === undefined) this.setEntireState(state); return state; } /** * Notifies all connected state watchers that a state update has happened. */ notifyStateUpdate(path, value) { if (process.env.NODE_ENV !== 'production' && (0, _utils.isDebugMode)()) { /* eslint-disable no-console */ const p = typeof path === 'string' ? `"${path}"` : 'none (entire state update)'; console.groupCollapsed(`ReactGlobalState update. Path: ${p}`); console.log('New value:', (0, _utils.cloneDeepForLog)(value, path ?? '')); console.log('New state:', (0, _utils.cloneDeepForLog)(this.#currentState)); console.groupEnd(); /* eslint-enable no-console */ } if (this.ssrContext) { this.ssrContext.dirty = true; this.ssrContext.state = this.#currentState; } else if (!this.#nextNotifierId) { this.#nextNotifierId = setTimeout(() => { this.#nextNotifierId = undefined; const watchers = [...this.#watchers]; for (const watcher of watchers) watcher(); }); } } /** * Registers an abort callback for an async data retrieval operation with * the given operation ID. Throws if already registered. */ setAsyncDataAbortCallback(opid, cb) { this.#asyncDataAbortCallbacks[opid] = cb; } /** * Sets entire state, the same way as .set(null, value) would do. * @param value */ setEntireState(value) { if (this.#currentState !== value) { this.#currentState = value; this.notifyStateUpdate(null, value); } return value; } /** * Gets current or initial value at the specified "path" of the global state. * @param path Dot-delimitered state path. * @param options Additional options. * @param options.initialState If "true" the value will be read * from the initial state instead of the current one. * @param options.initialValue If the value read from the "path" is * "undefined", this "initialValue" will be returned instead. In such case * "initialValue" will also be written to the "path" of the current global * state (no matter "initialState" flag), if "undefined" is stored there. * @return Retrieved value. */ // .get() without arguments just falls back to .getEntireState(). // This variant attempts to automatically resolve and check the type of value // at the given path, as precise as the actual state and path types permit. // If the automatic path resolution is not possible, the ValueT fallsback // to `never` (or to `undefined` in some cases), effectively forbidding // to use this .get() variant. // This variant is not callable by default (without generic arguments), // otherwise it allows to set the correct ValueT directly. get(path, opts) { if ((0, _lodash.isNil)(path)) { const res = this.getEntireState(opts); return res; } const state = opts?.initialState ? this.#initialState : this.#currentState; let res = (0, _lodash.get)(state, path); if (res !== undefined || opts?.initialValue === undefined) return res; const iv = opts.initialValue; res = (0, _lodash.isFunction)(iv) ? iv() : iv; // TODO: Revise. // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression if (!opts.initialState || this.get(path) === undefined) { this.set(path, res); } return res; } /** * Writes the `value` to given global state `path`. * @param path Dot-delimitered state path. If not given, entire * global state content is replaced by the `value`. * @param value The value. * @return Given `value` itself. */ // This variant attempts automatic value type resolution & checking. // This variant is disabled by default, otherwise allows to give // expected value type explicitly. set(path, value) { if ((0, _lodash.isNil)(path)) return this.setEntireState(value); // TODO: Revise. // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression if (value !== this.get(path)) { const root = { state: this.#currentState }; let segIdx = 0; // TODO: It is not 100% correct, as `pos` can be an array, or any other // value as we travel through the state tree. To simplify the typing for // now, I guess, we can go with this record type, though. let pos = root; const pathSegments = (0, _lodash.toPath)(`state.${path}`); for (; segIdx < pathSegments.length - 1; segIdx += 1) { const seg = pathSegments[segIdx]; // TODO: Revise: Typing is not quite correct here, but it works fine in the runtime. const next = pos[seg]; if (Array.isArray(next)) pos[seg] = [...next];else if ((0, _lodash.isObject)(next)) pos[seg] = { ...next };else { // We arrived to a state sub-segment, where the remaining part of // the update path does not exist yet. We rely on lodash's set() // function to create the remaining path, and set the value. (0, _lodash.set)(pos, pathSegments.slice(segIdx), value); break; } pos = pos[seg]; } if (segIdx === pathSegments.length - 1) { pos[pathSegments[segIdx]] = value; } this.#currentState = root.state; this.notifyStateUpdate(path, value); } return value; } /** * Unsubscribes `callback` from watching state updates; no operation if * `callback` is not subscribed to the state updates. * @param callback * @throws if {@link SsrContext} is attached to the state instance: the state * watching functionality is intended for client-side (non-SSR) only. */ unWatch(callback) { if (this.ssrContext) throw new Error(ERR_NO_SSR_WATCH); const watchers = this.#watchers; const pos = watchers.indexOf(callback); if (pos >= 0) { watchers[pos] = watchers[watchers.length - 1]; watchers.pop(); } } /** * Subscribes `callback` to watch state updates; no operation if * `callback` is already subscribed to this state instance. * @param callback It will be called without any arguments every * time the state content changes (note, howhever, separate state updates can * be applied to the state at once, and watching callbacks will be called once * after such bulk update). * @throws if {@link SsrContext} is attached to the state instance: the state * watching functionality is intended for client-side (non-SSR) only. */ watch(callback) { if (this.ssrContext) throw new Error(ERR_NO_SSR_WATCH); const watchers = this.#watchers; if (!watchers.includes(callback)) { watchers.push(callback); } } } exports.default = GlobalState; //# sourceMappingURL=GlobalState.js.map