@dr.pogodin/react-global-state 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,114 +1,405 @@
1
1
  /**
2
- * Loads and uses an item in an async collection.
2
+ * Loads and uses item(s) in an async collection.
3
3
  */
4
4
 
5
+ import { useEffect, useRef } from 'react';
6
+ import { v4 as uuid } from 'uuid';
7
+
8
+ import GlobalState from './GlobalState';
9
+ import { getGlobalState } from './GlobalStateProvider';
10
+
5
11
  import {
12
+ type AsyncDataEnvelopeT,
13
+ type AsyncDataReloaderT,
6
14
  type DataInEnvelopeAtPathT,
15
+ type OperationIdT,
7
16
  type UseAsyncDataOptionsT,
8
17
  type UseAsyncDataResT,
9
- useAsyncData,
18
+ DEFAULT_MAXAGE,
19
+ load,
20
+ newAsyncDataEnvelope,
10
21
  } from './useAsyncData';
11
22
 
12
- import { type ForceT, type LockT, type TypeLock } from './utils';
23
+ import useGlobalState from './useGlobalState';
24
+
25
+ import {
26
+ type ForceT,
27
+ type LockT,
28
+ type TypeLock,
29
+ isDebugMode,
30
+ } from './utils';
31
+
32
+ export type AsyncCollectionLoaderT<
33
+ DataT,
34
+ IdT extends number | string = number | string,
35
+ > =
36
+ (id: IdT, oldData: null | DataT, meta: {
37
+ isAborted: () => boolean;
38
+ oldDataTimestamp: number;
39
+ }) => DataT | Promise<DataT | null> | null;
40
+
41
+ export type AsyncCollectionReloaderT<
42
+ DataT,
43
+ IdT extends number | string = number | string,
44
+ >
45
+ = (loader?: AsyncCollectionLoaderT<DataT, IdT>) => Promise<void>;
46
+
47
+ type CollectionItemT<DataT> = {
48
+ data: DataT | null;
49
+ loading: boolean;
50
+ timestamp: number;
51
+ };
52
+
53
+ export type UseAsyncCollectionResT<
54
+ DataT,
55
+ IdT extends number | string = number | string,
56
+ > = {
57
+ items: {
58
+ [id in IdT]: CollectionItemT<DataT>;
59
+ }
60
+ loading: boolean;
61
+ reload: AsyncCollectionReloaderT<DataT, IdT>;
62
+ timestamp: number;
63
+ };
13
64
 
14
- export type AsyncCollectionLoaderT<DataT> =
15
- (id: string, oldData: null | DataT) => DataT | Promise<DataT>;
65
+ type HeapT<
66
+ DataT,
67
+ IdT extends number | string,
68
+ > = {
69
+ // Note: these heap fields are necessary to make reload() a stable function.
70
+ globalState?: GlobalState<unknown>;
71
+ ids?: IdT[];
72
+ path?: null | string;
73
+ loader?: AsyncCollectionLoaderT<DataT, IdT>;
74
+ reload?: AsyncCollectionReloaderT<DataT, IdT>;
75
+ reloadD?: AsyncDataReloaderT<DataT>;
76
+ };
16
77
 
17
78
  /**
18
- * Resolves and stores at the given `path` of global state elements of
19
- * an asynchronous data collection. In other words, it is an auxiliar wrapper
20
- * around {@link useAsyncData}, which uses a loader which resolves to different
21
- * data, based on ID argument passed in, and stores data fetched for different
22
- * IDs in the state.
23
- * @param id ID of the collection item to load & use.
24
- * @param path The global state path where entire collection should be
25
- * stored.
26
- * @param loader A loader function, which takes an
27
- * ID of data to load, and resolves to the corresponding data.
28
- * @param options Additional options.
29
- * @param options.deps An array of dependencies, which trigger
30
- * data reload when changed. Given dependency changes are watched shallowly
31
- * (similarly to the standard React's
32
- * [useEffect()](https://reactjs.org/docs/hooks-reference.html#useeffect)).
33
- * @param options.noSSR If `true`, this hook won't load data during
34
- * server-side rendering.
35
- * @param options.garbageCollectAge The maximum age of data
36
- * (in milliseconds), after which they are dropped from the state when the last
37
- * component referencing them via `useAsyncData()` hook unmounts. Defaults to
38
- * `maxage` option value.
39
- * @param options.maxage The maximum age of
40
- * data (in milliseconds) acceptable to the hook's caller. If loaded data are
41
- * older than this value, `null` is returned instead. Defaults to 5 minutes.
42
- * @param options.refreshAge The maximum age of data
43
- * (in milliseconds), after which their refreshment will be triggered when
44
- * any component referencing them via `useAsyncData()` hook (re-)renders.
45
- * Defaults to `maxage` value.
46
- * @return Returns an object with three fields: `data` holds the actual result of
47
- * last `loader` invokation, if any, and if satisfies `maxage` limit; `loading`
48
- * is a boolean flag, which is `true` if data are being loaded (the hook is
49
- * waiting for `loader` function resolution); `timestamp` (in milliseconds)
50
- * is Unix timestamp of related data currently loaded into the global state.
51
- *
52
- * Note that loaded data, if any, are stored at the given `path` of global state
53
- * along with related meta-information, using slightly different state segment
54
- * structure (see {@link AsyncDataEnvelope}). That segment of the global state
55
- * can be accessed, and even modified using other hooks,
56
- * _e.g._ {@link useGlobalState}, but doing so you may interfere with related
57
- * `useAsyncData()` hooks logic.
79
+ * Resolves and stores at the given `path` of the global state elements of
80
+ * an asynchronous data collection.
58
81
  */
59
82
 
60
83
  function useAsyncCollection<
61
84
  StateT,
62
85
  PathT extends null | string | undefined,
63
- IdT extends string,
86
+ IdT extends number | string,
64
87
 
65
88
  DataT extends DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`> =
66
89
  DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>,
67
90
  >(
68
91
  id: IdT,
69
92
  path: PathT,
70
- loader: AsyncCollectionLoaderT<DataT>,
93
+ loader: AsyncCollectionLoaderT<DataT, IdT>,
71
94
  options?: UseAsyncDataOptionsT,
72
95
  ): UseAsyncDataResT<DataT>;
73
96
 
74
97
  function useAsyncCollection<
75
98
  Forced extends ForceT | LockT = LockT,
76
99
  DataT = unknown,
100
+ IdT extends number | string = number | string,
77
101
  >(
78
- id: string,
102
+ id: IdT,
79
103
  path: null | string | undefined,
80
- loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>>,
104
+ loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>, IdT>,
81
105
  options?: UseAsyncDataOptionsT,
82
106
  ): UseAsyncDataResT<TypeLock<Forced, void, DataT>>;
83
107
 
84
- function useAsyncCollection<DataT>(
85
- id: string,
108
+ function useAsyncCollection<
109
+ StateT,
110
+ PathT extends null | string | undefined,
111
+ IdT extends number | string,
112
+
113
+ DataT extends DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`> =
114
+ DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>,
115
+ >(
116
+ id: IdT[],
117
+ path: PathT,
118
+ loader: AsyncCollectionLoaderT<DataT, IdT>,
119
+ options?: UseAsyncDataOptionsT,
120
+ ): UseAsyncCollectionResT<DataT, IdT>;
121
+
122
+ function useAsyncCollection<
123
+ Forced extends ForceT | LockT = LockT,
124
+ DataT = unknown,
125
+ IdT extends number | string = number | string,
126
+ >(
127
+ id: IdT[],
86
128
  path: null | string | undefined,
87
- loader: AsyncCollectionLoaderT<DataT>,
129
+ loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>, IdT>,
130
+ options?: UseAsyncDataOptionsT,
131
+ ): UseAsyncCollectionResT<DataT, IdT>;
132
+
133
+ // TODO: This is largely similar to useAsyncData() logic, just more generic.
134
+ // Perhaps, a bunch of logic blocks can be split into stand-alone functions,
135
+ // and reused in both hooks.
136
+ function useAsyncCollection<
137
+ DataT,
138
+ IdT extends number | string,
139
+ >(
140
+ idOrIds: IdT | IdT[],
141
+ path: null | string | undefined,
142
+ loader: AsyncCollectionLoaderT<DataT, IdT>,
88
143
  options: UseAsyncDataOptionsT = {},
89
- ): UseAsyncDataResT<DataT> {
90
- const itemPath = path ? `${path}.${id}` : id;
91
- return useAsyncData<ForceT, DataT>(
92
- itemPath,
93
- (oldData: null | DataT) => loader(id, oldData),
94
- options,
95
- );
144
+ ): UseAsyncDataResT<DataT> | UseAsyncCollectionResT<DataT, IdT> {
145
+ const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
146
+
147
+ const maxage: number = options.maxage ?? DEFAULT_MAXAGE;
148
+ const refreshAge: number = options.refreshAge ?? maxage;
149
+ const garbageCollectAge: number = options.garbageCollectAge ?? maxage;
150
+
151
+ // To avoid unnecessary work if consumer passes down the same IDs
152
+ // in an unstable order.
153
+ // TODO: Should we also filter out any duplicates? Or just assume consumer
154
+ // knows what he is doing, and won't place duplicates into IDs array?e
155
+ ids.sort();
156
+
157
+ const globalState = getGlobalState();
158
+
159
+ const { current: heap } = useRef<HeapT<DataT, IdT>>({});
160
+
161
+ heap.globalState = globalState;
162
+ heap.ids = ids;
163
+ heap.path = path;
164
+ heap.loader = loader;
165
+
166
+ if (!heap.reload) {
167
+ heap.reload = async (customLoader?: AsyncCollectionLoaderT<DataT, IdT>) => {
168
+ const localLoader = customLoader || heap.loader;
169
+ if (!localLoader || !heap.globalState || !heap.ids) {
170
+ throw Error('Internal error');
171
+ }
172
+
173
+ for (let i = 0; i < heap.ids.length; ++i) {
174
+ const id = heap.ids[i]!;
175
+ const itemPath = heap.path ? `${heap.path}.${id}` : `${id}`;
176
+
177
+ // eslint-disable-next-line no-await-in-loop
178
+ await load(
179
+ itemPath,
180
+ (oldData: DataT | null, meta) => localLoader(id, oldData, meta),
181
+ heap.globalState,
182
+ );
183
+ }
184
+ };
185
+ }
186
+
187
+ if (!Array.isArray(idOrIds)) {
188
+ heap.reloadD = (customLoader) => heap.reload!(
189
+ customLoader && ((id, ...args) => customLoader(...args)),
190
+ );
191
+ }
192
+
193
+ // Server-side logic.
194
+ if (globalState.ssrContext && !options.noSSR) {
195
+ const operationId: OperationIdT = `S${uuid()}`;
196
+ for (let i = 0; i < ids.length; ++i) {
197
+ const id = ids[i]!;
198
+ const itemPath = path ? `${path}.${id}` : `${id}`;
199
+ const state = globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(itemPath, {
200
+ initialValue: newAsyncDataEnvelope<DataT>(),
201
+ });
202
+ if (!state.timestamp && !state.operationId) {
203
+ globalState.ssrContext.pending.push(
204
+ load(itemPath, (...args) => loader(id, ...args), globalState, {
205
+ data: state.data,
206
+ timestamp: state.timestamp,
207
+ }, operationId),
208
+ );
209
+ }
210
+ }
211
+
212
+ // Client-side logic.
213
+ } else {
214
+ // Reference-counting & garbage collection.
215
+
216
+ // TODO: Violation of rules of hooks is fine here,
217
+ // but perhaps it can be refactored to avoid the need for it.
218
+ useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks
219
+ for (let i = 0; i < ids.length; ++i) {
220
+ const id = ids[i];
221
+ const itemPath = path ? `${path}.${id}` : `${id}`;
222
+ const state = globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(
223
+ itemPath,
224
+ { initialValue: newAsyncDataEnvelope() },
225
+ );
226
+
227
+ const numRefsPath = itemPath ? `${itemPath}.numRefs` : 'numRefs';
228
+ globalState.set<ForceT, number>(numRefsPath, state.numRefs + 1);
229
+ }
230
+
231
+ return () => {
232
+ for (let i = 0; i < ids.length; ++i) {
233
+ const id = ids[i];
234
+ const itemPath = path ? `${path}.${id}` : `${id}`;
235
+ const state2: AsyncDataEnvelopeT<DataT> = globalState.get<
236
+ ForceT, AsyncDataEnvelopeT<DataT>
237
+ >(itemPath);
238
+ if (
239
+ state2.numRefs === 1
240
+ && garbageCollectAge < Date.now() - state2.timestamp
241
+ ) {
242
+ if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
243
+ /* eslint-disable no-console */
244
+ console.log(
245
+ `ReactGlobalState - useAsyncCollection garbage collected at path ${
246
+ itemPath || ''
247
+ }`,
248
+ );
249
+ /* eslint-enable no-console */
250
+ }
251
+ globalState.dropDependencies(itemPath || '');
252
+ globalState.set<ForceT, AsyncDataEnvelopeT<DataT>>(itemPath, {
253
+ ...state2,
254
+ data: null,
255
+ numRefs: 0,
256
+ timestamp: 0,
257
+ });
258
+ } else {
259
+ const numRefsPath = itemPath ? `${itemPath}.numRefs` : 'numRefs';
260
+ globalState.set<ForceT, number>(numRefsPath, state2.numRefs - 1);
261
+ }
262
+ }
263
+ };
264
+ // eslint-disable-next-line react-hooks/exhaustive-deps
265
+ }, [garbageCollectAge, globalState, path, ...ids]);
266
+
267
+ // NOTE: a bunch of Rules of Hooks ignored belows because in our very
268
+ // special case the otherwise wrong behavior is actually what we need.
269
+
270
+ // Data loading and refreshing.
271
+ const loadTriggeredForIds = new Set<IdT>();
272
+ useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks
273
+ (async () => {
274
+ for (let i = 0; i < ids.length; ++i) {
275
+ const id = ids[i]!;
276
+ const itemPath = path ? `${path}.${id}` : `${id}`;
277
+ const state2: AsyncDataEnvelopeT<DataT> = globalState.get<
278
+ ForceT, AsyncDataEnvelopeT<DataT>>(itemPath);
279
+
280
+ const { deps } = options;
281
+ if (
282
+ (deps && globalState.hasChangedDependencies(itemPath, deps))
283
+ || (
284
+ refreshAge < Date.now() - state2.timestamp
285
+ && (!state2.operationId || state2.operationId.charAt(0) === 'S')
286
+ )
287
+ ) {
288
+ if (!deps) globalState.dropDependencies(itemPath);
289
+ loadTriggeredForIds.add(id);
290
+ // eslint-disable-next-line no-await-in-loop
291
+ await load(itemPath, (...args) => loader(id, ...args), globalState, {
292
+ data: state2.data,
293
+ timestamp: state2.timestamp,
294
+ });
295
+ }
296
+ }
297
+ })();
298
+ });
299
+
300
+ useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks
301
+ (async () => {
302
+ const { deps } = options;
303
+ for (let i = 0; i < ids.length; ++i) {
304
+ const id = ids[i]!;
305
+ const itemPath = path ? `${path}.${id}` : `${id}`;
306
+ if (
307
+ deps
308
+ && globalState.hasChangedDependencies(itemPath || '', deps)
309
+ && !loadTriggeredForIds.has(id)
310
+ ) {
311
+ // eslint-disable-next-line no-await-in-loop
312
+ await load(
313
+ itemPath,
314
+ (oldData: null | DataT, meta) => loader(id, oldData, meta),
315
+ globalState,
316
+ );
317
+ }
318
+ }
319
+ })();
320
+
321
+ // Here we need to default to empty array, so that this hook is re-evaluated
322
+ // only when dependencies specified in options change, and it should not be
323
+ // re-evaluated at all if no `deps` option is used.
324
+ }, options.deps || []); // eslint-disable-line react-hooks/exhaustive-deps
325
+ }
326
+
327
+ const [localState] = useGlobalState<
328
+ ForceT, { [id: string]: AsyncDataEnvelopeT<DataT> }
329
+ >(path, {});
330
+
331
+ if (!Array.isArray(idOrIds)) {
332
+ const e = localState[idOrIds];
333
+ const timestamp = e?.timestamp ?? 0;
334
+ return {
335
+ data: maxage < Date.now() - timestamp ? null : (e?.data ?? null),
336
+ loading: !!e?.operationId,
337
+ reload: heap.reloadD!,
338
+ timestamp,
339
+ };
340
+ }
341
+
342
+ const res: UseAsyncCollectionResT<DataT, IdT> = {
343
+ items: {} as Record<IdT, CollectionItemT<DataT>>,
344
+ loading: false,
345
+ reload: heap.reload,
346
+ timestamp: Number.MAX_VALUE,
347
+ };
348
+
349
+ for (let i = 0; i < ids.length; ++i) {
350
+ const id = ids[i]!;
351
+ const e = localState[id];
352
+ const loading = !!e?.operationId;
353
+ const timestamp = e?.timestamp ?? 0;
354
+
355
+ res.items[id] = {
356
+ data: maxage < Date.now() - timestamp ? null : (e?.data ?? null),
357
+ loading,
358
+ timestamp,
359
+ };
360
+ res.loading ||= loading;
361
+ if (res.timestamp > timestamp) res.timestamp = timestamp;
362
+ }
363
+
364
+ return res;
96
365
  }
97
366
 
98
367
  export default useAsyncCollection;
99
368
 
100
369
  export interface UseAsyncCollectionI<StateT> {
101
- <PathT extends null | string | undefined, IdT extends string>(
370
+ <PathT extends null | string | undefined, IdT extends number | string>(
102
371
  id: IdT,
103
372
  path: PathT,
104
- loader: AsyncCollectionLoaderT<DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>>,
373
+ loader: AsyncCollectionLoaderT<DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>, IdT>,
105
374
  options?: UseAsyncDataOptionsT,
106
375
  ): UseAsyncDataResT<DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>>;
107
376
 
108
- <Forced extends ForceT | LockT = LockT, DataT = unknown>(
109
- id: string,
377
+ <
378
+ Forced extends ForceT | LockT = LockT,
379
+ DataT = unknown,
380
+ IdT extends number | string = number | string,
381
+ >(
382
+ id: IdT,
110
383
  path: null | string | undefined,
111
- loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>>,
384
+ loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>, IdT>,
112
385
  options?: UseAsyncDataOptionsT,
113
386
  ): UseAsyncDataResT<TypeLock<Forced, void, DataT>>;
387
+
388
+ <PathT extends null | string | undefined, IdT extends number | string>(
389
+ id: IdT[],
390
+ path: PathT,
391
+ loader: AsyncCollectionLoaderT<DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>, IdT>,
392
+ options?: UseAsyncDataOptionsT,
393
+ ): UseAsyncCollectionResT<DataInEnvelopeAtPathT<StateT, `${PathT}.${IdT}`>, IdT>;
394
+
395
+ <
396
+ Forced extends ForceT | LockT = LockT,
397
+ DataT = unknown,
398
+ IdT extends number | string = number | string,
399
+ >(
400
+ id: IdT[],
401
+ path: null | string | undefined,
402
+ loader: AsyncCollectionLoaderT<TypeLock<Forced, void, DataT>, IdT>,
403
+ options?: UseAsyncDataOptionsT,
404
+ ): UseAsyncCollectionResT<DataT, IdT>;
114
405
  }
@@ -22,10 +22,13 @@ import {
22
22
  import GlobalState from './GlobalState';
23
23
  import SsrContext from './SsrContext';
24
24
 
25
- const DEFAULT_MAXAGE = 5 * MIN_MS; // 5 minutes.
25
+ export const DEFAULT_MAXAGE = 5 * MIN_MS; // 5 minutes.
26
26
 
27
27
  export type AsyncDataLoaderT<DataT>
28
- = (oldData: null | DataT) => DataT | Promise<DataT>;
28
+ = (oldData: null | DataT, meta: {
29
+ isAborted: () => boolean;
30
+ oldDataTimestamp: number;
31
+ }) => DataT | Promise<DataT | null> | null;
29
32
 
30
33
  export type AsyncDataReloaderT<DataT>
31
34
  = (loader?: AsyncDataLoaderT<DataT>) => Promise<void>;
@@ -37,6 +40,8 @@ export type AsyncDataEnvelopeT<DataT> = {
37
40
  timestamp: number;
38
41
  };
39
42
 
43
+ export type OperationIdT = `${'C' | 'S'}${string}`;
44
+
40
45
  export function newAsyncDataEnvelope<DataT>(
41
46
  initialData: DataT | null = null,
42
47
  { numRefs = 0, timestamp = 0 } = {},
@@ -78,37 +83,49 @@ export type UseAsyncDataResT<DataT> = {
78
83
  * @return Resolves once the operation is done.
79
84
  * @ignore
80
85
  */
81
- async function load<DataT>(
86
+ export async function load<DataT>(
82
87
  path: null | string | undefined,
83
88
  loader: AsyncDataLoaderT<DataT>,
84
89
  globalState: GlobalState<unknown, SsrContext<unknown>>,
85
- oldData: DataT | null,
86
- opIdPrefix: 'C' | 'S' = 'C',
90
+ old?: { data: DataT | null, timestamp: number },
91
+ operationId: OperationIdT = `C${uuid()}`,
87
92
  ): Promise<void> {
88
93
  if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
89
94
  /* eslint-disable no-console */
90
95
  console.log(
91
- `ReactGlobalState: useAsyncData data (re-)loading. Path: "${path || ''}"`,
96
+ `ReactGlobalState: async data (re-)loading. Path: "${path || ''}"`,
92
97
  );
93
98
  /* eslint-enable no-console */
94
99
  }
95
- const operationId = opIdPrefix + uuid();
100
+
96
101
  const operationIdPath = path ? `${path}.operationId` : 'operationId';
97
102
  globalState.set<ForceT, string>(operationIdPath, operationId);
98
103
 
99
- const dataOrLoader = loader(
100
- oldData || (globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(path)).data,
101
- );
104
+ let definedOld = old;
105
+ if (!definedOld) {
106
+ // TODO: Can we improve the typing, to avoid ForceT?
107
+ const e = globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(path);
108
+ definedOld = { data: e.data, timestamp: e.timestamp };
109
+ }
110
+
111
+ const dataOrPromise = loader(definedOld.data, {
112
+ isAborted: () => {
113
+ // TODO: Can we improve the typing, to avoid ForceT?
114
+ const opid = globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(path).operationId;
115
+ return opid !== operationId;
116
+ },
117
+ oldDataTimestamp: definedOld.timestamp,
118
+ });
102
119
 
103
- const data: DataT = dataOrLoader instanceof Promise
104
- ? await dataOrLoader : dataOrLoader;
120
+ const data: DataT | null = dataOrPromise instanceof Promise
121
+ ? await dataOrPromise : dataOrPromise;
105
122
 
106
123
  const state: AsyncDataEnvelopeT<DataT> = globalState.get<ForceT, AsyncDataEnvelopeT<DataT>>(path);
107
124
  if (operationId === state.operationId) {
108
125
  if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
109
126
  /* eslint-disable no-console */
110
127
  console.groupCollapsed(
111
- `ReactGlobalState: useAsyncData data (re-)loaded. Path: "${
128
+ `ReactGlobalState: async data (re-)loaded. Path: "${
112
129
  path || ''
113
130
  }"`,
114
131
  );
@@ -131,56 +148,13 @@ async function load<DataT>(
131
148
 
132
149
  /**
133
150
  * Resolves asynchronous data, and stores them at given `path` of global
134
- * state. When multiple components rely on asynchronous data at the same `path`,
135
- * the data are resolved once, and reused until their age is within specified
136
- * bounds. Once the data are stale, the hook allows to refresh them. It also
137
- * garbage-collects stale data from the global state when the last component
138
- * relying on them is unmounted.
139
- * @param path Dot-delimitered state path, where data envelop is
140
- * stored.
141
- * @param loader Asynchronous function which resolves (loads)
142
- * data, which should be stored at the global state `path`. When multiple
143
- * components
144
- * use `useAsyncData()` hook for the same `path`, the library assumes that all
145
- * hook instances are called with the same `loader` (_i.e._ whichever of these
146
- * loaders is used to resolve async data, the result is acceptable to be reused
147
- * in all related components).
148
- * @param options Additional options.
149
- * @param options.deps An array of dependencies, which trigger
150
- * data reload when changed. Given dependency changes are watched shallowly
151
- * (similarly to the standard React's
152
- * [useEffect()](https://reactjs.org/docs/hooks-reference.html#useeffect)).
153
- * @param options.noSSR If `true`, this hook won't load data during
154
- * server-side rendering.
155
- * @param options.garbageCollectAge The maximum age of data
156
- * (in milliseconds), after which they are dropped from the state when the last
157
- * component referencing them via `useAsyncData()` hook unmounts. Defaults to
158
- * `maxage` option value.
159
- * @param options.maxage The maximum age of
160
- * data (in milliseconds) acceptable to the hook's caller. If loaded data are
161
- * older than this value, `null` is returned instead. Defaults to 5 minutes.
162
- * @param options.refreshAge The maximum age of data
163
- * (in milliseconds), after which their refreshment will be triggered when
164
- * any component referencing them via `useAsyncData()` hook (re-)renders.
165
- * Defaults to `maxage` value.
166
- * @return Returns an object with three fields: `data` holds the actual result of
167
- * last `loader` invokation, if any, and if satisfies `maxage` limit; `loading`
168
- * is a boolean flag, which is `true` if data are being loaded (the hook is
169
- * waiting for `loader` function resolution); `timestamp` (in milliseconds)
170
- * is Unix timestamp of related data currently loaded into the global state.
171
- *
172
- * Note that loaded data, if any, are stored at the given `path` of global state
173
- * along with related meta-information, using slightly different state segment
174
- * structure (see {@link AsyncDataEnvelopeT}). That segment of the global state
175
- * can be accessed, and even modified using other hooks,
176
- * _e.g._ {@link useGlobalState}, but doing so you may interfere with related
177
- * `useAsyncData()` hooks logic.
151
+ * state.
178
152
  */
179
153
 
180
- export type DataInEnvelopeAtPathT<StateT, PathT extends null | string | undefined>
181
- = ValueAtPathT<StateT, PathT, never> extends AsyncDataEnvelopeT<unknown>
182
- ? Exclude<ValueAtPathT<StateT, PathT, never>['data'], null>
183
- : void;
154
+ export type DataInEnvelopeAtPathT<
155
+ StateT,
156
+ PathT extends null | string | undefined,
157
+ > = Exclude<Extract<ValueAtPathT<StateT, PathT, void>, AsyncDataEnvelopeT<unknown>>['data'], null>;
184
158
 
185
159
  type HeapT<DataT> = {
186
160
  // Note: these heap fields are necessary to make reload() a stable function.
@@ -216,14 +190,9 @@ function useAsyncData<DataT>(
216
190
  loader: AsyncDataLoaderT<DataT>,
217
191
  options: UseAsyncDataOptionsT = {},
218
192
  ): UseAsyncDataResT<DataT> {
219
- const maxage: number = options.maxage === undefined
220
- ? DEFAULT_MAXAGE : options.maxage;
221
-
222
- const refreshAge: number = options.refreshAge === undefined
223
- ? maxage : options.refreshAge;
224
-
225
- const garbageCollectAge: number = options.garbageCollectAge === undefined
226
- ? maxage : options.garbageCollectAge;
193
+ const maxage: number = options.maxage ?? DEFAULT_MAXAGE;
194
+ const refreshAge: number = options.refreshAge ?? maxage;
195
+ const garbageCollectAge: number = options.garbageCollectAge ?? maxage;
227
196
 
228
197
  // Note: here we can't depend on useGlobalState() to init the initial value,
229
198
  // because that way we'll have issues with SSR (see details below).
@@ -241,14 +210,17 @@ function useAsyncData<DataT>(
241
210
  heap.reload = (customLoader?: AsyncDataLoaderT<DataT>) => {
242
211
  const localLoader = customLoader || heap.loader;
243
212
  if (!localLoader || !heap.globalState) throw Error('Internal error');
244
- return load(heap.path, localLoader, heap.globalState, null);
213
+ return load(heap.path, localLoader, heap.globalState);
245
214
  };
246
215
  }
247
216
 
248
217
  if (globalState.ssrContext && !options.noSSR) {
249
218
  if (!state.timestamp && !state.operationId) {
250
219
  globalState.ssrContext.pending.push(
251
- load(path, loader, globalState, state.data, 'S'),
220
+ load(path, loader, globalState, {
221
+ data: state.data,
222
+ timestamp: state.timestamp,
223
+ }, `S${uuid()}`),
252
224
  );
253
225
  }
254
226
  } else {
@@ -303,17 +275,35 @@ function useAsyncData<DataT>(
303
275
  const state2: AsyncDataEnvelopeT<DataT> = globalState.get<
304
276
  ForceT, AsyncDataEnvelopeT<DataT>>(path);
305
277
 
306
- if (refreshAge < Date.now() - state2.timestamp
307
- && (!state2.operationId || state2.operationId.charAt(0) === 'S')) {
308
- load(path, loader, globalState, state2.data);
278
+ const { deps } = options;
279
+ if (
280
+ // The hook is called with a list of dependencies, that mismatch
281
+ // dependencies last used to retrieve the data at given path.
282
+ (deps && globalState.hasChangedDependencies(path || '', deps))
283
+
284
+ // Data at the path are stale, and are not being loaded.
285
+ || (
286
+ refreshAge < Date.now() - state2.timestamp
287
+ && (!state2.operationId || state2.operationId.charAt(0) === 'S')
288
+ )
289
+ ) {
309
290
  loadTriggered = true; // eslint-disable-line react-hooks/exhaustive-deps
291
+ if (!deps) globalState.dropDependencies(path || '');
292
+ load(path, loader, globalState, {
293
+ data: state2.data,
294
+ timestamp: state2.timestamp,
295
+ });
310
296
  }
311
297
  });
312
298
 
313
299
  useEffect(() => { // eslint-disable-line react-hooks/rules-of-hooks
314
300
  const { deps } = options;
315
- if (deps && globalState.hasChangedDependencies(path || '', deps) && !loadTriggered) {
316
- load(path, loader, globalState, null);
301
+ if (
302
+ deps
303
+ && globalState.hasChangedDependencies(path || '', deps)
304
+ && !loadTriggered
305
+ ) {
306
+ load(path, loader, globalState);
317
307
  }
318
308
 
319
309
  // Here we need to default to empty array, so that this hook is re-evaluated