@dr.pogodin/react-global-state
Version:
Hook-based global state for React
251 lines (239 loc) • 10.2 kB
JavaScript
/**
* 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