UNPKG

@dr.pogodin/react-global-state

Version:
251 lines (239 loc) 10.2 kB
/** * Loads and uses async data into the GlobalState path. */ import { useEffect, useRef } from 'react'; import { v4 as uuid } from 'uuid'; import { MIN_MS } from '@dr.pogodin/js-utils'; import { getGlobalState } from "./GlobalStateProvider"; import useGlobalState from "./useGlobalState"; import { cloneDeepForLog, isDebugMode } from "./utils"; export const DEFAULT_MAXAGE = 5 * MIN_MS; // 5 minutes. // NOTE: Here, and below it is important whether a loader and related // (re-)loading handlers return a promise or a value, as returning promises // mean the async mode, in which related global state values are updated // asynchronously (the new value comes into effect in a next rendering cycle), // while returning a non-promise value means a synchronous mode, in which // related global state values are updated immediately, within the current // rendering cycle. export function newAsyncDataEnvelope() { let initialData = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; let { numRefs = 0, timestamp = 0 } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; return { data: initialData, numRefs, operationId: '', timestamp }; } function finalizeLoad(data, path, globalState, operationId) { // NOTE: We don't really mean that it hasn't been aborted, // the "false" flag rather says we don't need to trigger "on aborted" // callback for this operation, if any is registered - just drop it. // // Also, in the synchronous state update mode, we don't really need to set up // the abort callback at all (as there is no way to use it), but for now it is // set up, thus it should be cleaned out here. globalState.asyncDataLoadDone(operationId, false); const state = globalState.get(path); if (operationId === (state === null || state === void 0 ? void 0 : state.operationId)) { if (process.env.NODE_ENV !== 'production' && isDebugMode()) { /* eslint-disable no-console */ console.groupCollapsed(`ReactGlobalState: async data (re-)loaded. Path: "${path !== null && path !== void 0 ? path : ''}"`); console.log('Data:', cloneDeepForLog(data, path !== null && path !== void 0 ? path : '')); /* eslint-enable no-console */ } globalState.set(path, { ...state, data, operationId: '', timestamp: Date.now() }); if (process.env.NODE_ENV !== 'production' && isDebugMode()) { /* eslint-disable no-console */ console.groupEnd(); /* eslint-enable no-console */ } } } /** * Executes the data loading operation. * @param path Data segment path inside the global state. * @param loader Data loader. * @param globalState The global state instance. * @param oldData Optional. Previously fetched data, currently stored in * the state, if already fetched by the caller; otherwise, they will be fetched * by the load() function itself. * @param opIdPrefix operationId prefix to use, which should be * 'C' at the client-side (default), or 'S' at the server-side (within SSR * context). * @return Resolves once the operation is done. * @ignore */ export function load(path, loader, globalState, old) { let operationId = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : `C${uuid()}`; if (process.env.NODE_ENV !== 'production' && isDebugMode()) { /* eslint-disable no-console */ console.log(`ReactGlobalState: async data (re-)loading. Path: "${path !== null && path !== void 0 ? path : ''}"`); /* eslint-enable no-console */ } const operationIdPath = path ? `${path}.operationId` : 'operationId'; { const prevOperationId = globalState.get(operationIdPath); if (prevOperationId) globalState.asyncDataLoadDone(prevOperationId, true); } globalState.set(operationIdPath, operationId); let definedOld = old; if (!definedOld) { // TODO: Can we improve the typing, to avoid ForceT? const e = globalState.get(path); definedOld = { data: e.data, timestamp: e.timestamp }; } const dataOrPromise = loader(definedOld.data, { isAborted: () => { // TODO: Can we improve the typing, to avoid ForceT? const opid = globalState.get(path).operationId; return opid !== operationId; }, oldDataTimestamp: definedOld.timestamp, setAbortCallback(cb) { const opid = globalState.get(path).operationId; if (opid !== operationId) { throw Error(`Operation #${operationId} has completed already`); } globalState.setAsyncDataAbortCallback(operationId, cb); } }); if (dataOrPromise instanceof Promise) { return dataOrPromise.then(data => { finalizeLoad(data, path, globalState, operationId); }).finally(() => { // NOTE: We don't really mean that it hasn't been aborted, // the "false" flag rather says we don't need to trigger "on aborted" // callback for this operation, if any is registered - just drop it. globalState.asyncDataLoadDone(operationId, false); }); } finalizeLoad(dataOrPromise, path, globalState, operationId); return undefined; } /** * Resolves asynchronous data, and stores them at given `path` of global * state. */ function useAsyncData(path, loader) { var _options$maxage, _options$refreshAge, _options$garbageColle, _heap$reload; let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 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; // Note: here we can't depend on useGlobalState() to init the initial value, // because that way we'll have issues with SSR (see details below). const globalState = getGlobalState(); const state = globalState.get(path, { initialValue: newAsyncDataEnvelope() }); const { current: heap } = useRef({}); heap.globalState = globalState; heap.path = path; heap.loader = loader; (_heap$reload = heap.reload) !== null && _heap$reload !== void 0 ? _heap$reload : heap.reload = customLoader => { const localLoader = customLoader !== null && customLoader !== void 0 ? customLoader : heap.loader; if (!localLoader || !heap.globalState) throw Error('Internal error'); return load(heap.path, localLoader, heap.globalState); }; if (globalState.ssrContext) { if (!options.disabled && !options.noSSR && !state.operationId && !state.timestamp) { const promiseOrVoid = load(path, loader, globalState, { data: state.data, timestamp: state.timestamp }, `S${uuid()}`); if (promiseOrVoid instanceof Promise) { globalState.ssrContext.pending.push(promiseOrVoid); } } } else { const { disabled } = options; // This takes care about the client-side reference counting, and garbage // collection. // // Note: the Rules of Hook below are violated by conditional call to a hook, // but as the condition is actually server-side or client-side environment, // it is effectively non-conditional at the runtime. // // TODO: Though, maybe there is a way to refactor it into a cleaner code. // The same applies to other useEffect() hooks below. useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks const numRefsPath = path ? `${path}.numRefs` : 'numRefs'; if (!disabled) { const numRefs = globalState.get(numRefsPath); globalState.set(numRefsPath, numRefs + 1); } return () => { if (!disabled) { const state2 = globalState.get(path); if (state2.numRefs === 1 && garbageCollectAge < Date.now() - state2.timestamp) { if (process.env.NODE_ENV !== 'production' && isDebugMode()) { /* eslint-disable no-console */ console.log(`ReactGlobalState - useAsyncData garbage collected at path ${path !== null && path !== void 0 ? path : ''}`); /* eslint-enable no-console */ } globalState.dropDependencies(path !== null && path !== void 0 ? path : ''); globalState.set(path, { ...state2, data: null, numRefs: 0, timestamp: 0 }); } else { globalState.set(numRefsPath, state2.numRefs - 1); } } }; }, [disabled, garbageCollectAge, globalState, 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) { const state2 = globalState.get(path); const { deps } = options; if ( // The hook is called with a list of dependencies, that mismatch // dependencies last used to retrieve the data at given path. deps && globalState.hasChangedDependencies(path !== null && path !== void 0 ? path : '', deps) // Data at the path are stale, and are not being loaded. || refreshAge < Date.now() - state2.timestamp && (!state2.operationId || state2.operationId.startsWith('S'))) { if (!deps) globalState.dropDependencies(path !== null && path !== void 0 ? path : ''); void load(path, loader, globalState, { data: state2.data, timestamp: state2.timestamp }); } } }); } const [localState] = useGlobalState(path, newAsyncDataEnvelope()); return { data: maxage < Date.now() - localState.timestamp ? null : localState.data, loading: Boolean(localState.operationId), reload: heap.reload, timestamp: localState.timestamp }; } export { useAsyncData }; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions //# sourceMappingURL=useAsyncData.js.map