@choksheak/react-utils 0.2.3 → 0.2.5

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 (93) hide show
  1. package/README.md +3 -1
  2. package/fetcher.min.cjs +2 -0
  3. package/fetcher.min.cjs.map +1 -0
  4. package/fetcher.min.mjs +2 -0
  5. package/fetcher.min.mjs.map +1 -0
  6. package/index.min.cjs +2 -0
  7. package/index.min.cjs.map +1 -0
  8. package/index.min.mjs +2 -0
  9. package/index.min.mjs.map +1 -0
  10. package/package.json +38 -5
  11. package/{sharedQuery.js → sharedQuery.cjs} +202 -79
  12. package/sharedQuery.d.mts +73 -9
  13. package/sharedQuery.d.ts +72 -8
  14. package/sharedQuery.min.cjs +2 -0
  15. package/sharedQuery.min.cjs.map +1 -0
  16. package/sharedQuery.min.mjs +2 -0
  17. package/sharedQuery.min.mjs.map +1 -0
  18. package/sharedQuery.mjs +200 -77
  19. package/sharedState.min.cjs +2 -0
  20. package/sharedState.min.cjs.map +1 -0
  21. package/sharedState.min.mjs +2 -0
  22. package/sharedState.min.mjs.map +1 -0
  23. package/utils/storage.min.cjs +2 -0
  24. package/utils/storage.min.cjs.map +1 -0
  25. package/utils/storage.min.mjs +2 -0
  26. package/utils/storage.min.mjs.map +1 -0
  27. package/utils/stringify.min.cjs +2 -0
  28. package/utils/stringify.min.cjs.map +1 -0
  29. package/utils/stringify.min.mjs +2 -0
  30. package/utils/stringify.min.mjs.map +1 -0
  31. package/utils/useDeepMemo.cjs +51 -0
  32. package/utils/useDeepMemo.d.mts +4 -0
  33. package/utils/useDeepMemo.d.ts +2 -0
  34. package/utils/useDeepMemo.min.cjs +2 -0
  35. package/utils/useDeepMemo.min.cjs.map +1 -0
  36. package/utils/useDeepMemo.min.mjs +2 -0
  37. package/utils/useDeepMemo.min.mjs.map +1 -0
  38. package/utils/useDeepMemo.mjs +16 -0
  39. package/docs/.nojekyll +0 -1
  40. package/docs/assets/hierarchy.js +0 -1
  41. package/docs/assets/highlight.css +0 -71
  42. package/docs/assets/icons.js +0 -18
  43. package/docs/assets/icons.svg +0 -1
  44. package/docs/assets/main.js +0 -60
  45. package/docs/assets/navigation.js +0 -1
  46. package/docs/assets/search.js +0 -1
  47. package/docs/assets/style.css +0 -1633
  48. package/docs/classes/sharedQuery.SharedQuery.html +0 -22
  49. package/docs/classes/sharedState.SharedState.html +0 -9
  50. package/docs/classes/utils_storage.InMemoryStorageAdapter.html +0 -9
  51. package/docs/classes/utils_storage.IndexedDbAdapter.html +0 -9
  52. package/docs/classes/utils_storage.LocalStorageAdapter.html +0 -9
  53. package/docs/functions/sharedQuery.sharedQuery.html +0 -9
  54. package/docs/functions/sharedQuery.useSharedQuery.html +0 -6
  55. package/docs/functions/sharedState.sharedState.html +0 -3
  56. package/docs/functions/sharedState.useSharedState.html +0 -3
  57. package/docs/functions/sharedState.useSharedStateSetter.html +0 -2
  58. package/docs/functions/sharedState.useSharedStateValue.html +0 -2
  59. package/docs/functions/utils_storage.getStorageAdapter.html +0 -1
  60. package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +0 -7
  61. package/docs/hierarchy.html +0 -1
  62. package/docs/index.html +0 -2
  63. package/docs/modules/fetcher.html +0 -1
  64. package/docs/modules/index.html +0 -1
  65. package/docs/modules/sharedQuery.html +0 -1
  66. package/docs/modules/sharedState.html +0 -1
  67. package/docs/modules/utils_storage.html +0 -1
  68. package/docs/modules/utils_stringify.html +0 -1
  69. package/docs/modules.html +0 -1
  70. package/docs/types/sharedQuery.FetchFn.html +0 -1
  71. package/docs/types/sharedQuery.PersistTo.html +0 -1
  72. package/docs/types/sharedQuery.QueryStateValue.html +0 -9
  73. package/docs/types/sharedQuery.SharedQueryOptions.html +0 -1
  74. package/docs/types/sharedQuery.SharedQueryState.html +0 -3
  75. package/docs/types/sharedQuery.UseQueryResult.html +0 -1
  76. package/docs/types/sharedState.SharedStateOptions.html +0 -1
  77. package/docs/types/utils_storage.StorageAdapter.html +0 -5
  78. package/docs/types/utils_storage.StorageOptions.html +0 -5
  79. package/docs/variables/fetcher.fetcher.html +0 -2
  80. package/docs/variables/fetcher.fetcherWithRetries.html +0 -2
  81. package/docs/variables/sharedQuery.SharedQueryDefaults.html +0 -2
  82. package/docs/variables/sharedState.SharedStateDefaults.html +0 -6
  83. package/src/fetcher.ts +0 -14
  84. package/src/index.ts +0 -4
  85. package/src/sharedQuery.ts +0 -507
  86. package/src/sharedState.ts +0 -232
  87. package/src/utils/storage.ts +0 -197
  88. package/src/utils/stringify.ts +0 -49
  89. /package/{fetcher.js → fetcher.cjs} +0 -0
  90. /package/{index.js → index.cjs} +0 -0
  91. /package/{sharedState.js → sharedState.cjs} +0 -0
  92. /package/utils/{storage.js → storage.cjs} +0 -0
  93. /package/utils/{stringify.js → stringify.cjs} +0 -0
@@ -1,507 +0,0 @@
1
- import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
2
- import getByteSize from "object-sizeof";
3
- import { useCallback, useEffect, useMemo, useRef } from "react";
4
-
5
- import {
6
- SharedState,
7
- sharedState,
8
- SharedStateOptions,
9
- useSharedState,
10
- } from "./sharedState";
11
- import { stringifyDeterministicForKeys } from "./utils/stringify";
12
-
13
- export type FetchFn<TArgs extends unknown[], TData> = (
14
- ...args: TArgs
15
- ) => Promise<TData> | TData;
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
-
24
- loading: boolean;
25
- data?: TData;
26
- dataUpdatedMs?: number;
27
- error?: Error;
28
- errorUpdatedMs?: number;
29
- };
30
-
31
- export type UseQueryResult<TData> = QueryStateValue<TData> & {
32
- refetch: () => Promise<TData>;
33
- setData: (data: TData, dataUpdatedMs?: number) => void;
34
- deleteData: () => void;
35
- };
36
-
37
- export type PersistTo = "localStorage" | "indexedDb";
38
-
39
- /**
40
- * The full query state is a record of all the fetch states mapped by
41
- * query key to the state entry.
42
- */
43
- export type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;
44
-
45
- export type SharedQueryOptions<TArgs extends unknown[], TData> = {
46
- // The name could have been auto-generated, but we let the user give us a
47
- // human-readable name that can be identified quickly in the logs.
48
- queryName: string;
49
-
50
- // Function to fetch data.
51
- queryFn: FetchFn<TArgs, TData>;
52
-
53
- // ms before data considered stale; 0 means always stale.
54
- staleMs?: number;
55
-
56
- // ms before data is removed from cache; 0 means never expire.
57
- expiryMs?: number;
58
-
59
- // Trigger background re-fetch if stale.
60
- refetchOnStale?: boolean;
61
-
62
- // Shortcut to set the localStorageKey or indexedDbKey automatically from
63
- // queryName. This is important because it avoids requiring the user to
64
- // specify the same queryName twice everytime.
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;
74
- } & SharedStateOptions<SharedQueryState<TData>>;
75
-
76
- /** Users can override these values globally. */
77
- export const SharedQueryDefaults = {
78
- staleMs: 0,
79
- expiryMs: 30 * MS_PER_DAY,
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
83
- };
84
-
85
- export class SharedQuery<TArgs extends unknown[], TData> {
86
- private readonly inflightPromises = new Map<string, Promise<TData>>();
87
- public readonly queryState: SharedState<SharedQueryState<TData>>;
88
-
89
- public readonly queryName: string;
90
- private readonly queryFn: FetchFn<TArgs, TData>;
91
- public readonly expiryMs: number;
92
- public readonly staleMs: number;
93
- public readonly refetchOnStale: boolean;
94
- public readonly maxSize: number;
95
- public readonly maxBytes: number;
96
-
97
- public constructor(options: SharedQueryOptions<TArgs, TData>) {
98
- this.queryName = options.queryName;
99
-
100
- this.queryFn = options.queryFn;
101
-
102
- this.expiryMs =
103
- options?.expiryMs !== undefined
104
- ? options?.expiryMs
105
- : SharedQueryDefaults.expiryMs;
106
-
107
- this.staleMs =
108
- options?.staleMs !== undefined
109
- ? options?.staleMs
110
- : SharedQueryDefaults.staleMs;
111
-
112
- this.refetchOnStale = Boolean(options?.refetchOnStale);
113
-
114
- // Apply shortcut when using `persistTo`.
115
- const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
116
-
117
- if (persistTo === "localStorage") {
118
- options.localStorageKey = options.queryName;
119
- } else if (persistTo === "indexedDb") {
120
- options.indexedDbKey = options.queryName;
121
- }
122
-
123
- this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
124
- this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
125
-
126
- this.queryState = sharedState<SharedQueryState<TData>>(
127
- {},
128
- {
129
- ...options,
130
- // Fallback to use expiryMs for the storeExpiryMs.
131
- storeExpiryMs: options?.storeExpiryMs || this.expiryMs,
132
- },
133
- );
134
- }
135
-
136
- public getQueryKey(args: TArgs): string {
137
- return this.queryName + ":" + stringifyDeterministicForKeys(args);
138
- }
139
-
140
- private isStale(dataUpdatedMs: number): boolean {
141
- if (this.staleMs === 0) return true; // always stale
142
-
143
- const ageMs = Date.now() - dataUpdatedMs;
144
- return ageMs > this.staleMs;
145
- }
146
-
147
- private isExpired(dataUpdatedMs: number): boolean {
148
- if (this.expiryMs === 0) return false; // never expire
149
-
150
- const ageMs = Date.now() - dataUpdatedMs;
151
- return ageMs > this.expiryMs;
152
- }
153
-
154
- public async getCachedOrFetch(...args: TArgs): Promise<TData> {
155
- const queryKey = this.getQueryKey(args);
156
- const cached = this.queryState.getSnapshot()?.[queryKey];
157
-
158
- if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
159
- // Return cached value if not stale.
160
- if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
161
- console.log(
162
- `Return fresh data ${queryKey} from cache without fetching`,
163
- );
164
- return cached.data;
165
- }
166
-
167
- // If stale, optionally update the data in the background.
168
- if (this.refetchOnStale) {
169
- console.log(`refetchOnStale for ${queryKey}`);
170
- void this.dedupedFetch(queryKey, ...args);
171
- }
172
-
173
- // Still return stale data immediately.
174
- console.log(`Returning stale data for ${queryKey}`);
175
- return cached.data;
176
- }
177
-
178
- return await this.dedupedFetch(queryKey, ...args);
179
- }
180
-
181
- private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {
182
- const inflightPromise = this.inflightPromises.get(queryKey);
183
-
184
- // De-duplicate in-flight requests
185
- if (inflightPromise) {
186
- console.log(`Deduplicating inflight fetch for ${queryKey}`);
187
- return inflightPromise;
188
- }
189
-
190
- // Fetch new data
191
- const promise = this.startFetching(queryKey, args);
192
-
193
- this.inflightPromises.set(queryKey, promise);
194
- return promise;
195
- }
196
-
197
- private async startFetching(queryKey: string, args: TArgs) {
198
- console.log(`Start fetching ${queryKey}`);
199
-
200
- try {
201
- const data = await this.queryFn(...args);
202
-
203
- console.log(`Successfully fetched ${queryKey}`);
204
- this.setData(queryKey, data, Date.now());
205
- return data;
206
- } catch (e) {
207
- console.error(`Failed to fetch ${queryKey}: ${e}`);
208
- throw e;
209
- } finally {
210
- this.inflightPromises.delete(queryKey);
211
- }
212
- }
213
-
214
- /** Set the data directly if the user obtained it from somewhere else. */
215
- public async setData(
216
- queryKey: string,
217
- data: TData,
218
- dataUpdatedMs = Date.now(),
219
- ): Promise<void> {
220
- const entry: QueryStateValue<TData> = {
221
- lastUpdatedMs: Date.now(),
222
- loading: false,
223
- data,
224
- dataUpdatedMs,
225
- };
226
-
227
- this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
228
-
229
- this.enforceSizeLimit();
230
- }
231
-
232
- /** Do a new fetch even when the data is already cached. */
233
- public async updateFromSource(...args: TArgs): Promise<TData> {
234
- const key = this.getQueryKey(args);
235
- return await this.dedupedFetch(key, ...args);
236
- }
237
-
238
- /** Delete the cached data for one set of arguments. */
239
- public deleteData(args: TArgs): void {
240
- const queryKey = this.getQueryKey(args);
241
-
242
- const record = this.queryState.getSnapshot();
243
- const clone = { ...record };
244
- delete clone[queryKey];
245
- this.queryState.setValue(clone);
246
-
247
- this.inflightPromises.delete(queryKey);
248
- }
249
-
250
- /** Delete all currently cached data & all inflight promises. */
251
- public clear(): void {
252
- this.queryState.delete();
253
- this.inflightPromises.clear();
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
- }
357
- }
358
-
359
- const seenQueryNames = new Set<string>();
360
-
361
- /**
362
- * Create a reusable shared query object that can be used to auto de-duplicate
363
- * and cache all data fetches, with auto-expiration and staleness checks.
364
- *
365
- * Each `queryName` must be unique, and can only be declared once, most often
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
- * ```
377
- */
378
- export function sharedQuery<TArgs extends unknown[], TData>(
379
- // The name could have been auto-generated, but we let the user give us a
380
- // human-readable name that can be identified quickly in the logs.
381
- options: SharedQueryOptions<TArgs, TData>,
382
- ): SharedQuery<TArgs, TData> {
383
- if (seenQueryNames.has(options.queryName)) {
384
- throw new Error(`Duplicate shared query "${options.queryName}"`);
385
- }
386
- seenQueryNames.add(options.queryName);
387
-
388
- return new SharedQuery(options);
389
- }
390
-
391
- const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
392
- lastUpdatedMs: 0,
393
- loading: true,
394
- };
395
-
396
- /**
397
- * React hook to make use of a shared query inside any React component.
398
- *
399
- * Example:
400
- * ```
401
- * const users = useSharedQuery(usersQuery, ["123"]);
402
- * ```
403
- */
404
- export function useSharedQuery<TArgs extends unknown[], TData>(
405
- query: SharedQuery<TArgs, TData>,
406
- // Default to use no arguments.
407
- args: TArgs = [] as unknown as TArgs,
408
- ): UseQueryResult<TData> {
409
- const [queryState, setQueryState] = useSharedState(query.queryState);
410
-
411
- const isMounted = useRef(true);
412
-
413
- // Identify args changes using deep equality.
414
- // We intentionally chose not to memoize this value because args is likely
415
- // to be an unstable value (changes on every render).
416
- const queryKey = query.getQueryKey(args);
417
-
418
- // The fetch logic wrapped in useCallback to be stable for useEffect
419
- const execute = useCallback(async () => {
420
- console.log(`Begin executing shared query ${queryKey}`);
421
-
422
- setQueryState((prev) => {
423
- const clone = { ...prev };
424
-
425
- clone[queryKey] = {
426
- ...(clone[queryKey] ?? {}),
427
- lastUpdatedMs: Date.now(),
428
- loading: true,
429
- };
430
-
431
- return clone;
432
- });
433
-
434
- try {
435
- const data = await query.getCachedOrFetch(...args);
436
-
437
- if (isMounted.current) {
438
- setQueryState((prev) => {
439
- const clone = { ...prev };
440
- const now = Date.now();
441
-
442
- clone[queryKey] = {
443
- // Don't keep old error.
444
- lastUpdatedMs: now,
445
- loading: false,
446
- data,
447
- dataUpdatedMs: now,
448
- };
449
-
450
- return clone;
451
- });
452
- }
453
- } catch (e) {
454
- const error =
455
- e instanceof Error
456
- ? e
457
- : new Error(`An unknown error occurred during fetch: ${e}`);
458
-
459
- if (isMounted.current) {
460
- setQueryState((prev) => {
461
- const clone = { ...prev };
462
- const now = Date.now();
463
-
464
- clone[queryKey] = {
465
- // Keep old data.
466
- ...(clone[queryKey] ?? {}),
467
- lastUpdatedMs: now,
468
- loading: false,
469
- error,
470
- errorUpdatedMs: now,
471
- };
472
-
473
- return clone;
474
- });
475
- }
476
- }
477
- }, [queryKey]);
478
-
479
- useEffect(() => {
480
- isMounted.current = true;
481
-
482
- void execute();
483
-
484
- return () => {
485
- isMounted.current = false;
486
- };
487
- }, [execute]);
488
-
489
- return useMemo(() => {
490
- const state =
491
- queryState[queryKey] ??
492
- (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
493
-
494
- return {
495
- ...state,
496
- refetch: () => {
497
- return query.updateFromSource(...args);
498
- },
499
- setData: (data: TData, dataUpdatedMs?: number) => {
500
- query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
501
- },
502
- deleteData: () => {
503
- query.deleteData(args);
504
- },
505
- };
506
- }, [query, queryKey, queryState]);
507
- }