UNPKG

@dr.pogodin/react-global-state

Version:
260 lines (247 loc) 9.83 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_MAXAGE = void 0; exports.load = load; exports.newAsyncDataEnvelope = newAsyncDataEnvelope; exports.useAsyncData = useAsyncData; var _react = require("react"); var _uuid = require("uuid"); var _jsUtils = require("@dr.pogodin/js-utils"); var _GlobalStateProvider = require("./GlobalStateProvider"); var _useGlobalState = _interopRequireDefault(require("./useGlobalState")); var _utils = require("./utils"); /** * Loads and uses async data into the GlobalState path. */ const DEFAULT_MAXAGE = exports.DEFAULT_MAXAGE = 5 * _jsUtils.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. function newAsyncDataEnvelope(initialData = null, { numRefs = 0, timestamp = 0 } = {}) { 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?.operationId) { if (process.env.NODE_ENV !== 'production' && (0, _utils.isDebugMode)()) { /* eslint-disable no-console */ console.groupCollapsed(`ReactGlobalState: async data (re-)loaded. Path: "${path ?? ''}"`); console.log('Data:', (0, _utils.cloneDeepForLog)(data, path ?? '')); /* eslint-enable no-console */ } globalState.set(path, { ...state, data, operationId: '', timestamp: Date.now() }); if (process.env.NODE_ENV !== 'production' && (0, _utils.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 */ function load(path, loader, globalState, old, // TODO: Should this parameter be just a binary flag (client or server), // and UUID always generated inside this function? Or do we need it in // the caller methods as well, in some cases (see useAsyncCollection() // use case as well). operationId = `C${(0, _uuid.v4)()}`) { if (process.env.NODE_ENV !== 'production' && (0, _utils.isDebugMode)()) { /* eslint-disable no-console */ console.log(`ReactGlobalState: async data (re-)loading. Path: "${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, options = {}) { const maxage = options.maxage ?? DEFAULT_MAXAGE; const refreshAge = options.refreshAge ?? maxage; const garbageCollectAge = options.garbageCollectAge ?? 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 = (0, _GlobalStateProvider.getGlobalState)(); const state = globalState.get(path, { initialValue: newAsyncDataEnvelope() }); const { current: heap } = (0, _react.useRef)({}); heap.globalState = globalState; heap.path = path; heap.loader = loader; heap.reload ??= customLoader => { const localLoader = 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${(0, _uuid.v4)()}`); 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. (0, _react.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' && (0, _utils.isDebugMode)()) { /* eslint-disable no-console */ console.log(`ReactGlobalState - useAsyncData garbage collected at path ${path ?? ''}`); /* eslint-enable no-console */ } globalState.dropDependencies(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. (0, _react.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 ?? '', 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 ?? ''); void load(path, loader, globalState, { data: state2.data, timestamp: state2.timestamp }); } } }); } const [localState] = (0, _useGlobalState.default)(path, newAsyncDataEnvelope()); return { data: maxage < Date.now() - localState.timestamp ? null : localState.data, loading: Boolean(localState.operationId), reload: heap.reload, timestamp: localState.timestamp }; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions //# sourceMappingURL=useAsyncData.js.map