@dr.pogodin/react-global-state
Version:
Hook-based global state for React
282 lines (266 loc) • 11.3 kB
JavaScript
/**
* Loads and uses item(s) in an async collection.
*/
import { useEffect, useRef, useState } from 'react';
import { getGlobalState } from "./GlobalStateProvider.js";
import { DEFAULT_MAXAGE, loadAsyncData, newAsyncDataEnvelope } from "./useAsyncData.js";
import useGlobalState from "./useGlobalState.js";
import { hash, isDebugMode } from "./utils.js";
/**
* 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)) {
// Removes ID duplicates.
const res = Array.from(new Set(idOrIds));
// Ensures stable ID order.
res.sort((a, b) => a.toString().localeCompare(b.toString()));
return res;
}
return [idOrIds];
}
/**
* 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 = {}) {
var _options$maxage, _options$refreshAge, _options$garbageColle, _ref$current;
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();
// Server-side logic.
if (globalState.ssrContext) {
if (!options.disabled && !options.noSSR) {
const operationId = `S${globalThis.crypto.randomUUID()}`;
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 = loadAsyncData(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 {
const {
disabled
} = options;
// 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
if (!disabled) gcOnWithhold(ids, path, globalState);
return () => {
if (!disabled) 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
}, [disabled, 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
if (!disabled) {
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$data, _state2$timestamp2;
if (!deps) globalState.dropDependencies(itemPath);
await loadAsyncData(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 = state2 === null || state2 === void 0 ? void 0 : state2.data) !== null && _state2$data !== void 0 ? _state2$data : null,
timestamp: (_state2$timestamp2 = state2 === null || state2 === void 0 ? void 0 : state2.timestamp) !== null && _state2$timestamp2 !== void 0 ? _state2$timestamp2 : 0
});
}
}
})();
}
});
}
const [localState] = useGlobalState(path, {});
const ref = useRef(null);
(_ref$current = ref.current) !== null && _ref$current !== void 0 ? _ref$current : ref.current = {
globalState,
ids,
loader,
path
};
useEffect(() => {
ref.current = {
globalState,
ids,
loader,
path
};
}, [globalState, ids, loader, path]);
const [stable] = useState(() => {
const reload = async customLoader => {
const rc = ref.current;
if (!rc) throw Error('Internal error');
const localLoader = customLoader !== null && customLoader !== void 0 ? customLoader : rc.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 || !rc.globalState || !rc.ids) {
throw Error('Internal error');
}
for (const id of rc.ids) {
const itemPath = rc.path ? `${rc.path}.${id}` : `${id}`;
const promiseOrVoid = loadAsyncData(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), rc.globalState);
if (promiseOrVoid instanceof Promise) await promiseOrVoid;
}
};
// 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
const reloadSingle = customLoader => 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)));
const setSingle = data => {
void reload(() => data);
};
return {
reload,
reloadSingle,
setSingle
};
});
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: stable.reloadSingle,
set: stable.setSingle,
timestamp
};
}
const res = {
items: {},
loading: false,
reload: stable.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