@dr.pogodin/react-global-state
Version:
Hook-based global state for React
260 lines (247 loc) • 9.83 kB
JavaScript
;
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