dev-react-microstore 5.0.0 → 6.0.0
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 +187 -220
- package/dist/index.d.mts +60 -16
- package/dist/index.d.ts +60 -16
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +16 -6
- package/src/hooks.test.tsx +271 -0
- package/src/index.ts +312 -122
- package/src/store.test.ts +997 -0
- package/src/types.test.ts +161 -0
- package/vitest.config.ts +10 -0
package/src/index.ts
CHANGED
|
@@ -8,19 +8,11 @@ type MiddlewareFunction<T extends object> = (
|
|
|
8
8
|
next: (modifiedUpdate?: Partial<T>) => void
|
|
9
9
|
) => void;
|
|
10
10
|
|
|
11
|
-
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
|
12
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
-
return ((...args: Parameters<T>) => {
|
|
14
|
-
clearTimeout(timeoutId);
|
|
15
|
-
timeoutId = setTimeout(() => fn(...args), delay);
|
|
16
|
-
}) as T;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
11
|
/**
|
|
20
12
|
* Creates a new reactive store with fine-grained subscriptions and middleware support.
|
|
21
13
|
*
|
|
22
14
|
* @param initialState - The initial state object for the store
|
|
23
|
-
* @returns Store object with methods: get, set, subscribe, select, addMiddleware
|
|
15
|
+
* @returns Store object with methods: get, set, subscribe, select, addMiddleware, onChange
|
|
24
16
|
*
|
|
25
17
|
* @example
|
|
26
18
|
* ```ts
|
|
@@ -32,20 +24,106 @@ function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
|
|
32
24
|
export function createStoreState<T extends object>(
|
|
33
25
|
initialState: T
|
|
34
26
|
) {
|
|
27
|
+
const _initialState = { ...initialState };
|
|
35
28
|
let state = initialState;
|
|
36
|
-
const keyListeners
|
|
37
|
-
const debouncedNotifiers = new Map<keyof T, () => void>();
|
|
29
|
+
const keyListeners: Record<string, Set<StoreListener> | undefined> = Object.create(null);
|
|
38
30
|
|
|
39
31
|
// Middleware storage
|
|
40
32
|
const middleware: Array<{ callback: MiddlewareFunction<T>; keys: (keyof T)[] | null }> = [];
|
|
41
33
|
|
|
34
|
+
// Per-key equality registry
|
|
35
|
+
const equalityRegistry: Record<string, ((prev: any, next: any) => boolean) | undefined> = Object.create(null);
|
|
42
36
|
|
|
37
|
+
// Batching
|
|
38
|
+
let batchedKeys: Set<keyof T> | null = null;
|
|
43
39
|
|
|
44
40
|
const get = () => state;
|
|
45
41
|
|
|
46
|
-
const
|
|
42
|
+
const getKey = <K extends keyof T>(key: K): T[K] => state[key];
|
|
43
|
+
|
|
44
|
+
const notifyKey = (key: keyof T) => {
|
|
45
|
+
if (batchedKeys) {
|
|
46
|
+
batchedKeys.add(key);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
keyListeners[key as string]?.forEach(listener => listener());
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const applySingleKey = <K extends keyof T>(key: K, value: T[K]) => {
|
|
53
|
+
if (Object.is(state[key], value)) return;
|
|
54
|
+
const eq = equalityRegistry[key as string];
|
|
55
|
+
if (eq && eq(state[key], value)) return;
|
|
56
|
+
state[key] = value;
|
|
57
|
+
notifyKey(key);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const setKey = <K extends keyof T>(key: K, value: T[K]) => {
|
|
61
|
+
if (middleware.length === 0) {
|
|
62
|
+
applySingleKey(key, value);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const partial = {} as Partial<T>;
|
|
66
|
+
partial[key] = value;
|
|
67
|
+
set(partial);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const merge = <K extends keyof T>(key: K, value: T[K] extends object ? Partial<T[K]> : never): T[K] => {
|
|
71
|
+
return { ...state[key], ...value };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const mergeSet = <K extends keyof T>(key: K, value: T[K] extends object ? Partial<T[K]> : never) => {
|
|
75
|
+
if (middleware.length === 0) {
|
|
76
|
+
applySingleKey(key, { ...state[key], ...value } as T[K]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const partial = {} as Partial<T>;
|
|
80
|
+
partial[key] = { ...state[key], ...value } as T[K];
|
|
81
|
+
set(partial);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const reset = (keys?: (keyof T)[]) => {
|
|
85
|
+
if (!keys) {
|
|
86
|
+
set({ ..._initialState });
|
|
87
|
+
} else {
|
|
88
|
+
const partial = {} as Partial<T>;
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
partial[key] = _initialState[key];
|
|
91
|
+
}
|
|
92
|
+
set(partial);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const batch = (fn: () => void) => {
|
|
97
|
+
if (batchedKeys) {
|
|
98
|
+
fn();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
batchedKeys = new Set();
|
|
102
|
+
try {
|
|
103
|
+
fn();
|
|
104
|
+
} finally {
|
|
105
|
+
const keys = batchedKeys;
|
|
106
|
+
batchedKeys = null;
|
|
107
|
+
const notified = new Set<StoreListener>();
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
keyListeners[key as string]?.forEach(listener => {
|
|
110
|
+
if (!notified.has(listener)) {
|
|
111
|
+
notified.add(listener);
|
|
112
|
+
listener();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const set = (update: Partial<T>) => {
|
|
47
120
|
if (!update) return;
|
|
48
121
|
|
|
122
|
+
if (middleware.length === 0) {
|
|
123
|
+
applyUpdate(update);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
49
127
|
// Run middleware chain
|
|
50
128
|
let currentUpdate = update;
|
|
51
129
|
let middlewareIndex = 0;
|
|
@@ -59,7 +137,7 @@ export function createStoreState<T extends object>(
|
|
|
59
137
|
if (middlewareIndex >= middleware.length) {
|
|
60
138
|
// All middleware processed, apply the update
|
|
61
139
|
if (!blocked) {
|
|
62
|
-
applyUpdate(currentUpdate
|
|
140
|
+
applyUpdate(currentUpdate);
|
|
63
141
|
}
|
|
64
142
|
return;
|
|
65
143
|
}
|
|
@@ -98,34 +176,41 @@ export function createStoreState<T extends object>(
|
|
|
98
176
|
runMiddleware();
|
|
99
177
|
};
|
|
100
178
|
|
|
101
|
-
const applyUpdate = (processedUpdate: Partial<T
|
|
102
|
-
const
|
|
179
|
+
const applyUpdate = (processedUpdate: Partial<T>) => {
|
|
180
|
+
const changedKeys: string[] = [];
|
|
103
181
|
|
|
182
|
+
// Phase 1: update all state before notifying
|
|
104
183
|
for (const key in processedUpdate) {
|
|
105
184
|
const typedKey = key as keyof T;
|
|
106
|
-
|
|
107
|
-
const
|
|
185
|
+
if (Object.is(state[typedKey], processedUpdate[typedKey])) continue;
|
|
186
|
+
const eq = equalityRegistry[key];
|
|
187
|
+
if (eq && eq(state[typedKey], processedUpdate[typedKey])) continue;
|
|
188
|
+
state[typedKey] = processedUpdate[typedKey]!;
|
|
189
|
+
changedKeys.push(key);
|
|
190
|
+
}
|
|
108
191
|
|
|
109
|
-
|
|
192
|
+
if (changedKeys.length === 0) return;
|
|
110
193
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
194
|
+
// Phase 2: notify after state is fully consistent
|
|
195
|
+
if (batchedKeys) {
|
|
196
|
+
for (const key of changedKeys) {
|
|
197
|
+
batchedKeys.add(key as keyof T);
|
|
114
198
|
}
|
|
199
|
+
return;
|
|
115
200
|
}
|
|
116
201
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
202
|
+
// Synchronous: deduplicate listeners across keys
|
|
203
|
+
if (changedKeys.length === 1) {
|
|
204
|
+
keyListeners[changedKeys[0]]?.forEach(listener => listener());
|
|
205
|
+
} else {
|
|
206
|
+
const notified = new Set<StoreListener>();
|
|
207
|
+
for (const key of changedKeys) {
|
|
208
|
+
keyListeners[key]?.forEach(listener => {
|
|
209
|
+
if (!notified.has(listener)) {
|
|
210
|
+
notified.add(listener);
|
|
211
|
+
listener();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
129
214
|
}
|
|
130
215
|
}
|
|
131
216
|
};
|
|
@@ -155,19 +240,16 @@ export function createStoreState<T extends object>(
|
|
|
155
240
|
};
|
|
156
241
|
};
|
|
157
242
|
|
|
158
|
-
|
|
159
|
-
|
|
160
243
|
const subscribe = (keys: (keyof T)[], listener: StoreListener): (() => void) => {
|
|
161
244
|
for (const key of keys) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
keyListeners.get(key)!.add(listener);
|
|
245
|
+
const k = key as string;
|
|
246
|
+
if (!keyListeners[k]) keyListeners[k] = new Set();
|
|
247
|
+
keyListeners[k]!.add(listener);
|
|
166
248
|
}
|
|
167
249
|
|
|
168
250
|
return () => {
|
|
169
251
|
for (const key of keys) {
|
|
170
|
-
keyListeners
|
|
252
|
+
keyListeners[key as string]?.delete(listener);
|
|
171
253
|
}
|
|
172
254
|
};
|
|
173
255
|
};
|
|
@@ -181,7 +263,52 @@ export function createStoreState<T extends object>(
|
|
|
181
263
|
return result;
|
|
182
264
|
};
|
|
183
265
|
|
|
184
|
-
|
|
266
|
+
const onChange = <K extends keyof T>(
|
|
267
|
+
keys: K[],
|
|
268
|
+
callback: (values: Pick<T, K>, prev: Pick<T, K>) => void
|
|
269
|
+
): (() => void) => {
|
|
270
|
+
let prev = {} as Pick<T, K>;
|
|
271
|
+
for (const key of keys) {
|
|
272
|
+
prev[key] = state[key];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let scheduled = false;
|
|
276
|
+
|
|
277
|
+
const listener = () => {
|
|
278
|
+
if (scheduled) return;
|
|
279
|
+
scheduled = true;
|
|
280
|
+
queueMicrotask(() => {
|
|
281
|
+
scheduled = false;
|
|
282
|
+
let hasChanges = false;
|
|
283
|
+
for (const key of keys) {
|
|
284
|
+
if (!Object.is(state[key], prev[key])) {
|
|
285
|
+
hasChanges = true;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!hasChanges) return;
|
|
290
|
+
const next = {} as Pick<T, K>;
|
|
291
|
+
for (const key of keys) {
|
|
292
|
+
next[key] = state[key];
|
|
293
|
+
}
|
|
294
|
+
const snapshot = prev;
|
|
295
|
+
prev = next;
|
|
296
|
+
callback(next, snapshot);
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return subscribe(keys, listener);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const skipSetWhen = <K extends keyof T>(key: K, fn: (prev: T[K], next: T[K]) => boolean) => {
|
|
304
|
+
equalityRegistry[key as string] = fn;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const removeSkipSetWhen = (key: keyof T) => {
|
|
308
|
+
delete equalityRegistry[key as string];
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
return { get, getKey, set, setKey, merge, mergeSet, reset, batch, subscribe, select, addMiddleware, onChange, skipSetWhen, removeSkipSetWhen, _eqReg: equalityRegistry };
|
|
185
312
|
}
|
|
186
313
|
|
|
187
314
|
type StoreType<T extends object> = ReturnType<typeof createStoreState<T>>;
|
|
@@ -237,28 +364,31 @@ export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
|
237
364
|
store: StoreType<T>,
|
|
238
365
|
selector: S
|
|
239
366
|
): Picked<T, S> {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
367
|
+
const ref = useRef({
|
|
368
|
+
lastSelected: {} as Partial<T>,
|
|
369
|
+
prevSelector: null as SelectorInput<T> | null,
|
|
370
|
+
normalized: null as NormalizedSelector<T>[] | null,
|
|
371
|
+
keys: null as (keyof T)[] | null,
|
|
372
|
+
isFirstRun: true,
|
|
373
|
+
lastValues: {} as Partial<T>,
|
|
374
|
+
subscribe: null as ((onStoreChange: () => void) => () => void) | null,
|
|
375
|
+
store,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const r = ref.current;
|
|
379
|
+
const storeChanged = r.store !== store;
|
|
250
380
|
if (storeChanged) {
|
|
251
|
-
|
|
252
|
-
lastSelected
|
|
253
|
-
prevSelector
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
lastValues
|
|
258
|
-
|
|
381
|
+
r.store = store;
|
|
382
|
+
r.lastSelected = {};
|
|
383
|
+
r.prevSelector = null;
|
|
384
|
+
r.normalized = null;
|
|
385
|
+
r.keys = null;
|
|
386
|
+
r.isFirstRun = true;
|
|
387
|
+
r.lastValues = {};
|
|
388
|
+
r.subscribe = null;
|
|
259
389
|
}
|
|
260
390
|
|
|
261
|
-
if (!prevSelector
|
|
391
|
+
if (!r.prevSelector || !shallowEqualSelector(r.prevSelector, selector)) {
|
|
262
392
|
const normalized: NormalizedSelector<T>[] = [];
|
|
263
393
|
const keys: (keyof T)[] = [];
|
|
264
394
|
|
|
@@ -278,63 +408,54 @@ export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
|
278
408
|
}
|
|
279
409
|
}
|
|
280
410
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
prevSelector
|
|
284
|
-
|
|
411
|
+
r.normalized = normalized;
|
|
412
|
+
r.keys = keys;
|
|
413
|
+
r.prevSelector = selector;
|
|
414
|
+
r.subscribe = null;
|
|
285
415
|
}
|
|
286
416
|
|
|
287
|
-
const normalized =
|
|
288
|
-
const keys =
|
|
417
|
+
const normalized = r.normalized!;
|
|
418
|
+
const keys = r.keys!;
|
|
289
419
|
|
|
290
420
|
const getSnapshot = () => {
|
|
291
421
|
const current = store.get();
|
|
292
|
-
const isFirstRun = isFirstRunRef.current;
|
|
293
422
|
|
|
294
|
-
if (isFirstRun) {
|
|
295
|
-
|
|
423
|
+
if (r.isFirstRun) {
|
|
424
|
+
r.isFirstRun = false;
|
|
296
425
|
const result = {} as Partial<T>;
|
|
297
426
|
for (const { key } of normalized) {
|
|
298
427
|
const value = current[key];
|
|
299
|
-
lastValues
|
|
428
|
+
r.lastValues[key] = value;
|
|
300
429
|
result[key] = value;
|
|
301
430
|
}
|
|
302
|
-
lastSelected
|
|
431
|
+
r.lastSelected = result;
|
|
303
432
|
return result as Picked<T, S>;
|
|
304
433
|
}
|
|
305
434
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
435
|
+
// Single pass — lazy allocation on first change
|
|
436
|
+
let result: Partial<T> | null = null;
|
|
437
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
438
|
+
const { key, compare } = normalized[i];
|
|
439
|
+
const prevVal = r.lastValues[key]!;
|
|
440
|
+
const nextVal = current[key];
|
|
441
|
+
if (!Object.is(prevVal, nextVal)) {
|
|
442
|
+
const cmp = compare || store._eqReg[key as string];
|
|
443
|
+
if (!cmp || !cmp(prevVal, nextVal)) {
|
|
444
|
+
if (!result) {
|
|
445
|
+
result = {} as Partial<T>;
|
|
446
|
+
for (let j = 0; j < i; j++) result[normalized[j].key] = r.lastValues[normalized[j].key];
|
|
447
|
+
}
|
|
448
|
+
r.lastValues[key] = nextVal;
|
|
449
|
+
result[key] = nextVal;
|
|
450
|
+
continue;
|
|
312
451
|
}
|
|
313
452
|
}
|
|
314
|
-
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
if (!hasChanges()) {
|
|
318
|
-
return lastSelected.current as Picked<T, S>;
|
|
453
|
+
if (result) result[key] = prevVal;
|
|
319
454
|
}
|
|
320
455
|
|
|
321
|
-
|
|
322
|
-
for (const { key, compare } of normalized) {
|
|
323
|
-
const prevVal = lastValues.current[key];
|
|
324
|
-
const nextVal = current[key];
|
|
456
|
+
if (!result) return r.lastSelected as Picked<T, S>;
|
|
325
457
|
|
|
326
|
-
|
|
327
|
-
const changed = isFirstTime || (compare ? !compare(prevVal, nextVal) : !Object.is(prevVal, nextVal));
|
|
328
|
-
|
|
329
|
-
if (changed) {
|
|
330
|
-
lastValues.current[key] = nextVal;
|
|
331
|
-
result[key] = nextVal;
|
|
332
|
-
} else {
|
|
333
|
-
result[key] = prevVal;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
lastSelected.current = result;
|
|
458
|
+
r.lastSelected = result;
|
|
338
459
|
return result as Picked<T, S>;
|
|
339
460
|
};
|
|
340
461
|
|
|
@@ -347,29 +468,66 @@ export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
|
347
468
|
return result as Picked<T, S>;
|
|
348
469
|
}, [keys]);
|
|
349
470
|
|
|
350
|
-
if (!
|
|
351
|
-
|
|
471
|
+
if (!r.subscribe || storeChanged) {
|
|
472
|
+
r.subscribe = (onStoreChange: () => void) =>
|
|
352
473
|
store.subscribe(keys, onStoreChange);
|
|
353
474
|
}
|
|
354
475
|
|
|
355
|
-
return useSyncExternalStore(
|
|
476
|
+
return useSyncExternalStore(r.subscribe, getSnapshot, () => staticSnapshot);
|
|
356
477
|
}
|
|
357
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Creates a pre-bound selector hook for a specific store instance.
|
|
481
|
+
* Infers the state type from the store — no manual generics needed.
|
|
482
|
+
*
|
|
483
|
+
* @param store - The store created with createStoreState
|
|
484
|
+
* @returns A React hook with the same API as useStoreSelector, but with the store already bound
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* ```ts
|
|
488
|
+
* const useMyStore = createSelectorHook(myStore);
|
|
489
|
+
*
|
|
490
|
+
* // In a component:
|
|
491
|
+
* const { count, name } = useMyStore(['count', 'name']);
|
|
492
|
+
* ```
|
|
493
|
+
*/
|
|
494
|
+
export function createSelectorHook<T extends object>(store: StoreType<T>) {
|
|
495
|
+
return function <S extends SelectorInput<T>>(selector: S): Picked<T, S> {
|
|
496
|
+
return useStoreSelector(store, selector);
|
|
497
|
+
};
|
|
498
|
+
}
|
|
358
499
|
|
|
359
500
|
/**
|
|
360
|
-
* Interface for storage
|
|
361
|
-
* Includes localStorage, sessionStorage, AsyncStorage, or any custom storage.
|
|
501
|
+
* Interface for synchronous storage (localStorage, sessionStorage, etc.).
|
|
362
502
|
*/
|
|
363
503
|
export interface StorageSupportingInterface {
|
|
364
504
|
getItem(key: string): string | null;
|
|
365
505
|
setItem(key: string, value: string): void;
|
|
366
506
|
}
|
|
367
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Interface for asynchronous storage (React Native AsyncStorage, etc.).
|
|
510
|
+
*/
|
|
511
|
+
export interface AsyncStorageSupportingInterface {
|
|
512
|
+
getItem(key: string): Promise<string | null>;
|
|
513
|
+
setItem(key: string, value: string): Promise<void>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
type AnyStorage = Storage | StorageSupportingInterface | AsyncStorageSupportingInterface;
|
|
517
|
+
|
|
518
|
+
function isThenable(value: unknown): value is Promise<unknown> {
|
|
519
|
+
return value != null && typeof (value as any).then === 'function';
|
|
520
|
+
}
|
|
521
|
+
|
|
368
522
|
/**
|
|
369
523
|
* Creates a persistence middleware that saves individual keys to storage.
|
|
370
524
|
* Only writes when the specified keys actually change, using per-key storage.
|
|
371
525
|
* Storage format: `${persistKey}:${keyName}` for each persisted key.
|
|
372
526
|
*
|
|
527
|
+
* Works with both synchronous storage (localStorage) and asynchronous storage
|
|
528
|
+
* (React Native AsyncStorage). Async writes are fire-and-forget — the state
|
|
529
|
+
* update is never blocked by a slow write.
|
|
530
|
+
*
|
|
373
531
|
* @param storage - Storage interface (localStorage, sessionStorage, AsyncStorage, etc.)
|
|
374
532
|
* @param persistKey - Base key prefix for storage (e.g., 'myapp' creates 'myapp:theme')
|
|
375
533
|
* @param keys - Array of state keys to persist
|
|
@@ -377,30 +535,38 @@ export interface StorageSupportingInterface {
|
|
|
377
535
|
*
|
|
378
536
|
* @example
|
|
379
537
|
* ```ts
|
|
380
|
-
* //
|
|
538
|
+
* // Sync — localStorage
|
|
381
539
|
* store.addMiddleware(
|
|
382
540
|
* createPersistenceMiddleware(localStorage, 'myapp', ['theme', 'isLoggedIn'])
|
|
383
541
|
* );
|
|
542
|
+
*
|
|
543
|
+
* // Async — React Native AsyncStorage
|
|
544
|
+
* store.addMiddleware(
|
|
545
|
+
* createPersistenceMiddleware(AsyncStorage, 'myapp', ['theme', 'isLoggedIn'])
|
|
546
|
+
* );
|
|
384
547
|
* ```
|
|
385
548
|
*/
|
|
386
549
|
export function createPersistenceMiddleware<T extends object>(
|
|
387
|
-
storage:
|
|
550
|
+
storage: AnyStorage,
|
|
388
551
|
persistKey: string,
|
|
389
552
|
keys: (keyof T)[]
|
|
390
553
|
): [MiddlewareFunction<T>, (keyof T)[]] {
|
|
391
554
|
const middlewareFunction: MiddlewareFunction<T> = (_, update, next) => {
|
|
392
|
-
// Check if any of the persisted keys are being updated
|
|
393
555
|
const changedKeys = keys.filter(key => key in update);
|
|
394
556
|
if (changedKeys.length === 0) {
|
|
395
557
|
return next();
|
|
396
558
|
}
|
|
397
559
|
|
|
398
|
-
// Save each changed key individually
|
|
399
560
|
for (const key of changedKeys) {
|
|
400
561
|
try {
|
|
401
562
|
const value = update[key];
|
|
402
563
|
const storageKey = `${persistKey}:${String(key)}`;
|
|
403
|
-
storage.setItem(storageKey, JSON.stringify(value));
|
|
564
|
+
const result = storage.setItem(storageKey, JSON.stringify(value));
|
|
565
|
+
if (isThenable(result)) {
|
|
566
|
+
result.catch(error => {
|
|
567
|
+
console.warn(`Failed to persist key ${String(key)}:`, error);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
404
570
|
} catch (error) {
|
|
405
571
|
console.warn(`Failed to persist key ${String(key)}:`, error);
|
|
406
572
|
}
|
|
@@ -416,41 +582,65 @@ export function createPersistenceMiddleware<T extends object>(
|
|
|
416
582
|
* Loads persisted state from individual key storage during store initialization.
|
|
417
583
|
* Reads keys saved by createPersistenceMiddleware and returns them as partial state.
|
|
418
584
|
*
|
|
585
|
+
* Returns synchronously for sync storage and a Promise for async storage.
|
|
586
|
+
*
|
|
419
587
|
* @param storage - Storage interface to read from (same as used in middleware)
|
|
420
588
|
* @param persistKey - Base key prefix used for storage (same as used in middleware)
|
|
421
589
|
* @param keys - Array of keys to restore (should match middleware keys)
|
|
422
|
-
* @returns Partial state object
|
|
590
|
+
* @returns Partial state object (sync) or Promise of partial state (async)
|
|
423
591
|
*
|
|
424
592
|
* @example
|
|
425
593
|
* ```ts
|
|
426
|
-
* //
|
|
427
|
-
* const
|
|
594
|
+
* // Sync — localStorage
|
|
595
|
+
* const persisted = loadPersistedState(localStorage, 'myapp', ['theme']);
|
|
596
|
+
* const store = createStoreState({ theme: 'light', ...persisted });
|
|
428
597
|
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* ...persistedState // Apply persisted values
|
|
433
|
-
* });
|
|
598
|
+
* // Async — React Native AsyncStorage
|
|
599
|
+
* const persisted = await loadPersistedState(AsyncStorage, 'myapp', ['theme']);
|
|
600
|
+
* const store = createStoreState({ theme: 'light', ...persisted });
|
|
434
601
|
* ```
|
|
435
602
|
*/
|
|
436
603
|
export function loadPersistedState<T extends object>(
|
|
437
604
|
storage: Storage | StorageSupportingInterface,
|
|
438
605
|
persistKey: string,
|
|
439
606
|
keys: (keyof T)[]
|
|
440
|
-
): Partial<T
|
|
607
|
+
): Partial<T>;
|
|
608
|
+
export function loadPersistedState<T extends object>(
|
|
609
|
+
storage: AsyncStorageSupportingInterface,
|
|
610
|
+
persistKey: string,
|
|
611
|
+
keys: (keyof T)[]
|
|
612
|
+
): Promise<Partial<T>>;
|
|
613
|
+
export function loadPersistedState<T extends object>(
|
|
614
|
+
storage: AnyStorage,
|
|
615
|
+
persistKey: string,
|
|
616
|
+
keys: (keyof T)[]
|
|
617
|
+
): Partial<T> | Promise<Partial<T>> {
|
|
441
618
|
const result: Partial<T> = {};
|
|
442
|
-
|
|
619
|
+
const pending: Promise<void>[] = [];
|
|
620
|
+
|
|
443
621
|
for (const key of keys) {
|
|
622
|
+
const storageKey = `${persistKey}:${String(key)}`;
|
|
444
623
|
try {
|
|
445
|
-
const storageKey = `${persistKey}:${String(key)}`;
|
|
446
624
|
const stored = storage.getItem(storageKey);
|
|
447
|
-
if (stored
|
|
448
|
-
|
|
625
|
+
if (isThenable(stored)) {
|
|
626
|
+
pending.push(
|
|
627
|
+
stored.then(value => {
|
|
628
|
+
if (value !== null) result[key] = JSON.parse(value as string);
|
|
629
|
+
}).catch(error => {
|
|
630
|
+
console.warn(`Failed to load persisted key ${String(key)}:`, error);
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
} else if (stored !== null) {
|
|
634
|
+
result[key] = JSON.parse(stored as string);
|
|
449
635
|
}
|
|
450
636
|
} catch (error) {
|
|
451
637
|
console.warn(`Failed to load persisted key ${String(key)}:`, error);
|
|
452
638
|
}
|
|
453
639
|
}
|
|
454
|
-
|
|
640
|
+
|
|
641
|
+
if (pending.length > 0) {
|
|
642
|
+
return Promise.all(pending).then(() => result);
|
|
643
|
+
}
|
|
644
|
+
|
|
455
645
|
return result;
|
|
456
646
|
}
|