@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
@@ -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 FetchFn<TArgs extends unknown[], TData> = (
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
- error?: Error;
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
- refetch: () => Promise<TData>;
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: FetchFn<TArgs, TData>;
73
+ queryFn: QueryFn<TArgs, TData>;
45
74
 
46
- // ms before data considered stale; 0 means never stale.
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 inflightPromises = new Map<string, Promise<TData>>();
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: FetchFn<TArgs, TData>;
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.expiryMs =
84
- options?.expiryMs !== undefined
85
- ? options?.expiryMs
86
- : SharedQueryDefaults.expiryMs;
146
+ this.staleMs = options?.staleMs ?? SharedQueryDefaults.staleMs;
147
+ this.expiryMs = options?.expiryMs ?? SharedQueryDefaults.expiryMs;
87
148
 
88
- this.staleMs =
89
- options?.staleMs !== undefined
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
- const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
97
-
98
- if (persistTo === "localStorage") {
99
- options.localStorageKey = options.queryName;
100
- } else if (persistTo === "indexedDb") {
101
- options.indexedDbKey = options.queryName;
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
- console.log(
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
- console.log(`refetchOnStale for ${queryKey}`);
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
- console.log(`Returning stale data for ${queryKey}`);
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 inflightPromise = this.inflightPromises.get(queryKey);
240
+ const inflightQuery = this.inflightQueries.get(queryKey);
161
241
 
162
242
  // De-duplicate in-flight requests
163
- if (inflightPromise) {
164
- console.log(`Deduplicating inflight fetch for ${queryKey}`);
165
- return inflightPromise;
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
- console.log(`Start fetching ${queryKey}`);
258
+ this.log(`Start fetching ${queryKey}`);
177
259
 
178
260
  try {
179
261
  const data = await this.queryFn(...args);
180
262
 
181
- console.log(`Successfully fetched ${queryKey}`);
263
+ this.log(`Successfully fetched ${queryKey}`);
182
264
  this.setData(queryKey, data, Date.now());
183
265
  return data;
184
266
  } catch (e) {
185
- console.error(`Failed to fetch ${queryKey}: ${e}`);
267
+ this.log(`Failed to fetch ${queryKey}: ${e}`);
186
268
  throw e;
187
269
  } finally {
188
- this.inflightPromises.delete(queryKey);
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(args: TArgs): void {
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.inflightPromises.delete(queryKey);
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
- this.inflightPromises.clear();
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> = { loading: true };
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
- * const users = useSharedQuery(usersQuery, ["123"]);
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
- // Identify args changes using deep equality.
271
- // We intentionally chose not to memoize this value because args is likely
272
- // to be an unstable value (changes on every render).
273
- const queryKey = query.getQueryKey(args);
485
+ const queryKey = useMemo(
486
+ () => query.getQueryKey(stableArgs),
487
+ [query, stableArgs],
488
+ );
274
489
 
275
- // The fetch logic wrapped in useCallback to be stable for useEffect
276
- const execute = useCallback(async () => {
277
- console.log(`Begin executing shared query ${queryKey}`);
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
- try {
289
- const data = await query.getCachedOrFetch(...args);
290
-
291
- if (isMounted.current) {
292
- setQueryState((prev) => {
293
- const clone = { ...prev };
294
- clone[queryKey] = {
295
- // Don't keep old error.
296
- loading: false,
297
- data,
298
- dataUpdatedMs: Date.now(),
299
- };
300
- return clone;
301
- });
302
- }
303
- } catch (e) {
304
- const error =
305
- e instanceof Error
306
- ? e
307
- : new Error(`An unknown error occurred during fetch: ${e}`);
308
-
309
- if (isMounted.current) {
310
- setQueryState((prev) => {
311
- const clone = { ...prev };
312
- clone[queryKey] = {
313
- // Keep old data.
314
- ...(clone[queryKey] ?? {}),
315
- loading: false,
316
- error,
317
- errorUpdatedMs: Date.now(),
318
- };
319
- return clone;
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
- }, [queryKey]);
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
- queryState[queryKey] ??
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
- return query.updateFromSource(...args);
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(args);
584
+ query.deleteData(queryKey);
350
585
  },
351
586
  };
352
- }, [query, queryKey, queryState]);
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
+ }
@@ -0,0 +1,4 @@
1
+ /** Provides a stable value for T based on deep equality. */
2
+ declare function useDeepMemo<T>(value: T): T;
3
+
4
+ export { useDeepMemo };
@@ -0,0 +1,2 @@
1
+ /** Provides a stable value for T based on deep equality. */
2
+ export declare function useDeepMemo<T>(value: T): T;