@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.
- package/build/common/GlobalState.js.map +1 -1
- package/build/common/index.js +12 -21
- package/build/common/index.js.map +1 -1
- package/build/common/useAsyncCollection.js +190 -44
- package/build/common/useAsyncCollection.js.map +1 -1
- package/build/common/useAsyncData.js +49 -59
- package/build/common/useAsyncData.js.map +1 -1
- package/build/module/GlobalState.js.map +1 -1
- package/build/module/index.js +1 -2
- package/build/module/index.js.map +1 -1
- package/build/module/useAsyncCollection.js +208 -45
- package/build/module/useAsyncCollection.js.map +1 -1
- package/build/module/useAsyncData.js +49 -60
- package/build/module/useAsyncData.js.map +1 -1
- package/build/types/index.d.ts +3 -4
- package/build/types/useAsyncCollection.d.ts +29 -46
- package/build/types/useAsyncData.d.ts +28 -46
- package/package.json +11 -11
- package/src/GlobalState.ts +5 -5
- package/src/index.ts +12 -2
- package/src/useAsyncCollection.ts +355 -64
- package/src/useAsyncData.ts +66 -76
- package/tsconfig.json +1 -0
|
@@ -1,114 +1,405 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loads and uses
|
|
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
|
-
|
|
18
|
+
DEFAULT_MAXAGE,
|
|
19
|
+
load,
|
|
20
|
+
newAsyncDataEnvelope,
|
|
10
21
|
} from './useAsyncData';
|
|
11
22
|
|
|
12
|
-
import
|
|
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
|
-
|
|
15
|
-
|
|
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.
|
|
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:
|
|
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<
|
|
85
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
<
|
|
109
|
-
|
|
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
|
}
|
package/src/useAsyncData.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
96
|
+
`ReactGlobalState: async data (re-)loading. Path: "${path || ''}"`,
|
|
92
97
|
);
|
|
93
98
|
/* eslint-enable no-console */
|
|
94
99
|
}
|
|
95
|
-
|
|
100
|
+
|
|
96
101
|
const operationIdPath = path ? `${path}.operationId` : 'operationId';
|
|
97
102
|
globalState.set<ForceT, string>(operationIdPath, operationId);
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
104
|
-
? await
|
|
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:
|
|
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.
|
|
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<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 (
|
|
316
|
-
|
|
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
|