@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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/package.json +39 -33
  3. package/sharedQuery.d.mts +68 -8
  4. package/sharedQuery.d.ts +67 -7
  5. package/sharedQuery.js +240 -79
  6. package/sharedQuery.mjs +228 -77
  7. package/src/sharedQuery.ts +338 -103
  8. package/src/utils/useDeepMemo.ts +19 -0
  9. package/utils/useDeepMemo.d.mts +4 -0
  10. package/utils/useDeepMemo.d.ts +2 -0
  11. package/utils/useDeepMemo.js +51 -0
  12. package/utils/useDeepMemo.mjs +16 -0
  13. package/docs/.nojekyll +0 -1
  14. package/docs/assets/hierarchy.js +0 -1
  15. package/docs/assets/highlight.css +0 -22
  16. package/docs/assets/icons.js +0 -18
  17. package/docs/assets/icons.svg +0 -1
  18. package/docs/assets/main.js +0 -60
  19. package/docs/assets/navigation.js +0 -1
  20. package/docs/assets/search.js +0 -1
  21. package/docs/assets/style.css +0 -1633
  22. package/docs/classes/sharedQuery.SharedQuery.html +0 -17
  23. package/docs/classes/sharedState.SharedState.html +0 -9
  24. package/docs/classes/utils_storage.InMemoryStorageAdapter.html +0 -9
  25. package/docs/classes/utils_storage.IndexedDbAdapter.html +0 -9
  26. package/docs/classes/utils_storage.LocalStorageAdapter.html +0 -9
  27. package/docs/functions/sharedQuery.sharedQuery.html +0 -5
  28. package/docs/functions/sharedQuery.useSharedQuery.html +0 -4
  29. package/docs/functions/sharedState.sharedState.html +0 -3
  30. package/docs/functions/sharedState.useSharedState.html +0 -3
  31. package/docs/functions/sharedState.useSharedStateSetter.html +0 -2
  32. package/docs/functions/sharedState.useSharedStateValue.html +0 -2
  33. package/docs/functions/utils_storage.getStorageAdapter.html +0 -1
  34. package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +0 -7
  35. package/docs/hierarchy.html +0 -1
  36. package/docs/index.html +0 -2
  37. package/docs/modules/fetcher.html +0 -1
  38. package/docs/modules/index.html +0 -1
  39. package/docs/modules/sharedQuery.html +0 -1
  40. package/docs/modules/sharedState.html +0 -1
  41. package/docs/modules/utils_storage.html +0 -1
  42. package/docs/modules/utils_stringify.html +0 -1
  43. package/docs/modules.html +0 -1
  44. package/docs/types/sharedQuery.FetchFn.html +0 -1
  45. package/docs/types/sharedQuery.PersistTo.html +0 -1
  46. package/docs/types/sharedQuery.QueryStateValue.html +0 -6
  47. package/docs/types/sharedQuery.SharedQueryOptions.html +0 -1
  48. package/docs/types/sharedQuery.SharedQueryState.html +0 -3
  49. package/docs/types/sharedQuery.UseQueryResult.html +0 -1
  50. package/docs/types/sharedState.SharedStateOptions.html +0 -1
  51. package/docs/types/utils_storage.StorageAdapter.html +0 -5
  52. package/docs/types/utils_storage.StorageOptions.html +0 -5
  53. package/docs/variables/fetcher.fetcher.html +0 -2
  54. package/docs/variables/fetcher.fetcherWithRetries.html +0 -2
  55. package/docs/variables/sharedQuery.SharedQueryDefaults.html +0 -2
  56. package/docs/variables/sharedState.SharedStateDefaults.html +0 -6
  57. package/src/utils/stringify.test.ts +0 -46
  58. package/utils/stringify.test.d.mts +0 -2
  59. package/utils/stringify.test.d.ts +0 -2
  60. package/utils/stringify.test.js +0 -69
  61. package/utils/stringify.test.mjs +0 -86
package/sharedQuery.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/sharedQuery.ts
@@ -27,7 +37,8 @@ __export(sharedQuery_exports, {
27
37
  });
28
38
  module.exports = __toCommonJS(sharedQuery_exports);
29
39
  var import_timeConstants2 = require("@choksheak/ts-utils/timeConstants");
30
- var import_react2 = require("react");
40
+ var import_object_sizeof = __toESM(require("object-sizeof"));
41
+ var import_react3 = require("react");
31
42
 
32
43
  // src/sharedState.ts
33
44
  var import_timeConstants = require("@choksheak/ts-utils/timeConstants");
@@ -292,34 +303,68 @@ function stringifyDeterministicForKeys(params) {
292
303
  });
293
304
  }
294
305
 
306
+ // src/utils/useDeepMemo.ts
307
+ var import_isEqual = __toESM(require("lodash/isEqual"));
308
+ var import_react2 = require("react");
309
+ function useDeepMemo(value) {
310
+ const [state, setState] = (0, import_react2.useState)(value);
311
+ return (0, import_react2.useMemo)(() => {
312
+ if (state === value || (0, import_isEqual.default)(state, value)) {
313
+ return state;
314
+ }
315
+ setState(value);
316
+ return value;
317
+ }, [state, value]);
318
+ }
319
+
295
320
  // src/sharedQuery.ts
296
321
  var SharedQueryDefaults = {
322
+ // Refetch on every page load.
297
323
  staleMs: 0,
324
+ // Keep in cache for a long time.
298
325
  expiryMs: 30 * import_timeConstants2.MS_PER_DAY,
299
- persistTo: void 0
326
+ // Always refetch automatically if the data is stale.
327
+ refetchOnStale: true,
328
+ // No default persistence configured.
329
+ persistTo: void 0,
330
+ // 100 is not too large, but please tweak accordingly.
331
+ maxSize: 100,
332
+ // 100kb limit before discarding old records.
333
+ maxBytes: 1e5,
334
+ // Default to log to console.
335
+ log: (...args) => console.log("[sharedQuery]", ...args)
300
336
  };
301
337
  var SharedQuery = class {
302
- inflightPromises = /* @__PURE__ */ new Map();
338
+ inflightQueries = /* @__PURE__ */ new Map();
303
339
  queryState;
304
340
  queryName;
305
341
  queryFn;
306
342
  expiryMs;
307
343
  staleMs;
308
344
  refetchOnStale;
345
+ maxSize;
346
+ maxBytes;
347
+ log;
309
348
  constructor(options) {
310
349
  this.queryName = options.queryName;
311
350
  this.queryFn = options.queryFn;
312
- this.expiryMs = options?.expiryMs !== void 0 ? options?.expiryMs : SharedQueryDefaults.expiryMs;
313
- this.staleMs = options?.staleMs !== void 0 ? options?.staleMs : SharedQueryDefaults.staleMs;
314
- this.refetchOnStale = Boolean(options?.refetchOnStale);
315
- const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
316
- if (persistTo === "localStorage") {
317
- options.localStorageKey = options.queryName;
318
- } else if (persistTo === "indexedDb") {
319
- options.indexedDbKey = options.queryName;
351
+ this.staleMs = options?.staleMs ?? SharedQueryDefaults.staleMs;
352
+ this.expiryMs = options?.expiryMs ?? SharedQueryDefaults.expiryMs;
353
+ this.refetchOnStale = options?.refetchOnStale ?? SharedQueryDefaults.refetchOnStale;
354
+ if (!options.localStorageKey && !options.indexedDbKey) {
355
+ const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
356
+ if (persistTo === "localStorage") {
357
+ options.localStorageKey = options.queryName;
358
+ } else if (persistTo === "indexedDb") {
359
+ options.indexedDbKey = options.queryName;
360
+ }
320
361
  }
362
+ this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
363
+ this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
364
+ this.log = options?.log ?? SharedQueryDefaults.log;
321
365
  this.queryState = sharedState(
322
366
  {},
367
+ // defaultValue
323
368
  {
324
369
  ...options,
325
370
  // Fallback to use expiryMs for the storeExpiryMs.
@@ -327,9 +372,24 @@ var SharedQuery = class {
327
372
  }
328
373
  );
329
374
  }
375
+ /** Returns a key to identify requests based on the given args. */
330
376
  getQueryKey(args) {
331
377
  return this.queryName + ":" + stringifyDeterministicForKeys(args);
332
378
  }
379
+ /**
380
+ * Get the AbortController to abort the inflight query (if any).
381
+ *
382
+ * Note that once you get the controller, you can also get the signal easily
383
+ * using `controller.signal`. So if you need both the controller and the
384
+ * signal, just use this function.
385
+ */
386
+ getAbortController(queryKey) {
387
+ return this.inflightQueries.get(queryKey)?.abortController ?? null;
388
+ }
389
+ /** Get the AbortSignal to check for query abortions. */
390
+ getAbortSignal(queryKey) {
391
+ return this.getAbortController(queryKey)?.signal ?? null;
392
+ }
333
393
  isStale(dataUpdatedMs) {
334
394
  if (this.staleMs === 0) return true;
335
395
  const ageMs = Date.now() - dataUpdatedMs;
@@ -345,52 +405,53 @@ var SharedQuery = class {
345
405
  const cached = this.queryState.getSnapshot()?.[queryKey];
346
406
  if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
347
407
  if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
348
- console.log(
349
- `Return fresh data ${queryKey} from cache without fetching`
350
- );
408
+ this.log(`Return fresh data ${queryKey} from cache without fetching`);
351
409
  return cached.data;
352
410
  }
353
411
  if (this.refetchOnStale) {
354
- console.log(`refetchOnStale for ${queryKey}`);
412
+ this.log(`refetchOnStale for ${queryKey}`);
355
413
  void this.dedupedFetch(queryKey, ...args);
356
414
  }
357
- console.log(`Returning stale data for ${queryKey}`);
415
+ this.log(`Returning stale data for ${queryKey}`);
358
416
  return cached.data;
359
417
  }
360
418
  return await this.dedupedFetch(queryKey, ...args);
361
419
  }
362
420
  dedupedFetch(queryKey, ...args) {
363
- const inflightPromise = this.inflightPromises.get(queryKey);
364
- if (inflightPromise) {
365
- console.log(`Deduplicating inflight fetch for ${queryKey}`);
366
- return inflightPromise;
421
+ const inflightQuery = this.inflightQueries.get(queryKey);
422
+ if (inflightQuery) {
423
+ this.log(`Deduplicating inflight fetch for ${queryKey}`);
424
+ return inflightQuery.promise;
367
425
  }
368
426
  const promise = this.startFetching(queryKey, args);
369
- this.inflightPromises.set(queryKey, promise);
427
+ const abortController = new AbortController();
428
+ this.inflightQueries.set(queryKey, { promise, abortController });
370
429
  return promise;
371
430
  }
372
431
  async startFetching(queryKey, args) {
373
- console.log(`Start fetching ${queryKey}`);
432
+ this.log(`Start fetching ${queryKey}`);
374
433
  try {
375
434
  const data = await this.queryFn(...args);
376
- console.log(`Successfully fetched ${queryKey}`);
435
+ this.log(`Successfully fetched ${queryKey}`);
377
436
  this.setData(queryKey, data, Date.now());
378
437
  return data;
379
438
  } catch (e) {
380
- console.error(`Failed to fetch ${queryKey}: ${e}`);
439
+ this.log(`Failed to fetch ${queryKey}: ${e}`);
381
440
  throw e;
382
441
  } finally {
383
- this.inflightPromises.delete(queryKey);
442
+ this.inflightQueries.delete(queryKey);
384
443
  }
385
444
  }
386
445
  /** Set the data directly if the user obtained it from somewhere else. */
387
446
  async setData(queryKey, data, dataUpdatedMs = Date.now()) {
388
447
  const entry = {
448
+ lastUpdatedMs: Date.now(),
389
449
  loading: false,
390
450
  data,
391
451
  dataUpdatedMs
392
452
  };
393
453
  this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
454
+ this.enforceSizeLimit();
394
455
  }
395
456
  /** Do a new fetch even when the data is already cached. */
396
457
  async updateFromSource(...args) {
@@ -398,18 +459,98 @@ var SharedQuery = class {
398
459
  return await this.dedupedFetch(key, ...args);
399
460
  }
400
461
  /** Delete the cached data for one set of arguments. */
401
- deleteData(args) {
402
- const queryKey = this.getQueryKey(args);
462
+ deleteData(queryKey) {
403
463
  const record = this.queryState.getSnapshot();
404
464
  const clone = { ...record };
405
465
  delete clone[queryKey];
406
466
  this.queryState.setValue(clone);
407
- this.inflightPromises.delete(queryKey);
467
+ const inflight = this.inflightQueries.get(queryKey);
468
+ if (inflight) {
469
+ this.inflightQueries.delete(queryKey);
470
+ inflight.abortController.abort("Deleted");
471
+ }
408
472
  }
409
473
  /** Delete all currently cached data & all inflight promises. */
410
474
  clear() {
411
475
  this.queryState.delete();
412
- this.inflightPromises.clear();
476
+ for (const { abortController } of Array.from(
477
+ this.inflightQueries.values()
478
+ )) {
479
+ abortController.abort("Cleared");
480
+ }
481
+ this.inflightQueries.clear();
482
+ }
483
+ /**
484
+ * Discard the oldest entries if the size exceeds the limit. The last
485
+ * remaining record cannot be deleted no matter what the limit is.
486
+ */
487
+ enforceSizeLimit() {
488
+ if (!this.maxSize && !this.maxBytes) {
489
+ return;
490
+ }
491
+ const oldState = this.queryState.getSnapshot();
492
+ const oldSize = Object.keys(oldState).length;
493
+ if (oldSize <= 1) {
494
+ return;
495
+ }
496
+ const oldByteSize = (0, import_object_sizeof.default)(oldState);
497
+ let newState = oldState;
498
+ let needUpdate = false;
499
+ if (this.maxSize) {
500
+ const numToCut = oldSize - this.maxSize;
501
+ if (numToCut > 0) {
502
+ this.log(
503
+ `enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`
504
+ );
505
+ newState = { ...newState };
506
+ needUpdate = true;
507
+ Object.entries(newState).sort(
508
+ (entry1, entry2) => (
509
+ // Sort descending.
510
+ entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs
511
+ )
512
+ ).slice(-numToCut).forEach(([key]) => {
513
+ delete newState[key];
514
+ });
515
+ }
516
+ }
517
+ if (this.maxBytes && Object.keys(newState).length > 1) {
518
+ const currentByteSize = needUpdate ? (0, import_object_sizeof.default)(newState) : oldByteSize;
519
+ let bytesToCut = currentByteSize - this.maxBytes;
520
+ if (bytesToCut > 0) {
521
+ this.log(
522
+ `enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`
523
+ );
524
+ if (!needUpdate) {
525
+ newState = { ...newState };
526
+ needUpdate = true;
527
+ }
528
+ const entriesByTimeAscending = Object.entries(newState).sort(
529
+ (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
530
+ );
531
+ for (
532
+ let i = 0;
533
+ // Leave the last record entriesByTimeAscending[len-1] untouched.
534
+ i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
535
+ i++
536
+ ) {
537
+ const key = entriesByTimeAscending[i][0];
538
+ const thisSize = (0, import_object_sizeof.default)(key) + (0, import_object_sizeof.default)(newState[key]);
539
+ delete newState[key];
540
+ bytesToCut -= thisSize;
541
+ }
542
+ }
543
+ }
544
+ if (needUpdate) {
545
+ this.queryState.setValue(newState);
546
+ this.log(
547
+ `Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${(0, import_object_sizeof.default)(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`
548
+ );
549
+ return;
550
+ }
551
+ this.log(
552
+ `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
553
+ );
413
554
  }
414
555
  };
415
556
  var seenQueryNames = /* @__PURE__ */ new Set();
@@ -420,74 +561,94 @@ function sharedQuery(options) {
420
561
  seenQueryNames.add(options.queryName);
421
562
  return new SharedQuery(options);
422
563
  }
423
- var DEFAULT_QUERY_STATE_ENTRY = { loading: true };
564
+ var DEFAULT_QUERY_STATE_ENTRY = {
565
+ lastUpdatedMs: 0,
566
+ loading: true
567
+ };
424
568
  function useSharedQuery(query, args = []) {
569
+ const stableArgs = useDeepMemo(args);
425
570
  const [queryState, setQueryState] = useSharedState(query.queryState);
426
- const isMounted = (0, import_react2.useRef)(true);
427
- const queryKey = query.getQueryKey(args);
428
- const execute = (0, import_react2.useCallback)(async () => {
429
- console.log(`Begin executing shared query ${queryKey}`);
430
- setQueryState((prev) => {
431
- const clone = { ...prev };
432
- clone[queryKey] = {
433
- ...clone[queryKey] ?? {},
434
- loading: true
435
- };
436
- return clone;
437
- });
438
- try {
439
- const data = await query.getCachedOrFetch(...args);
440
- if (isMounted.current) {
441
- setQueryState((prev) => {
442
- const clone = { ...prev };
443
- clone[queryKey] = {
444
- // Don't keep old error.
445
- loading: false,
446
- data,
447
- dataUpdatedMs: Date.now()
448
- };
449
- return clone;
450
- });
451
- }
452
- } catch (e) {
453
- const error = e instanceof Error ? e : new Error(`An unknown error occurred during fetch: ${e}`);
454
- if (isMounted.current) {
455
- setQueryState((prev) => {
456
- const clone = { ...prev };
457
- clone[queryKey] = {
458
- // Keep old data.
459
- ...clone[queryKey] ?? {},
460
- loading: false,
461
- error,
462
- errorUpdatedMs: Date.now()
463
- };
464
- return clone;
465
- });
571
+ const isMounted = (0, import_react3.useRef)(true);
572
+ const queryKey = (0, import_react3.useMemo)(
573
+ () => query.getQueryKey(stableArgs),
574
+ [query, stableArgs]
575
+ );
576
+ const queryStateValue = queryState[queryKey];
577
+ const execute = (0, import_react3.useCallback)(
578
+ async (forceRefresh) => {
579
+ query.log(`Begin executing shared query ${queryKey}`);
580
+ setQueryState((prev) => {
581
+ const clone = { ...prev };
582
+ clone[queryKey] = {
583
+ ...clone[queryKey] ?? {},
584
+ lastUpdatedMs: Date.now(),
585
+ loading: true
586
+ };
587
+ return clone;
588
+ });
589
+ try {
590
+ const data = await (forceRefresh ? query.updateFromSource(...stableArgs) : query.getCachedOrFetch(...stableArgs));
591
+ if (isMounted.current) {
592
+ setQueryState((prev) => {
593
+ const clone = { ...prev };
594
+ const now = Date.now();
595
+ clone[queryKey] = {
596
+ // Don't keep old error.
597
+ lastUpdatedMs: now,
598
+ loading: false,
599
+ data,
600
+ dataUpdatedMs: now
601
+ };
602
+ return clone;
603
+ });
604
+ }
605
+ } catch (e) {
606
+ if (isMounted.current) {
607
+ setQueryState((prev) => {
608
+ const clone = { ...prev };
609
+ const now = Date.now();
610
+ clone[queryKey] = {
611
+ // Keep old data.
612
+ ...clone[queryKey] ?? {},
613
+ lastUpdatedMs: now,
614
+ loading: false,
615
+ error: e,
616
+ errorUpdatedMs: now
617
+ };
618
+ return clone;
619
+ });
620
+ }
466
621
  }
467
- }
468
- }, [queryKey]);
469
- (0, import_react2.useEffect)(() => {
622
+ },
623
+ [query, queryKey, setQueryState, stableArgs]
624
+ );
625
+ (0, import_react3.useEffect)(() => {
470
626
  isMounted.current = true;
471
- void execute();
627
+ void execute(false);
472
628
  return () => {
473
629
  isMounted.current = false;
474
630
  };
475
631
  }, [execute]);
476
- return (0, import_react2.useMemo)(() => {
477
- const state = queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;
632
+ return (0, import_react3.useMemo)(() => {
633
+ const state = queryStateValue ?? DEFAULT_QUERY_STATE_ENTRY;
478
634
  return {
479
635
  ...state,
636
+ abortCurrentQuery: (reason) => {
637
+ const controller = query.getAbortController(queryKey);
638
+ controller?.abort(reason);
639
+ return Boolean(controller);
640
+ },
480
641
  refetch: () => {
481
- return query.updateFromSource(...args);
642
+ void execute(true);
482
643
  },
483
644
  setData: (data, dataUpdatedMs) => {
484
645
  query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
485
646
  },
486
647
  deleteData: () => {
487
- query.deleteData(args);
648
+ query.deleteData(queryKey);
488
649
  }
489
650
  };
490
- }, [query, queryKey, queryState]);
651
+ }, [execute, query, queryKey, queryStateValue]);
491
652
  }
492
653
  // Annotate the CommonJS export names for ESM import in node:
493
654
  0 && (module.exports = {