@choksheak/react-utils 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/sharedQuery.SharedQuery.html +15 -0
- package/docs/classes/sharedState.SharedState.html +8 -0
- package/docs/classes/sharedState.SharedStateConfig.html +7 -6
- package/docs/classes/utils_storage.InMemoryStorageAdapter.html +9 -0
- package/docs/classes/utils_storage.IndexedDbAdapter.html +9 -0
- package/docs/classes/utils_storage.LocalStorageAdapter.html +9 -0
- package/docs/functions/sharedQuery.sharedQuery.html +5 -0
- package/docs/functions/sharedQuery.useQuery.html +4 -0
- package/docs/functions/sharedState.sharedState.html +2 -2
- package/docs/functions/sharedState.useSharedState.html +2 -2
- package/docs/functions/sharedState.useSharedStateSetter.html +2 -2
- package/docs/functions/sharedState.useSharedStateValue.html +2 -2
- package/docs/functions/utils_storage.getStorageAdapter.html +1 -0
- package/docs/functions/utils_stringify.stringifyDeterministicForKeys.html +7 -0
- package/docs/modules/sharedQuery.html +1 -0
- package/docs/modules/sharedState.html +1 -1
- package/docs/modules/utils_storage.html +1 -0
- package/docs/modules/utils_stringify.html +1 -0
- package/docs/modules.html +1 -1
- package/docs/types/sharedQuery.FetchFn.html +1 -0
- package/docs/types/sharedQuery.FetchedData.html +3 -0
- package/docs/types/sharedQuery.QueryStateValue.html +4 -0
- package/docs/types/sharedQuery.SharedQueryOptions.html +1 -0
- package/docs/types/sharedQuery.SharedQueryState.html +3 -0
- package/docs/types/sharedState.SharedStateOptions.html +1 -0
- package/docs/types/utils_storage.StorageAdapter.html +5 -0
- package/docs/types/utils_storage.StorageOptions.html +5 -0
- package/docs/variables/fetcher.fetcher.html +1 -1
- package/docs/variables/fetcher.fetcherWithRetries.html +1 -1
- package/package.json +9 -6
- package/sharedQuery.d.mts +61 -0
- package/sharedQuery.d.ts +58 -0
- package/sharedQuery.js +471 -0
- package/sharedQuery.mjs +444 -0
- package/sharedState.d.mts +22 -16
- package/sharedState.d.ts +20 -15
- package/sharedState.js +157 -85
- package/sharedState.mjs +156 -85
- package/src/sharedQuery.ts +303 -0
- package/src/sharedState.ts +75 -137
- package/src/utils/storage.ts +197 -0
- package/src/utils/stringify.test.ts +38 -0
- package/src/utils/stringify.ts +49 -0
- package/utils/storage.d.mts +46 -0
- package/utils/storage.d.ts +44 -0
- package/utils/storage.js +162 -0
- package/utils/storage.mjs +134 -0
- package/utils/stringify.d.mts +12 -0
- package/utils/stringify.d.ts +10 -0
- package/utils/stringify.js +58 -0
- package/utils/stringify.mjs +33 -0
- package/utils/stringify.test.d.mts +2 -0
- package/utils/stringify.test.d.ts +2 -0
- package/utils/stringify.test.js +64 -0
- package/utils/stringify.test.mjs +81 -0
- package/docs/types/sharedState.SharedState.html +0 -6
|
@@ -0,0 +1,303 @@
|
|
|
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";
|
|
6
|
+
import { stringifyDeterministicForKeys } from "./utils/stringify";
|
|
7
|
+
|
|
8
|
+
export type FetchFn<TArgs extends unknown[], TResult> = (
|
|
9
|
+
...args: TArgs
|
|
10
|
+
) => Promise<TResult>;
|
|
11
|
+
|
|
12
|
+
export type FetchedData<TResult> = {
|
|
13
|
+
value: TResult;
|
|
14
|
+
updatedMs: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type QueryStateValue<TResult> = {
|
|
18
|
+
data: FetchedData<TResult> | null;
|
|
19
|
+
loading: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The full query state is a record of all the fetch results mapped by
|
|
25
|
+
* query key to the state entry.
|
|
26
|
+
*/
|
|
27
|
+
export type SharedQueryState<TResult> = Record<
|
|
28
|
+
string,
|
|
29
|
+
QueryStateValue<TResult>
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
export type SharedQueryOptions<TResult> = {
|
|
33
|
+
// ms before data considered stale; 0 means never stale.
|
|
34
|
+
staleMs?: number;
|
|
35
|
+
// ms before data is removed from cache; 0 means never expire.
|
|
36
|
+
expiryMs?: number;
|
|
37
|
+
// Trigger background re-fetch if stale.
|
|
38
|
+
revalidateOnStale?: boolean;
|
|
39
|
+
} & StorageOptions<SharedQueryState<TResult>>;
|
|
40
|
+
|
|
41
|
+
const DEFAULT_STALE_MS = 0;
|
|
42
|
+
|
|
43
|
+
export class SharedQuery<TArgs extends unknown[], TResult> {
|
|
44
|
+
private readonly inflightPromises = new Map<string, Promise<TResult>>();
|
|
45
|
+
public readonly queryState: SharedState<SharedQueryState<TResult>>;
|
|
46
|
+
public readonly expiryMs: number;
|
|
47
|
+
public readonly staleMs: number;
|
|
48
|
+
public readonly revalidateOnStale: boolean;
|
|
49
|
+
|
|
50
|
+
public constructor(
|
|
51
|
+
public readonly queryName: string,
|
|
52
|
+
private readonly fetchFn: FetchFn<TArgs, TResult>,
|
|
53
|
+
options?: SharedQueryOptions<TResult>,
|
|
54
|
+
) {
|
|
55
|
+
this.expiryMs =
|
|
56
|
+
options?.expiryMs !== undefined
|
|
57
|
+
? options?.expiryMs
|
|
58
|
+
: DEFAULT_EXPIRY_DELTA_MS;
|
|
59
|
+
|
|
60
|
+
this.staleMs =
|
|
61
|
+
options?.staleMs !== undefined ? options?.staleMs : DEFAULT_STALE_MS;
|
|
62
|
+
|
|
63
|
+
this.revalidateOnStale = Boolean(options?.revalidateOnStale);
|
|
64
|
+
|
|
65
|
+
this.queryState = sharedState<SharedQueryState<TResult>>(
|
|
66
|
+
{},
|
|
67
|
+
{
|
|
68
|
+
...options,
|
|
69
|
+
// Fallback to use expiryMs for the storeExpiryMs.
|
|
70
|
+
storeExpiryMs: options?.storeExpiryMs || this.expiryMs,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public getQueryKey(args: TArgs): string {
|
|
76
|
+
return this.queryName + ":" + stringifyDeterministicForKeys(args);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private isStale(entry: FetchedData<TResult>): boolean {
|
|
80
|
+
if (this.staleMs === 0) return true; // always stale
|
|
81
|
+
|
|
82
|
+
const ageMs = Date.now() - entry.updatedMs;
|
|
83
|
+
return ageMs > this.staleMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private isExpired(entry: FetchedData<TResult>): boolean {
|
|
87
|
+
if (this.expiryMs === 0) return false; // never expire
|
|
88
|
+
|
|
89
|
+
const ageMs = Date.now() - entry.updatedMs;
|
|
90
|
+
return ageMs > this.expiryMs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fetchNoCaching(queryKey: string, ...args: TArgs): Promise<TResult> {
|
|
94
|
+
const inflightPromise = this.inflightPromises.get(queryKey);
|
|
95
|
+
|
|
96
|
+
// De-duplicate in-flight requests
|
|
97
|
+
if (inflightPromise) {
|
|
98
|
+
console.log(`Deduplicating inflight fetch for ${queryKey}`);
|
|
99
|
+
return inflightPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 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
|
+
});
|
|
118
|
+
|
|
119
|
+
this.inflightPromises.set(queryKey, promise);
|
|
120
|
+
return promise;
|
|
121
|
+
}
|
|
122
|
+
|
|
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
|
+
}
|
|
135
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
// Still return stale data immediately.
|
|
143
|
+
console.log(`Returning stale data for ${queryKey}`);
|
|
144
|
+
return cached.data.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return await this.fetchNoCaching(queryKey, ...args);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async setCache(queryKey: string, value: TResult): Promise<void> {
|
|
151
|
+
const entry: QueryStateValue<TResult> = {
|
|
152
|
+
data: { value, updatedMs: Date.now() },
|
|
153
|
+
loading: false,
|
|
154
|
+
error: null,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
this.queryState.setValue((prev) => ({ ...prev, [queryKey]: entry }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Do a new fetch even when the data is already cached. */
|
|
161
|
+
public async updateFromSource(...args: TArgs): Promise<TResult> {
|
|
162
|
+
const key = this.getQueryKey(args);
|
|
163
|
+
return await this.fetchNoCaching(key, ...args);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Delete the currently cached data. */
|
|
167
|
+
public invalidate(args?: TArgs): void {
|
|
168
|
+
if (args) {
|
|
169
|
+
const queryKey = this.getQueryKey(args);
|
|
170
|
+
|
|
171
|
+
const record = this.queryState.getSnapshot();
|
|
172
|
+
const clone = { ...record };
|
|
173
|
+
delete clone[queryKey];
|
|
174
|
+
this.queryState.setValue(clone);
|
|
175
|
+
|
|
176
|
+
this.inflightPromises.delete(queryKey);
|
|
177
|
+
} else {
|
|
178
|
+
this.clearAll();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Delete the currently cached data & all inflight promises. */
|
|
183
|
+
public clearAll(): void {
|
|
184
|
+
this.queryState.delete();
|
|
185
|
+
this.inflightPromises.clear();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const seenQueryNames = new Set<string>();
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a reusable shared query object that can be used to auto de-duplicate
|
|
193
|
+
* and cache all data fetches, with auto-expiration and staleness checks.
|
|
194
|
+
*
|
|
195
|
+
* Each `queryName` must be unique, and can only be declared once, most often
|
|
196
|
+
* in the top level scope. The queryName is used for logging only.
|
|
197
|
+
*/
|
|
198
|
+
export function sharedQuery<TArgs extends unknown[], TResult>(
|
|
199
|
+
// The name could have been auto-generated, but we let the user give us a
|
|
200
|
+
// 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}"`);
|
|
207
|
+
}
|
|
208
|
+
seenQueryNames.add(queryName);
|
|
209
|
+
|
|
210
|
+
return new SharedQuery(queryName, fetchFn, options);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const DEFAULT_QUERY_STATE_ENTRY: QueryStateValue<unknown> = {
|
|
214
|
+
data: null,
|
|
215
|
+
loading: true,
|
|
216
|
+
error: null,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* React hook to make use of a shared query inside any React component.
|
|
221
|
+
*
|
|
222
|
+
* const users = useQuery(usersQuery, { userId: "123" });
|
|
223
|
+
*/
|
|
224
|
+
export function useQuery<TArgs extends unknown[], TResult>(
|
|
225
|
+
query: SharedQuery<TArgs, TResult>,
|
|
226
|
+
args: TArgs,
|
|
227
|
+
): QueryStateValue<TResult> {
|
|
228
|
+
const [queryState, setQueryState] = useSharedState(query.queryState);
|
|
229
|
+
|
|
230
|
+
const isMounted = useRef(true);
|
|
231
|
+
|
|
232
|
+
// Identify args changes using deep equality.
|
|
233
|
+
// We intentionally chose not to memoize this value because args is likely
|
|
234
|
+
// to be an unstable value (changes on every render).
|
|
235
|
+
const queryKey = query.getQueryKey(args);
|
|
236
|
+
|
|
237
|
+
// The fetch logic wrapped in useCallback to be stable for useEffect
|
|
238
|
+
const execute = useCallback(async () => {
|
|
239
|
+
console.log(`Begin executing shared query ${queryKey}`);
|
|
240
|
+
|
|
241
|
+
setQueryState((prev) => {
|
|
242
|
+
const clone = { ...prev };
|
|
243
|
+
clone[queryKey] = {
|
|
244
|
+
data: clone[queryKey]?.data ?? null,
|
|
245
|
+
loading: true,
|
|
246
|
+
error: null,
|
|
247
|
+
};
|
|
248
|
+
return clone;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const value = await query.getCachedOrFetch(...args);
|
|
253
|
+
|
|
254
|
+
if (isMounted.current) {
|
|
255
|
+
setQueryState((prev) => {
|
|
256
|
+
const clone = { ...prev };
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
clone[queryKey] = {
|
|
259
|
+
data: {
|
|
260
|
+
value,
|
|
261
|
+
updatedMs: now,
|
|
262
|
+
},
|
|
263
|
+
loading: false,
|
|
264
|
+
error: null,
|
|
265
|
+
};
|
|
266
|
+
return clone;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
const error =
|
|
271
|
+
e instanceof Error
|
|
272
|
+
? e
|
|
273
|
+
: new Error(`An unknown error occurred during fetch: ${e}`);
|
|
274
|
+
|
|
275
|
+
if (isMounted.current) {
|
|
276
|
+
setQueryState((prev) => {
|
|
277
|
+
const clone = { ...prev };
|
|
278
|
+
clone[queryKey] = {
|
|
279
|
+
data: clone[queryKey]?.data ?? null,
|
|
280
|
+
loading: false,
|
|
281
|
+
error,
|
|
282
|
+
};
|
|
283
|
+
return clone;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}, [queryKey]);
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
isMounted.current = true;
|
|
291
|
+
|
|
292
|
+
void execute();
|
|
293
|
+
|
|
294
|
+
return () => {
|
|
295
|
+
isMounted.current = false;
|
|
296
|
+
};
|
|
297
|
+
}, [execute]);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
queryState[queryKey] ??
|
|
301
|
+
(DEFAULT_QUERY_STATE_ENTRY as QueryStateValue<TResult>)
|
|
302
|
+
);
|
|
303
|
+
}
|
package/src/sharedState.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_EXPIRY_DELTA_MS } from "@choksheak/ts-utils/kvStore";
|
|
2
2
|
import { MS_PER_DAY } from "@choksheak/ts-utils/timeConstants";
|
|
3
3
|
import { useSyncExternalStore } from "react";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
getStorageAdapter,
|
|
7
|
+
StorageAdapter,
|
|
8
|
+
StorageOptions,
|
|
9
|
+
} from "./utils/storage";
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* The "shared state" is a React state that shares the same value across the
|
|
7
13
|
* entire app. It is largely similar to the jotai library, but is much smaller
|
|
@@ -22,12 +28,13 @@ import { useSyncExternalStore } from "react";
|
|
|
22
28
|
*/
|
|
23
29
|
export class SharedStateConfig {
|
|
24
30
|
/**
|
|
25
|
-
* Default duration for
|
|
31
|
+
* Default duration for storage expiration in milliseconds. Initial value
|
|
32
|
+
* is 30 days, but can be changed to anything.
|
|
26
33
|
*/
|
|
27
|
-
public static
|
|
34
|
+
public static storeExpiryMs = MS_PER_DAY * 30;
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
|
-
* True to load from
|
|
37
|
+
* True to load from storage only on mount, false (default) to load in
|
|
31
38
|
* the top level scope when the shared state is defined.
|
|
32
39
|
*/
|
|
33
40
|
public static lazyLoad = false;
|
|
@@ -39,11 +46,6 @@ export class SharedStateConfig {
|
|
|
39
46
|
|
|
40
47
|
type Subscriber<T> = (next: T, prev: T) => void;
|
|
41
48
|
|
|
42
|
-
type PersistenceAdapter<T> = {
|
|
43
|
-
save?: (value: T) => Promise<void> | void;
|
|
44
|
-
load?: () => Promise<T | undefined> | T | undefined;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
49
|
class PubSubStore {
|
|
48
50
|
private dataByKey = new Map<string, unknown>();
|
|
49
51
|
private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();
|
|
@@ -64,6 +66,10 @@ class PubSubStore {
|
|
|
64
66
|
this.notify(key, value, prev);
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
public delete(key: string): void {
|
|
70
|
+
this.dataByKey.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
public setNoNotify<T>(key: string, value: T): void {
|
|
68
74
|
this.dataByKey.set(key, value);
|
|
69
75
|
}
|
|
@@ -102,95 +108,91 @@ class PubSubStore {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
const
|
|
111
|
+
const pubSubStore = new PubSubStore();
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
setValue: (next: T | ((prev: T) => T)) => void;
|
|
113
|
-
};
|
|
113
|
+
const STORAGE_KEY = "state";
|
|
114
|
+
|
|
115
|
+
export type SharedStateOptions<T> = {
|
|
116
|
+
lazyLoad?: boolean;
|
|
117
|
+
} & StorageOptions<T>;
|
|
114
118
|
|
|
115
119
|
let stateKey = 0;
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
): SharedState<T> {
|
|
131
|
-
const { localStorageKey, indexedDbKey, persistExpiryMs, lazyLoad } =
|
|
132
|
-
options ?? {};
|
|
121
|
+
export class SharedState<T> {
|
|
122
|
+
private readonly pubSubKey: string;
|
|
123
|
+
private readonly storageAdapter: StorageAdapter<T> | null;
|
|
124
|
+
public initDone = false;
|
|
125
|
+
|
|
126
|
+
public constructor(
|
|
127
|
+
private readonly defaultValue: T,
|
|
128
|
+
options?: SharedStateOptions<T>,
|
|
129
|
+
) {
|
|
130
|
+
this.pubSubKey = String(stateKey++);
|
|
131
|
+
|
|
132
|
+
// Use max of 1 storage adapter per shared state.
|
|
133
|
+
this.storageAdapter = getStorageAdapter(options, DEFAULT_EXPIRY_DELTA_MS);
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
// Always set the default value first to avoid returning undefineds if that
|
|
136
|
+
// is not part of T.
|
|
137
|
+
pubSubStore.setNoNotify(this.pubSubKey, defaultValue);
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const expiryMs = persistExpiryMs ?? SharedStateConfig.defaultPersistExpiryMs;
|
|
139
|
+
const lazyLoad = options?.lazyLoad;
|
|
140
|
+
const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
|
|
139
141
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
persistenceAdapter = new IndexedDbAdapter<T>(indexedDbKey, expiryMs);
|
|
142
|
+
if (!lazy) {
|
|
143
|
+
void this.initDefaultValueOnce();
|
|
144
|
+
}
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
// `subscribe` function required by useSyncExternalStore
|
|
147
|
-
|
|
148
|
-
return
|
|
148
|
+
public subscribe(subscriber: () => void): () => void {
|
|
149
|
+
return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
// Get the value of the shared state.
|
|
151
153
|
// `getSnapshot` function — must return same ref if value unchanged
|
|
152
|
-
|
|
153
|
-
return
|
|
154
|
+
public getSnapshot(): T {
|
|
155
|
+
return pubSubStore.get(this.pubSubKey);
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
|
|
158
|
+
// Set the value of the shared state.
|
|
159
|
+
public setValue(next: T | ((prev: T) => T)): void {
|
|
157
160
|
if (typeof next === "function") {
|
|
158
|
-
const prev =
|
|
161
|
+
const prev = pubSubStore.get<T>(this.pubSubKey);
|
|
159
162
|
next = (next as (p: T) => T)(prev);
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
void
|
|
165
|
+
pubSubStore.set(this.pubSubKey, next);
|
|
166
|
+
void this.storageAdapter?.save(STORAGE_KEY, next);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
getSnapshot,
|
|
173
|
-
|
|
174
|
-
// initial default handling
|
|
175
|
-
initDefaultValueOnce: async () => {
|
|
176
|
-
if (state.initDone) return;
|
|
177
|
-
state.initDone = true;
|
|
178
|
-
|
|
179
|
-
const v = await persistenceAdapter?.load?.();
|
|
180
|
-
store.set(key, v !== undefined ? v : defaultValue);
|
|
181
|
-
},
|
|
182
|
-
initDone: false,
|
|
183
|
-
|
|
184
|
-
setValue,
|
|
185
|
-
};
|
|
169
|
+
// Remove the value of the shared state. Actually this just sets the value
|
|
170
|
+
// back to the given default value.
|
|
171
|
+
public delete() {
|
|
172
|
+
pubSubStore.set(this.pubSubKey, this.defaultValue);
|
|
173
|
+
void this.storageAdapter?.save(STORAGE_KEY, this.defaultValue);
|
|
174
|
+
}
|
|
186
175
|
|
|
187
|
-
|
|
176
|
+
// Initial default handling
|
|
177
|
+
public async initDefaultValueOnce() {
|
|
178
|
+
if (this.initDone) return;
|
|
179
|
+
this.initDone = true;
|
|
188
180
|
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
const v = await this.storageAdapter?.load(STORAGE_KEY);
|
|
182
|
+
pubSubStore.set(this.pubSubKey, v !== undefined ? v : this.defaultValue);
|
|
191
183
|
}
|
|
184
|
+
}
|
|
192
185
|
|
|
193
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Create a new shared state object. Put this code at the top level scope:
|
|
188
|
+
*
|
|
189
|
+
* const myState = sharedState<number>(0);
|
|
190
|
+
*/
|
|
191
|
+
export function sharedState<T>(
|
|
192
|
+
defaultValue: T,
|
|
193
|
+
options?: SharedStateOptions<T>,
|
|
194
|
+
): SharedState<T> {
|
|
195
|
+
return new SharedState(defaultValue, options);
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
/**
|
|
@@ -221,67 +223,3 @@ export function useSharedStateValue<T>(state: SharedState<T>) {
|
|
|
221
223
|
export function useSharedStateSetter<T>(state: SharedState<T>) {
|
|
222
224
|
return useSharedState(state)[1];
|
|
223
225
|
}
|
|
224
|
-
|
|
225
|
-
/************************************************************************/
|
|
226
|
-
/* Persistence adapters */
|
|
227
|
-
/************************************************************************/
|
|
228
|
-
|
|
229
|
-
/** Save state to local storage. */
|
|
230
|
-
type LSBox<T> = { value: T; expiresAt: number };
|
|
231
|
-
|
|
232
|
-
class LocalStorageAdapter<T> implements PersistenceAdapter<T> {
|
|
233
|
-
public constructor(
|
|
234
|
-
public readonly key: string,
|
|
235
|
-
public readonly expiryMs: number,
|
|
236
|
-
) {}
|
|
237
|
-
|
|
238
|
-
public save(value: T): void {
|
|
239
|
-
if (typeof window === "undefined") return;
|
|
240
|
-
|
|
241
|
-
window.localStorage.setItem(
|
|
242
|
-
this.key,
|
|
243
|
-
JSON.stringify({
|
|
244
|
-
value,
|
|
245
|
-
expiresAt: Date.now() + this.expiryMs,
|
|
246
|
-
} satisfies LSBox<T>),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
public load(): T | undefined {
|
|
251
|
-
if (typeof window === "undefined") return undefined;
|
|
252
|
-
|
|
253
|
-
const raw = window.localStorage.getItem(this.key);
|
|
254
|
-
if (!raw) return undefined;
|
|
255
|
-
|
|
256
|
-
const box = JSON.parse(raw) as LSBox<T>;
|
|
257
|
-
if (!box || !box?.expiresAt || Date.now() >= box.expiresAt) {
|
|
258
|
-
window.localStorage.removeItem(this.key);
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return box.value;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Save state to indexed db. */
|
|
267
|
-
class IndexedDbAdapter<T> implements PersistenceAdapter<T> {
|
|
268
|
-
public constructor(
|
|
269
|
-
public readonly key: string,
|
|
270
|
-
public readonly expiryMs: number,
|
|
271
|
-
) {}
|
|
272
|
-
|
|
273
|
-
public async save(value: T): Promise<void> {
|
|
274
|
-
if (typeof window === "undefined") return;
|
|
275
|
-
|
|
276
|
-
await kvStore.set(this.key, JSON.stringify(value), this.expiryMs);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
public async load(): Promise<T | undefined> {
|
|
280
|
-
if (typeof window === "undefined") return undefined;
|
|
281
|
-
|
|
282
|
-
const raw = await kvStore.get<string>(this.key);
|
|
283
|
-
if (raw === undefined) return undefined;
|
|
284
|
-
|
|
285
|
-
return JSON.parse(raw);
|
|
286
|
-
}
|
|
287
|
-
}
|