@choksheak/react-utils 0.2.2 → 0.2.4
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/README.md +3 -1
- package/package.json +39 -33
- package/sharedQuery.d.mts +68 -8
- package/sharedQuery.d.ts +67 -7
- package/sharedQuery.js +240 -79
- package/sharedQuery.mjs +228 -77
- package/src/sharedQuery.ts +338 -103
- package/src/utils/useDeepMemo.ts +19 -0
- package/utils/useDeepMemo.d.mts +4 -0
- package/utils/useDeepMemo.d.ts +2 -0
- package/utils/useDeepMemo.js +51 -0
- package/utils/useDeepMemo.mjs +16 -0
- package/docs/.nojekyll +0 -1
- package/docs/assets/hierarchy.js +0 -1
- package/docs/assets/highlight.css +0 -22
- package/docs/assets/icons.js +0 -18
- package/docs/assets/icons.svg +0 -1
- package/docs/assets/main.js +0 -60
- package/docs/assets/navigation.js +0 -1
- package/docs/assets/search.js +0 -1
- package/docs/assets/style.css +0 -1633
- package/docs/classes/sharedQuery.SharedQuery.html +0 -17
- package/docs/classes/sharedState.SharedState.html +0 -9
- package/docs/classes/utils_storage.InMemoryStorageAdapter.html +0 -9
- package/docs/classes/utils_storage.IndexedDbAdapter.html +0 -9
- package/docs/classes/utils_storage.LocalStorageAdapter.html +0 -9
- package/docs/functions/sharedQuery.sharedQuery.html +0 -5
- package/docs/functions/sharedQuery.useSharedQuery.html +0 -4
- package/docs/functions/sharedState.sharedState.html +0 -3
- package/docs/functions/sharedState.useSharedState.html +0 -3
- package/docs/functions/sharedState.useSharedStateSetter.html +0 -2
- package/docs/functions/sharedState.useSharedStateValue.html +0 -2
- package/docs/functions/utils_storage.getStorageAdapter.html +0 -1
- package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +0 -7
- package/docs/hierarchy.html +0 -1
- package/docs/index.html +0 -2
- package/docs/modules/fetcher.html +0 -1
- package/docs/modules/index.html +0 -1
- package/docs/modules/sharedQuery.html +0 -1
- package/docs/modules/sharedState.html +0 -1
- package/docs/modules/utils_storage.html +0 -1
- package/docs/modules/utils_stringify.html +0 -1
- package/docs/modules.html +0 -1
- package/docs/types/sharedQuery.FetchFn.html +0 -1
- package/docs/types/sharedQuery.PersistTo.html +0 -1
- package/docs/types/sharedQuery.QueryStateValue.html +0 -6
- package/docs/types/sharedQuery.SharedQueryOptions.html +0 -1
- package/docs/types/sharedQuery.SharedQueryState.html +0 -3
- package/docs/types/sharedQuery.UseQueryResult.html +0 -1
- package/docs/types/sharedState.SharedStateOptions.html +0 -1
- package/docs/types/utils_storage.StorageAdapter.html +0 -5
- package/docs/types/utils_storage.StorageOptions.html +0 -5
- package/docs/variables/fetcher.fetcher.html +0 -2
- package/docs/variables/fetcher.fetcherWithRetries.html +0 -2
- package/docs/variables/sharedQuery.SharedQueryDefaults.html +0 -2
- package/docs/variables/sharedState.SharedStateDefaults.html +0 -6
- package/src/utils/stringify.test.ts +0 -46
- package/utils/stringify.test.d.mts +0 -2
- package/utils/stringify.test.d.ts +0 -2
- package/utils/stringify.test.js +0 -69
- package/utils/stringify.test.mjs +0 -86
package/src/sharedQuery.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
|
|
2
|
+
import getByteSize from "object-sizeof";
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
4
|
|
|
4
5
|
import {
|
|
@@ -8,22 +9,50 @@ import {
|
|
|
8
9
|
useSharedState,
|
|
9
10
|
} from "./sharedState";
|
|
10
11
|
import { stringifyDeterministicForKeys } from "./utils/stringify";
|
|
12
|
+
import { useDeepMemo } from "./utils/useDeepMemo";
|
|
11
13
|
|
|
12
|
-
export type
|
|
14
|
+
export type QueryFn<TArgs extends unknown[], TData> = (
|
|
13
15
|
...args: TArgs
|
|
14
16
|
) => Promise<TData> | TData;
|
|
15
17
|
|
|
16
18
|
export type QueryStateValue<TData> = {
|
|
19
|
+
/**
|
|
20
|
+
* When this record was last updated. Used for LRU determination. If you want
|
|
21
|
+
* the data age, please use `dataUpdatedMs` instead.
|
|
22
|
+
*/
|
|
23
|
+
lastUpdatedMs: number;
|
|
24
|
+
|
|
17
25
|
loading: boolean;
|
|
18
26
|
data?: TData;
|
|
19
27
|
dataUpdatedMs?: number;
|
|
20
|
-
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Use unknown type for error because we can't assume what type of variable
|
|
31
|
+
* will be thrown from the query function. We also don't want to add this
|
|
32
|
+
* type to QueryStateValue<TError> as it adds complexity to the type.
|
|
33
|
+
*/
|
|
34
|
+
error?: unknown;
|
|
35
|
+
|
|
21
36
|
errorUpdatedMs?: number;
|
|
22
37
|
};
|
|
23
38
|
|
|
24
39
|
export type UseQueryResult<TData> = QueryStateValue<TData> & {
|
|
25
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Cancels the inflight query (if any). Returns true if canceled.
|
|
42
|
+
*/
|
|
43
|
+
abortCurrentQuery: (reason?: unknown) => boolean;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Manually refresh the data by doing a new query.
|
|
47
|
+
*/
|
|
48
|
+
refetch: () => void;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* If you got the data from somewhere else (e.g. an in-memory data update),
|
|
52
|
+
* just set it directly.
|
|
53
|
+
*/
|
|
26
54
|
setData: (data: TData, dataUpdatedMs?: number) => void;
|
|
55
|
+
|
|
27
56
|
deleteData: () => void;
|
|
28
57
|
};
|
|
29
58
|
|
|
@@ -41,9 +70,9 @@ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
|
|
|
41
70
|
queryName: string;
|
|
42
71
|
|
|
43
72
|
// Function to fetch data.
|
|
44
|
-
queryFn:
|
|
73
|
+
queryFn: QueryFn<TArgs, TData>;
|
|
45
74
|
|
|
46
|
-
// ms before data considered stale; 0 means
|
|
75
|
+
// ms before data considered stale; 0 means always stale.
|
|
47
76
|
staleMs?: number;
|
|
48
77
|
|
|
49
78
|
// ms before data is removed from cache; 0 means never expire.
|
|
@@ -56,53 +85,89 @@ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
|
|
|
56
85
|
// queryName. This is important because it avoids requiring the user to
|
|
57
86
|
// specify the same queryName twice everytime.
|
|
58
87
|
persistTo?: PersistTo;
|
|
88
|
+
|
|
89
|
+
// Max number of entries to keep. The oldest entries will be discarded.
|
|
90
|
+
// The last record cannot be discarded. 0 means no limit.
|
|
91
|
+
maxSize?: number;
|
|
92
|
+
|
|
93
|
+
// Max number of bytes to keep. The oldest entries will be discarded.
|
|
94
|
+
// The last record cannot be discarded. 0 means no limit.
|
|
95
|
+
maxBytes?: number;
|
|
96
|
+
|
|
97
|
+
// Customize the logger.
|
|
98
|
+
log?: (...args: unknown[]) => void;
|
|
59
99
|
} & SharedStateOptions<SharedQueryState<TData>>;
|
|
60
100
|
|
|
61
101
|
/** Users can override these values globally. */
|
|
62
102
|
export const SharedQueryDefaults = {
|
|
103
|
+
// Refetch on every page load.
|
|
63
104
|
staleMs: 0,
|
|
105
|
+
|
|
106
|
+
// Keep in cache for a long time.
|
|
64
107
|
expiryMs: 30 * MS_PER_DAY,
|
|
108
|
+
|
|
109
|
+
// Always refetch automatically if the data is stale.
|
|
110
|
+
refetchOnStale: true,
|
|
111
|
+
|
|
112
|
+
// No default persistence configured.
|
|
65
113
|
persistTo: undefined as PersistTo | undefined,
|
|
114
|
+
|
|
115
|
+
// 100 is not too large, but please tweak accordingly.
|
|
116
|
+
maxSize: 100,
|
|
117
|
+
|
|
118
|
+
// 100kb limit before discarding old records.
|
|
119
|
+
maxBytes: 100_000,
|
|
120
|
+
|
|
121
|
+
// Default to log to console.
|
|
122
|
+
log: (...args: unknown[]) => console.log("[sharedQuery]", ...args),
|
|
66
123
|
};
|
|
67
124
|
|
|
68
125
|
export class SharedQuery<TArgs extends unknown[], TData> {
|
|
69
|
-
private readonly
|
|
126
|
+
private readonly inflightQueries = new Map<
|
|
127
|
+
string,
|
|
128
|
+
{ promise: Promise<TData>; abortController: AbortController }
|
|
129
|
+
>();
|
|
130
|
+
|
|
70
131
|
public readonly queryState: SharedState<SharedQueryState<TData>>;
|
|
71
132
|
|
|
72
133
|
public readonly queryName: string;
|
|
73
|
-
private readonly queryFn:
|
|
134
|
+
private readonly queryFn: QueryFn<TArgs, TData>;
|
|
74
135
|
public readonly expiryMs: number;
|
|
75
136
|
public readonly staleMs: number;
|
|
76
137
|
public readonly refetchOnStale: boolean;
|
|
138
|
+
public readonly maxSize: number;
|
|
139
|
+
public readonly maxBytes: number;
|
|
140
|
+
public readonly log: (...args: unknown[]) => void;
|
|
77
141
|
|
|
78
142
|
public constructor(options: SharedQueryOptions<TArgs, TData>) {
|
|
79
143
|
this.queryName = options.queryName;
|
|
80
|
-
|
|
81
144
|
this.queryFn = options.queryFn;
|
|
82
145
|
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
? options?.expiryMs
|
|
86
|
-
: SharedQueryDefaults.expiryMs;
|
|
146
|
+
this.staleMs = options?.staleMs ?? SharedQueryDefaults.staleMs;
|
|
147
|
+
this.expiryMs = options?.expiryMs ?? SharedQueryDefaults.expiryMs;
|
|
87
148
|
|
|
88
|
-
this.
|
|
89
|
-
options?.
|
|
90
|
-
? options?.staleMs
|
|
91
|
-
: SharedQueryDefaults.staleMs;
|
|
92
|
-
|
|
93
|
-
this.refetchOnStale = Boolean(options?.refetchOnStale);
|
|
149
|
+
this.refetchOnStale =
|
|
150
|
+
options?.refetchOnStale ?? SharedQueryDefaults.refetchOnStale;
|
|
94
151
|
|
|
95
152
|
// Apply shortcut when using `persistTo`.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
153
|
+
// Specifying the storage keys will take precedence over `persistTo`.
|
|
154
|
+
if (!options.localStorageKey && !options.indexedDbKey) {
|
|
155
|
+
const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
|
|
156
|
+
|
|
157
|
+
if (persistTo === "localStorage") {
|
|
158
|
+
options.localStorageKey = options.queryName;
|
|
159
|
+
} else if (persistTo === "indexedDb") {
|
|
160
|
+
options.indexedDbKey = options.queryName;
|
|
161
|
+
}
|
|
102
162
|
}
|
|
103
163
|
|
|
164
|
+
this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
|
|
165
|
+
this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
|
|
166
|
+
|
|
167
|
+
this.log = options?.log ?? SharedQueryDefaults.log;
|
|
168
|
+
|
|
104
169
|
this.queryState = sharedState<SharedQueryState<TData>>(
|
|
105
|
-
{},
|
|
170
|
+
{}, // defaultValue
|
|
106
171
|
{
|
|
107
172
|
...options,
|
|
108
173
|
// Fallback to use expiryMs for the storeExpiryMs.
|
|
@@ -111,10 +176,27 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
111
176
|
);
|
|
112
177
|
}
|
|
113
178
|
|
|
179
|
+
/** Returns a key to identify requests based on the given args. */
|
|
114
180
|
public getQueryKey(args: TArgs): string {
|
|
115
181
|
return this.queryName + ":" + stringifyDeterministicForKeys(args);
|
|
116
182
|
}
|
|
117
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Get the AbortController to abort the inflight query (if any).
|
|
186
|
+
*
|
|
187
|
+
* Note that once you get the controller, you can also get the signal easily
|
|
188
|
+
* using `controller.signal`. So if you need both the controller and the
|
|
189
|
+
* signal, just use this function.
|
|
190
|
+
*/
|
|
191
|
+
public getAbortController(queryKey: string): AbortController | null {
|
|
192
|
+
return this.inflightQueries.get(queryKey)?.abortController ?? null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Get the AbortSignal to check for query abortions. */
|
|
196
|
+
public getAbortSignal(queryKey: string): AbortSignal | null {
|
|
197
|
+
return this.getAbortController(queryKey)?.signal ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
118
200
|
private isStale(dataUpdatedMs: number): boolean {
|
|
119
201
|
if (this.staleMs === 0) return true; // always stale
|
|
120
202
|
|
|
@@ -136,20 +218,18 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
136
218
|
if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
|
|
137
219
|
// Return cached value if not stale.
|
|
138
220
|
if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
|
|
139
|
-
|
|
140
|
-
`Return fresh data ${queryKey} from cache without fetching`,
|
|
141
|
-
);
|
|
221
|
+
this.log(`Return fresh data ${queryKey} from cache without fetching`);
|
|
142
222
|
return cached.data;
|
|
143
223
|
}
|
|
144
224
|
|
|
145
225
|
// If stale, optionally update the data in the background.
|
|
146
226
|
if (this.refetchOnStale) {
|
|
147
|
-
|
|
227
|
+
this.log(`refetchOnStale for ${queryKey}`);
|
|
148
228
|
void this.dedupedFetch(queryKey, ...args);
|
|
149
229
|
}
|
|
150
230
|
|
|
151
231
|
// Still return stale data immediately.
|
|
152
|
-
|
|
232
|
+
this.log(`Returning stale data for ${queryKey}`);
|
|
153
233
|
return cached.data;
|
|
154
234
|
}
|
|
155
235
|
|
|
@@ -157,35 +237,37 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
157
237
|
}
|
|
158
238
|
|
|
159
239
|
private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {
|
|
160
|
-
const
|
|
240
|
+
const inflightQuery = this.inflightQueries.get(queryKey);
|
|
161
241
|
|
|
162
242
|
// De-duplicate in-flight requests
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
return
|
|
243
|
+
if (inflightQuery) {
|
|
244
|
+
this.log(`Deduplicating inflight fetch for ${queryKey}`);
|
|
245
|
+
return inflightQuery.promise;
|
|
166
246
|
}
|
|
167
247
|
|
|
168
248
|
// Fetch new data
|
|
169
249
|
const promise = this.startFetching(queryKey, args);
|
|
250
|
+
const abortController = new AbortController();
|
|
251
|
+
|
|
252
|
+
this.inflightQueries.set(queryKey, { promise, abortController });
|
|
170
253
|
|
|
171
|
-
this.inflightPromises.set(queryKey, promise);
|
|
172
254
|
return promise;
|
|
173
255
|
}
|
|
174
256
|
|
|
175
257
|
private async startFetching(queryKey: string, args: TArgs) {
|
|
176
|
-
|
|
258
|
+
this.log(`Start fetching ${queryKey}`);
|
|
177
259
|
|
|
178
260
|
try {
|
|
179
261
|
const data = await this.queryFn(...args);
|
|
180
262
|
|
|
181
|
-
|
|
263
|
+
this.log(`Successfully fetched ${queryKey}`);
|
|
182
264
|
this.setData(queryKey, data, Date.now());
|
|
183
265
|
return data;
|
|
184
266
|
} catch (e) {
|
|
185
|
-
|
|
267
|
+
this.log(`Failed to fetch ${queryKey}: ${e}`);
|
|
186
268
|
throw e;
|
|
187
269
|
} finally {
|
|
188
|
-
this.
|
|
270
|
+
this.inflightQueries.delete(queryKey);
|
|
189
271
|
}
|
|
190
272
|
}
|
|
191
273
|
|
|
@@ -196,12 +278,15 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
196
278
|
dataUpdatedMs = Date.now(),
|
|
197
279
|
): Promise<void> {
|
|
198
280
|
const entry: QueryStateValue<TData> = {
|
|
281
|
+
lastUpdatedMs: Date.now(),
|
|
199
282
|
loading: false,
|
|
200
283
|
data,
|
|
201
284
|
dataUpdatedMs,
|
|
202
285
|
};
|
|
203
286
|
|
|
204
287
|
this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
|
|
288
|
+
|
|
289
|
+
this.enforceSizeLimit();
|
|
205
290
|
}
|
|
206
291
|
|
|
207
292
|
/** Do a new fetch even when the data is already cached. */
|
|
@@ -211,21 +296,132 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
211
296
|
}
|
|
212
297
|
|
|
213
298
|
/** Delete the cached data for one set of arguments. */
|
|
214
|
-
public deleteData(
|
|
215
|
-
const queryKey = this.getQueryKey(args);
|
|
216
|
-
|
|
299
|
+
public deleteData(queryKey: string): void {
|
|
217
300
|
const record = this.queryState.getSnapshot();
|
|
218
301
|
const clone = { ...record };
|
|
219
302
|
delete clone[queryKey];
|
|
220
303
|
this.queryState.setValue(clone);
|
|
221
304
|
|
|
222
|
-
this.
|
|
305
|
+
const inflight = this.inflightQueries.get(queryKey);
|
|
306
|
+
if (inflight) {
|
|
307
|
+
this.inflightQueries.delete(queryKey);
|
|
308
|
+
inflight.abortController.abort("Deleted");
|
|
309
|
+
}
|
|
223
310
|
}
|
|
224
311
|
|
|
225
312
|
/** Delete all currently cached data & all inflight promises. */
|
|
226
313
|
public clear(): void {
|
|
227
314
|
this.queryState.delete();
|
|
228
|
-
|
|
315
|
+
|
|
316
|
+
for (const { abortController } of Array.from(
|
|
317
|
+
this.inflightQueries.values(),
|
|
318
|
+
)) {
|
|
319
|
+
abortController.abort("Cleared");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.inflightQueries.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Discard the oldest entries if the size exceeds the limit. The last
|
|
327
|
+
* remaining record cannot be deleted no matter what the limit is.
|
|
328
|
+
*/
|
|
329
|
+
public enforceSizeLimit(): void {
|
|
330
|
+
// Skip if there are no limits.
|
|
331
|
+
if (!this.maxSize && !this.maxBytes) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const oldState = this.queryState.getSnapshot();
|
|
336
|
+
const oldSize = Object.keys(oldState).length;
|
|
337
|
+
|
|
338
|
+
// Cannot do GC if size is 1 or less.
|
|
339
|
+
if (oldSize <= 1) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const oldByteSize = getByteSize(oldState);
|
|
344
|
+
|
|
345
|
+
let newState = oldState;
|
|
346
|
+
let needUpdate = false;
|
|
347
|
+
|
|
348
|
+
// Limit by number of records.
|
|
349
|
+
if (this.maxSize) {
|
|
350
|
+
const numToCut = oldSize - this.maxSize;
|
|
351
|
+
|
|
352
|
+
if (numToCut > 0) {
|
|
353
|
+
this.log(
|
|
354
|
+
`enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
newState = { ...newState }; // shallow clone
|
|
358
|
+
needUpdate = true;
|
|
359
|
+
|
|
360
|
+
Object.entries(newState)
|
|
361
|
+
.sort(
|
|
362
|
+
(entry1, entry2) =>
|
|
363
|
+
// Sort descending.
|
|
364
|
+
entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs,
|
|
365
|
+
)
|
|
366
|
+
.slice(-numToCut)
|
|
367
|
+
.forEach(([key]) => {
|
|
368
|
+
delete newState[key];
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Limit by byte size.
|
|
374
|
+
if (this.maxBytes && Object.keys(newState).length > 1) {
|
|
375
|
+
// Recompute only if changed by above code.
|
|
376
|
+
const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
|
|
377
|
+
|
|
378
|
+
let bytesToCut = currentByteSize - this.maxBytes;
|
|
379
|
+
|
|
380
|
+
if (bytesToCut > 0) {
|
|
381
|
+
this.log(
|
|
382
|
+
`enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Shallow clone only if not already cloned above.
|
|
386
|
+
if (!needUpdate) {
|
|
387
|
+
newState = { ...newState };
|
|
388
|
+
needUpdate = true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const entriesByTimeAscending = Object.entries(newState).sort(
|
|
392
|
+
(entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
for (
|
|
396
|
+
let i = 0;
|
|
397
|
+
// Leave the last record entriesByTimeAscending[len-1] untouched.
|
|
398
|
+
i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
|
|
399
|
+
i++
|
|
400
|
+
) {
|
|
401
|
+
const key = entriesByTimeAscending[i][0];
|
|
402
|
+
const thisSize = getByteSize(key) + getByteSize(newState[key]);
|
|
403
|
+
delete newState[key];
|
|
404
|
+
bytesToCut -= thisSize;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Update the state.
|
|
410
|
+
if (needUpdate) {
|
|
411
|
+
this.queryState.setValue(newState);
|
|
412
|
+
|
|
413
|
+
// Log to inform user that the data was trimmed.
|
|
414
|
+
this.log(
|
|
415
|
+
`Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Log to indicate why trimming was not needed.
|
|
422
|
+
this.log(
|
|
423
|
+
`No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,
|
|
424
|
+
);
|
|
229
425
|
}
|
|
230
426
|
}
|
|
231
427
|
|
|
@@ -237,6 +433,16 @@ const seenQueryNames = new Set<string>();
|
|
|
237
433
|
*
|
|
238
434
|
* Each `queryName` must be unique, and can only be declared once, most often
|
|
239
435
|
* in the top level scope. The queryName is used for logging only.
|
|
436
|
+
*
|
|
437
|
+
* Example:
|
|
438
|
+
* ```
|
|
439
|
+
* export const usersQuery = sharedQuery({
|
|
440
|
+
* queryName: "users",
|
|
441
|
+
* queryFn: listUsers,
|
|
442
|
+
* persistTo: "indexedDb",
|
|
443
|
+
* staleMs: MS_PER_DAY,
|
|
444
|
+
* });
|
|
445
|
+
* ```
|
|
240
446
|
*/
|
|
241
447
|
export function sharedQuery<TArgs extends unknown[], TData>(
|
|
242
448
|
// The name could have been auto-generated, but we let the user give us a
|
|
@@ -251,81 +457,106 @@ export function sharedQuery<TArgs extends unknown[], TData>(
|
|
|
251
457
|
return new SharedQuery(options);
|
|
252
458
|
}
|
|
253
459
|
|
|
254
|
-
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
460
|
+
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
461
|
+
lastUpdatedMs: 0,
|
|
462
|
+
loading: true,
|
|
463
|
+
};
|
|
255
464
|
|
|
256
465
|
/**
|
|
257
466
|
* React hook to make use of a shared query inside any React component.
|
|
258
467
|
*
|
|
259
|
-
*
|
|
468
|
+
* Example:
|
|
469
|
+
* ```
|
|
470
|
+
* const users = useSharedQuery(usersQuery, ["123"]);
|
|
471
|
+
* ```
|
|
260
472
|
*/
|
|
261
473
|
export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
262
474
|
query: SharedQuery<TArgs, TData>,
|
|
263
475
|
// Default to use no arguments.
|
|
264
476
|
args: TArgs = [] as unknown as TArgs,
|
|
265
477
|
): UseQueryResult<TData> {
|
|
478
|
+
// We don't expect users to provide a stable `args`, so stabilize it here.
|
|
479
|
+
const stableArgs = useDeepMemo(args);
|
|
480
|
+
|
|
266
481
|
const [queryState, setQueryState] = useSharedState(query.queryState);
|
|
267
482
|
|
|
268
483
|
const isMounted = useRef(true);
|
|
269
484
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
485
|
+
const queryKey = useMemo(
|
|
486
|
+
() => query.getQueryKey(stableArgs),
|
|
487
|
+
[query, stableArgs],
|
|
488
|
+
);
|
|
274
489
|
|
|
275
|
-
//
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
setQueryState((prev) => {
|
|
280
|
-
const clone = { ...prev };
|
|
281
|
-
clone[queryKey] = {
|
|
282
|
-
...(clone[queryKey] ?? {}),
|
|
283
|
-
loading: true,
|
|
284
|
-
};
|
|
285
|
-
return clone;
|
|
286
|
-
});
|
|
490
|
+
// Make sure typechecker knows this could be undefined.
|
|
491
|
+
const queryStateValue = queryState[queryKey] as
|
|
492
|
+
| QueryStateValue<TData>
|
|
493
|
+
| undefined;
|
|
287
494
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
495
|
+
// The fetch logic wrapped in useCallback to be stable for useEffect
|
|
496
|
+
const execute = useCallback(
|
|
497
|
+
async (forceRefresh: boolean) => {
|
|
498
|
+
query.log(`Begin executing shared query ${queryKey}`);
|
|
499
|
+
|
|
500
|
+
setQueryState((prev) => {
|
|
501
|
+
const clone = { ...prev };
|
|
502
|
+
|
|
503
|
+
clone[queryKey] = {
|
|
504
|
+
...(clone[queryKey] ?? {}),
|
|
505
|
+
lastUpdatedMs: Date.now(),
|
|
506
|
+
loading: true,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
return clone;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const data = await (forceRefresh
|
|
514
|
+
? query.updateFromSource(...stableArgs)
|
|
515
|
+
: query.getCachedOrFetch(...stableArgs));
|
|
516
|
+
|
|
517
|
+
if (isMounted.current) {
|
|
518
|
+
setQueryState((prev) => {
|
|
519
|
+
const clone = { ...prev };
|
|
520
|
+
const now = Date.now();
|
|
521
|
+
|
|
522
|
+
clone[queryKey] = {
|
|
523
|
+
// Don't keep old error.
|
|
524
|
+
lastUpdatedMs: now,
|
|
525
|
+
loading: false,
|
|
526
|
+
data,
|
|
527
|
+
dataUpdatedMs: now,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
return clone;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
} catch (e) {
|
|
534
|
+
if (isMounted.current) {
|
|
535
|
+
setQueryState((prev) => {
|
|
536
|
+
const clone = { ...prev };
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
|
|
539
|
+
clone[queryKey] = {
|
|
540
|
+
// Keep old data.
|
|
541
|
+
...(clone[queryKey] ?? {}),
|
|
542
|
+
lastUpdatedMs: now,
|
|
543
|
+
loading: false,
|
|
544
|
+
error: e,
|
|
545
|
+
errorUpdatedMs: now,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
return clone;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
321
551
|
}
|
|
322
|
-
}
|
|
323
|
-
|
|
552
|
+
},
|
|
553
|
+
[query, queryKey, setQueryState, stableArgs],
|
|
554
|
+
);
|
|
324
555
|
|
|
325
556
|
useEffect(() => {
|
|
326
557
|
isMounted.current = true;
|
|
327
558
|
|
|
328
|
-
void execute();
|
|
559
|
+
void execute(false);
|
|
329
560
|
|
|
330
561
|
return () => {
|
|
331
562
|
isMounted.current = false;
|
|
@@ -334,20 +565,24 @@ export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
|
334
565
|
|
|
335
566
|
return useMemo(() => {
|
|
336
567
|
const state =
|
|
337
|
-
|
|
338
|
-
(DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
|
|
568
|
+
queryStateValue ?? (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
|
|
339
569
|
|
|
340
570
|
return {
|
|
341
571
|
...state,
|
|
572
|
+
abortCurrentQuery: (reason?: unknown) => {
|
|
573
|
+
const controller = query.getAbortController(queryKey);
|
|
574
|
+
controller?.abort(reason);
|
|
575
|
+
return Boolean(controller);
|
|
576
|
+
},
|
|
342
577
|
refetch: () => {
|
|
343
|
-
|
|
578
|
+
void execute(true);
|
|
344
579
|
},
|
|
345
580
|
setData: (data: TData, dataUpdatedMs?: number) => {
|
|
346
581
|
query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
|
|
347
582
|
},
|
|
348
583
|
deleteData: () => {
|
|
349
|
-
query.deleteData(
|
|
584
|
+
query.deleteData(queryKey);
|
|
350
585
|
},
|
|
351
586
|
};
|
|
352
|
-
}, [query, queryKey,
|
|
587
|
+
}, [execute, query, queryKey, queryStateValue]);
|
|
353
588
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import isEqual from "lodash/isEqual";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** Provides a stable value for T based on deep equality. */
|
|
5
|
+
export function useDeepMemo<T>(value: T): T {
|
|
6
|
+
const [state, setState] = useState<T>(value);
|
|
7
|
+
|
|
8
|
+
return useMemo(() => {
|
|
9
|
+
if (state === value || isEqual(state, value)) {
|
|
10
|
+
return state;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// We check equality, so this should be safe.
|
|
14
|
+
// eslint-disable-next-line react-hooks/set-state-in-render
|
|
15
|
+
setState(value);
|
|
16
|
+
|
|
17
|
+
return value;
|
|
18
|
+
}, [state, value]);
|
|
19
|
+
}
|