UNPKG

@dr.pogodin/react-global-state

Version:
287 lines (272 loc) 10 kB
/** * Loads and uses item(s) in an async collection. */ import { useEffect, useRef, useState } from 'react'; import { useGlobalStateObject } from "./GlobalStateProvider.js"; import { DEFAULT_MAXAGE, loadAsyncData, newAsyncDataEnvelope } from "./useAsyncData.js"; import useGlobalState from "./useGlobalState.js"; import { areEqual, 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 = {}) { const ids = normalizeIds(idOrIds); const maxage = options.maxage ?? DEFAULT_MAXAGE; const refreshAge = options.refreshAge ?? maxage; const garbageCollectAge = options.garbageCollectAge ?? maxage; const globalState = useGlobalStateObject(); // 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); } } } } } const { disabled } = options; // Reference-counting & garbage collection. const idsString = JSON.stringify(ids); useEffect(() => { const localIds = JSON.parse(idsString); if (!disabled) gcOnWithhold(localIds, path, globalState); return () => { if (!disabled) { gcOnRelease(localIds, path, globalState, garbageCollectAge); } }; // `ids` are represented in the dependencies array by `idsHash` value, // as useEffect() hook requires a constant size of dependencies array. }, [disabled, garbageCollectAge, globalState, idsString, 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(() => { if (!disabled) { void (async () => { for (const id_0 of ids) { const itemPath_0 = path ? `${path}.${id_0}` : `${id_0}`; const state2 = globalState.get(itemPath_0); const { deps } = options; if (deps && globalState.hasChangedDependencies(itemPath_0, deps) || refreshAge < Date.now() - (state2?.timestamp ?? 0) && (!state2?.operationId || state2.operationId.startsWith('S'))) { if (!deps) globalState.dropDependencies(itemPath_0); await loadAsyncData(itemPath_0, // 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_0) => loader(id_0, old, ...args_0), globalState, { data: state2?.data ?? null, timestamp: state2?.timestamp ?? 0 }); } } })(); } }); const [localState] = useGlobalState(path, {}); const ref = useRef(null); 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 ?? 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_1 of rc.ids) { const itemPath_1 = rc.path ? `${rc.path}.${id_1}` : `${id_1}`; const promiseOrVoid_0 = loadAsyncData(itemPath_1, // 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_1, oldData, meta), rc.globalState); if (promiseOrVoid_0 instanceof Promise) await promiseOrVoid_0; } }; // 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_0 => 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_0 && ((id_2, ...args_1) => customLoader_0(...args_1))); const setSingle = data => { void reload(() => data); }; return { reload, reloadSingle, setSingle }; }); const [stale, setStale] = useState({}); // TODO: Merge into the data-reloading effect above? useEffect(() => { const now = Date.now(); const nowStale = {}; for (const [key, e] of Object.entries(localState)) { nowStale[key] = maxage < now - e.timestamp; } const id_3 = areEqual(stale, nowStale) ? null : requestAnimationFrame(() => { setStale(nowStale); }); return () => { if (id_3 !== null) cancelAnimationFrame(id_3); }; }); if (!Array.isArray(idOrIds)) { // TODO: Revise related typings! const e_0 = localState[idOrIds]; const timestamp = e_0?.timestamp ?? 0; return { data: stale[idOrIds] ? null : e_0?.data ?? null, loading: !!e_0?.operationId, reload: stable.reloadSingle, set: stable.setSingle, timestamp }; } const res = { items: {}, loading: false, reload: stable.reload, timestamp: Number.MAX_VALUE }; for (const id_4 of ids) { // TODO: Revise related typing. Should `localState` have a more specific type? const e_1 = localState[id_4]; const loading = !!e_1?.operationId; const timestamp_0 = e_1?.timestamp ?? 0; res.items[id_4] = { data: stale[id_4] ? null : e_1?.data ?? null, loading, timestamp: timestamp_0 }; res.loading ||= loading; if (res.timestamp > timestamp_0) res.timestamp = timestamp_0; } return res; } export default useAsyncCollection; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions //# sourceMappingURL=useAsyncCollection.js.map