@choksheak/react-utils 0.2.3 → 0.2.5
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/README.md +3 -1
- package/fetcher.min.cjs +2 -0
- package/fetcher.min.cjs.map +1 -0
- package/fetcher.min.mjs +2 -0
- package/fetcher.min.mjs.map +1 -0
- package/index.min.cjs +2 -0
- package/index.min.cjs.map +1 -0
- package/index.min.mjs +2 -0
- package/index.min.mjs.map +1 -0
- package/package.json +38 -5
- package/{sharedQuery.js → sharedQuery.cjs} +202 -79
- package/sharedQuery.d.mts +73 -9
- package/sharedQuery.d.ts +72 -8
- package/sharedQuery.min.cjs +2 -0
- package/sharedQuery.min.cjs.map +1 -0
- package/sharedQuery.min.mjs +2 -0
- package/sharedQuery.min.mjs.map +1 -0
- package/sharedQuery.mjs +200 -77
- package/sharedState.min.cjs +2 -0
- package/sharedState.min.cjs.map +1 -0
- package/sharedState.min.mjs +2 -0
- package/sharedState.min.mjs.map +1 -0
- package/utils/storage.min.cjs +2 -0
- package/utils/storage.min.cjs.map +1 -0
- package/utils/storage.min.mjs +2 -0
- package/utils/storage.min.mjs.map +1 -0
- package/utils/stringify.min.cjs +2 -0
- package/utils/stringify.min.cjs.map +1 -0
- package/utils/stringify.min.mjs +2 -0
- package/utils/stringify.min.mjs.map +1 -0
- package/utils/useDeepMemo.cjs +51 -0
- package/utils/useDeepMemo.d.mts +4 -0
- package/utils/useDeepMemo.d.ts +2 -0
- package/utils/useDeepMemo.min.cjs +2 -0
- package/utils/useDeepMemo.min.cjs.map +1 -0
- package/utils/useDeepMemo.min.mjs +2 -0
- package/utils/useDeepMemo.min.mjs.map +1 -0
- package/utils/useDeepMemo.mjs +16 -0
- package/docs/.nojekyll +0 -1
- package/docs/assets/hierarchy.js +0 -1
- package/docs/assets/highlight.css +0 -71
- package/docs/assets/icons.js +0 -18
- package/docs/assets/icons.svg +0 -1
- package/docs/assets/main.js +0 -60
- package/docs/assets/navigation.js +0 -1
- package/docs/assets/search.js +0 -1
- package/docs/assets/style.css +0 -1633
- package/docs/classes/sharedQuery.SharedQuery.html +0 -22
- package/docs/classes/sharedState.SharedState.html +0 -9
- package/docs/classes/utils_storage.InMemoryStorageAdapter.html +0 -9
- package/docs/classes/utils_storage.IndexedDbAdapter.html +0 -9
- package/docs/classes/utils_storage.LocalStorageAdapter.html +0 -9
- package/docs/functions/sharedQuery.sharedQuery.html +0 -9
- package/docs/functions/sharedQuery.useSharedQuery.html +0 -6
- package/docs/functions/sharedState.sharedState.html +0 -3
- package/docs/functions/sharedState.useSharedState.html +0 -3
- package/docs/functions/sharedState.useSharedStateSetter.html +0 -2
- package/docs/functions/sharedState.useSharedStateValue.html +0 -2
- package/docs/functions/utils_storage.getStorageAdapter.html +0 -1
- package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +0 -7
- package/docs/hierarchy.html +0 -1
- package/docs/index.html +0 -2
- package/docs/modules/fetcher.html +0 -1
- package/docs/modules/index.html +0 -1
- package/docs/modules/sharedQuery.html +0 -1
- package/docs/modules/sharedState.html +0 -1
- package/docs/modules/utils_storage.html +0 -1
- package/docs/modules/utils_stringify.html +0 -1
- package/docs/modules.html +0 -1
- package/docs/types/sharedQuery.FetchFn.html +0 -1
- package/docs/types/sharedQuery.PersistTo.html +0 -1
- package/docs/types/sharedQuery.QueryStateValue.html +0 -9
- package/docs/types/sharedQuery.SharedQueryOptions.html +0 -1
- package/docs/types/sharedQuery.SharedQueryState.html +0 -3
- package/docs/types/sharedQuery.UseQueryResult.html +0 -1
- package/docs/types/sharedState.SharedStateOptions.html +0 -1
- package/docs/types/utils_storage.StorageAdapter.html +0 -5
- package/docs/types/utils_storage.StorageOptions.html +0 -5
- package/docs/variables/fetcher.fetcher.html +0 -2
- package/docs/variables/fetcher.fetcherWithRetries.html +0 -2
- package/docs/variables/sharedQuery.SharedQueryDefaults.html +0 -2
- package/docs/variables/sharedState.SharedStateDefaults.html +0 -6
- package/src/fetcher.ts +0 -14
- package/src/index.ts +0 -4
- package/src/sharedQuery.ts +0 -507
- package/src/sharedState.ts +0 -232
- package/src/utils/storage.ts +0 -197
- package/src/utils/stringify.ts +0 -49
- /package/{fetcher.js → fetcher.cjs} +0 -0
- /package/{index.js → index.cjs} +0 -0
- /package/{sharedState.js → sharedState.cjs} +0 -0
- /package/utils/{storage.js → storage.cjs} +0 -0
- /package/utils/{stringify.js → stringify.cjs} +0 -0
package/src/sharedQuery.ts
DELETED
|
@@ -1,507 +0,0 @@
|
|
|
1
|
-
import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
|
|
2
|
-
import getByteSize from "object-sizeof";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
SharedState,
|
|
7
|
-
sharedState,
|
|
8
|
-
SharedStateOptions,
|
|
9
|
-
useSharedState,
|
|
10
|
-
} from "./sharedState";
|
|
11
|
-
import { stringifyDeterministicForKeys } from "./utils/stringify";
|
|
12
|
-
|
|
13
|
-
export type FetchFn<TArgs extends unknown[], TData> = (
|
|
14
|
-
...args: TArgs
|
|
15
|
-
) => Promise<TData> | TData;
|
|
16
|
-
|
|
17
|
-
export type QueryStateValue<TData> = {
|
|
18
|
-
/**
|
|
19
|
-
* When this record was last updated. Used for LRU determination. If you want
|
|
20
|
-
* the data age, please use `dataUpdatedMs` instead.
|
|
21
|
-
*/
|
|
22
|
-
lastUpdatedMs: number;
|
|
23
|
-
|
|
24
|
-
loading: boolean;
|
|
25
|
-
data?: TData;
|
|
26
|
-
dataUpdatedMs?: number;
|
|
27
|
-
error?: Error;
|
|
28
|
-
errorUpdatedMs?: number;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type UseQueryResult<TData> = QueryStateValue<TData> & {
|
|
32
|
-
refetch: () => Promise<TData>;
|
|
33
|
-
setData: (data: TData, dataUpdatedMs?: number) => void;
|
|
34
|
-
deleteData: () => void;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type PersistTo = "localStorage" | "indexedDb";
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The full query state is a record of all the fetch states mapped by
|
|
41
|
-
* query key to the state entry.
|
|
42
|
-
*/
|
|
43
|
-
export type SharedQueryState<TData> = Record<string, QueryStateValue<TData>>;
|
|
44
|
-
|
|
45
|
-
export type SharedQueryOptions<TArgs extends unknown[], TData> = {
|
|
46
|
-
// The name could have been auto-generated, but we let the user give us a
|
|
47
|
-
// human-readable name that can be identified quickly in the logs.
|
|
48
|
-
queryName: string;
|
|
49
|
-
|
|
50
|
-
// Function to fetch data.
|
|
51
|
-
queryFn: FetchFn<TArgs, TData>;
|
|
52
|
-
|
|
53
|
-
// ms before data considered stale; 0 means always stale.
|
|
54
|
-
staleMs?: number;
|
|
55
|
-
|
|
56
|
-
// ms before data is removed from cache; 0 means never expire.
|
|
57
|
-
expiryMs?: number;
|
|
58
|
-
|
|
59
|
-
// Trigger background re-fetch if stale.
|
|
60
|
-
refetchOnStale?: boolean;
|
|
61
|
-
|
|
62
|
-
// Shortcut to set the localStorageKey or indexedDbKey automatically from
|
|
63
|
-
// queryName. This is important because it avoids requiring the user to
|
|
64
|
-
// specify the same queryName twice everytime.
|
|
65
|
-
persistTo?: PersistTo;
|
|
66
|
-
|
|
67
|
-
// Max number of entries to keep. The oldest entries will be discarded.
|
|
68
|
-
// The last record cannot be discarded. 0 means no limit.
|
|
69
|
-
maxSize?: number;
|
|
70
|
-
|
|
71
|
-
// Max number of bytes to keep. The oldest entries will be discarded.
|
|
72
|
-
// The last record cannot be discarded. 0 means no limit.
|
|
73
|
-
maxBytes?: number;
|
|
74
|
-
} & SharedStateOptions<SharedQueryState<TData>>;
|
|
75
|
-
|
|
76
|
-
/** Users can override these values globally. */
|
|
77
|
-
export const SharedQueryDefaults = {
|
|
78
|
-
staleMs: 0,
|
|
79
|
-
expiryMs: 30 * MS_PER_DAY,
|
|
80
|
-
persistTo: undefined as PersistTo | undefined,
|
|
81
|
-
maxSize: 100, // 100 is not too large, but please tweak accordingly
|
|
82
|
-
maxBytes: 100_000, // 100kb before GC
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export class SharedQuery<TArgs extends unknown[], TData> {
|
|
86
|
-
private readonly inflightPromises = new Map<string, Promise<TData>>();
|
|
87
|
-
public readonly queryState: SharedState<SharedQueryState<TData>>;
|
|
88
|
-
|
|
89
|
-
public readonly queryName: string;
|
|
90
|
-
private readonly queryFn: FetchFn<TArgs, TData>;
|
|
91
|
-
public readonly expiryMs: number;
|
|
92
|
-
public readonly staleMs: number;
|
|
93
|
-
public readonly refetchOnStale: boolean;
|
|
94
|
-
public readonly maxSize: number;
|
|
95
|
-
public readonly maxBytes: number;
|
|
96
|
-
|
|
97
|
-
public constructor(options: SharedQueryOptions<TArgs, TData>) {
|
|
98
|
-
this.queryName = options.queryName;
|
|
99
|
-
|
|
100
|
-
this.queryFn = options.queryFn;
|
|
101
|
-
|
|
102
|
-
this.expiryMs =
|
|
103
|
-
options?.expiryMs !== undefined
|
|
104
|
-
? options?.expiryMs
|
|
105
|
-
: SharedQueryDefaults.expiryMs;
|
|
106
|
-
|
|
107
|
-
this.staleMs =
|
|
108
|
-
options?.staleMs !== undefined
|
|
109
|
-
? options?.staleMs
|
|
110
|
-
: SharedQueryDefaults.staleMs;
|
|
111
|
-
|
|
112
|
-
this.refetchOnStale = Boolean(options?.refetchOnStale);
|
|
113
|
-
|
|
114
|
-
// Apply shortcut when using `persistTo`.
|
|
115
|
-
const persistTo = options.persistTo ?? SharedQueryDefaults.persistTo;
|
|
116
|
-
|
|
117
|
-
if (persistTo === "localStorage") {
|
|
118
|
-
options.localStorageKey = options.queryName;
|
|
119
|
-
} else if (persistTo === "indexedDb") {
|
|
120
|
-
options.indexedDbKey = options.queryName;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
this.maxSize = options?.maxSize ?? SharedQueryDefaults.maxSize;
|
|
124
|
-
this.maxBytes = options?.maxBytes ?? SharedQueryDefaults.maxBytes;
|
|
125
|
-
|
|
126
|
-
this.queryState = sharedState<SharedQueryState<TData>>(
|
|
127
|
-
{},
|
|
128
|
-
{
|
|
129
|
-
...options,
|
|
130
|
-
// Fallback to use expiryMs for the storeExpiryMs.
|
|
131
|
-
storeExpiryMs: options?.storeExpiryMs || this.expiryMs,
|
|
132
|
-
},
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
public getQueryKey(args: TArgs): string {
|
|
137
|
-
return this.queryName + ":" + stringifyDeterministicForKeys(args);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private isStale(dataUpdatedMs: number): boolean {
|
|
141
|
-
if (this.staleMs === 0) return true; // always stale
|
|
142
|
-
|
|
143
|
-
const ageMs = Date.now() - dataUpdatedMs;
|
|
144
|
-
return ageMs > this.staleMs;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private isExpired(dataUpdatedMs: number): boolean {
|
|
148
|
-
if (this.expiryMs === 0) return false; // never expire
|
|
149
|
-
|
|
150
|
-
const ageMs = Date.now() - dataUpdatedMs;
|
|
151
|
-
return ageMs > this.expiryMs;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
public async getCachedOrFetch(...args: TArgs): Promise<TData> {
|
|
155
|
-
const queryKey = this.getQueryKey(args);
|
|
156
|
-
const cached = this.queryState.getSnapshot()?.[queryKey];
|
|
157
|
-
|
|
158
|
-
if (cached?.data && !this.isExpired(cached.dataUpdatedMs ?? 0)) {
|
|
159
|
-
// Return cached value if not stale.
|
|
160
|
-
if (!this.isStale(cached.dataUpdatedMs ?? 0)) {
|
|
161
|
-
console.log(
|
|
162
|
-
`Return fresh data ${queryKey} from cache without fetching`,
|
|
163
|
-
);
|
|
164
|
-
return cached.data;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// If stale, optionally update the data in the background.
|
|
168
|
-
if (this.refetchOnStale) {
|
|
169
|
-
console.log(`refetchOnStale for ${queryKey}`);
|
|
170
|
-
void this.dedupedFetch(queryKey, ...args);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Still return stale data immediately.
|
|
174
|
-
console.log(`Returning stale data for ${queryKey}`);
|
|
175
|
-
return cached.data;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return await this.dedupedFetch(queryKey, ...args);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private dedupedFetch(queryKey: string, ...args: TArgs): Promise<TData> {
|
|
182
|
-
const inflightPromise = this.inflightPromises.get(queryKey);
|
|
183
|
-
|
|
184
|
-
// De-duplicate in-flight requests
|
|
185
|
-
if (inflightPromise) {
|
|
186
|
-
console.log(`Deduplicating inflight fetch for ${queryKey}`);
|
|
187
|
-
return inflightPromise;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Fetch new data
|
|
191
|
-
const promise = this.startFetching(queryKey, args);
|
|
192
|
-
|
|
193
|
-
this.inflightPromises.set(queryKey, promise);
|
|
194
|
-
return promise;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private async startFetching(queryKey: string, args: TArgs) {
|
|
198
|
-
console.log(`Start fetching ${queryKey}`);
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const data = await this.queryFn(...args);
|
|
202
|
-
|
|
203
|
-
console.log(`Successfully fetched ${queryKey}`);
|
|
204
|
-
this.setData(queryKey, data, Date.now());
|
|
205
|
-
return data;
|
|
206
|
-
} catch (e) {
|
|
207
|
-
console.error(`Failed to fetch ${queryKey}: ${e}`);
|
|
208
|
-
throw e;
|
|
209
|
-
} finally {
|
|
210
|
-
this.inflightPromises.delete(queryKey);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Set the data directly if the user obtained it from somewhere else. */
|
|
215
|
-
public async setData(
|
|
216
|
-
queryKey: string,
|
|
217
|
-
data: TData,
|
|
218
|
-
dataUpdatedMs = Date.now(),
|
|
219
|
-
): Promise<void> {
|
|
220
|
-
const entry: QueryStateValue<TData> = {
|
|
221
|
-
lastUpdatedMs: Date.now(),
|
|
222
|
-
loading: false,
|
|
223
|
-
data,
|
|
224
|
-
dataUpdatedMs,
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
|
|
228
|
-
|
|
229
|
-
this.enforceSizeLimit();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Do a new fetch even when the data is already cached. */
|
|
233
|
-
public async updateFromSource(...args: TArgs): Promise<TData> {
|
|
234
|
-
const key = this.getQueryKey(args);
|
|
235
|
-
return await this.dedupedFetch(key, ...args);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Delete the cached data for one set of arguments. */
|
|
239
|
-
public deleteData(args: TArgs): void {
|
|
240
|
-
const queryKey = this.getQueryKey(args);
|
|
241
|
-
|
|
242
|
-
const record = this.queryState.getSnapshot();
|
|
243
|
-
const clone = { ...record };
|
|
244
|
-
delete clone[queryKey];
|
|
245
|
-
this.queryState.setValue(clone);
|
|
246
|
-
|
|
247
|
-
this.inflightPromises.delete(queryKey);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Delete all currently cached data & all inflight promises. */
|
|
251
|
-
public clear(): void {
|
|
252
|
-
this.queryState.delete();
|
|
253
|
-
this.inflightPromises.clear();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Discard the oldest entries if the size exceeds the limit. The last
|
|
258
|
-
* remaining record cannot be deleted no matter what the limit is.
|
|
259
|
-
*/
|
|
260
|
-
public enforceSizeLimit(): void {
|
|
261
|
-
// Skip if there are no limits.
|
|
262
|
-
if (!this.maxSize && !this.maxBytes) {
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const oldState = this.queryState.getSnapshot();
|
|
267
|
-
const oldSize = Object.keys(oldState).length;
|
|
268
|
-
|
|
269
|
-
// Cannot do GC if size is 1 or less.
|
|
270
|
-
if (oldSize <= 1) {
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const oldByteSize = getByteSize(oldState);
|
|
275
|
-
|
|
276
|
-
let newState = oldState;
|
|
277
|
-
let needUpdate = false;
|
|
278
|
-
|
|
279
|
-
// Limit by number of records.
|
|
280
|
-
if (this.maxSize) {
|
|
281
|
-
const numToCut = oldSize - this.maxSize;
|
|
282
|
-
|
|
283
|
-
if (numToCut > 0) {
|
|
284
|
-
console.log(
|
|
285
|
-
`enforceSizeLimit for ${this.queryName} needed due to size ${oldSize} > ${this.maxSize}`,
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
newState = { ...newState }; // shallow clone
|
|
289
|
-
needUpdate = true;
|
|
290
|
-
|
|
291
|
-
Object.entries(newState)
|
|
292
|
-
.sort(
|
|
293
|
-
(entry1, entry2) =>
|
|
294
|
-
// Sort descending.
|
|
295
|
-
entry2[1].lastUpdatedMs - entry1[1].lastUpdatedMs,
|
|
296
|
-
)
|
|
297
|
-
.slice(-numToCut)
|
|
298
|
-
.forEach(([key]) => {
|
|
299
|
-
delete newState[key];
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Limit by byte size.
|
|
305
|
-
if (this.maxBytes && Object.keys(newState).length > 1) {
|
|
306
|
-
// Recompute only if changed by above code.
|
|
307
|
-
const currentByteSize = needUpdate ? getByteSize(newState) : oldByteSize;
|
|
308
|
-
|
|
309
|
-
let bytesToCut = currentByteSize - this.maxBytes;
|
|
310
|
-
|
|
311
|
-
if (bytesToCut > 0) {
|
|
312
|
-
console.log(
|
|
313
|
-
`enforceSizeLimit for ${this.queryName} needed due to byte size ${currentByteSize.toLocaleString()} > ${this.maxBytes.toLocaleString()}`,
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
// Shallow clone only if not already cloned above.
|
|
317
|
-
if (!needUpdate) {
|
|
318
|
-
newState = { ...newState };
|
|
319
|
-
needUpdate = true;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const entriesByTimeAscending = Object.entries(newState).sort(
|
|
323
|
-
(entry1, entry2) => entry1[1].lastUpdatedMs - entry2[1].lastUpdatedMs,
|
|
324
|
-
);
|
|
325
|
-
|
|
326
|
-
for (
|
|
327
|
-
let i = 0;
|
|
328
|
-
// Leave the last record entriesByTimeAscending[len-1] untouched.
|
|
329
|
-
i < entriesByTimeAscending.length - 1 && bytesToCut > 0;
|
|
330
|
-
i++
|
|
331
|
-
) {
|
|
332
|
-
const key = entriesByTimeAscending[i][0];
|
|
333
|
-
const thisSize = getByteSize(key) + getByteSize(newState[key]);
|
|
334
|
-
delete newState[key];
|
|
335
|
-
bytesToCut -= thisSize;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Update the state.
|
|
341
|
-
if (needUpdate) {
|
|
342
|
-
this.queryState.setValue(newState);
|
|
343
|
-
|
|
344
|
-
// Log to inform user that the data was trimmed.
|
|
345
|
-
console.log(
|
|
346
|
-
`Trimmed data for ${this.queryName}: size=(${oldSize} -> ${Object.keys(newState).length}) (limit=${this.maxSize}), byteSize=(${oldByteSize.toLocaleString()} -> ${getByteSize(newState).toLocaleString()}) (limit=${this.maxBytes.toLocaleString()})`,
|
|
347
|
-
);
|
|
348
|
-
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Log to indicate why trimming was not needed.
|
|
353
|
-
console.log(
|
|
354
|
-
`No need to trim data for ${this.queryName}: size=${oldSize} (limit=${this.maxSize}), byteSize=${oldByteSize.toLocaleString()} (limit=${this.maxBytes.toLocaleString()})`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const seenQueryNames = new Set<string>();
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Create a reusable shared query object that can be used to auto de-duplicate
|
|
363
|
-
* and cache all data fetches, with auto-expiration and staleness checks.
|
|
364
|
-
*
|
|
365
|
-
* Each `queryName` must be unique, and can only be declared once, most often
|
|
366
|
-
* in the top level scope. The queryName is used for logging only.
|
|
367
|
-
*
|
|
368
|
-
* Example:
|
|
369
|
-
* ```
|
|
370
|
-
* export const usersQuery = sharedQuery({
|
|
371
|
-
* queryName: "users",
|
|
372
|
-
* queryFn: listUsers,
|
|
373
|
-
* persistTo: "indexedDb",
|
|
374
|
-
* staleMs: MS_PER_DAY,
|
|
375
|
-
* });
|
|
376
|
-
* ```
|
|
377
|
-
*/
|
|
378
|
-
export function sharedQuery<TArgs extends unknown[], TData>(
|
|
379
|
-
// The name could have been auto-generated, but we let the user give us a
|
|
380
|
-
// human-readable name that can be identified quickly in the logs.
|
|
381
|
-
options: SharedQueryOptions<TArgs, TData>,
|
|
382
|
-
): SharedQuery<TArgs, TData> {
|
|
383
|
-
if (seenQueryNames.has(options.queryName)) {
|
|
384
|
-
throw new Error(`Duplicate shared query "${options.queryName}"`);
|
|
385
|
-
}
|
|
386
|
-
seenQueryNames.add(options.queryName);
|
|
387
|
-
|
|
388
|
-
return new SharedQuery(options);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
392
|
-
lastUpdatedMs: 0,
|
|
393
|
-
loading: true,
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* React hook to make use of a shared query inside any React component.
|
|
398
|
-
*
|
|
399
|
-
* Example:
|
|
400
|
-
* ```
|
|
401
|
-
* const users = useSharedQuery(usersQuery, ["123"]);
|
|
402
|
-
* ```
|
|
403
|
-
*/
|
|
404
|
-
export function useSharedQuery<TArgs extends unknown[], TData>(
|
|
405
|
-
query: SharedQuery<TArgs, TData>,
|
|
406
|
-
// Default to use no arguments.
|
|
407
|
-
args: TArgs = [] as unknown as TArgs,
|
|
408
|
-
): UseQueryResult<TData> {
|
|
409
|
-
const [queryState, setQueryState] = useSharedState(query.queryState);
|
|
410
|
-
|
|
411
|
-
const isMounted = useRef(true);
|
|
412
|
-
|
|
413
|
-
// Identify args changes using deep equality.
|
|
414
|
-
// We intentionally chose not to memoize this value because args is likely
|
|
415
|
-
// to be an unstable value (changes on every render).
|
|
416
|
-
const queryKey = query.getQueryKey(args);
|
|
417
|
-
|
|
418
|
-
// The fetch logic wrapped in useCallback to be stable for useEffect
|
|
419
|
-
const execute = useCallback(async () => {
|
|
420
|
-
console.log(`Begin executing shared query ${queryKey}`);
|
|
421
|
-
|
|
422
|
-
setQueryState((prev) => {
|
|
423
|
-
const clone = { ...prev };
|
|
424
|
-
|
|
425
|
-
clone[queryKey] = {
|
|
426
|
-
...(clone[queryKey] ?? {}),
|
|
427
|
-
lastUpdatedMs: Date.now(),
|
|
428
|
-
loading: true,
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
return clone;
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
try {
|
|
435
|
-
const data = await query.getCachedOrFetch(...args);
|
|
436
|
-
|
|
437
|
-
if (isMounted.current) {
|
|
438
|
-
setQueryState((prev) => {
|
|
439
|
-
const clone = { ...prev };
|
|
440
|
-
const now = Date.now();
|
|
441
|
-
|
|
442
|
-
clone[queryKey] = {
|
|
443
|
-
// Don't keep old error.
|
|
444
|
-
lastUpdatedMs: now,
|
|
445
|
-
loading: false,
|
|
446
|
-
data,
|
|
447
|
-
dataUpdatedMs: now,
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
return clone;
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
} catch (e) {
|
|
454
|
-
const error =
|
|
455
|
-
e instanceof Error
|
|
456
|
-
? e
|
|
457
|
-
: new Error(`An unknown error occurred during fetch: ${e}`);
|
|
458
|
-
|
|
459
|
-
if (isMounted.current) {
|
|
460
|
-
setQueryState((prev) => {
|
|
461
|
-
const clone = { ...prev };
|
|
462
|
-
const now = Date.now();
|
|
463
|
-
|
|
464
|
-
clone[queryKey] = {
|
|
465
|
-
// Keep old data.
|
|
466
|
-
...(clone[queryKey] ?? {}),
|
|
467
|
-
lastUpdatedMs: now,
|
|
468
|
-
loading: false,
|
|
469
|
-
error,
|
|
470
|
-
errorUpdatedMs: now,
|
|
471
|
-
};
|
|
472
|
-
|
|
473
|
-
return clone;
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}, [queryKey]);
|
|
478
|
-
|
|
479
|
-
useEffect(() => {
|
|
480
|
-
isMounted.current = true;
|
|
481
|
-
|
|
482
|
-
void execute();
|
|
483
|
-
|
|
484
|
-
return () => {
|
|
485
|
-
isMounted.current = false;
|
|
486
|
-
};
|
|
487
|
-
}, [execute]);
|
|
488
|
-
|
|
489
|
-
return useMemo(() => {
|
|
490
|
-
const state =
|
|
491
|
-
queryState[queryKey] ??
|
|
492
|
-
(DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TData>);
|
|
493
|
-
|
|
494
|
-
return {
|
|
495
|
-
...state,
|
|
496
|
-
refetch: () => {
|
|
497
|
-
return query.updateFromSource(...args);
|
|
498
|
-
},
|
|
499
|
-
setData: (data: TData, dataUpdatedMs?: number) => {
|
|
500
|
-
query.setData(queryKey, data, dataUpdatedMs ?? Date.now());
|
|
501
|
-
},
|
|
502
|
-
deleteData: () => {
|
|
503
|
-
query.deleteData(args);
|
|
504
|
-
},
|
|
505
|
-
};
|
|
506
|
-
}, [query, queryKey, queryState]);
|
|
507
|
-
}
|