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
|
@@ -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
|
+
})
|