@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.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/sharedQuery.ts
2
2
  import { MS_PER_DAY as MS_PER_DAY2 } from "@choksheak/ts-utils/timeConstants";
3
- import { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import getByteSize from "object-sizeof";
4
+ import { useCallback, useEffect, useMemo as useMemo2, useRef } from "react";
4
5
 
5
6
  // src/sharedState.ts
6
7
  import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
@@ -265,34 +266,68 @@ function stringifyDeterministicForKeys(params) {
265
266
  });
266
267
  }
267
268
 
269
+ // src/utils/useDeepMemo.ts
270
+ import isEqual from "lodash/isEqual";
271
+ import { useMemo, useState } from "react";
272
+ function useDeepMemo(value) {
273
+ const [state, setState] = useState(value);
274
+ return useMemo(() => {
275
+ if (state === value || isEqual(state, value)) {
276
+ return state;
277
+ }
278
+ setState(value);
279
+ return value;
280
+ }, [state, value]);
281
+ }
282
+
268
283
  // src/sharedQuery.ts
269
284
  var SharedQueryDefaults = {
285
+ // Refetch on every page load.
270
286
  staleMs: 0,
287
+ // Keep in cache for a long time.
271
288
  expiryMs: 30 * MS_PER_DAY2,
272
- persistTo: void 0
289
+ // Always refetch automatically if the data is stale.
290
+ refetchOnStale: true,
291
+ // No default persistence configured.
292
+ persistTo: void 0,
293
+ // 100 is not too large, but please tweak accordingly.
294
+ maxSize: 100,
295
+ // 100kb limit before discarding old records.
296
+ maxBytes: 1e5,
297
+ // Default to log to console.
298
+ log: (...args) => console.log("[sharedQuery]", ...args)
273
299
  };
274
300
  var SharedQuery = class {
275
- inflightPromises = /* @__PURE__ */ new Map();
301
+ inflightQueries = /* @__PURE__ */ new Map();
276
302
  queryState;
277
303
  queryName;
278
304
  queryFn;
279
305
  expiryMs;
280
306
  staleMs;
281
307
  refetchOnStale;
308
+ maxSize;
309
+ maxBytes;
310
+ log;
282
311
  constructor(options) {
283
312
  this.queryName = options.queryName;
284
313
  this.queryFn = options.queryFn;
285
- this.expiryMs = options?.expiryMs !== void 0 ? options?.expiryMs : SharedQueryDefaults.expiryMs;
286
- this.staleMs = options?.staleMs !== void 0 ? options?.staleMs : SharedQueryDefaults.staleMs;
287
- this.refetchOnStale = Boolean(options?.refetchOnStale);
288
- const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
289
- if (persistTo === "localStorage") {
290
- options.localStorageKey = options.queryName;
291
- } else if (persistTo === "indexedDb") {
292
- options.indexedDbKey = options.queryName;
314
+ this.staleMs = options?.staleMs ?? SharedQueryDefaults.staleMs;
315
+ this.expiryMs = options?.expiryMs ?? SharedQueryDefaults.expiryMs;
316
+ this.refetchOnStale = options?.refetchOnStale ?? SharedQueryDefaults.refetchOnStale;
317
+ if (!options.localStorageKey && !options.indexedDbKey) {
318
+ const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
319
+ if (persistTo === "localStorage") {
320
+ options.localStorageKey = options.queryName;
321
+ } else if (persistTo === "indexedDb") {
322
+ options.indexedDbKey = options.queryName;
323
+ }
293
324
  }
325
+ this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
326
+ this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
327
+ this.log = options?.log ?? SharedQueryDefaults.log;
294
328
  this.queryState = sharedState(
295
329
  {},
330
+ // defaultValue
296
331
  {
297
332
  ...options,
298
333
  // Fallback to use expiryMs for the storeExpiryMs.
@@ -300,9 +335,24 @@ var SharedQuery = class {
300
335
  }
301
336
  );
302
337
  }
338
+ /** Returns a key to identify requests based on the given args. */
303
339
  getQueryKey(args) {
304
340
  return this.queryName + ":" + stringifyDeterministicForKeys(args);
305
341
  }
342
+ /**
343
+ * Get the AbortController to abort the inflight query (if any).
344
+ *
345
+ * Note that once you get the controller, you can also get the signal easily
346
+ * using `controller.signal`. So if you need both the controller and the
347
+ * signal, just use this function.
348
+ */
349
+ getAbortController(queryKey) {
350
+ return this.inflightQueries.get(queryKey)?.abortController ?? null;
351
+ }
352
+ /** Get the AbortSignal to check for query abortions. */
353
+ getAbortSignal(queryKey) {
354
+ return this.getAbortController(queryKey)?.signal ?? null;
355
+ }
306
356
  isStale(dataUpdatedMs) {
307
357
  if (this.staleMs === 0) return true;
308
358
  const ageMs = Date.now() - dataUpdatedMs;
@@ -318,52 +368,53 @@ var SharedQuery = class {
318
368
  const cached = this.queryState.getSnapshot()?.[queryKey];
319
369
  if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
320
370
  if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
321
- console.log(
322
- `Return fresh data ${queryKey} from cache without fetching`
323
- );
371
+ this.log(`Return fresh data ${queryKey} from cache without fetching`);
324
372
  return cached.data;
325
373
  }
326
374
  if (this.refetchOnStale) {
327
- console.log(`refetchOnStale for ${queryKey}`);
375
+ this.log(`refetchOnStale for ${queryKey}`);
328
376
  void this.dedupedFetch(queryKey, ...args);
329
377
  }
330
- console.log(`Returning stale data for ${queryKey}`);
378
+ this.log(`Returning stale data for ${queryKey}`);
331
379
  return cached.data;
332
380
  }
333
381
  return await this.dedupedFetch(queryKey, ...args);
334
382
  }
335
383
  dedupedFetch(queryKey, ...args) {
336
- const inflightPromise = this.inflightPromises.get(queryKey);
337
- if (inflightPromise) {
338
- console.log(`Deduplicating inflight fetch for ${queryKey}`);
339
- return inflightPromise;
384
+ const inflightQuery = this.inflightQueries.get(queryKey);
385
+ if (inflightQuery) {
386
+ this.log(`Deduplicating inflight fetch for ${queryKey}`);
387
+ return inflightQuery.promise;
340
388
  }
341
389
  const promise = this.startFetching(queryKey, args);
342
- this.inflightPromises.set(queryKey, promise);
390
+ const abortController = new AbortController();
391
+ this.inflightQueries.set(queryKey, { promise, abortController });
343
392
  return promise;
344
393
  }
345
394
  async startFetching(queryKey, args) {
346
- console.log(`Start fetching ${queryKey}`);
395
+ this.log(`Start fetching ${queryKey}`);
347
396
  try {
348
397
  const data = await this.queryFn(...args);
349
- console.log(`Successfully fetched ${queryKey}`);
398
+ this.log(`Successfully fetched ${queryKey}`);
350
399
  this.setData(queryKey, data, Date.now());
351
400
  return data;
352
401
  } catch (e) {
353
- console.error(`Failed to fetch ${queryKey}: ${e}`);
402
+ this.log(`Failed to fetch ${queryKey}: ${e}`);
354
403
  throw e;
355
404
  } finally {
356
- this.inflightPromises.delete(queryKey);
405
+ this.inflightQueries.delete(queryKey);
357
406
  }
358
407
  }
359
408
  /** Set the data directly if the user obtained it from somewhere else. */
360
409
  async setData(queryKey, data, dataUpdatedMs = Date.now()) {
361
410
  const entry = {
411
+ lastUpdatedMs: Date.now(),
362
412
  loading: false,
363
413
  data,
364
414
  dataUpdatedMs
365
415
  };
366
416
  this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
417
+ this.enforceSizeLimit();
367
418
  }
368
419
  /** Do a new fetch even when the data is already cached. */
369
420
  async updateFromSource(...args) {
@@ -371,18 +422,98 @@ var SharedQuery = class {
371
422
  return await this.dedupedFetch(key, ...args);
372
423
  }
373
424
  /** Delete the cached data for one set of arguments. */
374
- deleteData(args) {
375
- const queryKey = this.getQueryKey(args);
425
+ deleteData(queryKey) {
376
426
  const record = this.queryState.getSnapshot();
377
427
  const clone = { ...record };
378
428
  delete clone[queryKey];
379
429
  this.queryState.setValue(clone);
380
- this.inflightPromises.delete(queryKey);
430
+ const inflight = this.inflightQueries.get(queryKey);
431
+ if (inflight) {
432
+ this.inflightQueries.delete(queryKey);
433
+ inflight.abortController.abort("Deleted");
434
+ }
381
435
  }
382
436
  /** Delete all currently cached data & all inflight promises. */
383
437
  clear() {
384
438
  this.queryState.delete();
385
- this.inflightPromises.clear();
439
+ for (const { abortController } of Array.from(
440
+ this.inflightQueries.values()
441
+ )) {
442
+ abortController.abort("Cleared");
443
+ }
444
+ this.inflightQueries.clear();
445
+ }
446
+ /**
447
+ * Discard the oldest entries if the size exceeds the limit. The last
448
+ * remaining record cannot be deleted no matter what the limit is.
449
+ */
450
+ enforceSizeLimit() {
451
+ if (!this.maxSize && !this.maxBytes) {
452
+ return;
453
+ }
454
+ const oldState = this.queryState.getSnapshot();
455
+ const oldSize = Object.keys(oldState).length;
456
+ if (oldSize <= 1) {
457
+ return;
458
+ }
459
+ const oldByteSize = getByteSize(oldState);
460
+ let newState = oldState;
461
+ let needUpdate = false;
462
+ if (this.maxSize) {
463
+ const numToCut = oldSize - this.maxSize;
464
+ if (numToCut > 0) {
465
+ this.log(
466
+ `enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`
467
+ );
468
+ newState = { ...newState };
469
+ needUpdate = true;
470
+ Object.entries(newState).sort(
471
+ (entry1, entry2) => (
472
+ // Sort descending.
473
+ entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs
474
+ )
475
+ ).slice(-numToCut).forEach(([key]) => {
476
+ delete newState[key];
477
+ });
478
+ }
479
+ }
480
+ if (this.maxBytes && Object.keys(newState).length > 1) {
481
+ const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
482
+ let bytesToCut = currentByteSize - this.maxBytes;
483
+ if (bytesToCut > 0) {
484
+ this.log(
485
+ `enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`
486
+ );
487
+ if (!needUpdate) {
488
+ newState = { ...newState };
489
+ needUpdate = true;
490
+ }
491
+ const entriesByTimeAscending = Object.entries(newState).sort(
492
+ (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
493
+ );
494
+ for (
495
+ let i = 0;
496
+ // Leave the last record entriesByTimeAscending[len-1] untouched.
497
+ i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
498
+ i++
499
+ ) {
500
+ const key = entriesByTimeAscending[i][0];
501
+ const thisSize = getByteSize(key) + getByteSize(newState[key]);
502
+ delete newState[key];
503
+ bytesToCut -= thisSize;
504
+ }
505
+ }
506
+ }
507
+ if (needUpdate) {
508
+ this.queryState.setValue(newState);
509
+ this.log(
510
+ `Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`
511
+ );
512
+ return;
513
+ }
514
+ this.log(
515
+ `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
516
+ );
386
517
  }
387
518
  };
388
519
  var seenQueryNames = /* @__PURE__ */ new Set();
@@ -393,74 +524,94 @@ function sharedQuery(options) {
393
524
  seenQueryNames.add(options.queryName);
394
525
  return new SharedQuery(options);
395
526
  }
396
- var DEFAULT_QUERY_STATE_ENTRY = { loading: true };
527
+ var DEFAULT_QUERY_STATE_ENTRY = {
528
+ lastUpdatedMs: 0,
529
+ loading: true
530
+ };
397
531
  function useSharedQuery(query, args = []) {
532
+ const stableArgs = useDeepMemo(args);
398
533
  const [queryState, setQueryState] = useSharedState(query.queryState);
399
534
  const isMounted = useRef(true);
400
- const queryKey = query.getQueryKey(args);
401
- const execute = useCallback(async () => {
402
- console.log(`Begin executing shared query ${queryKey}`);
403
- setQueryState((prev) => {
404
- const clone = { ...prev };
405
- clone[queryKey] = {
406
- ...clone[queryKey] ?? {},
407
- loading: true
408
- };
409
- return clone;
410
- });
411
- try {
412
- const data = await query.getCachedOrFetch(...args);
413
- if (isMounted.current) {
414
- setQueryState((prev) => {
415
- const clone = { ...prev };
416
- clone[queryKey] = {
417
- // Don't keep old error.
418
- loading: false,
419
- data,
420
- dataUpdatedMs: Date.now()
421
- };
422
- return clone;
423
- });
424
- }
425
- } catch (e) {
426
- const error = e instanceof Error ? e : new Error(`An unknown error occurred during fetch: ${e}`);
427
- if (isMounted.current) {
428
- setQueryState((prev) => {
429
- const clone = { ...prev };
430
- clone[queryKey] = {
431
- // Keep old data.
432
- ...clone[queryKey] ?? {},
433
- loading: false,
434
- error,
435
- errorUpdatedMs: Date.now()
436
- };
437
- return clone;
438
- });
535
+ const queryKey = useMemo2(
536
+ () => query.getQueryKey(stableArgs),
537
+ [query, stableArgs]
538
+ );
539
+ const queryStateValue = queryState[queryKey];
540
+ const execute = useCallback(
541
+ async (forceRefresh) => {
542
+ query.log(`Begin executing shared query ${queryKey}`);
543
+ setQueryState((prev) => {
544
+ const clone = { ...prev };
545
+ clone[queryKey] = {
546
+ ...clone[queryKey] ?? {},
547
+ lastUpdatedMs: Date.now(),
548
+ loading: true
549
+ };
550
+ return clone;
551
+ });
552
+ try {
553
+ const data = await (forceRefresh ? query.updateFromSource(...stableArgs) : query.getCachedOrFetch(...stableArgs));
554
+ if (isMounted.current) {
555
+ setQueryState((prev) => {
556
+ const clone = { ...prev };
557
+ const now = Date.now();
558
+ clone[queryKey] = {
559
+ // Don't keep old error.
560
+ lastUpdatedMs: now,
561
+ loading: false,
562
+ data,
563
+ dataUpdatedMs: now
564
+ };
565
+ return clone;
566
+ });
567
+ }
568
+ } catch (e) {
569
+ if (isMounted.current) {
570
+ setQueryState((prev) => {
571
+ const clone = { ...prev };
572
+ const now = Date.now();
573
+ clone[queryKey] = {
574
+ // Keep old data.
575
+ ...clone[queryKey] ?? {},
576
+ lastUpdatedMs: now,
577
+ loading: false,
578
+ error: e,
579
+ errorUpdatedMs: now
580
+ };
581
+ return clone;
582
+ });
583
+ }
439
584
  }
440
- }
441
- }, [queryKey]);
585
+ },
586
+ [query, queryKey, setQueryState, stableArgs]
587
+ );
442
588
  useEffect(() => {
443
589
  isMounted.current = true;
444
- void execute();
590
+ void execute(false);
445
591
  return () => {
446
592
  isMounted.current = false;
447
593
  };
448
594
  }, [execute]);
449
- return useMemo(() => {
450
- const state = queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;
595
+ return useMemo2(() => {
596
+ const state = queryStateValue ?? DEFAULT_QUERY_STATE_ENTRY;
451
597
  return {
452
598
  ...state,
599
+ abortCurrentQuery: (reason) => {
600
+ const controller = query.getAbortController(queryKey);
601
+ controller?.abort(reason);
602
+ return Boolean(controller);
603
+ },
453
604
  refetch: () => {
454
- return query.updateFromSource(...args);
605
+ void execute(true);
455
606
  },
456
607
  setData: (data, dataUpdatedMs) => {
457
608
  query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
458
609
  },
459
610
  deleteData: () => {
460
- query.deleteData(args);
611
+ query.deleteData(queryKey);
461
612
  }
462
613
  };
463
- }, [query, queryKey, queryState]);
614
+ }, [execute, query, queryKey, queryStateValue]);
464
615
  }
465
616
  export {
466
617
  SharedQuery,