UNPKG

@dr.pogodin/react-global-state

Version:
282 lines (266 loc) 11.3 kB
/** * 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