@choksheak/react-utils 0.2.3 → 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 (56) hide show
  1. package/README.md +3 -1
  2. package/package.json +7 -2
  3. package/sharedQuery.d.mts +38 -7
  4. package/sharedQuery.d.ts +37 -6
  5. package/sharedQuery.js +149 -89
  6. package/sharedQuery.mjs +147 -87
  7. package/src/sharedQuery.ts +198 -117
  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 -71
  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 -22
  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 -9
  28. package/docs/functions/sharedQuery.useSharedQuery.html +0 -6
  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 -9
  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
package/sharedQuery.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/sharedQuery.ts
2
2
  import { MS_PER_DAY as MS_PER_DAY2 } from "@choksheak/ts-utils/timeConstants";
3
3
  import getByteSize from "object-sizeof";
4
- import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import { useCallback, useEffect, useMemo as useMemo2, useRef } from "react";
5
5
 
6
6
  // src/sharedState.ts
7
7
  import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
@@ -266,18 +266,39 @@ function stringifyDeterministicForKeys(params) {
266
266
  });
267
267
  }
268
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
+
269
283
  // src/sharedQuery.ts
270
284
  var SharedQueryDefaults = {
285
+ // Refetch on every page load.
271
286
  staleMs: 0,
287
+ // Keep in cache for a long time.
272
288
  expiryMs: 30 * MS_PER_DAY2,
289
+ // Always refetch automatically if the data is stale.
290
+ refetchOnStale: true,
291
+ // No default persistence configured.
273
292
  persistTo: void 0,
293
+ // 100 is not too large, but please tweak accordingly.
274
294
  maxSize: 100,
275
- // 100 is not too large, but please tweak accordingly
276
- maxBytes: 1e5
277
- // 100kb before GC
295
+ // 100kb limit before discarding old records.
296
+ maxBytes: 1e5,
297
+ // Default to log to console.
298
+ log: (...args) => console.log("[sharedQuery]", ...args)
278
299
  };
279
300
  var SharedQuery = class {
280
- inflightPromises = /* @__PURE__ */ new Map();
301
+ inflightQueries = /* @__PURE__ */ new Map();
281
302
  queryState;
282
303
  queryName;
283
304
  queryFn;
@@ -286,22 +307,27 @@ var SharedQuery = class {
286
307
  refetchOnStale;
287
308
  maxSize;
288
309
  maxBytes;
310
+ log;
289
311
  constructor(options) {
290
312
  this.queryName = options.queryName;
291
313
  this.queryFn = options.queryFn;
292
- this.expiryMs = options?.expiryMs !== void 0 ? options?.expiryMs : SharedQueryDefaults.expiryMs;
293
- this.staleMs = options?.staleMs !== void 0 ? options?.staleMs : SharedQueryDefaults.staleMs;
294
- this.refetchOnStale = Boolean(options?.refetchOnStale);
295
- const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
296
- if (persistTo === "localStorage") {
297
- options.localStorageKey = options.queryName;
298
- } else if (persistTo === "indexedDb") {
299
- 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
+ }
300
324
  }
301
325
  this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
302
326
  this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
327
+ this.log = options?.log ?? SharedQueryDefaults.log;
303
328
  this.queryState = sharedState(
304
329
  {},
330
+ // defaultValue
305
331
  {
306
332
  ...options,
307
333
  // Fallback to use expiryMs for the storeExpiryMs.
@@ -309,9 +335,24 @@ var SharedQuery = class {
309
335
  }
310
336
  );
311
337
  }
338
+ /** Returns a key to identify requests based on the given args. */
312
339
  getQueryKey(args) {
313
340
  return this.queryName + ":" + stringifyDeterministicForKeys(args);
314
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
+ }
315
356
  isStale(dataUpdatedMs) {
316
357
  if (this.staleMs === 0) return true;
317
358
  const ageMs = Date.now() - dataUpdatedMs;
@@ -327,42 +368,41 @@ var SharedQuery = class {
327
368
  const cached = this.queryState.getSnapshot()?.[queryKey];
328
369
  if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
329
370
  if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
330
- console.log(
331
- `Return fresh data ${queryKey} from cache without fetching`
332
- );
371
+ this.log(`Return fresh data ${queryKey} from cache without fetching`);
333
372
  return cached.data;
334
373
  }
335
374
  if (this.refetchOnStale) {
336
- console.log(`refetchOnStale for ${queryKey}`);
375
+ this.log(`refetchOnStale for ${queryKey}`);
337
376
  void this.dedupedFetch(queryKey, ...args);
338
377
  }
339
- console.log(`Returning stale data for ${queryKey}`);
378
+ this.log(`Returning stale data for ${queryKey}`);
340
379
  return cached.data;
341
380
  }
342
381
  return await this.dedupedFetch(queryKey, ...args);
343
382
  }
344
383
  dedupedFetch(queryKey, ...args) {
345
- const inflightPromise = this.inflightPromises.get(queryKey);
346
- if (inflightPromise) {
347
- console.log(`Deduplicating inflight fetch for ${queryKey}`);
348
- return inflightPromise;
384
+ const inflightQuery = this.inflightQueries.get(queryKey);
385
+ if (inflightQuery) {
386
+ this.log(`Deduplicating inflight fetch for ${queryKey}`);
387
+ return inflightQuery.promise;
349
388
  }
350
389
  const promise = this.startFetching(queryKey, args);
351
- this.inflightPromises.set(queryKey, promise);
390
+ const abortController = new AbortController();
391
+ this.inflightQueries.set(queryKey, { promise, abortController });
352
392
  return promise;
353
393
  }
354
394
  async startFetching(queryKey, args) {
355
- console.log(`Start fetching ${queryKey}`);
395
+ this.log(`Start fetching ${queryKey}`);
356
396
  try {
357
397
  const data = await this.queryFn(...args);
358
- console.log(`Successfully fetched ${queryKey}`);
398
+ this.log(`Successfully fetched ${queryKey}`);
359
399
  this.setData(queryKey, data, Date.now());
360
400
  return data;
361
401
  } catch (e) {
362
- console.error(`Failed to fetch ${queryKey}: ${e}`);
402
+ this.log(`Failed to fetch ${queryKey}: ${e}`);
363
403
  throw e;
364
404
  } finally {
365
- this.inflightPromises.delete(queryKey);
405
+ this.inflightQueries.delete(queryKey);
366
406
  }
367
407
  }
368
408
  /** Set the data directly if the user obtained it from somewhere else. */
@@ -382,18 +422,26 @@ var SharedQuery = class {
382
422
  return await this.dedupedFetch(key, ...args);
383
423
  }
384
424
  /** Delete the cached data for one set of arguments. */
385
- deleteData(args) {
386
- const queryKey = this.getQueryKey(args);
425
+ deleteData(queryKey) {
387
426
  const record = this.queryState.getSnapshot();
388
427
  const clone = { ...record };
389
428
  delete clone[queryKey];
390
429
  this.queryState.setValue(clone);
391
- 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
+ }
392
435
  }
393
436
  /** Delete all currently cached data & all inflight promises. */
394
437
  clear() {
395
438
  this.queryState.delete();
396
- this.inflightPromises.clear();
439
+ for (const { abortController } of Array.from(
440
+ this.inflightQueries.values()
441
+ )) {
442
+ abortController.abort("Cleared");
443
+ }
444
+ this.inflightQueries.clear();
397
445
  }
398
446
  /**
399
447
  * Discard the oldest entries if the size exceeds the limit. The last
@@ -414,7 +462,7 @@ var SharedQuery = class {
414
462
  if (this.maxSize) {
415
463
  const numToCut = oldSize - this.maxSize;
416
464
  if (numToCut > 0) {
417
- console.log(
465
+ this.log(
418
466
  `enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`
419
467
  );
420
468
  newState = { ...newState };
@@ -433,7 +481,7 @@ var SharedQuery = class {
433
481
  const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
434
482
  let bytesToCut = currentByteSize - this.maxBytes;
435
483
  if (bytesToCut > 0) {
436
- console.log(
484
+ this.log(
437
485
  `enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`
438
486
  );
439
487
  if (!needUpdate) {
@@ -458,12 +506,12 @@ var SharedQuery = class {
458
506
  }
459
507
  if (needUpdate) {
460
508
  this.queryState.setValue(newState);
461
- console.log(
509
+ this.log(
462
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()})`
463
511
  );
464
512
  return;
465
513
  }
466
- console.log(
514
+ this.log(
467
515
  `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
468
516
  );
469
517
  }
@@ -481,77 +529,89 @@ var DEFAULT_QUERY_STATE_ENTRY = {
481
529
  loading: true
482
530
  };
483
531
  function useSharedQuery(query, args = []) {
532
+ const stableArgs = useDeepMemo(args);
484
533
  const [queryState, setQueryState] = useSharedState(query.queryState);
485
534
  const isMounted = useRef(true);
486
- const queryKey = query.getQueryKey(args);
487
- const execute = useCallback(async () => {
488
- console.log(`Begin executing shared query ${queryKey}`);
489
- setQueryState((prev) => {
490
- const clone = { ...prev };
491
- clone[queryKey] = {
492
- ...clone[queryKey] ?? {},
493
- lastUpdatedMs: Date.now(),
494
- loading: true
495
- };
496
- return clone;
497
- });
498
- try {
499
- const data = await query.getCachedOrFetch(...args);
500
- if (isMounted.current) {
501
- setQueryState((prev) => {
502
- const clone = { ...prev };
503
- const now = Date.now();
504
- clone[queryKey] = {
505
- // Don't keep old error.
506
- lastUpdatedMs: now,
507
- loading: false,
508
- data,
509
- dataUpdatedMs: now
510
- };
511
- return clone;
512
- });
513
- }
514
- } catch (e) {
515
- const error = e instanceof Error ? e : new Error(`An unknown error occurred during fetch: ${e}`);
516
- if (isMounted.current) {
517
- setQueryState((prev) => {
518
- const clone = { ...prev };
519
- const now = Date.now();
520
- clone[queryKey] = {
521
- // Keep old data.
522
- ...clone[queryKey] ?? {},
523
- lastUpdatedMs: now,
524
- loading: false,
525
- error,
526
- errorUpdatedMs: now
527
- };
528
- return clone;
529
- });
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
+ }
530
584
  }
531
- }
532
- }, [queryKey]);
585
+ },
586
+ [query, queryKey, setQueryState, stableArgs]
587
+ );
533
588
  useEffect(() => {
534
589
  isMounted.current = true;
535
- void execute();
590
+ void execute(false);
536
591
  return () => {
537
592
  isMounted.current = false;
538
593
  };
539
594
  }, [execute]);
540
- return useMemo(() => {
541
- const state = queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;
595
+ return useMemo2(() => {
596
+ const state = queryStateValue ?? DEFAULT_QUERY_STATE_ENTRY;
542
597
  return {
543
598
  ...state,
599
+ abortCurrentQuery: (reason) => {
600
+ const controller = query.getAbortController(queryKey);
601
+ controller?.abort(reason);
602
+ return Boolean(controller);
603
+ },
544
604
  refetch: () => {
545
- return query.updateFromSource(...args);
605
+ void execute(true);
546
606
  },
547
607
  setData: (data, dataUpdatedMs) => {
548
608
  query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
549
609
  },
550
610
  deleteData: () => {
551
- query.deleteData(args);
611
+ query.deleteData(queryKey);
552
612
  }
553
613
  };
554
- }, [query, queryKey, queryState]);
614
+ }, [execute, query, queryKey, queryStateValue]);
555
615
  }
556
616
  export {
557
617
  SharedQuery,