@choksheak/react-utils 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@choksheak/react-utils",
3
3
  "license": "The Unlicense",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "description": "Miscellaneous React+TypeScript utilities with support for full tree-shaking.",
6
6
  "private": false,
7
7
  "files": [
@@ -95,7 +95,7 @@
95
95
  "wretch": "^3.0.2"
96
96
  },
97
97
  "peerDependencies": {
98
- "@choksheak/ts-utils": ">=0.3.1",
98
+ "@choksheak/ts-utils": ">=0.3.2",
99
99
  "lodash": ">=4.17.21",
100
100
  "object-sizeof": ">=2.6.5",
101
101
  "react": ">=16",
package/sharedQuery.cjs CHANGED
@@ -364,6 +364,7 @@ var SharedQuery = class {
364
364
  mountedKeys = /* @__PURE__ */ new Map();
365
365
  queryName;
366
366
  queryFn;
367
+ refetchFn;
367
368
  expiryMs;
368
369
  staleMs;
369
370
  refetchOnStale;
@@ -374,6 +375,7 @@ var SharedQuery = class {
374
375
  constructor(options) {
375
376
  this.queryName = options.queryName;
376
377
  this.queryFn = options.queryFn;
378
+ this.refetchFn = options.refetchFn;
377
379
  this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;
378
380
  this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;
379
381
  this.refetchOnStale = options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;
@@ -457,43 +459,54 @@ var SharedQuery = class {
457
459
  const ageMs = Date.now() - dataUpdatedMs;
458
460
  return ageMs > this.expiryMs;
459
461
  }
462
+ /** For cached data, we need to return the correct timestamps as well. */
460
463
  async getCachedOrFetch(...args) {
461
464
  const queryKey = this.getQueryKey(args);
462
465
  const cached = this.queryState.getSnapshot()?.[queryKey];
463
466
  if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
464
467
  if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
465
468
  this.log(`Return fresh data ${queryKey} from cache without fetching`);
466
- return cached.data;
469
+ return cached;
467
470
  }
468
471
  if (this.refetchOnStale) {
469
472
  this.log(`refetchOnStale for ${queryKey}`);
470
- void this.dedupedFetch(queryKey, ...args);
473
+ void this.dedupedFetch(queryKey, "query", ...args);
471
474
  }
472
475
  this.log(`Returning stale data for ${queryKey}`);
473
- return cached.data;
476
+ return cached;
474
477
  }
475
- return await this.dedupedFetch(queryKey, ...args);
478
+ const data = await this.dedupedFetch(queryKey, "query", ...args);
479
+ const now = Date.now();
480
+ return {
481
+ // Don't keep old error.
482
+ lastUpdatedMs: now,
483
+ loading: false,
484
+ data,
485
+ dataUpdatedMs: now
486
+ };
476
487
  }
477
- dedupedFetch(queryKey, ...args) {
488
+ dedupedFetch(queryKey, queryType, ...args) {
478
489
  const inflightQuery = this.inflightQueries.get(queryKey);
479
490
  if (inflightQuery) {
480
- this.log(`Deduplicating inflight fetch for ${queryKey}`);
491
+ this.log(
492
+ `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`
493
+ );
481
494
  return inflightQuery.promise;
482
495
  }
483
- const promise = this.startFetching(queryKey, args);
496
+ const promise = this.startFetching(queryKey, queryType, args);
484
497
  const abortController = new AbortController();
485
498
  this.inflightQueries.set(queryKey, { promise, abortController });
486
499
  return promise;
487
500
  }
488
- async startFetching(queryKey, args) {
489
- this.log(`Start fetching ${queryKey}`);
501
+ async startFetching(queryKey, queryType, args) {
502
+ this.log(`Start fetching ${queryKey}, queryType=${queryType}`);
490
503
  try {
491
- const data = await this.queryFn(...args);
504
+ const data = await (queryType === "refetch" && this.refetchFn ? this.refetchFn(...args) : this.queryFn(...args));
492
505
  this.log(`Successfully fetched ${queryKey}`);
493
506
  this.setData(queryKey, data, Date.now());
494
507
  return data;
495
508
  } catch (e) {
496
- this.log(`Failed to fetch ${queryKey}: ${e}`);
509
+ this.log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);
497
510
  throw e;
498
511
  } finally {
499
512
  this.inflightQueries.delete(queryKey);
@@ -531,7 +544,15 @@ var SharedQuery = class {
531
544
  */
532
545
  async updateFromSource(...args) {
533
546
  const key = this.getQueryKey(args);
534
- return await this.dedupedFetch(key, ...args);
547
+ return await this.dedupedFetch(key, "query", ...args);
548
+ }
549
+ /**
550
+ * Perform an explicit refetch. If `options.refetchFn` was not specified,
551
+ * then this would be identical to calling `this.updateFromSource()`.
552
+ */
553
+ async refetch(...args) {
554
+ const key = this.getQueryKey(args);
555
+ return await this.dedupedFetch(key, "refetch", ...args);
535
556
  }
536
557
  /** Delete the cached data for one set of arguments. */
537
558
  deleteData(queryKey) {
@@ -681,8 +702,11 @@ function useSharedQuery(query, ...args) {
681
702
  }
682
703
  }, [query.keepLastNonEmptyData, queryStateValue.data]);
683
704
  const execute = (0, import_react3.useCallback)(
684
- async (forceRefresh) => {
685
- query.log(`Begin executing shared query ${queryKey}`);
705
+ async (isRefetch) => {
706
+ query.log(
707
+ `Begin executing shared query ${queryKey}, isRefetch=${isRefetch}`
708
+ );
709
+ if (!isMounted.current) return;
686
710
  setQueryStateValue((prev) => ({
687
711
  // Keep old data and error.
688
712
  ...prev,
@@ -690,29 +714,33 @@ function useSharedQuery(query, ...args) {
690
714
  loading: true
691
715
  }));
692
716
  try {
693
- const data = await (forceRefresh ? query.updateFromSource(...stableArgs) : query.getCachedOrFetch(...stableArgs));
694
- if (isMounted.current) {
717
+ let state;
718
+ if (isRefetch) {
719
+ const data = await query.refetch(...stableArgs);
695
720
  const now = Date.now();
696
- setQueryStateValue({
721
+ state = {
697
722
  // Don't keep old error.
698
723
  lastUpdatedMs: now,
699
724
  loading: false,
700
725
  data,
701
726
  dataUpdatedMs: now
702
- });
727
+ };
728
+ } else {
729
+ state = await query.getCachedOrFetch(...stableArgs);
703
730
  }
731
+ if (!isMounted.current) return;
732
+ setQueryStateValue(state);
704
733
  } catch (e) {
705
- if (isMounted.current) {
706
- const now = Date.now();
707
- setQueryStateValue((prev) => ({
708
- // Keep old data.
709
- ...prev,
710
- lastUpdatedMs: now,
711
- loading: false,
712
- error: e,
713
- errorUpdatedMs: now
714
- }));
715
- }
734
+ if (!isMounted.current) return;
735
+ const now = Date.now();
736
+ setQueryStateValue((prev) => ({
737
+ // Keep old data.
738
+ ...prev,
739
+ lastUpdatedMs: now,
740
+ loading: false,
741
+ error: e,
742
+ errorUpdatedMs: now
743
+ }));
716
744
  }
717
745
  },
718
746
  [query, queryKey, setQueryStateValue, stableArgs]
package/sharedQuery.d.mts CHANGED
@@ -147,6 +147,11 @@ type SharedQueryOptions<TArgs extends unknown[], TData> = {
147
147
  * Function to fetch data.
148
148
  */
149
149
  queryFn: QueryFn<TArgs, TData>;
150
+ /**
151
+ * Function to re-fetch data. Usually this would just be the `queryFn`, but
152
+ * if you need a different behavior for refetching, then specify this.
153
+ */
154
+ refetchFn?: QueryFn<TArgs, TData>;
150
155
  /**
151
156
  * ms before data considered stale; 0 means always stale.
152
157
  */
@@ -217,7 +222,8 @@ declare class SharedQuery<TArgs extends unknown[], TData> {
217
222
  */
218
223
  private readonly mountedKeys;
219
224
  readonly queryName: string;
220
- private readonly queryFn;
225
+ readonly queryFn: QueryFn<TArgs, TData>;
226
+ readonly refetchFn: QueryFn<TArgs, TData> | undefined;
221
227
  readonly expiryMs: number;
222
228
  readonly staleMs: number;
223
229
  readonly refetchOnStale: boolean;
@@ -266,7 +272,8 @@ declare class SharedQuery<TArgs extends unknown[], TData> {
266
272
  getAbortSignalByKey(queryKey: string): AbortSignal | null;
267
273
  private isStale;
268
274
  private isExpired;
269
- getCachedOrFetch(...args: TArgs): Promise<TData>;
275
+ /** For cached data, we need to return the correct timestamps as well. */
276
+ getCachedOrFetch(...args: TArgs): Promise<QueryStateValue<TData>>;
270
277
  private dedupedFetch;
271
278
  private startFetching;
272
279
  /** Get the entire stored entry for a query key. */
@@ -280,6 +287,11 @@ declare class SharedQuery<TArgs extends unknown[], TData> {
280
287
  * another fetch is already inflight.
281
288
  */
282
289
  updateFromSource(...args: TArgs): Promise<TData>;
290
+ /**
291
+ * Perform an explicit refetch. If `options.refetchFn` was not specified,
292
+ * then this would be identical to calling `this.updateFromSource()`.
293
+ */
294
+ refetch(...args: TArgs): Promise<TData>;
283
295
  /** Delete the cached data for one set of arguments. */
284
296
  deleteData(queryKey: string): void;
285
297
  /** Delete all currently cached data & all inflight promises. */
package/sharedQuery.d.ts CHANGED
@@ -143,6 +143,11 @@ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
143
143
  * Function to fetch data.
144
144
  */
145
145
  queryFn: QueryFn<TArgs, TData>;
146
+ /**
147
+ * Function to re-fetch data. Usually this would just be the `queryFn`, but
148
+ * if you need a different behavior for refetching, then specify this.
149
+ */
150
+ refetchFn?: QueryFn<TArgs, TData>;
146
151
  /**
147
152
  * ms before data considered stale; 0 means always stale.
148
153
  */
@@ -213,7 +218,8 @@ export declare class SharedQuery<TArgs extends unknown[], TData> {
213
218
  */
214
219
  private readonly mountedKeys;
215
220
  readonly queryName: string;
216
- private readonly queryFn;
221
+ readonly queryFn: QueryFn<TArgs, TData>;
222
+ readonly refetchFn: QueryFn<TArgs, TData> | undefined;
217
223
  readonly expiryMs: number;
218
224
  readonly staleMs: number;
219
225
  readonly refetchOnStale: boolean;
@@ -262,7 +268,8 @@ export declare class SharedQuery<TArgs extends unknown[], TData> {
262
268
  getAbortSignalByKey(queryKey: string): AbortSignal | null;
263
269
  private isStale;
264
270
  private isExpired;
265
- getCachedOrFetch(...args: TArgs): Promise<TData>;
271
+ /** For cached data, we need to return the correct timestamps as well. */
272
+ getCachedOrFetch(...args: TArgs): Promise<QueryStateValue<TData>>;
266
273
  private dedupedFetch;
267
274
  private startFetching;
268
275
  /** Get the entire stored entry for a query key. */
@@ -276,6 +283,11 @@ export declare class SharedQuery<TArgs extends unknown[], TData> {
276
283
  * another fetch is already inflight.
277
284
  */
278
285
  updateFromSource(...args: TArgs): Promise<TData>;
286
+ /**
287
+ * Perform an explicit refetch. If `options.refetchFn` was not specified,
288
+ * then this would be identical to calling `this.updateFromSource()`.
289
+ */
290
+ refetch(...args: TArgs): Promise<TData>;
279
291
  /** Delete the cached data for one set of arguments. */
280
292
  deleteData(queryKey: string): void;
281
293
  /** Delete all currently cached data & all inflight promises. */
@@ -1,2 +1,2 @@
1
- "use strict";var q=Object.create;var D=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var j=Object.getOwnPropertyNames;var Y=Object.getPrototypeOf,I=Object.prototype.hasOwnProperty;var J=(r,e)=>{for(var t in e)D(r,t,{get:e[t],enumerable:!0})},O=(r,e,t,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of j(e))!I.call(r,i)&&i!==t&&D(r,i,{get:()=>e[i],enumerable:!(a=_(e,i))||a.enumerable});return r};var B=(r,e,t)=>(t=r!=null?q(Y(r)):{},O(e||!r||!r.__esModule?D(t,"default",{value:r,enumerable:!0}):t,r)),G=r=>O(D({},"__esModule",{value:!0}),r);var ee={};J(ee,{SharedQuery:()=>w,SharedQueryConfig:()=>S,configureSharedQuery:()=>H,sharedQuery:()=>X,useSharedQuery:()=>Z});module.exports=G(ee);var R=require("@choksheak/ts-utils/timeConstants"),T=B(require("object-sizeof")),p=require("react");var C=require("@choksheak/ts-utils/timeConstants"),z=require("react");var m=require("@choksheak/ts-utils/kvStore"),$=require("@choksheak/ts-utils/localStore"),f=typeof window>"u";function N(r,e){let{store:t}=r??{};if(!t)return null;if("persistTo"in t&&typeof t.persistTo=="string"&&"key"in t&&typeof t.key=="string"){let a=t.expiryMs||e,i=t.isValid;switch(t.persistTo){case"localStorage":{let n=new $.LocalStore(t.key,{defaultExpiryMs:a});return{set:(s,o)=>{f||n.set(s,o)},get:s=>{if(f)return;let o=n.get(s);if(i&&!i(o)){console.warn(`localStorage adapter: Auto-discard invalid value for ${s}: ${JSON.stringify(o)}`),n.delete(s);return}return o},delete:s=>{f||n.delete(s)},clear:()=>{f||n.clear()}}}case"indexedDb":{let n=t.key+":",s=t.isValid;return{set:async(o,d)=>{f||await m.kvStore.set(n+o,d,a)},get:async o=>{if(f)return;let d=n+o,h=await m.kvStore.get(d);if(s&&!s(h)){console.warn(`indexedDb adapter: Auto-discard invalid value for ${o}: ${JSON.stringify(h)}`),await m.kvStore.delete(d);return}return h},delete:async o=>{f||await m.kvStore.delete(n+o)},clear:async()=>{f||await m.kvStore.clear()}}}default:throw t.persistTo,new Error(`Unknown store.adapter ${t.persistTo}`)}}return t}var K={storeExpiryMs:30*C.MS_PER_DAY,lazyLoad:!1};var Q=class{dataByKey=new Map;subscribersByKey=new Map;get(e){return this.dataByKey.get(e)}set(e,t){let a=this.dataByKey.get(e);a!==t&&(this.dataByKey.set(e,t),this.notify(e,t,a))}delete(e){this.dataByKey.delete(e)}setNoNotify(e,t){this.dataByKey.set(e,t)}subscribe(e,t){let a=this.subscribersByKey.get(e);return a||(a=new Set,this.subscribersByKey.set(e,a)),a.add(t),()=>{a.delete(t),a.size===0&&this.subscribersByKey.delete(e)}}notify(e,t,a){let i=this.subscribersByKey.get(e);if(!i)return;let n=Array.from(i.values());for(let s of n)try{s(t,a)}catch(o){console.error("Error invoking subscriber:",o)}}},b=new Q,M="state",W=0,v=class{constructor(e,t){this.defaultValue=e;this.pubSubKey=String(W++),this.storageAdapter=N(t,K.storeExpiryMs),this.setValueBounded=n=>{this.setValue(n)},b.setNoNotify(this.pubSubKey,e);let a=t?.lazyLoad;(a!==void 0?a:K.lazyLoad)||this.initDefaultValueOnce()}pubSubKey;storageAdapter;setValueBounded;initStarted=!1;initDone=!1;subscribe(e){return b.subscribe(this.pubSubKey,()=>e())}hasStore(){return!!this.storageAdapter}async setInStore(e){return await this.storageAdapter?.set(M,e)}async getFromStore(){return await this.storageAdapter?.get(M)}getSnapshot(){return b.get(this.pubSubKey)}setValue(e){if(typeof e=="function"){let t=b.get(this.pubSubKey);e=e(t)}b.set(this.pubSubKey,e),this.setInStore(e)}delete(){b.set(this.pubSubKey,this.defaultValue),this.storageAdapter?.set(M,this.defaultValue)}async initDefaultValueOnce(){if(this.initDone||this.initStarted)return;this.initStarted=!0;let e=await this.getFromStore(),t=e!==void 0?e:this.defaultValue;b.set(this.pubSubKey,t),this.initDone=!0}};function V(r,e){return new v(r,e)}function E(r){return r.initDefaultValueOnce(),[(0,z.useSyncExternalStore)(t=>r.subscribe(t),()=>r.getSnapshot(),()=>r.getSnapshot()),r.setValueBounded]}function k(r){let e=new Set;return JSON.stringify(r,(t,a)=>{if(typeof a=="function")throw new Error("Functions should not be passed into query parameters");if(!a||typeof a!="object"||Array.isArray(a))return a;if(e.has(a))return"[Circular]";if(e.add(a),a!==r)try{JSON.stringify(a)}catch{return"[Unserializable]"}return Object.keys(a).sort().reduce((i,n)=>(i[n]=a[n],i),{})})}var U=B(require("lodash/isEqual")),x=require("react");function F(r){let[e,t]=(0,x.useState)(r);return(0,x.useMemo)(()=>e===r||(0,U.default)(e,r)?e:(t(r),r),[e,r])}var S={staleMs:0,expiryMs:30*R.MS_PER_DAY,refetchOnStale:!0,persistTo:void 0,maxSize:100,maxBytes:1e5,log:(...r)=>console.log("[sharedQuery]",...r)};function H(r){Object.assign(S,r)}var w=class{inflightQueries=new Map;queryState;mountedKeys=new Map;queryName;queryFn;expiryMs;staleMs;refetchOnStale;maxSize;maxBytes;keepLastNonEmptyData;log;constructor(e){if(this.queryName=e.queryName,this.queryFn=e.queryFn,this.staleMs=e?.staleMs??S.staleMs,this.expiryMs=e?.expiryMs??S.expiryMs,this.refetchOnStale=e?.refetchOnStale??S.refetchOnStale,!e.store){let t=e.persistTo??S.persistTo;t&&(e.store={persistTo:t,key:e.queryName,expiryMs:this.expiryMs})}this.maxSize=e?.maxSize??S.maxSize,this.maxBytes=e?.maxBytes??S.maxBytes,this.keepLastNonEmptyData=!!e?.keepLastNonEmptyData,this.log=e?.log??S.log,this.queryState=V({},e)}getQueryKey(e){return this.queryName+":"+k(e)}getAbortController(e){let t=this.getQueryKey(e);return this.getAbortControllerByKey(t)}getAbortControllerByKey(e){return this.inflightQueries.get(e)?.abortController??null}getAbortSignal(e){let t=this.getQueryKey(e);return this.getAbortSignalByKey(t)}getAbortSignalByKey(e){return this.getAbortControllerByKey(e)?.signal??null}isStale(e){return this.staleMs===0?!0:Date.now()-e>this.staleMs}isExpired(e){return this.expiryMs===0?!1:Date.now()-e>this.expiryMs}async getCachedOrFetch(...e){let t=this.getQueryKey(e),a=this.queryState.getSnapshot()?.[t];return a?.data&&!this.isExpired(a.dataUpdatedMs??0)?this.isStale(a.dataUpdatedMs??0)?(this.refetchOnStale&&(this.log(`refetchOnStale for ${t}`),this.dedupedFetch(t,...e)),this.log(`Returning stale data for ${t}`),a.data):(this.log(`Return fresh data ${t} from cache without fetching`),a.data):await this.dedupedFetch(t,...e)}dedupedFetch(e,...t){let a=this.inflightQueries.get(e);if(a)return this.log(`Deduplicating inflight fetch for ${e}`),a.promise;let i=this.startFetching(e,t),n=new AbortController;return this.inflightQueries.set(e,{promise:i,abortController:n}),i}async startFetching(e,t){this.log(`Start fetching ${e}`);try{let a=await this.queryFn(...t);return this.log(`Successfully fetched ${e}`),this.setData(e,a,Date.now()),a}catch(a){throw this.log(`Failed to fetch ${e}: ${a}`),a}finally{this.inflightQueries.delete(e)}}getQueryValue(e){return this.queryState.getSnapshot()?.[e]}getData(e){let t=this.queryState.getSnapshot()?.[e];if(t?.lastUpdatedMs&&this.isExpired(t.lastUpdatedMs)){this.deleteData(e);return}return t?.data}setData(e,t,a=Date.now()){let i={lastUpdatedMs:Date.now(),loading:!1,data:t,dataUpdatedMs:a};this.queryState.setValue(n=>({...n,[e]:i})),this.enforceSizeLimit()}async updateFromSource(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,...e)}deleteData(e){let a={...this.queryState.getSnapshot()};delete a[e],this.queryState.setValue(a);let i=this.inflightQueries.get(e);i&&(this.inflightQueries.delete(e),i.abortController.abort("Deleted"))}clear(){this.queryState.delete();for(let{abortController:e}of Array.from(this.inflightQueries.values()))e.abort("Cleared");this.inflightQueries.clear()}mount(e){this.mountedKeys.set(e,(this.mountedKeys.get(e)??0)+1)}unmount(e){let t=this.mountedKeys.get(e);t===1?this.mountedKeys.delete(e):this.mountedKeys.set(e,(t??0)-1)}enforceSizeLimit(){if(!this.maxSize&&!this.maxBytes)return;let e=this.queryState.getSnapshot(),t=Object.keys(e).length;if(t<=1)return;let a=(0,T.default)(e),i=this.maxSize?t-this.maxSize:0,n=this.maxBytes?a-this.maxBytes:0;if(i<=0&&n<=0){this.log(`No need to trim data for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${a.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.log(`Need to trim ${this.queryName}: numToCut=${i}, bytesToCut=${n}`);let s={...e},o=!1,d=(u,g)=>{if(this.mountedKeys.has(u)){this.log(`Cannot clean up ${u} as it is mounted`);return}let c=(0,T.default)(u)+(0,T.default)(s[u]);this.log(`Cleaning up unmounted${g?", expired":""} ${u} (byteSize=${c.toLocaleString()})`),i--,n-=c,delete s[u],o=!0},h=Object.entries(s).sort((u,g)=>u[1].lastUpdatedMs-g[1].lastUpdatedMs);for(let[u]of h){if(!this.isExpired(s[u].lastUpdatedMs))break;d(u,!0)}if(i>0||n>0){o&&(h=Object.entries(s).sort((u,g)=>u[1].lastUpdatedMs-g[1].lastUpdatedMs));for(let[u]of h)if(d(u,!1),i<=0&&n<=0)break}if(!o){this.log(`No trimmable entries found for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${a.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.queryState.setValue(s),this.log(`Trimmed data for ${this.queryName}: size=[${t} -> ${Object.keys(s).length}] (limit=${this.maxSize}), byteSize=[${a.toLocaleString()} -> ${(0,T.default)(s).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`)}},L=new Set;function X(r){if(L.has(r.queryName))throw new Error(`Duplicate shared query "${r.queryName}"`);return L.add(r.queryName),new w(r)}var P={lastUpdatedMs:0,loading:!0};function Z(r,...e){let t=F(e),[a,i]=E(r.queryState),n=(0,p.useRef)(!0),s=(0,p.useMemo)(()=>r.getQueryKey(t),[r,t]),o=a[s]??P,d=(0,p.useCallback)(c=>{i(l=>{let y={...l};return y[s]=typeof c=="function"?c(y[s]??P):c,y})},[s,i]),[h,u]=(0,p.useState)();(0,p.useEffect)(()=>{r.keepLastNonEmptyData&&o.data!==void 0&&u(o.data)},[r.keepLastNonEmptyData,o.data]);let g=(0,p.useCallback)(async c=>{r.log(`Begin executing shared query ${s}`),d(l=>({...l,lastUpdatedMs:Date.now(),loading:!0}));try{let l=await(c?r.updateFromSource(...t):r.getCachedOrFetch(...t));if(n.current){let y=Date.now();d({lastUpdatedMs:y,loading:!1,data:l,dataUpdatedMs:y})}}catch(l){if(n.current){let y=Date.now();d(A=>({...A,lastUpdatedMs:y,loading:!1,error:l,errorUpdatedMs:y}))}}},[r,s,d,t]);return(0,p.useEffect)(()=>(n.current=!0,r.mount(s),g(!1),()=>{n.current=!1,r.unmount(s)}),[g,r,s]),(0,p.useMemo)(()=>({...o,lastNonEmptyData:h,abortCurrentQuery:c=>{let l=r.getAbortControllerByKey(s);return l?.abort(c),!!l},refetch:()=>{g(!0)},setData:(c,l)=>{let y=Date.now();d(A=>({...A,data:c,dataUpdatedMs:l??y,lastUpdatedMs:y,loading:!1}))},deleteData:()=>{i(c=>{let l={...c};return delete l[s],l})}}),[g,h,r,s,o,i,d])}0&&(module.exports={SharedQuery,SharedQueryConfig,configureSharedQuery,sharedQuery,useSharedQuery});
1
+ "use strict";var q=Object.create;var x=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var j=Object.getOwnPropertyNames;var Y=Object.getPrototypeOf,I=Object.prototype.hasOwnProperty;var J=(r,e)=>{for(var t in e)x(r,t,{get:e[t],enumerable:!0})},$=(r,e,t,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of j(e))!I.call(r,s)&&s!==t&&x(r,s,{get:()=>e[s],enumerable:!(a=_(e,s))||a.enumerable});return r};var O=(r,e,t)=>(t=r!=null?q(Y(r)):{},$(e||!r||!r.__esModule?x(t,"default",{value:r,enumerable:!0}):t,r)),G=r=>$(x({},"__esModule",{value:!0}),r);var ee={};J(ee,{SharedQuery:()=>A,SharedQueryConfig:()=>g,configureSharedQuery:()=>H,sharedQuery:()=>X,useSharedQuery:()=>Z});module.exports=G(ee);var R=require("@choksheak/ts-utils/timeConstants"),D=O(require("object-sizeof")),y=require("react");var C=require("@choksheak/ts-utils/timeConstants"),V=require("react");var m=require("@choksheak/ts-utils/kvStore"),B=require("@choksheak/ts-utils/localStore"),S=typeof window>"u";function K(r,e){let{store:t}=r??{};if(!t)return null;if("persistTo"in t&&typeof t.persistTo=="string"&&"key"in t&&typeof t.key=="string"){let a=t.expiryMs||e,s=t.isValid;switch(t.persistTo){case"localStorage":{let i=new B.LocalStore(t.key,{defaultExpiryMs:a});return{set:(n,o)=>{S||i.set(n,o)},get:n=>{if(S)return;let o=i.get(n);if(s&&!s(o)){console.warn(`localStorage adapter: Auto-discard invalid value for ${n}: ${JSON.stringify(o)}`),i.delete(n);return}return o},delete:n=>{S||i.delete(n)},clear:()=>{S||i.clear()}}}case"indexedDb":{let i=t.key+":",n=t.isValid;return{set:async(o,c)=>{S||await m.kvStore.set(i+o,c,a)},get:async o=>{if(S)return;let c=i+o,h=await m.kvStore.get(c);if(n&&!n(h)){console.warn(`indexedDb adapter: Auto-discard invalid value for ${o}: ${JSON.stringify(h)}`),await m.kvStore.delete(c);return}return h},delete:async o=>{S||await m.kvStore.delete(i+o)},clear:async()=>{S||await m.kvStore.clear()}}}default:throw t.persistTo,new Error(`Unknown store.adapter ${t.persistTo}`)}}return t}var N={storeExpiryMs:30*C.MS_PER_DAY,lazyLoad:!1};var Q=class{dataByKey=new Map;subscribersByKey=new Map;get(e){return this.dataByKey.get(e)}set(e,t){let a=this.dataByKey.get(e);a!==t&&(this.dataByKey.set(e,t),this.notify(e,t,a))}delete(e){this.dataByKey.delete(e)}setNoNotify(e,t){this.dataByKey.set(e,t)}subscribe(e,t){let a=this.subscribersByKey.get(e);return a||(a=new Set,this.subscribersByKey.set(e,a)),a.add(t),()=>{a.delete(t),a.size===0&&this.subscribersByKey.delete(e)}}notify(e,t,a){let s=this.subscribersByKey.get(e);if(!s)return;let i=Array.from(s.values());for(let n of i)try{n(t,a)}catch(o){console.error("Error invoking subscriber:",o)}}},b=new Q,M="state",W=0,v=class{constructor(e,t){this.defaultValue=e;this.pubSubKey=String(W++),this.storageAdapter=K(t,N.storeExpiryMs),this.setValueBounded=i=>{this.setValue(i)},b.setNoNotify(this.pubSubKey,e);let a=t?.lazyLoad;(a!==void 0?a:N.lazyLoad)||this.initDefaultValueOnce()}pubSubKey;storageAdapter;setValueBounded;initStarted=!1;initDone=!1;subscribe(e){return b.subscribe(this.pubSubKey,()=>e())}hasStore(){return!!this.storageAdapter}async setInStore(e){return await this.storageAdapter?.set(M,e)}async getFromStore(){return await this.storageAdapter?.get(M)}getSnapshot(){return b.get(this.pubSubKey)}setValue(e){if(typeof e=="function"){let t=b.get(this.pubSubKey);e=e(t)}b.set(this.pubSubKey,e),this.setInStore(e)}delete(){b.set(this.pubSubKey,this.defaultValue),this.storageAdapter?.set(M,this.defaultValue)}async initDefaultValueOnce(){if(this.initDone||this.initStarted)return;this.initStarted=!0;let e=await this.getFromStore(),t=e!==void 0?e:this.defaultValue;b.set(this.pubSubKey,t),this.initDone=!0}};function z(r,e){return new v(r,e)}function F(r){return r.initDefaultValueOnce(),[(0,V.useSyncExternalStore)(t=>r.subscribe(t),()=>r.getSnapshot(),()=>r.getSnapshot()),r.setValueBounded]}function k(r){let e=new Set;return JSON.stringify(r,(t,a)=>{if(typeof a=="function")throw new Error("Functions should not be passed into query parameters");if(!a||typeof a!="object"||Array.isArray(a))return a;if(e.has(a))return"[Circular]";if(e.add(a),a!==r)try{JSON.stringify(a)}catch{return"[Unserializable]"}return Object.keys(a).sort().reduce((s,i)=>(s[i]=a[i],s),{})})}var E=O(require("lodash/isEqual")),w=require("react");function U(r){let[e,t]=(0,w.useState)(r);return(0,w.useMemo)(()=>e===r||(0,E.default)(e,r)?e:(t(r),r),[e,r])}var g={staleMs:0,expiryMs:30*R.MS_PER_DAY,refetchOnStale:!0,persistTo:void 0,maxSize:100,maxBytes:1e5,log:(...r)=>console.log("[sharedQuery]",...r)};function H(r){Object.assign(g,r)}var A=class{inflightQueries=new Map;queryState;mountedKeys=new Map;queryName;queryFn;refetchFn;expiryMs;staleMs;refetchOnStale;maxSize;maxBytes;keepLastNonEmptyData;log;constructor(e){if(this.queryName=e.queryName,this.queryFn=e.queryFn,this.refetchFn=e.refetchFn,this.staleMs=e?.staleMs??g.staleMs,this.expiryMs=e?.expiryMs??g.expiryMs,this.refetchOnStale=e?.refetchOnStale??g.refetchOnStale,!e.store){let t=e.persistTo??g.persistTo;t&&(e.store={persistTo:t,key:e.queryName,expiryMs:this.expiryMs})}this.maxSize=e?.maxSize??g.maxSize,this.maxBytes=e?.maxBytes??g.maxBytes,this.keepLastNonEmptyData=!!e?.keepLastNonEmptyData,this.log=e?.log??g.log,this.queryState=z({},e)}getQueryKey(e){return this.queryName+":"+k(e)}getAbortController(e){let t=this.getQueryKey(e);return this.getAbortControllerByKey(t)}getAbortControllerByKey(e){return this.inflightQueries.get(e)?.abortController??null}getAbortSignal(e){let t=this.getQueryKey(e);return this.getAbortSignalByKey(t)}getAbortSignalByKey(e){return this.getAbortControllerByKey(e)?.signal??null}isStale(e){return this.staleMs===0?!0:Date.now()-e>this.staleMs}isExpired(e){return this.expiryMs===0?!1:Date.now()-e>this.expiryMs}async getCachedOrFetch(...e){let t=this.getQueryKey(e),a=this.queryState.getSnapshot()?.[t];if(a?.data&&!this.isExpired(a.dataUpdatedMs??0))return this.isStale(a.dataUpdatedMs??0)?(this.refetchOnStale&&(this.log(`refetchOnStale for ${t}`),this.dedupedFetch(t,"query",...e)),this.log(`Returning stale data for ${t}`),a):(this.log(`Return fresh data ${t} from cache without fetching`),a);let s=await this.dedupedFetch(t,"query",...e),i=Date.now();return{lastUpdatedMs:i,loading:!1,data:s,dataUpdatedMs:i}}dedupedFetch(e,t,...a){let s=this.inflightQueries.get(e);if(s)return this.log(`Deduplicating inflight fetch for ${e}, queryType=${t}`),s.promise;let i=this.startFetching(e,t,a),n=new AbortController;return this.inflightQueries.set(e,{promise:i,abortController:n}),i}async startFetching(e,t,a){this.log(`Start fetching ${e}, queryType=${t}`);try{let s=await(t==="refetch"&&this.refetchFn?this.refetchFn(...a):this.queryFn(...a));return this.log(`Successfully fetched ${e}`),this.setData(e,s,Date.now()),s}catch(s){throw this.log(`Failed to fetch ${e} for queryType=${t}: ${s}`),s}finally{this.inflightQueries.delete(e)}}getQueryValue(e){return this.queryState.getSnapshot()?.[e]}getData(e){let t=this.queryState.getSnapshot()?.[e];if(t?.lastUpdatedMs&&this.isExpired(t.lastUpdatedMs)){this.deleteData(e);return}return t?.data}setData(e,t,a=Date.now()){let s={lastUpdatedMs:Date.now(),loading:!1,data:t,dataUpdatedMs:a};this.queryState.setValue(i=>({...i,[e]:s})),this.enforceSizeLimit()}async updateFromSource(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,"query",...e)}async refetch(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,"refetch",...e)}deleteData(e){let a={...this.queryState.getSnapshot()};delete a[e],this.queryState.setValue(a);let s=this.inflightQueries.get(e);s&&(this.inflightQueries.delete(e),s.abortController.abort("Deleted"))}clear(){this.queryState.delete();for(let{abortController:e}of Array.from(this.inflightQueries.values()))e.abort("Cleared");this.inflightQueries.clear()}mount(e){this.mountedKeys.set(e,(this.mountedKeys.get(e)??0)+1)}unmount(e){let t=this.mountedKeys.get(e);t===1?this.mountedKeys.delete(e):this.mountedKeys.set(e,(t??0)-1)}enforceSizeLimit(){if(!this.maxSize&&!this.maxBytes)return;let e=this.queryState.getSnapshot(),t=Object.keys(e).length;if(t<=1)return;let a=(0,D.default)(e),s=this.maxSize?t-this.maxSize:0,i=this.maxBytes?a-this.maxBytes:0;if(s<=0&&i<=0){this.log(`No need to trim data for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${a.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.log(`Need to trim ${this.queryName}: numToCut=${s}, bytesToCut=${i}`);let n={...e},o=!1,c=(u,f)=>{if(this.mountedKeys.has(u)){this.log(`Cannot clean up ${u} as it is mounted`);return}let d=(0,D.default)(u)+(0,D.default)(n[u]);this.log(`Cleaning up unmounted${f?", expired":""} ${u} (byteSize=${d.toLocaleString()})`),s--,i-=d,delete n[u],o=!0},h=Object.entries(n).sort((u,f)=>u[1].lastUpdatedMs-f[1].lastUpdatedMs);for(let[u]of h){if(!this.isExpired(n[u].lastUpdatedMs))break;c(u,!0)}if(s>0||i>0){o&&(h=Object.entries(n).sort((u,f)=>u[1].lastUpdatedMs-f[1].lastUpdatedMs));for(let[u]of h)if(c(u,!1),s<=0&&i<=0)break}if(!o){this.log(`No trimmable entries found for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${a.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.queryState.setValue(n),this.log(`Trimmed data for ${this.queryName}: size=[${t} -> ${Object.keys(n).length}] (limit=${this.maxSize}), byteSize=[${a.toLocaleString()} -> ${(0,D.default)(n).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`)}},L=new Set;function X(r){if(L.has(r.queryName))throw new Error(`Duplicate shared query "${r.queryName}"`);return L.add(r.queryName),new A(r)}var P={lastUpdatedMs:0,loading:!0};function Z(r,...e){let t=U(e),[a,s]=F(r.queryState),i=(0,y.useRef)(!0),n=(0,y.useMemo)(()=>r.getQueryKey(t),[r,t]),o=a[n]??P,c=(0,y.useCallback)(d=>{s(l=>{let p={...l};return p[n]=typeof d=="function"?d(p[n]??P):d,p})},[n,s]),[h,u]=(0,y.useState)();(0,y.useEffect)(()=>{r.keepLastNonEmptyData&&o.data!==void 0&&u(o.data)},[r.keepLastNonEmptyData,o.data]);let f=(0,y.useCallback)(async d=>{if(r.log(`Begin executing shared query ${n}, isRefetch=${d}`),!!i.current){c(l=>({...l,lastUpdatedMs:Date.now(),loading:!0}));try{let l;if(d){let p=await r.refetch(...t),T=Date.now();l={lastUpdatedMs:T,loading:!1,data:p,dataUpdatedMs:T}}else l=await r.getCachedOrFetch(...t);if(!i.current)return;c(l)}catch(l){if(!i.current)return;let p=Date.now();c(T=>({...T,lastUpdatedMs:p,loading:!1,error:l,errorUpdatedMs:p}))}}},[r,n,c,t]);return(0,y.useEffect)(()=>(i.current=!0,r.mount(n),f(!1),()=>{i.current=!1,r.unmount(n)}),[f,r,n]),(0,y.useMemo)(()=>({...o,lastNonEmptyData:h,abortCurrentQuery:d=>{let l=r.getAbortControllerByKey(n);return l?.abort(d),!!l},refetch:()=>{f(!0)},setData:(d,l)=>{let p=Date.now();c(T=>({...T,data:d,dataUpdatedMs:l??p,lastUpdatedMs:p,loading:!1}))},deleteData:()=>{s(d=>{let l={...d};return delete l[n],l})}}),[f,h,r,n,o,s,c])}0&&(module.exports={SharedQuery,SharedQueryConfig,configureSharedQuery,sharedQuery,useSharedQuery});
2
2
  //# sourceMappingURL=sharedQuery.min.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sharedQuery.ts","../src/sharedState.ts","../src/storage.ts","../src/stringify.ts","../src/useDeepMemo.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * The \"shared query\" is a data fetching convenience library that helps users\n * to load data, check for staleness and expiration, cache in memory, local\n * storage or indexed db, and be able to manipulate the data anytime.\n *\n * Each shared query creates a new private shared state that handles the data\n * storage. This shared state contains the records for all queries, where each\n * query is a unique set of arguments used to make the query. The number of\n * records and the total byte size of data can be limited so as to ensure the\n * query does not take up too much memory.\n *\n * Example:\n * ```\n * import { sharedQuery, useSharedQuery } from \"@choksheak/react-utils/sharedQuery\";\n *\n * // Create a new shared query in the top level scope.\n * // The query function `queryFn` here does not take any arguments.\n * //\n * // Only the queryName and queryFn are required and everything else is\n * // optional.\n * //\n * // The queryName must be unique, otherwise we will throw an\n * // error. This is because defining the same queryName for two different\n * // queries implies that the queries are the same, thus violating the idea\n * // of a \"shared\" query, and almost certainly points to a bug in the code.\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * // Note that the query will only start fetching on mount in React.\n * // But you can do `usersQuery.getCachedOrFetch()` here to prefetch the\n * // data if necessary.\n * usersQuery.getCachedOrFetch();\n *\n * // Example of using a query function with arguments.\n * export const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: getUser, // getUser(userId: string) => User\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * export const UsersComponent: React.FC = () => {\n * // Second argument is optional, and is an array of arguments to be\n * // passed to the `queryFn`.\n * const users = useSharedQuery(usersQuery);\n *\n * return (\n * <>\n * <h1>List of Users</h1>\n *\n * <button onclick={users.refetch}>Refresh</button>\n *\n * {users.loading && <Loader />}\n *\n * {users.error && !users.data && <p>Error: {String(users.error)}</p>}\n *\n * <p>Data: {users.data ? JSON.stringify(users.data) : \"-\"}</p>\n * </>\n * );\n * };\n *\n * export const AnotherComponent: React.FC = () => {\n * // This query will be deduped with the one above, sharing the same\n * // data and the same fetches.\n * const users = useSharedQuery(usersQuery);\n * ...\n * };\n *\n * export const DisplayOneUser: React.FC<{userId: string}> = ({userId}) => {\n * // Example of using the query with an argument. Whenever the argument\n * // changes, the data will be fetched and updated automatically.\n * const user = useSharedQuery(getUserQuery, [userId]);\n *\n * return <>User: {user.data ? JSON.stringify(user.data) : \"-\"}</>;\n * };\n * ```\n */\n\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport getByteSize from \"object-sizeof\";\nimport {\n SetStateAction,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport {\n SharedState,\n sharedState,\n SharedStateOptions,\n useSharedState,\n} from \"./sharedState\";\nimport { PersistTo } from \"./storage\";\nimport { stringifyDeterministicForKeys } from \"./stringify\";\nimport { useDeepMemo } from \"./useDeepMemo\";\n\n/** Type of the queryFn used to fetch the data from source. */\nexport type QueryFn<TArgs extends unknown[], TData> = (\n ...args: TArgs\n) => Promise<TData> | TData;\n\n/** An entry for a persisted state corresponding to one query key. */\nexport type QueryStateValue<TData> = Readonly<{\n /**\n * When this record was last updated. Used for LRU determination. If you want\n * the data age, please use `dataUpdatedMs` instead.\n */\n lastUpdatedMs: number;\n\n loading: boolean;\n data?: TData;\n dataUpdatedMs?: number;\n\n /**\n * Use unknown type for error because we can't assume what type of variable\n * will be thrown from the query function. We also don't want to add this\n * type to QueryStateValue<TError> as it adds complexity to the type.\n */\n error?: unknown;\n\n errorUpdatedMs?: number;\n}>;\n\n/** Return type for useSharedQuery(). */\nexport type UseQueryResult<TData> = QueryStateValue<TData> &\n Readonly<{\n /**\n * Return the last non-empty data if you want to display the last loaded data\n * while waiting for the new data to load.\n */\n lastNonEmptyData: TData | undefined;\n\n /**\n * Cancels the inflight query (if any). Returns true if canceled.\n */\n abortCurrentQuery: (reason?: unknown) => boolean;\n\n /**\n * Manually refresh the data by doing a new query.\n */\n refetch: () => void;\n\n /**\n * If you got the data from somewhere else (e.g. an in-memory data update),\n * just set it directly.\n */\n setData: (data: TData, dataUpdatedMs?: number) => void;\n\n deleteData: () => void;\n }>;\n\n/**\n * The full query state is a record of all the fetch states mapped by\n * query key to the state entry.\n */\nexport type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;\n\n/** Options to configure a shared query. */\nexport type SharedQueryOptions<TArgs extends unknown[], TData> = {\n /**\n * The name could have been auto-generated, but we let the user give us a\n * human-readable name that can be identified quickly in the logs.\n * Note that the queryKey is a string formed by the queryName plus the query\n * arguments.\n */\n queryName: string;\n\n /**\n * Function to fetch data.\n */\n queryFn: QueryFn<TArgs, TData>;\n\n /**\n * ms before data considered stale; 0 means always stale.\n */\n staleMs?: number;\n\n /**\n * ms before data is removed from cache; 0 means never expire.\n */\n expiryMs?: number;\n\n /**\n * Trigger background re-fetch if stale.\n */\n refetchOnStale?: boolean;\n\n /**\n * Shortcut to set the localStorageKey or indexedDbKey automatically from\n * queryName. This is important because it avoids requiring the user to\n * specify the same queryName twice everytime.\n */\n persistTo?: PersistTo;\n\n /**\n * Max number of entries to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxSize?: number;\n\n /**\n * Max number of bytes to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxBytes?: number;\n\n /**\n * Keep a copy of the last non-empty data so that when you change the\n * query arguments, you can still display the old data while waiting for the\n * new data to load.\n */\n keepLastNonEmptyData?: boolean;\n\n /**\n * Customize the logger.\n */\n log?: (...args: unknown[]) => void;\n} & SharedStateOptions<SharedQueryState<TData>>;\n\n/** Users can override these values globally. */\nexport const SharedQueryConfig = {\n /** Refetch on every page load. */\n staleMs: 0,\n\n /** Keep in cache for a long time. */\n expiryMs: 30 * MS_PER_DAY,\n\n /** Always refetch automatically if the data is stale. */\n refetchOnStale: true,\n\n /** No default persistence configured. */\n persistTo: undefined as PersistTo | undefined,\n\n /** 100 is not too large, but please tweak accordingly. */\n maxSize: 100,\n\n /** 100kb limit before discarding old records. */\n maxBytes: 100_000,\n\n /** Default to log to console. */\n log: (...args: unknown[]) => console.log(\"[sharedQuery]\", ...args),\n};\n\nexport type SharedQueryConfig = typeof SharedQueryConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedQuery(config: Partial<SharedQueryConfig>) {\n Object.assign(SharedQueryConfig, config);\n}\n\n/** Please use sharedQuery() instead. */\nexport class SharedQuery<TArgs extends unknown[], TData> {\n private readonly inflightQueries = new Map<\n string,\n { promise: Promise<TData>; abortController: AbortController }\n >();\n\n public readonly queryState: SharedState<SharedQueryState<TData>>;\n\n /**\n * Keep track of the mounted query keys. This handles the case where an\n * earlier-started query returns later, and when it returns, it was already\n * unmounted, thus making it eligible for clean up right after fetch.\n */\n private readonly mountedKeys = new Map<string, number>();\n\n public readonly queryName: string;\n private readonly queryFn: QueryFn<TArgs, TData>;\n public readonly expiryMs: number;\n public readonly staleMs: number;\n public readonly refetchOnStale: boolean;\n public readonly maxSize: number;\n public readonly maxBytes: number;\n public readonly keepLastNonEmptyData: boolean;\n public readonly log: (...args: unknown[]) => void;\n\n public constructor(options: SharedQueryOptions<TArgs, TData>) {\n this.queryName = options.queryName;\n this.queryFn = options.queryFn;\n\n this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;\n this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;\n\n this.refetchOnStale =\n options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;\n\n // Apply shortcut when using `persistTo`.\n // Specifying the `store` will take precedence over `persistTo`.\n if (!options.store) {\n const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;\n\n if (persistTo) {\n options.store = {\n persistTo,\n key: options.queryName,\n expiryMs: this.expiryMs,\n };\n }\n }\n\n this.maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;\n this.maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;\n\n this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);\n\n this.log = options?.log ?? SharedQueryConfig.log;\n\n this.queryState = sharedState<SharedQueryState<TData>>(\n {}, // defaultValue\n options,\n );\n }\n\n /** Returns a key to identify requests based on the given args. */\n public getQueryKey(args: TArgs): string {\n return this.queryName + \":\" + stringifyDeterministicForKeys(args);\n }\n\n /**\n * Get the AbortController to abort the inflight query (if any).\n *\n * Note that once you get the controller, you can also get the signal easily\n * using `controller.signal`. So if you need both the controller and the\n * signal, just use this function.\n */\n public getAbortController(args: TArgs): AbortController | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortControllerByKey(queryKey);\n }\n\n public getAbortControllerByKey(queryKey: string): AbortController | null {\n return this.inflightQueries.get(queryKey)?.abortController ?? null;\n }\n\n /**\n * Get the AbortSignal to check for query abortions.\n *\n * Example:\n * ```\n * const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: (userId: string) => {\n * // Get the signal for this current execution.\n * const signal = getUserQuery.getAbortSignal([userId]);\n *\n * // Pass the signal to fetch so that it can be aborted.\n * const response = await fetch(`/users/${userId}`, { signal });\n *\n * // Check for errors.\n * if (!response.ok) {\n * throw new Error(response.statusText);\n * }\n *\n * // Return the data.\n * return await response.json();\n * },\n * });\n * ```\n */\n public getAbortSignal(args: TArgs): AbortSignal | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortSignalByKey(queryKey);\n }\n\n public getAbortSignalByKey(queryKey: string): AbortSignal | null {\n return this.getAbortControllerByKey(queryKey)?.signal ?? null;\n }\n\n private isStale(dataUpdatedMs: number): boolean {\n if (this.staleMs === 0) return true; // always stale\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.staleMs;\n }\n\n private isExpired(dataUpdatedMs: number): boolean {\n if (this.expiryMs === 0) return false; // never expire\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.expiryMs;\n }\n\n public async getCachedOrFetch(...args: TArgs): Promise<TData> {\n const queryKey = this.getQueryKey(args);\n const cached = this.queryState.getSnapshot()?.[queryKey];\n\n if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {\n // Return cached value if not stale.\n if (!this.isStale(cached.dataUpdatedMs ?? 0)) {\n this.log(`Return fresh data ${queryKey} from cache without fetching`);\n return cached.data;\n }\n\n // If stale, optionally update the data in the background.\n if (this.refetchOnStale) {\n this.log(`refetchOnStale for ${queryKey}`);\n void this.dedupedFetch(queryKey, ...args);\n }\n\n // Still return stale data immediately.\n this.log(`Returning stale data for ${queryKey}`);\n return cached.data;\n }\n\n return await this.dedupedFetch(queryKey, ...args);\n }\n\n private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {\n const inflightQuery = this.inflightQueries.get(queryKey);\n\n // De-duplicate in-flight requests\n if (inflightQuery) {\n this.log(`Deduplicating inflight fetch for ${queryKey}`);\n return inflightQuery.promise;\n }\n\n // Fetch new data\n const promise = this.startFetching(queryKey, args);\n const abortController = new AbortController();\n\n this.inflightQueries.set(queryKey, { promise, abortController });\n\n return promise;\n }\n\n private async startFetching(queryKey: string, args: TArgs) {\n this.log(`Start fetching ${queryKey}`);\n\n try {\n const data = await this.queryFn(...args);\n\n this.log(`Successfully fetched ${queryKey}`);\n this.setData(queryKey, data, Date.now());\n return data;\n } catch (e) {\n this.log(`Failed to fetch ${queryKey}: ${e}`);\n throw e;\n } finally {\n this.inflightQueries.delete(queryKey);\n }\n }\n\n /** Get the entire stored entry for a query key. */\n public getQueryValue(queryKey: string): QueryStateValue<TData> | undefined {\n return this.queryState.getSnapshot()?.[queryKey];\n }\n\n /** Get the current stored data for a query key. */\n public getData(queryKey: string): TData | undefined {\n const stateValue: QueryStateValue<TData> | undefined =\n this.queryState.getSnapshot()?.[queryKey];\n\n // Check for expiration.\n if (stateValue?.lastUpdatedMs) {\n if (this.isExpired(stateValue.lastUpdatedMs)) {\n this.deleteData(queryKey);\n return undefined;\n }\n }\n\n return stateValue?.data;\n }\n\n /** Set the data directly if the user obtained it from somewhere else. */\n public setData(\n queryKey: string,\n data: TData,\n dataUpdatedMs = Date.now(),\n ): void {\n const entry: QueryStateValue<TData> = {\n lastUpdatedMs: Date.now(),\n loading: false,\n data,\n dataUpdatedMs,\n };\n\n this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));\n\n this.enforceSizeLimit();\n }\n\n /**\n * Do a new fetch even when the data is already cached, but don't fetch if\n * another fetch is already inflight.\n */\n public async updateFromSource(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, ...args);\n }\n\n /** Delete the cached data for one set of arguments. */\n public deleteData(queryKey: string): void {\n const record = this.queryState.getSnapshot();\n const clone = { ...record };\n delete clone[queryKey];\n this.queryState.setValue(clone);\n\n const inflight = this.inflightQueries.get(queryKey);\n if (inflight) {\n this.inflightQueries.delete(queryKey);\n inflight.abortController.abort(\"Deleted\");\n }\n }\n\n /** Delete all currently cached data & all inflight promises. */\n public clear(): void {\n this.queryState.delete();\n\n for (const { abortController } of Array.from(\n this.inflightQueries.values(),\n )) {\n abortController.abort(\"Cleared\");\n }\n\n this.inflightQueries.clear();\n }\n\n /** Keep track of mounted keys. */\n public mount(queryKey: string): void {\n this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);\n }\n\n /** Clean up mounted keys. */\n public unmount(queryKey: string): void {\n const count = this.mountedKeys.get(queryKey);\n if (count === 1) {\n this.mountedKeys.delete(queryKey);\n } else {\n this.mountedKeys.set(queryKey, (count ?? 0) - 1);\n }\n }\n\n /**\n * Discard the oldest entries if the size exceeds the limit. The last\n * remaining record cannot be deleted no matter what the limit is.\n */\n public enforceSizeLimit(): void {\n // Skip if there are no limits.\n if (!this.maxSize && !this.maxBytes) {\n return;\n }\n\n const oldState = this.queryState.getSnapshot();\n const oldSize = Object.keys(oldState).length;\n\n // Cannot do GC if size is 1 or less.\n if (oldSize <= 1) {\n return;\n }\n\n const oldByteSize = getByteSize(oldState);\n\n let numToCut = this.maxSize ? oldSize - this.maxSize : 0;\n let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;\n\n // Log to indicate why trimming was not needed.\n if (numToCut <= 0 && bytesToCut <= 0) {\n this.log(\n `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Log to inform user that trimming is needed.\n this.log(\n `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`,\n );\n\n const newState = { ...oldState }; // shallow clone\n let needUpdate = false;\n\n const deleteKeyIfNotMounted = (key: string, expired: boolean) => {\n // Mounted keys cannot be cleaned up as they are visible in the UI.\n if (this.mountedKeys.has(key)) {\n this.log(`Cannot clean up ${key} as it is mounted`);\n return;\n }\n\n const byteSize = getByteSize(key) + getByteSize(newState[key]);\n this.log(\n `Cleaning up unmounted${expired ? \", expired\" : \"\"} ${key} (byteSize=${byteSize.toLocaleString()})`,\n );\n\n numToCut--;\n bytesToCut -= byteSize;\n\n delete newState[key];\n needUpdate = true;\n };\n\n let entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n\n // Clean up all expired entries. Note that the auto-expiration in the\n // storage adapter only applies to the entire state, which contains all\n // the different query keys and their values. Each query key has its own\n // timestamp and we need to apply our own expiration here.\n for (const [key] of entriesByTimeAscending) {\n // Anything after this is not expired yet.\n if (!this.isExpired(newState[key].lastUpdatedMs)) {\n break;\n }\n\n deleteKeyIfNotMounted(key, true);\n }\n\n if (numToCut > 0 || bytesToCut > 0) {\n if (needUpdate) {\n entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n }\n\n // Clean up starting from oldest entry first.\n for (const [key] of entriesByTimeAscending) {\n deleteKeyIfNotMounted(key, false);\n\n if (numToCut <= 0 && bytesToCut <= 0) {\n break;\n }\n }\n }\n\n // Log to indicate why trimming was not needed.\n if (!needUpdate) {\n this.log(\n `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Update the state.\n this.queryState.setValue(newState);\n\n // Log to inform user that the data was trimmed.\n this.log(\n `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`,\n );\n }\n}\n\n// Disallow duplicate query names.\nconst seenQueryNames = new Set<string>();\n\n/**\n * Create a reusable shared query object that can be used to auto de-duplicate\n * and cache all data fetches, with auto-expiration and staleness checks.\n *\n * Each `queryName` must be unique, and can only be declared once, most often\n * in the top level scope. The queryName is used for logging only.\n *\n * Example:\n * ```\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n * ```\n */\nexport function sharedQuery<TArgs extends unknown[], TData>(\n // The name could have been auto-generated, but we let the user give us a\n // human-readable name that can be identified quickly in the logs.\n options: SharedQueryOptions<TArgs, TData>,\n): SharedQuery<TArgs, TData> {\n if (seenQueryNames.has(options.queryName)) {\n throw new Error(`Duplicate shared query \"${options.queryName}\"`);\n }\n seenQueryNames.add(options.queryName);\n\n return new SharedQuery(options);\n}\n\n// Using any so that we don't need to typecast later.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst DEFAULT_QUERY_STATE_ENTRY: Readonly<QueryStateValue<any>> = {\n lastUpdatedMs: 0,\n loading: true,\n};\n\n/**\n * React hook to make use of a shared query inside any React component. The\n * args do not need to be specified if no args are needed, i.e. this would work\n * ```\n * const allUsers = useSharedQuery(listUsersQuery);\n * ```\n *\n * Example:\n * ```\n * const users = useSharedQuery(usersQuery, \"userid123\");\n * ```\n */\nexport function useSharedQuery<TArgs extends unknown[], TData>(\n query: SharedQuery<TArgs, TData>,\n // Optional: Default to use no arguments.\n ...args: TArgs\n): UseQueryResult<TData> {\n // We don't expect users to provide a stable `args`, so stabilize it here.\n const stableArgs = useDeepMemo(args);\n\n const [queryState, setQueryState] = useSharedState(query.queryState);\n\n const isMounted = useRef(true);\n\n const queryKey = useMemo(\n () => query.getQueryKey(stableArgs),\n [query, stableArgs],\n );\n\n // Init with empty object.\n const queryStateValue: QueryStateValue<TData> =\n queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;\n\n const setQueryStateValue = useCallback(\n (next: SetStateAction<QueryStateValue<TData>>) => {\n setQueryState((prev) => {\n const clone = { ...prev };\n clone[queryKey] =\n typeof next === \"function\"\n ? next(clone[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY)\n : next;\n return clone;\n });\n },\n [queryKey, setQueryState],\n );\n\n // Keep the last non-empty data loaded in case you want to show a placeholder\n // during new loads.\n const [lastNonEmptyData, setLastNonEmptyData] = useState<TData | undefined>();\n\n useEffect(() => {\n if (query.keepLastNonEmptyData && queryStateValue.data !== undefined) {\n setLastNonEmptyData(queryStateValue.data);\n }\n }, [query.keepLastNonEmptyData, queryStateValue.data]);\n\n // The fetch logic wrapped in useCallback to be stable for useEffect\n const execute = useCallback(\n async (forceRefresh: boolean) => {\n query.log(`Begin executing shared query ${queryKey}`);\n\n setQueryStateValue((prev) => ({\n // Keep old data and error.\n ...prev,\n lastUpdatedMs: Date.now(),\n loading: true,\n }));\n\n try {\n const data = await (forceRefresh\n ? query.updateFromSource(...stableArgs)\n : query.getCachedOrFetch(...stableArgs));\n\n if (isMounted.current) {\n const now = Date.now();\n\n setQueryStateValue({\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n });\n }\n } catch (e) {\n if (isMounted.current) {\n const now = Date.now();\n\n setQueryStateValue((prev) => ({\n // Keep old data.\n ...prev,\n lastUpdatedMs: now,\n loading: false,\n error: e,\n errorUpdatedMs: now,\n }));\n }\n }\n },\n [query, queryKey, setQueryStateValue, stableArgs],\n );\n\n useEffect(() => {\n isMounted.current = true;\n query.mount(queryKey);\n\n void execute(false);\n\n return () => {\n isMounted.current = false;\n query.unmount(queryKey);\n };\n }, [execute, query, queryKey]);\n\n return useMemo(() => {\n return {\n ...queryStateValue,\n lastNonEmptyData,\n // This works only if the user-given queryFn supports abort. If not,\n // this function doesn't do anything, since we don't have any means to\n // abort the running queryFn.\n abortCurrentQuery: (reason?: unknown) => {\n const controller = query.getAbortControllerByKey(queryKey);\n controller?.abort(reason);\n return Boolean(controller);\n },\n refetch: () => {\n void execute(true);\n },\n setData: (data: TData, dataUpdatedMs?: number) => {\n const now = Date.now();\n setQueryStateValue((prev) => ({\n ...prev,\n data,\n dataUpdatedMs: dataUpdatedMs ?? now,\n lastUpdatedMs: now,\n loading: false,\n }));\n },\n deleteData: () => {\n setQueryState((prev) => {\n const clone = { ...prev };\n delete clone[queryKey];\n return clone;\n });\n },\n } satisfies UseQueryResult<TData>;\n }, [\n execute,\n lastNonEmptyData,\n query,\n queryKey,\n queryStateValue,\n setQueryState,\n setQueryStateValue,\n ]);\n}\n","/**\n * @packageDocumentation\n *\n * The \"shared state\" is a React state that shares the same value across the\n * entire app. It is largely similar to the jotai library, but is much smaller\n * and much easier to work with when used for persisting in local storage or\n * indexed db, with an automatic expiration for persisted values.\n *\n * This library aims to be the shortest, simplest, easiest to use, and most\n * efficient implementation of shared global React states with seamless support\n * for client persistence.\n *\n * Example:\n * ```\n * import { sharedState, useSharedState } from \"@choksheak/react-utils/sharedState\";\n *\n * // Create a new shared state in the top level scope.\n * // The first argument is the default value, which is required.\n * // The second argument is an options object, and is optional (defaults\n * // to storing in memory only without any persistence).\n * const usersState = sharedState([], { indexedDbKey: \"users\" });\n *\n * export const UsersComponent: React.FC = () => {\n * // Get both the value and the setter.\n * const [users, setUsers] = useSharedState(usersState);\n * ...\n * };\n *\n * export const ReadOnlyComponent: React.FC = () => {\n * // Get the value only.\n * const users = useSharedStateValue(usersState);\n * ...\n * };\n *\n * export const WriteOnlyComponent: React.FC = () => {\n * // Get the setter only.\n * const setUsers = useSharedStateSetter(usersState);\n * ...\n * };\n * ```\n */\n\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport { Dispatch, SetStateAction, useSyncExternalStore } from \"react\";\n\nimport { getStorageAdapter, StorageOptions } from \"./storage\";\n\n/************************************************************************/\n/* Global config */\n/************************************************************************/\n\n/**\n * These values are configurable by the user at any time.\n */\nexport const SharedStateConfig = {\n /**\n * Default duration for storage expiration in milliseconds. Initial value\n * is 30 days, but can be changed to anything.\n */\n storeExpiryMs: 30 * MS_PER_DAY,\n\n /**\n * True to load from storage only on mount, false (default) to load in\n * the top level scope when the shared state is defined.\n */\n lazyLoad: false,\n};\n\nexport type SharedStateConfig = typeof SharedStateConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedState(config: Partial<SharedStateConfig>) {\n Object.assign(SharedStateConfig, config);\n}\n\n/************************************************************************/\n/* Shared state implementation */\n/************************************************************************/\n\ntype Subscriber<T> = (next: T, prev: T) => void;\n\nclass PubSubStore {\n private dataByKey = new Map<string, unknown>();\n private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();\n\n // This could be undefined if T includes undefined, else it should never\n // return undefined because the user has to always specify the default value.\n public get<T>(key: string): T {\n return this.dataByKey.get(key) as T;\n }\n\n public set<T>(key: string, value: T): void {\n const prev = this.dataByKey.get(key) as T;\n\n // Do nothing if no change.\n if (prev === value) return;\n\n this.dataByKey.set(key, value);\n this.notify(key, value, prev);\n }\n\n public delete(key: string): void {\n this.dataByKey.delete(key);\n }\n\n public setNoNotify<T>(key: string, value: T): void {\n this.dataByKey.set(key, value);\n }\n\n // Subscribe to changes of a specific key\n public subscribe<T>(key: string, subscriber: Subscriber<T>): () => void {\n let subscribers = this.subscribersByKey.get(key);\n if (!subscribers) {\n subscribers = new Set();\n this.subscribersByKey.set(key, subscribers);\n }\n\n // Type coercion: we store Subscriber<unknown>\n subscribers.add(subscriber as Subscriber<unknown>);\n\n // Return unsubscribe function\n return () => {\n subscribers.delete(subscriber as Subscriber<unknown>);\n if (subscribers.size === 0) this.subscribersByKey.delete(key);\n };\n }\n\n private notify<T>(key: string, next: T, prev: T): void {\n const subscribers = this.subscribersByKey.get(key);\n if (!subscribers) return;\n\n // Make copy to protect against mutation during iteration.\n const copy = Array.from(subscribers.values());\n for (const subscriber of copy) {\n try {\n subscriber(next, prev);\n } catch (e) {\n console.error(`Error invoking subscriber:`, e);\n }\n }\n }\n}\n\nconst pubSubStore = new PubSubStore();\n\nconst STORAGE_KEY = \"state\";\n\n/** Options to configure a shared state. */\nexport type SharedStateOptions<T> = {\n lazyLoad?: boolean;\n} & StorageOptions<T>;\n\nlet stateKey = 0;\n\n/** Please use sharedState() instead. */\nexport class SharedState<T> {\n private readonly pubSubKey: string;\n private readonly storageAdapter: StorageAdapter<T> | null;\n public readonly setValueBounded: Dispatch<SetStateAction<T>>;\n public initStarted = false;\n public initDone = false;\n\n public constructor(\n private readonly defaultValue: T,\n options?: SharedStateOptions<T>,\n ) {\n this.pubSubKey = String(stateKey++);\n\n // Use max of 1 storage adapter per shared state.\n this.storageAdapter = getStorageAdapter(\n options,\n SharedStateConfig.storeExpiryMs,\n );\n\n // Same as this.setValue, but binding the `this` reference.\n this.setValueBounded = (next: SetStateAction<T>) => {\n this.setValue(next);\n };\n\n // Always set the default value first to avoid returning undefineds if that\n // is not part of T.\n pubSubStore.setNoNotify(this.pubSubKey, defaultValue);\n\n const lazyLoad = options?.lazyLoad;\n const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;\n\n if (!lazy) {\n void this.initDefaultValueOnce();\n }\n }\n\n /** `subscribe` function required by useSyncExternalStore. */\n public subscribe(subscriber: () => void): () => void {\n return pubSubStore.subscribe(this.pubSubKey, () => subscriber());\n }\n\n /** Returns true if there is a backing store. */\n public hasStore(): boolean {\n return Boolean(this.storageAdapter);\n }\n\n /**\n * Set a value directly into the store. Does nothing if there is no\n * backing store.\n *\n * Warning: If you want to set the value properly, you should use\n * `setValue()` instead which also updates the in-memory copy.\n */\n public async setInStore(value: T): Promise<void> {\n return await this.storageAdapter?.set(STORAGE_KEY, value);\n }\n\n /**\n * Get a value directly from the store. Returns undefined if there is no\n * backing store.\n */\n public async getFromStore(): Promise<T | undefined> {\n return await this.storageAdapter?.get(STORAGE_KEY);\n }\n\n /**\n * Get the value of the shared state.\n * `getSnapshot` function — must return same ref if value unchanged.\n * Note that this would return the default value if data init from store is\n * not completed yet.\n */\n public getSnapshot(): T {\n return pubSubStore.get(this.pubSubKey);\n }\n\n /** Set the value of the shared state. */\n public setValue(next: SetStateAction<T>): void {\n if (typeof next === \"function\") {\n const prev = pubSubStore.get<T>(this.pubSubKey);\n next = (next as (p: T) => T)(prev);\n }\n\n pubSubStore.set(this.pubSubKey, next);\n void this.setInStore(next);\n }\n\n /**\n * Remove the value of the shared state. Actually this just sets the value\n * back to the given default value.\n */\n public delete() {\n pubSubStore.set(this.pubSubKey, this.defaultValue);\n void this.storageAdapter?.set(STORAGE_KEY, this.defaultValue);\n }\n\n /** Initial default handling. */\n public async initDefaultValueOnce() {\n if (this.initDone || this.initStarted) return;\n this.initStarted = true;\n\n const stored = await this.getFromStore();\n const value = stored !== undefined ? stored : this.defaultValue;\n\n pubSubStore.set(this.pubSubKey, value);\n\n this.initDone = true;\n }\n}\n\n/**\n * Create a new shared state object. Put this code at the top level scope:\n *\n * Example:\n * ```\n * const counterState = sharedState<number>(0);\n * ```\n */\nexport function sharedState<T>(\n defaultValue: T,\n options?: SharedStateOptions<T>,\n): SharedState<T> {\n return new SharedState(defaultValue, options);\n}\n\n/**\n * React hook for subscribing to a key in the global store.\n *\n * Example:\n * ```\n * const [counter, setCounter] = useSharedState(counterState);\n * ```\n */\nexport function useSharedState<T>(\n state: SharedState<T>,\n): [T, Dispatch<SetStateAction<T>>] {\n state.initDefaultValueOnce();\n\n const value = useSyncExternalStore(\n (onStoreChange) => state.subscribe(onStoreChange),\n () => state.getSnapshot(),\n // getServerSnapshot is needed to prevent SSR dev errors in nextjs.\n () => state.getSnapshot(),\n );\n\n return [value, state.setValueBounded];\n}\n\n/**\n * Same as useSharedState(), but returns the value only.\n *\n * Example:\n * ```\n * const counter = useSharedStateValue(counterState);\n * ```\n */\nexport function useSharedStateValue<T>(state: SharedState<T>) {\n return useSharedState(state)[0];\n}\n\n/**\n * Same as useSharedState(), but returns the setter only.\n *\n * Example:\n * ```\n * const setCounter = useSharedStateSetter(counterState);\n * ```\n */\nexport function useSharedStateSetter<T>(state: SharedState<T>) {\n return useSharedState(state)[1];\n}\n","import { kvStore } from \"@choksheak/ts-utils/kvStore\";\nimport { LocalStore } from \"@choksheak/ts-utils/localStore\";\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\n\n/** List of all available pre-built persistence options. */\nexport type PersistTo = \"localStorage\" | \"indexedDb\";\n\n/** Options to configure a pre-built store adapter. */\nexport type StoreOptions = {\n /** Choose which pre-built store adapter to use. */\n persistTo: PersistTo;\n\n /** Storage key to act as a namespace to store the data. */\n key: string;\n\n /** How long to wait before the value is considered expired. */\n expiryMs?: number;\n\n /** Returns true if the given value supposed to be of type T is valid. */\n isValid?: (u: unknown) => boolean;\n};\n\n/** Either use a pre-built store, or a custom one you provide. */\nexport type StorageOptions<T> = {\n /**\n * You can either specify the storage adapter directly to use a custom\n * store, or specify the properties to use the pre-built ones.\n */\n store?: StorageAdapter<T> | StoreOptions;\n};\n\n// In SSR (Server-Side Rendering) mode, we never want to use our storage\n// adapters for anything.\nconst IS_NOT_BROWSER = typeof window === \"undefined\";\n\nexport function getStorageAdapter<T>(\n options: StorageOptions<T> | undefined,\n defaultStoreExpiryMs: number,\n): StorageAdapter<T> | null {\n const { store } = options ?? {};\n\n if (!store) {\n return null;\n }\n\n if (\n \"persistTo\" in store &&\n typeof store.persistTo === \"string\" &&\n \"key\" in store &&\n typeof store.key === \"string\"\n ) {\n const expiryMs = store.expiryMs || defaultStoreExpiryMs;\n const isValid = store.isValid;\n\n switch (store.persistTo) {\n case \"localStorage\": {\n const adapter = new LocalStore(store.key, {\n defaultExpiryMs: expiryMs,\n });\n\n // Handle SSR.\n return {\n set: (key: string, value: T): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n adapter.set(key, value);\n },\n get: (key: string): T | undefined => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const value = adapter.get<T>(key);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `localStorage adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n adapter.delete(key);\n return undefined;\n }\n\n return value;\n },\n delete: (key: string): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.delete(key);\n },\n clear: (): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.clear();\n },\n };\n }\n\n case \"indexedDb\": {\n // Use the key prefix as a unique namespace for this store.\n const keyPrefix = store.key + \":\";\n const isValid = store.isValid;\n\n // Prefix all keys using `keyPrefix` so that we can keep all data in\n // the same store in the same DB. If we keep the data in different\n // DB stores, then GC will not be able to clean up abandoned stores.\n return {\n set: async (key: string, value: T): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n await kvStore.set(keyPrefix + key, value, expiryMs);\n },\n get: async (key: string): Promise<T | undefined> => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const fullKey = keyPrefix + key;\n const value = await kvStore.get<T>(fullKey);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `indexedDb adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n await kvStore.delete(fullKey);\n return undefined;\n }\n\n return value;\n },\n delete: async (key: string): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.delete(keyPrefix + key);\n },\n clear: async (): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.clear();\n },\n };\n }\n\n default:\n store.persistTo satisfies never;\n throw new Error(`Unknown store.adapter ${store.persistTo}`);\n }\n }\n\n return store as StorageAdapter<T>;\n}\n","/**\n * JSON stringify a value of any type, and returns an unsafe string. The string\n * is \"unsafe\" because you might not be able to restore the original object\n * using the serialized JSON. This is specifically used for query key generation\n * and is not suitable for more general uses.\n *\n * Note: The given value should not contain any functions because functions\n * cannot be serialized and deserialized properly.\n */\nexport function stringifyDeterministicForKeys(params: unknown): string {\n const cache = new Set();\n\n return JSON.stringify(params, (_, value) => {\n if (typeof value === \"function\") {\n throw new Error(`Functions should not be passed into query parameters`);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n return value;\n }\n\n if (cache.has(value)) {\n // Circular reference detected! Return a placeholder string.\n return \"[Circular]\";\n }\n\n cache.add(value);\n\n // Don't crash on circular references, but don't mark the top level object\n // as circular because we want to return as much string as possible.\n if (value !== params) {\n try {\n JSON.stringify(value);\n } catch {\n return \"[Unserializable]\";\n }\n }\n\n return Object.keys(value)\n .sort()\n .reduce(\n (result: Record<string, unknown>, key: string) => {\n result[key] = value[key];\n return result;\n },\n {} as Record<string, unknown>,\n );\n });\n}\n","import isEqual from \"lodash/isEqual\";\nimport { useMemo, useState } from \"react\";\n\n/**\n * Provides a stable value for T based on deep equality. The deep equality is\n * determined by the `isEqual()` function in lodash.\n */\nexport function useDeepMemo<T>(value: T): T {\n const [state, setState] = useState<T>(value);\n\n return useMemo(() => {\n if (state === value || isEqual(state, value)) {\n return state;\n }\n\n // We check equality, so this should be safe.\n // eslint-disable-next-line react-hooks/set-state-in-render\n setState(value);\n\n return value;\n }, [state, value]);\n}\n"],"mappings":"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","names":["sharedQuery_exports","__export","SharedQuery","SharedQueryConfig","configureSharedQuery","sharedQuery","useSharedQuery","__toCommonJS","import_timeConstants","import_object_sizeof","import_react","import_timeConstants","import_react","import_kvStore","import_localStore","IS_NOT_BROWSER","getStorageAdapter","options","defaultStoreExpiryMs","store","expiryMs","isValid","adapter","key","value","keyPrefix","fullKey","SharedStateConfig","PubSubStore","key","value","prev","subscriber","subscribers","next","copy","e","pubSubStore","STORAGE_KEY","stateKey","SharedState","defaultValue","options","getStorageAdapter","SharedStateConfig","lazyLoad","stored","sharedState","useSharedState","state","onStoreChange","stringifyDeterministicForKeys","params","cache","_","value","result","key","import_isEqual","import_react","useDeepMemo","value","state","setState","isEqual","SharedQueryConfig","args","configureSharedQuery","config","SharedQuery","options","persistTo","sharedState","stringifyDeterministicForKeys","queryKey","dataUpdatedMs","cached","inflightQuery","promise","abortController","data","e","stateValue","entry","prev","key","clone","inflight","count","oldState","oldSize","oldByteSize","getByteSize","numToCut","bytesToCut","newState","needUpdate","deleteKeyIfNotMounted","expired","byteSize","entriesByTimeAscending","entry1","entry2","seenQueryNames","sharedQuery","DEFAULT_QUERY_STATE_ENTRY","useSharedQuery","query","stableArgs","useDeepMemo","queryState","setQueryState","useSharedState","isMounted","queryStateValue","setQueryStateValue","next","lastNonEmptyData","setLastNonEmptyData","execute","forceRefresh","now","reason","controller"]}
1
+ {"version":3,"sources":["../src/sharedQuery.ts","../src/sharedState.ts","../src/storage.ts","../src/stringify.ts","../src/useDeepMemo.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * The \"shared query\" is a data fetching convenience library that helps users\n * to load data, check for staleness and expiration, cache in memory, local\n * storage or indexed db, and be able to manipulate the data anytime.\n *\n * Each shared query creates a new private shared state that handles the data\n * storage. This shared state contains the records for all queries, where each\n * query is a unique set of arguments used to make the query. The number of\n * records and the total byte size of data can be limited so as to ensure the\n * query does not take up too much memory.\n *\n * Example:\n * ```\n * import { sharedQuery, useSharedQuery } from \"@choksheak/react-utils/sharedQuery\";\n *\n * // Create a new shared query in the top level scope.\n * // The query function `queryFn` here does not take any arguments.\n * //\n * // Only the queryName and queryFn are required and everything else is\n * // optional.\n * //\n * // The queryName must be unique, otherwise we will throw an\n * // error. This is because defining the same queryName for two different\n * // queries implies that the queries are the same, thus violating the idea\n * // of a \"shared\" query, and almost certainly points to a bug in the code.\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * // Note that the query will only start fetching on mount in React.\n * // But you can do `usersQuery.getCachedOrFetch()` here to prefetch the\n * // data if necessary.\n * usersQuery.getCachedOrFetch();\n *\n * // Example of using a query function with arguments.\n * export const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: getUser, // getUser(userId: string) => User\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * export const UsersComponent: React.FC = () => {\n * // Second argument is optional, and is an array of arguments to be\n * // passed to the `queryFn`.\n * const users = useSharedQuery(usersQuery);\n *\n * return (\n * <>\n * <h1>List of Users</h1>\n *\n * <button onclick={users.refetch}>Refresh</button>\n *\n * {users.loading && <Loader />}\n *\n * {users.error && !users.data && <p>Error: {String(users.error)}</p>}\n *\n * <p>Data: {users.data ? JSON.stringify(users.data) : \"-\"}</p>\n * </>\n * );\n * };\n *\n * export const AnotherComponent: React.FC = () => {\n * // This query will be deduped with the one above, sharing the same\n * // data and the same fetches.\n * const users = useSharedQuery(usersQuery);\n * ...\n * };\n *\n * export const DisplayOneUser: React.FC<{userId: string}> = ({userId}) => {\n * // Example of using the query with an argument. Whenever the argument\n * // changes, the data will be fetched and updated automatically.\n * const user = useSharedQuery(getUserQuery, [userId]);\n *\n * return <>User: {user.data ? JSON.stringify(user.data) : \"-\"}</>;\n * };\n * ```\n */\n\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport getByteSize from \"object-sizeof\";\nimport {\n SetStateAction,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport {\n SharedState,\n sharedState,\n SharedStateOptions,\n useSharedState,\n} from \"./sharedState\";\nimport { PersistTo } from \"./storage\";\nimport { stringifyDeterministicForKeys } from \"./stringify\";\nimport { useDeepMemo } from \"./useDeepMemo\";\n\n/** Type of the queryFn used to fetch the data from source. */\nexport type QueryFn<TArgs extends unknown[], TData> = (\n ...args: TArgs\n) => Promise<TData> | TData;\n\n/** An entry for a persisted state corresponding to one query key. */\nexport type QueryStateValue<TData> = Readonly<{\n /**\n * When this record was last updated. Used for LRU determination. If you want\n * the data age, please use `dataUpdatedMs` instead.\n */\n lastUpdatedMs: number;\n\n loading: boolean;\n data?: TData;\n dataUpdatedMs?: number;\n\n /**\n * Use unknown type for error because we can't assume what type of variable\n * will be thrown from the query function. We also don't want to add this\n * type to QueryStateValue<TError> as it adds complexity to the type.\n */\n error?: unknown;\n\n errorUpdatedMs?: number;\n}>;\n\n/** Return type for useSharedQuery(). */\nexport type UseQueryResult<TData> = QueryStateValue<TData> &\n Readonly<{\n /**\n * Return the last non-empty data if you want to display the last loaded data\n * while waiting for the new data to load.\n */\n lastNonEmptyData: TData | undefined;\n\n /**\n * Cancels the inflight query (if any). Returns true if canceled.\n */\n abortCurrentQuery: (reason?: unknown) => boolean;\n\n /**\n * Manually refresh the data by doing a new query.\n */\n refetch: () => void;\n\n /**\n * If you got the data from somewhere else (e.g. an in-memory data update),\n * just set it directly.\n */\n setData: (data: TData, dataUpdatedMs?: number) => void;\n\n deleteData: () => void;\n }>;\n\n/**\n * The full query state is a record of all the fetch states mapped by\n * query key to the state entry.\n */\nexport type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;\n\n/** Options to configure a shared query. */\nexport type SharedQueryOptions<TArgs extends unknown[], TData> = {\n /**\n * The name could have been auto-generated, but we let the user give us a\n * human-readable name that can be identified quickly in the logs.\n * Note that the queryKey is a string formed by the queryName plus the query\n * arguments.\n */\n queryName: string;\n\n /**\n * Function to fetch data.\n */\n queryFn: QueryFn<TArgs, TData>;\n\n /**\n * Function to re-fetch data. Usually this would just be the `queryFn`, but\n * if you need a different behavior for refetching, then specify this.\n */\n refetchFn?: QueryFn<TArgs, TData>;\n\n /**\n * ms before data considered stale; 0 means always stale.\n */\n staleMs?: number;\n\n /**\n * ms before data is removed from cache; 0 means never expire.\n */\n expiryMs?: number;\n\n /**\n * Trigger background re-fetch if stale.\n */\n refetchOnStale?: boolean;\n\n /**\n * Shortcut to set the localStorageKey or indexedDbKey automatically from\n * queryName. This is important because it avoids requiring the user to\n * specify the same queryName twice everytime.\n */\n persistTo?: PersistTo;\n\n /**\n * Max number of entries to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxSize?: number;\n\n /**\n * Max number of bytes to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxBytes?: number;\n\n /**\n * Keep a copy of the last non-empty data so that when you change the\n * query arguments, you can still display the old data while waiting for the\n * new data to load.\n */\n keepLastNonEmptyData?: boolean;\n\n /**\n * Customize the logger.\n */\n log?: (...args: unknown[]) => void;\n} & SharedStateOptions<SharedQueryState<TData>>;\n\n/** Users can override these values globally. */\nexport const SharedQueryConfig = {\n /** Refetch on every page load. */\n staleMs: 0,\n\n /** Keep in cache for a long time. */\n expiryMs: 30 * MS_PER_DAY,\n\n /** Always refetch automatically if the data is stale. */\n refetchOnStale: true,\n\n /** No default persistence configured. */\n persistTo: undefined as PersistTo | undefined,\n\n /** 100 is not too large, but please tweak accordingly. */\n maxSize: 100,\n\n /** 100kb limit before discarding old records. */\n maxBytes: 100_000,\n\n /** Default to log to console. */\n log: (...args: unknown[]) => console.log(\"[sharedQuery]\", ...args),\n};\n\nexport type SharedQueryConfig = typeof SharedQueryConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedQuery(config: Partial<SharedQueryConfig>) {\n Object.assign(SharedQueryConfig, config);\n}\n\n/** Whether this is a normal query or an explicit refetch. */\ntype QueryType = \"query\" | \"refetch\";\n\n/** Please use sharedQuery() instead. */\nexport class SharedQuery<TArgs extends unknown[], TData> {\n private readonly inflightQueries = new Map<\n string,\n { promise: Promise<TData>; abortController: AbortController }\n >();\n\n public readonly queryState: SharedState<SharedQueryState<TData>>;\n\n /**\n * Keep track of the mounted query keys. This handles the case where an\n * earlier-started query returns later, and when it returns, it was already\n * unmounted, thus making it eligible for clean up right after fetch.\n */\n private readonly mountedKeys = new Map<string, number>();\n\n public readonly queryName: string;\n public readonly queryFn: QueryFn<TArgs, TData>;\n public readonly refetchFn: QueryFn<TArgs, TData> | undefined;\n public readonly expiryMs: number;\n public readonly staleMs: number;\n public readonly refetchOnStale: boolean;\n public readonly maxSize: number;\n public readonly maxBytes: number;\n public readonly keepLastNonEmptyData: boolean;\n public readonly log: (...args: unknown[]) => void;\n\n public constructor(options: SharedQueryOptions<TArgs, TData>) {\n this.queryName = options.queryName;\n this.queryFn = options.queryFn;\n this.refetchFn = options.refetchFn;\n\n this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;\n this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;\n\n this.refetchOnStale =\n options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;\n\n // Apply shortcut when using `persistTo`.\n // Specifying the `store` will take precedence over `persistTo`.\n if (!options.store) {\n const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;\n\n if (persistTo) {\n options.store = {\n persistTo,\n key: options.queryName,\n expiryMs: this.expiryMs,\n };\n }\n }\n\n this.maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;\n this.maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;\n\n this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);\n\n this.log = options?.log ?? SharedQueryConfig.log;\n\n this.queryState = sharedState<SharedQueryState<TData>>(\n {}, // defaultValue\n options,\n );\n }\n\n /** Returns a key to identify requests based on the given args. */\n public getQueryKey(args: TArgs): string {\n return this.queryName + \":\" + stringifyDeterministicForKeys(args);\n }\n\n /**\n * Get the AbortController to abort the inflight query (if any).\n *\n * Note that once you get the controller, you can also get the signal easily\n * using `controller.signal`. So if you need both the controller and the\n * signal, just use this function.\n */\n public getAbortController(args: TArgs): AbortController | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortControllerByKey(queryKey);\n }\n\n public getAbortControllerByKey(queryKey: string): AbortController | null {\n return this.inflightQueries.get(queryKey)?.abortController ?? null;\n }\n\n /**\n * Get the AbortSignal to check for query abortions.\n *\n * Example:\n * ```\n * const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: (userId: string) => {\n * // Get the signal for this current execution.\n * const signal = getUserQuery.getAbortSignal([userId]);\n *\n * // Pass the signal to fetch so that it can be aborted.\n * const response = await fetch(`/users/${userId}`, { signal });\n *\n * // Check for errors.\n * if (!response.ok) {\n * throw new Error(response.statusText);\n * }\n *\n * // Return the data.\n * return await response.json();\n * },\n * });\n * ```\n */\n public getAbortSignal(args: TArgs): AbortSignal | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortSignalByKey(queryKey);\n }\n\n public getAbortSignalByKey(queryKey: string): AbortSignal | null {\n return this.getAbortControllerByKey(queryKey)?.signal ?? null;\n }\n\n private isStale(dataUpdatedMs: number): boolean {\n if (this.staleMs === 0) return true; // always stale\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.staleMs;\n }\n\n private isExpired(dataUpdatedMs: number): boolean {\n if (this.expiryMs === 0) return false; // never expire\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.expiryMs;\n }\n\n /** For cached data, we need to return the correct timestamps as well. */\n public async getCachedOrFetch(\n ...args: TArgs\n ): Promise<QueryStateValue<TData>> {\n const queryKey = this.getQueryKey(args);\n const cached = this.queryState.getSnapshot()?.[queryKey];\n\n if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {\n // Return cached value if not stale.\n if (!this.isStale(cached.dataUpdatedMs ?? 0)) {\n this.log(`Return fresh data ${queryKey} from cache without fetching`);\n return cached;\n }\n\n // If stale, optionally update the data in the background.\n if (this.refetchOnStale) {\n this.log(`refetchOnStale for ${queryKey}`);\n void this.dedupedFetch(queryKey, \"query\", ...args);\n }\n\n // Still return stale data immediately.\n this.log(`Returning stale data for ${queryKey}`);\n return cached;\n }\n\n const data = await this.dedupedFetch(queryKey, \"query\", ...args);\n const now = Date.now();\n\n return {\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n };\n }\n\n private dedupedFetch(\n queryKey: string,\n queryType: QueryType,\n ...args: TArgs\n ): Promise<TData> {\n const inflightQuery = this.inflightQueries.get(queryKey);\n\n // De-duplicate in-flight requests. Note that queries and refetches are\n // also deduped because they should technically be trying to fetch the same\n // data.\n if (inflightQuery) {\n this.log(\n `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`,\n );\n return inflightQuery.promise;\n }\n\n // Fetch new data\n const promise = this.startFetching(queryKey, queryType, args);\n const abortController = new AbortController();\n\n this.inflightQueries.set(queryKey, { promise, abortController });\n\n return promise;\n }\n\n private async startFetching(\n queryKey: string,\n queryType: QueryType,\n args: TArgs,\n ) {\n this.log(`Start fetching ${queryKey}, queryType=${queryType}`);\n\n try {\n const data = await (queryType === \"refetch\" && this.refetchFn\n ? this.refetchFn(...args)\n : this.queryFn(...args));\n\n this.log(`Successfully fetched ${queryKey}`);\n this.setData(queryKey, data, Date.now());\n return data;\n } catch (e) {\n this.log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);\n throw e;\n } finally {\n this.inflightQueries.delete(queryKey);\n }\n }\n\n /** Get the entire stored entry for a query key. */\n public getQueryValue(queryKey: string): QueryStateValue<TData> | undefined {\n return this.queryState.getSnapshot()?.[queryKey];\n }\n\n /** Get the current stored data for a query key. */\n public getData(queryKey: string): TData | undefined {\n const stateValue: QueryStateValue<TData> | undefined =\n this.queryState.getSnapshot()?.[queryKey];\n\n // Check for expiration.\n if (stateValue?.lastUpdatedMs) {\n if (this.isExpired(stateValue.lastUpdatedMs)) {\n this.deleteData(queryKey);\n return undefined;\n }\n }\n\n return stateValue?.data;\n }\n\n /** Set the data directly if the user obtained it from somewhere else. */\n public setData(\n queryKey: string,\n data: TData,\n dataUpdatedMs = Date.now(),\n ): void {\n const entry: QueryStateValue<TData> = {\n lastUpdatedMs: Date.now(),\n loading: false,\n data,\n dataUpdatedMs,\n };\n\n this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));\n\n this.enforceSizeLimit();\n }\n\n /**\n * Do a new fetch even when the data is already cached, but don't fetch if\n * another fetch is already inflight.\n */\n public async updateFromSource(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, \"query\", ...args);\n }\n\n /**\n * Perform an explicit refetch. If `options.refetchFn` was not specified,\n * then this would be identical to calling `this.updateFromSource()`.\n */\n public async refetch(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, \"refetch\", ...args);\n }\n\n /** Delete the cached data for one set of arguments. */\n public deleteData(queryKey: string): void {\n const record = this.queryState.getSnapshot();\n const clone = { ...record };\n delete clone[queryKey];\n this.queryState.setValue(clone);\n\n const inflight = this.inflightQueries.get(queryKey);\n if (inflight) {\n this.inflightQueries.delete(queryKey);\n inflight.abortController.abort(\"Deleted\");\n }\n }\n\n /** Delete all currently cached data & all inflight promises. */\n public clear(): void {\n this.queryState.delete();\n\n for (const { abortController } of Array.from(\n this.inflightQueries.values(),\n )) {\n abortController.abort(\"Cleared\");\n }\n\n this.inflightQueries.clear();\n }\n\n /** Keep track of mounted keys. */\n public mount(queryKey: string): void {\n this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);\n }\n\n /** Clean up mounted keys. */\n public unmount(queryKey: string): void {\n const count = this.mountedKeys.get(queryKey);\n if (count === 1) {\n this.mountedKeys.delete(queryKey);\n } else {\n this.mountedKeys.set(queryKey, (count ?? 0) - 1);\n }\n }\n\n /**\n * Discard the oldest entries if the size exceeds the limit. The last\n * remaining record cannot be deleted no matter what the limit is.\n */\n public enforceSizeLimit(): void {\n // Skip if there are no limits.\n if (!this.maxSize && !this.maxBytes) {\n return;\n }\n\n const oldState = this.queryState.getSnapshot();\n const oldSize = Object.keys(oldState).length;\n\n // Cannot do GC if size is 1 or less.\n if (oldSize <= 1) {\n return;\n }\n\n const oldByteSize = getByteSize(oldState);\n\n let numToCut = this.maxSize ? oldSize - this.maxSize : 0;\n let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;\n\n // Log to indicate why trimming was not needed.\n if (numToCut <= 0 && bytesToCut <= 0) {\n this.log(\n `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Log to inform user that trimming is needed.\n this.log(\n `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`,\n );\n\n const newState = { ...oldState }; // shallow clone\n let needUpdate = false;\n\n const deleteKeyIfNotMounted = (key: string, expired: boolean) => {\n // Mounted keys cannot be cleaned up as they are visible in the UI.\n if (this.mountedKeys.has(key)) {\n this.log(`Cannot clean up ${key} as it is mounted`);\n return;\n }\n\n const byteSize = getByteSize(key) + getByteSize(newState[key]);\n this.log(\n `Cleaning up unmounted${expired ? \", expired\" : \"\"} ${key} (byteSize=${byteSize.toLocaleString()})`,\n );\n\n numToCut--;\n bytesToCut -= byteSize;\n\n delete newState[key];\n needUpdate = true;\n };\n\n let entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n\n // Clean up all expired entries. Note that the auto-expiration in the\n // storage adapter only applies to the entire state, which contains all\n // the different query keys and their values. Each query key has its own\n // timestamp and we need to apply our own expiration here.\n for (const [key] of entriesByTimeAscending) {\n // Anything after this is not expired yet.\n if (!this.isExpired(newState[key].lastUpdatedMs)) {\n break;\n }\n\n deleteKeyIfNotMounted(key, true);\n }\n\n if (numToCut > 0 || bytesToCut > 0) {\n if (needUpdate) {\n entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n }\n\n // Clean up starting from oldest entry first.\n for (const [key] of entriesByTimeAscending) {\n deleteKeyIfNotMounted(key, false);\n\n if (numToCut <= 0 && bytesToCut <= 0) {\n break;\n }\n }\n }\n\n // Log to indicate why trimming was not needed.\n if (!needUpdate) {\n this.log(\n `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Update the state.\n this.queryState.setValue(newState);\n\n // Log to inform user that the data was trimmed.\n this.log(\n `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`,\n );\n }\n}\n\n// Disallow duplicate query names.\nconst seenQueryNames = new Set<string>();\n\n/**\n * Create a reusable shared query object that can be used to auto de-duplicate\n * and cache all data fetches, with auto-expiration and staleness checks.\n *\n * Each `queryName` must be unique, and can only be declared once, most often\n * in the top level scope. The queryName is used for logging only.\n *\n * Example:\n * ```\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n * ```\n */\nexport function sharedQuery<TArgs extends unknown[], TData>(\n // The name could have been auto-generated, but we let the user give us a\n // human-readable name that can be identified quickly in the logs.\n options: SharedQueryOptions<TArgs, TData>,\n): SharedQuery<TArgs, TData> {\n if (seenQueryNames.has(options.queryName)) {\n throw new Error(`Duplicate shared query \"${options.queryName}\"`);\n }\n seenQueryNames.add(options.queryName);\n\n return new SharedQuery(options);\n}\n\n// Using any so that we don't need to typecast later.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst DEFAULT_QUERY_STATE_ENTRY: Readonly<QueryStateValue<any>> = {\n lastUpdatedMs: 0,\n loading: true,\n};\n\n/**\n * React hook to make use of a shared query inside any React component. The\n * args do not need to be specified if no args are needed, i.e. this would work\n * ```\n * const allUsers = useSharedQuery(listUsersQuery);\n * ```\n *\n * Example:\n * ```\n * const users = useSharedQuery(usersQuery, \"userid123\");\n * ```\n */\nexport function useSharedQuery<TArgs extends unknown[], TData>(\n query: SharedQuery<TArgs, TData>,\n // Optional: Default to use no arguments.\n ...args: TArgs\n): UseQueryResult<TData> {\n // We don't expect users to provide a stable `args`, so stabilize it here.\n const stableArgs = useDeepMemo(args);\n\n const [queryState, setQueryState] = useSharedState(query.queryState);\n\n const isMounted = useRef(true);\n\n const queryKey = useMemo(\n () => query.getQueryKey(stableArgs),\n [query, stableArgs],\n );\n\n // Init with empty object.\n const queryStateValue: QueryStateValue<TData> =\n queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;\n\n const setQueryStateValue = useCallback(\n (next: SetStateAction<QueryStateValue<TData>>) => {\n setQueryState((prev) => {\n const clone = { ...prev };\n clone[queryKey] =\n typeof next === \"function\"\n ? next(clone[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY)\n : next;\n return clone;\n });\n },\n [queryKey, setQueryState],\n );\n\n // Keep the last non-empty data loaded in case you want to show a placeholder\n // during new loads.\n const [lastNonEmptyData, setLastNonEmptyData] = useState<TData | undefined>();\n\n useEffect(() => {\n if (query.keepLastNonEmptyData && queryStateValue.data !== undefined) {\n setLastNonEmptyData(queryStateValue.data);\n }\n }, [query.keepLastNonEmptyData, queryStateValue.data]);\n\n // The fetch logic wrapped in useCallback to be stable for useEffect\n const execute = useCallback(\n async (isRefetch: boolean) => {\n query.log(\n `Begin executing shared query ${queryKey}, isRefetch=${isRefetch}`,\n );\n\n if (!isMounted.current) return;\n\n setQueryStateValue((prev) => ({\n // Keep old data and error.\n ...prev,\n lastUpdatedMs: Date.now(),\n loading: true,\n }));\n\n try {\n let state: QueryStateValue<TData>;\n\n if (isRefetch) {\n const data = await query.refetch(...stableArgs);\n const now = Date.now();\n\n state = {\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n };\n } else {\n state = await query.getCachedOrFetch(...stableArgs);\n }\n\n if (!isMounted.current) return;\n\n setQueryStateValue(state);\n } catch (e) {\n if (!isMounted.current) return;\n\n const now = Date.now();\n\n setQueryStateValue((prev) => ({\n // Keep old data.\n ...prev,\n lastUpdatedMs: now,\n loading: false,\n error: e,\n errorUpdatedMs: now,\n }));\n }\n },\n [query, queryKey, setQueryStateValue, stableArgs],\n );\n\n useEffect(() => {\n isMounted.current = true;\n query.mount(queryKey);\n\n void execute(false);\n\n return () => {\n isMounted.current = false;\n query.unmount(queryKey);\n };\n }, [execute, query, queryKey]);\n\n return useMemo(() => {\n return {\n ...queryStateValue,\n lastNonEmptyData,\n // This works only if the user-given queryFn supports abort. If not,\n // this function doesn't do anything, since we don't have any means to\n // abort the running queryFn.\n abortCurrentQuery: (reason?: unknown) => {\n const controller = query.getAbortControllerByKey(queryKey);\n controller?.abort(reason);\n return Boolean(controller);\n },\n refetch: () => {\n void execute(true);\n },\n setData: (data: TData, dataUpdatedMs?: number) => {\n const now = Date.now();\n setQueryStateValue((prev) => ({\n ...prev,\n data,\n dataUpdatedMs: dataUpdatedMs ?? now,\n lastUpdatedMs: now,\n loading: false,\n }));\n },\n deleteData: () => {\n setQueryState((prev) => {\n const clone = { ...prev };\n delete clone[queryKey];\n return clone;\n });\n },\n } satisfies UseQueryResult<TData>;\n }, [\n execute,\n lastNonEmptyData,\n query,\n queryKey,\n queryStateValue,\n setQueryState,\n setQueryStateValue,\n ]);\n}\n","/**\n * @packageDocumentation\n *\n * The \"shared state\" is a React state that shares the same value across the\n * entire app. It is largely similar to the jotai library, but is much smaller\n * and much easier to work with when used for persisting in local storage or\n * indexed db, with an automatic expiration for persisted values.\n *\n * This library aims to be the shortest, simplest, easiest to use, and most\n * efficient implementation of shared global React states with seamless support\n * for client persistence.\n *\n * Example:\n * ```\n * import { sharedState, useSharedState } from \"@choksheak/react-utils/sharedState\";\n *\n * // Create a new shared state in the top level scope.\n * // The first argument is the default value, which is required.\n * // The second argument is an options object, and is optional (defaults\n * // to storing in memory only without any persistence).\n * const usersState = sharedState([], { indexedDbKey: \"users\" });\n *\n * export const UsersComponent: React.FC = () => {\n * // Get both the value and the setter.\n * const [users, setUsers] = useSharedState(usersState);\n * ...\n * };\n *\n * export const ReadOnlyComponent: React.FC = () => {\n * // Get the value only.\n * const users = useSharedStateValue(usersState);\n * ...\n * };\n *\n * export const WriteOnlyComponent: React.FC = () => {\n * // Get the setter only.\n * const setUsers = useSharedStateSetter(usersState);\n * ...\n * };\n * ```\n */\n\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport { Dispatch, SetStateAction, useSyncExternalStore } from \"react\";\n\nimport { getStorageAdapter, StorageOptions } from \"./storage\";\n\n/************************************************************************/\n/* Global config */\n/************************************************************************/\n\n/**\n * These values are configurable by the user at any time.\n */\nexport const SharedStateConfig = {\n /**\n * Default duration for storage expiration in milliseconds. Initial value\n * is 30 days, but can be changed to anything.\n */\n storeExpiryMs: 30 * MS_PER_DAY,\n\n /**\n * True to load from storage only on mount, false (default) to load in\n * the top level scope when the shared state is defined.\n */\n lazyLoad: false,\n};\n\nexport type SharedStateConfig = typeof SharedStateConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedState(config: Partial<SharedStateConfig>) {\n Object.assign(SharedStateConfig, config);\n}\n\n/************************************************************************/\n/* Shared state implementation */\n/************************************************************************/\n\ntype Subscriber<T> = (next: T, prev: T) => void;\n\nclass PubSubStore {\n private dataByKey = new Map<string, unknown>();\n private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();\n\n // This could be undefined if T includes undefined, else it should never\n // return undefined because the user has to always specify the default value.\n public get<T>(key: string): T {\n return this.dataByKey.get(key) as T;\n }\n\n public set<T>(key: string, value: T): void {\n const prev = this.dataByKey.get(key) as T;\n\n // Do nothing if no change.\n if (prev === value) return;\n\n this.dataByKey.set(key, value);\n this.notify(key, value, prev);\n }\n\n public delete(key: string): void {\n this.dataByKey.delete(key);\n }\n\n public setNoNotify<T>(key: string, value: T): void {\n this.dataByKey.set(key, value);\n }\n\n // Subscribe to changes of a specific key\n public subscribe<T>(key: string, subscriber: Subscriber<T>): () => void {\n let subscribers = this.subscribersByKey.get(key);\n if (!subscribers) {\n subscribers = new Set();\n this.subscribersByKey.set(key, subscribers);\n }\n\n // Type coercion: we store Subscriber<unknown>\n subscribers.add(subscriber as Subscriber<unknown>);\n\n // Return unsubscribe function\n return () => {\n subscribers.delete(subscriber as Subscriber<unknown>);\n if (subscribers.size === 0) this.subscribersByKey.delete(key);\n };\n }\n\n private notify<T>(key: string, next: T, prev: T): void {\n const subscribers = this.subscribersByKey.get(key);\n if (!subscribers) return;\n\n // Make copy to protect against mutation during iteration.\n const copy = Array.from(subscribers.values());\n for (const subscriber of copy) {\n try {\n subscriber(next, prev);\n } catch (e) {\n console.error(`Error invoking subscriber:`, e);\n }\n }\n }\n}\n\nconst pubSubStore = new PubSubStore();\n\nconst STORAGE_KEY = \"state\";\n\n/** Options to configure a shared state. */\nexport type SharedStateOptions<T> = {\n lazyLoad?: boolean;\n} & StorageOptions<T>;\n\nlet stateKey = 0;\n\n/** Please use sharedState() instead. */\nexport class SharedState<T> {\n private readonly pubSubKey: string;\n private readonly storageAdapter: StorageAdapter<T> | null;\n public readonly setValueBounded: Dispatch<SetStateAction<T>>;\n public initStarted = false;\n public initDone = false;\n\n public constructor(\n private readonly defaultValue: T,\n options?: SharedStateOptions<T>,\n ) {\n this.pubSubKey = String(stateKey++);\n\n // Use max of 1 storage adapter per shared state.\n this.storageAdapter = getStorageAdapter(\n options,\n SharedStateConfig.storeExpiryMs,\n );\n\n // Same as this.setValue, but binding the `this` reference.\n this.setValueBounded = (next: SetStateAction<T>) => {\n this.setValue(next);\n };\n\n // Always set the default value first to avoid returning undefineds if that\n // is not part of T.\n pubSubStore.setNoNotify(this.pubSubKey, defaultValue);\n\n const lazyLoad = options?.lazyLoad;\n const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;\n\n if (!lazy) {\n void this.initDefaultValueOnce();\n }\n }\n\n /** `subscribe` function required by useSyncExternalStore. */\n public subscribe(subscriber: () => void): () => void {\n return pubSubStore.subscribe(this.pubSubKey, () => subscriber());\n }\n\n /** Returns true if there is a backing store. */\n public hasStore(): boolean {\n return Boolean(this.storageAdapter);\n }\n\n /**\n * Set a value directly into the store. Does nothing if there is no\n * backing store.\n *\n * Warning: If you want to set the value properly, you should use\n * `setValue()` instead which also updates the in-memory copy.\n */\n public async setInStore(value: T): Promise<void> {\n return await this.storageAdapter?.set(STORAGE_KEY, value);\n }\n\n /**\n * Get a value directly from the store. Returns undefined if there is no\n * backing store.\n */\n public async getFromStore(): Promise<T | undefined> {\n return await this.storageAdapter?.get(STORAGE_KEY);\n }\n\n /**\n * Get the value of the shared state.\n * `getSnapshot` function — must return same ref if value unchanged.\n * Note that this would return the default value if data init from store is\n * not completed yet.\n */\n public getSnapshot(): T {\n return pubSubStore.get(this.pubSubKey);\n }\n\n /** Set the value of the shared state. */\n public setValue(next: SetStateAction<T>): void {\n if (typeof next === \"function\") {\n const prev = pubSubStore.get<T>(this.pubSubKey);\n next = (next as (p: T) => T)(prev);\n }\n\n pubSubStore.set(this.pubSubKey, next);\n void this.setInStore(next);\n }\n\n /**\n * Remove the value of the shared state. Actually this just sets the value\n * back to the given default value.\n */\n public delete() {\n pubSubStore.set(this.pubSubKey, this.defaultValue);\n void this.storageAdapter?.set(STORAGE_KEY, this.defaultValue);\n }\n\n /** Initial default handling. */\n public async initDefaultValueOnce() {\n if (this.initDone || this.initStarted) return;\n this.initStarted = true;\n\n const stored = await this.getFromStore();\n const value = stored !== undefined ? stored : this.defaultValue;\n\n pubSubStore.set(this.pubSubKey, value);\n\n this.initDone = true;\n }\n}\n\n/**\n * Create a new shared state object. Put this code at the top level scope:\n *\n * Example:\n * ```\n * const counterState = sharedState<number>(0);\n * ```\n */\nexport function sharedState<T>(\n defaultValue: T,\n options?: SharedStateOptions<T>,\n): SharedState<T> {\n return new SharedState(defaultValue, options);\n}\n\n/**\n * React hook for subscribing to a key in the global store.\n *\n * Example:\n * ```\n * const [counter, setCounter] = useSharedState(counterState);\n * ```\n */\nexport function useSharedState<T>(\n state: SharedState<T>,\n): [T, Dispatch<SetStateAction<T>>] {\n state.initDefaultValueOnce();\n\n const value = useSyncExternalStore(\n (onStoreChange) => state.subscribe(onStoreChange),\n () => state.getSnapshot(),\n // getServerSnapshot is needed to prevent SSR dev errors in nextjs.\n () => state.getSnapshot(),\n );\n\n return [value, state.setValueBounded];\n}\n\n/**\n * Same as useSharedState(), but returns the value only.\n *\n * Example:\n * ```\n * const counter = useSharedStateValue(counterState);\n * ```\n */\nexport function useSharedStateValue<T>(state: SharedState<T>) {\n return useSharedState(state)[0];\n}\n\n/**\n * Same as useSharedState(), but returns the setter only.\n *\n * Example:\n * ```\n * const setCounter = useSharedStateSetter(counterState);\n * ```\n */\nexport function useSharedStateSetter<T>(state: SharedState<T>) {\n return useSharedState(state)[1];\n}\n","import { kvStore } from \"@choksheak/ts-utils/kvStore\";\nimport { LocalStore } from \"@choksheak/ts-utils/localStore\";\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\n\n/** List of all available pre-built persistence options. */\nexport type PersistTo = \"localStorage\" | \"indexedDb\";\n\n/** Options to configure a pre-built store adapter. */\nexport type StoreOptions = {\n /** Choose which pre-built store adapter to use. */\n persistTo: PersistTo;\n\n /** Storage key to act as a namespace to store the data. */\n key: string;\n\n /** How long to wait before the value is considered expired. */\n expiryMs?: number;\n\n /** Returns true if the given value supposed to be of type T is valid. */\n isValid?: (u: unknown) => boolean;\n};\n\n/** Either use a pre-built store, or a custom one you provide. */\nexport type StorageOptions<T> = {\n /**\n * You can either specify the storage adapter directly to use a custom\n * store, or specify the properties to use the pre-built ones.\n */\n store?: StorageAdapter<T> | StoreOptions;\n};\n\n// In SSR (Server-Side Rendering) mode, we never want to use our storage\n// adapters for anything.\nconst IS_NOT_BROWSER = typeof window === \"undefined\";\n\nexport function getStorageAdapter<T>(\n options: StorageOptions<T> | undefined,\n defaultStoreExpiryMs: number,\n): StorageAdapter<T> | null {\n const { store } = options ?? {};\n\n if (!store) {\n return null;\n }\n\n if (\n \"persistTo\" in store &&\n typeof store.persistTo === \"string\" &&\n \"key\" in store &&\n typeof store.key === \"string\"\n ) {\n const expiryMs = store.expiryMs || defaultStoreExpiryMs;\n const isValid = store.isValid;\n\n switch (store.persistTo) {\n case \"localStorage\": {\n const adapter = new LocalStore(store.key, {\n defaultExpiryMs: expiryMs,\n });\n\n // Handle SSR.\n return {\n set: (key: string, value: T): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n adapter.set(key, value);\n },\n get: (key: string): T | undefined => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const value = adapter.get<T>(key);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `localStorage adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n adapter.delete(key);\n return undefined;\n }\n\n return value;\n },\n delete: (key: string): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.delete(key);\n },\n clear: (): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.clear();\n },\n };\n }\n\n case \"indexedDb\": {\n // Use the key prefix as a unique namespace for this store.\n const keyPrefix = store.key + \":\";\n const isValid = store.isValid;\n\n // Prefix all keys using `keyPrefix` so that we can keep all data in\n // the same store in the same DB. If we keep the data in different\n // DB stores, then GC will not be able to clean up abandoned stores.\n return {\n set: async (key: string, value: T): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n await kvStore.set(keyPrefix + key, value, expiryMs);\n },\n get: async (key: string): Promise<T | undefined> => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const fullKey = keyPrefix + key;\n const value = await kvStore.get<T>(fullKey);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `indexedDb adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n await kvStore.delete(fullKey);\n return undefined;\n }\n\n return value;\n },\n delete: async (key: string): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.delete(keyPrefix + key);\n },\n clear: async (): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.clear();\n },\n };\n }\n\n default:\n store.persistTo satisfies never;\n throw new Error(`Unknown store.adapter ${store.persistTo}`);\n }\n }\n\n return store as StorageAdapter<T>;\n}\n","/**\n * JSON stringify a value of any type, and returns an unsafe string. The string\n * is \"unsafe\" because you might not be able to restore the original object\n * using the serialized JSON. This is specifically used for query key generation\n * and is not suitable for more general uses.\n *\n * Note: The given value should not contain any functions because functions\n * cannot be serialized and deserialized properly.\n */\nexport function stringifyDeterministicForKeys(params: unknown): string {\n const cache = new Set();\n\n return JSON.stringify(params, (_, value) => {\n if (typeof value === \"function\") {\n throw new Error(`Functions should not be passed into query parameters`);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n return value;\n }\n\n if (cache.has(value)) {\n // Circular reference detected! Return a placeholder string.\n return \"[Circular]\";\n }\n\n cache.add(value);\n\n // Don't crash on circular references, but don't mark the top level object\n // as circular because we want to return as much string as possible.\n if (value !== params) {\n try {\n JSON.stringify(value);\n } catch {\n return \"[Unserializable]\";\n }\n }\n\n return Object.keys(value)\n .sort()\n .reduce(\n (result: Record<string, unknown>, key: string) => {\n result[key] = value[key];\n return result;\n },\n {} as Record<string, unknown>,\n );\n });\n}\n","import isEqual from \"lodash/isEqual\";\nimport { useMemo, useState } from \"react\";\n\n/**\n * Provides a stable value for T based on deep equality. The deep equality is\n * determined by the `isEqual()` function in lodash.\n */\nexport function useDeepMemo<T>(value: T): T {\n const [state, setState] = useState<T>(value);\n\n return useMemo(() => {\n if (state === value || isEqual(state, value)) {\n return state;\n }\n\n // We check equality, so this should be safe.\n // eslint-disable-next-line react-hooks/set-state-in-render\n setState(value);\n\n return value;\n }, [state, value]);\n}\n"],"mappings":"0jBAAA,IAAAA,GAAA,GAAAC,EAAAD,GAAA,iBAAAE,EAAA,sBAAAC,EAAA,yBAAAC,EAAA,gBAAAC,EAAA,mBAAAC,IAAA,eAAAC,EAAAP,IAoFA,IAAAQ,EAA2B,6CAC3BC,EAAwB,4BACxBC,EAOO,iBClDP,IAAAC,EAA2B,6CAC3BC,EAA+D,iBC5C/D,IAAAC,EAAwB,uCACxBC,EAA2B,0CAgCrBC,EAAiB,OAAO,OAAW,IAElC,SAASC,EACdC,EACAC,EAC0B,CAC1B,GAAM,CAAE,MAAAC,CAAM,EAAIF,GAAW,CAAC,EAE9B,GAAI,CAACE,EACH,OAAO,KAGT,GACE,cAAeA,GACf,OAAOA,EAAM,WAAc,UAC3B,QAASA,GACT,OAAOA,EAAM,KAAQ,SACrB,CACA,IAAMC,EAAWD,EAAM,UAAYD,EAC7BG,EAAUF,EAAM,QAEtB,OAAQA,EAAM,UAAW,CACvB,IAAK,eAAgB,CACnB,IAAMG,EAAU,IAAI,aAAWH,EAAM,IAAK,CACxC,gBAAiBC,CACnB,CAAC,EAGD,MAAO,CACL,IAAK,CAACG,EAAaC,IAAmB,CAChCT,GAGJO,EAAQ,IAAIC,EAAKC,CAAK,CACxB,EACA,IAAMD,GAA+B,CACnC,GAAIR,EAAgB,OAEpB,IAAMS,EAAQF,EAAQ,IAAOC,CAAG,EAEhC,GAAIF,GAAW,CAACA,EAAQG,CAAK,EAAG,CAC9B,QAAQ,KACN,wDAAwDD,CAAG,KAAK,KAAK,UAAUC,CAAK,CAAC,EACvF,EACAF,EAAQ,OAAOC,CAAG,EAClB,MACF,CAEA,OAAOC,CACT,EACA,OAASD,GAAsB,CACzBR,GAEJO,EAAQ,OAAOC,CAAG,CACpB,EACA,MAAO,IAAY,CACbR,GAEJO,EAAQ,MAAM,CAChB,CACF,CACF,CAEA,IAAK,YAAa,CAEhB,IAAMG,EAAYN,EAAM,IAAM,IACxBE,EAAUF,EAAM,QAKtB,MAAO,CACL,IAAK,MAAOI,EAAaC,IAA4B,CAC/CT,GAGJ,MAAM,UAAQ,IAAIU,EAAYF,EAAKC,EAAOJ,CAAQ,CACpD,EACA,IAAK,MAAOG,GAAwC,CAClD,GAAIR,EAAgB,OAEpB,IAAMW,EAAUD,EAAYF,EACtBC,EAAQ,MAAM,UAAQ,IAAOE,CAAO,EAE1C,GAAIL,GAAW,CAACA,EAAQG,CAAK,EAAG,CAC9B,QAAQ,KACN,qDAAqDD,CAAG,KAAK,KAAK,UAAUC,CAAK,CAAC,EACpF,EACA,MAAM,UAAQ,OAAOE,CAAO,EAC5B,MACF,CAEA,OAAOF,CACT,EACA,OAAQ,MAAOD,GAA+B,CACxCR,GAEJ,MAAM,UAAQ,OAAOU,EAAYF,CAAG,CACtC,EACA,MAAO,SAA2B,CAC5BR,GAEJ,MAAM,UAAQ,MAAM,CACtB,CACF,CACF,CAEA,QACE,MAAAI,EAAM,UACA,IAAI,MAAM,yBAAyBA,EAAM,SAAS,EAAE,CAC9D,CACF,CAEA,OAAOA,CACT,CD5FO,IAAMQ,EAAoB,CAK/B,cAAe,GAAK,aAMpB,SAAU,EACZ,EAeA,IAAMC,EAAN,KAAkB,CACR,UAAY,IAAI,IAChB,iBAAmB,IAAI,IAIxB,IAAOC,EAAgB,CAC5B,OAAO,KAAK,UAAU,IAAIA,CAAG,CAC/B,CAEO,IAAOA,EAAaC,EAAgB,CACzC,IAAMC,EAAO,KAAK,UAAU,IAAIF,CAAG,EAG/BE,IAASD,IAEb,KAAK,UAAU,IAAID,EAAKC,CAAK,EAC7B,KAAK,OAAOD,EAAKC,EAAOC,CAAI,EAC9B,CAEO,OAAOF,EAAmB,CAC/B,KAAK,UAAU,OAAOA,CAAG,CAC3B,CAEO,YAAeA,EAAaC,EAAgB,CACjD,KAAK,UAAU,IAAID,EAAKC,CAAK,CAC/B,CAGO,UAAaD,EAAaG,EAAuC,CACtE,IAAIC,EAAc,KAAK,iBAAiB,IAAIJ,CAAG,EAC/C,OAAKI,IACHA,EAAc,IAAI,IAClB,KAAK,iBAAiB,IAAIJ,EAAKI,CAAW,GAI5CA,EAAY,IAAID,CAAiC,EAG1C,IAAM,CACXC,EAAY,OAAOD,CAAiC,EAChDC,EAAY,OAAS,GAAG,KAAK,iBAAiB,OAAOJ,CAAG,CAC9D,CACF,CAEQ,OAAUA,EAAaK,EAASH,EAAe,CACrD,IAAME,EAAc,KAAK,iBAAiB,IAAIJ,CAAG,EACjD,GAAI,CAACI,EAAa,OAGlB,IAAME,EAAO,MAAM,KAAKF,EAAY,OAAO,CAAC,EAC5C,QAAWD,KAAcG,EACvB,GAAI,CACFH,EAAWE,EAAMH,CAAI,CACvB,OAASK,EAAG,CACV,QAAQ,MAAM,6BAA8BA,CAAC,CAC/C,CAEJ,CACF,EAEMC,EAAc,IAAIT,EAElBU,EAAc,QAOhBC,EAAW,EAGFC,EAAN,KAAqB,CAOnB,YACYC,EACjBC,EACA,CAFiB,kBAAAD,EAGjB,KAAK,UAAY,OAAOF,GAAU,EAGlC,KAAK,eAAiBI,EACpBD,EACAE,EAAkB,aACpB,EAGA,KAAK,gBAAmBV,GAA4B,CAClD,KAAK,SAASA,CAAI,CACpB,EAIAG,EAAY,YAAY,KAAK,UAAWI,CAAY,EAEpD,IAAMI,EAAWH,GAAS,UACbG,IAAa,OAAYA,EAAWD,EAAkB,WAG5D,KAAK,qBAAqB,CAEnC,CAjCiB,UACA,eACD,gBACT,YAAc,GACd,SAAW,GAgCX,UAAUZ,EAAoC,CACnD,OAAOK,EAAY,UAAU,KAAK,UAAW,IAAML,EAAW,CAAC,CACjE,CAGO,UAAoB,CACzB,MAAO,EAAQ,KAAK,cACtB,CASA,MAAa,WAAWF,EAAyB,CAC/C,OAAO,MAAM,KAAK,gBAAgB,IAAIQ,EAAaR,CAAK,CAC1D,CAMA,MAAa,cAAuC,CAClD,OAAO,MAAM,KAAK,gBAAgB,IAAIQ,CAAW,CACnD,CAQO,aAAiB,CACtB,OAAOD,EAAY,IAAI,KAAK,SAAS,CACvC,CAGO,SAASH,EAA+B,CAC7C,GAAI,OAAOA,GAAS,WAAY,CAC9B,IAAMH,EAAOM,EAAY,IAAO,KAAK,SAAS,EAC9CH,EAAQA,EAAqBH,CAAI,CACnC,CAEAM,EAAY,IAAI,KAAK,UAAWH,CAAI,EAC/B,KAAK,WAAWA,CAAI,CAC3B,CAMO,QAAS,CACdG,EAAY,IAAI,KAAK,UAAW,KAAK,YAAY,EAC5C,KAAK,gBAAgB,IAAIC,EAAa,KAAK,YAAY,CAC9D,CAGA,MAAa,sBAAuB,CAClC,GAAI,KAAK,UAAY,KAAK,YAAa,OACvC,KAAK,YAAc,GAEnB,IAAMQ,EAAS,MAAM,KAAK,aAAa,EACjChB,EAAQgB,IAAW,OAAYA,EAAS,KAAK,aAEnDT,EAAY,IAAI,KAAK,UAAWP,CAAK,EAErC,KAAK,SAAW,EAClB,CACF,EAUO,SAASiB,EACdN,EACAC,EACgB,CAChB,OAAO,IAAIF,EAAYC,EAAcC,CAAO,CAC9C,CAUO,SAASM,EACdC,EACkC,CAClC,OAAAA,EAAM,qBAAqB,EASpB,IAPO,wBACXC,GAAkBD,EAAM,UAAUC,CAAa,EAChD,IAAMD,EAAM,YAAY,EAExB,IAAMA,EAAM,YAAY,CAC1B,EAEeA,EAAM,eAAe,CACtC,CEpSO,SAASE,EAA8BC,EAAyB,CACrE,IAAMC,EAAQ,IAAI,IAElB,OAAO,KAAK,UAAUD,EAAQ,CAACE,EAAGC,IAAU,CAC1C,GAAI,OAAOA,GAAU,WACnB,MAAM,IAAI,MAAM,sDAAsD,EAGxE,GAAI,CAACA,GAAS,OAAOA,GAAU,UAAY,MAAM,QAAQA,CAAK,EAC5D,OAAOA,EAGT,GAAIF,EAAM,IAAIE,CAAK,EAEjB,MAAO,aAOT,GAJAF,EAAM,IAAIE,CAAK,EAIXA,IAAUH,EACZ,GAAI,CACF,KAAK,UAAUG,CAAK,CACtB,MAAQ,CACN,MAAO,kBACT,CAGF,OAAO,OAAO,KAAKA,CAAK,EACrB,KAAK,EACL,OACC,CAACC,EAAiCC,KAChCD,EAAOC,CAAG,EAAIF,EAAME,CAAG,EAChBD,GAET,CAAC,CACH,CACJ,CAAC,CACH,CChDA,IAAAE,EAAoB,6BACpBC,EAAkC,iBAM3B,SAASC,EAAeC,EAAa,CAC1C,GAAM,CAACC,EAAOC,CAAQ,KAAI,YAAYF,CAAK,EAE3C,SAAO,WAAQ,IACTC,IAAUD,MAAS,EAAAG,SAAQF,EAAOD,CAAK,EAClCC,GAKTC,EAASF,CAAK,EAEPA,GACN,CAACC,EAAOD,CAAK,CAAC,CACnB,CJsNO,IAAMI,EAAoB,CAE/B,QAAS,EAGT,SAAU,GAAK,aAGf,eAAgB,GAGhB,UAAW,OAGX,QAAS,IAGT,SAAU,IAGV,IAAK,IAAIC,IAAoB,QAAQ,IAAI,gBAAiB,GAAGA,CAAI,CACnE,EAKO,SAASC,EAAqBC,EAAoC,CACvE,OAAO,OAAOH,EAAmBG,CAAM,CACzC,CAMO,IAAMC,EAAN,KAAkD,CACtC,gBAAkB,IAAI,IAKvB,WAOC,YAAc,IAAI,IAEnB,UACA,QACA,UACA,SACA,QACA,eACA,QACA,SACA,qBACA,IAET,YAAYC,EAA2C,CAa5D,GAZA,KAAK,UAAYA,EAAQ,UACzB,KAAK,QAAUA,EAAQ,QACvB,KAAK,UAAYA,EAAQ,UAEzB,KAAK,QAAUA,GAAS,SAAWL,EAAkB,QACrD,KAAK,SAAWK,GAAS,UAAYL,EAAkB,SAEvD,KAAK,eACHK,GAAS,gBAAkBL,EAAkB,eAI3C,CAACK,EAAQ,MAAO,CAClB,IAAMC,EAAYD,EAAQ,WAAaL,EAAkB,UAErDM,IACFD,EAAQ,MAAQ,CACd,UAAAC,EACA,IAAKD,EAAQ,UACb,SAAU,KAAK,QACjB,EAEJ,CAEA,KAAK,QAAUA,GAAS,SAAWL,EAAkB,QACrD,KAAK,SAAWK,GAAS,UAAYL,EAAkB,SAEvD,KAAK,qBAAuB,EAAQK,GAAS,qBAE7C,KAAK,IAAMA,GAAS,KAAOL,EAAkB,IAE7C,KAAK,WAAaO,EAChB,CAAC,EACDF,CACF,CACF,CAGO,YAAYJ,EAAqB,CACtC,OAAO,KAAK,UAAY,IAAMO,EAA8BP,CAAI,CAClE,CASO,mBAAmBA,EAAqC,CAC7D,IAAMQ,EAAW,KAAK,YAAYR,CAAI,EACtC,OAAO,KAAK,wBAAwBQ,CAAQ,CAC9C,CAEO,wBAAwBA,EAA0C,CACvE,OAAO,KAAK,gBAAgB,IAAIA,CAAQ,GAAG,iBAAmB,IAChE,CA2BO,eAAeR,EAAiC,CACrD,IAAMQ,EAAW,KAAK,YAAYR,CAAI,EACtC,OAAO,KAAK,oBAAoBQ,CAAQ,CAC1C,CAEO,oBAAoBA,EAAsC,CAC/D,OAAO,KAAK,wBAAwBA,CAAQ,GAAG,QAAU,IAC3D,CAEQ,QAAQC,EAAgC,CAC9C,OAAI,KAAK,UAAY,EAAU,GAEjB,KAAK,IAAI,EAAIA,EACZ,KAAK,OACtB,CAEQ,UAAUA,EAAgC,CAChD,OAAI,KAAK,WAAa,EAAU,GAElB,KAAK,IAAI,EAAIA,EACZ,KAAK,QACtB,CAGA,MAAa,oBACRT,EAC8B,CACjC,IAAMQ,EAAW,KAAK,YAAYR,CAAI,EAChCU,EAAS,KAAK,WAAW,YAAY,IAAIF,CAAQ,EAEvD,GAAIE,GAAQ,MAAQ,CAAC,KAAK,UAAUA,EAAO,eAAiB,CAAC,EAE3D,OAAK,KAAK,QAAQA,EAAO,eAAiB,CAAC,GAMvC,KAAK,iBACP,KAAK,IAAI,sBAAsBF,CAAQ,EAAE,EACpC,KAAK,aAAaA,EAAU,QAAS,GAAGR,CAAI,GAInD,KAAK,IAAI,4BAA4BQ,CAAQ,EAAE,EACxCE,IAZL,KAAK,IAAI,qBAAqBF,CAAQ,8BAA8B,EAC7DE,GAcX,IAAMC,EAAO,MAAM,KAAK,aAAaH,EAAU,QAAS,GAAGR,CAAI,EACzDY,EAAM,KAAK,IAAI,EAErB,MAAO,CAEL,cAAeA,EACf,QAAS,GACT,KAAAD,EACA,cAAeC,CACjB,CACF,CAEQ,aACNJ,EACAK,KACGb,EACa,CAChB,IAAMc,EAAgB,KAAK,gBAAgB,IAAIN,CAAQ,EAKvD,GAAIM,EACF,YAAK,IACH,oCAAoCN,CAAQ,eAAeK,CAAS,EACtE,EACOC,EAAc,QAIvB,IAAMC,EAAU,KAAK,cAAcP,EAAUK,EAAWb,CAAI,EACtDgB,EAAkB,IAAI,gBAE5B,YAAK,gBAAgB,IAAIR,EAAU,CAAE,QAAAO,EAAS,gBAAAC,CAAgB,CAAC,EAExDD,CACT,CAEA,MAAc,cACZP,EACAK,EACAb,EACA,CACA,KAAK,IAAI,kBAAkBQ,CAAQ,eAAeK,CAAS,EAAE,EAE7D,GAAI,CACF,IAAMF,EAAO,MAAOE,IAAc,WAAa,KAAK,UAChD,KAAK,UAAU,GAAGb,CAAI,EACtB,KAAK,QAAQ,GAAGA,CAAI,GAExB,YAAK,IAAI,wBAAwBQ,CAAQ,EAAE,EAC3C,KAAK,QAAQA,EAAUG,EAAM,KAAK,IAAI,CAAC,EAChCA,CACT,OAASM,EAAG,CACV,WAAK,IAAI,mBAAmBT,CAAQ,kBAAkBK,CAAS,KAAKI,CAAC,EAAE,EACjEA,CACR,QAAE,CACA,KAAK,gBAAgB,OAAOT,CAAQ,CACtC,CACF,CAGO,cAAcA,EAAsD,CACzE,OAAO,KAAK,WAAW,YAAY,IAAIA,CAAQ,CACjD,CAGO,QAAQA,EAAqC,CAClD,IAAMU,EACJ,KAAK,WAAW,YAAY,IAAIV,CAAQ,EAG1C,GAAIU,GAAY,eACV,KAAK,UAAUA,EAAW,aAAa,EAAG,CAC5C,KAAK,WAAWV,CAAQ,EACxB,MACF,CAGF,OAAOU,GAAY,IACrB,CAGO,QACLV,EACAG,EACAF,EAAgB,KAAK,IAAI,EACnB,CACN,IAAMU,EAAgC,CACpC,cAAe,KAAK,IAAI,EACxB,QAAS,GACT,KAAAR,EACA,cAAAF,CACF,EAEA,KAAK,WAAW,SAAUW,IAAU,CAAE,GAAGA,EAAM,CAACZ,CAAQ,EAAGW,CAAM,EAAE,EAEnE,KAAK,iBAAiB,CACxB,CAMA,MAAa,oBAAoBnB,EAA6B,CAC5D,IAAMqB,EAAM,KAAK,YAAYrB,CAAI,EACjC,OAAO,MAAM,KAAK,aAAaqB,EAAK,QAAS,GAAGrB,CAAI,CACtD,CAMA,MAAa,WAAWA,EAA6B,CACnD,IAAMqB,EAAM,KAAK,YAAYrB,CAAI,EACjC,OAAO,MAAM,KAAK,aAAaqB,EAAK,UAAW,GAAGrB,CAAI,CACxD,CAGO,WAAWQ,EAAwB,CAExC,IAAMc,EAAQ,CAAE,GADD,KAAK,WAAW,YAAY,CACjB,EAC1B,OAAOA,EAAMd,CAAQ,EACrB,KAAK,WAAW,SAASc,CAAK,EAE9B,IAAMC,EAAW,KAAK,gBAAgB,IAAIf,CAAQ,EAC9Ce,IACF,KAAK,gBAAgB,OAAOf,CAAQ,EACpCe,EAAS,gBAAgB,MAAM,SAAS,EAE5C,CAGO,OAAc,CACnB,KAAK,WAAW,OAAO,EAEvB,OAAW,CAAE,gBAAAP,CAAgB,IAAK,MAAM,KACtC,KAAK,gBAAgB,OAAO,CAC9B,EACEA,EAAgB,MAAM,SAAS,EAGjC,KAAK,gBAAgB,MAAM,CAC7B,CAGO,MAAMR,EAAwB,CACnC,KAAK,YAAY,IAAIA,GAAW,KAAK,YAAY,IAAIA,CAAQ,GAAK,GAAK,CAAC,CAC1E,CAGO,QAAQA,EAAwB,CACrC,IAAMgB,EAAQ,KAAK,YAAY,IAAIhB,CAAQ,EACvCgB,IAAU,EACZ,KAAK,YAAY,OAAOhB,CAAQ,EAEhC,KAAK,YAAY,IAAIA,GAAWgB,GAAS,GAAK,CAAC,CAEnD,CAMO,kBAAyB,CAE9B,GAAI,CAAC,KAAK,SAAW,CAAC,KAAK,SACzB,OAGF,IAAMC,EAAW,KAAK,WAAW,YAAY,EACvCC,EAAU,OAAO,KAAKD,CAAQ,EAAE,OAGtC,GAAIC,GAAW,EACb,OAGF,IAAMC,KAAc,EAAAC,SAAYH,CAAQ,EAEpCI,EAAW,KAAK,QAAUH,EAAU,KAAK,QAAU,EACnDI,EAAa,KAAK,SAAWH,EAAc,KAAK,SAAW,EAG/D,GAAIE,GAAY,GAAKC,GAAc,EAAG,CACpC,KAAK,IACH,4BAA4B,KAAK,SAAS,UAAUJ,CAAO,WAAW,KAAK,OAAO,eAAeC,EAAY,eAAe,CAAC,WAAW,KAAK,SAAS,eAAe,CAAC,GACxK,EACA,MACF,CAGA,KAAK,IACH,gBAAgB,KAAK,SAAS,cAAcE,CAAQ,gBAAgBC,CAAU,EAChF,EAEA,IAAMC,EAAW,CAAE,GAAGN,CAAS,EAC3BO,EAAa,GAEXC,EAAwB,CAACZ,EAAaa,IAAqB,CAE/D,GAAI,KAAK,YAAY,IAAIb,CAAG,EAAG,CAC7B,KAAK,IAAI,mBAAmBA,CAAG,mBAAmB,EAClD,MACF,CAEA,IAAMc,KAAW,EAAAP,SAAYP,CAAG,KAAI,EAAAO,SAAYG,EAASV,CAAG,CAAC,EAC7D,KAAK,IACH,wBAAwBa,EAAU,YAAc,EAAE,IAAIb,CAAG,cAAcc,EAAS,eAAe,CAAC,GAClG,EAEAN,IACAC,GAAcK,EAEd,OAAOJ,EAASV,CAAG,EACnBW,EAAa,EACf,EAEII,EAAyB,OAAO,QAAQL,CAAQ,EAAE,KACpD,CAACM,EAAQC,IAAWD,EAAO,CAAC,EAAE,cAAgBC,EAAO,CAAC,EAAE,aAC1D,EAMA,OAAW,CAACjB,CAAG,IAAKe,EAAwB,CAE1C,GAAI,CAAC,KAAK,UAAUL,EAASV,CAAG,EAAE,aAAa,EAC7C,MAGFY,EAAsBZ,EAAK,EAAI,CACjC,CAEA,GAAIQ,EAAW,GAAKC,EAAa,EAAG,CAC9BE,IACFI,EAAyB,OAAO,QAAQL,CAAQ,EAAE,KAChD,CAACM,EAAQC,IAAWD,EAAO,CAAC,EAAE,cAAgBC,EAAO,CAAC,EAAE,aAC1D,GAIF,OAAW,CAACjB,CAAG,IAAKe,EAGlB,GAFAH,EAAsBZ,EAAK,EAAK,EAE5BQ,GAAY,GAAKC,GAAc,EACjC,KAGN,CAGA,GAAI,CAACE,EAAY,CACf,KAAK,IACH,kCAAkC,KAAK,SAAS,UAAUN,CAAO,WAAW,KAAK,OAAO,eAAeC,EAAY,eAAe,CAAC,WAAW,KAAK,SAAS,eAAe,CAAC,GAC9K,EACA,MACF,CAGA,KAAK,WAAW,SAASI,CAAQ,EAGjC,KAAK,IACH,oBAAoB,KAAK,SAAS,WAAWL,CAAO,OAAO,OAAO,KAAKK,CAAQ,EAAE,MAAM,YAAY,KAAK,OAAO,gBAAgBJ,EAAY,eAAe,CAAC,UAAO,EAAAC,SAAYG,CAAQ,EAAE,eAAe,CAAC,YAAY,KAAK,SAAS,eAAe,CAAC,GACpP,CACF,CACF,EAGMQ,EAAiB,IAAI,IAmBpB,SAASC,EAGdpC,EAC2B,CAC3B,GAAImC,EAAe,IAAInC,EAAQ,SAAS,EACtC,MAAM,IAAI,MAAM,2BAA2BA,EAAQ,SAAS,GAAG,EAEjE,OAAAmC,EAAe,IAAInC,EAAQ,SAAS,EAE7B,IAAID,EAAYC,CAAO,CAChC,CAIA,IAAMqC,EAA4D,CAChE,cAAe,EACf,QAAS,EACX,EAcO,SAASC,EACdC,KAEG3C,EACoB,CAEvB,IAAM4C,EAAaC,EAAY7C,CAAI,EAE7B,CAAC8C,EAAYC,CAAa,EAAIC,EAAeL,EAAM,UAAU,EAE7DM,KAAY,UAAO,EAAI,EAEvBzC,KAAW,WACf,IAAMmC,EAAM,YAAYC,CAAU,EAClC,CAACD,EAAOC,CAAU,CACpB,EAGMM,EACJJ,EAAWtC,CAAQ,GAAKiC,EAEpBU,KAAqB,eACxBC,GAAiD,CAChDL,EAAe3B,GAAS,CACtB,IAAME,EAAQ,CAAE,GAAGF,CAAK,EACxB,OAAAE,EAAMd,CAAQ,EACZ,OAAO4C,GAAS,WACZA,EAAK9B,EAAMd,CAAQ,GAAKiC,CAAyB,EACjDW,EACC9B,CACT,CAAC,CACH,EACA,CAACd,EAAUuC,CAAa,CAC1B,EAIM,CAACM,EAAkBC,CAAmB,KAAI,YAA4B,KAE5E,aAAU,IAAM,CACVX,EAAM,sBAAwBO,EAAgB,OAAS,QACzDI,EAAoBJ,EAAgB,IAAI,CAE5C,EAAG,CAACP,EAAM,qBAAsBO,EAAgB,IAAI,CAAC,EAGrD,IAAMK,KAAU,eACd,MAAOC,GAAuB,CAK5B,GAJAb,EAAM,IACJ,gCAAgCnC,CAAQ,eAAegD,CAAS,EAClE,EAEI,EAACP,EAAU,QAEf,CAAAE,EAAoB/B,IAAU,CAE5B,GAAGA,EACH,cAAe,KAAK,IAAI,EACxB,QAAS,EACX,EAAE,EAEF,GAAI,CACF,IAAIqC,EAEJ,GAAID,EAAW,CACb,IAAM7C,EAAO,MAAMgC,EAAM,QAAQ,GAAGC,CAAU,EACxChC,EAAM,KAAK,IAAI,EAErB6C,EAAQ,CAEN,cAAe7C,EACf,QAAS,GACT,KAAAD,EACA,cAAeC,CACjB,CACF,MACE6C,EAAQ,MAAMd,EAAM,iBAAiB,GAAGC,CAAU,EAGpD,GAAI,CAACK,EAAU,QAAS,OAExBE,EAAmBM,CAAK,CAC1B,OAASxC,EAAG,CACV,GAAI,CAACgC,EAAU,QAAS,OAExB,IAAMrC,EAAM,KAAK,IAAI,EAErBuC,EAAoB/B,IAAU,CAE5B,GAAGA,EACH,cAAeR,EACf,QAAS,GACT,MAAOK,EACP,eAAgBL,CAClB,EAAE,CACJ,EACF,EACA,CAAC+B,EAAOnC,EAAU2C,EAAoBP,CAAU,CAClD,EAEA,sBAAU,KACRK,EAAU,QAAU,GACpBN,EAAM,MAAMnC,CAAQ,EAEf+C,EAAQ,EAAK,EAEX,IAAM,CACXN,EAAU,QAAU,GACpBN,EAAM,QAAQnC,CAAQ,CACxB,GACC,CAAC+C,EAASZ,EAAOnC,CAAQ,CAAC,KAEtB,WAAQ,KACN,CACL,GAAG0C,EACH,iBAAAG,EAIA,kBAAoBK,GAAqB,CACvC,IAAMC,EAAahB,EAAM,wBAAwBnC,CAAQ,EACzD,OAAAmD,GAAY,MAAMD,CAAM,EACjB,EAAQC,CACjB,EACA,QAAS,IAAM,CACRJ,EAAQ,EAAI,CACnB,EACA,QAAS,CAAC5C,EAAaF,IAA2B,CAChD,IAAMG,EAAM,KAAK,IAAI,EACrBuC,EAAoB/B,IAAU,CAC5B,GAAGA,EACH,KAAAT,EACA,cAAeF,GAAiBG,EAChC,cAAeA,EACf,QAAS,EACX,EAAE,CACJ,EACA,WAAY,IAAM,CAChBmC,EAAe3B,GAAS,CACtB,IAAME,EAAQ,CAAE,GAAGF,CAAK,EACxB,cAAOE,EAAMd,CAAQ,EACdc,CACT,CAAC,CACH,CACF,GACC,CACDiC,EACAF,EACAV,EACAnC,EACA0C,EACAH,EACAI,CACF,CAAC,CACH","names":["sharedQuery_exports","__export","SharedQuery","SharedQueryConfig","configureSharedQuery","sharedQuery","useSharedQuery","__toCommonJS","import_timeConstants","import_object_sizeof","import_react","import_timeConstants","import_react","import_kvStore","import_localStore","IS_NOT_BROWSER","getStorageAdapter","options","defaultStoreExpiryMs","store","expiryMs","isValid","adapter","key","value","keyPrefix","fullKey","SharedStateConfig","PubSubStore","key","value","prev","subscriber","subscribers","next","copy","e","pubSubStore","STORAGE_KEY","stateKey","SharedState","defaultValue","options","getStorageAdapter","SharedStateConfig","lazyLoad","stored","sharedState","useSharedState","state","onStoreChange","stringifyDeterministicForKeys","params","cache","_","value","result","key","import_isEqual","import_react","useDeepMemo","value","state","setState","isEqual","SharedQueryConfig","args","configureSharedQuery","config","SharedQuery","options","persistTo","sharedState","stringifyDeterministicForKeys","queryKey","dataUpdatedMs","cached","data","now","queryType","inflightQuery","promise","abortController","e","stateValue","entry","prev","key","clone","inflight","count","oldState","oldSize","oldByteSize","getByteSize","numToCut","bytesToCut","newState","needUpdate","deleteKeyIfNotMounted","expired","byteSize","entriesByTimeAscending","entry1","entry2","seenQueryNames","sharedQuery","DEFAULT_QUERY_STATE_ENTRY","useSharedQuery","query","stableArgs","useDeepMemo","queryState","setQueryState","useSharedState","isMounted","queryStateValue","setQueryStateValue","next","lastNonEmptyData","setLastNonEmptyData","execute","isRefetch","state","reason","controller"]}
@@ -1,2 +1,2 @@
1
- import{MS_PER_DAY as q}from"@choksheak/ts-utils/timeConstants";import m from"object-sizeof";import{useCallback as N,useEffect as K,useMemo as C,useRef as _,useState as j}from"react";import{MS_PER_DAY as k}from"@choksheak/ts-utils/timeConstants";import{useSyncExternalStore as U}from"react";import{kvStore as b}from"@choksheak/ts-utils/kvStore";import{LocalStore as E}from"@choksheak/ts-utils/localStore";var g=typeof window>"u";function M(a,e){let{store:t}=a??{};if(!t)return null;if("persistTo"in t&&typeof t.persistTo=="string"&&"key"in t&&typeof t.key=="string"){let r=t.expiryMs||e,n=t.isValid;switch(t.persistTo){case"localStorage":{let s=new E(t.key,{defaultExpiryMs:r});return{set:(i,o)=>{g||s.set(i,o)},get:i=>{if(g)return;let o=s.get(i);if(n&&!n(o)){console.warn(`localStorage adapter: Auto-discard invalid value for ${i}: ${JSON.stringify(o)}`),s.delete(i);return}return o},delete:i=>{g||s.delete(i)},clear:()=>{g||s.clear()}}}case"indexedDb":{let s=t.key+":",i=t.isValid;return{set:async(o,d)=>{g||await b.set(s+o,d,r)},get:async o=>{if(g)return;let d=s+o,y=await b.get(d);if(i&&!i(y)){console.warn(`indexedDb adapter: Auto-discard invalid value for ${o}: ${JSON.stringify(y)}`),await b.delete(d);return}return y},delete:async o=>{g||await b.delete(s+o)},clear:async()=>{g||await b.clear()}}}default:throw t.persistTo,new Error(`Unknown store.adapter ${t.persistTo}`)}}return t}var Q={storeExpiryMs:30*k,lazyLoad:!1};var x=class{dataByKey=new Map;subscribersByKey=new Map;get(e){return this.dataByKey.get(e)}set(e,t){let r=this.dataByKey.get(e);r!==t&&(this.dataByKey.set(e,t),this.notify(e,t,r))}delete(e){this.dataByKey.delete(e)}setNoNotify(e,t){this.dataByKey.set(e,t)}subscribe(e,t){let r=this.subscribersByKey.get(e);return r||(r=new Set,this.subscribersByKey.set(e,r)),r.add(t),()=>{r.delete(t),r.size===0&&this.subscribersByKey.delete(e)}}notify(e,t,r){let n=this.subscribersByKey.get(e);if(!n)return;let s=Array.from(n.values());for(let i of s)try{i(t,r)}catch(o){console.error("Error invoking subscriber:",o)}}},f=new x,D="state",F=0,w=class{constructor(e,t){this.defaultValue=e;this.pubSubKey=String(F++),this.storageAdapter=M(t,Q.storeExpiryMs),this.setValueBounded=s=>{this.setValue(s)},f.setNoNotify(this.pubSubKey,e);let r=t?.lazyLoad;(r!==void 0?r:Q.lazyLoad)||this.initDefaultValueOnce()}pubSubKey;storageAdapter;setValueBounded;initStarted=!1;initDone=!1;subscribe(e){return f.subscribe(this.pubSubKey,()=>e())}hasStore(){return!!this.storageAdapter}async setInStore(e){return await this.storageAdapter?.set(D,e)}async getFromStore(){return await this.storageAdapter?.get(D)}getSnapshot(){return f.get(this.pubSubKey)}setValue(e){if(typeof e=="function"){let t=f.get(this.pubSubKey);e=e(t)}f.set(this.pubSubKey,e),this.setInStore(e)}delete(){f.set(this.pubSubKey,this.defaultValue),this.storageAdapter?.set(D,this.defaultValue)}async initDefaultValueOnce(){if(this.initDone||this.initStarted)return;this.initStarted=!0;let e=await this.getFromStore(),t=e!==void 0?e:this.defaultValue;f.set(this.pubSubKey,t),this.initDone=!0}};function v(a,e){return new w(a,e)}function O(a){return a.initDefaultValueOnce(),[U(t=>a.subscribe(t),()=>a.getSnapshot(),()=>a.getSnapshot()),a.setValueBounded]}function B(a){let e=new Set;return JSON.stringify(a,(t,r)=>{if(typeof r=="function")throw new Error("Functions should not be passed into query parameters");if(!r||typeof r!="object"||Array.isArray(r))return r;if(e.has(r))return"[Circular]";if(e.add(r),r!==a)try{JSON.stringify(r)}catch{return"[Unserializable]"}return Object.keys(r).sort().reduce((n,s)=>(n[s]=r[s],n),{})})}import L from"lodash/isEqual";import{useMemo as P,useState as R}from"react";function $(a){let[e,t]=R(a);return P(()=>e===a||L(e,a)?e:(t(a),a),[e,a])}var S={staleMs:0,expiryMs:30*q,refetchOnStale:!0,persistTo:void 0,maxSize:100,maxBytes:1e5,log:(...a)=>console.log("[sharedQuery]",...a)};function ge(a){Object.assign(S,a)}var A=class{inflightQueries=new Map;queryState;mountedKeys=new Map;queryName;queryFn;expiryMs;staleMs;refetchOnStale;maxSize;maxBytes;keepLastNonEmptyData;log;constructor(e){if(this.queryName=e.queryName,this.queryFn=e.queryFn,this.staleMs=e?.staleMs??S.staleMs,this.expiryMs=e?.expiryMs??S.expiryMs,this.refetchOnStale=e?.refetchOnStale??S.refetchOnStale,!e.store){let t=e.persistTo??S.persistTo;t&&(e.store={persistTo:t,key:e.queryName,expiryMs:this.expiryMs})}this.maxSize=e?.maxSize??S.maxSize,this.maxBytes=e?.maxBytes??S.maxBytes,this.keepLastNonEmptyData=!!e?.keepLastNonEmptyData,this.log=e?.log??S.log,this.queryState=v({},e)}getQueryKey(e){return this.queryName+":"+B(e)}getAbortController(e){let t=this.getQueryKey(e);return this.getAbortControllerByKey(t)}getAbortControllerByKey(e){return this.inflightQueries.get(e)?.abortController??null}getAbortSignal(e){let t=this.getQueryKey(e);return this.getAbortSignalByKey(t)}getAbortSignalByKey(e){return this.getAbortControllerByKey(e)?.signal??null}isStale(e){return this.staleMs===0?!0:Date.now()-e>this.staleMs}isExpired(e){return this.expiryMs===0?!1:Date.now()-e>this.expiryMs}async getCachedOrFetch(...e){let t=this.getQueryKey(e),r=this.queryState.getSnapshot()?.[t];return r?.data&&!this.isExpired(r.dataUpdatedMs??0)?this.isStale(r.dataUpdatedMs??0)?(this.refetchOnStale&&(this.log(`refetchOnStale for ${t}`),this.dedupedFetch(t,...e)),this.log(`Returning stale data for ${t}`),r.data):(this.log(`Return fresh data ${t} from cache without fetching`),r.data):await this.dedupedFetch(t,...e)}dedupedFetch(e,...t){let r=this.inflightQueries.get(e);if(r)return this.log(`Deduplicating inflight fetch for ${e}`),r.promise;let n=this.startFetching(e,t),s=new AbortController;return this.inflightQueries.set(e,{promise:n,abortController:s}),n}async startFetching(e,t){this.log(`Start fetching ${e}`);try{let r=await this.queryFn(...t);return this.log(`Successfully fetched ${e}`),this.setData(e,r,Date.now()),r}catch(r){throw this.log(`Failed to fetch ${e}: ${r}`),r}finally{this.inflightQueries.delete(e)}}getQueryValue(e){return this.queryState.getSnapshot()?.[e]}getData(e){let t=this.queryState.getSnapshot()?.[e];if(t?.lastUpdatedMs&&this.isExpired(t.lastUpdatedMs)){this.deleteData(e);return}return t?.data}setData(e,t,r=Date.now()){let n={lastUpdatedMs:Date.now(),loading:!1,data:t,dataUpdatedMs:r};this.queryState.setValue(s=>({...s,[e]:n})),this.enforceSizeLimit()}async updateFromSource(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,...e)}deleteData(e){let r={...this.queryState.getSnapshot()};delete r[e],this.queryState.setValue(r);let n=this.inflightQueries.get(e);n&&(this.inflightQueries.delete(e),n.abortController.abort("Deleted"))}clear(){this.queryState.delete();for(let{abortController:e}of Array.from(this.inflightQueries.values()))e.abort("Cleared");this.inflightQueries.clear()}mount(e){this.mountedKeys.set(e,(this.mountedKeys.get(e)??0)+1)}unmount(e){let t=this.mountedKeys.get(e);t===1?this.mountedKeys.delete(e):this.mountedKeys.set(e,(t??0)-1)}enforceSizeLimit(){if(!this.maxSize&&!this.maxBytes)return;let e=this.queryState.getSnapshot(),t=Object.keys(e).length;if(t<=1)return;let r=m(e),n=this.maxSize?t-this.maxSize:0,s=this.maxBytes?r-this.maxBytes:0;if(n<=0&&s<=0){this.log(`No need to trim data for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${r.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.log(`Need to trim ${this.queryName}: numToCut=${n}, bytesToCut=${s}`);let i={...e},o=!1,d=(u,h)=>{if(this.mountedKeys.has(u)){this.log(`Cannot clean up ${u} as it is mounted`);return}let c=m(u)+m(i[u]);this.log(`Cleaning up unmounted${h?", expired":""} ${u} (byteSize=${c.toLocaleString()})`),n--,s-=c,delete i[u],o=!0},y=Object.entries(i).sort((u,h)=>u[1].lastUpdatedMs-h[1].lastUpdatedMs);for(let[u]of y){if(!this.isExpired(i[u].lastUpdatedMs))break;d(u,!0)}if(n>0||s>0){o&&(y=Object.entries(i).sort((u,h)=>u[1].lastUpdatedMs-h[1].lastUpdatedMs));for(let[u]of y)if(d(u,!1),n<=0&&s<=0)break}if(!o){this.log(`No trimmable entries found for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${r.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.queryState.setValue(i),this.log(`Trimmed data for ${this.queryName}: size=[${t} -> ${Object.keys(i).length}] (limit=${this.maxSize}), byteSize=[${r.toLocaleString()} -> ${m(i).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`)}},z=new Set;function Se(a){if(z.has(a.queryName))throw new Error(`Duplicate shared query "${a.queryName}"`);return z.add(a.queryName),new A(a)}var V={lastUpdatedMs:0,loading:!0};function fe(a,...e){let t=$(e),[r,n]=O(a.queryState),s=_(!0),i=C(()=>a.getQueryKey(t),[a,t]),o=r[i]??V,d=N(c=>{n(l=>{let p={...l};return p[i]=typeof c=="function"?c(p[i]??V):c,p})},[i,n]),[y,u]=j();K(()=>{a.keepLastNonEmptyData&&o.data!==void 0&&u(o.data)},[a.keepLastNonEmptyData,o.data]);let h=N(async c=>{a.log(`Begin executing shared query ${i}`),d(l=>({...l,lastUpdatedMs:Date.now(),loading:!0}));try{let l=await(c?a.updateFromSource(...t):a.getCachedOrFetch(...t));if(s.current){let p=Date.now();d({lastUpdatedMs:p,loading:!1,data:l,dataUpdatedMs:p})}}catch(l){if(s.current){let p=Date.now();d(T=>({...T,lastUpdatedMs:p,loading:!1,error:l,errorUpdatedMs:p}))}}},[a,i,d,t]);return K(()=>(s.current=!0,a.mount(i),h(!1),()=>{s.current=!1,a.unmount(i)}),[h,a,i]),C(()=>({...o,lastNonEmptyData:y,abortCurrentQuery:c=>{let l=a.getAbortControllerByKey(i);return l?.abort(c),!!l},refetch:()=>{h(!0)},setData:(c,l)=>{let p=Date.now();d(T=>({...T,data:c,dataUpdatedMs:l??p,lastUpdatedMs:p,loading:!1}))},deleteData:()=>{n(c=>{let l={...c};return delete l[i],l})}}),[h,y,a,i,o,n,d])}export{A as SharedQuery,S as SharedQueryConfig,ge as configureSharedQuery,Se as sharedQuery,fe as useSharedQuery};
1
+ import{MS_PER_DAY as q}from"@choksheak/ts-utils/timeConstants";import m from"object-sizeof";import{useCallback as K,useEffect as N,useMemo as C,useRef as _,useState as j}from"react";import{MS_PER_DAY as k}from"@choksheak/ts-utils/timeConstants";import{useSyncExternalStore as E}from"react";import{kvStore as T}from"@choksheak/ts-utils/kvStore";import{LocalStore as F}from"@choksheak/ts-utils/localStore";var f=typeof window>"u";function M(a,e){let{store:t}=a??{};if(!t)return null;if("persistTo"in t&&typeof t.persistTo=="string"&&"key"in t&&typeof t.key=="string"){let r=t.expiryMs||e,s=t.isValid;switch(t.persistTo){case"localStorage":{let i=new F(t.key,{defaultExpiryMs:r});return{set:(n,o)=>{f||i.set(n,o)},get:n=>{if(f)return;let o=i.get(n);if(s&&!s(o)){console.warn(`localStorage adapter: Auto-discard invalid value for ${n}: ${JSON.stringify(o)}`),i.delete(n);return}return o},delete:n=>{f||i.delete(n)},clear:()=>{f||i.clear()}}}case"indexedDb":{let i=t.key+":",n=t.isValid;return{set:async(o,c)=>{f||await T.set(i+o,c,r)},get:async o=>{if(f)return;let c=i+o,p=await T.get(c);if(n&&!n(p)){console.warn(`indexedDb adapter: Auto-discard invalid value for ${o}: ${JSON.stringify(p)}`),await T.delete(c);return}return p},delete:async o=>{f||await T.delete(i+o)},clear:async()=>{f||await T.clear()}}}default:throw t.persistTo,new Error(`Unknown store.adapter ${t.persistTo}`)}}return t}var Q={storeExpiryMs:30*k,lazyLoad:!1};var x=class{dataByKey=new Map;subscribersByKey=new Map;get(e){return this.dataByKey.get(e)}set(e,t){let r=this.dataByKey.get(e);r!==t&&(this.dataByKey.set(e,t),this.notify(e,t,r))}delete(e){this.dataByKey.delete(e)}setNoNotify(e,t){this.dataByKey.set(e,t)}subscribe(e,t){let r=this.subscribersByKey.get(e);return r||(r=new Set,this.subscribersByKey.set(e,r)),r.add(t),()=>{r.delete(t),r.size===0&&this.subscribersByKey.delete(e)}}notify(e,t,r){let s=this.subscribersByKey.get(e);if(!s)return;let i=Array.from(s.values());for(let n of i)try{n(t,r)}catch(o){console.error("Error invoking subscriber:",o)}}},S=new x,D="state",U=0,w=class{constructor(e,t){this.defaultValue=e;this.pubSubKey=String(U++),this.storageAdapter=M(t,Q.storeExpiryMs),this.setValueBounded=i=>{this.setValue(i)},S.setNoNotify(this.pubSubKey,e);let r=t?.lazyLoad;(r!==void 0?r:Q.lazyLoad)||this.initDefaultValueOnce()}pubSubKey;storageAdapter;setValueBounded;initStarted=!1;initDone=!1;subscribe(e){return S.subscribe(this.pubSubKey,()=>e())}hasStore(){return!!this.storageAdapter}async setInStore(e){return await this.storageAdapter?.set(D,e)}async getFromStore(){return await this.storageAdapter?.get(D)}getSnapshot(){return S.get(this.pubSubKey)}setValue(e){if(typeof e=="function"){let t=S.get(this.pubSubKey);e=e(t)}S.set(this.pubSubKey,e),this.setInStore(e)}delete(){S.set(this.pubSubKey,this.defaultValue),this.storageAdapter?.set(D,this.defaultValue)}async initDefaultValueOnce(){if(this.initDone||this.initStarted)return;this.initStarted=!0;let e=await this.getFromStore(),t=e!==void 0?e:this.defaultValue;S.set(this.pubSubKey,t),this.initDone=!0}};function v(a,e){return new w(a,e)}function $(a){return a.initDefaultValueOnce(),[E(t=>a.subscribe(t),()=>a.getSnapshot(),()=>a.getSnapshot()),a.setValueBounded]}function O(a){let e=new Set;return JSON.stringify(a,(t,r)=>{if(typeof r=="function")throw new Error("Functions should not be passed into query parameters");if(!r||typeof r!="object"||Array.isArray(r))return r;if(e.has(r))return"[Circular]";if(e.add(r),r!==a)try{JSON.stringify(r)}catch{return"[Unserializable]"}return Object.keys(r).sort().reduce((s,i)=>(s[i]=r[i],s),{})})}import L from"lodash/isEqual";import{useMemo as P,useState as R}from"react";function B(a){let[e,t]=R(a);return P(()=>e===a||L(e,a)?e:(t(a),a),[e,a])}var g={staleMs:0,expiryMs:30*q,refetchOnStale:!0,persistTo:void 0,maxSize:100,maxBytes:1e5,log:(...a)=>console.log("[sharedQuery]",...a)};function fe(a){Object.assign(g,a)}var A=class{inflightQueries=new Map;queryState;mountedKeys=new Map;queryName;queryFn;refetchFn;expiryMs;staleMs;refetchOnStale;maxSize;maxBytes;keepLastNonEmptyData;log;constructor(e){if(this.queryName=e.queryName,this.queryFn=e.queryFn,this.refetchFn=e.refetchFn,this.staleMs=e?.staleMs??g.staleMs,this.expiryMs=e?.expiryMs??g.expiryMs,this.refetchOnStale=e?.refetchOnStale??g.refetchOnStale,!e.store){let t=e.persistTo??g.persistTo;t&&(e.store={persistTo:t,key:e.queryName,expiryMs:this.expiryMs})}this.maxSize=e?.maxSize??g.maxSize,this.maxBytes=e?.maxBytes??g.maxBytes,this.keepLastNonEmptyData=!!e?.keepLastNonEmptyData,this.log=e?.log??g.log,this.queryState=v({},e)}getQueryKey(e){return this.queryName+":"+O(e)}getAbortController(e){let t=this.getQueryKey(e);return this.getAbortControllerByKey(t)}getAbortControllerByKey(e){return this.inflightQueries.get(e)?.abortController??null}getAbortSignal(e){let t=this.getQueryKey(e);return this.getAbortSignalByKey(t)}getAbortSignalByKey(e){return this.getAbortControllerByKey(e)?.signal??null}isStale(e){return this.staleMs===0?!0:Date.now()-e>this.staleMs}isExpired(e){return this.expiryMs===0?!1:Date.now()-e>this.expiryMs}async getCachedOrFetch(...e){let t=this.getQueryKey(e),r=this.queryState.getSnapshot()?.[t];if(r?.data&&!this.isExpired(r.dataUpdatedMs??0))return this.isStale(r.dataUpdatedMs??0)?(this.refetchOnStale&&(this.log(`refetchOnStale for ${t}`),this.dedupedFetch(t,"query",...e)),this.log(`Returning stale data for ${t}`),r):(this.log(`Return fresh data ${t} from cache without fetching`),r);let s=await this.dedupedFetch(t,"query",...e),i=Date.now();return{lastUpdatedMs:i,loading:!1,data:s,dataUpdatedMs:i}}dedupedFetch(e,t,...r){let s=this.inflightQueries.get(e);if(s)return this.log(`Deduplicating inflight fetch for ${e}, queryType=${t}`),s.promise;let i=this.startFetching(e,t,r),n=new AbortController;return this.inflightQueries.set(e,{promise:i,abortController:n}),i}async startFetching(e,t,r){this.log(`Start fetching ${e}, queryType=${t}`);try{let s=await(t==="refetch"&&this.refetchFn?this.refetchFn(...r):this.queryFn(...r));return this.log(`Successfully fetched ${e}`),this.setData(e,s,Date.now()),s}catch(s){throw this.log(`Failed to fetch ${e} for queryType=${t}: ${s}`),s}finally{this.inflightQueries.delete(e)}}getQueryValue(e){return this.queryState.getSnapshot()?.[e]}getData(e){let t=this.queryState.getSnapshot()?.[e];if(t?.lastUpdatedMs&&this.isExpired(t.lastUpdatedMs)){this.deleteData(e);return}return t?.data}setData(e,t,r=Date.now()){let s={lastUpdatedMs:Date.now(),loading:!1,data:t,dataUpdatedMs:r};this.queryState.setValue(i=>({...i,[e]:s})),this.enforceSizeLimit()}async updateFromSource(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,"query",...e)}async refetch(...e){let t=this.getQueryKey(e);return await this.dedupedFetch(t,"refetch",...e)}deleteData(e){let r={...this.queryState.getSnapshot()};delete r[e],this.queryState.setValue(r);let s=this.inflightQueries.get(e);s&&(this.inflightQueries.delete(e),s.abortController.abort("Deleted"))}clear(){this.queryState.delete();for(let{abortController:e}of Array.from(this.inflightQueries.values()))e.abort("Cleared");this.inflightQueries.clear()}mount(e){this.mountedKeys.set(e,(this.mountedKeys.get(e)??0)+1)}unmount(e){let t=this.mountedKeys.get(e);t===1?this.mountedKeys.delete(e):this.mountedKeys.set(e,(t??0)-1)}enforceSizeLimit(){if(!this.maxSize&&!this.maxBytes)return;let e=this.queryState.getSnapshot(),t=Object.keys(e).length;if(t<=1)return;let r=m(e),s=this.maxSize?t-this.maxSize:0,i=this.maxBytes?r-this.maxBytes:0;if(s<=0&&i<=0){this.log(`No need to trim data for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${r.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.log(`Need to trim ${this.queryName}: numToCut=${s}, bytesToCut=${i}`);let n={...e},o=!1,c=(u,h)=>{if(this.mountedKeys.has(u)){this.log(`Cannot clean up ${u} as it is mounted`);return}let d=m(u)+m(n[u]);this.log(`Cleaning up unmounted${h?", expired":""} ${u} (byteSize=${d.toLocaleString()})`),s--,i-=d,delete n[u],o=!0},p=Object.entries(n).sort((u,h)=>u[1].lastUpdatedMs-h[1].lastUpdatedMs);for(let[u]of p){if(!this.isExpired(n[u].lastUpdatedMs))break;c(u,!0)}if(s>0||i>0){o&&(p=Object.entries(n).sort((u,h)=>u[1].lastUpdatedMs-h[1].lastUpdatedMs));for(let[u]of p)if(c(u,!1),s<=0&&i<=0)break}if(!o){this.log(`No trimmable entries found for ${this.queryName}: size=${t} (limit=${this.maxSize}), byteSize=${r.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`);return}this.queryState.setValue(n),this.log(`Trimmed data for ${this.queryName}: size=[${t} -> ${Object.keys(n).length}] (limit=${this.maxSize}), byteSize=[${r.toLocaleString()} -> ${m(n).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`)}},V=new Set;function ge(a){if(V.has(a.queryName))throw new Error(`Duplicate shared query "${a.queryName}"`);return V.add(a.queryName),new A(a)}var z={lastUpdatedMs:0,loading:!0};function Se(a,...e){let t=B(e),[r,s]=$(a.queryState),i=_(!0),n=C(()=>a.getQueryKey(t),[a,t]),o=r[n]??z,c=K(d=>{s(l=>{let y={...l};return y[n]=typeof d=="function"?d(y[n]??z):d,y})},[n,s]),[p,u]=j();N(()=>{a.keepLastNonEmptyData&&o.data!==void 0&&u(o.data)},[a.keepLastNonEmptyData,o.data]);let h=K(async d=>{if(a.log(`Begin executing shared query ${n}, isRefetch=${d}`),!!i.current){c(l=>({...l,lastUpdatedMs:Date.now(),loading:!0}));try{let l;if(d){let y=await a.refetch(...t),b=Date.now();l={lastUpdatedMs:b,loading:!1,data:y,dataUpdatedMs:b}}else l=await a.getCachedOrFetch(...t);if(!i.current)return;c(l)}catch(l){if(!i.current)return;let y=Date.now();c(b=>({...b,lastUpdatedMs:y,loading:!1,error:l,errorUpdatedMs:y}))}}},[a,n,c,t]);return N(()=>(i.current=!0,a.mount(n),h(!1),()=>{i.current=!1,a.unmount(n)}),[h,a,n]),C(()=>({...o,lastNonEmptyData:p,abortCurrentQuery:d=>{let l=a.getAbortControllerByKey(n);return l?.abort(d),!!l},refetch:()=>{h(!0)},setData:(d,l)=>{let y=Date.now();c(b=>({...b,data:d,dataUpdatedMs:l??y,lastUpdatedMs:y,loading:!1}))},deleteData:()=>{s(d=>{let l={...d};return delete l[n],l})}}),[h,p,a,n,o,s,c])}export{A as SharedQuery,g as SharedQueryConfig,fe as configureSharedQuery,ge as sharedQuery,Se as useSharedQuery};
2
2
  //# sourceMappingURL=sharedQuery.min.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sharedQuery.ts","../src/sharedState.ts","../src/storage.ts","../src/stringify.ts","../src/useDeepMemo.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * The \"shared query\" is a data fetching convenience library that helps users\n * to load data, check for staleness and expiration, cache in memory, local\n * storage or indexed db, and be able to manipulate the data anytime.\n *\n * Each shared query creates a new private shared state that handles the data\n * storage. This shared state contains the records for all queries, where each\n * query is a unique set of arguments used to make the query. The number of\n * records and the total byte size of data can be limited so as to ensure the\n * query does not take up too much memory.\n *\n * Example:\n * ```\n * import { sharedQuery, useSharedQuery } from \"@choksheak/react-utils/sharedQuery\";\n *\n * // Create a new shared query in the top level scope.\n * // The query function `queryFn` here does not take any arguments.\n * //\n * // Only the queryName and queryFn are required and everything else is\n * // optional.\n * //\n * // The queryName must be unique, otherwise we will throw an\n * // error. This is because defining the same queryName for two different\n * // queries implies that the queries are the same, thus violating the idea\n * // of a \"shared\" query, and almost certainly points to a bug in the code.\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * // Note that the query will only start fetching on mount in React.\n * // But you can do `usersQuery.getCachedOrFetch()` here to prefetch the\n * // data if necessary.\n * usersQuery.getCachedOrFetch();\n *\n * // Example of using a query function with arguments.\n * export const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: getUser, // getUser(userId: string) => User\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * export const UsersComponent: React.FC = () => {\n * // Second argument is optional, and is an array of arguments to be\n * // passed to the `queryFn`.\n * const users = useSharedQuery(usersQuery);\n *\n * return (\n * <>\n * <h1>List of Users</h1>\n *\n * <button onclick={users.refetch}>Refresh</button>\n *\n * {users.loading && <Loader />}\n *\n * {users.error && !users.data && <p>Error: {String(users.error)}</p>}\n *\n * <p>Data: {users.data ? JSON.stringify(users.data) : \"-\"}</p>\n * </>\n * );\n * };\n *\n * export const AnotherComponent: React.FC = () => {\n * // This query will be deduped with the one above, sharing the same\n * // data and the same fetches.\n * const users = useSharedQuery(usersQuery);\n * ...\n * };\n *\n * export const DisplayOneUser: React.FC<{userId: string}> = ({userId}) => {\n * // Example of using the query with an argument. Whenever the argument\n * // changes, the data will be fetched and updated automatically.\n * const user = useSharedQuery(getUserQuery, [userId]);\n *\n * return <>User: {user.data ? JSON.stringify(user.data) : \"-\"}</>;\n * };\n * ```\n */\n\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport getByteSize from \"object-sizeof\";\nimport {\n SetStateAction,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport {\n SharedState,\n sharedState,\n SharedStateOptions,\n useSharedState,\n} from \"./sharedState\";\nimport { PersistTo } from \"./storage\";\nimport { stringifyDeterministicForKeys } from \"./stringify\";\nimport { useDeepMemo } from \"./useDeepMemo\";\n\n/** Type of the queryFn used to fetch the data from source. */\nexport type QueryFn<TArgs extends unknown[], TData> = (\n ...args: TArgs\n) => Promise<TData> | TData;\n\n/** An entry for a persisted state corresponding to one query key. */\nexport type QueryStateValue<TData> = Readonly<{\n /**\n * When this record was last updated. Used for LRU determination. If you want\n * the data age, please use `dataUpdatedMs` instead.\n */\n lastUpdatedMs: number;\n\n loading: boolean;\n data?: TData;\n dataUpdatedMs?: number;\n\n /**\n * Use unknown type for error because we can't assume what type of variable\n * will be thrown from the query function. We also don't want to add this\n * type to QueryStateValue<TError> as it adds complexity to the type.\n */\n error?: unknown;\n\n errorUpdatedMs?: number;\n}>;\n\n/** Return type for useSharedQuery(). */\nexport type UseQueryResult<TData> = QueryStateValue<TData> &\n Readonly<{\n /**\n * Return the last non-empty data if you want to display the last loaded data\n * while waiting for the new data to load.\n */\n lastNonEmptyData: TData | undefined;\n\n /**\n * Cancels the inflight query (if any). Returns true if canceled.\n */\n abortCurrentQuery: (reason?: unknown) => boolean;\n\n /**\n * Manually refresh the data by doing a new query.\n */\n refetch: () => void;\n\n /**\n * If you got the data from somewhere else (e.g. an in-memory data update),\n * just set it directly.\n */\n setData: (data: TData, dataUpdatedMs?: number) => void;\n\n deleteData: () => void;\n }>;\n\n/**\n * The full query state is a record of all the fetch states mapped by\n * query key to the state entry.\n */\nexport type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;\n\n/** Options to configure a shared query. */\nexport type SharedQueryOptions<TArgs extends unknown[], TData> = {\n /**\n * The name could have been auto-generated, but we let the user give us a\n * human-readable name that can be identified quickly in the logs.\n * Note that the queryKey is a string formed by the queryName plus the query\n * arguments.\n */\n queryName: string;\n\n /**\n * Function to fetch data.\n */\n queryFn: QueryFn<TArgs, TData>;\n\n /**\n * ms before data considered stale; 0 means always stale.\n */\n staleMs?: number;\n\n /**\n * ms before data is removed from cache; 0 means never expire.\n */\n expiryMs?: number;\n\n /**\n * Trigger background re-fetch if stale.\n */\n refetchOnStale?: boolean;\n\n /**\n * Shortcut to set the localStorageKey or indexedDbKey automatically from\n * queryName. This is important because it avoids requiring the user to\n * specify the same queryName twice everytime.\n */\n persistTo?: PersistTo;\n\n /**\n * Max number of entries to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxSize?: number;\n\n /**\n * Max number of bytes to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxBytes?: number;\n\n /**\n * Keep a copy of the last non-empty data so that when you change the\n * query arguments, you can still display the old data while waiting for the\n * new data to load.\n */\n keepLastNonEmptyData?: boolean;\n\n /**\n * Customize the logger.\n */\n log?: (...args: unknown[]) => void;\n} & SharedStateOptions<SharedQueryState<TData>>;\n\n/** Users can override these values globally. */\nexport const SharedQueryConfig = {\n /** Refetch on every page load. */\n staleMs: 0,\n\n /** Keep in cache for a long time. */\n expiryMs: 30 * MS_PER_DAY,\n\n /** Always refetch automatically if the data is stale. */\n refetchOnStale: true,\n\n /** No default persistence configured. */\n persistTo: undefined as PersistTo | undefined,\n\n /** 100 is not too large, but please tweak accordingly. */\n maxSize: 100,\n\n /** 100kb limit before discarding old records. */\n maxBytes: 100_000,\n\n /** Default to log to console. */\n log: (...args: unknown[]) => console.log(\"[sharedQuery]\", ...args),\n};\n\nexport type SharedQueryConfig = typeof SharedQueryConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedQuery(config: Partial<SharedQueryConfig>) {\n Object.assign(SharedQueryConfig, config);\n}\n\n/** Please use sharedQuery() instead. */\nexport class SharedQuery<TArgs extends unknown[], TData> {\n private readonly inflightQueries = new Map<\n string,\n { promise: Promise<TData>; abortController: AbortController }\n >();\n\n public readonly queryState: SharedState<SharedQueryState<TData>>;\n\n /**\n * Keep track of the mounted query keys. This handles the case where an\n * earlier-started query returns later, and when it returns, it was already\n * unmounted, thus making it eligible for clean up right after fetch.\n */\n private readonly mountedKeys = new Map<string, number>();\n\n public readonly queryName: string;\n private readonly queryFn: QueryFn<TArgs, TData>;\n public readonly expiryMs: number;\n public readonly staleMs: number;\n public readonly refetchOnStale: boolean;\n public readonly maxSize: number;\n public readonly maxBytes: number;\n public readonly keepLastNonEmptyData: boolean;\n public readonly log: (...args: unknown[]) => void;\n\n public constructor(options: SharedQueryOptions<TArgs, TData>) {\n this.queryName = options.queryName;\n this.queryFn = options.queryFn;\n\n this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;\n this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;\n\n this.refetchOnStale =\n options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;\n\n // Apply shortcut when using `persistTo`.\n // Specifying the `store` will take precedence over `persistTo`.\n if (!options.store) {\n const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;\n\n if (persistTo) {\n options.store = {\n persistTo,\n key: options.queryName,\n expiryMs: this.expiryMs,\n };\n }\n }\n\n this.maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;\n this.maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;\n\n this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);\n\n this.log = options?.log ?? SharedQueryConfig.log;\n\n this.queryState = sharedState<SharedQueryState<TData>>(\n {}, // defaultValue\n options,\n );\n }\n\n /** Returns a key to identify requests based on the given args. */\n public getQueryKey(args: TArgs): string {\n return this.queryName + \":\" + stringifyDeterministicForKeys(args);\n }\n\n /**\n * Get the AbortController to abort the inflight query (if any).\n *\n * Note that once you get the controller, you can also get the signal easily\n * using `controller.signal`. So if you need both the controller and the\n * signal, just use this function.\n */\n public getAbortController(args: TArgs): AbortController | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortControllerByKey(queryKey);\n }\n\n public getAbortControllerByKey(queryKey: string): AbortController | null {\n return this.inflightQueries.get(queryKey)?.abortController ?? null;\n }\n\n /**\n * Get the AbortSignal to check for query abortions.\n *\n * Example:\n * ```\n * const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: (userId: string) => {\n * // Get the signal for this current execution.\n * const signal = getUserQuery.getAbortSignal([userId]);\n *\n * // Pass the signal to fetch so that it can be aborted.\n * const response = await fetch(`/users/${userId}`, { signal });\n *\n * // Check for errors.\n * if (!response.ok) {\n * throw new Error(response.statusText);\n * }\n *\n * // Return the data.\n * return await response.json();\n * },\n * });\n * ```\n */\n public getAbortSignal(args: TArgs): AbortSignal | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortSignalByKey(queryKey);\n }\n\n public getAbortSignalByKey(queryKey: string): AbortSignal | null {\n return this.getAbortControllerByKey(queryKey)?.signal ?? null;\n }\n\n private isStale(dataUpdatedMs: number): boolean {\n if (this.staleMs === 0) return true; // always stale\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.staleMs;\n }\n\n private isExpired(dataUpdatedMs: number): boolean {\n if (this.expiryMs === 0) return false; // never expire\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.expiryMs;\n }\n\n public async getCachedOrFetch(...args: TArgs): Promise<TData> {\n const queryKey = this.getQueryKey(args);\n const cached = this.queryState.getSnapshot()?.[queryKey];\n\n if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {\n // Return cached value if not stale.\n if (!this.isStale(cached.dataUpdatedMs ?? 0)) {\n this.log(`Return fresh data ${queryKey} from cache without fetching`);\n return cached.data;\n }\n\n // If stale, optionally update the data in the background.\n if (this.refetchOnStale) {\n this.log(`refetchOnStale for ${queryKey}`);\n void this.dedupedFetch(queryKey, ...args);\n }\n\n // Still return stale data immediately.\n this.log(`Returning stale data for ${queryKey}`);\n return cached.data;\n }\n\n return await this.dedupedFetch(queryKey, ...args);\n }\n\n private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {\n const inflightQuery = this.inflightQueries.get(queryKey);\n\n // De-duplicate in-flight requests\n if (inflightQuery) {\n this.log(`Deduplicating inflight fetch for ${queryKey}`);\n return inflightQuery.promise;\n }\n\n // Fetch new data\n const promise = this.startFetching(queryKey, args);\n const abortController = new AbortController();\n\n this.inflightQueries.set(queryKey, { promise, abortController });\n\n return promise;\n }\n\n private async startFetching(queryKey: string, args: TArgs) {\n this.log(`Start fetching ${queryKey}`);\n\n try {\n const data = await this.queryFn(...args);\n\n this.log(`Successfully fetched ${queryKey}`);\n this.setData(queryKey, data, Date.now());\n return data;\n } catch (e) {\n this.log(`Failed to fetch ${queryKey}: ${e}`);\n throw e;\n } finally {\n this.inflightQueries.delete(queryKey);\n }\n }\n\n /** Get the entire stored entry for a query key. */\n public getQueryValue(queryKey: string): QueryStateValue<TData> | undefined {\n return this.queryState.getSnapshot()?.[queryKey];\n }\n\n /** Get the current stored data for a query key. */\n public getData(queryKey: string): TData | undefined {\n const stateValue: QueryStateValue<TData> | undefined =\n this.queryState.getSnapshot()?.[queryKey];\n\n // Check for expiration.\n if (stateValue?.lastUpdatedMs) {\n if (this.isExpired(stateValue.lastUpdatedMs)) {\n this.deleteData(queryKey);\n return undefined;\n }\n }\n\n return stateValue?.data;\n }\n\n /** Set the data directly if the user obtained it from somewhere else. */\n public setData(\n queryKey: string,\n data: TData,\n dataUpdatedMs = Date.now(),\n ): void {\n const entry: QueryStateValue<TData> = {\n lastUpdatedMs: Date.now(),\n loading: false,\n data,\n dataUpdatedMs,\n };\n\n this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));\n\n this.enforceSizeLimit();\n }\n\n /**\n * Do a new fetch even when the data is already cached, but don't fetch if\n * another fetch is already inflight.\n */\n public async updateFromSource(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, ...args);\n }\n\n /** Delete the cached data for one set of arguments. */\n public deleteData(queryKey: string): void {\n const record = this.queryState.getSnapshot();\n const clone = { ...record };\n delete clone[queryKey];\n this.queryState.setValue(clone);\n\n const inflight = this.inflightQueries.get(queryKey);\n if (inflight) {\n this.inflightQueries.delete(queryKey);\n inflight.abortController.abort(\"Deleted\");\n }\n }\n\n /** Delete all currently cached data & all inflight promises. */\n public clear(): void {\n this.queryState.delete();\n\n for (const { abortController } of Array.from(\n this.inflightQueries.values(),\n )) {\n abortController.abort(\"Cleared\");\n }\n\n this.inflightQueries.clear();\n }\n\n /** Keep track of mounted keys. */\n public mount(queryKey: string): void {\n this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);\n }\n\n /** Clean up mounted keys. */\n public unmount(queryKey: string): void {\n const count = this.mountedKeys.get(queryKey);\n if (count === 1) {\n this.mountedKeys.delete(queryKey);\n } else {\n this.mountedKeys.set(queryKey, (count ?? 0) - 1);\n }\n }\n\n /**\n * Discard the oldest entries if the size exceeds the limit. The last\n * remaining record cannot be deleted no matter what the limit is.\n */\n public enforceSizeLimit(): void {\n // Skip if there are no limits.\n if (!this.maxSize && !this.maxBytes) {\n return;\n }\n\n const oldState = this.queryState.getSnapshot();\n const oldSize = Object.keys(oldState).length;\n\n // Cannot do GC if size is 1 or less.\n if (oldSize <= 1) {\n return;\n }\n\n const oldByteSize = getByteSize(oldState);\n\n let numToCut = this.maxSize ? oldSize - this.maxSize : 0;\n let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;\n\n // Log to indicate why trimming was not needed.\n if (numToCut <= 0 && bytesToCut <= 0) {\n this.log(\n `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Log to inform user that trimming is needed.\n this.log(\n `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`,\n );\n\n const newState = { ...oldState }; // shallow clone\n let needUpdate = false;\n\n const deleteKeyIfNotMounted = (key: string, expired: boolean) => {\n // Mounted keys cannot be cleaned up as they are visible in the UI.\n if (this.mountedKeys.has(key)) {\n this.log(`Cannot clean up ${key} as it is mounted`);\n return;\n }\n\n const byteSize = getByteSize(key) + getByteSize(newState[key]);\n this.log(\n `Cleaning up unmounted${expired ? \", expired\" : \"\"} ${key} (byteSize=${byteSize.toLocaleString()})`,\n );\n\n numToCut--;\n bytesToCut -= byteSize;\n\n delete newState[key];\n needUpdate = true;\n };\n\n let entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n\n // Clean up all expired entries. Note that the auto-expiration in the\n // storage adapter only applies to the entire state, which contains all\n // the different query keys and their values. Each query key has its own\n // timestamp and we need to apply our own expiration here.\n for (const [key] of entriesByTimeAscending) {\n // Anything after this is not expired yet.\n if (!this.isExpired(newState[key].lastUpdatedMs)) {\n break;\n }\n\n deleteKeyIfNotMounted(key, true);\n }\n\n if (numToCut > 0 || bytesToCut > 0) {\n if (needUpdate) {\n entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n }\n\n // Clean up starting from oldest entry first.\n for (const [key] of entriesByTimeAscending) {\n deleteKeyIfNotMounted(key, false);\n\n if (numToCut <= 0 && bytesToCut <= 0) {\n break;\n }\n }\n }\n\n // Log to indicate why trimming was not needed.\n if (!needUpdate) {\n this.log(\n `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Update the state.\n this.queryState.setValue(newState);\n\n // Log to inform user that the data was trimmed.\n this.log(\n `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`,\n );\n }\n}\n\n// Disallow duplicate query names.\nconst seenQueryNames = new Set<string>();\n\n/**\n * Create a reusable shared query object that can be used to auto de-duplicate\n * and cache all data fetches, with auto-expiration and staleness checks.\n *\n * Each `queryName` must be unique, and can only be declared once, most often\n * in the top level scope. The queryName is used for logging only.\n *\n * Example:\n * ```\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n * ```\n */\nexport function sharedQuery<TArgs extends unknown[], TData>(\n // The name could have been auto-generated, but we let the user give us a\n // human-readable name that can be identified quickly in the logs.\n options: SharedQueryOptions<TArgs, TData>,\n): SharedQuery<TArgs, TData> {\n if (seenQueryNames.has(options.queryName)) {\n throw new Error(`Duplicate shared query \"${options.queryName}\"`);\n }\n seenQueryNames.add(options.queryName);\n\n return new SharedQuery(options);\n}\n\n// Using any so that we don't need to typecast later.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst DEFAULT_QUERY_STATE_ENTRY: Readonly<QueryStateValue<any>> = {\n lastUpdatedMs: 0,\n loading: true,\n};\n\n/**\n * React hook to make use of a shared query inside any React component. The\n * args do not need to be specified if no args are needed, i.e. this would work\n * ```\n * const allUsers = useSharedQuery(listUsersQuery);\n * ```\n *\n * Example:\n * ```\n * const users = useSharedQuery(usersQuery, \"userid123\");\n * ```\n */\nexport function useSharedQuery<TArgs extends unknown[], TData>(\n query: SharedQuery<TArgs, TData>,\n // Optional: Default to use no arguments.\n ...args: TArgs\n): UseQueryResult<TData> {\n // We don't expect users to provide a stable `args`, so stabilize it here.\n const stableArgs = useDeepMemo(args);\n\n const [queryState, setQueryState] = useSharedState(query.queryState);\n\n const isMounted = useRef(true);\n\n const queryKey = useMemo(\n () => query.getQueryKey(stableArgs),\n [query, stableArgs],\n );\n\n // Init with empty object.\n const queryStateValue: QueryStateValue<TData> =\n queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;\n\n const setQueryStateValue = useCallback(\n (next: SetStateAction<QueryStateValue<TData>>) => {\n setQueryState((prev) => {\n const clone = { ...prev };\n clone[queryKey] =\n typeof next === \"function\"\n ? next(clone[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY)\n : next;\n return clone;\n });\n },\n [queryKey, setQueryState],\n );\n\n // Keep the last non-empty data loaded in case you want to show a placeholder\n // during new loads.\n const [lastNonEmptyData, setLastNonEmptyData] = useState<TData | undefined>();\n\n useEffect(() => {\n if (query.keepLastNonEmptyData && queryStateValue.data !== undefined) {\n setLastNonEmptyData(queryStateValue.data);\n }\n }, [query.keepLastNonEmptyData, queryStateValue.data]);\n\n // The fetch logic wrapped in useCallback to be stable for useEffect\n const execute = useCallback(\n async (forceRefresh: boolean) => {\n query.log(`Begin executing shared query ${queryKey}`);\n\n setQueryStateValue((prev) => ({\n // Keep old data and error.\n ...prev,\n lastUpdatedMs: Date.now(),\n loading: true,\n }));\n\n try {\n const data = await (forceRefresh\n ? query.updateFromSource(...stableArgs)\n : query.getCachedOrFetch(...stableArgs));\n\n if (isMounted.current) {\n const now = Date.now();\n\n setQueryStateValue({\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n });\n }\n } catch (e) {\n if (isMounted.current) {\n const now = Date.now();\n\n setQueryStateValue((prev) => ({\n // Keep old data.\n ...prev,\n lastUpdatedMs: now,\n loading: false,\n error: e,\n errorUpdatedMs: now,\n }));\n }\n }\n },\n [query, queryKey, setQueryStateValue, stableArgs],\n );\n\n useEffect(() => {\n isMounted.current = true;\n query.mount(queryKey);\n\n void execute(false);\n\n return () => {\n isMounted.current = false;\n query.unmount(queryKey);\n };\n }, [execute, query, queryKey]);\n\n return useMemo(() => {\n return {\n ...queryStateValue,\n lastNonEmptyData,\n // This works only if the user-given queryFn supports abort. If not,\n // this function doesn't do anything, since we don't have any means to\n // abort the running queryFn.\n abortCurrentQuery: (reason?: unknown) => {\n const controller = query.getAbortControllerByKey(queryKey);\n controller?.abort(reason);\n return Boolean(controller);\n },\n refetch: () => {\n void execute(true);\n },\n setData: (data: TData, dataUpdatedMs?: number) => {\n const now = Date.now();\n setQueryStateValue((prev) => ({\n ...prev,\n data,\n dataUpdatedMs: dataUpdatedMs ?? now,\n lastUpdatedMs: now,\n loading: false,\n }));\n },\n deleteData: () => {\n setQueryState((prev) => {\n const clone = { ...prev };\n delete clone[queryKey];\n return clone;\n });\n },\n } satisfies UseQueryResult<TData>;\n }, [\n execute,\n lastNonEmptyData,\n query,\n queryKey,\n queryStateValue,\n setQueryState,\n setQueryStateValue,\n ]);\n}\n","/**\n * @packageDocumentation\n *\n * The \"shared state\" is a React state that shares the same value across the\n * entire app. It is largely similar to the jotai library, but is much smaller\n * and much easier to work with when used for persisting in local storage or\n * indexed db, with an automatic expiration for persisted values.\n *\n * This library aims to be the shortest, simplest, easiest to use, and most\n * efficient implementation of shared global React states with seamless support\n * for client persistence.\n *\n * Example:\n * ```\n * import { sharedState, useSharedState } from \"@choksheak/react-utils/sharedState\";\n *\n * // Create a new shared state in the top level scope.\n * // The first argument is the default value, which is required.\n * // The second argument is an options object, and is optional (defaults\n * // to storing in memory only without any persistence).\n * const usersState = sharedState([], { indexedDbKey: \"users\" });\n *\n * export const UsersComponent: React.FC = () => {\n * // Get both the value and the setter.\n * const [users, setUsers] = useSharedState(usersState);\n * ...\n * };\n *\n * export const ReadOnlyComponent: React.FC = () => {\n * // Get the value only.\n * const users = useSharedStateValue(usersState);\n * ...\n * };\n *\n * export const WriteOnlyComponent: React.FC = () => {\n * // Get the setter only.\n * const setUsers = useSharedStateSetter(usersState);\n * ...\n * };\n * ```\n */\n\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport { Dispatch, SetStateAction, useSyncExternalStore } from \"react\";\n\nimport { getStorageAdapter, StorageOptions } from \"./storage\";\n\n/************************************************************************/\n/* Global config */\n/************************************************************************/\n\n/**\n * These values are configurable by the user at any time.\n */\nexport const SharedStateConfig = {\n /**\n * Default duration for storage expiration in milliseconds. Initial value\n * is 30 days, but can be changed to anything.\n */\n storeExpiryMs: 30 * MS_PER_DAY,\n\n /**\n * True to load from storage only on mount, false (default) to load in\n * the top level scope when the shared state is defined.\n */\n lazyLoad: false,\n};\n\nexport type SharedStateConfig = typeof SharedStateConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedState(config: Partial<SharedStateConfig>) {\n Object.assign(SharedStateConfig, config);\n}\n\n/************************************************************************/\n/* Shared state implementation */\n/************************************************************************/\n\ntype Subscriber<T> = (next: T, prev: T) => void;\n\nclass PubSubStore {\n private dataByKey = new Map<string, unknown>();\n private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();\n\n // This could be undefined if T includes undefined, else it should never\n // return undefined because the user has to always specify the default value.\n public get<T>(key: string): T {\n return this.dataByKey.get(key) as T;\n }\n\n public set<T>(key: string, value: T): void {\n const prev = this.dataByKey.get(key) as T;\n\n // Do nothing if no change.\n if (prev === value) return;\n\n this.dataByKey.set(key, value);\n this.notify(key, value, prev);\n }\n\n public delete(key: string): void {\n this.dataByKey.delete(key);\n }\n\n public setNoNotify<T>(key: string, value: T): void {\n this.dataByKey.set(key, value);\n }\n\n // Subscribe to changes of a specific key\n public subscribe<T>(key: string, subscriber: Subscriber<T>): () => void {\n let subscribers = this.subscribersByKey.get(key);\n if (!subscribers) {\n subscribers = new Set();\n this.subscribersByKey.set(key, subscribers);\n }\n\n // Type coercion: we store Subscriber<unknown>\n subscribers.add(subscriber as Subscriber<unknown>);\n\n // Return unsubscribe function\n return () => {\n subscribers.delete(subscriber as Subscriber<unknown>);\n if (subscribers.size === 0) this.subscribersByKey.delete(key);\n };\n }\n\n private notify<T>(key: string, next: T, prev: T): void {\n const subscribers = this.subscribersByKey.get(key);\n if (!subscribers) return;\n\n // Make copy to protect against mutation during iteration.\n const copy = Array.from(subscribers.values());\n for (const subscriber of copy) {\n try {\n subscriber(next, prev);\n } catch (e) {\n console.error(`Error invoking subscriber:`, e);\n }\n }\n }\n}\n\nconst pubSubStore = new PubSubStore();\n\nconst STORAGE_KEY = \"state\";\n\n/** Options to configure a shared state. */\nexport type SharedStateOptions<T> = {\n lazyLoad?: boolean;\n} & StorageOptions<T>;\n\nlet stateKey = 0;\n\n/** Please use sharedState() instead. */\nexport class SharedState<T> {\n private readonly pubSubKey: string;\n private readonly storageAdapter: StorageAdapter<T> | null;\n public readonly setValueBounded: Dispatch<SetStateAction<T>>;\n public initStarted = false;\n public initDone = false;\n\n public constructor(\n private readonly defaultValue: T,\n options?: SharedStateOptions<T>,\n ) {\n this.pubSubKey = String(stateKey++);\n\n // Use max of 1 storage adapter per shared state.\n this.storageAdapter = getStorageAdapter(\n options,\n SharedStateConfig.storeExpiryMs,\n );\n\n // Same as this.setValue, but binding the `this` reference.\n this.setValueBounded = (next: SetStateAction<T>) => {\n this.setValue(next);\n };\n\n // Always set the default value first to avoid returning undefineds if that\n // is not part of T.\n pubSubStore.setNoNotify(this.pubSubKey, defaultValue);\n\n const lazyLoad = options?.lazyLoad;\n const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;\n\n if (!lazy) {\n void this.initDefaultValueOnce();\n }\n }\n\n /** `subscribe` function required by useSyncExternalStore. */\n public subscribe(subscriber: () => void): () => void {\n return pubSubStore.subscribe(this.pubSubKey, () => subscriber());\n }\n\n /** Returns true if there is a backing store. */\n public hasStore(): boolean {\n return Boolean(this.storageAdapter);\n }\n\n /**\n * Set a value directly into the store. Does nothing if there is no\n * backing store.\n *\n * Warning: If you want to set the value properly, you should use\n * `setValue()` instead which also updates the in-memory copy.\n */\n public async setInStore(value: T): Promise<void> {\n return await this.storageAdapter?.set(STORAGE_KEY, value);\n }\n\n /**\n * Get a value directly from the store. Returns undefined if there is no\n * backing store.\n */\n public async getFromStore(): Promise<T | undefined> {\n return await this.storageAdapter?.get(STORAGE_KEY);\n }\n\n /**\n * Get the value of the shared state.\n * `getSnapshot` function — must return same ref if value unchanged.\n * Note that this would return the default value if data init from store is\n * not completed yet.\n */\n public getSnapshot(): T {\n return pubSubStore.get(this.pubSubKey);\n }\n\n /** Set the value of the shared state. */\n public setValue(next: SetStateAction<T>): void {\n if (typeof next === \"function\") {\n const prev = pubSubStore.get<T>(this.pubSubKey);\n next = (next as (p: T) => T)(prev);\n }\n\n pubSubStore.set(this.pubSubKey, next);\n void this.setInStore(next);\n }\n\n /**\n * Remove the value of the shared state. Actually this just sets the value\n * back to the given default value.\n */\n public delete() {\n pubSubStore.set(this.pubSubKey, this.defaultValue);\n void this.storageAdapter?.set(STORAGE_KEY, this.defaultValue);\n }\n\n /** Initial default handling. */\n public async initDefaultValueOnce() {\n if (this.initDone || this.initStarted) return;\n this.initStarted = true;\n\n const stored = await this.getFromStore();\n const value = stored !== undefined ? stored : this.defaultValue;\n\n pubSubStore.set(this.pubSubKey, value);\n\n this.initDone = true;\n }\n}\n\n/**\n * Create a new shared state object. Put this code at the top level scope:\n *\n * Example:\n * ```\n * const counterState = sharedState<number>(0);\n * ```\n */\nexport function sharedState<T>(\n defaultValue: T,\n options?: SharedStateOptions<T>,\n): SharedState<T> {\n return new SharedState(defaultValue, options);\n}\n\n/**\n * React hook for subscribing to a key in the global store.\n *\n * Example:\n * ```\n * const [counter, setCounter] = useSharedState(counterState);\n * ```\n */\nexport function useSharedState<T>(\n state: SharedState<T>,\n): [T, Dispatch<SetStateAction<T>>] {\n state.initDefaultValueOnce();\n\n const value = useSyncExternalStore(\n (onStoreChange) => state.subscribe(onStoreChange),\n () => state.getSnapshot(),\n // getServerSnapshot is needed to prevent SSR dev errors in nextjs.\n () => state.getSnapshot(),\n );\n\n return [value, state.setValueBounded];\n}\n\n/**\n * Same as useSharedState(), but returns the value only.\n *\n * Example:\n * ```\n * const counter = useSharedStateValue(counterState);\n * ```\n */\nexport function useSharedStateValue<T>(state: SharedState<T>) {\n return useSharedState(state)[0];\n}\n\n/**\n * Same as useSharedState(), but returns the setter only.\n *\n * Example:\n * ```\n * const setCounter = useSharedStateSetter(counterState);\n * ```\n */\nexport function useSharedStateSetter<T>(state: SharedState<T>) {\n return useSharedState(state)[1];\n}\n","import { kvStore } from \"@choksheak/ts-utils/kvStore\";\nimport { LocalStore } from \"@choksheak/ts-utils/localStore\";\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\n\n/** List of all available pre-built persistence options. */\nexport type PersistTo = \"localStorage\" | \"indexedDb\";\n\n/** Options to configure a pre-built store adapter. */\nexport type StoreOptions = {\n /** Choose which pre-built store adapter to use. */\n persistTo: PersistTo;\n\n /** Storage key to act as a namespace to store the data. */\n key: string;\n\n /** How long to wait before the value is considered expired. */\n expiryMs?: number;\n\n /** Returns true if the given value supposed to be of type T is valid. */\n isValid?: (u: unknown) => boolean;\n};\n\n/** Either use a pre-built store, or a custom one you provide. */\nexport type StorageOptions<T> = {\n /**\n * You can either specify the storage adapter directly to use a custom\n * store, or specify the properties to use the pre-built ones.\n */\n store?: StorageAdapter<T> | StoreOptions;\n};\n\n// In SSR (Server-Side Rendering) mode, we never want to use our storage\n// adapters for anything.\nconst IS_NOT_BROWSER = typeof window === \"undefined\";\n\nexport function getStorageAdapter<T>(\n options: StorageOptions<T> | undefined,\n defaultStoreExpiryMs: number,\n): StorageAdapter<T> | null {\n const { store } = options ?? {};\n\n if (!store) {\n return null;\n }\n\n if (\n \"persistTo\" in store &&\n typeof store.persistTo === \"string\" &&\n \"key\" in store &&\n typeof store.key === \"string\"\n ) {\n const expiryMs = store.expiryMs || defaultStoreExpiryMs;\n const isValid = store.isValid;\n\n switch (store.persistTo) {\n case \"localStorage\": {\n const adapter = new LocalStore(store.key, {\n defaultExpiryMs: expiryMs,\n });\n\n // Handle SSR.\n return {\n set: (key: string, value: T): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n adapter.set(key, value);\n },\n get: (key: string): T | undefined => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const value = adapter.get<T>(key);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `localStorage adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n adapter.delete(key);\n return undefined;\n }\n\n return value;\n },\n delete: (key: string): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.delete(key);\n },\n clear: (): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.clear();\n },\n };\n }\n\n case \"indexedDb\": {\n // Use the key prefix as a unique namespace for this store.\n const keyPrefix = store.key + \":\";\n const isValid = store.isValid;\n\n // Prefix all keys using `keyPrefix` so that we can keep all data in\n // the same store in the same DB. If we keep the data in different\n // DB stores, then GC will not be able to clean up abandoned stores.\n return {\n set: async (key: string, value: T): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n await kvStore.set(keyPrefix + key, value, expiryMs);\n },\n get: async (key: string): Promise<T | undefined> => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const fullKey = keyPrefix + key;\n const value = await kvStore.get<T>(fullKey);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `indexedDb adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n await kvStore.delete(fullKey);\n return undefined;\n }\n\n return value;\n },\n delete: async (key: string): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.delete(keyPrefix + key);\n },\n clear: async (): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.clear();\n },\n };\n }\n\n default:\n store.persistTo satisfies never;\n throw new Error(`Unknown store.adapter ${store.persistTo}`);\n }\n }\n\n return store as StorageAdapter<T>;\n}\n","/**\n * JSON stringify a value of any type, and returns an unsafe string. The string\n * is \"unsafe\" because you might not be able to restore the original object\n * using the serialized JSON. This is specifically used for query key generation\n * and is not suitable for more general uses.\n *\n * Note: The given value should not contain any functions because functions\n * cannot be serialized and deserialized properly.\n */\nexport function stringifyDeterministicForKeys(params: unknown): string {\n const cache = new Set();\n\n return JSON.stringify(params, (_, value) => {\n if (typeof value === \"function\") {\n throw new Error(`Functions should not be passed into query parameters`);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n return value;\n }\n\n if (cache.has(value)) {\n // Circular reference detected! Return a placeholder string.\n return \"[Circular]\";\n }\n\n cache.add(value);\n\n // Don't crash on circular references, but don't mark the top level object\n // as circular because we want to return as much string as possible.\n if (value !== params) {\n try {\n JSON.stringify(value);\n } catch {\n return \"[Unserializable]\";\n }\n }\n\n return Object.keys(value)\n .sort()\n .reduce(\n (result: Record<string, unknown>, key: string) => {\n result[key] = value[key];\n return result;\n },\n {} as Record<string, unknown>,\n );\n });\n}\n","import isEqual from \"lodash/isEqual\";\nimport { useMemo, useState } from \"react\";\n\n/**\n * Provides a stable value for T based on deep equality. The deep equality is\n * determined by the `isEqual()` function in lodash.\n */\nexport function useDeepMemo<T>(value: T): T {\n const [state, setState] = useState<T>(value);\n\n return useMemo(() => {\n if (state === value || isEqual(state, value)) {\n return state;\n }\n\n // We check equality, so this should be safe.\n // eslint-disable-next-line react-hooks/set-state-in-render\n setState(value);\n\n return value;\n }, [state, value]);\n}\n"],"mappings":"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","names":["MS_PER_DAY","getByteSize","useCallback","useEffect","useMemo","useRef","useState","MS_PER_DAY","useSyncExternalStore","kvStore","LocalStore","IS_NOT_BROWSER","getStorageAdapter","options","defaultStoreExpiryMs","store","expiryMs","isValid","adapter","key","value","keyPrefix","fullKey","SharedStateConfig","MS_PER_DAY","PubSubStore","key","value","prev","subscriber","subscribers","next","copy","e","pubSubStore","STORAGE_KEY","stateKey","SharedState","defaultValue","options","getStorageAdapter","SharedStateConfig","lazyLoad","stored","sharedState","useSharedState","state","useSyncExternalStore","onStoreChange","stringifyDeterministicForKeys","params","cache","_","value","result","key","isEqual","useMemo","useState","useDeepMemo","value","state","setState","SharedQueryConfig","MS_PER_DAY","args","configureSharedQuery","config","SharedQuery","options","persistTo","sharedState","stringifyDeterministicForKeys","queryKey","dataUpdatedMs","cached","inflightQuery","promise","abortController","data","e","stateValue","entry","prev","key","clone","inflight","count","oldState","oldSize","oldByteSize","getByteSize","numToCut","bytesToCut","newState","needUpdate","deleteKeyIfNotMounted","expired","byteSize","entriesByTimeAscending","entry1","entry2","seenQueryNames","sharedQuery","DEFAULT_QUERY_STATE_ENTRY","useSharedQuery","query","stableArgs","useDeepMemo","queryState","setQueryState","useSharedState","isMounted","useRef","useMemo","queryStateValue","setQueryStateValue","useCallback","next","lastNonEmptyData","setLastNonEmptyData","useState","useEffect","execute","forceRefresh","now","reason","controller"]}
1
+ {"version":3,"sources":["../src/sharedQuery.ts","../src/sharedState.ts","../src/storage.ts","../src/stringify.ts","../src/useDeepMemo.ts"],"sourcesContent":["/**\n * @packageDocumentation\n *\n * The \"shared query\" is a data fetching convenience library that helps users\n * to load data, check for staleness and expiration, cache in memory, local\n * storage or indexed db, and be able to manipulate the data anytime.\n *\n * Each shared query creates a new private shared state that handles the data\n * storage. This shared state contains the records for all queries, where each\n * query is a unique set of arguments used to make the query. The number of\n * records and the total byte size of data can be limited so as to ensure the\n * query does not take up too much memory.\n *\n * Example:\n * ```\n * import { sharedQuery, useSharedQuery } from \"@choksheak/react-utils/sharedQuery\";\n *\n * // Create a new shared query in the top level scope.\n * // The query function `queryFn` here does not take any arguments.\n * //\n * // Only the queryName and queryFn are required and everything else is\n * // optional.\n * //\n * // The queryName must be unique, otherwise we will throw an\n * // error. This is because defining the same queryName for two different\n * // queries implies that the queries are the same, thus violating the idea\n * // of a \"shared\" query, and almost certainly points to a bug in the code.\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * // Note that the query will only start fetching on mount in React.\n * // But you can do `usersQuery.getCachedOrFetch()` here to prefetch the\n * // data if necessary.\n * usersQuery.getCachedOrFetch();\n *\n * // Example of using a query function with arguments.\n * export const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: getUser, // getUser(userId: string) => User\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n *\n * export const UsersComponent: React.FC = () => {\n * // Second argument is optional, and is an array of arguments to be\n * // passed to the `queryFn`.\n * const users = useSharedQuery(usersQuery);\n *\n * return (\n * <>\n * <h1>List of Users</h1>\n *\n * <button onclick={users.refetch}>Refresh</button>\n *\n * {users.loading && <Loader />}\n *\n * {users.error && !users.data && <p>Error: {String(users.error)}</p>}\n *\n * <p>Data: {users.data ? JSON.stringify(users.data) : \"-\"}</p>\n * </>\n * );\n * };\n *\n * export const AnotherComponent: React.FC = () => {\n * // This query will be deduped with the one above, sharing the same\n * // data and the same fetches.\n * const users = useSharedQuery(usersQuery);\n * ...\n * };\n *\n * export const DisplayOneUser: React.FC<{userId: string}> = ({userId}) => {\n * // Example of using the query with an argument. Whenever the argument\n * // changes, the data will be fetched and updated automatically.\n * const user = useSharedQuery(getUserQuery, [userId]);\n *\n * return <>User: {user.data ? JSON.stringify(user.data) : \"-\"}</>;\n * };\n * ```\n */\n\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport getByteSize from \"object-sizeof\";\nimport {\n SetStateAction,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport {\n SharedState,\n sharedState,\n SharedStateOptions,\n useSharedState,\n} from \"./sharedState\";\nimport { PersistTo } from \"./storage\";\nimport { stringifyDeterministicForKeys } from \"./stringify\";\nimport { useDeepMemo } from \"./useDeepMemo\";\n\n/** Type of the queryFn used to fetch the data from source. */\nexport type QueryFn<TArgs extends unknown[], TData> = (\n ...args: TArgs\n) => Promise<TData> | TData;\n\n/** An entry for a persisted state corresponding to one query key. */\nexport type QueryStateValue<TData> = Readonly<{\n /**\n * When this record was last updated. Used for LRU determination. If you want\n * the data age, please use `dataUpdatedMs` instead.\n */\n lastUpdatedMs: number;\n\n loading: boolean;\n data?: TData;\n dataUpdatedMs?: number;\n\n /**\n * Use unknown type for error because we can't assume what type of variable\n * will be thrown from the query function. We also don't want to add this\n * type to QueryStateValue<TError> as it adds complexity to the type.\n */\n error?: unknown;\n\n errorUpdatedMs?: number;\n}>;\n\n/** Return type for useSharedQuery(). */\nexport type UseQueryResult<TData> = QueryStateValue<TData> &\n Readonly<{\n /**\n * Return the last non-empty data if you want to display the last loaded data\n * while waiting for the new data to load.\n */\n lastNonEmptyData: TData | undefined;\n\n /**\n * Cancels the inflight query (if any). Returns true if canceled.\n */\n abortCurrentQuery: (reason?: unknown) => boolean;\n\n /**\n * Manually refresh the data by doing a new query.\n */\n refetch: () => void;\n\n /**\n * If you got the data from somewhere else (e.g. an in-memory data update),\n * just set it directly.\n */\n setData: (data: TData, dataUpdatedMs?: number) => void;\n\n deleteData: () => void;\n }>;\n\n/**\n * The full query state is a record of all the fetch states mapped by\n * query key to the state entry.\n */\nexport type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;\n\n/** Options to configure a shared query. */\nexport type SharedQueryOptions<TArgs extends unknown[], TData> = {\n /**\n * The name could have been auto-generated, but we let the user give us a\n * human-readable name that can be identified quickly in the logs.\n * Note that the queryKey is a string formed by the queryName plus the query\n * arguments.\n */\n queryName: string;\n\n /**\n * Function to fetch data.\n */\n queryFn: QueryFn<TArgs, TData>;\n\n /**\n * Function to re-fetch data. Usually this would just be the `queryFn`, but\n * if you need a different behavior for refetching, then specify this.\n */\n refetchFn?: QueryFn<TArgs, TData>;\n\n /**\n * ms before data considered stale; 0 means always stale.\n */\n staleMs?: number;\n\n /**\n * ms before data is removed from cache; 0 means never expire.\n */\n expiryMs?: number;\n\n /**\n * Trigger background re-fetch if stale.\n */\n refetchOnStale?: boolean;\n\n /**\n * Shortcut to set the localStorageKey or indexedDbKey automatically from\n * queryName. This is important because it avoids requiring the user to\n * specify the same queryName twice everytime.\n */\n persistTo?: PersistTo;\n\n /**\n * Max number of entries to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxSize?: number;\n\n /**\n * Max number of bytes to keep. The oldest entries will be discarded.\n * The last record cannot be discarded. 0 means no limit.\n */\n maxBytes?: number;\n\n /**\n * Keep a copy of the last non-empty data so that when you change the\n * query arguments, you can still display the old data while waiting for the\n * new data to load.\n */\n keepLastNonEmptyData?: boolean;\n\n /**\n * Customize the logger.\n */\n log?: (...args: unknown[]) => void;\n} & SharedStateOptions<SharedQueryState<TData>>;\n\n/** Users can override these values globally. */\nexport const SharedQueryConfig = {\n /** Refetch on every page load. */\n staleMs: 0,\n\n /** Keep in cache for a long time. */\n expiryMs: 30 * MS_PER_DAY,\n\n /** Always refetch automatically if the data is stale. */\n refetchOnStale: true,\n\n /** No default persistence configured. */\n persistTo: undefined as PersistTo | undefined,\n\n /** 100 is not too large, but please tweak accordingly. */\n maxSize: 100,\n\n /** 100kb limit before discarding old records. */\n maxBytes: 100_000,\n\n /** Default to log to console. */\n log: (...args: unknown[]) => console.log(\"[sharedQuery]\", ...args),\n};\n\nexport type SharedQueryConfig = typeof SharedQueryConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedQuery(config: Partial<SharedQueryConfig>) {\n Object.assign(SharedQueryConfig, config);\n}\n\n/** Whether this is a normal query or an explicit refetch. */\ntype QueryType = \"query\" | \"refetch\";\n\n/** Please use sharedQuery() instead. */\nexport class SharedQuery<TArgs extends unknown[], TData> {\n private readonly inflightQueries = new Map<\n string,\n { promise: Promise<TData>; abortController: AbortController }\n >();\n\n public readonly queryState: SharedState<SharedQueryState<TData>>;\n\n /**\n * Keep track of the mounted query keys. This handles the case where an\n * earlier-started query returns later, and when it returns, it was already\n * unmounted, thus making it eligible for clean up right after fetch.\n */\n private readonly mountedKeys = new Map<string, number>();\n\n public readonly queryName: string;\n public readonly queryFn: QueryFn<TArgs, TData>;\n public readonly refetchFn: QueryFn<TArgs, TData> | undefined;\n public readonly expiryMs: number;\n public readonly staleMs: number;\n public readonly refetchOnStale: boolean;\n public readonly maxSize: number;\n public readonly maxBytes: number;\n public readonly keepLastNonEmptyData: boolean;\n public readonly log: (...args: unknown[]) => void;\n\n public constructor(options: SharedQueryOptions<TArgs, TData>) {\n this.queryName = options.queryName;\n this.queryFn = options.queryFn;\n this.refetchFn = options.refetchFn;\n\n this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;\n this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;\n\n this.refetchOnStale =\n options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;\n\n // Apply shortcut when using `persistTo`.\n // Specifying the `store` will take precedence over `persistTo`.\n if (!options.store) {\n const persistTo = options.persistTo ?? SharedQueryConfig.persistTo;\n\n if (persistTo) {\n options.store = {\n persistTo,\n key: options.queryName,\n expiryMs: this.expiryMs,\n };\n }\n }\n\n this.maxSize = options?.maxSize ?? SharedQueryConfig.maxSize;\n this.maxBytes = options?.maxBytes ?? SharedQueryConfig.maxBytes;\n\n this.keepLastNonEmptyData = Boolean(options?.keepLastNonEmptyData);\n\n this.log = options?.log ?? SharedQueryConfig.log;\n\n this.queryState = sharedState<SharedQueryState<TData>>(\n {}, // defaultValue\n options,\n );\n }\n\n /** Returns a key to identify requests based on the given args. */\n public getQueryKey(args: TArgs): string {\n return this.queryName + \":\" + stringifyDeterministicForKeys(args);\n }\n\n /**\n * Get the AbortController to abort the inflight query (if any).\n *\n * Note that once you get the controller, you can also get the signal easily\n * using `controller.signal`. So if you need both the controller and the\n * signal, just use this function.\n */\n public getAbortController(args: TArgs): AbortController | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortControllerByKey(queryKey);\n }\n\n public getAbortControllerByKey(queryKey: string): AbortController | null {\n return this.inflightQueries.get(queryKey)?.abortController ?? null;\n }\n\n /**\n * Get the AbortSignal to check for query abortions.\n *\n * Example:\n * ```\n * const getUserQuery = sharedQuery({\n * queryName: \"getUser\",\n * queryFn: (userId: string) => {\n * // Get the signal for this current execution.\n * const signal = getUserQuery.getAbortSignal([userId]);\n *\n * // Pass the signal to fetch so that it can be aborted.\n * const response = await fetch(`/users/${userId}`, { signal });\n *\n * // Check for errors.\n * if (!response.ok) {\n * throw new Error(response.statusText);\n * }\n *\n * // Return the data.\n * return await response.json();\n * },\n * });\n * ```\n */\n public getAbortSignal(args: TArgs): AbortSignal | null {\n const queryKey = this.getQueryKey(args);\n return this.getAbortSignalByKey(queryKey);\n }\n\n public getAbortSignalByKey(queryKey: string): AbortSignal | null {\n return this.getAbortControllerByKey(queryKey)?.signal ?? null;\n }\n\n private isStale(dataUpdatedMs: number): boolean {\n if (this.staleMs === 0) return true; // always stale\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.staleMs;\n }\n\n private isExpired(dataUpdatedMs: number): boolean {\n if (this.expiryMs === 0) return false; // never expire\n\n const ageMs = Date.now() - dataUpdatedMs;\n return ageMs > this.expiryMs;\n }\n\n /** For cached data, we need to return the correct timestamps as well. */\n public async getCachedOrFetch(\n ...args: TArgs\n ): Promise<QueryStateValue<TData>> {\n const queryKey = this.getQueryKey(args);\n const cached = this.queryState.getSnapshot()?.[queryKey];\n\n if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {\n // Return cached value if not stale.\n if (!this.isStale(cached.dataUpdatedMs ?? 0)) {\n this.log(`Return fresh data ${queryKey} from cache without fetching`);\n return cached;\n }\n\n // If stale, optionally update the data in the background.\n if (this.refetchOnStale) {\n this.log(`refetchOnStale for ${queryKey}`);\n void this.dedupedFetch(queryKey, \"query\", ...args);\n }\n\n // Still return stale data immediately.\n this.log(`Returning stale data for ${queryKey}`);\n return cached;\n }\n\n const data = await this.dedupedFetch(queryKey, \"query\", ...args);\n const now = Date.now();\n\n return {\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n };\n }\n\n private dedupedFetch(\n queryKey: string,\n queryType: QueryType,\n ...args: TArgs\n ): Promise<TData> {\n const inflightQuery = this.inflightQueries.get(queryKey);\n\n // De-duplicate in-flight requests. Note that queries and refetches are\n // also deduped because they should technically be trying to fetch the same\n // data.\n if (inflightQuery) {\n this.log(\n `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`,\n );\n return inflightQuery.promise;\n }\n\n // Fetch new data\n const promise = this.startFetching(queryKey, queryType, args);\n const abortController = new AbortController();\n\n this.inflightQueries.set(queryKey, { promise, abortController });\n\n return promise;\n }\n\n private async startFetching(\n queryKey: string,\n queryType: QueryType,\n args: TArgs,\n ) {\n this.log(`Start fetching ${queryKey}, queryType=${queryType}`);\n\n try {\n const data = await (queryType === \"refetch\" && this.refetchFn\n ? this.refetchFn(...args)\n : this.queryFn(...args));\n\n this.log(`Successfully fetched ${queryKey}`);\n this.setData(queryKey, data, Date.now());\n return data;\n } catch (e) {\n this.log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);\n throw e;\n } finally {\n this.inflightQueries.delete(queryKey);\n }\n }\n\n /** Get the entire stored entry for a query key. */\n public getQueryValue(queryKey: string): QueryStateValue<TData> | undefined {\n return this.queryState.getSnapshot()?.[queryKey];\n }\n\n /** Get the current stored data for a query key. */\n public getData(queryKey: string): TData | undefined {\n const stateValue: QueryStateValue<TData> | undefined =\n this.queryState.getSnapshot()?.[queryKey];\n\n // Check for expiration.\n if (stateValue?.lastUpdatedMs) {\n if (this.isExpired(stateValue.lastUpdatedMs)) {\n this.deleteData(queryKey);\n return undefined;\n }\n }\n\n return stateValue?.data;\n }\n\n /** Set the data directly if the user obtained it from somewhere else. */\n public setData(\n queryKey: string,\n data: TData,\n dataUpdatedMs = Date.now(),\n ): void {\n const entry: QueryStateValue<TData> = {\n lastUpdatedMs: Date.now(),\n loading: false,\n data,\n dataUpdatedMs,\n };\n\n this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));\n\n this.enforceSizeLimit();\n }\n\n /**\n * Do a new fetch even when the data is already cached, but don't fetch if\n * another fetch is already inflight.\n */\n public async updateFromSource(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, \"query\", ...args);\n }\n\n /**\n * Perform an explicit refetch. If `options.refetchFn` was not specified,\n * then this would be identical to calling `this.updateFromSource()`.\n */\n public async refetch(...args: TArgs): Promise<TData> {\n const key = this.getQueryKey(args);\n return await this.dedupedFetch(key, \"refetch\", ...args);\n }\n\n /** Delete the cached data for one set of arguments. */\n public deleteData(queryKey: string): void {\n const record = this.queryState.getSnapshot();\n const clone = { ...record };\n delete clone[queryKey];\n this.queryState.setValue(clone);\n\n const inflight = this.inflightQueries.get(queryKey);\n if (inflight) {\n this.inflightQueries.delete(queryKey);\n inflight.abortController.abort(\"Deleted\");\n }\n }\n\n /** Delete all currently cached data & all inflight promises. */\n public clear(): void {\n this.queryState.delete();\n\n for (const { abortController } of Array.from(\n this.inflightQueries.values(),\n )) {\n abortController.abort(\"Cleared\");\n }\n\n this.inflightQueries.clear();\n }\n\n /** Keep track of mounted keys. */\n public mount(queryKey: string): void {\n this.mountedKeys.set(queryKey, (this.mountedKeys.get(queryKey) ?? 0) + 1);\n }\n\n /** Clean up mounted keys. */\n public unmount(queryKey: string): void {\n const count = this.mountedKeys.get(queryKey);\n if (count === 1) {\n this.mountedKeys.delete(queryKey);\n } else {\n this.mountedKeys.set(queryKey, (count ?? 0) - 1);\n }\n }\n\n /**\n * Discard the oldest entries if the size exceeds the limit. The last\n * remaining record cannot be deleted no matter what the limit is.\n */\n public enforceSizeLimit(): void {\n // Skip if there are no limits.\n if (!this.maxSize && !this.maxBytes) {\n return;\n }\n\n const oldState = this.queryState.getSnapshot();\n const oldSize = Object.keys(oldState).length;\n\n // Cannot do GC if size is 1 or less.\n if (oldSize <= 1) {\n return;\n }\n\n const oldByteSize = getByteSize(oldState);\n\n let numToCut = this.maxSize ? oldSize - this.maxSize : 0;\n let bytesToCut = this.maxBytes ? oldByteSize - this.maxBytes : 0;\n\n // Log to indicate why trimming was not needed.\n if (numToCut <= 0 && bytesToCut <= 0) {\n this.log(\n `No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Log to inform user that trimming is needed.\n this.log(\n `Need to trim ${this.queryName}: numToCut=${numToCut}, bytesToCut=${bytesToCut}`,\n );\n\n const newState = { ...oldState }; // shallow clone\n let needUpdate = false;\n\n const deleteKeyIfNotMounted = (key: string, expired: boolean) => {\n // Mounted keys cannot be cleaned up as they are visible in the UI.\n if (this.mountedKeys.has(key)) {\n this.log(`Cannot clean up ${key} as it is mounted`);\n return;\n }\n\n const byteSize = getByteSize(key) + getByteSize(newState[key]);\n this.log(\n `Cleaning up unmounted${expired ? \", expired\" : \"\"} ${key} (byteSize=${byteSize.toLocaleString()})`,\n );\n\n numToCut--;\n bytesToCut -= byteSize;\n\n delete newState[key];\n needUpdate = true;\n };\n\n let entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n\n // Clean up all expired entries. Note that the auto-expiration in the\n // storage adapter only applies to the entire state, which contains all\n // the different query keys and their values. Each query key has its own\n // timestamp and we need to apply our own expiration here.\n for (const [key] of entriesByTimeAscending) {\n // Anything after this is not expired yet.\n if (!this.isExpired(newState[key].lastUpdatedMs)) {\n break;\n }\n\n deleteKeyIfNotMounted(key, true);\n }\n\n if (numToCut > 0 || bytesToCut > 0) {\n if (needUpdate) {\n entriesByTimeAscending = Object.entries(newState).sort(\n (entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,\n );\n }\n\n // Clean up starting from oldest entry first.\n for (const [key] of entriesByTimeAscending) {\n deleteKeyIfNotMounted(key, false);\n\n if (numToCut <= 0 && bytesToCut <= 0) {\n break;\n }\n }\n }\n\n // Log to indicate why trimming was not needed.\n if (!needUpdate) {\n this.log(\n `No trimmable entries found for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,\n );\n return;\n }\n\n // Update the state.\n this.queryState.setValue(newState);\n\n // Log to inform user that the data was trimmed.\n this.log(\n `Trimmed data for ${this.queryName}: size=[${oldSize} -> ${Object.keys(newState).length}] (limit=${this.maxSize}), byteSize=[${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}] (limit=${this.maxBytes.toLocaleString()})`,\n );\n }\n}\n\n// Disallow duplicate query names.\nconst seenQueryNames = new Set<string>();\n\n/**\n * Create a reusable shared query object that can be used to auto de-duplicate\n * and cache all data fetches, with auto-expiration and staleness checks.\n *\n * Each `queryName` must be unique, and can only be declared once, most often\n * in the top level scope. The queryName is used for logging only.\n *\n * Example:\n * ```\n * export const usersQuery = sharedQuery({\n * queryName: \"users\",\n * queryFn: listUsers,\n * persistTo: \"indexedDb\",\n * staleMs: MS_PER_DAY,\n * });\n * ```\n */\nexport function sharedQuery<TArgs extends unknown[], TData>(\n // The name could have been auto-generated, but we let the user give us a\n // human-readable name that can be identified quickly in the logs.\n options: SharedQueryOptions<TArgs, TData>,\n): SharedQuery<TArgs, TData> {\n if (seenQueryNames.has(options.queryName)) {\n throw new Error(`Duplicate shared query \"${options.queryName}\"`);\n }\n seenQueryNames.add(options.queryName);\n\n return new SharedQuery(options);\n}\n\n// Using any so that we don't need to typecast later.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst DEFAULT_QUERY_STATE_ENTRY: Readonly<QueryStateValue<any>> = {\n lastUpdatedMs: 0,\n loading: true,\n};\n\n/**\n * React hook to make use of a shared query inside any React component. The\n * args do not need to be specified if no args are needed, i.e. this would work\n * ```\n * const allUsers = useSharedQuery(listUsersQuery);\n * ```\n *\n * Example:\n * ```\n * const users = useSharedQuery(usersQuery, \"userid123\");\n * ```\n */\nexport function useSharedQuery<TArgs extends unknown[], TData>(\n query: SharedQuery<TArgs, TData>,\n // Optional: Default to use no arguments.\n ...args: TArgs\n): UseQueryResult<TData> {\n // We don't expect users to provide a stable `args`, so stabilize it here.\n const stableArgs = useDeepMemo(args);\n\n const [queryState, setQueryState] = useSharedState(query.queryState);\n\n const isMounted = useRef(true);\n\n const queryKey = useMemo(\n () => query.getQueryKey(stableArgs),\n [query, stableArgs],\n );\n\n // Init with empty object.\n const queryStateValue: QueryStateValue<TData> =\n queryState[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY;\n\n const setQueryStateValue = useCallback(\n (next: SetStateAction<QueryStateValue<TData>>) => {\n setQueryState((prev) => {\n const clone = { ...prev };\n clone[queryKey] =\n typeof next === \"function\"\n ? next(clone[queryKey] ?? DEFAULT_QUERY_STATE_ENTRY)\n : next;\n return clone;\n });\n },\n [queryKey, setQueryState],\n );\n\n // Keep the last non-empty data loaded in case you want to show a placeholder\n // during new loads.\n const [lastNonEmptyData, setLastNonEmptyData] = useState<TData | undefined>();\n\n useEffect(() => {\n if (query.keepLastNonEmptyData && queryStateValue.data !== undefined) {\n setLastNonEmptyData(queryStateValue.data);\n }\n }, [query.keepLastNonEmptyData, queryStateValue.data]);\n\n // The fetch logic wrapped in useCallback to be stable for useEffect\n const execute = useCallback(\n async (isRefetch: boolean) => {\n query.log(\n `Begin executing shared query ${queryKey}, isRefetch=${isRefetch}`,\n );\n\n if (!isMounted.current) return;\n\n setQueryStateValue((prev) => ({\n // Keep old data and error.\n ...prev,\n lastUpdatedMs: Date.now(),\n loading: true,\n }));\n\n try {\n let state: QueryStateValue<TData>;\n\n if (isRefetch) {\n const data = await query.refetch(...stableArgs);\n const now = Date.now();\n\n state = {\n // Don't keep old error.\n lastUpdatedMs: now,\n loading: false,\n data,\n dataUpdatedMs: now,\n };\n } else {\n state = await query.getCachedOrFetch(...stableArgs);\n }\n\n if (!isMounted.current) return;\n\n setQueryStateValue(state);\n } catch (e) {\n if (!isMounted.current) return;\n\n const now = Date.now();\n\n setQueryStateValue((prev) => ({\n // Keep old data.\n ...prev,\n lastUpdatedMs: now,\n loading: false,\n error: e,\n errorUpdatedMs: now,\n }));\n }\n },\n [query, queryKey, setQueryStateValue, stableArgs],\n );\n\n useEffect(() => {\n isMounted.current = true;\n query.mount(queryKey);\n\n void execute(false);\n\n return () => {\n isMounted.current = false;\n query.unmount(queryKey);\n };\n }, [execute, query, queryKey]);\n\n return useMemo(() => {\n return {\n ...queryStateValue,\n lastNonEmptyData,\n // This works only if the user-given queryFn supports abort. If not,\n // this function doesn't do anything, since we don't have any means to\n // abort the running queryFn.\n abortCurrentQuery: (reason?: unknown) => {\n const controller = query.getAbortControllerByKey(queryKey);\n controller?.abort(reason);\n return Boolean(controller);\n },\n refetch: () => {\n void execute(true);\n },\n setData: (data: TData, dataUpdatedMs?: number) => {\n const now = Date.now();\n setQueryStateValue((prev) => ({\n ...prev,\n data,\n dataUpdatedMs: dataUpdatedMs ?? now,\n lastUpdatedMs: now,\n loading: false,\n }));\n },\n deleteData: () => {\n setQueryState((prev) => {\n const clone = { ...prev };\n delete clone[queryKey];\n return clone;\n });\n },\n } satisfies UseQueryResult<TData>;\n }, [\n execute,\n lastNonEmptyData,\n query,\n queryKey,\n queryStateValue,\n setQueryState,\n setQueryStateValue,\n ]);\n}\n","/**\n * @packageDocumentation\n *\n * The \"shared state\" is a React state that shares the same value across the\n * entire app. It is largely similar to the jotai library, but is much smaller\n * and much easier to work with when used for persisting in local storage or\n * indexed db, with an automatic expiration for persisted values.\n *\n * This library aims to be the shortest, simplest, easiest to use, and most\n * efficient implementation of shared global React states with seamless support\n * for client persistence.\n *\n * Example:\n * ```\n * import { sharedState, useSharedState } from \"@choksheak/react-utils/sharedState\";\n *\n * // Create a new shared state in the top level scope.\n * // The first argument is the default value, which is required.\n * // The second argument is an options object, and is optional (defaults\n * // to storing in memory only without any persistence).\n * const usersState = sharedState([], { indexedDbKey: \"users\" });\n *\n * export const UsersComponent: React.FC = () => {\n * // Get both the value and the setter.\n * const [users, setUsers] = useSharedState(usersState);\n * ...\n * };\n *\n * export const ReadOnlyComponent: React.FC = () => {\n * // Get the value only.\n * const users = useSharedStateValue(usersState);\n * ...\n * };\n *\n * export const WriteOnlyComponent: React.FC = () => {\n * // Get the setter only.\n * const setUsers = useSharedStateSetter(usersState);\n * ...\n * };\n * ```\n */\n\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\nimport { MS_PER_DAY } from \"@choksheak/ts-utils/timeConstants\";\nimport { Dispatch, SetStateAction, useSyncExternalStore } from \"react\";\n\nimport { getStorageAdapter, StorageOptions } from \"./storage\";\n\n/************************************************************************/\n/* Global config */\n/************************************************************************/\n\n/**\n * These values are configurable by the user at any time.\n */\nexport const SharedStateConfig = {\n /**\n * Default duration for storage expiration in milliseconds. Initial value\n * is 30 days, but can be changed to anything.\n */\n storeExpiryMs: 30 * MS_PER_DAY,\n\n /**\n * True to load from storage only on mount, false (default) to load in\n * the top level scope when the shared state is defined.\n */\n lazyLoad: false,\n};\n\nexport type SharedStateConfig = typeof SharedStateConfig;\n\n/** Convenience function to update global defaults. */\nexport function configureSharedState(config: Partial<SharedStateConfig>) {\n Object.assign(SharedStateConfig, config);\n}\n\n/************************************************************************/\n/* Shared state implementation */\n/************************************************************************/\n\ntype Subscriber<T> = (next: T, prev: T) => void;\n\nclass PubSubStore {\n private dataByKey = new Map<string, unknown>();\n private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();\n\n // This could be undefined if T includes undefined, else it should never\n // return undefined because the user has to always specify the default value.\n public get<T>(key: string): T {\n return this.dataByKey.get(key) as T;\n }\n\n public set<T>(key: string, value: T): void {\n const prev = this.dataByKey.get(key) as T;\n\n // Do nothing if no change.\n if (prev === value) return;\n\n this.dataByKey.set(key, value);\n this.notify(key, value, prev);\n }\n\n public delete(key: string): void {\n this.dataByKey.delete(key);\n }\n\n public setNoNotify<T>(key: string, value: T): void {\n this.dataByKey.set(key, value);\n }\n\n // Subscribe to changes of a specific key\n public subscribe<T>(key: string, subscriber: Subscriber<T>): () => void {\n let subscribers = this.subscribersByKey.get(key);\n if (!subscribers) {\n subscribers = new Set();\n this.subscribersByKey.set(key, subscribers);\n }\n\n // Type coercion: we store Subscriber<unknown>\n subscribers.add(subscriber as Subscriber<unknown>);\n\n // Return unsubscribe function\n return () => {\n subscribers.delete(subscriber as Subscriber<unknown>);\n if (subscribers.size === 0) this.subscribersByKey.delete(key);\n };\n }\n\n private notify<T>(key: string, next: T, prev: T): void {\n const subscribers = this.subscribersByKey.get(key);\n if (!subscribers) return;\n\n // Make copy to protect against mutation during iteration.\n const copy = Array.from(subscribers.values());\n for (const subscriber of copy) {\n try {\n subscriber(next, prev);\n } catch (e) {\n console.error(`Error invoking subscriber:`, e);\n }\n }\n }\n}\n\nconst pubSubStore = new PubSubStore();\n\nconst STORAGE_KEY = \"state\";\n\n/** Options to configure a shared state. */\nexport type SharedStateOptions<T> = {\n lazyLoad?: boolean;\n} & StorageOptions<T>;\n\nlet stateKey = 0;\n\n/** Please use sharedState() instead. */\nexport class SharedState<T> {\n private readonly pubSubKey: string;\n private readonly storageAdapter: StorageAdapter<T> | null;\n public readonly setValueBounded: Dispatch<SetStateAction<T>>;\n public initStarted = false;\n public initDone = false;\n\n public constructor(\n private readonly defaultValue: T,\n options?: SharedStateOptions<T>,\n ) {\n this.pubSubKey = String(stateKey++);\n\n // Use max of 1 storage adapter per shared state.\n this.storageAdapter = getStorageAdapter(\n options,\n SharedStateConfig.storeExpiryMs,\n );\n\n // Same as this.setValue, but binding the `this` reference.\n this.setValueBounded = (next: SetStateAction<T>) => {\n this.setValue(next);\n };\n\n // Always set the default value first to avoid returning undefineds if that\n // is not part of T.\n pubSubStore.setNoNotify(this.pubSubKey, defaultValue);\n\n const lazyLoad = options?.lazyLoad;\n const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;\n\n if (!lazy) {\n void this.initDefaultValueOnce();\n }\n }\n\n /** `subscribe` function required by useSyncExternalStore. */\n public subscribe(subscriber: () => void): () => void {\n return pubSubStore.subscribe(this.pubSubKey, () => subscriber());\n }\n\n /** Returns true if there is a backing store. */\n public hasStore(): boolean {\n return Boolean(this.storageAdapter);\n }\n\n /**\n * Set a value directly into the store. Does nothing if there is no\n * backing store.\n *\n * Warning: If you want to set the value properly, you should use\n * `setValue()` instead which also updates the in-memory copy.\n */\n public async setInStore(value: T): Promise<void> {\n return await this.storageAdapter?.set(STORAGE_KEY, value);\n }\n\n /**\n * Get a value directly from the store. Returns undefined if there is no\n * backing store.\n */\n public async getFromStore(): Promise<T | undefined> {\n return await this.storageAdapter?.get(STORAGE_KEY);\n }\n\n /**\n * Get the value of the shared state.\n * `getSnapshot` function — must return same ref if value unchanged.\n * Note that this would return the default value if data init from store is\n * not completed yet.\n */\n public getSnapshot(): T {\n return pubSubStore.get(this.pubSubKey);\n }\n\n /** Set the value of the shared state. */\n public setValue(next: SetStateAction<T>): void {\n if (typeof next === \"function\") {\n const prev = pubSubStore.get<T>(this.pubSubKey);\n next = (next as (p: T) => T)(prev);\n }\n\n pubSubStore.set(this.pubSubKey, next);\n void this.setInStore(next);\n }\n\n /**\n * Remove the value of the shared state. Actually this just sets the value\n * back to the given default value.\n */\n public delete() {\n pubSubStore.set(this.pubSubKey, this.defaultValue);\n void this.storageAdapter?.set(STORAGE_KEY, this.defaultValue);\n }\n\n /** Initial default handling. */\n public async initDefaultValueOnce() {\n if (this.initDone || this.initStarted) return;\n this.initStarted = true;\n\n const stored = await this.getFromStore();\n const value = stored !== undefined ? stored : this.defaultValue;\n\n pubSubStore.set(this.pubSubKey, value);\n\n this.initDone = true;\n }\n}\n\n/**\n * Create a new shared state object. Put this code at the top level scope:\n *\n * Example:\n * ```\n * const counterState = sharedState<number>(0);\n * ```\n */\nexport function sharedState<T>(\n defaultValue: T,\n options?: SharedStateOptions<T>,\n): SharedState<T> {\n return new SharedState(defaultValue, options);\n}\n\n/**\n * React hook for subscribing to a key in the global store.\n *\n * Example:\n * ```\n * const [counter, setCounter] = useSharedState(counterState);\n * ```\n */\nexport function useSharedState<T>(\n state: SharedState<T>,\n): [T, Dispatch<SetStateAction<T>>] {\n state.initDefaultValueOnce();\n\n const value = useSyncExternalStore(\n (onStoreChange) => state.subscribe(onStoreChange),\n () => state.getSnapshot(),\n // getServerSnapshot is needed to prevent SSR dev errors in nextjs.\n () => state.getSnapshot(),\n );\n\n return [value, state.setValueBounded];\n}\n\n/**\n * Same as useSharedState(), but returns the value only.\n *\n * Example:\n * ```\n * const counter = useSharedStateValue(counterState);\n * ```\n */\nexport function useSharedStateValue<T>(state: SharedState<T>) {\n return useSharedState(state)[0];\n}\n\n/**\n * Same as useSharedState(), but returns the setter only.\n *\n * Example:\n * ```\n * const setCounter = useSharedStateSetter(counterState);\n * ```\n */\nexport function useSharedStateSetter<T>(state: SharedState<T>) {\n return useSharedState(state)[1];\n}\n","import { kvStore } from \"@choksheak/ts-utils/kvStore\";\nimport { LocalStore } from \"@choksheak/ts-utils/localStore\";\nimport { StorageAdapter } from \"@choksheak/ts-utils/storageAdapter\";\n\n/** List of all available pre-built persistence options. */\nexport type PersistTo = \"localStorage\" | \"indexedDb\";\n\n/** Options to configure a pre-built store adapter. */\nexport type StoreOptions = {\n /** Choose which pre-built store adapter to use. */\n persistTo: PersistTo;\n\n /** Storage key to act as a namespace to store the data. */\n key: string;\n\n /** How long to wait before the value is considered expired. */\n expiryMs?: number;\n\n /** Returns true if the given value supposed to be of type T is valid. */\n isValid?: (u: unknown) => boolean;\n};\n\n/** Either use a pre-built store, or a custom one you provide. */\nexport type StorageOptions<T> = {\n /**\n * You can either specify the storage adapter directly to use a custom\n * store, or specify the properties to use the pre-built ones.\n */\n store?: StorageAdapter<T> | StoreOptions;\n};\n\n// In SSR (Server-Side Rendering) mode, we never want to use our storage\n// adapters for anything.\nconst IS_NOT_BROWSER = typeof window === \"undefined\";\n\nexport function getStorageAdapter<T>(\n options: StorageOptions<T> | undefined,\n defaultStoreExpiryMs: number,\n): StorageAdapter<T> | null {\n const { store } = options ?? {};\n\n if (!store) {\n return null;\n }\n\n if (\n \"persistTo\" in store &&\n typeof store.persistTo === \"string\" &&\n \"key\" in store &&\n typeof store.key === \"string\"\n ) {\n const expiryMs = store.expiryMs || defaultStoreExpiryMs;\n const isValid = store.isValid;\n\n switch (store.persistTo) {\n case \"localStorage\": {\n const adapter = new LocalStore(store.key, {\n defaultExpiryMs: expiryMs,\n });\n\n // Handle SSR.\n return {\n set: (key: string, value: T): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n adapter.set(key, value);\n },\n get: (key: string): T | undefined => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const value = adapter.get<T>(key);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `localStorage adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n adapter.delete(key);\n return undefined;\n }\n\n return value;\n },\n delete: (key: string): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.delete(key);\n },\n clear: (): void => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n adapter.clear();\n },\n };\n }\n\n case \"indexedDb\": {\n // Use the key prefix as a unique namespace for this store.\n const keyPrefix = store.key + \":\";\n const isValid = store.isValid;\n\n // Prefix all keys using `keyPrefix` so that we can keep all data in\n // the same store in the same DB. If we keep the data in different\n // DB stores, then GC will not be able to clean up abandoned stores.\n return {\n set: async (key: string, value: T): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n // Also apply the custom expiryMs on every item in this store.\n await kvStore.set(keyPrefix + key, value, expiryMs);\n },\n get: async (key: string): Promise<T | undefined> => {\n if (IS_NOT_BROWSER) return undefined; // handle SSR\n\n const fullKey = keyPrefix + key;\n const value = await kvStore.get<T>(fullKey);\n\n if (isValid && !isValid(value)) {\n console.warn(\n `indexedDb adapter: Auto-discard invalid value for ${key}: ${JSON.stringify(value)}`,\n );\n await kvStore.delete(fullKey);\n return undefined;\n }\n\n return value;\n },\n delete: async (key: string): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.delete(keyPrefix + key);\n },\n clear: async (): Promise<void> => {\n if (IS_NOT_BROWSER) return; // handle SSR\n\n await kvStore.clear();\n },\n };\n }\n\n default:\n store.persistTo satisfies never;\n throw new Error(`Unknown store.adapter ${store.persistTo}`);\n }\n }\n\n return store as StorageAdapter<T>;\n}\n","/**\n * JSON stringify a value of any type, and returns an unsafe string. The string\n * is \"unsafe\" because you might not be able to restore the original object\n * using the serialized JSON. This is specifically used for query key generation\n * and is not suitable for more general uses.\n *\n * Note: The given value should not contain any functions because functions\n * cannot be serialized and deserialized properly.\n */\nexport function stringifyDeterministicForKeys(params: unknown): string {\n const cache = new Set();\n\n return JSON.stringify(params, (_, value) => {\n if (typeof value === \"function\") {\n throw new Error(`Functions should not be passed into query parameters`);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n return value;\n }\n\n if (cache.has(value)) {\n // Circular reference detected! Return a placeholder string.\n return \"[Circular]\";\n }\n\n cache.add(value);\n\n // Don't crash on circular references, but don't mark the top level object\n // as circular because we want to return as much string as possible.\n if (value !== params) {\n try {\n JSON.stringify(value);\n } catch {\n return \"[Unserializable]\";\n }\n }\n\n return Object.keys(value)\n .sort()\n .reduce(\n (result: Record<string, unknown>, key: string) => {\n result[key] = value[key];\n return result;\n },\n {} as Record<string, unknown>,\n );\n });\n}\n","import isEqual from \"lodash/isEqual\";\nimport { useMemo, useState } from \"react\";\n\n/**\n * Provides a stable value for T based on deep equality. The deep equality is\n * determined by the `isEqual()` function in lodash.\n */\nexport function useDeepMemo<T>(value: T): T {\n const [state, setState] = useState<T>(value);\n\n return useMemo(() => {\n if (state === value || isEqual(state, value)) {\n return state;\n }\n\n // We check equality, so this should be safe.\n // eslint-disable-next-line react-hooks/set-state-in-render\n setState(value);\n\n return value;\n }, [state, value]);\n}\n"],"mappings":"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","names":["MS_PER_DAY","getByteSize","useCallback","useEffect","useMemo","useRef","useState","MS_PER_DAY","useSyncExternalStore","kvStore","LocalStore","IS_NOT_BROWSER","getStorageAdapter","options","defaultStoreExpiryMs","store","expiryMs","isValid","adapter","key","value","keyPrefix","fullKey","SharedStateConfig","MS_PER_DAY","PubSubStore","key","value","prev","subscriber","subscribers","next","copy","e","pubSubStore","STORAGE_KEY","stateKey","SharedState","defaultValue","options","getStorageAdapter","SharedStateConfig","lazyLoad","stored","sharedState","useSharedState","state","useSyncExternalStore","onStoreChange","stringifyDeterministicForKeys","params","cache","_","value","result","key","isEqual","useMemo","useState","useDeepMemo","value","state","setState","SharedQueryConfig","MS_PER_DAY","args","configureSharedQuery","config","SharedQuery","options","persistTo","sharedState","stringifyDeterministicForKeys","queryKey","dataUpdatedMs","cached","data","now","queryType","inflightQuery","promise","abortController","e","stateValue","entry","prev","key","clone","inflight","count","oldState","oldSize","oldByteSize","getByteSize","numToCut","bytesToCut","newState","needUpdate","deleteKeyIfNotMounted","expired","byteSize","entriesByTimeAscending","entry1","entry2","seenQueryNames","sharedQuery","DEFAULT_QUERY_STATE_ENTRY","useSharedQuery","query","stableArgs","useDeepMemo","queryState","setQueryState","useSharedState","isMounted","useRef","useMemo","queryStateValue","setQueryStateValue","useCallback","next","lastNonEmptyData","setLastNonEmptyData","useState","useEffect","execute","isRefetch","state","reason","controller"]}
package/sharedQuery.mjs CHANGED
@@ -332,6 +332,7 @@ var SharedQuery = class {
332
332
  mountedKeys = /* @__PURE__ */ new Map();
333
333
  queryName;
334
334
  queryFn;
335
+ refetchFn;
335
336
  expiryMs;
336
337
  staleMs;
337
338
  refetchOnStale;
@@ -342,6 +343,7 @@ var SharedQuery = class {
342
343
  constructor(options) {
343
344
  this.queryName = options.queryName;
344
345
  this.queryFn = options.queryFn;
346
+ this.refetchFn = options.refetchFn;
345
347
  this.staleMs = options?.staleMs ?? SharedQueryConfig.staleMs;
346
348
  this.expiryMs = options?.expiryMs ?? SharedQueryConfig.expiryMs;
347
349
  this.refetchOnStale = options?.refetchOnStale ?? SharedQueryConfig.refetchOnStale;
@@ -425,43 +427,54 @@ var SharedQuery = class {
425
427
  const ageMs = Date.now() - dataUpdatedMs;
426
428
  return ageMs > this.expiryMs;
427
429
  }
430
+ /** For cached data, we need to return the correct timestamps as well. */
428
431
  async getCachedOrFetch(...args) {
429
432
  const queryKey = this.getQueryKey(args);
430
433
  const cached = this.queryState.getSnapshot()?.[queryKey];
431
434
  if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
432
435
  if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
433
436
  this.log(`Return fresh data ${queryKey} from cache without fetching`);
434
- return cached.data;
437
+ return cached;
435
438
  }
436
439
  if (this.refetchOnStale) {
437
440
  this.log(`refetchOnStale for ${queryKey}`);
438
- void this.dedupedFetch(queryKey, ...args);
441
+ void this.dedupedFetch(queryKey, "query", ...args);
439
442
  }
440
443
  this.log(`Returning stale data for ${queryKey}`);
441
- return cached.data;
444
+ return cached;
442
445
  }
443
- return await this.dedupedFetch(queryKey, ...args);
446
+ const data = await this.dedupedFetch(queryKey, "query", ...args);
447
+ const now = Date.now();
448
+ return {
449
+ // Don't keep old error.
450
+ lastUpdatedMs: now,
451
+ loading: false,
452
+ data,
453
+ dataUpdatedMs: now
454
+ };
444
455
  }
445
- dedupedFetch(queryKey, ...args) {
456
+ dedupedFetch(queryKey, queryType, ...args) {
446
457
  const inflightQuery = this.inflightQueries.get(queryKey);
447
458
  if (inflightQuery) {
448
- this.log(`Deduplicating inflight fetch for ${queryKey}`);
459
+ this.log(
460
+ `Deduplicating inflight fetch for ${queryKey}, queryType=${queryType}`
461
+ );
449
462
  return inflightQuery.promise;
450
463
  }
451
- const promise = this.startFetching(queryKey, args);
464
+ const promise = this.startFetching(queryKey, queryType, args);
452
465
  const abortController = new AbortController();
453
466
  this.inflightQueries.set(queryKey, { promise, abortController });
454
467
  return promise;
455
468
  }
456
- async startFetching(queryKey, args) {
457
- this.log(`Start fetching ${queryKey}`);
469
+ async startFetching(queryKey, queryType, args) {
470
+ this.log(`Start fetching ${queryKey}, queryType=${queryType}`);
458
471
  try {
459
- const data = await this.queryFn(...args);
472
+ const data = await (queryType === "refetch" && this.refetchFn ? this.refetchFn(...args) : this.queryFn(...args));
460
473
  this.log(`Successfully fetched ${queryKey}`);
461
474
  this.setData(queryKey, data, Date.now());
462
475
  return data;
463
476
  } catch (e) {
464
- this.log(`Failed to fetch ${queryKey}: ${e}`);
477
+ this.log(`Failed to fetch ${queryKey} for queryType=${queryType}: ${e}`);
465
478
  throw e;
466
479
  } finally {
467
480
  this.inflightQueries.delete(queryKey);
@@ -499,7 +512,15 @@ var SharedQuery = class {
499
512
  */
500
513
  async updateFromSource(...args) {
501
514
  const key = this.getQueryKey(args);
502
- return await this.dedupedFetch(key, ...args);
515
+ return await this.dedupedFetch(key, "query", ...args);
516
+ }
517
+ /**
518
+ * Perform an explicit refetch. If `options.refetchFn` was not specified,
519
+ * then this would be identical to calling `this.updateFromSource()`.
520
+ */
521
+ async refetch(...args) {
522
+ const key = this.getQueryKey(args);
523
+ return await this.dedupedFetch(key, "refetch", ...args);
503
524
  }
504
525
  /** Delete the cached data for one set of arguments. */
505
526
  deleteData(queryKey) {
@@ -649,8 +670,11 @@ function useSharedQuery(query, ...args) {
649
670
  }
650
671
  }, [query.keepLastNonEmptyData, queryStateValue.data]);
651
672
  const execute = useCallback(
652
- async (forceRefresh) => {
653
- query.log(`Begin executing shared query ${queryKey}`);
673
+ async (isRefetch) => {
674
+ query.log(
675
+ `Begin executing shared query ${queryKey}, isRefetch=${isRefetch}`
676
+ );
677
+ if (!isMounted.current) return;
654
678
  setQueryStateValue((prev) => ({
655
679
  // Keep old data and error.
656
680
  ...prev,
@@ -658,29 +682,33 @@ function useSharedQuery(query, ...args) {
658
682
  loading: true
659
683
  }));
660
684
  try {
661
- const data = await (forceRefresh ? query.updateFromSource(...stableArgs) : query.getCachedOrFetch(...stableArgs));
662
- if (isMounted.current) {
685
+ let state;
686
+ if (isRefetch) {
687
+ const data = await query.refetch(...stableArgs);
663
688
  const now = Date.now();
664
- setQueryStateValue({
689
+ state = {
665
690
  // Don't keep old error.
666
691
  lastUpdatedMs: now,
667
692
  loading: false,
668
693
  data,
669
694
  dataUpdatedMs: now
670
- });
695
+ };
696
+ } else {
697
+ state = await query.getCachedOrFetch(...stableArgs);
671
698
  }
699
+ if (!isMounted.current) return;
700
+ setQueryStateValue(state);
672
701
  } catch (e) {
673
- if (isMounted.current) {
674
- const now = Date.now();
675
- setQueryStateValue((prev) => ({
676
- // Keep old data.
677
- ...prev,
678
- lastUpdatedMs: now,
679
- loading: false,
680
- error: e,
681
- errorUpdatedMs: now
682
- }));
683
- }
702
+ if (!isMounted.current) return;
703
+ const now = Date.now();
704
+ setQueryStateValue((prev) => ({
705
+ // Keep old data.
706
+ ...prev,
707
+ lastUpdatedMs: now,
708
+ loading: false,
709
+ error: e,
710
+ errorUpdatedMs: now
711
+ }));
684
712
  }
685
713
  },
686
714
  [query, queryKey, setQueryStateValue, stableArgs]