UNPKG

@dr.pogodin/react-global-state

Version:
286 lines (273 loc) 11.5 kB
/** * 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