@dr.pogodin/react-global-state
Version:
Hook-based global state for React
292 lines (268 loc) • 12.3 kB
JavaScript
function _classPrivateFieldInitSpec(e, t, a) { _checkPrivateRedeclaration(e, t), t.set(e, a); }
function _checkPrivateRedeclaration(e, t) { if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object"); }
function _classPrivateFieldGet(s, a) { return s.get(_assertClassBrand(s, a)); }
function _classPrivateFieldSet(s, a, r) { return s.set(_assertClassBrand(s, a), r), r; }
function _assertClassBrand(e, t, n) { if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n; throw new TypeError("Private element is not present on this object"); }
import { get, set, toPath } from 'lodash-es';
import { cloneDeepForLog, isDebugMode } from "./utils.js";
const ERR_NO_SSR_WATCH = 'GlobalState must not be watched at server side';
var _asyncDataAbortCallbacks = /*#__PURE__*/new WeakMap();
var _dependencies = /*#__PURE__*/new WeakMap();
var _initialState = /*#__PURE__*/new WeakMap();
var _watchers = /*#__PURE__*/new WeakMap();
var _nextNotifierId = /*#__PURE__*/new WeakMap();
var _currentState = /*#__PURE__*/new WeakMap();
export default class GlobalState {
/**
* Creates a new global state object.
* @param initialState Intial global state content.
* @param ssrContext Server-side rendering context.
*/
constructor(initialState, ssrContext) {
_classPrivateFieldInitSpec(this, _asyncDataAbortCallbacks, new Map());
_classPrivateFieldInitSpec(this, _dependencies, new Map());
_classPrivateFieldInitSpec(this, _initialState, void 0);
// 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.
_classPrivateFieldInitSpec(this, _watchers, []);
_classPrivateFieldInitSpec(this, _nextNotifierId, void 0);
_classPrivateFieldInitSpec(this, _currentState, void 0);
_classPrivateFieldSet(_currentState, this, initialState);
_classPrivateFieldSet(_initialState, this, initialState);
if (ssrContext) {
/* eslint-disable no-param-reassign */
ssrContext.dirty = false;
ssrContext.pending = [];
ssrContext.state = _classPrivateFieldGet(_currentState, this);
/* eslint-enable no-param-reassign */
this.ssrContext = ssrContext;
}
if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
/* eslint-disable no-console */
let msg = 'New ReactGlobalState created';
if (ssrContext) msg += ' (SSR mode)';
console.groupCollapsed(msg);
console.log('Initial state:', 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 _classPrivateFieldGet(_asyncDataAbortCallbacks, this).size;
}
/**
* 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) {
var _classPrivateFieldGet2;
if (aborted) (_classPrivateFieldGet2 = _classPrivateFieldGet(_asyncDataAbortCallbacks, this).get(opid)) === null || _classPrivateFieldGet2 === void 0 || _classPrivateFieldGet2();
_classPrivateFieldGet(_asyncDataAbortCallbacks, this).delete(opid);
}
/**
* Drops the record of dependencies, if any, for the given path.
*/
dropDependencies(path) {
_classPrivateFieldGet(_dependencies, this).delete(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 = _classPrivateFieldGet(_dependencies, this).get(path);
let changed = (prevDeps === null || prevDeps === void 0 ? void 0 : prevDeps.length) !== deps.length;
for (let i = 0; !changed && i < deps.length; ++i) {
changed = prevDeps[i] !== deps[i];
}
_classPrivateFieldGet(_dependencies, this).set(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 !== null && opts !== void 0 && opts.initialState ? _classPrivateFieldGet(_initialState, this) : _classPrivateFieldGet(_currentState, this);
if (state !== undefined || (opts === null || opts === void 0 ? void 0 : opts.initialValue) === undefined) return state;
const iv = opts.initialValue;
state = typeof iv === 'function' ? iv() : iv;
if (_classPrivateFieldGet(_currentState, this) === 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' && 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:', cloneDeepForLog(value, path !== null && path !== void 0 ? path : ''));
console.log('New state:', cloneDeepForLog(_classPrivateFieldGet(_currentState, this)));
console.groupEnd();
/* eslint-enable no-console */
}
if (this.ssrContext) {
this.ssrContext.dirty = true;
this.ssrContext.state = _classPrivateFieldGet(_currentState, this);
} else if (!_classPrivateFieldGet(_nextNotifierId, this)) {
_classPrivateFieldSet(_nextNotifierId, this, setTimeout(() => {
_classPrivateFieldSet(_nextNotifierId, this, undefined);
const watchers = [..._classPrivateFieldGet(_watchers, this)];
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) {
_classPrivateFieldGet(_asyncDataAbortCallbacks, this).set(opid, cb);
}
/**
* Sets entire state, the same way as .set(null, value) would do.
* @param value
*/
setEntireState(value) {
if (_classPrivateFieldGet(_currentState, this) !== value) {
_classPrivateFieldSet(_currentState, this, 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 (typeof path !== 'string') {
const res = this.getEntireState(opts);
return res;
}
const state = opts !== null && opts !== void 0 && opts.initialState ? _classPrivateFieldGet(_initialState, this) : _classPrivateFieldGet(_currentState, this);
let res = get(state, path);
if (res !== undefined || (opts === null || opts === void 0 ? void 0 : opts.initialValue) === undefined) return res;
const iv = opts.initialValue;
res = typeof iv === 'function' ? 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 (typeof path !== 'string') return this.setEntireState(value);
// TODO: Revise.
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
if (value !== this.get(path)) {
const root = {
state: _classPrivateFieldGet(_currentState, this)
};
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 = 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 (typeof next === 'object') 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.
set(pos, pathSegments.slice(segIdx), value);
break;
}
pos = pos[seg];
}
if (segIdx === pathSegments.length - 1) {
pos[pathSegments[segIdx]] = value;
}
_classPrivateFieldSet(_currentState, this, 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 = _classPrivateFieldGet(_watchers, this);
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 = _classPrivateFieldGet(_watchers, this);
if (!watchers.includes(callback)) {
watchers.push(callback);
}
}
}
//# sourceMappingURL=GlobalState.js.map