@dr.pogodin/react-global-state
Version:
Hook-based global state for React
289 lines (262 loc) • 10.3 kB
JavaScript
"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