@dr.pogodin/react-global-state
Version:
Hook-based global state for React
286 lines (273 loc) • 11.5 kB
JavaScript
/**
* Loads and uses item(s) in an async collection.
*/
import { useEffect, useRef } from 'react';
import { v4 as uuid } from 'uuid';
import { getGlobalState } from "./GlobalStateProvider";
import { DEFAULT_MAXAGE, load, newAsyncDataEnvelope } from "./useAsyncData";
import useGlobalState from "./useGlobalState";
import { hash, isDebugMode } from "./utils";
/**
* 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 = 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' && 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 = 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 !== null && customLoader !== void 0 ? 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 = 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 && function (id) {
for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
args[_key - 1] = arguments[_key];
}
return 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) {
var _options$maxage, _options$refreshAge, _options$garbageColle;
let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
const ids = normalizeIds(idOrIds);
const maxage = (_options$maxage = options.maxage) !== null && _options$maxage !== void 0 ? _options$maxage : DEFAULT_MAXAGE;
const refreshAge = (_options$refreshAge = options.refreshAge) !== null && _options$refreshAge !== void 0 ? _options$refreshAge : maxage;
const garbageCollectAge = (_options$garbageColle = options.garbageCollectAge) !== null && _options$garbageColle !== void 0 ? _options$garbageColle : maxage;
const globalState = getGlobalState();
const heap = useHeap(ids, path, loader, globalState);
// Server-side logic.
if (globalState.ssrContext && !options.noSSR) {
const operationId = `S${uuid()}`;
for (const id of ids) {
const itemPath = path ? `${path}.${id}` : `${id}`;
const state = globalState.get(itemPath, {
initialValue: newAsyncDataEnvelope()
});
if (!state.timestamp && !state.operationId) {
const promiseOrVoid = load(itemPath, function () {
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
args[_key2] = arguments[_key2];
}
return 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 = hash(ids);
// TODO: Violation of rules of hooks is fine here,
// but perhaps it can be refactored to avoid the need for it.
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.
useEffect(() => {
// eslint-disable-line react-hooks/rules-of-hooks
void (async () => {
for (const id of ids) {
var _state2$timestamp;
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 = state2 === null || state2 === void 0 ? void 0 : state2.timestamp) !== null && _state2$timestamp !== void 0 ? _state2$timestamp : 0) && (!(state2 !== null && state2 !== void 0 && state2.operationId) || state2.operationId.startsWith('S'))) {
var _state2$timestamp2;
if (!deps) globalState.dropDependencies(itemPath);
await 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
function (old) {
for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) {
args[_key3 - 1] = arguments[_key3];
}
return loader(id, old, ...args);
}, globalState, {
data: state2 === null || state2 === void 0 ? void 0 : state2.data,
timestamp: (_state2$timestamp2 = state2 === null || state2 === void 0 ? void 0 : state2.timestamp) !== null && _state2$timestamp2 !== void 0 ? _state2$timestamp2 : 0
});
}
}
})();
});
}
const [localState] = useGlobalState(path, {});
if (!Array.isArray(idOrIds)) {
var _e$timestamp, _e$data;
// TODO: Revise related typings!
const e = localState[idOrIds];
const timestamp = (_e$timestamp = e === null || e === void 0 ? void 0 : e.timestamp) !== null && _e$timestamp !== void 0 ? _e$timestamp : 0;
return {
data: maxage < Date.now() - timestamp ? null : (_e$data = e === null || e === void 0 ? void 0 : e.data) !== null && _e$data !== void 0 ? _e$data : null,
loading: !!(e !== null && e !== void 0 && e.operationId),
reload: heap.reloadSingle,
timestamp
};
}
const res = {
items: {},
loading: false,
reload: heap.reload,
timestamp: Number.MAX_VALUE
};
for (const id of ids) {
var _e$timestamp2, _e$data2;
// TODO: Revise related typing. Should `localState` have a more specific type?
const e = localState[id];
const loading = !!(e !== null && e !== void 0 && e.operationId);
const timestamp = (_e$timestamp2 = e === null || e === void 0 ? void 0 : e.timestamp) !== null && _e$timestamp2 !== void 0 ? _e$timestamp2 : 0;
res.items[id] = {
data: maxage < Date.now() - timestamp ? null : (_e$data2 = e === null || e === void 0 ? void 0 : e.data) !== null && _e$data2 !== void 0 ? _e$data2 : null,
loading,
timestamp
};
res.loading || (res.loading = loading);
if (res.timestamp > timestamp) res.timestamp = timestamp;
}
return res;
}
export default useAsyncCollection;
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
//# sourceMappingURL=useAsyncCollection.js.map