@choksheak/react-utils 0.1.6 → 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 +5 -5
- 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 +21 -16
- package/sharedState.d.ts +19 -15
- package/sharedState.js +156 -85
- package/sharedState.mjs +155 -85
- package/src/sharedQuery.ts +303 -0
- package/src/sharedState.ts +74 -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,13 +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
|
|
26
32
|
* is 30 days, but can be changed to anything.
|
|
27
33
|
*/
|
|
28
|
-
public static
|
|
34
|
+
public static storeExpiryMs = MS_PER_DAY * 30;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
|
-
* True to load from
|
|
37
|
+
* True to load from storage only on mount, false (default) to load in
|
|
32
38
|
* the top level scope when the shared state is defined.
|
|
33
39
|
*/
|
|
34
40
|
public static lazyLoad = false;
|
|
@@ -40,11 +46,6 @@ export class SharedStateConfig {
|
|
|
40
46
|
|
|
41
47
|
type Subscriber<T> = (next: T, prev: T) => void;
|
|
42
48
|
|
|
43
|
-
type PersistenceAdapter<T> = {
|
|
44
|
-
save?: (value: T) => Promise<void> | void;
|
|
45
|
-
load?: () => Promise<T | undefined> | T | undefined;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
49
|
class PubSubStore {
|
|
49
50
|
private dataByKey = new Map<string, unknown>();
|
|
50
51
|
private subscribersByKey = new Map<string, Set<Subscriber<unknown>>>();
|
|
@@ -65,6 +66,10 @@ class PubSubStore {
|
|
|
65
66
|
this.notify(key, value, prev);
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
public delete(key: string): void {
|
|
70
|
+
this.dataByKey.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
public setNoNotify<T>(key: string, value: T): void {
|
|
69
74
|
this.dataByKey.set(key, value);
|
|
70
75
|
}
|
|
@@ -103,95 +108,91 @@ class PubSubStore {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
const
|
|
111
|
+
const pubSubStore = new PubSubStore();
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
setValue: (next: T | ((prev: T) => T)) => void;
|
|
114
|
-
};
|
|
113
|
+
const STORAGE_KEY = "state";
|
|
114
|
+
|
|
115
|
+
export type SharedStateOptions<T> = {
|
|
116
|
+
lazyLoad?: boolean;
|
|
117
|
+
} & StorageOptions<T>;
|
|
115
118
|
|
|
116
119
|
let stateKey = 0;
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
): SharedState<T> {
|
|
132
|
-
const { localStorageKey, indexedDbKey, persistExpiryMs, lazyLoad } =
|
|
133
|
-
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);
|
|
134
134
|
|
|
135
|
-
|
|
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);
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const expiryMs = persistExpiryMs ?? SharedStateConfig.persistExpiryMs;
|
|
139
|
+
const lazyLoad = options?.lazyLoad;
|
|
140
|
+
const lazy = lazyLoad !== undefined ? lazyLoad : SharedStateConfig.lazyLoad;
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
persistenceAdapter = new IndexedDbAdapter<T>(indexedDbKey, expiryMs);
|
|
142
|
+
if (!lazy) {
|
|
143
|
+
void this.initDefaultValueOnce();
|
|
144
|
+
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
// `subscribe` function required by useSyncExternalStore
|
|
148
|
-
|
|
149
|
-
return
|
|
148
|
+
public subscribe(subscriber: () => void): () => void {
|
|
149
|
+
return pubSubStore.subscribe(this.pubSubKey, () => subscriber());
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Get the value of the shared state.
|
|
152
153
|
// `getSnapshot` function — must return same ref if value unchanged
|
|
153
|
-
|
|
154
|
-
return
|
|
154
|
+
public getSnapshot(): T {
|
|
155
|
+
return pubSubStore.get(this.pubSubKey);
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
+
// Set the value of the shared state.
|
|
159
|
+
public setValue(next: T | ((prev: T) => T)): void {
|
|
158
160
|
if (typeof next === "function") {
|
|
159
|
-
const prev =
|
|
161
|
+
const prev = pubSubStore.get<T>(this.pubSubKey);
|
|
160
162
|
next = (next as (p: T) => T)(prev);
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
void
|
|
165
|
+
pubSubStore.set(this.pubSubKey, next);
|
|
166
|
+
void this.storageAdapter?.save(STORAGE_KEY, next);
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
getSnapshot,
|
|
174
|
-
|
|
175
|
-
// initial default handling
|
|
176
|
-
initDefaultValueOnce: async () => {
|
|
177
|
-
if (state.initDone) return;
|
|
178
|
-
state.initDone = true;
|
|
179
|
-
|
|
180
|
-
const v = await persistenceAdapter?.load?.();
|
|
181
|
-
store.set(key, v !== undefined ? v : defaultValue);
|
|
182
|
-
},
|
|
183
|
-
initDone: false,
|
|
184
|
-
|
|
185
|
-
setValue,
|
|
186
|
-
};
|
|
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
|
+
}
|
|
187
175
|
|
|
188
|
-
|
|
176
|
+
// Initial default handling
|
|
177
|
+
public async initDefaultValueOnce() {
|
|
178
|
+
if (this.initDone) return;
|
|
179
|
+
this.initDone = true;
|
|
189
180
|
|
|
190
|
-
|
|
191
|
-
|
|
181
|
+
const v = await this.storageAdapter?.load(STORAGE_KEY);
|
|
182
|
+
pubSubStore.set(this.pubSubKey, v !== undefined ? v : this.defaultValue);
|
|
192
183
|
}
|
|
184
|
+
}
|
|
193
185
|
|
|
194
|
-
|
|
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);
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
/**
|
|
@@ -222,67 +223,3 @@ export function useSharedStateValue<T>(state: SharedState<T>) {
|
|
|
222
223
|
export function useSharedStateSetter<T>(state: SharedState<T>) {
|
|
223
224
|
return useSharedState(state)[1];
|
|
224
225
|
}
|
|
225
|
-
|
|
226
|
-
/************************************************************************/
|
|
227
|
-
/* Persistence adapters */
|
|
228
|
-
/************************************************************************/
|
|
229
|
-
|
|
230
|
-
/** Save state to local storage. */
|
|
231
|
-
type LSBox<T> = { value: T; expiresAt: number };
|
|
232
|
-
|
|
233
|
-
class LocalStorageAdapter<T> implements PersistenceAdapter<T> {
|
|
234
|
-
public constructor(
|
|
235
|
-
public readonly key: string,
|
|
236
|
-
public readonly expiryMs: number,
|
|
237
|
-
) {}
|
|
238
|
-
|
|
239
|
-
public save(value: T): void {
|
|
240
|
-
if (typeof window === "undefined") return;
|
|
241
|
-
|
|
242
|
-
window.localStorage.setItem(
|
|
243
|
-
this.key,
|
|
244
|
-
JSON.stringify({
|
|
245
|
-
value,
|
|
246
|
-
expiresAt: Date.now() + this.expiryMs,
|
|
247
|
-
} satisfies LSBox<T>),
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
public load(): T | undefined {
|
|
252
|
-
if (typeof window === "undefined") return undefined;
|
|
253
|
-
|
|
254
|
-
const raw = window.localStorage.getItem(this.key);
|
|
255
|
-
if (!raw) return undefined;
|
|
256
|
-
|
|
257
|
-
const box = JSON.parse(raw) as LSBox<T>;
|
|
258
|
-
if (!box || !box?.expiresAt || Date.now() >= box.expiresAt) {
|
|
259
|
-
window.localStorage.removeItem(this.key);
|
|
260
|
-
return undefined;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return box.value;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Save state to indexed db. */
|
|
268
|
-
class IndexedDbAdapter<T> implements PersistenceAdapter<T> {
|
|
269
|
-
public constructor(
|
|
270
|
-
public readonly key: string,
|
|
271
|
-
public readonly expiryMs: number,
|
|
272
|
-
) {}
|
|
273
|
-
|
|
274
|
-
public async save(value: T): Promise<void> {
|
|
275
|
-
if (typeof window === "undefined") return;
|
|
276
|
-
|
|
277
|
-
await kvStore.set(this.key, JSON.stringify(value), this.expiryMs);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
public async load(): Promise<T | undefined> {
|
|
281
|
-
if (typeof window === "undefined") return undefined;
|
|
282
|
-
|
|
283
|
-
const raw = await kvStore.get<string>(this.key);
|
|
284
|
-
if (raw === undefined) return undefined;
|
|
285
|
-
|
|
286
|
-
return JSON.parse(raw);
|
|
287
|
-
}
|
|
288
|
-
}
|