@storve/core 1.0.1 → 1.0.3

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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +993 -26
  3. package/dist/adapters/indexedDB.cjs +0 -1
  4. package/dist/adapters/indexedDB.mjs +0 -1
  5. package/dist/adapters/localStorage.cjs +0 -1
  6. package/dist/adapters/localStorage.mjs +0 -1
  7. package/dist/adapters/memory.cjs +0 -1
  8. package/dist/adapters/memory.mjs +0 -1
  9. package/dist/adapters/sessionStorage.cjs +0 -1
  10. package/dist/adapters/sessionStorage.mjs +0 -1
  11. package/dist/async-entry.d.ts +0 -1
  12. package/dist/async.cjs +0 -1
  13. package/dist/async.d.ts +0 -1
  14. package/dist/async.mjs +0 -1
  15. package/dist/batch.d.ts +0 -1
  16. package/dist/compose.d.ts +0 -1
  17. package/dist/computed-entry.d.ts +0 -1
  18. package/dist/computed.cjs +0 -1
  19. package/dist/computed.d.ts +0 -1
  20. package/dist/computed.mjs +0 -1
  21. package/dist/devtools/history.d.ts +0 -1
  22. package/dist/devtools/index.d.ts +0 -1
  23. package/dist/devtools/redux-bridge.d.ts +0 -1
  24. package/dist/devtools/snapshots.d.ts +0 -1
  25. package/dist/devtools/withDevtools.d.ts +0 -1
  26. package/dist/devtools.cjs +0 -1
  27. package/dist/devtools.mjs +0 -1
  28. package/dist/extensions/noop.d.ts +0 -1
  29. package/dist/index.cjs +0 -1
  30. package/dist/index.d.ts +0 -1
  31. package/dist/index.mjs +0 -1
  32. package/dist/persist/adapters/indexedDB.d.ts +0 -1
  33. package/dist/persist/adapters/localStorage.d.ts +0 -1
  34. package/dist/persist/adapters/memory.d.ts +0 -1
  35. package/dist/persist/adapters/sessionStorage.d.ts +0 -1
  36. package/dist/persist/debounce.d.ts +0 -1
  37. package/dist/persist/hydrate.d.ts +0 -1
  38. package/dist/persist/index.d.ts +0 -1
  39. package/dist/persist/serialize.d.ts +0 -1
  40. package/dist/persist.cjs +0 -1
  41. package/dist/persist.mjs +0 -1
  42. package/dist/proxy.d.ts +0 -1
  43. package/dist/registry-qtr1UpFU.js +0 -1
  44. package/dist/registry-zaKZ1P-s.js +0 -1
  45. package/dist/registry.d.ts +0 -1
  46. package/dist/signals/createSignal.d.ts +0 -1
  47. package/dist/signals/index.d.ts +0 -1
  48. package/dist/signals/useSignal.d.ts +0 -1
  49. package/dist/signals.cjs +0 -1
  50. package/dist/signals.mjs +0 -1
  51. package/dist/store.d.ts +0 -1
  52. package/dist/sync/channel.d.ts +0 -1
  53. package/dist/sync/index.d.ts +0 -1
  54. package/dist/sync/protocol.d.ts +0 -1
  55. package/dist/sync/withSync.d.ts +0 -1
  56. package/dist/sync.cjs +0 -1
  57. package/dist/sync.mjs +0 -1
  58. package/dist/types.d.ts +0 -1
  59. package/package.json +9 -3
  60. package/CHANGELOG.md +0 -151
  61. package/benchmarks/run.ts +0 -102
  62. package/benchmarks/week2.md +0 -9
  63. package/benchmarks/week2.ts +0 -64
  64. package/benchmarks/week4.md +0 -13
  65. package/benchmarks/week4.ts +0 -178
  66. package/benchmarks/week5.md +0 -15
  67. package/benchmarks/week5.ts +0 -184
  68. package/coverage/coverage-summary.json +0 -31
  69. package/dist/adapters/indexedDB.cjs.map +0 -1
  70. package/dist/adapters/indexedDB.mjs.map +0 -1
  71. package/dist/adapters/localStorage.cjs.map +0 -1
  72. package/dist/adapters/localStorage.mjs.map +0 -1
  73. package/dist/adapters/memory.cjs.map +0 -1
  74. package/dist/adapters/memory.mjs.map +0 -1
  75. package/dist/adapters/sessionStorage.cjs.map +0 -1
  76. package/dist/adapters/sessionStorage.mjs.map +0 -1
  77. package/dist/async-entry.d.ts.map +0 -1
  78. package/dist/async.cjs.map +0 -1
  79. package/dist/async.d.ts.map +0 -1
  80. package/dist/async.mjs.map +0 -1
  81. package/dist/batch.d.ts.map +0 -1
  82. package/dist/compose.d.ts.map +0 -1
  83. package/dist/computed-entry.d.ts.map +0 -1
  84. package/dist/computed.cjs.map +0 -1
  85. package/dist/computed.d.ts.map +0 -1
  86. package/dist/computed.mjs.map +0 -1
  87. package/dist/devtools/history.d.ts.map +0 -1
  88. package/dist/devtools/index.d.ts.map +0 -1
  89. package/dist/devtools/redux-bridge.d.ts.map +0 -1
  90. package/dist/devtools/snapshots.d.ts.map +0 -1
  91. package/dist/devtools/withDevtools.d.ts.map +0 -1
  92. package/dist/devtools.cjs.map +0 -1
  93. package/dist/devtools.mjs.map +0 -1
  94. package/dist/extensions/noop.d.ts.map +0 -1
  95. package/dist/index.cjs.js +0 -118
  96. package/dist/index.cjs.js.map +0 -1
  97. package/dist/index.cjs.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.esm.js +0 -116
  100. package/dist/index.esm.js.map +0 -1
  101. package/dist/index.mjs.map +0 -1
  102. package/dist/persist/adapters/indexedDB.d.ts.map +0 -1
  103. package/dist/persist/adapters/localStorage.d.ts.map +0 -1
  104. package/dist/persist/adapters/memory.d.ts.map +0 -1
  105. package/dist/persist/adapters/sessionStorage.d.ts.map +0 -1
  106. package/dist/persist/debounce.d.ts.map +0 -1
  107. package/dist/persist/hydrate.d.ts.map +0 -1
  108. package/dist/persist/index.d.ts.map +0 -1
  109. package/dist/persist/serialize.d.ts.map +0 -1
  110. package/dist/persist.cjs.map +0 -1
  111. package/dist/persist.mjs.map +0 -1
  112. package/dist/proxy.d.ts.map +0 -1
  113. package/dist/registry-D3X0HSbl.js +0 -26
  114. package/dist/registry-D3X0HSbl.js.map +0 -1
  115. package/dist/registry-RDjbeJdx.js +0 -29
  116. package/dist/registry-RDjbeJdx.js.map +0 -1
  117. package/dist/registry-qtr1UpFU.js.map +0 -1
  118. package/dist/registry-zaKZ1P-s.js.map +0 -1
  119. package/dist/registry.d.ts.map +0 -1
  120. package/dist/signals/createSignal.d.ts.map +0 -1
  121. package/dist/signals/index.d.ts.map +0 -1
  122. package/dist/signals/useSignal.d.ts.map +0 -1
  123. package/dist/signals.cjs.map +0 -1
  124. package/dist/signals.mjs.map +0 -1
  125. package/dist/stats.html +0 -4949
  126. package/dist/store.d.ts.map +0 -1
  127. package/dist/sync/channel.d.ts.map +0 -1
  128. package/dist/sync/index.d.ts.map +0 -1
  129. package/dist/sync/protocol.d.ts.map +0 -1
  130. package/dist/sync/withSync.d.ts.map +0 -1
  131. package/dist/sync.cjs.map +0 -1
  132. package/dist/sync.mjs.map +0 -1
  133. package/dist/types.d.ts.map +0 -1
  134. package/rollup.config.mjs +0 -44
  135. package/src/async-entry.ts +0 -6
  136. package/src/async.ts +0 -240
  137. package/src/batch.ts +0 -33
  138. package/src/compose.ts +0 -50
  139. package/src/computed-entry.ts +0 -6
  140. package/src/computed.ts +0 -187
  141. package/src/devtools/history.ts +0 -103
  142. package/src/devtools/index.ts +0 -5
  143. package/src/devtools/redux-bridge.ts +0 -70
  144. package/src/devtools/snapshots.ts +0 -54
  145. package/src/devtools/withDevtools.ts +0 -196
  146. package/src/extensions/noop.ts +0 -12
  147. package/src/index.ts +0 -4
  148. package/src/persist/adapters/indexedDB.ts +0 -114
  149. package/src/persist/adapters/localStorage.ts +0 -28
  150. package/src/persist/adapters/memory.ts +0 -26
  151. package/src/persist/adapters/sessionStorage.ts +0 -28
  152. package/src/persist/debounce.ts +0 -28
  153. package/src/persist/hydrate.ts +0 -60
  154. package/src/persist/index.ts +0 -141
  155. package/src/persist/serialize.ts +0 -60
  156. package/src/proxy.ts +0 -87
  157. package/src/registry.ts +0 -67
  158. package/src/signals/createSignal.ts +0 -81
  159. package/src/signals/index.ts +0 -20
  160. package/src/signals/useSignal.ts +0 -18
  161. package/src/store.ts +0 -250
  162. package/src/sync/channel.ts +0 -15
  163. package/src/sync/index.ts +0 -3
  164. package/src/sync/protocol.ts +0 -18
  165. package/src/sync/withSync.ts +0 -147
  166. package/src/types.ts +0 -159
  167. package/tests/async.test.ts +0 -1100
  168. package/tests/batch.test.ts +0 -41
  169. package/tests/compose.test.ts +0 -209
  170. package/tests/computed.test.ts +0 -867
  171. package/tests/devtools.test.ts +0 -1039
  172. package/tests/integration/persist.integration.test.ts +0 -258
  173. package/tests/integration/signals.integration.test.ts +0 -309
  174. package/tests/integration.test.ts +0 -278
  175. package/tests/persist/adapters/indexedDB.adapter.test.ts +0 -185
  176. package/tests/persist/adapters/localStorage.adapter.test.ts +0 -105
  177. package/tests/persist/adapters/memory.adapter.test.ts +0 -112
  178. package/tests/persist/adapters/sessionStorage.adapter.test.ts +0 -128
  179. package/tests/persist/debounce.test.ts +0 -121
  180. package/tests/persist/hydrate.test.ts +0 -120
  181. package/tests/persist/migrate.test.ts +0 -208
  182. package/tests/persist/persist.test.ts +0 -357
  183. package/tests/persist/serialize.test.ts +0 -128
  184. package/tests/proxy.test.ts +0 -473
  185. package/tests/registry.test.ts +0 -67
  186. package/tests/signals/derived.test.ts +0 -244
  187. package/tests/signals/inference.test.ts +0 -108
  188. package/tests/signals/signal.test.ts +0 -348
  189. package/tests/signals/useSignal.test.tsx +0 -275
  190. package/tests/store.test.ts +0 -482
  191. package/tests/stress.test.ts +0 -268
  192. package/tests/sync.test.ts +0 -576
  193. package/tests/types.test.ts +0 -32
  194. package/tests/v0.3.test.ts +0 -813
  195. package/tree-shake-test.js +0 -1
  196. package/tsconfig.json +0 -15
  197. package/vitest.config.ts +0 -22
  198. package/vitest_play.ts +0 -7
@@ -1,813 +0,0 @@
1
- // packages/storve/tests/v0.3.test.ts
2
- import { describe, it, expect, vi } from 'vitest'
3
- import { createStore } from '../src'
4
-
5
- // ─────────────────────────────────────────────
6
- // ACTIONS
7
- // ─────────────────────────────────────────────
8
- describe('Actions', () => {
9
-
10
- describe('State isolation', () => {
11
- it('actions key is absent from getState()', () => {
12
- const store = createStore({
13
- count: 0,
14
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
15
- })
16
- expect('actions' in store.getState()).toBe(false)
17
- })
18
-
19
- it('individual action names are absent from getState()', () => {
20
- const store = createStore({
21
- count: 0,
22
- actions: {
23
- increment() {},
24
- decrement() {},
25
- reset() {},
26
- }
27
- })
28
- const state = store.getState()
29
- expect('increment' in state).toBe(false)
30
- expect('decrement' in state).toBe(false)
31
- expect('reset' in state).toBe(false)
32
- })
33
-
34
- it('getState() returns only data keys', () => {
35
- const store = createStore({
36
- count: 0,
37
- name: 'test',
38
- actions: { doSomething() {} }
39
- })
40
- expect(Object.keys(store.getState()).sort()).toEqual(['count', 'name'].sort())
41
- })
42
-
43
- it('subscribe listener never receives actions in payload', () => {
44
- const store = createStore({
45
- count: 0,
46
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
47
- })
48
- let received: Record<string, unknown> = {}
49
- store.subscribe(s => { received = s as Record<string, unknown> })
50
- store.increment()
51
- expect('increment' in received).toBe(false)
52
- expect('actions' in received).toBe(false)
53
- })
54
-
55
- it('setState updater fn never receives actions in state arg', () => {
56
- const store = createStore({
57
- count: 0,
58
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
59
- })
60
- let capturedKeys: string[] = []
61
- store.setState(s => {
62
- capturedKeys = Object.keys(s)
63
- return s
64
- })
65
- expect(capturedKeys).not.toContain('actions')
66
- expect(capturedKeys).not.toContain('increment')
67
- })
68
- })
69
-
70
- describe('Callable behaviour', () => {
71
- it('action is callable directly on store', () => {
72
- const store = createStore({
73
- count: 0,
74
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
75
- })
76
- store.increment()
77
- expect(store.getState().count).toBe(1)
78
- })
79
-
80
- it('action updates state correctly', () => {
81
- const store = createStore({
82
- count: 0,
83
- actions: {
84
- increment() { store.setState(s => ({ count: s.count + 1 })) },
85
- decrement() { store.setState(s => ({ count: s.count - 1 })) },
86
- reset() { store.setState({ count: 0 }) },
87
- }
88
- })
89
- store.increment()
90
- store.increment()
91
- store.increment()
92
- expect(store.getState().count).toBe(3)
93
- store.decrement()
94
- expect(store.getState().count).toBe(2)
95
- store.reset()
96
- expect(store.getState().count).toBe(0)
97
- })
98
-
99
- it('action with single argument works', () => {
100
- const store = createStore({
101
- count: 0,
102
- actions: {
103
- incrementBy(n: number) { store.setState(s => ({ count: s.count + n })) }
104
- }
105
- })
106
- store.incrementBy(5)
107
- expect(store.getState().count).toBe(5)
108
- store.incrementBy(10)
109
- expect(store.getState().count).toBe(15)
110
- })
111
-
112
- it('action with multiple arguments works', () => {
113
- const store = createStore({
114
- items: [] as string[],
115
- actions: {
116
- insert(item: string, atStart: boolean) {
117
- store.setState(s => ({
118
- items: atStart ? [item, ...s.items] : [...s.items, item]
119
- }))
120
- }
121
- }
122
- })
123
- store.insert('b', false)
124
- store.insert('a', true)
125
- expect(store.getState().items).toEqual(['a', 'b'])
126
- })
127
-
128
- it('action with no arguments works', () => {
129
- const store = createStore({
130
- toggled: false,
131
- actions: {
132
- toggle() { store.setState(s => ({ toggled: !s.toggled })) }
133
- }
134
- })
135
- store.toggle()
136
- expect(store.getState().toggled).toBe(true)
137
- store.toggle()
138
- expect(store.getState().toggled).toBe(false)
139
- })
140
-
141
- it('10 rapid sequential action calls produce correct state', () => {
142
- const store = createStore({
143
- count: 0,
144
- actions: { inc() { store.setState(s => ({ count: s.count + 1 })) } }
145
- })
146
- for (let i = 0; i < 10; i++) store.inc()
147
- expect(store.getState().count).toBe(10)
148
- })
149
- })
150
-
151
- describe('Auto-binding', () => {
152
- it('destructured action works without .bind()', () => {
153
- const store = createStore({
154
- count: 0,
155
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
156
- })
157
- const { increment } = store
158
- increment()
159
- expect(store.getState().count).toBe(1)
160
- })
161
-
162
- it('action passed as callback reference works', () => {
163
- const store = createStore({
164
- count: 0,
165
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
166
- })
167
- const fn = store.increment
168
- ;[1, 2, 3].forEach(() => fn())
169
- expect(store.getState().count).toBe(3)
170
- })
171
-
172
- it('action assigned to variable and called later works', () => {
173
- const store = createStore({
174
- count: 0,
175
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
176
- })
177
- const saved = store.increment
178
- store.setState({ count: 99 })
179
- saved()
180
- expect(store.getState().count).toBe(100)
181
- })
182
-
183
- it('store.actions object is stable across multiple getState calls', () => {
184
- const store = createStore({
185
- count: 0,
186
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
187
- })
188
- const a1 = store.actions
189
- store.increment()
190
- store.increment()
191
- const a2 = store.actions
192
- expect(a1).toBe(a2) // same reference — not recreated
193
- })
194
- })
195
-
196
- describe('Async actions', () => {
197
- it('async action updates state after resolution', async () => {
198
- const store = createStore({
199
- count: 0,
200
- actions: {
201
- async incrementAsync() {
202
- await Promise.resolve()
203
- store.setState(s => ({ count: s.count + 1 }))
204
- }
205
- }
206
- })
207
- await store.incrementAsync()
208
- expect(store.getState().count).toBe(1)
209
- })
210
-
211
- it('async action notifies subscribers after completion', async () => {
212
- const store = createStore({
213
- data: '',
214
- actions: {
215
- async load() {
216
- await Promise.resolve()
217
- store.setState({ data: 'loaded' })
218
- }
219
- }
220
- })
221
- const listener = vi.fn()
222
- store.subscribe(listener)
223
- await store.load()
224
- expect(listener).toHaveBeenCalledTimes(1)
225
- })
226
-
227
- it('multiple concurrent async actions complete independently', async () => {
228
- const store = createStore({
229
- a: 0, b: 0,
230
- actions: {
231
- async setA() { await Promise.resolve(); store.setState({ a: 1 }) },
232
- async setB() { await Promise.resolve(); store.setState({ b: 2 }) },
233
- }
234
- })
235
- await Promise.all([store.setA(), store.setB()])
236
- expect(store.getState()).toMatchObject({ a: 1, b: 2 })
237
- })
238
-
239
- it('async action called 3 times sequentially accumulates state', async () => {
240
- const store = createStore({
241
- count: 0,
242
- actions: {
243
- async inc() {
244
- await Promise.resolve()
245
- store.setState(s => ({ count: s.count + 1 }))
246
- }
247
- }
248
- })
249
- await store.inc()
250
- await store.inc()
251
- await store.inc()
252
- expect(store.getState().count).toBe(3)
253
- })
254
- })
255
-
256
- describe('Actions + subscribers', () => {
257
- it('calling action notifies subscribers once', () => {
258
- const store = createStore({
259
- count: 0,
260
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
261
- })
262
- const listener = vi.fn()
263
- store.subscribe(listener)
264
- store.increment()
265
- expect(listener).toHaveBeenCalledTimes(1)
266
- })
267
-
268
- it('calling action N times notifies subscribers N times', () => {
269
- const store = createStore({
270
- count: 0,
271
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
272
- })
273
- const listener = vi.fn()
274
- store.subscribe(listener)
275
- for (let i = 0; i < 5; i++) store.increment()
276
- expect(listener).toHaveBeenCalledTimes(5)
277
- })
278
-
279
- it('unsubscribed listener does not receive action notification', () => {
280
- const store = createStore({
281
- count: 0,
282
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
283
- })
284
- const listener = vi.fn()
285
- const unsub = store.subscribe(listener)
286
- unsub()
287
- store.increment()
288
- expect(listener).not.toHaveBeenCalled()
289
- })
290
-
291
- it('multiple subscribers all notified on action call', () => {
292
- const store = createStore({
293
- count: 0,
294
- actions: { increment() { store.setState(s => ({ count: s.count + 1 })) } }
295
- })
296
- const l1 = vi.fn(), l2 = vi.fn(), l3 = vi.fn()
297
- store.subscribe(l1)
298
- store.subscribe(l2)
299
- store.subscribe(l3)
300
- store.increment()
301
- expect(l1).toHaveBeenCalledTimes(1)
302
- expect(l2).toHaveBeenCalledTimes(1)
303
- expect(l3).toHaveBeenCalledTimes(1)
304
- })
305
- })
306
-
307
- describe('Edge cases', () => {
308
- it('store with no actions still works normally', () => {
309
- const store = createStore({ count: 0 })
310
- store.setState({ count: 5 })
311
- expect(store.getState().count).toBe(5)
312
- })
313
-
314
- it('store with empty actions object works normally', () => {
315
- const store = createStore({ count: 0, actions: {} })
316
- store.setState({ count: 5 })
317
- expect(store.getState().count).toBe(5)
318
- })
319
-
320
- it('action that does not call setState does not notify subscribers', () => {
321
- const store = createStore({
322
- count: 0,
323
- actions: { noop() { /* intentionally empty */ } }
324
- })
325
- const listener = vi.fn()
326
- store.subscribe(listener)
327
- store.noop()
328
- expect(listener).not.toHaveBeenCalled()
329
- })
330
-
331
- it('action calling setState multiple times notifies subscribers multiple times', () => {
332
- const store = createStore({
333
- a: 0, b: 0,
334
- actions: {
335
- setboth() {
336
- store.setState({ a: 1 })
337
- store.setState({ b: 2 })
338
- }
339
- }
340
- })
341
- const listener = vi.fn()
342
- store.subscribe(listener)
343
- store.setboth()
344
- expect(listener).toHaveBeenCalledTimes(2)
345
- })
346
-
347
- it('100 stores created independently do not share state', () => {
348
- const stores = Array.from({ length: 100 }, () =>
349
- createStore({
350
- count: 0,
351
- actions: { inc() { stores[0].setState(s => ({ count: s.count + 1 })) } }
352
- })
353
- )
354
- stores[0].setState({ count: 99 })
355
- expect(stores[1].getState().count).toBe(0)
356
- expect(stores[99].getState().count).toBe(0)
357
- })
358
- })
359
- })
360
-
361
- // ─────────────────────────────────────────────
362
- // IMMER
363
- // ─────────────────────────────────────────────
364
- describe('Immer Integration', () => {
365
-
366
- describe('Basic mutations', () => {
367
- it('primitive mutation is applied', () => {
368
- const store = createStore({ count: 0 }, { immer: true })
369
- store.setState(draft => { draft.count = 5 })
370
- expect(store.getState().count).toBe(5)
371
- })
372
-
373
- it('string mutation is applied', () => {
374
- const store = createStore({ name: 'alice' }, { immer: true })
375
- store.setState(draft => { draft.name = 'bob' })
376
- expect(store.getState().name).toBe('bob')
377
- })
378
-
379
- it('boolean mutation is applied', () => {
380
- const store = createStore({ active: false }, { immer: true })
381
- store.setState(draft => { draft.active = true })
382
- expect(store.getState().active).toBe(true)
383
- })
384
-
385
- it('multiple fields mutated in single setState', () => {
386
- const store = createStore({ a: 0, b: 0, c: 0 }, { immer: true })
387
- store.setState(draft => { draft.a = 1; draft.b = 2; draft.c = 3 })
388
- expect(store.getState()).toMatchObject({ a: 1, b: 2, c: 3 })
389
- })
390
-
391
- it('sequential mutations accumulate correctly', () => {
392
- const store = createStore({ count: 0 }, { immer: true })
393
- store.setState(draft => { draft.count++ })
394
- store.setState(draft => { draft.count++ })
395
- store.setState(draft => { draft.count++ })
396
- expect(store.getState().count).toBe(3)
397
- })
398
- })
399
-
400
- describe('Immutability', () => {
401
- it('original state object is never mutated', () => {
402
- const store = createStore({ count: 0 }, { immer: true })
403
- const before = store.getState()
404
- store.setState(draft => { draft.count = 99 })
405
- expect(before.count).toBe(0)
406
- })
407
-
408
- it('new state is a new object reference after mutation', () => {
409
- const store = createStore({ count: 0 }, { immer: true })
410
- const before = store.getState()
411
- store.setState(draft => { draft.count = 1 })
412
- expect(store.getState()).not.toBe(before)
413
- })
414
-
415
- it('unchanged fields are preserved across mutations', () => {
416
- const store = createStore({ a: 1, b: 2, c: 3 }, { immer: true })
417
- store.setState(draft => { draft.a = 99 })
418
- expect(store.getState().b).toBe(2)
419
- expect(store.getState().c).toBe(3)
420
- })
421
- })
422
-
423
- describe('Nested state', () => {
424
- it('nested object field mutation works', () => {
425
- const store = createStore({ user: { name: 'Alice', age: 30 } }, { immer: true })
426
- store.setState(draft => { draft.user.age = 31 })
427
- expect(store.getState().user.age).toBe(31)
428
- expect(store.getState().user.name).toBe('Alice')
429
- })
430
-
431
- it('deeply nested mutation works', () => {
432
- const store = createStore({ a: { b: { c: { value: 0 } } } }, { immer: true })
433
- store.setState(draft => { draft.a.b.c.value = 42 })
434
- expect(store.getState().a.b.c.value).toBe(42)
435
- })
436
-
437
- it('multiple nested fields mutated independently', () => {
438
- const store = createStore({
439
- config: { theme: 'light', lang: 'en', debug: false }
440
- }, { immer: true })
441
- store.setState(draft => {
442
- draft.config.theme = 'dark'
443
- draft.config.debug = true
444
- })
445
- expect(store.getState().config.theme).toBe('dark')
446
- expect(store.getState().config.debug).toBe(true)
447
- expect(store.getState().config.lang).toBe('en')
448
- })
449
-
450
- it('nested object is replaced entirely', () => {
451
- const store = createStore({ user: { name: 'Alice', age: 30 } }, { immer: true })
452
- store.setState(draft => {
453
- draft.user = { name: 'Bob', age: 25 }
454
- })
455
- expect(store.getState().user).toEqual({ name: 'Bob', age: 25 })
456
- })
457
- })
458
-
459
- describe('Array operations', () => {
460
- it('array push works', () => {
461
- const store = createStore({ items: [1, 2, 3] }, { immer: true })
462
- store.setState(draft => { draft.items.push(4) })
463
- expect(store.getState().items).toEqual([1, 2, 3, 4])
464
- })
465
-
466
- it('array pop works', () => {
467
- const store = createStore({ items: [1, 2, 3] }, { immer: true })
468
- store.setState(draft => { draft.items.pop() })
469
- expect(store.getState().items).toEqual([1, 2])
470
- })
471
-
472
- it('array filter works', () => {
473
- const store = createStore({ items: [1, 2, 3, 4] }, { immer: true })
474
- store.setState(draft => {
475
- draft.items = draft.items.filter(i => i % 2 === 0)
476
- })
477
- expect(store.getState().items).toEqual([2, 4])
478
- })
479
-
480
- it('array splice works', () => {
481
- const store = createStore({ items: ['a', 'b', 'c'] }, { immer: true })
482
- store.setState(draft => { draft.items.splice(1, 1) })
483
- expect(store.getState().items).toEqual(['a', 'c'])
484
- })
485
-
486
- it('array item property mutation works', () => {
487
- type Todo = { id: number; done: boolean }
488
- const store = createStore({
489
- todos: [{ id: 1, done: false }, { id: 2, done: false }] as Todo[]
490
- }, { immer: true })
491
- store.setState(draft => {
492
- const t = draft.todos.find(t => t.id === 1)
493
- if (t) t.done = true
494
- })
495
- expect(store.getState().todos[0].done).toBe(true)
496
- expect(store.getState().todos[1].done).toBe(false)
497
- })
498
-
499
- it('array unshift works', () => {
500
- const store = createStore({ items: [2, 3] }, { immer: true })
501
- store.setState(draft => { draft.items.unshift(1) })
502
- expect(store.getState().items).toEqual([1, 2, 3])
503
- })
504
-
505
- it('array sort works', () => {
506
- const store = createStore({ items: [3, 1, 2] }, { immer: true })
507
- store.setState(draft => { draft.items.sort((a, b) => a - b) })
508
- expect(store.getState().items).toEqual([1, 2, 3])
509
- })
510
-
511
- it('array cleared by reassignment works', () => {
512
- const store = createStore({ items: [1, 2, 3] }, { immer: true })
513
- store.setState(draft => { draft.items = [] })
514
- expect(store.getState().items).toEqual([])
515
- })
516
- })
517
-
518
- describe('setState form compatibility', () => {
519
- it('plain object setState still works when immer: true', () => {
520
- const store = createStore({ count: 0, name: 'a' }, { immer: true })
521
- store.setState({ count: 5 })
522
- expect(store.getState().count).toBe(5)
523
- expect(store.getState().name).toBe('a')
524
- })
525
-
526
- it('updater function returning new state works when immer: true', () => {
527
- const store = createStore({ count: 0 }, { immer: true })
528
- store.setState(s => ({ count: s.count + 10 }))
529
- expect(store.getState().count).toBe(10)
530
- })
531
-
532
- it('immer mutator notifies subscribers', () => {
533
- const store = createStore({ count: 0 }, { immer: true })
534
- const listener = vi.fn()
535
- store.subscribe(listener)
536
- store.setState(draft => { draft.count++ })
537
- expect(listener).toHaveBeenCalledTimes(1)
538
- })
539
- })
540
-
541
- describe('Immer disabled (default)', () => {
542
- it('plain updater works without immer option', () => {
543
- const store = createStore({ count: 0 })
544
- store.setState(s => ({ count: s.count + 1 }))
545
- expect(store.getState().count).toBe(1)
546
- })
547
-
548
- it('plain object setState works without immer option', () => {
549
- const store = createStore({ count: 0 })
550
- store.setState({ count: 42 })
551
- expect(store.getState().count).toBe(42)
552
- })
553
- })
554
-
555
- describe('Immer + Actions', () => {
556
- it('action uses immer mutation style', () => {
557
- type Todo = { id: number; text: string; done: boolean }
558
- const store = createStore({
559
- todos: [] as Todo[],
560
- actions: {
561
- add(text: string) {
562
- store.setState(draft => {
563
- draft.todos.push({ id: 1, text, done: false })
564
- })
565
- },
566
- toggle(id: number) {
567
- store.setState(draft => {
568
- const t = draft.todos.find(t => t.id === id)
569
- if (t) t.done = !t.done
570
- })
571
- },
572
- remove(id: number) {
573
- store.setState(draft => {
574
- draft.todos = draft.todos.filter(t => t.id !== id)
575
- })
576
- }
577
- }
578
- }, { immer: true })
579
-
580
- store.add('Buy milk')
581
- expect(store.getState().todos).toHaveLength(1)
582
- store.toggle(1)
583
- expect(store.getState().todos[0].done).toBe(true)
584
- store.remove(1)
585
- expect(store.getState().todos).toHaveLength(0)
586
- })
587
-
588
- it('action using immer does not mutate previous state snapshots', () => {
589
- const store = createStore({ count: 0 }, { immer: true })
590
- const snapshots: number[] = []
591
- store.subscribe(s => snapshots.push((s as { count: number }).count))
592
- store.setState(draft => { draft.count = 1 })
593
- store.setState(draft => { draft.count = 2 })
594
- store.setState(draft => { draft.count = 3 })
595
- expect(snapshots).toEqual([1, 2, 3])
596
- })
597
- })
598
- })
599
-
600
- // ─────────────────────────────────────────────
601
- // BATCH UPDATES
602
- // ─────────────────────────────────────────────
603
- describe('Batch Updates', () => {
604
-
605
- describe('Notification count', () => {
606
- it('3 setState calls inside batch fire exactly 1 notification', () => {
607
- const store = createStore({ a: 0, b: 0, c: 0 })
608
- const listener = vi.fn()
609
- store.subscribe(listener)
610
- store.batch(() => {
611
- store.setState({ a: 1 })
612
- store.setState({ b: 2 })
613
- store.setState({ c: 3 })
614
- })
615
- expect(listener).toHaveBeenCalledTimes(1)
616
- })
617
-
618
- it('10 setState calls inside batch fire exactly 1 notification', () => {
619
- const store = createStore({
620
- v0:0,v1:0,v2:0,v3:0,v4:0,v5:0,v6:0,v7:0,v8:0,v9:0
621
- })
622
- const listener = vi.fn()
623
- store.subscribe(listener)
624
- store.batch(() => {
625
- for (let i = 0; i < 10; i++) store.setState({ v0: i })
626
- })
627
- expect(listener).toHaveBeenCalledTimes(1)
628
- })
629
-
630
- it('single setState inside batch fires exactly 1 notification', () => {
631
- const store = createStore({ count: 0 })
632
- const listener = vi.fn()
633
- store.subscribe(listener)
634
- store.batch(() => { store.setState({ count: 1 }) })
635
- expect(listener).toHaveBeenCalledTimes(1)
636
- })
637
-
638
- it('empty batch fires 0 notifications', () => {
639
- const store = createStore({ count: 0 })
640
- const listener = vi.fn()
641
- store.subscribe(listener)
642
- store.batch(() => {})
643
- expect(listener).toHaveBeenCalledTimes(0)
644
- })
645
-
646
- it('outside batch, 3 setState calls fire 3 notifications', () => {
647
- const store = createStore({ a: 0, b: 0, c: 0 })
648
- const listener = vi.fn()
649
- store.subscribe(listener)
650
- store.setState({ a: 1 })
651
- store.setState({ b: 2 })
652
- store.setState({ c: 3 })
653
- expect(listener).toHaveBeenCalledTimes(3)
654
- })
655
-
656
- it('multiple subscribers each receive exactly 1 notification from batch', () => {
657
- const store = createStore({ a: 0, b: 0 })
658
- const l1 = vi.fn(), l2 = vi.fn(), l3 = vi.fn()
659
- store.subscribe(l1)
660
- store.subscribe(l2)
661
- store.subscribe(l3)
662
- store.batch(() => {
663
- store.setState({ a: 1 })
664
- store.setState({ b: 2 })
665
- })
666
- expect(l1).toHaveBeenCalledTimes(1)
667
- expect(l2).toHaveBeenCalledTimes(1)
668
- expect(l3).toHaveBeenCalledTimes(1)
669
- })
670
- })
671
-
672
- describe('State correctness', () => {
673
- it('all changes from batch are visible after batch completes', () => {
674
- const store = createStore({ a: 0, b: 0, c: 0 })
675
- store.batch(() => {
676
- store.setState({ a: 1 })
677
- store.setState({ b: 2 })
678
- store.setState({ c: 3 })
679
- })
680
- expect(store.getState()).toMatchObject({ a: 1, b: 2, c: 3 })
681
- })
682
-
683
- it('subscriber receives final merged state from batch', () => {
684
- const store = createStore({ a: 0, b: 0, c: 0 })
685
- let received: Record<string, number> = {}
686
- store.subscribe(s => { received = s as Record<string, number> })
687
- store.batch(() => {
688
- store.setState({ a: 1 })
689
- store.setState({ b: 2 })
690
- store.setState({ c: 3 })
691
- })
692
- expect(received).toMatchObject({ a: 1, b: 2, c: 3 })
693
- })
694
-
695
- it('later setState in batch overwrites earlier one for same key', () => {
696
- const store = createStore({ count: 0 })
697
- store.batch(() => {
698
- store.setState({ count: 1 })
699
- store.setState({ count: 2 })
700
- store.setState({ count: 3 })
701
- })
702
- expect(store.getState().count).toBe(3)
703
- })
704
-
705
- it('updater functions in batch receive correct intermediate state', () => {
706
- const store = createStore({ count: 10 })
707
- store.batch(() => {
708
- store.setState(s => ({ count: s.count + 1 }))
709
- store.setState(s => ({ count: s.count + 1 }))
710
- store.setState(s => ({ count: s.count + 1 }))
711
- })
712
- expect(store.getState().count).toBe(13)
713
- })
714
-
715
- it('state during batch is not visible to outside code mid-batch', () => {
716
- const store = createStore({ count: 0 })
717
- const snapshots: number[] = []
718
- store.subscribe(s => snapshots.push((s as { count: number }).count))
719
- store.batch(() => {
720
- store.setState({ count: 1 })
721
- store.setState({ count: 2 })
722
- store.setState({ count: 3 })
723
- })
724
- // Only the final value should have been published
725
- expect(snapshots).toEqual([3])
726
- })
727
- })
728
-
729
- describe('Nested batch', () => {
730
- it('nested batch results in 1 total notification', () => {
731
- const store = createStore({ a: 0, b: 0 })
732
- const listener = vi.fn()
733
- store.subscribe(listener)
734
- store.batch(() => {
735
- store.setState({ a: 1 })
736
- store.batch(() => {
737
- store.setState({ b: 2 })
738
- })
739
- })
740
- expect(listener).toHaveBeenCalledTimes(1)
741
- })
742
-
743
- it('3-level nested batch results in 1 total notification', () => {
744
- const store = createStore({ a: 0, b: 0, c: 0 })
745
- const listener = vi.fn()
746
- store.subscribe(listener)
747
- store.batch(() => {
748
- store.setState({ a: 1 })
749
- store.batch(() => {
750
- store.setState({ b: 2 })
751
- store.batch(() => {
752
- store.setState({ c: 3 })
753
- })
754
- })
755
- })
756
- expect(listener).toHaveBeenCalledTimes(1)
757
- expect(store.getState()).toMatchObject({ a: 1, b: 2, c: 3 })
758
- })
759
- })
760
-
761
- describe('Batch + Actions', () => {
762
- it('multiple actions in batch fire 1 notification', () => {
763
- const store = createStore({
764
- count: 0,
765
- name: 'a',
766
- actions: {
767
- setCount(n: number) { store.setState({ count: n }) },
768
- setName(n: string) { store.setState({ name: n }) },
769
- }
770
- })
771
- const listener = vi.fn()
772
- store.subscribe(listener)
773
- store.batch(() => {
774
- store.setCount(5)
775
- store.setName('z')
776
- })
777
- expect(listener).toHaveBeenCalledTimes(1)
778
- expect(store.getState()).toMatchObject({ count: 5, name: 'z' })
779
- })
780
- })
781
-
782
- describe('Batch + Immer', () => {
783
- it('immer mutations inside batch fire 1 notification', () => {
784
- const store = createStore({ a: 0, b: 0 }, { immer: true })
785
- const listener = vi.fn()
786
- store.subscribe(listener)
787
- store.batch(() => {
788
- store.setState(draft => { draft.a = 1 })
789
- store.setState(draft => { draft.b = 2 })
790
- })
791
- expect(listener).toHaveBeenCalledTimes(1)
792
- expect(store.getState()).toMatchObject({ a: 1, b: 2 })
793
- })
794
- })
795
-
796
- describe('Batch error handling', () => {
797
- it('batchCount resets to 0 if batch fn throws', () => {
798
- const store = createStore({ count: 0 })
799
- const listener = vi.fn()
800
- store.subscribe(listener)
801
- expect(() => {
802
- store.batch(() => {
803
- store.setState({ count: 1 })
804
- throw new Error('intentional error')
805
- })
806
- }).toThrow('intentional error')
807
- // After throw, store should still be functional
808
- store.setState({ count: 99 })
809
- expect(listener).toHaveBeenCalled()
810
- expect(store.getState().count).toBe(99)
811
- })
812
- })
813
- })