@dr.pogodin/react-global-state
Version:
Hook-based global state for React
270 lines (257 loc) • 9.9 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = require("react");
var _uuid = require("uuid");
var _GlobalStateProvider = require("./GlobalStateProvider");
var _useAsyncData = require("./useAsyncData");
var _useGlobalState = _interopRequireDefault(require("./useGlobalState"));
var _utils = require("./utils");
/**
* Loads and uses item(s) in an async collection.
*/
/**
* GarbageCollector: the piece of logic executed on mounting of
* an useAsyncCollection() hook, and on update of hook params, to update
* the state according to the new param values. It increments by 1 `numRefs`
* counters for the requested collection items.
*/
function gcOnWithhold(ids, path, gs) {
const collection = {
...gs.get(path)
};
for (const id of ids) {
let envelope = collection[id];
if (envelope) envelope = {
...envelope,
numRefs: 1 + envelope.numRefs
};else envelope = (0, _useAsyncData.newAsyncDataEnvelope)(null, {
numRefs: 1
});
collection[id] = envelope;
}
gs.set(path, collection);
}
function idsToStringSet(ids) {
const res = new Set();
for (const id of ids) {
res.add(id.toString());
}
return res;
}
/**
* GarbageCollector: the piece of logic executed on un-mounting of
* an useAsyncCollection() hook, and on update of hook params, to clean-up
* after the previous param values. It decrements by 1 `numRefs` counters
* for previously requested collection items, and also drops from the state
* stale records.
*/
function gcOnRelease(ids, path, gs, gcAge) {
const entries = Object.entries(gs.get(path));
const now = Date.now();
const idSet = idsToStringSet(ids);
const collection = {};
for (const [id, envelope] of entries) {
if (envelope) {
const toBeReleased = idSet.has(id);
let {
numRefs
} = envelope;
if (toBeReleased) --numRefs;
if (gcAge > now - envelope.timestamp || numRefs > 0) {
collection[id] = toBeReleased ? {
...envelope,
numRefs
} : envelope;
} else if (process.env.NODE_ENV !== 'production' && (0, _utils.isDebugMode)()) {
// eslint-disable-next-line no-console
console.log(`useAsyncCollection(): Garbage collected at the path "${path}", ID = ${id}`);
}
}
}
gs.set(path, collection);
}
function normalizeIds(idOrIds) {
if (Array.isArray(idOrIds)) {
const res = [...idOrIds];
res.sort((a, b) => a.toString().localeCompare(b.toString()));
return res;
}
return [idOrIds];
}
/**
* Inits/updates, and returns the heap.
*/
function useHeap(ids, path, loader, gs) {
const ref = (0, _react.useRef)(undefined);
let heap = ref.current;
if (heap) {
// Update.
heap.ids = ids;
heap.path = path;
heap.loader = loader;
heap.globalState = gs;
} else {
// Initialization.
const reload = async customLoader => {
const heap2 = ref.current;
const localLoader = customLoader ?? heap2.loader;
// TODO: Revise - not sure all related typing is 100% correct,
// thus let's keep this runtime assertion.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!localLoader || !heap2.globalState || !heap2.ids) {
throw Error('Internal error');
}
for (const id of heap2.ids) {
const itemPath = heap2.path ? `${heap2.path}.${id}` : `${id}`;
const promiseOrVoid = (0, _useAsyncData.load)(itemPath,
// TODO: Revise! Most probably we don't have fully correct loader
// typing, as it may return either promise or value, and those two
// cases call for different runtime behavior, which in turns only
// happens if the outer function on the next line matches the same
// async / sync signature.
// eslint-disable-next-line @typescript-eslint/promise-function-async
(oldData, meta) => localLoader(id, oldData, meta), heap2.globalState);
if (promiseOrVoid instanceof Promise) await promiseOrVoid;
}
};
heap = {
globalState: gs,
ids,
loader,
path,
reload,
// TODO: Revise! Most probably we don't have fully correct loader
// typing, as it may return either promise or value, and those two
// cases call for different runtime behavior, which in turns only
// happens if the outer function on the next line matches the same
// async / sync signature.
// eslint-disable-next-line @typescript-eslint/promise-function-async
reloadSingle: customLoader => ref.current.reload(
// TODO: Revise! Most probably we don't have fully correct loader
// typing, as it may return either promise or value, and those two
// cases call for different runtime behavior, which in turns only
// happens if the outer function on the next line matches the same
// async / sync signature.
// eslint-disable-next-line @typescript-eslint/promise-function-async
customLoader && ((id, ...args) => customLoader(...args)))
};
ref.current = heap;
}
return heap;
}
/**
* Resolves and stores at the given `path` of the global state elements of
* an asynchronous data collection.
*/
// TODO: This is largely similar to useAsyncData() logic, just more generic.
// Perhaps, a bunch of logic blocks can be split into stand-alone functions,
// and reused in both hooks.
// eslint-disable-next-line complexity
function useAsyncCollection(idOrIds, path, loader, options = {}) {
const ids = normalizeIds(idOrIds);
const maxage = options.maxage ?? _useAsyncData.DEFAULT_MAXAGE;
const refreshAge = options.refreshAge ?? maxage;
const garbageCollectAge = options.garbageCollectAge ?? maxage;
const globalState = (0, _GlobalStateProvider.getGlobalState)();
const heap = useHeap(ids, path, loader, globalState);
// Server-side logic.
if (globalState.ssrContext && !options.noSSR) {
const operationId = `S${(0, _uuid.v4)()}`;
for (const id of ids) {
const itemPath = path ? `${path}.${id}` : `${id}`;
const state = globalState.get(itemPath, {
initialValue: (0, _useAsyncData.newAsyncDataEnvelope)()
});
if (!state.timestamp && !state.operationId) {
const promiseOrVoid = (0, _useAsyncData.load)(itemPath, (...args) => loader(id, ...args), globalState, {
data: state.data,
timestamp: state.timestamp
}, operationId);
if (promiseOrVoid instanceof Promise) {
globalState.ssrContext.pending.push(promiseOrVoid);
}
}
}
// Client-side logic.
} else {
// Reference-counting & garbage collection.
const idsHash = (0, _utils.hash)(ids);
// TODO: Violation of rules of hooks is fine here,
// but perhaps it can be refactored to avoid the need for it.
(0, _react.useEffect)(() => {
// eslint-disable-line react-hooks/rules-of-hooks
gcOnWithhold(ids, path, globalState);
return () => {
gcOnRelease(ids, path, globalState, garbageCollectAge);
};
// `ids` are represented in the dependencies array by `idsHash` value,
// as useEffect() hook requires a constant size of dependencies array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [garbageCollectAge, globalState, idsHash, path]);
// NOTE: a bunch of Rules of Hooks ignored belows because in our very
// special case the otherwise wrong behavior is actually what we need.
// Data loading and refreshing.
(0, _react.useEffect)(() => {
// eslint-disable-line react-hooks/rules-of-hooks
void (async () => {
for (const id of ids) {
const itemPath = path ? `${path}.${id}` : `${id}`;
const state2 = globalState.get(itemPath);
const {
deps
} = options;
if (deps && globalState.hasChangedDependencies(itemPath, deps) || refreshAge < Date.now() - (state2?.timestamp ?? 0) && (!state2?.operationId || state2.operationId.startsWith('S'))) {
if (!deps) globalState.dropDependencies(itemPath);
await (0, _useAsyncData.load)(itemPath,
// TODO: I guess, the loader is not correctly typed here -
// it can be synchronous, and in that case the following method
// should be kept synchronous to not alter the sync logic.
// eslint-disable-next-line @typescript-eslint/promise-function-async
(old, ...args) => loader(id, old, ...args), globalState, {
data: state2?.data,
timestamp: state2?.timestamp ?? 0
});
}
}
})();
});
}
const [localState] = (0, _useGlobalState.default)(path, {});
if (!Array.isArray(idOrIds)) {
// TODO: Revise related typings!
const e = localState[idOrIds];
const timestamp = e?.timestamp ?? 0;
return {
data: maxage < Date.now() - timestamp ? null : e?.data ?? null,
loading: !!e?.operationId,
reload: heap.reloadSingle,
timestamp
};
}
const res = {
items: {},
loading: false,
reload: heap.reload,
timestamp: Number.MAX_VALUE
};
for (const id of ids) {
// TODO: Revise related typing. Should `localState` have a more specific type?
const e = localState[id];
const loading = !!e?.operationId;
const timestamp = e?.timestamp ?? 0;
res.items[id] = {
data: maxage < Date.now() - timestamp ? null : e?.data ?? null,
loading,
timestamp
};
res.loading ||= loading;
if (res.timestamp > timestamp) res.timestamp = timestamp;
}
return res;
}
var _default = exports.default = useAsyncCollection; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
//# sourceMappingURL=useAsyncCollection.js.map