@choksheak/react-utils 0.2.4 → 0.2.6

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 (48) hide show
  1. package/README.md +2 -0
  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/package.json +32 -7
  7. package/{sharedQuery.js → sharedQuery.cjs} +106 -43
  8. package/sharedQuery.d.mts +52 -5
  9. package/sharedQuery.d.ts +51 -5
  10. package/sharedQuery.min.cjs +2 -0
  11. package/sharedQuery.min.cjs.map +1 -0
  12. package/sharedQuery.min.mjs +2 -0
  13. package/sharedQuery.min.mjs.map +1 -0
  14. package/sharedQuery.mjs +106 -43
  15. package/sharedState.d.mts +3 -0
  16. package/sharedState.d.ts +3 -1
  17. package/sharedState.min.cjs +2 -0
  18. package/sharedState.min.cjs.map +1 -0
  19. package/sharedState.min.mjs +2 -0
  20. package/sharedState.min.mjs.map +1 -0
  21. package/utils/storage.min.cjs +2 -0
  22. package/utils/storage.min.cjs.map +1 -0
  23. package/utils/storage.min.mjs +2 -0
  24. package/utils/storage.min.mjs.map +1 -0
  25. package/utils/stringify.min.cjs +2 -0
  26. package/utils/stringify.min.cjs.map +1 -0
  27. package/utils/stringify.min.mjs +2 -0
  28. package/utils/stringify.min.mjs.map +1 -0
  29. package/utils/useDeepMemo.min.cjs +2 -0
  30. package/utils/useDeepMemo.min.cjs.map +1 -0
  31. package/utils/useDeepMemo.min.mjs +2 -0
  32. package/utils/useDeepMemo.min.mjs.map +1 -0
  33. package/index.d.mts +0 -2
  34. package/index.d.ts +0 -0
  35. package/index.js +0 -4
  36. package/index.mjs +0 -12
  37. package/src/fetcher.ts +0 -14
  38. package/src/index.ts +0 -4
  39. package/src/sharedQuery.ts +0 -588
  40. package/src/sharedState.ts +0 -232
  41. package/src/utils/storage.ts +0 -197
  42. package/src/utils/stringify.ts +0 -49
  43. package/src/utils/useDeepMemo.ts +0 -19
  44. /package/{fetcher.js → fetcher.cjs} +0 -0
  45. /package/{sharedState.js → sharedState.cjs} +0 -0
  46. /package/utils/{storage.js → storage.cjs} +0 -0
  47. /package/utils/{stringify.js → stringify.cjs} +0 -0
  48. /package/utils/{useDeepMemo.js → useDeepMemo.cjs} +0 -0
package/index.mjs DELETED
@@ -1,12 +0,0 @@
1
- var __getOwnPropNames = Object.getOwnPropertyNames;
2
- var __commonJS = (cb, mod) => function __require() {
3
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
4
- };
5
-
6
- // src/index.ts
7
- var require_index = __commonJS({
8
- "src/index.ts"() {
9
- console.log(`Hello from Chok's React Utils`);
10
- }
11
- });
12
- export default require_index();
package/src/fetcher.ts DELETED
@@ -1,14 +0,0 @@
1
- import wretch from "wretch";
2
- import { retry } from "wretch/middlewares";
3
-
4
- /** Name this as "fetcher" to make it easier to understand. */
5
- export const fetcher = wretch();
6
-
7
- /** Fetcher with default short retries. */
8
- export const fetcherWithRetries = wretch().middlewares([
9
- retry({
10
- maxAttempts: 3,
11
- delayTimer: 1000,
12
- retryOnNetworkError: true,
13
- }),
14
- ]);
package/src/index.ts DELETED
@@ -1,4 +0,0 @@
1
- // Unused file. To support full tree-shaking, please import from the individual
2
- // files directly.
3
-
4
- console.log(`Hello from Chok's React Utils`);
@@ -1,588 +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
- import { useDeepMemo } from "./utils/useDeepMemo";
13
-
14
- export type QueryFn<TArgs extends unknown[], TData> = (
15
- ...args: TArgs
16
- ) => Promise<TData> | TData;
17
-
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
-
25
- loading: boolean;
26
- data?: TData;
27
- dataUpdatedMs?: number;
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
-
36
- errorUpdatedMs?: number;
37
- };
38
-
39
- export type UseQueryResult<TData> = QueryStateValue<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
- */
54
- setData: (data: TData, dataUpdatedMs?: number) => void;
55
-
56
- deleteData: () => void;
57
- };
58
-
59
- export type PersistTo = "localStorage" | "indexedDb";
60
-
61
- /**
62
- * The full query state is a record of all the fetch states mapped by
63
- * query key to the state entry.
64
- */
65
- export type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;
66
-
67
- export type SharedQueryOptions<TArgs extends unknown[], TData> = {
68
- // The name could have been auto-generated, but we let the user give us a
69
- // human-readable name that can be identified quickly in the logs.
70
- queryName: string;
71
-
72
- // Function to fetch data.
73
- queryFn: QueryFn<TArgs, TData>;
74
-
75
- // ms before data considered stale; 0 means always stale.
76
- staleMs?: number;
77
-
78
- // ms before data is removed from cache; 0 means never expire.
79
- expiryMs?: number;
80
-
81
- // Trigger background re-fetch if stale.
82
- refetchOnStale?: boolean;
83
-
84
- // Shortcut to set the localStorageKey or indexedDbKey automatically from
85
- // queryName. This is important because it avoids requiring the user to
86
- // specify the same queryName twice everytime.
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;
99
- } & SharedStateOptions<SharedQueryState<TData>>;
100
-
101
- /** Users can override these values globally. */
102
- export const SharedQueryDefaults = {
103
- // Refetch on every page load.
104
- staleMs: 0,
105
-
106
- // Keep in cache for a long time.
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.
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),
123
- };
124
-
125
- export class SharedQuery<TArgs extends unknown[], TData> {
126
- private readonly inflightQueries = new Map<
127
- string,
128
- { promise: Promise<TData>; abortController: AbortController }
129
- >();
130
-
131
- public readonly queryState: SharedState<SharedQueryState<TData>>;
132
-
133
- public readonly queryName: string;
134
- private readonly queryFn: QueryFn<TArgs, TData>;
135
- public readonly expiryMs: number;
136
- public readonly staleMs: number;
137
- public readonly refetchOnStale: boolean;
138
- public readonly maxSize: number;
139
- public readonly maxBytes: number;
140
- public readonly log: (...args: unknown[]) => void;
141
-
142
- public constructor(options: SharedQueryOptions<TArgs, TData>) {
143
- this.queryName = options.queryName;
144
- this.queryFn = options.queryFn;
145
-
146
- this.staleMs = options?.staleMs ?? SharedQueryDefaults.staleMs;
147
- this.expiryMs = options?.expiryMs ?? SharedQueryDefaults.expiryMs;
148
-
149
- this.refetchOnStale =
150
- options?.refetchOnStale ?? SharedQueryDefaults.refetchOnStale;
151
-
152
- // Apply shortcut when using `persistTo`.
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
- }
162
- }
163
-
164
- this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
165
- this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
166
-
167
- this.log = options?.log ?? SharedQueryDefaults.log;
168
-
169
- this.queryState = sharedState<SharedQueryState<TData>>(
170
- {}, // defaultValue
171
- {
172
- ...options,
173
- // Fallback to use expiryMs for the storeExpiryMs.
174
- storeExpiryMs: options?.storeExpiryMs || this.expiryMs,
175
- },
176
- );
177
- }
178
-
179
- /** Returns a key to identify requests based on the given args. */
180
- public getQueryKey(args: TArgs): string {
181
- return this.queryName + ":" + stringifyDeterministicForKeys(args);
182
- }
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
-
200
- private isStale(dataUpdatedMs: number): boolean {
201
- if (this.staleMs === 0) return true; // always stale
202
-
203
- const ageMs = Date.now() - dataUpdatedMs;
204
- return ageMs > this.staleMs;
205
- }
206
-
207
- private isExpired(dataUpdatedMs: number): boolean {
208
- if (this.expiryMs === 0) return false; // never expire
209
-
210
- const ageMs = Date.now() - dataUpdatedMs;
211
- return ageMs > this.expiryMs;
212
- }
213
-
214
- public async getCachedOrFetch(...args: TArgs): Promise<TData> {
215
- const queryKey = this.getQueryKey(args);
216
- const cached = this.queryState.getSnapshot()?.[queryKey];
217
-
218
- if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
219
- // Return cached value if not stale.
220
- if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
221
- this.log(`Return fresh data ${queryKey} from cache without fetching`);
222
- return cached.data;
223
- }
224
-
225
- // If stale, optionally update the data in the background.
226
- if (this.refetchOnStale) {
227
- this.log(`refetchOnStale for ${queryKey}`);
228
- void this.dedupedFetch(queryKey, ...args);
229
- }
230
-
231
- // Still return stale data immediately.
232
- this.log(`Returning stale data for ${queryKey}`);
233
- return cached.data;
234
- }
235
-
236
- return await this.dedupedFetch(queryKey, ...args);
237
- }
238
-
239
- private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {
240
- const inflightQuery = this.inflightQueries.get(queryKey);
241
-
242
- // De-duplicate in-flight requests
243
- if (inflightQuery) {
244
- this.log(`Deduplicating inflight fetch for ${queryKey}`);
245
- return inflightQuery.promise;
246
- }
247
-
248
- // Fetch new data
249
- const promise = this.startFetching(queryKey, args);
250
- const abortController = new AbortController();
251
-
252
- this.inflightQueries.set(queryKey, { promise, abortController });
253
-
254
- return promise;
255
- }
256
-
257
- private async startFetching(queryKey: string, args: TArgs) {
258
- this.log(`Start fetching ${queryKey}`);
259
-
260
- try {
261
- const data = await this.queryFn(...args);
262
-
263
- this.log(`Successfully fetched ${queryKey}`);
264
- this.setData(queryKey, data, Date.now());
265
- return data;
266
- } catch (e) {
267
- this.log(`Failed to fetch ${queryKey}: ${e}`);
268
- throw e;
269
- } finally {
270
- this.inflightQueries.delete(queryKey);
271
- }
272
- }
273
-
274
- /** Set the data directly if the user obtained it from somewhere else. */
275
- public async setData(
276
- queryKey: string,
277
- data: TData,
278
- dataUpdatedMs = Date.now(),
279
- ): Promise<void> {
280
- const entry: QueryStateValue<TData> = {
281
- lastUpdatedMs: Date.now(),
282
- loading: false,
283
- data,
284
- dataUpdatedMs,
285
- };
286
-
287
- this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
288
-
289
- this.enforceSizeLimit();
290
- }
291
-
292
- /** Do a new fetch even when the data is already cached. */
293
- public async updateFromSource(...args: TArgs): Promise<TData> {
294
- const key = this.getQueryKey(args);
295
- return await this.dedupedFetch(key, ...args);
296
- }
297
-
298
- /** Delete the cached data for one set of arguments. */
299
- public deleteData(queryKey: string): void {
300
- const record = this.queryState.getSnapshot();
301
- const clone = { ...record };
302
- delete clone[queryKey];
303
- this.queryState.setValue(clone);
304
-
305
- const inflight = this.inflightQueries.get(queryKey);
306
- if (inflight) {
307
- this.inflightQueries.delete(queryKey);
308
- inflight.abortController.abort("Deleted");
309
- }
310
- }
311
-
312
- /** Delete all currently cached data & all inflight promises. */
313
- public clear(): void {
314
- this.queryState.delete();
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
- );
425
- }
426
- }
427
-
428
- const seenQueryNames = new Set<string>();
429
-
430
- /**
431
- * Create a reusable shared query object that can be used to auto de-duplicate
432
- * and cache all data fetches, with auto-expiration and staleness checks.
433
- *
434
- * Each `queryName` must be unique, and can only be declared once, most often
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
- * ```
446
- */
447
- export function sharedQuery<TArgs extends unknown[], TData>(
448
- // The name could have been auto-generated, but we let the user give us a
449
- // human-readable name that can be identified quickly in the logs.
450
- options: SharedQueryOptions<TArgs, TData>,
451
- ): SharedQuery<TArgs, TData> {
452
- if (seenQueryNames.has(options.queryName)) {
453
- throw new Error(`Duplicate shared query "${options.queryName}"`);
454
- }
455
- seenQueryNames.add(options.queryName);
456
-
457
- return new SharedQuery(options);
458
- }
459
-
460
- const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
461
- lastUpdatedMs: 0,
462
- loading: true,
463
- };
464
-
465
- /**
466
- * React hook to make use of a shared query inside any React component.
467
- *
468
- * Example:
469
- * ```
470
- * const users = useSharedQuery(usersQuery, ["123"]);
471
- * ```
472
- */
473
- export function useSharedQuery<TArgs extends unknown[], TData>(
474
- query: SharedQuery<TArgs, TData>,
475
- // Default to use no arguments.
476
- args: TArgs = [] as unknown as TArgs,
477
- ): UseQueryResult<TData> {
478
- // We don't expect users to provide a stable `args`, so stabilize it here.
479
- const stableArgs = useDeepMemo(args);
480
-
481
- const [queryState, setQueryState] = useSharedState(query.queryState);
482
-
483
- const isMounted = useRef(true);
484
-
485
- const queryKey = useMemo(
486
- () => query.getQueryKey(stableArgs),
487
- [query, stableArgs],
488
- );
489
-
490
- // Make sure typechecker knows this could be undefined.
491
- const queryStateValue = queryState[queryKey] as
492
- | QueryStateValue<TData>
493
- | undefined;
494
-
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
- }
551
- }
552
- },
553
- [query, queryKey, setQueryState, stableArgs],
554
- );
555
-
556
- useEffect(() => {
557
- isMounted.current = true;
558
-
559
- void execute(false);
560
-
561
- return () => {
562
- isMounted.current = false;
563
- };
564
- }, [execute]);
565
-
566
- return useMemo(() => {
567
- const state =
568
- queryStateValue ?? (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
569
-
570
- return {
571
- ...state,
572
- abortCurrentQuery: (reason?: unknown) => {
573
- const controller = query.getAbortController(queryKey);
574
- controller?.abort(reason);
575
- return Boolean(controller);
576
- },
577
- refetch: () => {
578
- void execute(true);
579
- },
580
- setData: (data: TData, dataUpdatedMs?: number) => {
581
- query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
582
- },
583
- deleteData: () => {
584
- query.deleteData(queryKey);
585
- },
586
- };
587
- }, [execute, query, queryKey, queryStateValue]);
588
- }