@choksheak/react-utils 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/docs/assets/navigation.js +1 -1
  2. package/docs/assets/search.js +1 -1
  3. package/docs/classes/sharedQuery.SharedQuery.html +10 -8
  4. package/docs/classes/sharedState.SharedState.html +2 -2
  5. package/docs/classes/utils_storage.InMemoryStorageAdapter.html +2 -2
  6. package/docs/classes/utils_storage.IndexedDbAdapter.html +2 -2
  7. package/docs/classes/utils_storage.LocalStorageAdapter.html +2 -2
  8. package/docs/functions/sharedQuery.sharedQuery.html +2 -2
  9. package/docs/functions/sharedQuery.useSharedQuery.html +4 -0
  10. package/docs/functions/sharedState.sharedState.html +1 -1
  11. package/docs/functions/sharedState.useSharedState.html +1 -1
  12. package/docs/functions/sharedState.useSharedStateSetter.html +1 -1
  13. package/docs/functions/sharedState.useSharedStateValue.html +1 -1
  14. package/docs/functions/utils_storage.getStorageAdapter.html +1 -1
  15. package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +1 -1
  16. package/docs/modules/sharedQuery.html +1 -1
  17. package/docs/modules/sharedState.html +1 -1
  18. package/docs/types/sharedQuery.FetchFn.html +1 -1
  19. package/docs/types/sharedQuery.PersistTo.html +1 -0
  20. package/docs/types/sharedQuery.QueryStateValue.html +5 -3
  21. package/docs/types/sharedQuery.SharedQueryOptions.html +1 -1
  22. package/docs/types/sharedQuery.SharedQueryState.html +2 -2
  23. package/docs/types/sharedQuery.UseQueryResult.html +1 -0
  24. package/docs/types/sharedState.SharedStateOptions.html +1 -1
  25. package/docs/types/utils_storage.StorageAdapter.html +2 -2
  26. package/docs/types/utils_storage.StorageOptions.html +2 -2
  27. package/docs/variables/fetcher.fetcher.html +1 -1
  28. package/docs/variables/fetcher.fetcherWithRetries.html +1 -1
  29. package/docs/variables/sharedQuery.SharedQueryDefaults.html +2 -0
  30. package/docs/variables/sharedState.SharedStateDefaults.html +6 -0
  31. package/package.json +1 -1
  32. package/sharedQuery.d.mts +48 -33
  33. package/sharedQuery.d.ts +46 -32
  34. package/sharedQuery.js +121 -96
  35. package/sharedQuery.mjs +120 -96
  36. package/sharedState.d.mts +5 -5
  37. package/sharedState.d.ts +4 -4
  38. package/sharedState.js +10 -8
  39. package/sharedState.mjs +9 -7
  40. package/src/sharedQuery.ts +181 -131
  41. package/src/sharedState.ts +10 -7
  42. package/src/utils/stringify.test.ts +8 -0
  43. package/src/utils/stringify.ts +1 -1
  44. package/utils/stringify.js +1 -1
  45. package/utils/stringify.mjs +1 -1
  46. package/utils/stringify.test.js +6 -1
  47. package/utils/stringify.test.mjs +6 -1
  48. package/docs/classes/sharedState.SharedStateConfig.html +0 -9
  49. package/docs/functions/sharedQuery.useQuery.html +0 -4
  50. package/docs/types/sharedQuery.FetchedData.html +0 -3
package/sharedState.d.mts CHANGED
@@ -15,18 +15,18 @@ import { StorageOptions } from './utils/storage.mjs';
15
15
  /**
16
16
  * These values are configurable by the user at any time.
17
17
  */
18
- declare class SharedStateConfig {
18
+ declare const SharedStateDefaults: {
19
19
  /**
20
20
  * Default duration for storage expiration in milliseconds. Initial value
21
21
  * is 30 days, but can be changed to anything.
22
22
  */
23
- static storeExpiryMs: number;
23
+ storeExpiryMs: number;
24
24
  /**
25
25
  * True to load from storage only on mount, false (default) to load in
26
26
  * the top level scope when the shared state is defined.
27
27
  */
28
- static lazyLoad: boolean;
29
- }
28
+ lazyLoad: boolean;
29
+ };
30
30
  type SharedStateOptions<T> = {
31
31
  lazyLoad?: boolean;
32
32
  } & StorageOptions<T>;
@@ -59,4 +59,4 @@ declare function useSharedStateValue<T>(state: SharedState<T>): T;
59
59
  /** E.g. const setMy = useSharedStateSetter(myState) */
60
60
  declare function useSharedStateSetter<T>(state: SharedState<T>): (next: T | ((prev: T) => T)) => void;
61
61
 
62
- export { SharedState, SharedStateConfig, type SharedStateOptions, sharedState, useSharedState, useSharedStateSetter, useSharedStateValue };
62
+ export { SharedState, SharedStateDefaults, type SharedStateOptions, sharedState, useSharedState, useSharedStateSetter, useSharedStateValue };
package/sharedState.d.ts CHANGED
@@ -14,18 +14,18 @@ import { StorageOptions } from "./utils/storage";
14
14
  /**
15
15
  * These values are configurable by the user at any time.
16
16
  */
17
- export declare class SharedStateConfig {
17
+ export declare const SharedStateDefaults: {
18
18
  /**
19
19
  * Default duration for storage expiration in milliseconds. Initial value
20
20
  * is 30 days, but can be changed to anything.
21
21
  */
22
- static storeExpiryMs: number;
22
+ storeExpiryMs: number;
23
23
  /**
24
24
  * True to load from storage only on mount, false (default) to load in
25
25
  * the top level scope when the shared state is defined.
26
26
  */
27
- static lazyLoad: boolean;
28
- }
27
+ lazyLoad: boolean;
28
+ };
29
29
  export type SharedStateOptions<T> = {
30
30
  lazyLoad?: boolean;
31
31
  } & StorageOptions<T>;
package/sharedState.js CHANGED
@@ -21,14 +21,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var sharedState_exports = {};
22
22
  __export(sharedState_exports, {
23
23
  SharedState: () => SharedState,
24
- SharedStateConfig: () => SharedStateConfig,
24
+ SharedStateDefaults: () => SharedStateDefaults,
25
25
  sharedState: () => sharedState,
26
26
  useSharedState: () => useSharedState,
27
27
  useSharedStateSetter: () => useSharedStateSetter,
28
28
  useSharedStateValue: () => useSharedStateValue
29
29
  });
30
30
  module.exports = __toCommonJS(sharedState_exports);
31
- var import_kvStore2 = require("@choksheak/ts-utils/kvStore");
32
31
  var import_timeConstants = require("@choksheak/ts-utils/timeConstants");
33
32
  var import_react = require("react");
34
33
 
@@ -133,17 +132,17 @@ var IndexedDbAdapter = class {
133
132
  };
134
133
 
135
134
  // src/sharedState.ts
136
- var SharedStateConfig = class {
135
+ var SharedStateDefaults = {
137
136
  /**
138
137
  * Default duration for storage expiration in milliseconds. Initial value
139
138
  * is 30 days, but can be changed to anything.
140
139
  */
141
- static storeExpiryMs = import_timeConstants.MS_PER_DAY * 30;
140
+ storeExpiryMs: 30 * import_timeConstants.MS_PER_DAY,
142
141
  /**
143
142
  * True to load from storage only on mount, false (default) to load in
144
143
  * the top level scope when the shared state is defined.
145
144
  */
146
- static lazyLoad = false;
145
+ lazyLoad: false
147
146
  };
148
147
  var PubSubStore = class {
149
148
  dataByKey = /* @__PURE__ */ new Map();
@@ -198,11 +197,14 @@ var SharedState = class {
198
197
  constructor(defaultValue, options) {
199
198
  this.defaultValue = defaultValue;
200
199
  this.pubSubKey = String(stateKey++);
201
- this.storageAdapter = getStorageAdapter(options, import_kvStore2.DEFAULT_EXPIRY_DELTA_MS);
200
+ this.storageAdapter = getStorageAdapter(
201
+ options,
202
+ SharedStateDefaults.storeExpiryMs
203
+ );
202
204
  this.setValueBounded = (arg) => this.setValue(arg);
203
205
  pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
204
206
  const lazyLoad = options?.lazyLoad;
205
- const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateConfig.lazyLoad;
207
+ const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateDefaults.lazyLoad;
206
208
  if (!lazy) {
207
209
  void this.initDefaultValueOnce();
208
210
  }
@@ -265,7 +267,7 @@ function useSharedStateSetter(state) {
265
267
  // Annotate the CommonJS export names for ESM import in node:
266
268
  0 && (module.exports = {
267
269
  SharedState,
268
- SharedStateConfig,
270
+ SharedStateDefaults,
269
271
  sharedState,
270
272
  useSharedState,
271
273
  useSharedStateSetter,
package/sharedState.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  // src/sharedState.ts
2
- import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
3
2
  import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
4
3
  import { useSyncExternalStore } from "react";
5
4
 
@@ -104,17 +103,17 @@ var IndexedDbAdapter = class {
104
103
  };
105
104
 
106
105
  // src/sharedState.ts
107
- var SharedStateConfig = class {
106
+ var SharedStateDefaults = {
108
107
  /**
109
108
  * Default duration for storage expiration in milliseconds. Initial value
110
109
  * is 30 days, but can be changed to anything.
111
110
  */
112
- static storeExpiryMs = MS_PER_DAY * 30;
111
+ storeExpiryMs: 30 * MS_PER_DAY,
113
112
  /**
114
113
  * True to load from storage only on mount, false (default) to load in
115
114
  * the top level scope when the shared state is defined.
116
115
  */
117
- static lazyLoad = false;
116
+ lazyLoad: false
118
117
  };
119
118
  var PubSubStore = class {
120
119
  dataByKey = /* @__PURE__ */ new Map();
@@ -169,11 +168,14 @@ var SharedState = class {
169
168
  constructor(defaultValue, options) {
170
169
  this.defaultValue = defaultValue;
171
170
  this.pubSubKey = String(stateKey++);
172
- this.storageAdapter = getStorageAdapter(options, DEFAULT_EXPIRY_DELTA_MS);
171
+ this.storageAdapter = getStorageAdapter(
172
+ options,
173
+ SharedStateDefaults.storeExpiryMs
174
+ );
173
175
  this.setValueBounded = (arg) => this.setValue(arg);
174
176
  pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
175
177
  const lazyLoad = options?.lazyLoad;
176
- const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateConfig.lazyLoad;
178
+ const lazy = lazyLoad !== void 0 ? lazyLoad : SharedStateDefaults.lazyLoad;
177
179
  if (!lazy) {
178
180
  void this.initDefaultValueOnce();
179
181
  }
@@ -235,7 +237,7 @@ function useSharedStateSetter(state) {
235
237
  }
236
238
  export {
237
239
  SharedState,
238
- SharedStateConfig,
240
+ SharedStateDefaults,
239
241
  sharedState,
240
242
  useSharedState,
241
243
  useSharedStateSetter,
@@ -1,68 +1,107 @@
1
- import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
2
- import { useCallback, useEffect, useRef } from "react";
3
-
4
- import { SharedState, sharedState, useSharedState } from "./sharedState";
5
- import { StorageOptions } from "./utils/storage";
1
+ import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
2
+ import { useCallback, useEffect, useMemo, useRef } from "react";
3
+
4
+ import {
5
+ SharedState,
6
+ sharedState,
7
+ SharedStateOptions,
8
+ useSharedState,
9
+ } from "./sharedState";
6
10
  import { stringifyDeterministicForKeys } from "./utils/stringify";
7
11
 
8
- export type FetchFn<TArgs extends unknown[], TResult> = (
12
+ export type FetchFn<TArgs extends unknown[], TData> = (
9
13
  ...args: TArgs
10
- ) => Promise<TResult>;
14
+ ) => Promise<TData> | TData;
11
15
 
12
- export type FetchedData<TResult> = {
13
- value: TResult;
14
- updatedMs: number;
16
+ export type QueryStateValue<TData> = {
17
+ loading: boolean;
18
+ data?: TData;
19
+ dataUpdatedMs?: number;
20
+ error?: Error;
21
+ errorUpdatedMs?: number;
15
22
  };
16
23
 
17
- export type QueryStateValue<TResult> = {
18
- data: FetchedData<TResult> | null;
19
- loading: boolean;
20
- error: Error | null;
24
+ export type UseQueryResult<TData> = QueryStateValue<TData> & {
25
+ refetch: () => Promise<TData>;
26
+ setData: (data: TData, dataUpdatedMs?: number) => void;
27
+ deleteData: () => void;
21
28
  };
22
29
 
30
+ export type PersistTo = "localStorage" | "indexedDb";
31
+
23
32
  /**
24
- * The full query state is a record of all the fetch results mapped by
33
+ * The full query state is a record of all the fetch states mapped by
25
34
  * query key to the state entry.
26
35
  */
27
- export type SharedQueryState<TResult> = Record<
28
- string,
29
- QueryStateValue<TResult>
30
- >;
36
+ export type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;
37
+
38
+ export type SharedQueryOptions<TArgs extends unknown[], TData> = {
39
+ // The name could have been auto-generated, but we let the user give us a
40
+ // human-readable name that can be identified quickly in the logs.
41
+ queryName: string;
42
+
43
+ // Function to fetch data.
44
+ queryFn: FetchFn<TArgs, TData>;
31
45
 
32
- export type SharedQueryOptions<TResult> = {
33
46
  // ms before data considered stale; 0 means never stale.
34
47
  staleMs?: number;
48
+
35
49
  // ms before data is removed from cache; 0 means never expire.
36
50
  expiryMs?: number;
51
+
37
52
  // Trigger background re-fetch if stale.
38
- revalidateOnStale?: boolean;
39
- } & StorageOptions<SharedQueryState<TResult>>;
53
+ refetchOnStale?: boolean;
54
+
55
+ // Shortcut to set the localStorageKey or indexedDbKey automatically from
56
+ // queryName. This is important because it avoids requiring the user to
57
+ // specify the same queryName twice everytime.
58
+ persistTo?: PersistTo;
59
+ } & SharedStateOptions<SharedQueryState<TData>>;
60
+
61
+ /** Users can override these values globally. */
62
+ export const SharedQueryDefaults = {
63
+ staleMs: 0,
64
+ expiryMs: 30 * MS_PER_DAY,
65
+ persistTo: undefined as PersistTo | undefined,
66
+ };
40
67
 
41
- const DEFAULT_STALE_MS = 0;
68
+ export class SharedQuery<TArgs extends unknown[], TData> {
69
+ private readonly inflightPromises = new Map<string, Promise<TData>>();
70
+ public readonly queryState: SharedState<SharedQueryState<TData>>;
42
71
 
43
- export class SharedQuery<TArgs extends unknown[], TResult> {
44
- private readonly inflightPromises = new Map<string, Promise<TResult>>();
45
- public readonly queryState: SharedState<SharedQueryState<TResult>>;
72
+ public readonly queryName: string;
73
+ private readonly queryFn: FetchFn<TArgs, TData>;
46
74
  public readonly expiryMs: number;
47
75
  public readonly staleMs: number;
48
- public readonly revalidateOnStale: boolean;
76
+ public readonly refetchOnStale: boolean;
77
+
78
+ public constructor(options: SharedQueryOptions<TArgs, TData>) {
79
+ this.queryName = options.queryName;
80
+
81
+ this.queryFn = options.queryFn;
49
82
 
50
- public constructor(
51
- public readonly queryName: string,
52
- private readonly fetchFn: FetchFn<TArgs, TResult>,
53
- options?: SharedQueryOptions<TResult>,
54
- ) {
55
83
  this.expiryMs =
56
84
  options?.expiryMs !== undefined
57
85
  ? options?.expiryMs
58
- : DEFAULT_EXPIRY_DELTA_MS;
86
+ : SharedQueryDefaults.expiryMs;
59
87
 
60
88
  this.staleMs =
61
- options?.staleMs !== undefined ? options?.staleMs : DEFAULT_STALE_MS;
89
+ options?.staleMs !== undefined
90
+ ? options?.staleMs
91
+ : SharedQueryDefaults.staleMs;
92
+
93
+ this.refetchOnStale = Boolean(options?.refetchOnStale);
62
94
 
63
- this.revalidateOnStale = Boolean(options?.revalidateOnStale);
95
+ // Apply shortcut when using `persistTo`.
96
+ const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
97
+
98
+ if (persistTo === "localStorage") {
99
+ options.localStorageKey = options.queryName;
100
+ } else if (persistTo === "indexedDb") {
101
+ options.indexedDbKey = options.queryName;
102
+ }
64
103
 
65
- this.queryState = sharedState<SharedQueryState<TResult>>(
104
+ this.queryState = sharedState<SharedQueryState<TData>>(
66
105
  {},
67
106
  {
68
107
  ...options,
@@ -76,21 +115,48 @@ export class SharedQuery<TArgs extends unknown[], TResult> {
76
115
  return this.queryName + ":" + stringifyDeterministicForKeys(args);
77
116
  }
78
117
 
79
- private isStale(entry: FetchedData<TResult>): boolean {
118
+ private isStale(dataUpdatedMs: number): boolean {
80
119
  if (this.staleMs === 0) return true; // always stale
81
120
 
82
- const ageMs = Date.now() - entry.updatedMs;
121
+ const ageMs = Date.now() - dataUpdatedMs;
83
122
  return ageMs > this.staleMs;
84
123
  }
85
124
 
86
- private isExpired(entry: FetchedData<TResult>): boolean {
125
+ private isExpired(dataUpdatedMs: number): boolean {
87
126
  if (this.expiryMs === 0) return false; // never expire
88
127
 
89
- const ageMs = Date.now() - entry.updatedMs;
128
+ const ageMs = Date.now() - dataUpdatedMs;
90
129
  return ageMs > this.expiryMs;
91
130
  }
92
131
 
93
- private fetchNoCaching(queryKey: string, ...args: TArgs): Promise<TResult> {
132
+ public async getCachedOrFetch(...args: TArgs): Promise<TData> {
133
+ const queryKey = this.getQueryKey(args);
134
+ const cached = this.queryState.getSnapshot()?.[queryKey];
135
+
136
+ if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
137
+ // Return cached value if not stale.
138
+ if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
139
+ console.log(
140
+ `Return fresh data ${queryKey} from cache without fetching`,
141
+ );
142
+ return cached.data;
143
+ }
144
+
145
+ // If stale, optionally update the data in the background.
146
+ if (this.refetchOnStale) {
147
+ console.log(`refetchOnStale for ${queryKey}`);
148
+ void this.dedupedFetch(queryKey, ...args);
149
+ }
150
+
151
+ // Still return stale data immediately.
152
+ console.log(`Returning stale data for ${queryKey}`);
153
+ return cached.data;
154
+ }
155
+
156
+ return await this.dedupedFetch(queryKey, ...args);
157
+ }
158
+
159
+ private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {
94
160
  const inflightPromise = this.inflightPromises.get(queryKey);
95
161
 
96
162
  // De-duplicate in-flight requests
@@ -100,87 +166,64 @@ export class SharedQuery<TArgs extends unknown[], TResult> {
100
166
  }
101
167
 
102
168
  // Fetch new data
103
- console.log(`Start fetching ${queryKey}`);
104
-
105
- const promise = this.fetchFn(...args)
106
- .then((result) => {
107
- console.log(`Successfully fetched ${queryKey}`);
108
- this.setCache(queryKey, result);
109
- return result;
110
- })
111
- .catch((e) => {
112
- console.error(`Failed to fetch ${queryKey}: ${e}`);
113
- throw e;
114
- })
115
- .finally(() => {
116
- this.inflightPromises.delete(queryKey);
117
- });
169
+ const promise = this.startFetching(queryKey, args);
118
170
 
119
171
  this.inflightPromises.set(queryKey, promise);
120
172
  return promise;
121
173
  }
122
174
 
123
- public async getCachedOrFetch(...args: TArgs): Promise<TResult> {
124
- const queryKey = this.getQueryKey(args);
125
- const cached = this.queryState.getSnapshot()?.[queryKey];
126
-
127
- if (cached?.data && !this.isExpired(cached.data)) {
128
- // Return cached value if not stale.
129
- if (!this.isStale(cached.data)) {
130
- console.log(
131
- `Return fresh data ${queryKey} from cache without fetching`,
132
- );
133
- return cached.data.value;
134
- }
175
+ private async startFetching(queryKey: string, args: TArgs) {
176
+ console.log(`Start fetching ${queryKey}`);
135
177
 
136
- // If stale, optionally update the data in the background.
137
- if (this.revalidateOnStale) {
138
- console.log(`revalidateOnStale for ${queryKey}`);
139
- void this.fetchNoCaching(queryKey, ...args);
140
- }
178
+ try {
179
+ const data = await this.queryFn(...args);
141
180
 
142
- // Still return stale data immediately.
143
- console.log(`Returning stale data for ${queryKey}`);
144
- return cached.data.value;
181
+ console.log(`Successfully fetched ${queryKey}`);
182
+ this.setData(queryKey, data, Date.now());
183
+ return data;
184
+ } catch (e) {
185
+ console.error(`Failed to fetch ${queryKey}: ${e}`);
186
+ throw e;
187
+ } finally {
188
+ this.inflightPromises.delete(queryKey);
145
189
  }
146
-
147
- return await this.fetchNoCaching(queryKey, ...args);
148
190
  }
149
191
 
150
- private async setCache(queryKey: string, value: TResult): Promise<void> {
151
- const entry: QueryStateValue<TResult> = {
152
- data: { value, updatedMs: Date.now() },
192
+ /** Set the data directly if the user obtained it from somewhere else. */
193
+ public async setData(
194
+ queryKey: string,
195
+ data: TData,
196
+ dataUpdatedMs = Date.now(),
197
+ ): Promise<void> {
198
+ const entry: QueryStateValue<TData> = {
153
199
  loading: false,
154
- error: null,
200
+ data,
201
+ dataUpdatedMs,
155
202
  };
156
203
 
157
204
  this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
158
205
  }
159
206
 
160
207
  /** Do a new fetch even when the data is already cached. */
161
- public async updateFromSource(...args: TArgs): Promise<TResult> {
208
+ public async updateFromSource(...args: TArgs): Promise<TData> {
162
209
  const key = this.getQueryKey(args);
163
- return await this.fetchNoCaching(key, ...args);
210
+ return await this.dedupedFetch(key, ...args);
164
211
  }
165
212
 
166
- /** Delete the currently cached data. */
167
- public invalidate(args?: TArgs): void {
168
- if (args) {
169
- const queryKey = this.getQueryKey(args);
213
+ /** Delete the cached data for one set of arguments. */
214
+ public deleteData(args: TArgs): void {
215
+ const queryKey = this.getQueryKey(args);
170
216
 
171
- const record = this.queryState.getSnapshot();
172
- const clone = { ...record };
173
- delete clone[queryKey];
174
- this.queryState.setValue(clone);
217
+ const record = this.queryState.getSnapshot();
218
+ const clone = { ...record };
219
+ delete clone[queryKey];
220
+ this.queryState.setValue(clone);
175
221
 
176
- this.inflightPromises.delete(queryKey);
177
- } else {
178
- this.clearAll();
179
- }
222
+ this.inflightPromises.delete(queryKey);
180
223
  }
181
224
 
182
- /** Delete the currently cached data & all inflight promises. */
183
- public clearAll(): void {
225
+ /** Delete all currently cached data & all inflight promises. */
226
+ public clear(): void {
184
227
  this.queryState.delete();
185
228
  this.inflightPromises.clear();
186
229
  }
@@ -195,36 +238,31 @@ const seenQueryNames = new Set<string>();
195
238
  * Each `queryName` must be unique, and can only be declared once, most often
196
239
  * in the top level scope. The queryName is used for logging only.
197
240
  */
198
- export function sharedQuery<TArgs extends unknown[], TResult>(
241
+ export function sharedQuery<TArgs extends unknown[], TData>(
199
242
  // The name could have been auto-generated, but we let the user give us a
200
243
  // human-readable name that can be identified quickly in the logs.
201
- queryName: string,
202
- fetchFn: FetchFn<TArgs, TResult>,
203
- options?: SharedQueryOptions<TResult>,
204
- ): SharedQuery<TArgs, TResult> {
205
- if (seenQueryNames.has(queryName)) {
206
- throw new Error(`Duplicate shared query "${queryName}"`);
244
+ options: SharedQueryOptions<TArgs, TData>,
245
+ ): SharedQuery<TArgs, TData> {
246
+ if (seenQueryNames.has(options.queryName)) {
247
+ throw new Error(`Duplicate shared query "${options.queryName}"`);
207
248
  }
208
- seenQueryNames.add(queryName);
249
+ seenQueryNames.add(options.queryName);
209
250
 
210
- return new SharedQuery(queryName, fetchFn, options);
251
+ return new SharedQuery(options);
211
252
  }
212
253
 
213
- const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
214
- data: null,
215
- loading: true,
216
- error: null,
217
- };
254
+ const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = { loading: true };
218
255
 
219
256
  /**
220
257
  * React hook to make use of a shared query inside any React component.
221
258
  *
222
- * const users = useQuery(usersQuery, { userId: "123" });
259
+ * const users = useSharedQuery(usersQuery, ["123"]);
223
260
  */
224
- export function useQuery<TArgs extends unknown[], TResult>(
225
- query: SharedQuery<TArgs, TResult>,
226
- args: TArgs,
227
- ): QueryStateValue<TResult> {
261
+ export function useSharedQuery<TArgs extends unknown[], TData>(
262
+ query: SharedQuery<TArgs, TData>,
263
+ // Default to use no arguments.
264
+ args: TArgs = [] as unknown as TArgs,
265
+ ): UseQueryResult<TData> {
228
266
  const [queryState, setQueryState] = useSharedState(query.queryState);
229
267
 
230
268
  const isMounted = useRef(true);
@@ -241,27 +279,23 @@ export function useQuery<TArgs extends unknown[], TResult>(
241
279
  setQueryState((prev) => {
242
280
  const clone = { ...prev };
243
281
  clone[queryKey] = {
244
- data: clone[queryKey]?.data ?? null,
282
+ ...(clone[queryKey] ?? {}),
245
283
  loading: true,
246
- error: null,
247
284
  };
248
285
  return clone;
249
286
  });
250
287
 
251
288
  try {
252
- const value = await query.getCachedOrFetch(...args);
289
+ const data = await query.getCachedOrFetch(...args);
253
290
 
254
291
  if (isMounted.current) {
255
292
  setQueryState((prev) => {
256
293
  const clone = { ...prev };
257
- const now = Date.now();
258
294
  clone[queryKey] = {
259
- data: {
260
- value,
261
- updatedMs: now,
262
- },
295
+ // Don't keep old error.
263
296
  loading: false,
264
- error: null,
297
+ data,
298
+ dataUpdatedMs: Date.now(),
265
299
  };
266
300
  return clone;
267
301
  });
@@ -276,9 +310,11 @@ export function useQuery<TArgs extends unknown[], TResult>(
276
310
  setQueryState((prev) => {
277
311
  const clone = { ...prev };
278
312
  clone[queryKey] = {
279
- data: clone[queryKey]?.data ?? null,
313
+ // Keep old data.
314
+ ...(clone[queryKey] ?? {}),
280
315
  loading: false,
281
316
  error,
317
+ errorUpdatedMs: Date.now(),
282
318
  };
283
319
  return clone;
284
320
  });
@@ -296,8 +332,22 @@ export function useQuery<TArgs extends unknown[], TResult>(
296
332
  };
297
333
  }, [execute]);
298
334
 
299
- return (
300
- queryState[queryKey] ??
301
- (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TResult>)
302
- );
335
+ return useMemo(() => {
336
+ const state =
337
+ queryState[queryKey] ??
338
+ (DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
339
+
340
+ return {
341
+ ...state,
342
+ refetch: () => {
343
+ return query.updateFromSource(...args);
344
+ },
345
+ setData: (data: TData, dataUpdatedMs?: number) => {
346
+ query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
347
+ },
348
+ deleteData: () => {
349
+ query.deleteData(args);
350
+ },
351
+ };
352
+ }, [query, queryKey, queryState]);
303
353
  }
@@ -1,4 +1,3 @@
1
- import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
2
1
  import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
3
2
  import { useSyncExternalStore } from "react";
4
3
 
@@ -26,19 +25,19 @@ import {
26
25
  /**
27
26
  * These values are configurable by the user at any time.
28
27
  */
29
- export class SharedStateConfig {
28
+ export const SharedStateDefaults = {
30
29
  /**
31
30
  * Default duration for storage expiration in milliseconds. Initial value
32
31
  * is 30 days, but can be changed to anything.
33
32
  */
34
- public static storeExpiryMs = MS_PER_DAY * 30;
33
+ storeExpiryMs: 30 * MS_PER_DAY,
35
34
 
36
35
  /**
37
36
  * True to load from storage only on mount, false (default) to load in
38
37
  * the top level scope when the shared state is defined.
39
38
  */
40
- public static lazyLoad = false;
41
- }
39
+ lazyLoad: false,
40
+ };
42
41
 
43
42
  /************************************************************************/
44
43
  /* Shared state implementation */
@@ -131,7 +130,10 @@ export class SharedState<T> {
131
130
  this.pubSubKey = String(stateKey++);
132
131
 
133
132
  // Use max of 1 storage adapter per shared state.
134
- this.storageAdapter = getStorageAdapter(options, DEFAULT_EXPIRY_DELTA_MS);
133
+ this.storageAdapter = getStorageAdapter(
134
+ options,
135
+ SharedStateDefaults.storeExpiryMs,
136
+ );
135
137
 
136
138
  // Same as this.setValue, but binding the `this` reference.
137
139
  this.setValueBounded = (arg) => this.setValue(arg);
@@ -141,7 +143,8 @@ export class SharedState<T> {
141
143
  pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
142
144
 
143
145
  const lazyLoad = options?.lazyLoad;
144
- const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
146
+ const lazy =
147
+ lazyLoad !== undefined ? lazyLoad : SharedStateDefaults.lazyLoad;
145
148
 
146
149
  if (!lazy) {
147
150
  void this.initDefaultValueOnce();
@@ -35,4 +35,12 @@ describe("stringifyDeterministicForKeys", () => {
35
35
  `Functions should not be passed into query parameters`,
36
36
  );
37
37
  });
38
+
39
+ test("array", () => {
40
+ const a = [1, "a", { b: 2 }, [true]];
41
+
42
+ const actual = stringifyDeterministicForKeys(a);
43
+
44
+ expect(actual).toBe(`[1,"a",{"b":2},[true]]`);
45
+ });
38
46
  });