@choksheak/react-utils 0.2.1 → 0.2.3
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/docs/assets/highlight.css +49 -0
- package/docs/assets/search.js +1 -1
- package/docs/classes/sharedQuery.SharedQuery.html +11 -6
- package/docs/classes/sharedState.SharedState.html +2 -2
- package/docs/classes/utils_storage.InMemoryStorageAdapter.html +2 -2
- package/docs/classes/utils_storage.IndexedDbAdapter.html +2 -2
- package/docs/classes/utils_storage.LocalStorageAdapter.html +2 -2
- package/docs/functions/sharedQuery.sharedQuery.html +5 -1
- package/docs/functions/sharedQuery.useSharedQuery.html +5 -3
- package/docs/functions/sharedState.sharedState.html +1 -1
- package/docs/functions/sharedState.useSharedState.html +1 -1
- package/docs/functions/sharedState.useSharedStateSetter.html +1 -1
- package/docs/functions/sharedState.useSharedStateValue.html +1 -1
- package/docs/functions/utils_storage.getStorageAdapter.html +1 -1
- package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +1 -1
- package/docs/types/sharedQuery.FetchFn.html +1 -1
- package/docs/types/sharedQuery.PersistTo.html +1 -1
- package/docs/types/sharedQuery.QueryStateValue.html +5 -2
- package/docs/types/sharedQuery.SharedQueryOptions.html +1 -1
- package/docs/types/sharedQuery.SharedQueryState.html +1 -1
- package/docs/types/sharedQuery.UseQueryResult.html +1 -1
- package/docs/types/sharedState.SharedStateOptions.html +1 -1
- package/docs/types/utils_storage.StorageAdapter.html +2 -2
- package/docs/types/utils_storage.StorageOptions.html +2 -2
- package/docs/variables/fetcher.fetcher.html +1 -1
- package/docs/variables/fetcher.fetcherWithRetries.html +1 -1
- package/docs/variables/sharedQuery.SharedQueryDefaults.html +2 -2
- package/docs/variables/sharedState.SharedStateDefaults.html +1 -1
- package/package.json +33 -32
- package/sharedQuery.d.mts +30 -1
- package/sharedQuery.d.ts +30 -1
- package/sharedQuery.js +106 -5
- package/sharedQuery.mjs +96 -5
- package/src/sharedQuery.ts +159 -5
- package/src/utils/stringify.ts +1 -1
- package/utils/stringify.js +1 -1
- package/utils/stringify.mjs +1 -1
- package/src/utils/stringify.test.ts +0 -38
- package/utils/stringify.test.d.mts +0 -2
- package/utils/stringify.test.d.ts +0 -2
- package/utils/stringify.test.js +0 -64
- package/utils/stringify.test.mjs +0 -81
package/sharedQuery.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/sharedQuery.ts
|
|
2
2
|
import { MS_PER_DAY as MS_PER_DAY2 } from "@choksheak/ts-utils/timeConstants";
|
|
3
|
+
import getByteSize from "object-sizeof";
|
|
3
4
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
4
5
|
|
|
5
6
|
// src/sharedState.ts
|
|
@@ -241,7 +242,7 @@ function stringifyDeterministicForKeys(params) {
|
|
|
241
242
|
if (typeof value === "function") {
|
|
242
243
|
throw new Error(`Functions should not be passed into query parameters`);
|
|
243
244
|
}
|
|
244
|
-
if (!value || typeof value !== "object") {
|
|
245
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
245
246
|
return value;
|
|
246
247
|
}
|
|
247
248
|
if (cache.has(value)) {
|
|
@@ -269,7 +270,11 @@ function stringifyDeterministicForKeys(params) {
|
|
|
269
270
|
var SharedQueryDefaults = {
|
|
270
271
|
staleMs: 0,
|
|
271
272
|
expiryMs: 30 * MS_PER_DAY2,
|
|
272
|
-
persistTo: void 0
|
|
273
|
+
persistTo: void 0,
|
|
274
|
+
maxSize: 100,
|
|
275
|
+
// 100 is not too large, but please tweak accordingly
|
|
276
|
+
maxBytes: 1e5
|
|
277
|
+
// 100kb before GC
|
|
273
278
|
};
|
|
274
279
|
var SharedQuery = class {
|
|
275
280
|
inflightPromises = /* @__PURE__ */ new Map();
|
|
@@ -279,6 +284,8 @@ var SharedQuery = class {
|
|
|
279
284
|
expiryMs;
|
|
280
285
|
staleMs;
|
|
281
286
|
refetchOnStale;
|
|
287
|
+
maxSize;
|
|
288
|
+
maxBytes;
|
|
282
289
|
constructor(options) {
|
|
283
290
|
this.queryName = options.queryName;
|
|
284
291
|
this.queryFn = options.queryFn;
|
|
@@ -291,6 +298,8 @@ var SharedQuery = class {
|
|
|
291
298
|
} else if (persistTo === "indexedDb") {
|
|
292
299
|
options.indexedDbKey = options.queryName;
|
|
293
300
|
}
|
|
301
|
+
this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
|
|
302
|
+
this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
|
|
294
303
|
this.queryState = sharedState(
|
|
295
304
|
{},
|
|
296
305
|
{
|
|
@@ -359,11 +368,13 @@ var SharedQuery = class {
|
|
|
359
368
|
/** Set the data directly if the user obtained it from somewhere else. */
|
|
360
369
|
async setData(queryKey, data, dataUpdatedMs = Date.now()) {
|
|
361
370
|
const entry = {
|
|
371
|
+
lastUpdatedMs: Date.now(),
|
|
362
372
|
loading: false,
|
|
363
373
|
data,
|
|
364
374
|
dataUpdatedMs
|
|
365
375
|
};
|
|
366
376
|
this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
|
|
377
|
+
this.enforceSizeLimit();
|
|
367
378
|
}
|
|
368
379
|
/** Do a new fetch even when the data is already cached. */
|
|
369
380
|
async updateFromSource(...args) {
|
|
@@ -384,6 +395,78 @@ var SharedQuery = class {
|
|
|
384
395
|
this.queryState.delete();
|
|
385
396
|
this.inflightPromises.clear();
|
|
386
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Discard the oldest entries if the size exceeds the limit. The last
|
|
400
|
+
* remaining record cannot be deleted no matter what the limit is.
|
|
401
|
+
*/
|
|
402
|
+
enforceSizeLimit() {
|
|
403
|
+
if (!this.maxSize && !this.maxBytes) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const oldState = this.queryState.getSnapshot();
|
|
407
|
+
const oldSize = Object.keys(oldState).length;
|
|
408
|
+
if (oldSize <= 1) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const oldByteSize = getByteSize(oldState);
|
|
412
|
+
let newState = oldState;
|
|
413
|
+
let needUpdate = false;
|
|
414
|
+
if (this.maxSize) {
|
|
415
|
+
const numToCut = oldSize - this.maxSize;
|
|
416
|
+
if (numToCut > 0) {
|
|
417
|
+
console.log(
|
|
418
|
+
`enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`
|
|
419
|
+
);
|
|
420
|
+
newState = { ...newState };
|
|
421
|
+
needUpdate = true;
|
|
422
|
+
Object.entries(newState).sort(
|
|
423
|
+
(entry1, entry2) => (
|
|
424
|
+
// Sort descending.
|
|
425
|
+
entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs
|
|
426
|
+
)
|
|
427
|
+
).slice(-numToCut).forEach(([key]) => {
|
|
428
|
+
delete newState[key];
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (this.maxBytes && Object.keys(newState).length > 1) {
|
|
433
|
+
const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
|
|
434
|
+
let bytesToCut = currentByteSize - this.maxBytes;
|
|
435
|
+
if (bytesToCut > 0) {
|
|
436
|
+
console.log(
|
|
437
|
+
`enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`
|
|
438
|
+
);
|
|
439
|
+
if (!needUpdate) {
|
|
440
|
+
newState = { ...newState };
|
|
441
|
+
needUpdate = true;
|
|
442
|
+
}
|
|
443
|
+
const entriesByTimeAscending = Object.entries(newState).sort(
|
|
444
|
+
(entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
|
|
445
|
+
);
|
|
446
|
+
for (
|
|
447
|
+
let i = 0;
|
|
448
|
+
// Leave the last record entriesByTimeAscending[len-1] untouched.
|
|
449
|
+
i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
|
|
450
|
+
i++
|
|
451
|
+
) {
|
|
452
|
+
const key = entriesByTimeAscending[i][0];
|
|
453
|
+
const thisSize = getByteSize(key) + getByteSize(newState[key]);
|
|
454
|
+
delete newState[key];
|
|
455
|
+
bytesToCut -= thisSize;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (needUpdate) {
|
|
460
|
+
this.queryState.setValue(newState);
|
|
461
|
+
console.log(
|
|
462
|
+
`Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`
|
|
463
|
+
);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
console.log(
|
|
467
|
+
`No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
387
470
|
};
|
|
388
471
|
var seenQueryNames = /* @__PURE__ */ new Set();
|
|
389
472
|
function sharedQuery(options) {
|
|
@@ -393,7 +476,10 @@ function sharedQuery(options) {
|
|
|
393
476
|
seenQueryNames.add(options.queryName);
|
|
394
477
|
return new SharedQuery(options);
|
|
395
478
|
}
|
|
396
|
-
var DEFAULT_QUERY_STATE_ENTRY = {
|
|
479
|
+
var DEFAULT_QUERY_STATE_ENTRY = {
|
|
480
|
+
lastUpdatedMs: 0,
|
|
481
|
+
loading: true
|
|
482
|
+
};
|
|
397
483
|
function useSharedQuery(query, args = []) {
|
|
398
484
|
const [queryState, setQueryState] = useSharedState(query.queryState);
|
|
399
485
|
const isMounted = useRef(true);
|
|
@@ -404,6 +490,7 @@ function useSharedQuery(query, args = []) {
|
|
|
404
490
|
const clone = { ...prev };
|
|
405
491
|
clone[queryKey] = {
|
|
406
492
|
...clone[queryKey] ?? {},
|
|
493
|
+
lastUpdatedMs: Date.now(),
|
|
407
494
|
loading: true
|
|
408
495
|
};
|
|
409
496
|
return clone;
|
|
@@ -413,11 +500,13 @@ function useSharedQuery(query, args = []) {
|
|
|
413
500
|
if (isMounted.current) {
|
|
414
501
|
setQueryState((prev) => {
|
|
415
502
|
const clone = { ...prev };
|
|
503
|
+
const now = Date.now();
|
|
416
504
|
clone[queryKey] = {
|
|
417
505
|
// Don't keep old error.
|
|
506
|
+
lastUpdatedMs: now,
|
|
418
507
|
loading: false,
|
|
419
508
|
data,
|
|
420
|
-
dataUpdatedMs:
|
|
509
|
+
dataUpdatedMs: now
|
|
421
510
|
};
|
|
422
511
|
return clone;
|
|
423
512
|
});
|
|
@@ -427,12 +516,14 @@ function useSharedQuery(query, args = []) {
|
|
|
427
516
|
if (isMounted.current) {
|
|
428
517
|
setQueryState((prev) => {
|
|
429
518
|
const clone = { ...prev };
|
|
519
|
+
const now = Date.now();
|
|
430
520
|
clone[queryKey] = {
|
|
431
521
|
// Keep old data.
|
|
432
522
|
...clone[queryKey] ?? {},
|
|
523
|
+
lastUpdatedMs: now,
|
|
433
524
|
loading: false,
|
|
434
525
|
error,
|
|
435
|
-
errorUpdatedMs:
|
|
526
|
+
errorUpdatedMs: now
|
|
436
527
|
};
|
|
437
528
|
return clone;
|
|
438
529
|
});
|
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 {
|
|
@@ -14,6 +15,12 @@ export type FetchFn<TArgs extends unknown[], TData> = (
|
|
|
14
15
|
) => Promise<TData> | TData;
|
|
15
16
|
|
|
16
17
|
export type QueryStateValue<TData> = {
|
|
18
|
+
/**
|
|
19
|
+
* When this record was last updated. Used for LRU determination. If you want
|
|
20
|
+
* the data age, please use `dataUpdatedMs` instead.
|
|
21
|
+
*/
|
|
22
|
+
lastUpdatedMs: number;
|
|
23
|
+
|
|
17
24
|
loading: boolean;
|
|
18
25
|
data?: TData;
|
|
19
26
|
dataUpdatedMs?: number;
|
|
@@ -43,7 +50,7 @@ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
|
|
|
43
50
|
// Function to fetch data.
|
|
44
51
|
queryFn: FetchFn<TArgs, TData>;
|
|
45
52
|
|
|
46
|
-
// ms before data considered stale; 0 means
|
|
53
|
+
// ms before data considered stale; 0 means always stale.
|
|
47
54
|
staleMs?: number;
|
|
48
55
|
|
|
49
56
|
// ms before data is removed from cache; 0 means never expire.
|
|
@@ -56,6 +63,14 @@ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
|
|
|
56
63
|
// queryName. This is important because it avoids requiring the user to
|
|
57
64
|
// specify the same queryName twice everytime.
|
|
58
65
|
persistTo?: PersistTo;
|
|
66
|
+
|
|
67
|
+
// Max number of entries to keep. The oldest entries will be discarded.
|
|
68
|
+
// The last record cannot be discarded. 0 means no limit.
|
|
69
|
+
maxSize?: number;
|
|
70
|
+
|
|
71
|
+
// Max number of bytes to keep. The oldest entries will be discarded.
|
|
72
|
+
// The last record cannot be discarded. 0 means no limit.
|
|
73
|
+
maxBytes?: number;
|
|
59
74
|
} & SharedStateOptions<SharedQueryState<TData>>;
|
|
60
75
|
|
|
61
76
|
/** Users can override these values globally. */
|
|
@@ -63,6 +78,8 @@ export const SharedQueryDefaults = {
|
|
|
63
78
|
staleMs: 0,
|
|
64
79
|
expiryMs: 30 * MS_PER_DAY,
|
|
65
80
|
persistTo: undefined as PersistTo | undefined,
|
|
81
|
+
maxSize: 100, // 100 is not too large, but please tweak accordingly
|
|
82
|
+
maxBytes: 100_000, // 100kb before GC
|
|
66
83
|
};
|
|
67
84
|
|
|
68
85
|
export class SharedQuery<TArgs extends unknown[], TData> {
|
|
@@ -74,6 +91,8 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
74
91
|
public readonly expiryMs: number;
|
|
75
92
|
public readonly staleMs: number;
|
|
76
93
|
public readonly refetchOnStale: boolean;
|
|
94
|
+
public readonly maxSize: number;
|
|
95
|
+
public readonly maxBytes: number;
|
|
77
96
|
|
|
78
97
|
public constructor(options: SharedQueryOptions<TArgs, TData>) {
|
|
79
98
|
this.queryName = options.queryName;
|
|
@@ -101,6 +120,9 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
101
120
|
options.indexedDbKey = options.queryName;
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
|
|
124
|
+
this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
|
|
125
|
+
|
|
104
126
|
this.queryState = sharedState<SharedQueryState<TData>>(
|
|
105
127
|
{},
|
|
106
128
|
{
|
|
@@ -196,12 +218,15 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
196
218
|
dataUpdatedMs = Date.now(),
|
|
197
219
|
): Promise<void> {
|
|
198
220
|
const entry: QueryStateValue<TData> = {
|
|
221
|
+
lastUpdatedMs: Date.now(),
|
|
199
222
|
loading: false,
|
|
200
223
|
data,
|
|
201
224
|
dataUpdatedMs,
|
|
202
225
|
};
|
|
203
226
|
|
|
204
227
|
this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
|
|
228
|
+
|
|
229
|
+
this.enforceSizeLimit();
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
/** Do a new fetch even when the data is already cached. */
|
|
@@ -227,6 +252,108 @@ export class SharedQuery<TArgs extends unknown[], TData> {
|
|
|
227
252
|
this.queryState.delete();
|
|
228
253
|
this.inflightPromises.clear();
|
|
229
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Discard the oldest entries if the size exceeds the limit. The last
|
|
258
|
+
* remaining record cannot be deleted no matter what the limit is.
|
|
259
|
+
*/
|
|
260
|
+
public enforceSizeLimit(): void {
|
|
261
|
+
// Skip if there are no limits.
|
|
262
|
+
if (!this.maxSize && !this.maxBytes) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const oldState = this.queryState.getSnapshot();
|
|
267
|
+
const oldSize = Object.keys(oldState).length;
|
|
268
|
+
|
|
269
|
+
// Cannot do GC if size is 1 or less.
|
|
270
|
+
if (oldSize <= 1) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const oldByteSize = getByteSize(oldState);
|
|
275
|
+
|
|
276
|
+
let newState = oldState;
|
|
277
|
+
let needUpdate = false;
|
|
278
|
+
|
|
279
|
+
// Limit by number of records.
|
|
280
|
+
if (this.maxSize) {
|
|
281
|
+
const numToCut = oldSize - this.maxSize;
|
|
282
|
+
|
|
283
|
+
if (numToCut > 0) {
|
|
284
|
+
console.log(
|
|
285
|
+
`enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
newState = { ...newState }; // shallow clone
|
|
289
|
+
needUpdate = true;
|
|
290
|
+
|
|
291
|
+
Object.entries(newState)
|
|
292
|
+
.sort(
|
|
293
|
+
(entry1, entry2) =>
|
|
294
|
+
// Sort descending.
|
|
295
|
+
entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs,
|
|
296
|
+
)
|
|
297
|
+
.slice(-numToCut)
|
|
298
|
+
.forEach(([key]) => {
|
|
299
|
+
delete newState[key];
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Limit by byte size.
|
|
305
|
+
if (this.maxBytes && Object.keys(newState).length > 1) {
|
|
306
|
+
// Recompute only if changed by above code.
|
|
307
|
+
const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
|
|
308
|
+
|
|
309
|
+
let bytesToCut = currentByteSize - this.maxBytes;
|
|
310
|
+
|
|
311
|
+
if (bytesToCut > 0) {
|
|
312
|
+
console.log(
|
|
313
|
+
`enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Shallow clone only if not already cloned above.
|
|
317
|
+
if (!needUpdate) {
|
|
318
|
+
newState = { ...newState };
|
|
319
|
+
needUpdate = true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const entriesByTimeAscending = Object.entries(newState).sort(
|
|
323
|
+
(entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
for (
|
|
327
|
+
let i = 0;
|
|
328
|
+
// Leave the last record entriesByTimeAscending[len-1] untouched.
|
|
329
|
+
i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
|
|
330
|
+
i++
|
|
331
|
+
) {
|
|
332
|
+
const key = entriesByTimeAscending[i][0];
|
|
333
|
+
const thisSize = getByteSize(key) + getByteSize(newState[key]);
|
|
334
|
+
delete newState[key];
|
|
335
|
+
bytesToCut -= thisSize;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Update the state.
|
|
341
|
+
if (needUpdate) {
|
|
342
|
+
this.queryState.setValue(newState);
|
|
343
|
+
|
|
344
|
+
// Log to inform user that the data was trimmed.
|
|
345
|
+
console.log(
|
|
346
|
+
`Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Log to indicate why trimming was not needed.
|
|
353
|
+
console.log(
|
|
354
|
+
`No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
230
357
|
}
|
|
231
358
|
|
|
232
359
|
const seenQueryNames = new Set<string>();
|
|
@@ -237,6 +364,16 @@ const seenQueryNames = new Set<string>();
|
|
|
237
364
|
*
|
|
238
365
|
* Each `queryName` must be unique, and can only be declared once, most often
|
|
239
366
|
* in the top level scope. The queryName is used for logging only.
|
|
367
|
+
*
|
|
368
|
+
* Example:
|
|
369
|
+
* ```
|
|
370
|
+
* export const usersQuery = sharedQuery({
|
|
371
|
+
* queryName: "users",
|
|
372
|
+
* queryFn: listUsers,
|
|
373
|
+
* persistTo: "indexedDb",
|
|
374
|
+
* staleMs: MS_PER_DAY,
|
|
375
|
+
* });
|
|
376
|
+
* ```
|
|
240
377
|
*/
|
|
241
378
|
export function sharedQuery<TArgs extends unknown[], TData>(
|
|
242
379
|
// The name could have been auto-generated, but we let the user give us a
|
|
@@ -251,12 +388,18 @@ export function sharedQuery<TArgs extends unknown[], TData>(
|
|
|
251
388
|
return new SharedQuery(options);
|
|
252
389
|
}
|
|
253
390
|
|
|
254
|
-
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
391
|
+
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
392
|
+
lastUpdatedMs: 0,
|
|
393
|
+
loading: true,
|
|
394
|
+
};
|
|
255
395
|
|
|
256
396
|
/**
|
|
257
397
|
* React hook to make use of a shared query inside any React component.
|
|
258
398
|
*
|
|
259
|
-
*
|
|
399
|
+
* Example:
|
|
400
|
+
* ```
|
|
401
|
+
* const users = useSharedQuery(usersQuery, ["123"]);
|
|
402
|
+
* ```
|
|
260
403
|
*/
|
|
261
404
|
export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
262
405
|
query: SharedQuery<TArgs, TData>,
|
|
@@ -278,10 +421,13 @@ export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
|
278
421
|
|
|
279
422
|
setQueryState((prev) => {
|
|
280
423
|
const clone = { ...prev };
|
|
424
|
+
|
|
281
425
|
clone[queryKey] = {
|
|
282
426
|
...(clone[queryKey] ?? {}),
|
|
427
|
+
lastUpdatedMs: Date.now(),
|
|
283
428
|
loading: true,
|
|
284
429
|
};
|
|
430
|
+
|
|
285
431
|
return clone;
|
|
286
432
|
});
|
|
287
433
|
|
|
@@ -291,12 +437,16 @@ export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
|
291
437
|
if (isMounted.current) {
|
|
292
438
|
setQueryState((prev) => {
|
|
293
439
|
const clone = { ...prev };
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
|
|
294
442
|
clone[queryKey] = {
|
|
295
443
|
// Don't keep old error.
|
|
444
|
+
lastUpdatedMs: now,
|
|
296
445
|
loading: false,
|
|
297
446
|
data,
|
|
298
|
-
dataUpdatedMs:
|
|
447
|
+
dataUpdatedMs: now,
|
|
299
448
|
};
|
|
449
|
+
|
|
300
450
|
return clone;
|
|
301
451
|
});
|
|
302
452
|
}
|
|
@@ -309,13 +459,17 @@ export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
|
309
459
|
if (isMounted.current) {
|
|
310
460
|
setQueryState((prev) => {
|
|
311
461
|
const clone = { ...prev };
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
|
|
312
464
|
clone[queryKey] = {
|
|
313
465
|
// Keep old data.
|
|
314
466
|
...(clone[queryKey] ?? {}),
|
|
467
|
+
lastUpdatedMs: now,
|
|
315
468
|
loading: false,
|
|
316
469
|
error,
|
|
317
|
-
errorUpdatedMs:
|
|
470
|
+
errorUpdatedMs: now,
|
|
318
471
|
};
|
|
472
|
+
|
|
319
473
|
return clone;
|
|
320
474
|
});
|
|
321
475
|
}
|
package/src/utils/stringify.ts
CHANGED
|
@@ -15,7 +15,7 @@ export function stringifyDeterministicForKeys(params: unknown): string {
|
|
|
15
15
|
throw new Error(`Functions should not be passed into query parameters`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
if (!value || typeof value !== "object") {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
19
19
|
return value;
|
|
20
20
|
}
|
|
21
21
|
|
package/utils/stringify.js
CHANGED
|
@@ -29,7 +29,7 @@ function stringifyDeterministicForKeys(params) {
|
|
|
29
29
|
if (typeof value === "function") {
|
|
30
30
|
throw new Error(`Functions should not be passed into query parameters`);
|
|
31
31
|
}
|
|
32
|
-
if (!value || typeof value !== "object") {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
33
33
|
return value;
|
|
34
34
|
}
|
|
35
35
|
if (cache.has(value)) {
|
package/utils/stringify.mjs
CHANGED
|
@@ -5,7 +5,7 @@ function stringifyDeterministicForKeys(params) {
|
|
|
5
5
|
if (typeof value === "function") {
|
|
6
6
|
throw new Error(`Functions should not be passed into query parameters`);
|
|
7
7
|
}
|
|
8
|
-
if (!value || typeof value !== "object") {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
9
9
|
return value;
|
|
10
10
|
}
|
|
11
11
|
if (cache.has(value)) {
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { stringifyDeterministicForKeys } from "./stringify";
|
|
2
|
-
|
|
3
|
-
describe("stringifyDeterministicForKeys", () => {
|
|
4
|
-
test("happy", () => {
|
|
5
|
-
const expected = `{"a":1,"b":2,"c":{"e":1,"f":2}}`;
|
|
6
|
-
|
|
7
|
-
const actual1 = stringifyDeterministicForKeys({
|
|
8
|
-
a: 1,
|
|
9
|
-
b: 2,
|
|
10
|
-
c: { e: 1, f: 2 },
|
|
11
|
-
});
|
|
12
|
-
expect(actual1).toBe(expected);
|
|
13
|
-
|
|
14
|
-
const actual2 = stringifyDeterministicForKeys({
|
|
15
|
-
c: { f: 2, e: 1 },
|
|
16
|
-
b: 2,
|
|
17
|
-
a: 1,
|
|
18
|
-
});
|
|
19
|
-
expect(actual2).toBe(expected);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("circular", () => {
|
|
23
|
-
const circular = { b: {}, a: 1 };
|
|
24
|
-
circular.b = circular;
|
|
25
|
-
|
|
26
|
-
const actual = stringifyDeterministicForKeys(circular);
|
|
27
|
-
|
|
28
|
-
expect(actual).toBe(`{"a":1,"b":"[Circular]"}`);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("function", () => {
|
|
32
|
-
const fn = { b: {}, a: () => {} };
|
|
33
|
-
|
|
34
|
-
expect(() => stringifyDeterministicForKeys(fn)).toThrow(
|
|
35
|
-
`Functions should not be passed into query parameters`,
|
|
36
|
-
);
|
|
37
|
-
});
|
|
38
|
-
});
|
package/utils/stringify.test.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
// src/utils/stringify.ts
|
|
4
|
-
function stringifyDeterministicForKeys(params) {
|
|
5
|
-
const cache = /* @__PURE__ */ new Set();
|
|
6
|
-
return JSON.stringify(params, (_, value) => {
|
|
7
|
-
if (typeof value === "function") {
|
|
8
|
-
throw new Error(`Functions should not be passed into query parameters`);
|
|
9
|
-
}
|
|
10
|
-
if (!value || typeof value !== "object") {
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
|
-
if (cache.has(value)) {
|
|
14
|
-
return "[Circular]";
|
|
15
|
-
}
|
|
16
|
-
cache.add(value);
|
|
17
|
-
if (value !== params) {
|
|
18
|
-
try {
|
|
19
|
-
JSON.stringify(value);
|
|
20
|
-
} catch {
|
|
21
|
-
return "[Unserializable]";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return Object.keys(value).sort().reduce(
|
|
25
|
-
(result, key) => {
|
|
26
|
-
result[key] = value[key];
|
|
27
|
-
return result;
|
|
28
|
-
},
|
|
29
|
-
{}
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// src/utils/stringify.test.ts
|
|
35
|
-
describe("stringifyDeterministicForKeys", () => {
|
|
36
|
-
test("happy", () => {
|
|
37
|
-
const expected = `{"a":1,"b":2,"c":{"e":1,"f":2}}`;
|
|
38
|
-
const actual1 = stringifyDeterministicForKeys({
|
|
39
|
-
a: 1,
|
|
40
|
-
b: 2,
|
|
41
|
-
c: { e: 1, f: 2 }
|
|
42
|
-
});
|
|
43
|
-
expect(actual1).toBe(expected);
|
|
44
|
-
const actual2 = stringifyDeterministicForKeys({
|
|
45
|
-
c: { f: 2, e: 1 },
|
|
46
|
-
b: 2,
|
|
47
|
-
a: 1
|
|
48
|
-
});
|
|
49
|
-
expect(actual2).toBe(expected);
|
|
50
|
-
});
|
|
51
|
-
test("circular", () => {
|
|
52
|
-
const circular = { b: {}, a: 1 };
|
|
53
|
-
circular.b = circular;
|
|
54
|
-
const actual = stringifyDeterministicForKeys(circular);
|
|
55
|
-
expect(actual).toBe(`{"a":1,"b":"[Circular]"}`);
|
|
56
|
-
});
|
|
57
|
-
test("function", () => {
|
|
58
|
-
const fn = { b: {}, a: () => {
|
|
59
|
-
} };
|
|
60
|
-
expect(() => stringifyDeterministicForKeys(fn)).toThrow(
|
|
61
|
-
`Functions should not be passed into query parameters`
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
});
|
package/utils/stringify.test.mjs
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
2
|
-
var __esm = (fn, res) => function __init() {
|
|
3
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
4
|
-
};
|
|
5
|
-
var __commonJS = (cb, mod) => function __require() {
|
|
6
|
-
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
// src/utils/stringify.ts
|
|
10
|
-
function stringifyDeterministicForKeys(params) {
|
|
11
|
-
const cache = /* @__PURE__ */ new Set();
|
|
12
|
-
return JSON.stringify(params, (_, value) => {
|
|
13
|
-
if (typeof value === "function") {
|
|
14
|
-
throw new Error(`Functions should not be passed into query parameters`);
|
|
15
|
-
}
|
|
16
|
-
if (!value || typeof value !== "object") {
|
|
17
|
-
return value;
|
|
18
|
-
}
|
|
19
|
-
if (cache.has(value)) {
|
|
20
|
-
return "[Circular]";
|
|
21
|
-
}
|
|
22
|
-
cache.add(value);
|
|
23
|
-
if (value !== params) {
|
|
24
|
-
try {
|
|
25
|
-
JSON.stringify(value);
|
|
26
|
-
} catch {
|
|
27
|
-
return "[Unserializable]";
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return Object.keys(value).sort().reduce(
|
|
31
|
-
(result, key) => {
|
|
32
|
-
result[key] = value[key];
|
|
33
|
-
return result;
|
|
34
|
-
},
|
|
35
|
-
{}
|
|
36
|
-
);
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
var init_stringify = __esm({
|
|
40
|
-
"src/utils/stringify.ts"() {
|
|
41
|
-
"use strict";
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// src/utils/stringify.test.ts
|
|
46
|
-
var require_stringify_test = __commonJS({
|
|
47
|
-
"src/utils/stringify.test.ts"() {
|
|
48
|
-
init_stringify();
|
|
49
|
-
describe("stringifyDeterministicForKeys", () => {
|
|
50
|
-
test("happy", () => {
|
|
51
|
-
const expected = `{"a":1,"b":2,"c":{"e":1,"f":2}}`;
|
|
52
|
-
const actual1 = stringifyDeterministicForKeys({
|
|
53
|
-
a: 1,
|
|
54
|
-
b: 2,
|
|
55
|
-
c: { e: 1, f: 2 }
|
|
56
|
-
});
|
|
57
|
-
expect(actual1).toBe(expected);
|
|
58
|
-
const actual2 = stringifyDeterministicForKeys({
|
|
59
|
-
c: { f: 2, e: 1 },
|
|
60
|
-
b: 2,
|
|
61
|
-
a: 1
|
|
62
|
-
});
|
|
63
|
-
expect(actual2).toBe(expected);
|
|
64
|
-
});
|
|
65
|
-
test("circular", () => {
|
|
66
|
-
const circular = { b: {}, a: 1 };
|
|
67
|
-
circular.b = circular;
|
|
68
|
-
const actual = stringifyDeterministicForKeys(circular);
|
|
69
|
-
expect(actual).toBe(`{"a":1,"b":"[Circular]"}`);
|
|
70
|
-
});
|
|
71
|
-
test("function", () => {
|
|
72
|
-
const fn = { b: {}, a: () => {
|
|
73
|
-
} };
|
|
74
|
-
expect(() => stringifyDeterministicForKeys(fn)).toThrow(
|
|
75
|
-
`Functions should not be passed into query parameters`
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
export default require_stringify_test();
|