@choksheak/react-utils 0.1.6 → 0.1.7

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 (58) hide show
  1. package/docs/assets/navigation.js +1 -1
  2. package/docs/assets/search.js +1 -1
  3. package/docs/classes/sharedQuery.SharedQuery.html +15 -0
  4. package/docs/classes/sharedState.SharedState.html +8 -0
  5. package/docs/classes/sharedState.SharedStateConfig.html +5 -5
  6. package/docs/classes/utils_storage.InMemoryStorageAdapter.html +9 -0
  7. package/docs/classes/utils_storage.IndexedDbAdapter.html +9 -0
  8. package/docs/classes/utils_storage.LocalStorageAdapter.html +9 -0
  9. package/docs/functions/sharedQuery.sharedQuery.html +5 -0
  10. package/docs/functions/sharedQuery.useQuery.html +4 -0
  11. package/docs/functions/sharedState.sharedState.html +2 -2
  12. package/docs/functions/sharedState.useSharedState.html +2 -2
  13. package/docs/functions/sharedState.useSharedStateSetter.html +2 -2
  14. package/docs/functions/sharedState.useSharedStateValue.html +2 -2
  15. package/docs/functions/utils_storage.getStorageAdapter.html +1 -0
  16. package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +7 -0
  17. package/docs/modules/sharedQuery.html +1 -0
  18. package/docs/modules/sharedState.html +1 -1
  19. package/docs/modules/utils_storage.html +1 -0
  20. package/docs/modules/utils_stringify.html +1 -0
  21. package/docs/modules.html +1 -1
  22. package/docs/types/sharedQuery.FetchFn.html +1 -0
  23. package/docs/types/sharedQuery.FetchedData.html +3 -0
  24. package/docs/types/sharedQuery.QueryStateValue.html +4 -0
  25. package/docs/types/sharedQuery.SharedQueryOptions.html +1 -0
  26. package/docs/types/sharedQuery.SharedQueryState.html +3 -0
  27. package/docs/types/sharedState.SharedStateOptions.html +1 -0
  28. package/docs/types/utils_storage.StorageAdapter.html +5 -0
  29. package/docs/types/utils_storage.StorageOptions.html +5 -0
  30. package/docs/variables/fetcher.fetcher.html +1 -1
  31. package/docs/variables/fetcher.fetcherWithRetries.html +1 -1
  32. package/package.json +9 -6
  33. package/sharedQuery.d.mts +61 -0
  34. package/sharedQuery.d.ts +58 -0
  35. package/sharedQuery.js +471 -0
  36. package/sharedQuery.mjs +444 -0
  37. package/sharedState.d.mts +21 -16
  38. package/sharedState.d.ts +19 -15
  39. package/sharedState.js +156 -85
  40. package/sharedState.mjs +155 -85
  41. package/src/sharedQuery.ts +303 -0
  42. package/src/sharedState.ts +74 -137
  43. package/src/utils/storage.ts +197 -0
  44. package/src/utils/stringify.test.ts +38 -0
  45. package/src/utils/stringify.ts +49 -0
  46. package/utils/storage.d.mts +46 -0
  47. package/utils/storage.d.ts +44 -0
  48. package/utils/storage.js +162 -0
  49. package/utils/storage.mjs +134 -0
  50. package/utils/stringify.d.mts +12 -0
  51. package/utils/stringify.d.ts +10 -0
  52. package/utils/stringify.js +58 -0
  53. package/utils/stringify.mjs +33 -0
  54. package/utils/stringify.test.d.mts +2 -0
  55. package/utils/stringify.test.d.ts +2 -0
  56. package/utils/stringify.test.js +64 -0
  57. package/utils/stringify.test.mjs +81 -0
  58. package/docs/types/sharedState.SharedState.html +0 -6
package/sharedQuery.js ADDED
@@ -0,0 +1,471 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/sharedQuery.ts
21
+ var sharedQuery_exports = {};
22
+ __export(sharedQuery_exports, {
23
+ SharedQuery: () => SharedQuery,
24
+ sharedQuery: () => sharedQuery,
25
+ useQuery: () => useQuery
26
+ });
27
+ module.exports = __toCommonJS(sharedQuery_exports);
28
+ var import_kvStore3 = require("@choksheak/ts-utils/kvStore");
29
+ var import_react2 = require("react");
30
+
31
+ // src/sharedState.ts
32
+ var import_kvStore2 = require("@choksheak/ts-utils/kvStore");
33
+ var import_timeConstants = require("@choksheak/ts-utils/timeConstants");
34
+ var import_react = require("react");
35
+
36
+ // src/utils/storage.ts
37
+ var import_kvStore = require("@choksheak/ts-utils/kvStore");
38
+ function getStorageAdapter(options, defaultStoreExpiryMs) {
39
+ const { localStorageKey, indexedDbKey, storeExpiryMs, store } = options ?? {};
40
+ if (store) {
41
+ return store;
42
+ }
43
+ const expiryMs = storeExpiryMs || defaultStoreExpiryMs;
44
+ if (localStorageKey) {
45
+ return new LocalStorageAdapter(localStorageKey, expiryMs);
46
+ }
47
+ if (indexedDbKey) {
48
+ return new IndexedDbAdapter(indexedDbKey, expiryMs);
49
+ }
50
+ return null;
51
+ }
52
+ var LocalStorageAdapter = class {
53
+ constructor(keyPrefix, expiryMs) {
54
+ this.keyPrefix = keyPrefix;
55
+ this.expiryMs = expiryMs;
56
+ }
57
+ save(key, value) {
58
+ if (typeof window === "undefined") return;
59
+ const fullKey = this.keyPrefix + ":" + key;
60
+ window.localStorage.setItem(
61
+ fullKey,
62
+ JSON.stringify({
63
+ value,
64
+ expiresAt: this.expiryMs ? Date.now() + this.expiryMs : 0
65
+ })
66
+ );
67
+ }
68
+ load(key) {
69
+ if (typeof window === "undefined") return void 0;
70
+ const fullKey = this.keyPrefix + ":" + key;
71
+ const raw = window.localStorage.getItem(fullKey);
72
+ if (!raw) return void 0;
73
+ try {
74
+ const box = JSON.parse(raw);
75
+ if (!box || box.expiresAt && Date.now() >= box.expiresAt) {
76
+ window.localStorage.removeItem(fullKey);
77
+ return void 0;
78
+ }
79
+ return box.value;
80
+ } catch {
81
+ window.localStorage.removeItem(fullKey);
82
+ return void 0;
83
+ }
84
+ }
85
+ delete(key) {
86
+ if (typeof window === "undefined") return;
87
+ const fullKey = this.keyPrefix + ":" + key;
88
+ window.localStorage.removeItem(fullKey);
89
+ }
90
+ clear() {
91
+ if (typeof window === "undefined") return;
92
+ for (const key of Object.keys(window.localStorage)) {
93
+ if (key.startsWith(this.keyPrefix)) {
94
+ window.localStorage.removeItem(key);
95
+ }
96
+ }
97
+ }
98
+ };
99
+ var IndexedDbAdapter = class {
100
+ constructor(keyPrefix, expiryMs) {
101
+ this.keyPrefix = keyPrefix;
102
+ this.expiryMs = expiryMs;
103
+ }
104
+ async save(key, value) {
105
+ if (typeof window === "undefined") return;
106
+ const fullKey = this.keyPrefix + ":" + key;
107
+ await import_kvStore.kvStore.set(fullKey, JSON.stringify(value), this.expiryMs);
108
+ }
109
+ async load(key) {
110
+ if (typeof window === "undefined") return void 0;
111
+ const fullKey = this.keyPrefix + ":" + key;
112
+ const raw = await import_kvStore.kvStore.get(fullKey);
113
+ if (raw === void 0) return void 0;
114
+ try {
115
+ return JSON.parse(raw);
116
+ } catch {
117
+ await import_kvStore.kvStore.delete(fullKey);
118
+ return void 0;
119
+ }
120
+ }
121
+ async delete(key) {
122
+ if (typeof window === "undefined") return;
123
+ const fullKey = this.keyPrefix + ":" + key;
124
+ await import_kvStore.kvStore.delete(fullKey);
125
+ }
126
+ async clear() {
127
+ if (typeof window === "undefined") return;
128
+ await import_kvStore.kvStore.forEach(async (k) => {
129
+ if (k.startsWith(this.keyPrefix)) {
130
+ await import_kvStore.kvStore.delete(k);
131
+ }
132
+ });
133
+ }
134
+ };
135
+
136
+ // src/sharedState.ts
137
+ var SharedStateConfig = class {
138
+ /**
139
+ * Default duration for storage expiration in milliseconds. Initial value
140
+ * is 30 days, but can be changed to anything.
141
+ */
142
+ static storeExpiryMs = import_timeConstants.MS_PER_DAY * 30;
143
+ /**
144
+ * True to load from storage only on mount, false (default) to load in
145
+ * the top level scope when the shared state is defined.
146
+ */
147
+ static lazyLoad = false;
148
+ };
149
+ var PubSubStore = class {
150
+ dataByKey = /* @__PURE__ */ new Map();
151
+ subscribersByKey = /* @__PURE__ */ new Map();
152
+ // This could be undefined if T includes undefined, else it should never
153
+ // return undefined because the user has to always specify the default value.
154
+ get(key) {
155
+ return this.dataByKey.get(key);
156
+ }
157
+ set(key, value) {
158
+ const prev = this.dataByKey.get(key);
159
+ if (prev === value) return;
160
+ this.dataByKey.set(key, value);
161
+ this.notify(key, value, prev);
162
+ }
163
+ delete(key) {
164
+ this.dataByKey.delete(key);
165
+ }
166
+ setNoNotify(key, value) {
167
+ this.dataByKey.set(key, value);
168
+ }
169
+ // Subscribe to changes of a specific key
170
+ subscribe(key, subscriber) {
171
+ let subscribers = this.subscribersByKey.get(key);
172
+ if (!subscribers) {
173
+ subscribers = /* @__PURE__ */ new Set();
174
+ this.subscribersByKey.set(key, subscribers);
175
+ }
176
+ subscribers.add(subscriber);
177
+ return () => {
178
+ subscribers.delete(subscriber);
179
+ if (subscribers.size === 0) this.subscribersByKey.delete(key);
180
+ };
181
+ }
182
+ notify(key, next, prev) {
183
+ const subscribers = this.subscribersByKey.get(key);
184
+ if (!subscribers) return;
185
+ const copy = Array.from(subscribers.values());
186
+ for (const subscriber of copy) {
187
+ try {
188
+ subscriber(next, prev);
189
+ } catch (e) {
190
+ console.error(`Error invoking subscriber:`, e);
191
+ }
192
+ }
193
+ }
194
+ };
195
+ var pubSubStore = new PubSubStore();
196
+ var STORAGE_KEY = "state";
197
+ var stateKey = 0;
198
+ var SharedState = class {
199
+ constructor(defaultValue, options) {
200
+ this.defaultValue = defaultValue;
201
+ this.pubSubKey = String(stateKey++);
202
+ this.storageAdapter = getStorageAdapter(options, import_kvStore2.DEFAULT_EXPIRY_DELTA_MS);
203
+ pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
204
+ const lazyLoad = options?.lazyLoad;
205
+ const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateConfig.lazyLoad;
206
+ if (!lazy) {
207
+ void this.initDefaultValueOnce();
208
+ }
209
+ }
210
+ pubSubKey;
211
+ storageAdapter;
212
+ initDone = false;
213
+ // `subscribe` function required by useSyncExternalStore
214
+ subscribe(subscriber) {
215
+ return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
216
+ }
217
+ // Get the value of the shared state.
218
+ // `getSnapshot` function — must return same ref if value unchanged
219
+ getSnapshot() {
220
+ return pubSubStore.get(this.pubSubKey);
221
+ }
222
+ // Set the value of the shared state.
223
+ setValue(next) {
224
+ if (typeof next === "function") {
225
+ const prev = pubSubStore.get(this.pubSubKey);
226
+ next = next(prev);
227
+ }
228
+ pubSubStore.set(this.pubSubKey, next);
229
+ void this.storageAdapter?.save(STORAGE_KEY, next);
230
+ }
231
+ // Remove the value of the shared state. Actually this just sets the value
232
+ // back to the given default value.
233
+ delete() {
234
+ pubSubStore.set(this.pubSubKey, this.defaultValue);
235
+ void this.storageAdapter?.save(STORAGE_KEY, this.defaultValue);
236
+ }
237
+ // Initial default handling
238
+ async initDefaultValueOnce() {
239
+ if (this.initDone) return;
240
+ this.initDone = true;
241
+ const v = await this.storageAdapter?.load(STORAGE_KEY);
242
+ pubSubStore.set(this.pubSubKey, v !== void 0 ? v : this.defaultValue);
243
+ }
244
+ };
245
+ function sharedState(defaultValue, options) {
246
+ return new SharedState(defaultValue, options);
247
+ }
248
+ function useSharedState(state) {
249
+ state.initDefaultValueOnce();
250
+ const value = (0, import_react.useSyncExternalStore)(
251
+ state.subscribe,
252
+ state.getSnapshot,
253
+ // getServerSnapshot is needed to prevent SSR dev errors in nextjs.
254
+ state.getSnapshot
255
+ );
256
+ return [value, state.setValue];
257
+ }
258
+
259
+ // src/utils/stringify.ts
260
+ function stringifyDeterministicForKeys(params) {
261
+ const cache = /* @__PURE__ */ new Set();
262
+ return JSON.stringify(params, (_, value) => {
263
+ if (typeof value === "function") {
264
+ throw new Error(`Functions should not be passed into query parameters`);
265
+ }
266
+ if (!value || typeof value !== "object") {
267
+ return value;
268
+ }
269
+ if (cache.has(value)) {
270
+ return "[Circular]";
271
+ }
272
+ cache.add(value);
273
+ if (value !== params) {
274
+ try {
275
+ JSON.stringify(value);
276
+ } catch {
277
+ return "[Unserializable]";
278
+ }
279
+ }
280
+ return Object.keys(value).sort().reduce(
281
+ (result, key) => {
282
+ result[key] = value[key];
283
+ return result;
284
+ },
285
+ {}
286
+ );
287
+ });
288
+ }
289
+
290
+ // src/sharedQuery.ts
291
+ var DEFAULT_STALE_MS = 0;
292
+ var SharedQuery = class {
293
+ constructor(queryName, fetchFn, options) {
294
+ this.queryName = queryName;
295
+ this.fetchFn = fetchFn;
296
+ this.expiryMs = options?.expiryMs !== void 0 ? options?.expiryMs : import_kvStore3.DEFAULT_EXPIRY_DELTA_MS;
297
+ this.staleMs = options?.staleMs !== void 0 ? options?.staleMs : DEFAULT_STALE_MS;
298
+ this.revalidateOnStale = Boolean(options?.revalidateOnStale);
299
+ this.queryState = sharedState(
300
+ {},
301
+ {
302
+ ...options,
303
+ // Fallback to use expiryMs for the storeExpiryMs.
304
+ storeExpiryMs: options?.storeExpiryMs || this.expiryMs
305
+ }
306
+ );
307
+ }
308
+ inflightPromises = /* @__PURE__ */ new Map();
309
+ queryState;
310
+ expiryMs;
311
+ staleMs;
312
+ revalidateOnStale;
313
+ getQueryKey(args) {
314
+ return this.queryName + ":" + stringifyDeterministicForKeys(args);
315
+ }
316
+ isStale(entry) {
317
+ if (this.staleMs === 0) return true;
318
+ const ageMs = Date.now() - entry.updatedMs;
319
+ return ageMs > this.staleMs;
320
+ }
321
+ isExpired(entry) {
322
+ if (this.expiryMs === 0) return false;
323
+ const ageMs = Date.now() - entry.updatedMs;
324
+ return ageMs > this.expiryMs;
325
+ }
326
+ fetchNoCaching(queryKey, ...args) {
327
+ const inflightPromise = this.inflightPromises.get(queryKey);
328
+ if (inflightPromise) {
329
+ console.log(`Deduplicating inflight fetch for ${queryKey}`);
330
+ return inflightPromise;
331
+ }
332
+ console.log(`Start fetching ${queryKey}`);
333
+ const promise = this.fetchFn(...args).then((result) => {
334
+ console.log(`Successfully fetched ${queryKey}`);
335
+ this.setCache(queryKey, result);
336
+ return result;
337
+ }).catch((e) => {
338
+ console.error(`Failed to fetch ${queryKey}: ${e}`);
339
+ throw e;
340
+ }).finally(() => {
341
+ this.inflightPromises.delete(queryKey);
342
+ });
343
+ this.inflightPromises.set(queryKey, promise);
344
+ return promise;
345
+ }
346
+ async getCachedOrFetch(...args) {
347
+ const queryKey = this.getQueryKey(args);
348
+ const cached = this.queryState.getSnapshot()?.[queryKey];
349
+ if (cached?.data && !this.isExpired(cached.data)) {
350
+ if (!this.isStale(cached.data)) {
351
+ console.log(
352
+ `Return fresh data ${queryKey} from cache without fetching`
353
+ );
354
+ return cached.data.value;
355
+ }
356
+ if (this.revalidateOnStale) {
357
+ console.log(`revalidateOnStale for ${queryKey}`);
358
+ void this.fetchNoCaching(queryKey, ...args);
359
+ }
360
+ console.log(`Returning stale data for ${queryKey}`);
361
+ return cached.data.value;
362
+ }
363
+ return await this.fetchNoCaching(queryKey, ...args);
364
+ }
365
+ async setCache(queryKey, value) {
366
+ const entry = {
367
+ data: { value, updatedMs: Date.now() },
368
+ loading: false,
369
+ error: null
370
+ };
371
+ this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
372
+ }
373
+ /** Do a new fetch even when the data is already cached. */
374
+ async updateFromSource(...args) {
375
+ const key = this.getQueryKey(args);
376
+ return await this.fetchNoCaching(key, ...args);
377
+ }
378
+ /** Delete the currently cached data. */
379
+ invalidate(args) {
380
+ if (args) {
381
+ const queryKey = this.getQueryKey(args);
382
+ const record = this.queryState.getSnapshot();
383
+ const clone = { ...record };
384
+ delete clone[queryKey];
385
+ this.queryState.setValue(clone);
386
+ this.inflightPromises.delete(queryKey);
387
+ } else {
388
+ this.clearAll();
389
+ }
390
+ }
391
+ /** Delete the currently cached data & all inflight promises. */
392
+ clearAll() {
393
+ this.queryState.delete();
394
+ this.inflightPromises.clear();
395
+ }
396
+ };
397
+ var seenQueryNames = /* @__PURE__ */ new Set();
398
+ function sharedQuery(queryName, fetchFn, options) {
399
+ if (seenQueryNames.has(queryName)) {
400
+ throw new Error(`Duplicate shared query "${queryName}"`);
401
+ }
402
+ seenQueryNames.add(queryName);
403
+ return new SharedQuery(queryName, fetchFn, options);
404
+ }
405
+ var DEFAULT_QUERY_STATE_ENTRY = {
406
+ data: null,
407
+ loading: true,
408
+ error: null
409
+ };
410
+ function useQuery(query, args) {
411
+ const [queryState, setQueryState] = useSharedState(query.queryState);
412
+ const isMounted = (0, import_react2.useRef)(true);
413
+ const queryKey = query.getQueryKey(args);
414
+ const execute = (0, import_react2.useCallback)(async () => {
415
+ console.log(`Begin executing shared query ${queryKey}`);
416
+ setQueryState((prev) => {
417
+ const clone = { ...prev };
418
+ clone[queryKey] = {
419
+ data: clone[queryKey]?.data ?? null,
420
+ loading: true,
421
+ error: null
422
+ };
423
+ return clone;
424
+ });
425
+ try {
426
+ const value = await query.getCachedOrFetch(...args);
427
+ if (isMounted.current) {
428
+ setQueryState((prev) => {
429
+ const clone = { ...prev };
430
+ const now = Date.now();
431
+ clone[queryKey] = {
432
+ data: {
433
+ value,
434
+ updatedMs: now
435
+ },
436
+ loading: false,
437
+ error: null
438
+ };
439
+ return clone;
440
+ });
441
+ }
442
+ } catch (e) {
443
+ const error = e instanceof Error ? e : new Error(`An unknown error occurred during fetch: ${e}`);
444
+ if (isMounted.current) {
445
+ setQueryState((prev) => {
446
+ const clone = { ...prev };
447
+ clone[queryKey] = {
448
+ data: clone[queryKey]?.data ?? null,
449
+ loading: false,
450
+ error
451
+ };
452
+ return clone;
453
+ });
454
+ }
455
+ }
456
+ }, [queryKey]);
457
+ (0, import_react2.useEffect)(() => {
458
+ isMounted.current = true;
459
+ void execute();
460
+ return () => {
461
+ isMounted.current = false;
462
+ };
463
+ }, [execute]);
464
+ return queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;
465
+ }
466
+ // Annotate the CommonJS export names for ESM import in node:
467
+ 0 && (module.exports = {
468
+ SharedQuery,
469
+ sharedQuery,
470
+ useQuery
471
+ });