@dr.pogodin/react-global-state
Version:
Hook-based global state for React
354 lines (346 loc) • 11.1 kB
JavaScript
import { c as _c } from "react/compiler-runtime";
/**
* Loads and uses async data into the GlobalState path.
*/
import { useEffect, useRef, useState } from 'react';
import { MIN_MS } from '@dr.pogodin/js-utils';
import { useGlobalStateObject } from "./GlobalStateProvider.js";
import useGlobalState from "./useGlobalState.js";
import { cloneDeepForLog, isDebugMode } from "./utils.js";
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(initialData = null, {
numRefs = 0,
timestamp = 0
} = {}) {
return {
data: initialData,
numRefs,
operationId: '',
timestamp
};
}
/**
* Writes data into the global state, and prints console messages in the debug
* mode.
*/
function setState(data, path, gs, prevState = gs.get(path)) {
if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
/* eslint-disable no-console */
console.groupCollapsed(`ReactGlobalState: async data (re-)loaded. Path: "${path ?? ''}"`);
console.log('Data:', cloneDeepForLog(data, path ?? ''));
/* eslint-enable no-console */
}
gs.set(path, {
...prevState,
data,
operationId: '',
timestamp: Date.now()
});
if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
/* eslint-disable no-console */
console.groupEnd();
/* eslint-enable no-console */
}
}
function finalizeLoad(data, path, gs, 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.
gs.asyncDataLoadDone(operationId, false);
const state = gs.get(path);
if (operationId === state?.operationId) setState(data, path, gs, state);
}
/**
* 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 loadAsyncData(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${globalThis.crypto.randomUUID()}`) {
if (process.env.NODE_ENV !== 'production' && 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 controller = new AbortController();
globalState.setAsyncDataAbortCallback(operationId, () => {
controller.abort();
});
const dataOrPromise = loader(definedOld.data, {
abortSignal: controller.signal,
oldDataTimestamp: definedOld.timestamp
});
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.
*/
// TODO: Perhaps split the heap management to a dedicated hook,
// as it is done inside useAsyncCollection().
function useAsyncData(path, loader, t0) {
const $ = _c(38);
let t1;
if ($[0] !== t0) {
t1 = t0 === undefined ? {} : t0;
$[0] = t0;
$[1] = t1;
} else {
t1 = $[1];
}
const options = t1;
const maxage = options.maxage ?? DEFAULT_MAXAGE;
const refreshAge = options.refreshAge ?? maxage;
const garbageCollectAge = options.garbageCollectAge ?? maxage;
const globalState = useGlobalStateObject();
const state = globalState.get(path, {
initialValue: newAsyncDataEnvelope()
});
let t2;
if ($[2] !== globalState || $[3] !== loader || $[4] !== path) {
t2 = {
globalState,
loader,
path
};
$[2] = globalState;
$[3] = loader;
$[4] = path;
$[5] = t2;
} else {
t2 = $[5];
}
const {
current: heap
} = useRef(t2);
let t3;
if ($[6] !== globalState || $[7] !== loader || $[8] !== path) {
t3 = () => {
heap.globalState = globalState;
heap.path = path;
heap.loader = loader;
};
$[6] = globalState;
$[7] = loader;
$[8] = path;
$[9] = t3;
} else {
t3 = $[9];
}
useEffect(t3);
let t4;
if ($[10] === Symbol.for("react.memo_cache_sentinel")) {
t4 = () => ({
reload: customLoader => {
const localLoader = customLoader ?? heap.loader;
return loadAsyncData(heap.path, localLoader, heap.globalState);
},
set: data => {
setState(data, heap.path, heap.globalState);
}
});
$[10] = t4;
} else {
t4 = $[10];
}
const [stable] = useState(t4);
if (globalState.ssrContext) {
if (!options.disabled && !options.noSSR && !state.operationId && !state.timestamp) {
const promiseOrVoid = loadAsyncData(path, loader, globalState, {
data: state.data,
timestamp: state.timestamp
}, `S${globalThis.crypto.randomUUID()}`);
if (promiseOrVoid instanceof Promise) {
globalState.ssrContext.pending.push(promiseOrVoid);
}
}
}
const {
disabled
} = options;
let t5;
let t6;
if ($[11] !== disabled || $[12] !== garbageCollectAge || $[13] !== globalState || $[14] !== path) {
t5 = () => {
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()) {
console.log(`ReactGlobalState - useAsyncData garbage collected at path ${path ?? ""}`);
}
globalState.dropDependencies(path ?? "");
globalState.set(path, {
...state2,
data: null,
numRefs: 0,
timestamp: 0
});
} else {
globalState.set(numRefsPath, state2.numRefs - 1);
}
}
};
};
t6 = [disabled, garbageCollectAge, globalState, path];
$[11] = disabled;
$[12] = garbageCollectAge;
$[13] = globalState;
$[14] = path;
$[15] = t5;
$[16] = t6;
} else {
t5 = $[15];
t6 = $[16];
}
useEffect(t5, t6);
let t7;
if ($[17] !== disabled || $[18] !== globalState || $[19] !== loader || $[20] !== options || $[21] !== path || $[22] !== refreshAge) {
t7 = () => {
if (!disabled) {
const state2_0 = globalState.get(path);
const {
deps
} = options;
if (deps && globalState.hasChangedDependencies(path ?? "", deps) || refreshAge < Date.now() - state2_0.timestamp && (!state2_0.operationId || state2_0.operationId.startsWith("S"))) {
if (!deps) {
globalState.dropDependencies(path ?? "");
}
loadAsyncData(path, loader, globalState, {
data: state2_0.data,
timestamp: state2_0.timestamp
});
}
}
};
$[17] = disabled;
$[18] = globalState;
$[19] = loader;
$[20] = options;
$[21] = path;
$[22] = refreshAge;
$[23] = t7;
} else {
t7 = $[23];
}
useEffect(t7);
let t8;
if ($[24] === Symbol.for("react.memo_cache_sentinel")) {
t8 = newAsyncDataEnvelope();
$[24] = t8;
} else {
t8 = $[24];
}
const [localState] = useGlobalState(path, t8);
let t9;
if ($[25] !== localState.timestamp || $[26] !== maxage) {
t9 = () => maxage < Date.now() - localState.timestamp;
$[25] = localState.timestamp;
$[26] = maxage;
$[27] = t9;
} else {
t9 = $[27];
}
const [stale, setStale] = useState(t9);
let t10;
if ($[28] !== localState.timestamp || $[29] !== maxage || $[30] !== stale) {
t10 = () => {
const nowStale = maxage < Date.now() - localState.timestamp;
const id = stale === nowStale ? null : requestAnimationFrame(() => {
setStale(nowStale);
});
return () => {
if (id !== null) {
cancelAnimationFrame(id);
}
};
};
$[28] = localState.timestamp;
$[29] = maxage;
$[30] = stale;
$[31] = t10;
} else {
t10 = $[31];
}
useEffect(t10);
const t11 = stale ? null : localState.data;
const t12 = !!localState.operationId;
let t13;
if ($[32] !== localState.timestamp || $[33] !== stable.reload || $[34] !== stable.set || $[35] !== t11 || $[36] !== t12) {
t13 = {
data: t11,
loading: t12,
reload: stable.reload,
set: stable.set,
timestamp: localState.timestamp
};
$[32] = localState.timestamp;
$[33] = stable.reload;
$[34] = stable.set;
$[35] = t11;
$[36] = t12;
$[37] = t13;
} else {
t13 = $[37];
}
return t13;
}
export { useAsyncData };
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
//# sourceMappingURL=useAsyncData.js.map