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.
@@ -0,0 +1,997 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ createStoreState,
4
+ createPersistenceMiddleware,
5
+ loadPersistedState,
6
+ } from './index'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // createStoreState — core
10
+ // ---------------------------------------------------------------------------
11
+ describe('createStoreState', () => {
12
+ // -- get ------------------------------------------------------------------
13
+ describe('get', () => {
14
+ it('returns the initial state', () => {
15
+ const store = createStoreState({ count: 0, name: 'Alice' })
16
+ expect(store.get()).toEqual({ count: 0, name: 'Alice' })
17
+ })
18
+
19
+ it('returns a reference to the live state object', () => {
20
+ const store = createStoreState({ x: 1 })
21
+ const a = store.get()
22
+ store.set({ x: 2 })
23
+ const b = store.get()
24
+ expect(a).toBe(b) // same object, mutated in place
25
+ expect(b.x).toBe(2)
26
+ })
27
+ })
28
+
29
+ // -- set ------------------------------------------------------------------
30
+ describe('set', () => {
31
+ it('updates a single key', () => {
32
+ const store = createStoreState({ a: 1, b: 2 })
33
+ store.set({ a: 10 })
34
+ expect(store.get().a).toBe(10)
35
+ expect(store.get().b).toBe(2)
36
+ })
37
+
38
+ it('updates multiple keys at once', () => {
39
+ const store = createStoreState({ a: 1, b: 2, c: 3 })
40
+ store.set({ a: 10, c: 30 })
41
+ expect(store.get()).toEqual({ a: 10, b: 2, c: 30 })
42
+ })
43
+
44
+ it('skips update when value is identical (Object.is)', () => {
45
+ const listener = vi.fn()
46
+ const store = createStoreState({ count: 0 })
47
+ store.subscribe(['count'], listener)
48
+
49
+ store.set({ count: 0 })
50
+ expect(listener).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it('detects NaN === NaN as no change', () => {
54
+ const listener = vi.fn()
55
+ const store = createStoreState({ value: NaN })
56
+ store.subscribe(['value'], listener)
57
+
58
+ store.set({ value: NaN })
59
+ expect(listener).not.toHaveBeenCalled()
60
+ expect(store.get().value).toBeNaN()
61
+ })
62
+
63
+ it('is a no-op when called with null-ish update', () => {
64
+ const store = createStoreState({ a: 1 })
65
+ store.set(null as any)
66
+ store.set(undefined as any)
67
+ expect(store.get().a).toBe(1)
68
+ })
69
+ })
70
+
71
+ // -- getKey ---------------------------------------------------------------
72
+ describe('getKey', () => {
73
+ it('returns the value of a single key', () => {
74
+ const store = createStoreState({ a: 1, b: 'hello' })
75
+ expect(store.getKey('a')).toBe(1)
76
+ expect(store.getKey('b')).toBe('hello')
77
+ })
78
+
79
+ it('reflects updates made via set()', () => {
80
+ const store = createStoreState({ x: 0 })
81
+ store.set({ x: 42 })
82
+ expect(store.getKey('x')).toBe(42)
83
+ })
84
+ })
85
+
86
+ // -- setKey ---------------------------------------------------------------
87
+ describe('setKey', () => {
88
+ it('updates a single key', () => {
89
+ const store = createStoreState({ a: 1, b: 2 })
90
+ store.setKey('a', 10)
91
+ expect(store.get()).toEqual({ a: 10, b: 2 })
92
+ })
93
+
94
+ it('fires listeners for the changed key', () => {
95
+ const store = createStoreState({ v: 0 })
96
+ const listener = vi.fn()
97
+ store.subscribe(['v'], listener)
98
+
99
+ store.setKey('v', 5)
100
+ expect(listener).toHaveBeenCalledTimes(1)
101
+ })
102
+
103
+ it('does not fire listeners when value is identical', () => {
104
+ const store = createStoreState({ v: 0 })
105
+ const listener = vi.fn()
106
+ store.subscribe(['v'], listener)
107
+
108
+ store.setKey('v', 0)
109
+ expect(listener).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('passes through middleware', () => {
113
+ const store = createStoreState({ name: '' })
114
+ store.addMiddleware((_state, update, next) => {
115
+ if (update.name) next({ name: update.name.toUpperCase() })
116
+ else next()
117
+ })
118
+
119
+ store.setKey('name', 'alice')
120
+ expect(store.getKey('name')).toBe('ALICE')
121
+ })
122
+
123
+ })
124
+
125
+ // -- merge (pure read) ----------------------------------------------------
126
+ describe('merge', () => {
127
+ it('returns a merged object without modifying the store', () => {
128
+ const store = createStoreState({ user: { name: 'Alice', age: 30 } })
129
+ const result = store.merge('user', { age: 31 })
130
+ expect(result).toEqual({ name: 'Alice', age: 31 })
131
+ expect(store.get().user).toEqual({ name: 'Alice', age: 30 }) // untouched
132
+ })
133
+
134
+ it('preserves existing properties not included in partial', () => {
135
+ const store = createStoreState({ config: { theme: 'dark', lang: 'en', debug: false } })
136
+ const result = store.merge('config', { lang: 'fr' })
137
+ expect(result).toEqual({ theme: 'dark', lang: 'fr', debug: false })
138
+ expect(store.getKey('config').lang).toBe('en') // untouched
139
+ })
140
+
141
+ it('does not fire listeners', () => {
142
+ const store = createStoreState({ obj: { a: 1 } })
143
+ const listener = vi.fn()
144
+ store.subscribe(['obj'], listener)
145
+
146
+ store.merge('obj', { a: 10 })
147
+ expect(listener).not.toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ // -- mergeSet -------------------------------------------------------------
152
+ describe('mergeSet', () => {
153
+ it('shallow-merges and writes to the store', () => {
154
+ const store = createStoreState({ user: { name: 'Alice', age: 30 }, count: 0 })
155
+ store.mergeSet('user', { age: 31 })
156
+ expect(store.get().user).toEqual({ name: 'Alice', age: 31 })
157
+ })
158
+
159
+ it('fires listeners for the merged key', () => {
160
+ const store = createStoreState({ obj: { a: 1, b: 2 } })
161
+ const listener = vi.fn()
162
+ store.subscribe(['obj'], listener)
163
+
164
+ store.mergeSet('obj', { a: 10 })
165
+ expect(listener).toHaveBeenCalledTimes(1)
166
+ })
167
+
168
+ it('goes through middleware', () => {
169
+ const spy = vi.fn()
170
+ const store = createStoreState({ data: { x: 1 } })
171
+ store.addMiddleware((_s, _u, next) => { spy(); next() })
172
+
173
+ store.mergeSet('data', { x: 2 })
174
+ expect(spy).toHaveBeenCalledTimes(1)
175
+ expect(store.get().data).toEqual({ x: 2 })
176
+ })
177
+
178
+ })
179
+
180
+ // -- reset ----------------------------------------------------------------
181
+ describe('reset', () => {
182
+ it('resets all keys to initial state', () => {
183
+ const store = createStoreState({ a: 1, b: 'hello', c: true })
184
+ store.set({ a: 99, b: 'world', c: false })
185
+ store.reset()
186
+ expect(store.get()).toEqual({ a: 1, b: 'hello', c: true })
187
+ })
188
+
189
+ it('resets specific keys to initial state', () => {
190
+ const store = createStoreState({ a: 1, b: 2, c: 3 })
191
+ store.set({ a: 10, b: 20, c: 30 })
192
+ store.reset(['a', 'c'])
193
+ expect(store.get()).toEqual({ a: 1, b: 20, c: 3 })
194
+ })
195
+
196
+ it('fires listeners for reset keys', () => {
197
+ const store = createStoreState({ x: 0, y: 0 })
198
+ const lx = vi.fn()
199
+ const ly = vi.fn()
200
+ store.subscribe(['x'], lx)
201
+ store.subscribe(['y'], ly)
202
+
203
+ store.set({ x: 5, y: 5 })
204
+ lx.mockClear()
205
+ ly.mockClear()
206
+
207
+ store.reset(['x'])
208
+ expect(lx).toHaveBeenCalledTimes(1)
209
+ expect(ly).not.toHaveBeenCalled()
210
+ })
211
+
212
+ it('does not fire listeners when value is already at initial', () => {
213
+ const store = createStoreState({ v: 0 })
214
+ const listener = vi.fn()
215
+ store.subscribe(['v'], listener)
216
+
217
+ store.reset(['v']) // already at initial
218
+ expect(listener).not.toHaveBeenCalled()
219
+ })
220
+
221
+ it('goes through middleware', () => {
222
+ const spy = vi.fn()
223
+ const store = createStoreState({ v: 0 })
224
+ store.addMiddleware((_s, _u, next) => { spy(); next() })
225
+
226
+ store.set({ v: 5 })
227
+ spy.mockClear()
228
+
229
+ store.reset()
230
+ expect(spy).toHaveBeenCalledTimes(1)
231
+ expect(store.get().v).toBe(0)
232
+ })
233
+ })
234
+
235
+ // -- batch ----------------------------------------------------------------
236
+ describe('batch', () => {
237
+ it('defers all notifications until callback completes', () => {
238
+ const store = createStoreState({ a: 0, b: 0 })
239
+ const la = vi.fn()
240
+ const lb = vi.fn()
241
+ store.subscribe(['a'], la)
242
+ store.subscribe(['b'], lb)
243
+
244
+ store.batch(() => {
245
+ store.set({ a: 1 })
246
+ expect(la).not.toHaveBeenCalled() // not yet
247
+ store.set({ b: 2 })
248
+ expect(lb).not.toHaveBeenCalled() // not yet
249
+ })
250
+
251
+ expect(la).toHaveBeenCalledTimes(1)
252
+ expect(lb).toHaveBeenCalledTimes(1)
253
+ })
254
+
255
+ it('state is updated during the batch (only notifications deferred)', () => {
256
+ const store = createStoreState({ v: 0 })
257
+
258
+ store.batch(() => {
259
+ store.set({ v: 5 })
260
+ expect(store.get().v).toBe(5) // state is live
261
+ })
262
+ })
263
+
264
+ it('deduplicates notifications for the same key', () => {
265
+ const store = createStoreState({ v: 0 })
266
+ const listener = vi.fn()
267
+ store.subscribe(['v'], listener)
268
+
269
+ store.batch(() => {
270
+ store.set({ v: 1 })
271
+ store.set({ v: 2 })
272
+ store.set({ v: 3 })
273
+ })
274
+
275
+ expect(listener).toHaveBeenCalledTimes(1) // one notification, not three
276
+ expect(store.get().v).toBe(3)
277
+ })
278
+
279
+ it('nested batch calls are transparent (inner batch runs inline)', () => {
280
+ const store = createStoreState({ a: 0, b: 0 })
281
+ const listener = vi.fn()
282
+ store.subscribe(['a'], listener)
283
+
284
+ store.batch(() => {
285
+ store.set({ a: 1 })
286
+ store.batch(() => {
287
+ store.set({ b: 2 })
288
+ })
289
+ expect(listener).not.toHaveBeenCalled() // still deferred
290
+ })
291
+
292
+ expect(listener).toHaveBeenCalledTimes(1) // outer batch fires it
293
+ })
294
+
295
+ it('fires notifications even if callback throws', () => {
296
+ const store = createStoreState({ v: 0 })
297
+ const listener = vi.fn()
298
+ store.subscribe(['v'], listener)
299
+
300
+ expect(() => {
301
+ store.batch(() => {
302
+ store.set({ v: 1 })
303
+ throw new Error('oops')
304
+ })
305
+ }).toThrow('oops')
306
+
307
+ expect(store.get().v).toBe(1)
308
+ expect(listener).toHaveBeenCalledTimes(1) // finally block fired
309
+ })
310
+
311
+ it('works with mergeSet and setKey inside batch', () => {
312
+ const store = createStoreState({ obj: { a: 1, b: 2 }, count: 0 })
313
+ const lo = vi.fn()
314
+ const lc = vi.fn()
315
+ store.subscribe(['obj'], lo)
316
+ store.subscribe(['count'], lc)
317
+
318
+ store.batch(() => {
319
+ store.mergeSet('obj', { a: 10 })
320
+ store.setKey('count', 5)
321
+ })
322
+
323
+ expect(lo).toHaveBeenCalledTimes(1)
324
+ expect(lc).toHaveBeenCalledTimes(1)
325
+ expect(store.get().obj).toEqual({ a: 10, b: 2 })
326
+ expect(store.get().count).toBe(5)
327
+ })
328
+ })
329
+
330
+ // -- equality registry ----------------------------------------------------
331
+ describe('equality registry', () => {
332
+ it('skips update when registered equality returns true', () => {
333
+ const store = createStoreState({ user: { id: 1, name: 'Alice' } })
334
+ store.skipSetWhen('user', (prev, next) => prev.id === next.id && prev.name === next.name)
335
+
336
+ const listener = vi.fn()
337
+ store.subscribe(['user'], listener)
338
+
339
+ store.set({ user: { id: 1, name: 'Alice' } }) // same content, new reference
340
+ expect(listener).not.toHaveBeenCalled()
341
+ expect(store.get().user).toEqual({ id: 1, name: 'Alice' })
342
+ })
343
+
344
+ it('applies update when registered equality returns false', () => {
345
+ const store = createStoreState({ user: { id: 1, name: 'Alice' } })
346
+ store.skipSetWhen('user', (prev, next) => prev.id === next.id && prev.name === next.name)
347
+
348
+ const listener = vi.fn()
349
+ store.subscribe(['user'], listener)
350
+
351
+ store.set({ user: { id: 1, name: 'Bob' } })
352
+ expect(listener).toHaveBeenCalledTimes(1)
353
+ expect(store.get().user).toEqual({ id: 1, name: 'Bob' })
354
+ })
355
+
356
+ it('Object.is still catches identical references before equality fn runs', () => {
357
+ const store = createStoreState({ count: 0 })
358
+ const eqFn = vi.fn(() => true)
359
+ store.skipSetWhen('count', eqFn)
360
+
361
+ store.set({ count: 0 }) // same primitive — Object.is catches it
362
+ expect(eqFn).not.toHaveBeenCalled()
363
+ })
364
+
365
+ it('mergeSet skips when equality says unchanged', () => {
366
+ const store = createStoreState({ config: { theme: 'dark', lang: 'en' } })
367
+ store.skipSetWhen('config', (prev, next) => prev.theme === next.theme && prev.lang === next.lang)
368
+
369
+ const listener = vi.fn()
370
+ store.subscribe(['config'], listener)
371
+
372
+ store.mergeSet('config', { theme: 'dark' }) // same content
373
+ expect(listener).not.toHaveBeenCalled()
374
+ })
375
+
376
+ it('mergeSet applies when equality detects change', () => {
377
+ const store = createStoreState({ config: { theme: 'dark', lang: 'en' } })
378
+ store.skipSetWhen('config', (prev, next) => prev.theme === next.theme && prev.lang === next.lang)
379
+
380
+ const listener = vi.fn()
381
+ store.subscribe(['config'], listener)
382
+
383
+ store.mergeSet('config', { theme: 'light' })
384
+ expect(listener).toHaveBeenCalledTimes(1)
385
+ expect(store.get().config).toEqual({ theme: 'light', lang: 'en' })
386
+ })
387
+
388
+ it('removeSkipSetWhen restores default Object.is behavior', () => {
389
+ const store = createStoreState({ user: { id: 1, name: 'Alice' } })
390
+ store.skipSetWhen('user', (prev, next) => prev.id === next.id && prev.name === next.name)
391
+
392
+ const listener = vi.fn()
393
+ store.subscribe(['user'], listener)
394
+
395
+ // With equality — skipped
396
+ store.set({ user: { id: 1, name: 'Alice' } })
397
+ expect(listener).not.toHaveBeenCalled()
398
+
399
+ // Remove equality — new reference always applies
400
+ store.removeSkipSetWhen('user')
401
+ store.set({ user: { id: 1, name: 'Alice' } })
402
+ expect(listener).toHaveBeenCalledTimes(1)
403
+ })
404
+
405
+ it('only affects the registered key, not others', () => {
406
+ const store = createStoreState({ a: { x: 1 }, b: { x: 1 } })
407
+ store.skipSetWhen('a', (prev, next) => prev.x === next.x)
408
+
409
+ const la = vi.fn()
410
+ const lb = vi.fn()
411
+ store.subscribe(['a'], la)
412
+ store.subscribe(['b'], lb)
413
+
414
+ store.set({ a: { x: 1 }, b: { x: 1 } })
415
+ expect(la).not.toHaveBeenCalled() // equality catches it
416
+ expect(lb).toHaveBeenCalledTimes(1) // no equality — new reference triggers
417
+ })
418
+
419
+ it('equality fn receives prev and next values', () => {
420
+ const store = createStoreState({ items: [1, 2, 3] })
421
+ const eqFn = vi.fn((prev: number[], next: number[]) => prev.length === next.length)
422
+ store.skipSetWhen('items', eqFn)
423
+
424
+ store.set({ items: [4, 5, 6] })
425
+ expect(eqFn).toHaveBeenCalledWith([1, 2, 3], [4, 5, 6])
426
+ expect(store.get().items).toEqual([1, 2, 3]) // same length, equality returned true
427
+ })
428
+ })
429
+
430
+ // -- subscribe ------------------------------------------------------------
431
+ describe('subscribe', () => {
432
+ it('fires listener when subscribed key changes', () => {
433
+ const store = createStoreState({ a: 1, b: 2 })
434
+ const listener = vi.fn()
435
+ store.subscribe(['a'], listener)
436
+
437
+ store.set({ a: 10 })
438
+ expect(listener).toHaveBeenCalledTimes(1)
439
+ })
440
+
441
+ it('does not fire listener for unrelated key changes', () => {
442
+ const store = createStoreState({ a: 1, b: 2 })
443
+ const listener = vi.fn()
444
+ store.subscribe(['a'], listener)
445
+
446
+ store.set({ b: 20 })
447
+ expect(listener).not.toHaveBeenCalled()
448
+ })
449
+
450
+ it('deduplicates listener when multiple subscribed keys change', () => {
451
+ const store = createStoreState({ a: 1, b: 2 })
452
+ const listener = vi.fn()
453
+ store.subscribe(['a', 'b'], listener)
454
+
455
+ store.set({ a: 10, b: 20 })
456
+ // listener subscribed to both keys but deduped — fires once
457
+ expect(listener).toHaveBeenCalledTimes(1)
458
+ })
459
+
460
+ it('returns an unsubscribe function', () => {
461
+ const store = createStoreState({ a: 1 })
462
+ const listener = vi.fn()
463
+ const unsub = store.subscribe(['a'], listener)
464
+
465
+ store.set({ a: 2 })
466
+ expect(listener).toHaveBeenCalledTimes(1)
467
+
468
+ unsub()
469
+ store.set({ a: 3 })
470
+ expect(listener).toHaveBeenCalledTimes(1) // no additional calls
471
+ })
472
+
473
+ it('supports multiple listeners on the same key', () => {
474
+ const store = createStoreState({ x: 0 })
475
+ const l1 = vi.fn()
476
+ const l2 = vi.fn()
477
+ store.subscribe(['x'], l1)
478
+ store.subscribe(['x'], l2)
479
+
480
+ store.set({ x: 1 })
481
+ expect(l1).toHaveBeenCalledTimes(1)
482
+ expect(l2).toHaveBeenCalledTimes(1)
483
+ })
484
+
485
+ it('unsubscribing one listener does not affect others', () => {
486
+ const store = createStoreState({ x: 0 })
487
+ const l1 = vi.fn()
488
+ const l2 = vi.fn()
489
+ const unsub1 = store.subscribe(['x'], l1)
490
+ store.subscribe(['x'], l2)
491
+
492
+ unsub1()
493
+ store.set({ x: 1 })
494
+ expect(l1).not.toHaveBeenCalled()
495
+ expect(l2).toHaveBeenCalledTimes(1)
496
+ })
497
+ })
498
+
499
+ // -- select ---------------------------------------------------------------
500
+ describe('select', () => {
501
+ it('picks requested keys from state', () => {
502
+ const store = createStoreState({ a: 1, b: 2, c: 3 })
503
+ expect(store.select(['a', 'c'])).toEqual({ a: 1, c: 3 })
504
+ })
505
+
506
+ it('returns a new object each time', () => {
507
+ const store = createStoreState({ a: 1 })
508
+ const s1 = store.select(['a'])
509
+ const s2 = store.select(['a'])
510
+ expect(s1).not.toBe(s2)
511
+ expect(s1).toEqual(s2)
512
+ })
513
+ })
514
+ })
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Middleware
518
+ // ---------------------------------------------------------------------------
519
+ describe('middleware', () => {
520
+ it('allows updates through when next() is called', () => {
521
+ const store = createStoreState({ count: 0 })
522
+ store.addMiddleware((_state, _update, next) => next())
523
+
524
+ store.set({ count: 5 })
525
+ expect(store.get().count).toBe(5)
526
+ })
527
+
528
+ it('blocks updates when next() is not called', () => {
529
+ const store = createStoreState({ count: 0 })
530
+ store.addMiddleware(() => {
531
+ // intentionally not calling next()
532
+ })
533
+
534
+ store.set({ count: 5 })
535
+ expect(store.get().count).toBe(0)
536
+ })
537
+
538
+ it('can transform updates via next(modified)', () => {
539
+ const store = createStoreState({ name: '' })
540
+ store.addMiddleware((_state, update, next) => {
541
+ if (update.name) {
542
+ next({ name: update.name.toUpperCase() })
543
+ } else {
544
+ next()
545
+ }
546
+ })
547
+
548
+ store.set({ name: 'alice' })
549
+ expect(store.get().name).toBe('ALICE')
550
+ })
551
+
552
+ it('only runs for matching keys when key filter is set', () => {
553
+ const spy = vi.fn()
554
+ const store = createStoreState({ a: 1, b: 2 })
555
+ store.addMiddleware((_state, _update, next) => {
556
+ spy()
557
+ next()
558
+ }, ['a'])
559
+
560
+ store.set({ b: 20 }) // should skip middleware
561
+ expect(spy).not.toHaveBeenCalled()
562
+
563
+ store.set({ a: 10 }) // should run middleware
564
+ expect(spy).toHaveBeenCalledTimes(1)
565
+ })
566
+
567
+ it('supports tuple syntax [fn, keys]', () => {
568
+ const spy = vi.fn()
569
+ const store = createStoreState({ x: 0, y: 0 })
570
+ store.addMiddleware([(_state, _update, next) => { spy(); next() }, ['x']])
571
+
572
+ store.set({ y: 1 })
573
+ expect(spy).not.toHaveBeenCalled()
574
+
575
+ store.set({ x: 1 })
576
+ expect(spy).toHaveBeenCalledTimes(1)
577
+ })
578
+
579
+ it('runs multiple middleware in insertion order', () => {
580
+ const order: number[] = []
581
+ const store = createStoreState({ v: 0 })
582
+
583
+ store.addMiddleware((_s, _u, next) => { order.push(1); next() })
584
+ store.addMiddleware((_s, _u, next) => { order.push(2); next() })
585
+ store.addMiddleware((_s, _u, next) => { order.push(3); next() })
586
+
587
+ store.set({ v: 1 })
588
+ expect(order).toEqual([1, 2, 3])
589
+ })
590
+
591
+ it('blocks remaining middleware once one blocks', () => {
592
+ const spy = vi.fn()
593
+ const store = createStoreState({ v: 0 })
594
+
595
+ store.addMiddleware(() => { /* block */ })
596
+ store.addMiddleware((_s, _u, next) => { spy(); next() })
597
+
598
+ store.set({ v: 1 })
599
+ expect(spy).not.toHaveBeenCalled()
600
+ expect(store.get().v).toBe(0)
601
+ })
602
+
603
+ it('catches middleware errors and blocks the update', () => {
604
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
605
+ const store = createStoreState({ v: 0 })
606
+
607
+ store.addMiddleware(() => { throw new Error('boom') })
608
+
609
+ store.set({ v: 1 })
610
+ expect(store.get().v).toBe(0)
611
+ expect(consoleSpy).toHaveBeenCalled()
612
+ consoleSpy.mockRestore()
613
+ })
614
+
615
+ it('prevents double-calling next()', () => {
616
+ const store = createStoreState({ v: 0 })
617
+ const listener = vi.fn()
618
+ store.subscribe(['v'], listener)
619
+
620
+ store.addMiddleware((_s, _u, next) => {
621
+ next()
622
+ next() // second call should be ignored
623
+ })
624
+
625
+ store.set({ v: 1 })
626
+ expect(listener).toHaveBeenCalledTimes(1)
627
+ })
628
+
629
+ it('can be removed via returned cleanup function', () => {
630
+ const spy = vi.fn()
631
+ const store = createStoreState({ v: 0 })
632
+ const remove = store.addMiddleware((_s, _u, next) => { spy(); next() })
633
+
634
+ store.set({ v: 1 })
635
+ expect(spy).toHaveBeenCalledTimes(1)
636
+
637
+ remove()
638
+ store.set({ v: 2 })
639
+ expect(spy).toHaveBeenCalledTimes(1) // not called again
640
+ expect(store.get().v).toBe(2) // update still applied
641
+ })
642
+
643
+ it('receives current state and the update object', () => {
644
+ const store = createStoreState({ a: 1, b: 2 })
645
+ let capturedState: any
646
+ let capturedUpdate: any
647
+
648
+ store.addMiddleware((state, update, next) => {
649
+ capturedState = { ...state }
650
+ capturedUpdate = { ...update }
651
+ next()
652
+ })
653
+
654
+ store.set({ a: 10 })
655
+ expect(capturedState).toEqual({ a: 1, b: 2 }) // state before update
656
+ expect(capturedUpdate).toEqual({ a: 10 })
657
+ })
658
+ })
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // onChange
662
+ // ---------------------------------------------------------------------------
663
+ describe('onChange', () => {
664
+ it('fires callback with new and previous values', async () => {
665
+ const store = createStoreState({ count: 0, name: 'a' })
666
+ const cb = vi.fn()
667
+
668
+ store.onChange(['count'], cb)
669
+ store.set({ count: 5 })
670
+
671
+ // onChange uses queueMicrotask, await a tick
672
+ await Promise.resolve()
673
+
674
+ expect(cb).toHaveBeenCalledTimes(1)
675
+ expect(cb).toHaveBeenCalledWith({ count: 5 }, { count: 0 })
676
+ })
677
+
678
+ it('batches multiple key changes from a single set()', async () => {
679
+ const store = createStoreState({ a: 1, b: 2 })
680
+ const cb = vi.fn()
681
+
682
+ store.onChange(['a', 'b'], cb)
683
+ store.set({ a: 10, b: 20 })
684
+
685
+ await Promise.resolve()
686
+
687
+ expect(cb).toHaveBeenCalledTimes(1)
688
+ expect(cb).toHaveBeenCalledWith({ a: 10, b: 20 }, { a: 1, b: 2 })
689
+ })
690
+
691
+ it('batches rapid sequential set() calls into one callback', async () => {
692
+ const store = createStoreState({ x: 0 })
693
+ const cb = vi.fn()
694
+
695
+ store.onChange(['x'], cb)
696
+ store.set({ x: 1 })
697
+ store.set({ x: 2 })
698
+ store.set({ x: 3 })
699
+
700
+ await Promise.resolve()
701
+
702
+ expect(cb).toHaveBeenCalledTimes(1)
703
+ expect(cb).toHaveBeenCalledWith({ x: 3 }, { x: 0 })
704
+ })
705
+
706
+ it('does not fire when values do not actually change', async () => {
707
+ const store = createStoreState({ a: 1 })
708
+ const cb = vi.fn()
709
+
710
+ store.onChange(['a'], cb)
711
+ store.set({ a: 1 }) // same value
712
+
713
+ await Promise.resolve()
714
+
715
+ expect(cb).not.toHaveBeenCalled()
716
+ })
717
+
718
+ it('does not fire for unrelated key changes', async () => {
719
+ const store = createStoreState({ a: 1, b: 2 })
720
+ const cb = vi.fn()
721
+
722
+ store.onChange(['a'], cb)
723
+ store.set({ b: 20 })
724
+
725
+ await Promise.resolve()
726
+
727
+ expect(cb).not.toHaveBeenCalled()
728
+ })
729
+
730
+ it('returns an unsubscribe function', async () => {
731
+ const store = createStoreState({ v: 0 })
732
+ const cb = vi.fn()
733
+
734
+ const unsub = store.onChange(['v'], cb)
735
+ store.set({ v: 1 })
736
+ await Promise.resolve()
737
+ expect(cb).toHaveBeenCalledTimes(1)
738
+
739
+ unsub()
740
+ store.set({ v: 2 })
741
+ await Promise.resolve()
742
+ expect(cb).toHaveBeenCalledTimes(1) // no additional calls
743
+ })
744
+
745
+ it('tracks prev correctly across multiple change cycles', async () => {
746
+ const store = createStoreState({ v: 0 })
747
+ const cb = vi.fn()
748
+
749
+ store.onChange(['v'], cb)
750
+
751
+ store.set({ v: 1 })
752
+ await Promise.resolve()
753
+ expect(cb).toHaveBeenLastCalledWith({ v: 1 }, { v: 0 })
754
+
755
+ store.set({ v: 2 })
756
+ await Promise.resolve()
757
+ expect(cb).toHaveBeenLastCalledWith({ v: 2 }, { v: 1 })
758
+
759
+ store.set({ v: 3 })
760
+ await Promise.resolve()
761
+ expect(cb).toHaveBeenLastCalledWith({ v: 3 }, { v: 2 })
762
+ })
763
+ })
764
+
765
+ // ---------------------------------------------------------------------------
766
+ // Persistence
767
+ // ---------------------------------------------------------------------------
768
+ describe('persistence', () => {
769
+ function createMockStorage() {
770
+ const data = new Map<string, string>()
771
+ return {
772
+ getItem: vi.fn((key: string) => data.get(key) ?? null),
773
+ setItem: vi.fn((key: string, value: string) => { data.set(key, value) }),
774
+ data,
775
+ }
776
+ }
777
+
778
+ describe('createPersistenceMiddleware', () => {
779
+ it('saves changed keys to storage on update', () => {
780
+ const storage = createMockStorage()
781
+ const store = createStoreState({ theme: 'light', count: 0 })
782
+ store.addMiddleware(
783
+ createPersistenceMiddleware<{ theme: string; count: number }>(
784
+ storage, 'app', ['theme']
785
+ )
786
+ )
787
+
788
+ store.set({ theme: 'dark' })
789
+ expect(storage.setItem).toHaveBeenCalledWith('app:theme', '"dark"')
790
+ expect(store.get().theme).toBe('dark')
791
+ })
792
+
793
+ it('does not write untracked keys', () => {
794
+ const storage = createMockStorage()
795
+ const store = createStoreState({ theme: 'light', count: 0 })
796
+ store.addMiddleware(
797
+ createPersistenceMiddleware<{ theme: string; count: number }>(
798
+ storage, 'app', ['theme']
799
+ )
800
+ )
801
+
802
+ store.set({ count: 5 })
803
+ expect(storage.setItem).not.toHaveBeenCalled()
804
+ expect(store.get().count).toBe(5)
805
+ })
806
+
807
+ it('uses per-key storage format', () => {
808
+ const storage = createMockStorage()
809
+ const store = createStoreState({ a: 1, b: 'x' })
810
+ store.addMiddleware(
811
+ createPersistenceMiddleware<{ a: number; b: string }>(
812
+ storage, 'prefix', ['a', 'b']
813
+ )
814
+ )
815
+
816
+ store.set({ a: 2, b: 'y' })
817
+ expect(storage.setItem).toHaveBeenCalledWith('prefix:a', '2')
818
+ expect(storage.setItem).toHaveBeenCalledWith('prefix:b', '"y"')
819
+ })
820
+
821
+ it('handles storage errors gracefully', () => {
822
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
823
+ const storage = {
824
+ getItem: () => null,
825
+ setItem: () => { throw new Error('quota exceeded') },
826
+ }
827
+ const store = createStoreState({ v: 0 })
828
+ store.addMiddleware(
829
+ createPersistenceMiddleware<{ v: number }>(storage, 'k', ['v'])
830
+ )
831
+
832
+ store.set({ v: 1 })
833
+ expect(store.get().v).toBe(1) // update still applied
834
+ expect(warnSpy).toHaveBeenCalled()
835
+ warnSpy.mockRestore()
836
+ })
837
+ })
838
+
839
+ describe('loadPersistedState', () => {
840
+ it('loads saved keys from storage', () => {
841
+ const storage = createMockStorage()
842
+ storage.data.set('app:theme', '"dark"')
843
+ storage.data.set('app:count', '42')
844
+
845
+ const result = loadPersistedState<{ theme: string; count: number }>(
846
+ storage, 'app', ['theme', 'count']
847
+ )
848
+ expect(result).toEqual({ theme: 'dark', count: 42 })
849
+ })
850
+
851
+ it('returns empty object when no keys are persisted', () => {
852
+ const storage = createMockStorage()
853
+ const result = loadPersistedState<{ theme: string }>(
854
+ storage, 'app', ['theme']
855
+ )
856
+ expect(result).toEqual({})
857
+ })
858
+
859
+ it('skips keys that fail to parse', () => {
860
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
861
+ const storage = createMockStorage()
862
+ storage.data.set('app:a', 'not-json{{')
863
+ storage.data.set('app:b', '"valid"')
864
+
865
+ const result = loadPersistedState<{ a: string; b: string }>(
866
+ storage, 'app', ['a', 'b']
867
+ )
868
+ expect(result).toEqual({ b: 'valid' })
869
+ expect(warnSpy).toHaveBeenCalled()
870
+ warnSpy.mockRestore()
871
+ })
872
+
873
+ it('integrates with createPersistenceMiddleware round-trip', () => {
874
+ const storage = createMockStorage()
875
+
876
+ // Write
877
+ const store1 = createStoreState({ theme: 'light', lang: 'en' })
878
+ store1.addMiddleware(
879
+ createPersistenceMiddleware<{ theme: string; lang: string }>(
880
+ storage, 'rt', ['theme', 'lang']
881
+ )
882
+ )
883
+ store1.set({ theme: 'dark', lang: 'fr' })
884
+
885
+ // Read
886
+ const persisted = loadPersistedState<{ theme: string; lang: string }>(
887
+ storage, 'rt', ['theme', 'lang']
888
+ )
889
+ expect(persisted).toEqual({ theme: 'dark', lang: 'fr' })
890
+ })
891
+ })
892
+
893
+ // -- async storage --------------------------------------------------------
894
+ function createMockAsyncStorage() {
895
+ const data = new Map<string, string>()
896
+ return {
897
+ getItem: vi.fn((key: string) => Promise.resolve(data.get(key) ?? null)),
898
+ setItem: vi.fn((key: string, value: string) => {
899
+ data.set(key, value)
900
+ return Promise.resolve()
901
+ }),
902
+ data,
903
+ }
904
+ }
905
+
906
+ describe('createPersistenceMiddleware (async)', () => {
907
+ it('writes to async storage and still applies update synchronously', async () => {
908
+ const storage = createMockAsyncStorage()
909
+ const store = createStoreState({ theme: 'light' })
910
+ store.addMiddleware(
911
+ createPersistenceMiddleware<{ theme: string }>(storage, 'app', ['theme'])
912
+ )
913
+
914
+ store.set({ theme: 'dark' })
915
+ expect(store.get().theme).toBe('dark') // state updated synchronously
916
+ await Promise.resolve() // let async setItem resolve
917
+ expect(storage.setItem).toHaveBeenCalledWith('app:theme', '"dark"')
918
+ })
919
+
920
+ it('handles async storage write errors gracefully', async () => {
921
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
922
+ const storage = {
923
+ getItem: () => Promise.resolve(null),
924
+ setItem: () => Promise.reject(new Error('write failed')),
925
+ }
926
+ const store = createStoreState({ v: 0 })
927
+ store.addMiddleware(
928
+ createPersistenceMiddleware<{ v: number }>(storage, 'k', ['v'])
929
+ )
930
+
931
+ store.set({ v: 1 })
932
+ expect(store.get().v).toBe(1)
933
+ await Promise.resolve() // let rejection handler run
934
+ await Promise.resolve() // microtask for .catch
935
+ expect(warnSpy).toHaveBeenCalled()
936
+ warnSpy.mockRestore()
937
+ })
938
+ })
939
+
940
+ describe('loadPersistedState (async)', () => {
941
+ it('returns a Promise that resolves to persisted state', async () => {
942
+ const storage = createMockAsyncStorage()
943
+ storage.data.set('app:theme', '"dark"')
944
+ storage.data.set('app:count', '42')
945
+
946
+ const result = loadPersistedState<{ theme: string; count: number }>(
947
+ storage, 'app', ['theme', 'count']
948
+ )
949
+ expect(result).toBeInstanceOf(Promise)
950
+ expect(await result).toEqual({ theme: 'dark', count: 42 })
951
+ })
952
+
953
+ it('returns empty object when async storage has no keys', async () => {
954
+ const storage = createMockAsyncStorage()
955
+ const result = await loadPersistedState<{ theme: string }>(
956
+ storage, 'app', ['theme']
957
+ )
958
+ expect(result).toEqual({})
959
+ })
960
+
961
+ it('handles async getItem rejection gracefully', async () => {
962
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
963
+ const storage = {
964
+ getItem: vi.fn((key: string) =>
965
+ key === 'app:a' ? Promise.reject(new Error('read fail')) : Promise.resolve('"ok"')
966
+ ),
967
+ setItem: vi.fn(() => Promise.resolve()),
968
+ }
969
+
970
+ const result = await loadPersistedState<{ a: string; b: string }>(
971
+ storage, 'app', ['a', 'b']
972
+ )
973
+ expect(result).toEqual({ b: 'ok' })
974
+ expect(warnSpy).toHaveBeenCalled()
975
+ warnSpy.mockRestore()
976
+ })
977
+
978
+ it('integrates with async createPersistenceMiddleware round-trip', async () => {
979
+ const storage = createMockAsyncStorage()
980
+
981
+ const store = createStoreState({ theme: 'light', lang: 'en' })
982
+ store.addMiddleware(
983
+ createPersistenceMiddleware<{ theme: string; lang: string }>(
984
+ storage, 'rt', ['theme', 'lang']
985
+ )
986
+ )
987
+ store.set({ theme: 'dark', lang: 'fr' })
988
+
989
+ await Promise.resolve() // let async writes complete
990
+
991
+ const persisted = await loadPersistedState<{ theme: string; lang: string }>(
992
+ storage, 'rt', ['theme', 'lang']
993
+ )
994
+ expect(persisted).toEqual({ theme: 'dark', lang: 'fr' })
995
+ })
996
+ })
997
+ })