@choksheak/react-utils 0.3.6 → 0.3.8

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 (54) hide show
  1. package/NoSSR.cjs +37 -0
  2. package/NoSSR.d.mts +24 -0
  3. package/NoSSR.d.ts +20 -0
  4. package/NoSSR.min.cjs +2 -0
  5. package/NoSSR.min.cjs.map +1 -0
  6. package/NoSSR.min.mjs +2 -0
  7. package/NoSSR.min.mjs.map +1 -0
  8. package/NoSSR.mjs +12 -0
  9. package/README.md +10 -1
  10. package/fetcher.cjs +3 -3
  11. package/fetcher.d.mts +5 -4
  12. package/fetcher.d.ts +4 -3
  13. package/fetcher.min.cjs +1 -1
  14. package/fetcher.min.cjs.map +1 -1
  15. package/fetcher.min.mjs +1 -1
  16. package/fetcher.min.mjs.map +1 -1
  17. package/fetcher.mjs +2 -2
  18. package/package.json +21 -3
  19. package/sharedQuery.cjs +486 -486
  20. package/sharedQuery.d.mts +64 -61
  21. package/sharedQuery.d.ts +62 -59
  22. package/sharedQuery.min.cjs +1 -1
  23. package/sharedQuery.min.cjs.map +1 -1
  24. package/sharedQuery.min.mjs +1 -1
  25. package/sharedQuery.min.mjs.map +1 -1
  26. package/sharedQuery.mjs +487 -486
  27. package/sharedState.cjs +201 -199
  28. package/sharedState.d.mts +48 -38
  29. package/sharedState.d.ts +45 -36
  30. package/sharedState.min.cjs +1 -1
  31. package/sharedState.min.cjs.map +1 -1
  32. package/sharedState.min.mjs +1 -1
  33. package/sharedState.min.mjs.map +1 -1
  34. package/sharedState.mjs +202 -199
  35. package/storage.cjs +79 -65
  36. package/storage.d.mts +39 -2
  37. package/storage.d.ts +37 -1
  38. package/storage.min.cjs +1 -1
  39. package/storage.min.cjs.map +1 -1
  40. package/storage.min.mjs +1 -1
  41. package/storage.min.mjs.map +1 -1
  42. package/storage.mjs +77 -65
  43. package/stringify.d.mts +9 -0
  44. package/stringify.d.ts +9 -0
  45. package/stringify.min.cjs.map +1 -1
  46. package/stringify.min.mjs.map +1 -1
  47. package/useDeepMemo.cjs +1 -1
  48. package/useDeepMemo.d.mts +11 -0
  49. package/useDeepMemo.d.ts +11 -0
  50. package/useDeepMemo.min.cjs +1 -1
  51. package/useDeepMemo.min.cjs.map +1 -1
  52. package/useDeepMemo.min.mjs +1 -1
  53. package/useDeepMemo.min.mjs.map +1 -1
  54. package/useDeepMemo.mjs +1 -1
package/sharedQuery.mjs CHANGED
@@ -15,7 +15,7 @@ import { useSyncExternalStore } from "react";
15
15
 
16
16
  // src/storage.ts
17
17
  import { kvStore } from "@choksheak/ts-utils/kvStore";
18
- import { LocalStore } from "@choksheak/ts-utils/localStore";
18
+ import { createLocalStore } from "@choksheak/ts-utils/localStore";
19
19
  var IS_NOT_BROWSER = typeof window === "undefined";
20
20
  function getStorageAdapter(options, defaultStoreExpiryMs) {
21
21
  const { store } = options ?? {};
@@ -24,70 +24,12 @@ function getStorageAdapter(options, defaultStoreExpiryMs) {
24
24
  }
25
25
  if ("persistTo" in store && typeof store.persistTo === "string" && "key" in store && typeof store.key === "string") {
26
26
  const expiryMs = store.expiryMs || defaultStoreExpiryMs;
27
- const isValid = store.isValid;
27
+ const config = { ...store, expiryMs };
28
28
  switch (store.persistTo) {
29
- case "localStorage": {
30
- const adapter = new LocalStore(store.key, {
31
- defaultExpiryMs: expiryMs
32
- });
33
- return {
34
- set: (key, value) => {
35
- if (IS_NOT_BROWSER) return;
36
- adapter.set(key, value);
37
- },
38
- get: (key) => {
39
- if (IS_NOT_BROWSER) return void 0;
40
- const value = adapter.get(key);
41
- if (isValid && !isValid(value)) {
42
- console.warn(
43
- `localStorage adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`
44
- );
45
- adapter.delete(key);
46
- return void 0;
47
- }
48
- return value;
49
- },
50
- delete: (key) => {
51
- if (IS_NOT_BROWSER) return;
52
- adapter.delete(key);
53
- },
54
- clear: () => {
55
- if (IS_NOT_BROWSER) return;
56
- adapter.clear();
57
- }
58
- };
59
- }
60
- case "indexedDb": {
61
- const keyPrefix = store.key + ":";
62
- const isValid2 = store.isValid;
63
- return {
64
- set: async (key, value) => {
65
- if (IS_NOT_BROWSER) return;
66
- await kvStore.set(keyPrefix + key, value, expiryMs);
67
- },
68
- get: async (key) => {
69
- if (IS_NOT_BROWSER) return void 0;
70
- const fullKey = keyPrefix + key;
71
- const value = await kvStore.get(fullKey);
72
- if (isValid2 && !isValid2(value)) {
73
- console.warn(
74
- `indexedDb adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`
75
- );
76
- await kvStore.delete(fullKey);
77
- return void 0;
78
- }
79
- return value;
80
- },
81
- delete: async (key) => {
82
- if (IS_NOT_BROWSER) return;
83
- await kvStore.delete(keyPrefix + key);
84
- },
85
- clear: async () => {
86
- if (IS_NOT_BROWSER) return;
87
- await kvStore.clear();
88
- }
89
- };
90
- }
29
+ case "localStorage":
30
+ return localStoreAdapter(config);
31
+ case "indexedDb":
32
+ return kvStoreAdapter(config);
91
33
  default:
92
34
  store.persistTo;
93
35
  throw new Error(`Unknown store.adapter ${store.persistTo}`);
@@ -95,6 +37,74 @@ function getStorageAdapter(options, defaultStoreExpiryMs) {
95
37
  }
96
38
  return store;
97
39
  }
40
+ function localStoreAdapter({
41
+ key,
42
+ expiryMs,
43
+ isValid
44
+ }) {
45
+ const adapter = createLocalStore(key, { defaultExpiryMs: expiryMs });
46
+ return {
47
+ set: (key2, value) => {
48
+ if (IS_NOT_BROWSER) return;
49
+ adapter.set(key2, value);
50
+ },
51
+ get: (key2) => {
52
+ if (IS_NOT_BROWSER) return void 0;
53
+ const value = adapter.get(key2);
54
+ if (isValid && !isValid(value)) {
55
+ console.warn(
56
+ `localStorage adapter: Auto-discard invalid value for ${key2}: ${JSON.stringify(value)}`
57
+ );
58
+ adapter.delete(key2);
59
+ return void 0;
60
+ }
61
+ return value;
62
+ },
63
+ delete: (key2) => {
64
+ if (IS_NOT_BROWSER) return;
65
+ adapter.delete(key2);
66
+ },
67
+ clear: () => {
68
+ if (IS_NOT_BROWSER) return;
69
+ adapter.clear();
70
+ }
71
+ };
72
+ }
73
+ function kvStoreAdapter({
74
+ key,
75
+ expiryMs,
76
+ isValid
77
+ }) {
78
+ const keyPrefix = key + ":";
79
+ return {
80
+ set: async (key2, value) => {
81
+ if (IS_NOT_BROWSER) return;
82
+ await kvStore.set(keyPrefix + key2, value, expiryMs);
83
+ },
84
+ get: async (key2) => {
85
+ if (IS_NOT_BROWSER) return void 0;
86
+ const fullKey = keyPrefix + key2;
87
+ const value = await kvStore.get(fullKey);
88
+ if (value !== void 0 && isValid && !isValid(value)) {
89
+ console.warn(
90
+ `indexedDb adapter: Auto-discard invalid value for ${key2}:`,
91
+ value
92
+ );
93
+ await kvStore.delete(fullKey);
94
+ return void 0;
95
+ }
96
+ return value;
97
+ },
98
+ delete: async (key2) => {
99
+ if (IS_NOT_BROWSER) return;
100
+ await kvStore.delete(keyPrefix + key2);
101
+ },
102
+ clear: async () => {
103
+ if (IS_NOT_BROWSER) return;
104
+ await kvStore.clear();
105
+ }
106
+ };
107
+ }
98
108
 
99
109
  // src/sharedState.ts
100
110
  var SharedStateConfig = {
@@ -109,41 +119,11 @@ var SharedStateConfig = {
109
119
  */
110
120
  lazyLoad: false
111
121
  };
112
- var PubSubStore = class {
113
- dataByKey = /* @__PURE__ */ new Map();
114
- subscribersByKey = /* @__PURE__ */ new Map();
115
- // This could be undefined if T includes undefined, else it should never
116
- // return undefined because the user has to always specify the default value.
117
- get(key) {
118
- return this.dataByKey.get(key);
119
- }
120
- set(key, value) {
121
- const prev = this.dataByKey.get(key);
122
- if (prev === value) return;
123
- this.dataByKey.set(key, value);
124
- this.notify(key, value, prev);
125
- }
126
- delete(key) {
127
- this.dataByKey.delete(key);
128
- }
129
- setNoNotify(key, value) {
130
- this.dataByKey.set(key, value);
131
- }
132
- // Subscribe to changes of a specific key
133
- subscribe(key, subscriber) {
134
- let subscribers = this.subscribersByKey.get(key);
135
- if (!subscribers) {
136
- subscribers = /* @__PURE__ */ new Set();
137
- this.subscribersByKey.set(key, subscribers);
138
- }
139
- subscribers.add(subscriber);
140
- return () => {
141
- subscribers.delete(subscriber);
142
- if (subscribers.size === 0) this.subscribersByKey.delete(key);
143
- };
144
- }
145
- notify(key, next, prev) {
146
- const subscribers = this.subscribersByKey.get(key);
122
+ function createPubSubStore() {
123
+ const dataByKey = /* @__PURE__ */ new Map();
124
+ const subscribersByKey = /* @__PURE__ */ new Map();
125
+ function notify(key, next, prev) {
126
+ const subscribers = subscribersByKey.get(key);
147
127
  if (!subscribers) return;
148
128
  const copy = Array.from(subscribers.values());
149
129
  for (const subscriber of copy) {
@@ -154,116 +134,139 @@ var PubSubStore = class {
154
134
  }
155
135
  }
156
136
  }
157
- };
158
- var pubSubStore = new PubSubStore();
137
+ return {
138
+ // This could be undefined if T includes undefined, else it should never
139
+ // return undefined because the user has to always specify the default value.
140
+ get(key) {
141
+ return dataByKey.get(key);
142
+ },
143
+ set(key, value) {
144
+ const prev = dataByKey.get(key);
145
+ if (prev === value) return;
146
+ dataByKey.set(key, value);
147
+ notify(key, value, prev);
148
+ },
149
+ delete(key) {
150
+ dataByKey.delete(key);
151
+ },
152
+ setNoNotify(key, value) {
153
+ dataByKey.set(key, value);
154
+ },
155
+ // Subscribe to changes of a specific key
156
+ subscribe(key, subscriber) {
157
+ let subscribers = subscribersByKey.get(key);
158
+ if (!subscribers) {
159
+ subscribers = /* @__PURE__ */ new Set();
160
+ subscribersByKey.set(key, subscribers);
161
+ }
162
+ subscribers.add(subscriber);
163
+ return () => {
164
+ subscribers.delete(subscriber);
165
+ if (subscribers.size === 0) subscribersByKey.delete(key);
166
+ };
167
+ }
168
+ };
169
+ }
170
+ var pubSubStore = createPubSubStore();
159
171
  var STORAGE_KEY = "state";
160
172
  var stateKey = 0;
161
- var SharedState = class {
162
- constructor(defaultValue, options) {
163
- this.defaultValue = defaultValue;
164
- let readyResolve = () => {
165
- };
166
- this.readyPromise = new Promise((resolve) => {
167
- readyResolve = resolve;
168
- });
169
- this.resolveReadyPromise = readyResolve;
170
- this.pubSubKey = String(stateKey++);
171
- this.storageAdapter = getStorageAdapter(
172
- options,
173
- SharedStateConfig.storeExpiryMs
174
- );
175
- this.setValueBounded = (next) => {
176
- this.setValue(next);
177
- };
178
- pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
179
- const lazyLoad = options?.lazyLoad;
180
- const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateConfig.lazyLoad;
181
- if (!lazy) {
182
- void this.initDefaultValueOnce();
183
- }
184
- }
185
- pubSubKey;
186
- storageAdapter;
187
- setValueBounded;
188
- initStarted = false;
189
- initDone = false;
190
- // Allow users to await for the completion of data init.
191
- readyPromise;
192
- resolveReadyPromise;
193
- /** `subscribe` function required by useSyncExternalStore. */
194
- subscribe(subscriber) {
195
- return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
196
- }
197
- /** Returns true if there is a backing store. */
198
- hasStore() {
199
- return Boolean(this.storageAdapter);
200
- }
201
- /**
202
- * Set a value directly into the store. Does nothing if there is no
203
- * backing store.
204
- *
205
- * Warning: If you want to set the value properly, you should use
206
- * `setValue()` instead which also updates the in-memory copy.
207
- */
208
- async setInStore(value) {
209
- return await this.storageAdapter?.set(STORAGE_KEY, value);
210
- }
211
- /**
212
- * Get a value directly from the store. Returns undefined if there is no
213
- * backing store.
214
- */
215
- async getFromStore() {
216
- return await this.storageAdapter?.get(STORAGE_KEY);
217
- }
218
- /**
219
- * Get the value of the shared state.
220
- * `getSnapshot` function — must return same ref if value unchanged.
221
- * Note that this would return the default value if data init from store is
222
- * not completed yet.
223
- */
224
- getSnapshot() {
225
- return pubSubStore.get(this.pubSubKey);
226
- }
227
- /** Set the value of the shared state. */
228
- setValue(next) {
229
- if (typeof next === "function") {
230
- const prev = pubSubStore.get(this.pubSubKey);
231
- next = next(prev);
173
+ function sharedState(defaultValue, options) {
174
+ let readyResolve = () => {
175
+ };
176
+ const readyPromise = new Promise((resolve) => {
177
+ readyResolve = resolve;
178
+ });
179
+ const resolveReadyPromise = readyResolve;
180
+ const pubSubKey = String(stateKey++);
181
+ const storageAdapter = getStorageAdapter(
182
+ options,
183
+ SharedStateConfig.storeExpiryMs
184
+ );
185
+ pubSubStore.setNoNotify(pubSubKey, defaultValue);
186
+ const lazyLoad = options?.lazyLoad;
187
+ const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateConfig.lazyLoad;
188
+ const obj = {
189
+ /** Allow users to await for the completion of data init. */
190
+ readyPromise,
191
+ lazy,
192
+ initStarted: false,
193
+ initDone: false,
194
+ /** `subscribe` function required by useSyncExternalStore. */
195
+ subscribe(subscriber) {
196
+ return pubSubStore.subscribe(pubSubKey, () => subscriber());
197
+ },
198
+ /** Returns true if there is a backing store. */
199
+ hasStore() {
200
+ return Boolean(storageAdapter);
201
+ },
202
+ /**
203
+ * Set a value directly into the store. Does nothing if there is no
204
+ * backing store.
205
+ *
206
+ * Warning: If you want to set the value properly, you should use
207
+ * `setValue()` instead which also updates the in-memory copy.
208
+ */
209
+ async setInStore(value) {
210
+ return await storageAdapter?.set(STORAGE_KEY, value);
211
+ },
212
+ /**
213
+ * Get a value directly from the store. Returns undefined if there is no
214
+ * backing store.
215
+ */
216
+ async getFromStore() {
217
+ return await storageAdapter?.get(STORAGE_KEY);
218
+ },
219
+ /**
220
+ * Get the value of the shared state.
221
+ * `getSnapshot` function — must return same ref if value unchanged.
222
+ * Note that this would return the default value if data init from store is
223
+ * not completed yet.
224
+ */
225
+ getSnapshot() {
226
+ return pubSubStore.get(pubSubKey);
227
+ },
228
+ /** Set the value of the shared state. */
229
+ setValue(next) {
230
+ if (typeof next === "function") {
231
+ const prev = pubSubStore.get(pubSubKey);
232
+ next = next(prev);
233
+ }
234
+ pubSubStore.set(pubSubKey, next);
235
+ void obj.setInStore(next);
236
+ },
237
+ /**
238
+ * Remove the value of the shared state. Actually this just sets the value
239
+ * back to the given default value.
240
+ */
241
+ delete() {
242
+ pubSubStore.set(pubSubKey, defaultValue);
243
+ void storageAdapter?.set(STORAGE_KEY, defaultValue);
244
+ },
245
+ /** Initial default handling. */
246
+ async initDefaultValueOnce() {
247
+ if (obj.initDone || obj.initStarted) return;
248
+ obj.initStarted = true;
249
+ const stored = await obj.getFromStore();
250
+ const value = stored !== void 0 ? stored : defaultValue;
251
+ pubSubStore.set(pubSubKey, value);
252
+ obj.initDone = true;
253
+ resolveReadyPromise();
232
254
  }
233
- pubSubStore.set(this.pubSubKey, next);
234
- void this.setInStore(next);
255
+ };
256
+ if (!lazy) {
257
+ void obj.initDefaultValueOnce();
235
258
  }
236
- /**
237
- * Remove the value of the shared state. Actually this just sets the value
238
- * back to the given default value.
239
- */
240
- delete() {
241
- pubSubStore.set(this.pubSubKey, this.defaultValue);
242
- void this.storageAdapter?.set(STORAGE_KEY, this.defaultValue);
243
- }
244
- /** Initial default handling. */
245
- async initDefaultValueOnce() {
246
- if (this.initDone || this.initStarted) return;
247
- this.initStarted = true;
248
- const stored = await this.getFromStore();
249
- const value = stored !== void 0 ? stored : this.defaultValue;
250
- pubSubStore.set(this.pubSubKey, value);
251
- this.initDone = true;
252
- this.resolveReadyPromise();
253
- }
254
- };
255
- function sharedState(defaultValue, options) {
256
- return new SharedState(defaultValue, options);
259
+ return obj;
257
260
  }
258
261
  function useSharedState(state) {
259
- state.initDefaultValueOnce();
262
+ void state.initDefaultValueOnce();
260
263
  const value = useSyncExternalStore(
261
264
  (onStoreChange) => state.subscribe(onStoreChange),
262
265
  () => state.getSnapshot(),
263
266
  // getServerSnapshot is needed to prevent SSR dev errors in nextjs.
264
267
  () => state.getSnapshot()
265
268
  );
266
- return [value, state.setValueBounded];
269
+ return [value, state.setValue];
267
270
  }
268
271
 
269
272
  // src/stringify.ts
@@ -303,7 +306,7 @@ import { useMemo, useState } from "react";
303
306
  function useDeepMemo(value) {
304
307
  const [state, setState] = useState(value);
305
308
  return useMemo(() => {
306
- if (state === value || isEqual(state, value)) {
309
+ if (isEqual(state, value)) {
307
310
  return state;
308
311
  }
309
312
  setState(value);
@@ -331,282 +334,104 @@ var SharedQueryConfig = {
331
334
  function configureSharedQuery(config) {
332
335
  Object.assign(SharedQueryConfig, config);
333
336
  }
334
- var SharedQuery = class {
335
- inflightQueries = /* @__PURE__ */ new Map();
336
- queryState;
337
- /**
338
- * Keep track of the mounted query keys. This handles the case where an
339
- * earlier-started query returns later, and when it returns, it was already
340
- * unmounted, thus making it eligible for clean up right after fetch.
341
- */
342
- mountedKeys = /* @__PURE__ */ new Map();
343
- queryName;
344
- queryFn;
345
- refetchFn;
346
- expiryMs;
347
- staleMs;
348
- refetchOnStale;
349
- maxSize;
350
- maxBytes;
351
- keepLastNonEmptyData;
352
- log;
353
- constructor(options) {
354
- this.queryName = options.queryName;
355
- this.queryFn = options.queryFn;
356
- this.refetchFn = options.refetchFn;
357
- this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;
358
- this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;
359
- this.refetchOnStale = options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;
360
- if (!options.store) {
361
- const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;
362
- if (persistTo) {
363
- options.store = {
337
+ var seenQueryNames = /* @__PURE__ */ new Set();
338
+ function sharedQuery(options) {
339
+ if (seenQueryNames.has(options.queryName)) {
340
+ throw new Error(`Duplicate shared query name "${options.queryName}"`);
341
+ }
342
+ seenQueryNames.add(options.queryName);
343
+ const inflightQueries = /* @__PURE__ */ new Map();
344
+ const mountedKeys = /* @__PURE__ */ new Map();
345
+ const queryName = options.queryName;
346
+ const queryFn = options.queryFn;
347
+ const refetchFn = options.refetchFn;
348
+ const staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;
349
+ const expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;
350
+ const refetchOnStale = options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;
351
+ const store = options.store;
352
+ if (store) {
353
+ if ("persistTo" in store && store.isValid) {
354
+ const isValid = store.isValid;
355
+ options = {
356
+ ...options,
357
+ store: {
358
+ ...store,
359
+ isValid: (u) => {
360
+ if (!u || typeof u !== "object") {
361
+ return false;
362
+ }
363
+ for (const v of Object.values(u)) {
364
+ if (!v || typeof v !== "object" || !("data" in v) || !("dataUpdatedMs" in v) || !("lastUpdatedMs" in v) || typeof v.dataUpdatedMs !== "number" || typeof v.lastUpdatedMs !== "number" || !isValid(v.data)) {
365
+ return false;
366
+ }
367
+ }
368
+ return true;
369
+ }
370
+ }
371
+ };
372
+ }
373
+ } else {
374
+ const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;
375
+ if (persistTo) {
376
+ options = {
377
+ ...options,
378
+ store: {
364
379
  persistTo,
365
- key: options.queryName,
366
- expiryMs: this.expiryMs
367
- };
368
- }
380
+ key: queryName,
381
+ expiryMs
382
+ }
383
+ };
369
384
  }
370
- this.maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;
371
- this.maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;
372
- this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);
373
- this.log = options?.log ?? SharedQueryConfig.log;
374
- this.queryState = sharedState(
375
- {},
376
- // defaultValue
377
- options
378
- );
379
385
  }
380
- /** Returns a key to identify requests based on the given args. */
381
- getQueryKey(args) {
382
- return this.queryName + ":" + stringifyDeterministicForKeys(args);
383
- }
384
- /**
385
- * Get the AbortController to abort the inflight query (if any).
386
- *
387
- * Note that once you get the controller, you can also get the signal easily
388
- * using `controller.signal`. So if you need both the controller and the
389
- * signal, just use this function.
390
- */
391
- getAbortController(args) {
392
- const queryKey = this.getQueryKey(args);
393
- return this.getAbortControllerByKey(queryKey);
394
- }
395
- getAbortControllerByKey(queryKey) {
396
- return this.inflightQueries.get(queryKey)?.abortController ?? null;
397
- }
398
- /**
399
- * Get the AbortSignal to check for query abortions.
400
- *
401
- * Example:
402
- * ```
403
- * const getUserQuery = sharedQuery({
404
- * queryName: "getUser",
405
- * queryFn: (userId: string) => {
406
- * // Get the signal for this current execution.
407
- * const signal = getUserQuery.getAbortSignal([userId]);
408
- *
409
- * // Pass the signal to fetch so that it can be aborted.
410
- * const response = await fetch(`/users/${userId}`, { signal });
411
- *
412
- * // Check for errors.
413
- * if (!response.ok) {
414
- * throw new Error(response.statusText);
415
- * }
416
- *
417
- * // Return the data.
418
- * return await response.json();
419
- * },
420
- * });
421
- * ```
422
- */
423
- getAbortSignal(args) {
424
- const queryKey = this.getQueryKey(args);
425
- return this.getAbortSignalByKey(queryKey);
426
- }
427
- getAbortSignalByKey(queryKey) {
428
- return this.getAbortControllerByKey(queryKey)?.signal ?? null;
429
- }
430
- isStale(dataUpdatedMs) {
431
- if (this.staleMs === 0) return true;
386
+ const maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;
387
+ const maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;
388
+ const keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);
389
+ const log = options?.log ?? SharedQueryConfig.log;
390
+ const queryState = sharedState(
391
+ {},
392
+ // defaultValue
393
+ options
394
+ );
395
+ function isStale(dataUpdatedMs) {
396
+ if (staleMs === 0) return true;
432
397
  const ageMs = Date.now() - dataUpdatedMs;
433
- return ageMs > this.staleMs;
398
+ return ageMs > staleMs;
434
399
  }
435
- isExpired(dataUpdatedMs) {
436
- if (this.expiryMs === 0) return false;
400
+ function isExpired(dataUpdatedMs) {
401
+ if (expiryMs === 0) return false;
437
402
  const ageMs = Date.now() - dataUpdatedMs;
438
- return ageMs > this.expiryMs;
403
+ return ageMs > expiryMs;
439
404
  }
440
- /** For cached data, we need to return the correct timestamps as well. */
441
- async getCachedOrFetch(...args) {
442
- const startMs = Date.now();
443
- await this.queryState.readyPromise;
444
- const waitedMs = Date.now() - startMs;
445
- const queryKey = this.getQueryKey(args);
446
- const cached = this.queryState.getSnapshot()?.[queryKey];
447
- if (waitedMs) {
448
- this.log(`${queryKey}: Waited for ${waitedMs}ms before data is ready`);
449
- }
450
- if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
451
- if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
452
- this.log(`Return fresh data ${queryKey} from cache without fetching`);
453
- return cached;
454
- }
455
- if (this.refetchOnStale) {
456
- this.log(`refetchOnStale for ${queryKey}`);
457
- void this.dedupedFetch(queryKey, "query", ...args);
458
- }
459
- this.log(`Returning stale data for ${queryKey}`);
460
- return cached;
461
- }
462
- const data = await this.dedupedFetch(queryKey, "query", ...args);
463
- const now = Date.now();
464
- return {
465
- // Don't keep old error.
466
- lastUpdatedMs: now,
467
- loading: false,
468
- data,
469
- dataUpdatedMs: now
470
- };
471
- }
472
- dedupedFetch(queryKey, queryType, ...args) {
473
- const inflightQuery = this.inflightQueries.get(queryKey);
474
- if (inflightQuery) {
475
- this.log(
476
- `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`
477
- );
478
- return inflightQuery.promise;
479
- }
480
- const promise = this.startFetching(queryKey, queryType, args);
481
- const abortController = new AbortController();
482
- this.inflightQueries.set(queryKey, { promise, abortController });
483
- return promise;
484
- }
485
- async startFetching(queryKey, queryType, args) {
486
- this.log(`Start fetching ${queryKey}, queryType=${queryType}`);
487
- try {
488
- const data = await (queryType === "refetch" && this.refetchFn ? this.refetchFn(...args) : this.queryFn(...args));
489
- this.log(`Successfully fetched ${queryKey}`);
490
- this.setData(queryKey, data, Date.now());
491
- return data;
492
- } catch (e) {
493
- this.log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);
494
- throw e;
495
- } finally {
496
- this.inflightQueries.delete(queryKey);
497
- }
498
- }
499
- /** Get the entire stored entry for a query key. */
500
- getQueryValue(queryKey) {
501
- return this.queryState.getSnapshot()?.[queryKey];
502
- }
503
- /** Get the current stored data for a query key. */
504
- getData(queryKey) {
505
- const stateValue = this.queryState.getSnapshot()?.[queryKey];
506
- if (stateValue?.lastUpdatedMs) {
507
- if (this.isExpired(stateValue.lastUpdatedMs)) {
508
- this.deleteData(queryKey);
509
- return void 0;
510
- }
511
- }
512
- return stateValue?.data;
513
- }
514
- /** Set the data directly if the user obtained it from somewhere else. */
515
- setData(queryKey, data, dataUpdatedMs = Date.now()) {
516
- const entry = {
517
- lastUpdatedMs: Date.now(),
518
- loading: false,
519
- data,
520
- dataUpdatedMs
521
- };
522
- this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
523
- this.enforceSizeLimit();
524
- }
525
- /**
526
- * Do a new fetch even when the data is already cached, but don't fetch if
527
- * another fetch is already inflight.
528
- */
529
- async updateFromSource(...args) {
530
- const key = this.getQueryKey(args);
531
- return await this.dedupedFetch(key, "query", ...args);
532
- }
533
- /**
534
- * Perform an explicit refetch. If `options.refetchFn` was not specified,
535
- * then this would be identical to calling `this.updateFromSource()`.
536
- */
537
- async refetch(...args) {
538
- const key = this.getQueryKey(args);
539
- return await this.dedupedFetch(key, "refetch", ...args);
540
- }
541
- /** Delete the cached data for one set of arguments. */
542
- deleteData(queryKey) {
543
- const record = this.queryState.getSnapshot();
544
- const clone = { ...record };
545
- delete clone[queryKey];
546
- this.queryState.setValue(clone);
547
- const inflight = this.inflightQueries.get(queryKey);
548
- if (inflight) {
549
- this.inflightQueries.delete(queryKey);
550
- inflight.abortController.abort("Deleted");
551
- }
552
- }
553
- /** Delete all currently cached data & all inflight promises. */
554
- clear() {
555
- this.queryState.delete();
556
- for (const { abortController } of Array.from(
557
- this.inflightQueries.values()
558
- )) {
559
- abortController.abort("Cleared");
560
- }
561
- this.inflightQueries.clear();
562
- }
563
- /** Keep track of mounted keys. */
564
- mount(queryKey) {
565
- this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);
566
- }
567
- /** Clean up mounted keys. */
568
- unmount(queryKey) {
569
- const count = this.mountedKeys.get(queryKey);
570
- if (count === 1) {
571
- this.mountedKeys.delete(queryKey);
572
- } else {
573
- this.mountedKeys.set(queryKey, (count ?? 0) - 1);
574
- }
575
- }
576
- /**
577
- * Discard the oldest entries if the size exceeds the limit. The last
578
- * remaining record cannot be deleted no matter what the limit is.
579
- */
580
- enforceSizeLimit() {
581
- if (!this.maxSize && !this.maxBytes) {
405
+ function enforceSizeLimit() {
406
+ if (!maxSize && !maxBytes) {
582
407
  return;
583
408
  }
584
- const oldState = this.queryState.getSnapshot();
409
+ const oldState = queryState.getSnapshot();
585
410
  const oldSize = Object.keys(oldState).length;
586
411
  if (oldSize <= 1) {
587
412
  return;
588
413
  }
589
414
  const oldByteSize = getByteSize(oldState);
590
- let numToCut = this.maxSize ? oldSize - this.maxSize : 0;
591
- let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;
415
+ let numToCut = maxSize ? oldSize - maxSize : 0;
416
+ let bytesToCut = maxBytes ? oldByteSize - maxBytes : 0;
592
417
  if (numToCut <= 0 && bytesToCut <= 0) {
593
- this.log(
594
- `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
418
+ log(
419
+ `No need to trim data for ${queryName}: size=${oldSize} (limit=${maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${maxBytes.toLocaleString()})`
595
420
  );
596
421
  return;
597
422
  }
598
- this.log(
599
- `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`
423
+ log(
424
+ `Need to trim ${queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`
600
425
  );
601
426
  const newState = { ...oldState };
602
427
  let needUpdate = false;
603
428
  const deleteKeyIfNotMounted = (key, expired) => {
604
- if (this.mountedKeys.has(key)) {
605
- this.log(`Cannot clean up ${key} as it is mounted`);
429
+ if (mountedKeys.has(key)) {
430
+ log(`Cannot clean up ${key} as it is mounted`);
606
431
  return;
607
432
  }
608
433
  const byteSize = getByteSize(key) + getByteSize(newState[key]);
609
- this.log(
434
+ log(
610
435
  `Cleaning up unmounted${expired ? ", expired" : ""} ${key} (byteSize=${byteSize.toLocaleString()})`
611
436
  );
612
437
  numToCut--;
@@ -618,7 +443,7 @@ var SharedQuery = class {
618
443
  (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs
619
444
  );
620
445
  for (const [key] of entriesByTimeAscending) {
621
- if (!this.isExpired(newState[key].lastUpdatedMs)) {
446
+ if (!isExpired(newState[key].lastUpdatedMs)) {
622
447
  break;
623
448
  }
624
449
  deleteKeyIfNotMounted(key, true);
@@ -637,24 +462,220 @@ var SharedQuery = class {
637
462
  }
638
463
  }
639
464
  if (!needUpdate) {
640
- this.log(
641
- `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`
465
+ log(
466
+ `No trimmable entries found for ${queryName}: size=${oldSize} (limit=${maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${maxBytes.toLocaleString()})`
642
467
  );
643
468
  return;
644
469
  }
645
- this.queryState.setValue(newState);
646
- this.log(
647
- `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`
470
+ queryState.setValue(newState);
471
+ log(
472
+ `Trimmed data for ${queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${maxBytes.toLocaleString()})`
648
473
  );
649
474
  }
650
- };
651
- var seenQueryNames = /* @__PURE__ */ new Set();
652
- function sharedQuery(options) {
653
- if (seenQueryNames.has(options.queryName)) {
654
- throw new Error(`Duplicate shared query "${options.queryName}"`);
475
+ function setData(queryKey, data, dataUpdatedMs = Date.now()) {
476
+ const entry = {
477
+ lastUpdatedMs: Date.now(),
478
+ loading: false,
479
+ data,
480
+ dataUpdatedMs
481
+ };
482
+ queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
483
+ enforceSizeLimit();
655
484
  }
656
- seenQueryNames.add(options.queryName);
657
- return new SharedQuery(options);
485
+ async function startFetching(queryKey, queryType, args) {
486
+ log(`Start fetching ${queryKey}, queryType=${queryType}`);
487
+ try {
488
+ const data = await (queryType === "refetch" && refetchFn ? refetchFn(...args) : queryFn(...args));
489
+ log(`Successfully fetched ${queryKey}`);
490
+ setData(queryKey, data, Date.now());
491
+ return data;
492
+ } catch (e) {
493
+ log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);
494
+ throw e;
495
+ } finally {
496
+ inflightQueries.delete(queryKey);
497
+ }
498
+ }
499
+ function dedupedFetch(queryKey, queryType, ...args) {
500
+ const inflightQuery = inflightQueries.get(queryKey);
501
+ if (inflightQuery) {
502
+ log(
503
+ `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`
504
+ );
505
+ return inflightQuery.promise;
506
+ }
507
+ const promise = startFetching(queryKey, queryType, args);
508
+ const abortController = new AbortController();
509
+ inflightQueries.set(queryKey, { promise, abortController });
510
+ return promise;
511
+ }
512
+ const obj = {
513
+ queryName,
514
+ queryFn,
515
+ refetchFn,
516
+ expiryMs,
517
+ staleMs,
518
+ refetchOnStale,
519
+ maxSize,
520
+ maxBytes,
521
+ keepLastNonEmptyData,
522
+ log,
523
+ queryState,
524
+ /** Returns a key to identify requests based on the given args. */
525
+ getQueryKey(args) {
526
+ return queryName + ":" + stringifyDeterministicForKeys(args);
527
+ },
528
+ /**
529
+ * Get the AbortController to abort the inflight query (if any).
530
+ *
531
+ * Note that once you get the controller, you can also get the signal easily
532
+ * using `controller.signal`. So if you need both the controller and the
533
+ * signal, just use this function.
534
+ */
535
+ getAbortController(args) {
536
+ const queryKey = obj.getQueryKey(args);
537
+ return obj.getAbortControllerByKey(queryKey);
538
+ },
539
+ getAbortControllerByKey(queryKey) {
540
+ return inflightQueries.get(queryKey)?.abortController ?? null;
541
+ },
542
+ /**
543
+ * Get the AbortSignal to check for query abortions.
544
+ *
545
+ * Example:
546
+ * ```
547
+ * const getUserQuery = sharedQuery({
548
+ * queryName: "getUser",
549
+ * queryFn: (userId: string) => {
550
+ * // Get the signal for this current execution.
551
+ * const signal = getUserQuery.getAbortSignal([userId]);
552
+ *
553
+ * // Pass the signal to fetch so that it can be aborted.
554
+ * const response = await fetch(`/users/${userId}`, { signal });
555
+ *
556
+ * // Check for errors.
557
+ * if (!response.ok) {
558
+ * throw new Error(response.statusText);
559
+ * }
560
+ *
561
+ * // Return the data.
562
+ * return await response.json();
563
+ * },
564
+ * });
565
+ * ```
566
+ */
567
+ getAbortSignal(args) {
568
+ const queryKey = obj.getQueryKey(args);
569
+ return obj.getAbortSignalByKey(queryKey);
570
+ },
571
+ getAbortSignalByKey(queryKey) {
572
+ return obj.getAbortControllerByKey(queryKey)?.signal ?? null;
573
+ },
574
+ /** For cached data, we need to return the correct timestamps as well. */
575
+ async getCachedOrFetch(...args) {
576
+ const startMs = Date.now();
577
+ await queryState.readyPromise;
578
+ const waitedMs = Date.now() - startMs;
579
+ const queryKey = obj.getQueryKey(args);
580
+ const cached = queryState.getSnapshot()?.[queryKey];
581
+ if (waitedMs) {
582
+ log(`${queryKey}: Waited for ${waitedMs}ms before data is ready`);
583
+ }
584
+ if (cached?.data && !isExpired(cached.dataUpdatedMs ?? 0)) {
585
+ if (!isStale(cached.dataUpdatedMs ?? 0)) {
586
+ log(`Return fresh data ${queryKey} from cache without fetching`);
587
+ return cached;
588
+ }
589
+ if (refetchOnStale) {
590
+ log(`refetchOnStale for ${queryKey}`);
591
+ void dedupedFetch(queryKey, "query", ...args);
592
+ }
593
+ log(`Returning stale data for ${queryKey}`);
594
+ return cached;
595
+ }
596
+ const data = await dedupedFetch(queryKey, "query", ...args);
597
+ const now = Date.now();
598
+ return {
599
+ // Don't keep old error.
600
+ lastUpdatedMs: now,
601
+ loading: false,
602
+ data,
603
+ dataUpdatedMs: now
604
+ };
605
+ },
606
+ /** Get the entire stored entry for a query key. */
607
+ getQueryValue(queryKey) {
608
+ return queryState.getSnapshot()?.[queryKey];
609
+ },
610
+ /** Get the current stored data for a query key. */
611
+ getData(queryKey) {
612
+ const stateValue = queryState.getSnapshot()?.[queryKey];
613
+ if (stateValue?.lastUpdatedMs) {
614
+ if (isExpired(stateValue.lastUpdatedMs)) {
615
+ obj.deleteData(queryKey);
616
+ return void 0;
617
+ }
618
+ }
619
+ return stateValue?.data;
620
+ },
621
+ /** Set the data directly if the user obtained it from somewhere else. */
622
+ setData,
623
+ /**
624
+ * Do a new fetch even when the data is already cached, but don't fetch if
625
+ * another fetch is already inflight.
626
+ */
627
+ async updateFromSource(...args) {
628
+ const key = obj.getQueryKey(args);
629
+ return await dedupedFetch(key, "query", ...args);
630
+ },
631
+ /**
632
+ * Perform an explicit refetch. If `options.refetchFn` was not specified,
633
+ * then this would be identical to calling `obj.updateFromSource()`.
634
+ */
635
+ async refetch(...args) {
636
+ const key = obj.getQueryKey(args);
637
+ return await dedupedFetch(key, "refetch", ...args);
638
+ },
639
+ /** Delete the cached data for one set of arguments. */
640
+ deleteData(queryKey) {
641
+ const record = queryState.getSnapshot();
642
+ const clone = { ...record };
643
+ delete clone[queryKey];
644
+ queryState.setValue(clone);
645
+ const inflight = inflightQueries.get(queryKey);
646
+ if (inflight) {
647
+ inflightQueries.delete(queryKey);
648
+ inflight.abortController.abort("Deleted");
649
+ }
650
+ },
651
+ /** Delete all currently cached data & all inflight promises. */
652
+ clear() {
653
+ queryState.delete();
654
+ for (const { abortController } of Array.from(inflightQueries.values())) {
655
+ abortController.abort("Cleared");
656
+ }
657
+ inflightQueries.clear();
658
+ },
659
+ /** Keep track of mounted keys. */
660
+ mount(queryKey) {
661
+ mountedKeys.set(queryKey, (mountedKeys.get(queryKey) ?? 0) + 1);
662
+ },
663
+ /** Clean up mounted keys. */
664
+ unmount(queryKey) {
665
+ const count = mountedKeys.get(queryKey);
666
+ if (count === 1) {
667
+ mountedKeys.delete(queryKey);
668
+ } else {
669
+ mountedKeys.set(queryKey, (count ?? 0) - 1);
670
+ }
671
+ },
672
+ /**
673
+ * Discard the oldest entries if the size exceeds the limit. The last
674
+ * remaining record cannot be deleted no matter what the limit is.
675
+ */
676
+ enforceSizeLimit
677
+ };
678
+ return obj;
658
679
  }
659
680
  var DEFAULT_QUERY_STATE_ENTRY = {
660
681
  lastUpdatedMs: 0,
@@ -751,38 +772,18 @@ function useSharedQuery(query, ...args) {
751
772
  return Boolean(controller);
752
773
  },
753
774
  refetch: () => {
754
- void execute(true);
775
+ return execute(true);
755
776
  },
756
777
  setData: (data, dataUpdatedMs) => {
757
- const now = Date.now();
758
- setQueryStateValue((prev) => ({
759
- ...prev,
760
- data,
761
- dataUpdatedMs: dataUpdatedMs ?? now,
762
- lastUpdatedMs: now,
763
- loading: false
764
- }));
778
+ query.setData(queryKey, data, dataUpdatedMs);
765
779
  },
766
780
  deleteData: () => {
767
- setQueryState((prev) => {
768
- const clone = { ...prev };
769
- delete clone[queryKey];
770
- return clone;
771
- });
781
+ query.deleteData(queryKey);
772
782
  }
773
783
  };
774
- }, [
775
- execute,
776
- lastNonEmptyData,
777
- query,
778
- queryKey,
779
- queryStateValue,
780
- setQueryState,
781
- setQueryStateValue
782
- ]);
784
+ }, [execute, lastNonEmptyData, query, queryKey, queryStateValue]);
783
785
  }
784
786
  export {
785
- SharedQuery,
786
787
  SharedQueryConfig,
787
788
  configureSharedQuery,
788
789
  sharedQuery,