dev-react-microstore 4.0.1 → 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 +208 -142
- package/dist/index.d.mts +129 -2
- package/dist/index.d.ts +129 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/example/README.md +54 -0
- package/example/eslint.config.js +28 -0
- package/example/index.html +13 -0
- package/example/package-lock.json +3382 -0
- package/example/package.json +29 -0
- package/example/public/index.html +98 -0
- package/example/public/vite.svg +1 -0
- package/example/src/App.css +613 -0
- package/example/src/App.tsx +34 -0
- package/example/src/assets/react.svg +1 -0
- package/example/src/components/Counter.tsx +112 -0
- package/example/src/components/CustomCompare.tsx +466 -0
- package/example/src/components/Logs.tsx +28 -0
- package/example/src/components/Search.tsx +38 -0
- package/example/src/components/ThemeToggle.tsx +25 -0
- package/example/src/components/TodoList.tsx +63 -0
- package/example/src/components/UserManager.tsx +68 -0
- package/example/src/index.css +68 -0
- package/example/src/main.tsx +10 -0
- package/example/src/store.ts +223 -0
- package/example/src/vite-env.d.ts +1 -0
- package/example/tsconfig.app.json +26 -0
- package/example/tsconfig.json +7 -0
- package/example/tsconfig.node.json +25 -0
- package/example/vite.config.ts +7 -0
- package/package.json +22 -5
- package/src/hooks.test.tsx +271 -0
- package/src/index.ts +514 -87
- 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
|
@@ -2,67 +2,254 @@ import { useMemo, useRef, useSyncExternalStore } from 'react'
|
|
|
2
2
|
|
|
3
3
|
type StoreListener = () => void;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
type MiddlewareFunction<T extends object> = (
|
|
6
|
+
currentState: T,
|
|
7
|
+
update: Partial<T>,
|
|
8
|
+
next: (modifiedUpdate?: Partial<T>) => void
|
|
9
|
+
) => void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a new reactive store with fine-grained subscriptions and middleware support.
|
|
13
|
+
*
|
|
14
|
+
* @param initialState - The initial state object for the store
|
|
15
|
+
* @returns Store object with methods: get, set, subscribe, select, addMiddleware, onChange
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const store = createStoreState({ count: 0, name: 'John' });
|
|
20
|
+
* store.set({ count: 1 }); // Update state
|
|
21
|
+
* const { count } = useStoreSelector(store, ['count']); // Subscribe in React
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
13
24
|
export function createStoreState<T extends object>(
|
|
14
|
-
initialState: T
|
|
25
|
+
initialState: T
|
|
15
26
|
) {
|
|
27
|
+
const _initialState = { ...initialState };
|
|
16
28
|
let state = initialState;
|
|
17
|
-
const keyListeners
|
|
18
|
-
|
|
29
|
+
const keyListeners: Record<string, Set<StoreListener> | undefined> = Object.create(null);
|
|
30
|
+
|
|
31
|
+
// Middleware storage
|
|
32
|
+
const middleware: Array<{ callback: MiddlewareFunction<T>; keys: (keyof T)[] | null }> = [];
|
|
33
|
+
|
|
34
|
+
// Per-key equality registry
|
|
35
|
+
const equalityRegistry: Record<string, ((prev: any, next: any) => boolean) | undefined> = Object.create(null);
|
|
36
|
+
|
|
37
|
+
// Batching
|
|
38
|
+
let batchedKeys: Set<keyof T> | null = null;
|
|
19
39
|
|
|
20
40
|
const get = () => state;
|
|
21
41
|
|
|
22
|
-
const
|
|
23
|
-
if (!next) return;
|
|
42
|
+
const getKey = <K extends keyof T>(key: K): T[K] => state[key];
|
|
24
43
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
};
|
|
30
51
|
|
|
31
|
-
|
|
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
|
+
};
|
|
32
69
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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];
|
|
36
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>) => {
|
|
120
|
+
if (!update) return;
|
|
121
|
+
|
|
122
|
+
if (middleware.length === 0) {
|
|
123
|
+
applyUpdate(update);
|
|
124
|
+
return;
|
|
37
125
|
}
|
|
38
126
|
|
|
39
|
-
|
|
127
|
+
// Run middleware chain
|
|
128
|
+
let currentUpdate = update;
|
|
129
|
+
let middlewareIndex = 0;
|
|
130
|
+
let blocked = false;
|
|
40
131
|
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
132
|
+
const runMiddleware = (modifiedUpdate?: Partial<T>) => {
|
|
133
|
+
if (modifiedUpdate !== undefined) {
|
|
134
|
+
currentUpdate = modifiedUpdate;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (middlewareIndex >= middleware.length) {
|
|
138
|
+
// All middleware processed, apply the update
|
|
139
|
+
if (!blocked) {
|
|
140
|
+
applyUpdate(currentUpdate);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const currentMiddleware = middleware[middlewareIndex++];
|
|
146
|
+
|
|
147
|
+
// Check if middleware applies to these keys
|
|
148
|
+
if (!currentMiddleware.keys || currentMiddleware.keys.some(key => key in currentUpdate)) {
|
|
149
|
+
let nextCalled = false;
|
|
150
|
+
|
|
151
|
+
const next = (modifiedUpdate?: Partial<T>) => {
|
|
152
|
+
if (nextCalled) return; // Prevent multiple calls
|
|
153
|
+
nextCalled = true;
|
|
154
|
+
runMiddleware(modifiedUpdate);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
currentMiddleware.callback(state, currentUpdate, next);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
blocked = true;
|
|
161
|
+
console.error('Middleware error:', error);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If next() wasn't called, the middleware blocked the update
|
|
166
|
+
if (!nextCalled) {
|
|
167
|
+
blocked = true;
|
|
168
|
+
return;
|
|
47
169
|
}
|
|
48
|
-
debouncedNotifiers.get(key)!();
|
|
49
170
|
} else {
|
|
50
|
-
|
|
171
|
+
// Skip this middleware
|
|
172
|
+
runMiddleware();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
runMiddleware();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const applyUpdate = (processedUpdate: Partial<T>) => {
|
|
180
|
+
const changedKeys: string[] = [];
|
|
181
|
+
|
|
182
|
+
// Phase 1: update all state before notifying
|
|
183
|
+
for (const key in processedUpdate) {
|
|
184
|
+
const typedKey = key as keyof T;
|
|
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
|
+
}
|
|
191
|
+
|
|
192
|
+
if (changedKeys.length === 0) return;
|
|
193
|
+
|
|
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);
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
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
|
+
});
|
|
51
214
|
}
|
|
52
215
|
}
|
|
53
216
|
};
|
|
54
217
|
|
|
218
|
+
const addMiddleware = (
|
|
219
|
+
callbackOrTuple: MiddlewareFunction<T> | [MiddlewareFunction<T>, (keyof T)[]],
|
|
220
|
+
affectedKeys: (keyof T)[] | null = null
|
|
221
|
+
) => {
|
|
222
|
+
let callback: MiddlewareFunction<T>;
|
|
223
|
+
let keys: (keyof T)[] | null;
|
|
224
|
+
|
|
225
|
+
if (Array.isArray(callbackOrTuple)) {
|
|
226
|
+
[callback, keys] = callbackOrTuple;
|
|
227
|
+
} else {
|
|
228
|
+
callback = callbackOrTuple;
|
|
229
|
+
keys = affectedKeys;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const middlewareItem = { callback, keys };
|
|
233
|
+
middleware.push(middlewareItem);
|
|
234
|
+
|
|
235
|
+
return () => {
|
|
236
|
+
const index = middleware.indexOf(middlewareItem);
|
|
237
|
+
if (index > -1) {
|
|
238
|
+
middleware.splice(index, 1);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
55
243
|
const subscribe = (keys: (keyof T)[], listener: StoreListener): (() => void) => {
|
|
56
244
|
for (const key of keys) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
keyListeners.get(key)!.add(listener);
|
|
245
|
+
const k = key as string;
|
|
246
|
+
if (!keyListeners[k]) keyListeners[k] = new Set();
|
|
247
|
+
keyListeners[k]!.add(listener);
|
|
61
248
|
}
|
|
62
249
|
|
|
63
250
|
return () => {
|
|
64
251
|
for (const key of keys) {
|
|
65
|
-
keyListeners
|
|
252
|
+
keyListeners[key as string]?.delete(listener);
|
|
66
253
|
}
|
|
67
254
|
};
|
|
68
255
|
};
|
|
@@ -76,7 +263,52 @@ export function createStoreState<T extends object>(
|
|
|
76
263
|
return result;
|
|
77
264
|
};
|
|
78
265
|
|
|
79
|
-
|
|
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 };
|
|
80
312
|
}
|
|
81
313
|
|
|
82
314
|
type StoreType<T extends object> = ReturnType<typeof createStoreState<T>>;
|
|
@@ -109,19 +341,54 @@ function shallowEqualSelector<T extends object>(
|
|
|
109
341
|
return a.length === b.length && a.every((item, i) => item === b[i]);
|
|
110
342
|
}
|
|
111
343
|
|
|
344
|
+
/**
|
|
345
|
+
* React hook that subscribes to specific keys in a store with fine-grained re-renders.
|
|
346
|
+
* Only re-renders when the selected keys actually change (using Object.is comparison).
|
|
347
|
+
*
|
|
348
|
+
* @param store - The store created with createStoreState
|
|
349
|
+
* @param selector - Array of keys to subscribe to, or objects with custom compare functions
|
|
350
|
+
* @returns Selected state values from the store
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* ```ts
|
|
354
|
+
* // Subscribe to specific keys
|
|
355
|
+
* const { count, name } = useStoreSelector(store, ['count', 'name']);
|
|
356
|
+
*
|
|
357
|
+
* // Custom comparison for complex objects
|
|
358
|
+
* const { tasks } = useStoreSelector(store, [
|
|
359
|
+
* { tasks: (prev, next) => prev.length === next.length }
|
|
360
|
+
* ]);
|
|
361
|
+
* ```
|
|
362
|
+
*/
|
|
112
363
|
export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
113
364
|
store: StoreType<T>,
|
|
114
365
|
selector: S
|
|
115
366
|
): Picked<T, S> {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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;
|
|
380
|
+
if (storeChanged) {
|
|
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;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!r.prevSelector || !shallowEqualSelector(r.prevSelector, selector)) {
|
|
125
392
|
const normalized: NormalizedSelector<T>[] = [];
|
|
126
393
|
const keys: (keyof T)[] = [];
|
|
127
394
|
|
|
@@ -141,63 +408,54 @@ export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
|
141
408
|
}
|
|
142
409
|
}
|
|
143
410
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
prevSelector
|
|
147
|
-
|
|
411
|
+
r.normalized = normalized;
|
|
412
|
+
r.keys = keys;
|
|
413
|
+
r.prevSelector = selector;
|
|
414
|
+
r.subscribe = null;
|
|
148
415
|
}
|
|
149
416
|
|
|
150
|
-
const normalized =
|
|
151
|
-
const keys =
|
|
417
|
+
const normalized = r.normalized!;
|
|
418
|
+
const keys = r.keys!;
|
|
152
419
|
|
|
153
420
|
const getSnapshot = () => {
|
|
154
421
|
const current = store.get();
|
|
155
|
-
const isFirstRun = isFirstRunRef.current;
|
|
156
422
|
|
|
157
|
-
if (isFirstRun) {
|
|
158
|
-
|
|
423
|
+
if (r.isFirstRun) {
|
|
424
|
+
r.isFirstRun = false;
|
|
159
425
|
const result = {} as Partial<T>;
|
|
160
426
|
for (const { key } of normalized) {
|
|
161
427
|
const value = current[key];
|
|
162
|
-
lastValues
|
|
428
|
+
r.lastValues[key] = value;
|
|
163
429
|
result[key] = value;
|
|
164
430
|
}
|
|
165
|
-
lastSelected
|
|
431
|
+
r.lastSelected = result;
|
|
166
432
|
return result as Picked<T, S>;
|
|
167
433
|
}
|
|
168
434
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
451
|
}
|
|
176
452
|
}
|
|
177
|
-
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
if (!hasChanges()) {
|
|
181
|
-
return lastSelected.current as Picked<T, S>;
|
|
453
|
+
if (result) result[key] = prevVal;
|
|
182
454
|
}
|
|
183
455
|
|
|
184
|
-
|
|
185
|
-
for (const { key, compare } of normalized) {
|
|
186
|
-
const prevVal = lastValues.current[key];
|
|
187
|
-
const nextVal = current[key];
|
|
188
|
-
|
|
189
|
-
const isFirstTime = prevVal === undefined;
|
|
190
|
-
const changed = isFirstTime || (compare ? !compare(prevVal, nextVal) : !Object.is(prevVal, nextVal));
|
|
456
|
+
if (!result) return r.lastSelected as Picked<T, S>;
|
|
191
457
|
|
|
192
|
-
|
|
193
|
-
lastValues.current[key] = nextVal;
|
|
194
|
-
result[key] = nextVal;
|
|
195
|
-
} else {
|
|
196
|
-
result[key] = prevVal;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
lastSelected.current = result;
|
|
458
|
+
r.lastSelected = result;
|
|
201
459
|
return result as Picked<T, S>;
|
|
202
460
|
};
|
|
203
461
|
|
|
@@ -210,10 +468,179 @@ export function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
|
210
468
|
return result as Picked<T, S>;
|
|
211
469
|
}, [keys]);
|
|
212
470
|
|
|
213
|
-
if (!
|
|
214
|
-
|
|
471
|
+
if (!r.subscribe || storeChanged) {
|
|
472
|
+
r.subscribe = (onStoreChange: () => void) =>
|
|
215
473
|
store.subscribe(keys, onStoreChange);
|
|
216
474
|
}
|
|
217
475
|
|
|
218
|
-
return useSyncExternalStore(
|
|
476
|
+
return useSyncExternalStore(r.subscribe, getSnapshot, () => staticSnapshot);
|
|
477
|
+
}
|
|
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
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Interface for synchronous storage (localStorage, sessionStorage, etc.).
|
|
502
|
+
*/
|
|
503
|
+
export interface StorageSupportingInterface {
|
|
504
|
+
getItem(key: string): string | null;
|
|
505
|
+
setItem(key: string, value: string): void;
|
|
506
|
+
}
|
|
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
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Creates a persistence middleware that saves individual keys to storage.
|
|
524
|
+
* Only writes when the specified keys actually change, using per-key storage.
|
|
525
|
+
* Storage format: `${persistKey}:${keyName}` for each persisted key.
|
|
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
|
+
*
|
|
531
|
+
* @param storage - Storage interface (localStorage, sessionStorage, AsyncStorage, etc.)
|
|
532
|
+
* @param persistKey - Base key prefix for storage (e.g., 'myapp' creates 'myapp:theme')
|
|
533
|
+
* @param keys - Array of state keys to persist
|
|
534
|
+
* @returns Tuple of [middleware function, affected keys] for use with addMiddleware
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```ts
|
|
538
|
+
* // Sync — localStorage
|
|
539
|
+
* store.addMiddleware(
|
|
540
|
+
* createPersistenceMiddleware(localStorage, 'myapp', ['theme', 'isLoggedIn'])
|
|
541
|
+
* );
|
|
542
|
+
*
|
|
543
|
+
* // Async — React Native AsyncStorage
|
|
544
|
+
* store.addMiddleware(
|
|
545
|
+
* createPersistenceMiddleware(AsyncStorage, 'myapp', ['theme', 'isLoggedIn'])
|
|
546
|
+
* );
|
|
547
|
+
* ```
|
|
548
|
+
*/
|
|
549
|
+
export function createPersistenceMiddleware<T extends object>(
|
|
550
|
+
storage: AnyStorage,
|
|
551
|
+
persistKey: string,
|
|
552
|
+
keys: (keyof T)[]
|
|
553
|
+
): [MiddlewareFunction<T>, (keyof T)[]] {
|
|
554
|
+
const middlewareFunction: MiddlewareFunction<T> = (_, update, next) => {
|
|
555
|
+
const changedKeys = keys.filter(key => key in update);
|
|
556
|
+
if (changedKeys.length === 0) {
|
|
557
|
+
return next();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
for (const key of changedKeys) {
|
|
561
|
+
try {
|
|
562
|
+
const value = update[key];
|
|
563
|
+
const storageKey = `${persistKey}:${String(key)}`;
|
|
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
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
console.warn(`Failed to persist key ${String(key)}:`, error);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
next();
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
return [middlewareFunction, keys];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Loads persisted state from individual key storage during store initialization.
|
|
583
|
+
* Reads keys saved by createPersistenceMiddleware and returns them as partial state.
|
|
584
|
+
*
|
|
585
|
+
* Returns synchronously for sync storage and a Promise for async storage.
|
|
586
|
+
*
|
|
587
|
+
* @param storage - Storage interface to read from (same as used in middleware)
|
|
588
|
+
* @param persistKey - Base key prefix used for storage (same as used in middleware)
|
|
589
|
+
* @param keys - Array of keys to restore (should match middleware keys)
|
|
590
|
+
* @returns Partial state object (sync) or Promise of partial state (async)
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* ```ts
|
|
594
|
+
* // Sync — localStorage
|
|
595
|
+
* const persisted = loadPersistedState(localStorage, 'myapp', ['theme']);
|
|
596
|
+
* const store = createStoreState({ theme: 'light', ...persisted });
|
|
597
|
+
*
|
|
598
|
+
* // Async — React Native AsyncStorage
|
|
599
|
+
* const persisted = await loadPersistedState(AsyncStorage, 'myapp', ['theme']);
|
|
600
|
+
* const store = createStoreState({ theme: 'light', ...persisted });
|
|
601
|
+
* ```
|
|
602
|
+
*/
|
|
603
|
+
export function loadPersistedState<T extends object>(
|
|
604
|
+
storage: Storage | StorageSupportingInterface,
|
|
605
|
+
persistKey: string,
|
|
606
|
+
keys: (keyof 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>> {
|
|
618
|
+
const result: Partial<T> = {};
|
|
619
|
+
const pending: Promise<void>[] = [];
|
|
620
|
+
|
|
621
|
+
for (const key of keys) {
|
|
622
|
+
const storageKey = `${persistKey}:${String(key)}`;
|
|
623
|
+
try {
|
|
624
|
+
const stored = storage.getItem(storageKey);
|
|
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);
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
console.warn(`Failed to load persisted key ${String(key)}:`, error);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (pending.length > 0) {
|
|
642
|
+
return Promise.all(pending).then(() => result);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return result;
|
|
219
646
|
}
|