@zeix/cause-effect 0.17.2 → 0.18.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.
Files changed (94) hide show
  1. package/.ai-context.md +163 -226
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/.zed/settings.json +3 -0
  5. package/ARCHITECTURE.md +274 -0
  6. package/CLAUDE.md +197 -202
  7. package/COLLECTION_REFACTORING.md +161 -0
  8. package/GUIDE.md +298 -0
  9. package/README.md +241 -220
  10. package/REQUIREMENTS.md +100 -0
  11. package/bench/reactivity.bench.ts +577 -0
  12. package/index.dev.js +1326 -1174
  13. package/index.js +1 -1
  14. package/index.ts +58 -85
  15. package/package.json +9 -6
  16. package/src/errors.ts +118 -70
  17. package/src/graph.ts +601 -0
  18. package/src/nodes/collection.ts +474 -0
  19. package/src/nodes/effect.ts +149 -0
  20. package/src/nodes/list.ts +588 -0
  21. package/src/nodes/memo.ts +120 -0
  22. package/src/nodes/sensor.ts +139 -0
  23. package/src/nodes/state.ts +135 -0
  24. package/src/nodes/store.ts +383 -0
  25. package/src/nodes/task.ts +146 -0
  26. package/src/signal.ts +112 -64
  27. package/src/util.ts +26 -57
  28. package/test/batch.test.ts +96 -69
  29. package/test/benchmark.test.ts +473 -485
  30. package/test/collection.test.ts +455 -955
  31. package/test/effect.test.ts +293 -696
  32. package/test/list.test.ts +332 -857
  33. package/test/memo.test.ts +380 -0
  34. package/test/regression.test.ts +156 -0
  35. package/test/scope.test.ts +191 -0
  36. package/test/sensor.test.ts +454 -0
  37. package/test/signal.test.ts +220 -213
  38. package/test/state.test.ts +217 -271
  39. package/test/store.test.ts +346 -898
  40. package/test/task.test.ts +395 -0
  41. package/test/untrack.test.ts +167 -0
  42. package/test/util/dependency-graph.ts +2 -2
  43. package/tsconfig.build.json +11 -0
  44. package/tsconfig.json +5 -7
  45. package/types/index.d.ts +13 -15
  46. package/types/src/errors.d.ts +73 -19
  47. package/types/src/graph.d.ts +208 -0
  48. package/types/src/nodes/collection.d.ts +64 -0
  49. package/types/src/nodes/effect.d.ts +48 -0
  50. package/types/src/nodes/list.d.ts +65 -0
  51. package/types/src/nodes/memo.d.ts +57 -0
  52. package/types/src/nodes/sensor.d.ts +75 -0
  53. package/types/src/nodes/state.d.ts +78 -0
  54. package/types/src/nodes/store.d.ts +51 -0
  55. package/types/src/nodes/task.d.ts +73 -0
  56. package/types/src/signal.d.ts +43 -28
  57. package/types/src/util.d.ts +9 -16
  58. package/archive/benchmark.ts +0 -688
  59. package/archive/collection.ts +0 -310
  60. package/archive/computed.ts +0 -198
  61. package/archive/list.ts +0 -544
  62. package/archive/memo.ts +0 -140
  63. package/archive/state.ts +0 -90
  64. package/archive/store.ts +0 -357
  65. package/archive/task.ts +0 -191
  66. package/src/classes/collection.ts +0 -298
  67. package/src/classes/composite.ts +0 -171
  68. package/src/classes/computed.ts +0 -392
  69. package/src/classes/list.ts +0 -310
  70. package/src/classes/ref.ts +0 -96
  71. package/src/classes/state.ts +0 -131
  72. package/src/classes/store.ts +0 -227
  73. package/src/diff.ts +0 -138
  74. package/src/effect.ts +0 -96
  75. package/src/match.ts +0 -45
  76. package/src/resolve.ts +0 -49
  77. package/src/system.ts +0 -275
  78. package/test/computed.test.ts +0 -1126
  79. package/test/diff.test.ts +0 -955
  80. package/test/match.test.ts +0 -388
  81. package/test/ref.test.ts +0 -381
  82. package/test/resolve.test.ts +0 -154
  83. package/types/src/classes/collection.d.ts +0 -47
  84. package/types/src/classes/composite.d.ts +0 -15
  85. package/types/src/classes/computed.d.ts +0 -114
  86. package/types/src/classes/list.d.ts +0 -41
  87. package/types/src/classes/ref.d.ts +0 -48
  88. package/types/src/classes/state.d.ts +0 -61
  89. package/types/src/classes/store.d.ts +0 -51
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. package/types/src/system.d.ts +0 -81
@@ -1,52 +1,81 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- BaseStore,
4
3
  createEffect,
4
+ createMemo,
5
+ createState,
5
6
  createStore,
7
+ isList,
6
8
  isStore,
7
- Memo,
8
- State,
9
- UNSET,
10
9
  } from '../index.ts'
11
- import { HOOK_WATCH } from '../src/system'
12
10
 
13
- describe('store', () => {
14
- describe('creation and basic operations', () => {
15
- test('creates BaseStore with initial values', () => {
16
- const user = new BaseStore({
11
+ describe('Store', () => {
12
+ describe('createStore', () => {
13
+ test('should create a store with initial values', () => {
14
+ const user = createStore({
17
15
  name: 'Hannah',
18
16
  email: 'hannah@example.com',
19
17
  })
20
- expect(user.byKey('name').get()).toBe('Hannah')
21
- expect(user.byKey('email').get()).toBe('hannah@example.com')
18
+ expect(user.name.get()).toBe('Hannah')
19
+ expect(user.email.get()).toBe('hannah@example.com')
22
20
  })
23
21
 
24
- test('creates stores with initial values', () => {
22
+ test('should create nested stores for object properties', () => {
25
23
  const user = createStore({
26
- name: 'Hannah',
27
- email: 'hannah@example.com',
24
+ name: 'Alice',
25
+ preferences: { theme: 'light', notifications: true },
28
26
  })
29
- expect(user.name.get()).toBe('Hannah')
30
- expect(user.email.get()).toBe('hannah@example.com')
27
+ expect(isStore(user.preferences)).toBe(true)
28
+ expect(user.preferences.theme.get()).toBe('light')
29
+ expect(user.preferences.notifications.get()).toBe(true)
30
+ })
31
+
32
+ test('should create lists for array properties', () => {
33
+ const data = createStore({ tags: ['a', 'b', 'c'] })
34
+ expect(isList(data.tags)).toBe(true)
35
+ expect(data.tags.get()).toEqual(['a', 'b', 'c'])
36
+ })
37
+
38
+ test('should handle deeply nested objects', () => {
39
+ const config = createStore({
40
+ ui: { theme: { colors: { primary: '#007acc' } } },
41
+ })
42
+ expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
43
+ config.ui.theme.colors.primary.set('#ff6600')
44
+ expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
45
+ })
46
+
47
+ test('should handle empty initial value', () => {
48
+ const empty = createStore({})
49
+ expect(empty.get()).toEqual({})
31
50
  })
32
51
 
33
- test('has Symbol.toStringTag of Store', () => {
52
+ test('should have Symbol.toStringTag of "Store"', () => {
34
53
  const store = createStore({ a: 1 })
35
54
  expect(store[Symbol.toStringTag]).toBe('Store')
36
55
  })
37
56
 
38
- test('isStore identifies store instances correctly', () => {
57
+ test('should have Symbol.isConcatSpreadable set to false', () => {
39
58
  const store = createStore({ a: 1 })
40
- const state = new State(1)
41
- const computed = new Memo(() => 1)
59
+ expect(store[Symbol.isConcatSpreadable]).toBe(false)
60
+ })
61
+ })
42
62
 
63
+ describe('isStore', () => {
64
+ test('should return true for store instances', () => {
65
+ const store = createStore({ a: 1 })
43
66
  expect(isStore(store)).toBe(true)
44
- expect(isStore(state)).toBe(false)
45
- expect(isStore(computed)).toBe(false)
67
+ })
68
+
69
+ test('should return false for non-store values', () => {
70
+ expect(isStore(createState(1))).toBe(false)
71
+ expect(isStore(createMemo(() => 1))).toBe(false)
46
72
  expect(isStore({})).toBe(false)
73
+ expect(isStore(null)).toBe(false)
47
74
  })
75
+ })
48
76
 
49
- test('get() returns the complete store value', () => {
77
+ describe('get', () => {
78
+ test('should return the complete store value', () => {
50
79
  const user = createStore({
51
80
  name: 'Alice',
52
81
  email: 'alice@example.com',
@@ -56,146 +85,240 @@ describe('store', () => {
56
85
  email: 'alice@example.com',
57
86
  })
58
87
  })
88
+
89
+ test('should return updated value after property changes', () => {
90
+ const user = createStore({ name: 'Alice', age: 30 })
91
+ user.name.set('Bob')
92
+ expect(user.get()).toEqual({ name: 'Bob', age: 30 })
93
+ })
59
94
  })
60
95
 
61
- describe('proxy data access and modification', () => {
62
- test('properties can be accessed and modified via signals', () => {
63
- const user = createStore({ name: 'John', age: 30 })
64
- expect(user.name.get()).toBe('John')
65
- expect(user.age.get()).toBe(30)
96
+ describe('set', () => {
97
+ test('should replace entire store value', () => {
98
+ const user = createStore({
99
+ name: 'John',
100
+ email: 'john@example.com',
101
+ })
102
+ user.set({ name: 'Jane', email: 'jane@example.com' })
103
+ expect(user.name.get()).toBe('Jane')
104
+ expect(user.email.get()).toBe('jane@example.com')
105
+ })
66
106
 
67
- user.name.set('Alicia')
68
- user.age.set(31)
69
- expect(user.name.get()).toBe('Alicia')
70
- expect(user.age.get()).toBe(31)
107
+ test('should diff and apply granular changes', () => {
108
+ const user = createStore({ name: 'John', age: 25 })
109
+ let nameRuns = 0
110
+ let ageRuns = 0
111
+ createEffect(() => {
112
+ user.name.get()
113
+ nameRuns++
114
+ })
115
+ createEffect(() => {
116
+ user.age.get()
117
+ ageRuns++
118
+ })
119
+ expect(nameRuns).toBe(1)
120
+ expect(ageRuns).toBe(1)
121
+
122
+ // Only change age — name effect should not re-run
123
+ user.set({ name: 'John', age: 26 })
124
+ expect(nameRuns).toBe(1)
125
+ expect(ageRuns).toBe(2)
71
126
  })
72
127
 
73
- test('returns undefined for non-existent properties', () => {
74
- const user = createStore({ name: 'Alice' })
75
- // @ts-expect-error accessing non-existent property
76
- expect(user.nonexistent).toBeUndefined()
128
+ test('should not propagate when value is identical', () => {
129
+ const store = createStore({ x: 1 })
130
+ let runs = 0
131
+ createEffect(() => {
132
+ store.get()
133
+ runs++
134
+ })
135
+ expect(runs).toBe(1)
136
+
137
+ store.set({ x: 1 })
138
+ expect(runs).toBe(1)
77
139
  })
140
+ })
78
141
 
79
- test('supports string key access', () => {
80
- const items = createStore({ first: 'alpha', second: 'beta' })
81
- expect(items.first.get()).toBe('alpha')
82
- expect(items.second.get()).toBe('beta')
142
+ describe('update', () => {
143
+ test('should modify store using callback', () => {
144
+ const user = createStore({ name: 'John', age: 25 })
145
+ user.update(u => ({ ...u, age: u.age + 1 }))
146
+ expect(user.name.get()).toBe('John')
147
+ expect(user.age.get()).toBe(26)
83
148
  })
84
149
  })
85
150
 
86
- describe('add() and remove() methods', () => {
87
- test('add() method adds new properties', () => {
151
+ describe('add', () => {
152
+ test('should add a new property', () => {
88
153
  const user = createStore<{ name: string; email?: string }>({
89
154
  name: 'John',
90
155
  })
91
156
  user.add('email', 'john@example.com')
92
- expect(user.byKey('email')?.get()).toBe('john@example.com')
93
157
  expect(user.email?.get()).toBe('john@example.com')
158
+ expect(user.byKey('email')?.get()).toBe('john@example.com')
94
159
  })
95
160
 
96
- test('remove() method removes properties', () => {
161
+ test('should throw DuplicateKeyError for existing key', () => {
162
+ const user = createStore({ name: 'John' })
163
+ expect(() => user.add('name', 'Jane')).toThrow()
164
+ })
165
+
166
+ test('should be reactive', () => {
167
+ const store = createStore<{ name: string; email?: string }>({
168
+ name: 'John',
169
+ })
170
+ let lastValue: { name: string; email?: string } = { name: '' }
171
+ createEffect(() => {
172
+ lastValue = store.get()
173
+ })
174
+ expect(lastValue).toEqual({ name: 'John' })
175
+
176
+ store.add('email', 'john@example.com')
177
+ expect(lastValue).toEqual({
178
+ name: 'John',
179
+ email: 'john@example.com',
180
+ })
181
+ })
182
+ })
183
+
184
+ describe('remove', () => {
185
+ test('should remove an existing property', () => {
97
186
  const user = createStore<{ name: string; email?: string }>({
98
187
  name: 'John',
99
188
  email: 'john@example.com',
100
189
  })
101
190
  user.remove('email')
102
191
  expect(user.byKey('email')).toBeUndefined()
103
- // expect(user.byKey('name').get()).toBe('John')
104
192
  expect(user.email).toBeUndefined()
105
- // expect(user.name.get()).toBe('John')
106
193
  })
107
194
 
108
- test('add method prevents null values', () => {
109
- const user = createStore<{ name: string; email?: string }>({
110
- name: 'John',
111
- })
112
- // @ts-expect-error testing null values
113
- expect(() => user.add('email', null)).toThrow()
195
+ test('should handle non-existent key gracefully', () => {
196
+ const store = createStore({ name: 'John' })
197
+ expect(() => store.remove('nonexistent')).not.toThrow()
114
198
  })
115
199
 
116
- test('add method prevents overwriting existing properties', () => {
117
- const user = createStore({
200
+ test('should be reactive', () => {
201
+ const store = createStore({
118
202
  name: 'John',
119
203
  email: 'john@example.com',
120
204
  })
121
- expect(() => user.add('name', 'Jane')).toThrow()
122
- })
205
+ let lastValue: { name: string; email?: string } = {
206
+ name: '',
207
+ email: '',
208
+ }
209
+ let runs = 0
210
+ createEffect(() => {
211
+ lastValue = store.get()
212
+ runs++
213
+ })
214
+ expect(runs).toBe(1)
123
215
 
124
- test('remove method handles non-existent properties gracefully', () => {
125
- const user = createStore({ name: 'John' })
126
- expect(() => user.remove('nonexistent')).not.toThrow()
216
+ store.remove('email')
217
+ expect(lastValue).toEqual({ name: 'John' })
218
+ expect(runs).toBe(2)
127
219
  })
128
220
  })
129
221
 
130
- describe('nested stores', () => {
131
- test('creates nested stores for object properties', () => {
132
- const user = createStore({
133
- name: 'Alice',
134
- preferences: {
135
- theme: 'light',
136
- notifications: true,
137
- },
138
- })
222
+ describe('byKey', () => {
223
+ test('should return the signal for a property', () => {
224
+ const user = createStore({ name: 'Alice', age: 30 })
225
+ const nameSignal = user.byKey('name')
226
+ expect(nameSignal?.get()).toBe('Alice')
227
+ expect(nameSignal).toBe(user.name)
228
+ })
139
229
 
140
- expect(user.name.get()).toBe('Alice')
141
- expect(user.preferences.theme.get()).toBe('light')
142
- expect(user.preferences.notifications.get()).toBe(true)
230
+ test('should return nested store for object property', () => {
231
+ const app = createStore({ config: { version: '1.0.0' } })
232
+ const configStore = app.byKey('config')
233
+ expect(isStore(configStore)).toBe(true)
234
+ expect(configStore).toBe(app.config)
143
235
  })
144
236
 
145
- test('nested properties are reactive', () => {
146
- const user = createStore({
147
- preferences: {
148
- theme: 'light',
149
- },
150
- })
151
- let lastTheme = ''
152
- createEffect(() => {
153
- lastTheme = user.preferences.theme.get()
154
- })
237
+ test('should return undefined for non-existent key', () => {
238
+ const store = createStore({ name: 'Alice' })
239
+ // @ts-expect-error deliberate access for nonexistent key
240
+ expect(store.byKey('nonexistent')).toBeUndefined()
241
+ })
242
+ })
155
243
 
156
- expect(lastTheme).toBe('light')
157
- user.preferences.theme.set('dark')
158
- expect(lastTheme).toBe('dark')
244
+ describe('keys', () => {
245
+ test('should return an iterator of property keys', () => {
246
+ const store = createStore({ alpha: 1, beta: 2, gamma: 3 })
247
+ expect(Array.from(store.keys())).toEqual(['alpha', 'beta', 'gamma'])
159
248
  })
160
249
 
161
- test('deeply nested stores work correctly', () => {
162
- const config = createStore({
163
- ui: {
164
- theme: {
165
- colors: {
166
- primary: '#007acc',
167
- },
168
- },
169
- },
170
- })
250
+ test('should reflect additions and removals', () => {
251
+ const store = createStore<{ a: number; b?: number }>({ a: 1 })
252
+ expect(Array.from(store.keys())).toEqual(['a'])
171
253
 
172
- expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
173
- config.ui.theme.colors.primary.set('#ff6600')
174
- expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
254
+ store.add('b', 2)
255
+ expect(Array.from(store.keys())).toEqual(['a', 'b'])
256
+
257
+ store.remove('b')
258
+ expect(Array.from(store.keys())).toEqual(['a'])
175
259
  })
176
260
  })
177
261
 
178
- describe('set() and update() methods', () => {
179
- test('set() replaces entire store value', () => {
262
+ describe('Proxy Behavior', () => {
263
+ test('should access properties directly as signals', () => {
264
+ const user = createStore({ name: 'John', age: 30 })
265
+ expect(user.name.get()).toBe('John')
266
+ expect(user.age.get()).toBe(30)
267
+
268
+ user.name.set('Alicia')
269
+ expect(user.name.get()).toBe('Alicia')
270
+ })
271
+
272
+ test('should return undefined for non-existent properties', () => {
273
+ const user = createStore({ name: 'Alice' })
274
+ // @ts-expect-error accessing non-existent property
275
+ expect(user.nonexistent).toBeUndefined()
276
+ })
277
+
278
+ test('should support "in" operator', () => {
279
+ const user = createStore({ name: 'Alice' })
280
+ expect('name' in user).toBe(true)
281
+ expect('email' in user).toBe(false)
282
+ })
283
+
284
+ test('should support Object.keys()', () => {
180
285
  const user = createStore({
181
- name: 'John',
182
- email: 'john@example.com',
286
+ name: 'Alice',
287
+ email: 'alice@example.com',
183
288
  })
184
- user.set({ name: 'Jane', email: 'jane@example.com' })
185
- expect(user.name.get()).toBe('Jane')
186
- expect(user.email.get()).toBe('jane@example.com')
289
+ expect(Object.keys(user).sort()).toEqual(['email', 'name'])
187
290
  })
188
291
 
189
- test('update() modifies store using function', () => {
190
- const user = createStore({ name: 'John', age: 25 })
191
- user.update(u => ({ ...u, age: u.age + 1 }))
192
- expect(user.name.get()).toBe('John')
193
- expect(user.age.get()).toBe(26)
292
+ test('should support for...in enumeration', () => {
293
+ const user = createStore({
294
+ name: 'Alice',
295
+ email: 'alice@example.com',
296
+ })
297
+ const keys: string[] = []
298
+ for (const key in user) keys.push(key)
299
+ expect(keys.sort()).toEqual(['email', 'name'])
300
+ })
301
+
302
+ test('should support Object.getOwnPropertyDescriptor', () => {
303
+ const user = createStore({ name: 'Alice' })
304
+ expect(Object.getOwnPropertyDescriptor(user, 'name')).toEqual({
305
+ enumerable: true,
306
+ configurable: true,
307
+ writable: true,
308
+ value: user.name,
309
+ })
310
+ })
311
+
312
+ test('should return undefined descriptor for Symbol properties', () => {
313
+ const store = createStore({ a: 1 })
314
+ expect(
315
+ Object.getOwnPropertyDescriptor(store, Symbol('test')),
316
+ ).toBeUndefined()
194
317
  })
195
318
  })
196
319
 
197
- describe('iteration protocol', () => {
198
- test('supports for...of iteration', () => {
320
+ describe('Iteration', () => {
321
+ test('should support spread operator', () => {
199
322
  const user = createStore({ name: 'John', age: 25 })
200
323
  const entries = [...user]
201
324
  expect(entries).toHaveLength(2)
@@ -205,16 +328,8 @@ describe('store', () => {
205
328
  expect(entries[1][1].get()).toBe(25)
206
329
  })
207
330
 
208
- test('Symbol.isConcatSpreadable is false', () => {
209
- const user = createStore({ name: 'John', age: 25 })
210
- expect(user[Symbol.isConcatSpreadable]).toBe(false)
211
- })
212
-
213
- test('maintains property key ordering', () => {
331
+ test('should maintain property key ordering', () => {
214
332
  const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
215
- const keys = Object.keys(config)
216
- expect(keys).toEqual(['alpha', 'beta', 'gamma'])
217
-
218
333
  const entries = [...config]
219
334
  expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
220
335
  [
@@ -226,116 +341,8 @@ describe('store', () => {
226
341
  })
227
342
  })
228
343
 
229
- describe('Hooks', () => {
230
- test('triggers HOOK_ADD when properties are added', () => {
231
- let addedKeys: readonly string[] | undefined
232
- const user = createStore<{ name: string; email?: string }>({
233
- name: 'John',
234
- })
235
- user.on('add', add => {
236
- addedKeys = add
237
- })
238
- user.add('email', 'john@example.com')
239
- expect(addedKeys).toContain('email')
240
- })
241
-
242
- test('triggers HOOK_CHANGE when properties are modified', () => {
243
- const user = createStore({ name: 'John' })
244
- let changedKeys: readonly string[] | undefined
245
- user.on('change', change => {
246
- changedKeys = change
247
- })
248
- user.name.set('Jane')
249
- expect(changedKeys).toContain('name')
250
- })
251
-
252
- test('triggers HOOK_CHANGE for nested property changes', () => {
253
- const user = createStore({
254
- preferences: {
255
- theme: 'light',
256
- },
257
- })
258
- let changedKeys: readonly string[] | undefined
259
- user.on('change', change => {
260
- changedKeys = change
261
- })
262
- user.preferences.theme.set('dark')
263
- expect(changedKeys).toContain('preferences')
264
- })
265
-
266
- test('triggers HOOK_REMOVE when properties are removed', () => {
267
- const user = createStore({
268
- name: 'John',
269
- email: 'john@example.com',
270
- })
271
- let removedKeys: readonly string[] | undefined
272
- user.on('remove', remove => {
273
- removedKeys = remove
274
- })
275
- user.remove('email')
276
- expect(removedKeys).toContain('email')
277
- })
278
-
279
- test('set() correctly handles mixed changes, additions, and removals', () => {
280
- const user = createStore<{
281
- name: string
282
- email?: string
283
- preferences: { theme?: string }
284
- age?: number
285
- }>({
286
- name: 'John',
287
- email: 'john@example.com',
288
- preferences: {
289
- theme: 'light',
290
- },
291
- })
292
-
293
- let changedKeys: readonly string[] | undefined
294
- let addedKeys: readonly string[] | undefined
295
- let removedKeys: readonly string[] | undefined
296
-
297
- user.on('change', change => {
298
- changedKeys = change
299
- })
300
- user.on('add', add => {
301
- addedKeys = add
302
- })
303
- user.on('remove', remove => {
304
- removedKeys = remove
305
- })
306
-
307
- user.set({
308
- name: 'Jane',
309
- preferences: {
310
- theme: 'dark',
311
- },
312
- age: 30,
313
- })
314
-
315
- expect(changedKeys).toContain('name')
316
- expect(changedKeys).toContain('preferences')
317
- expect(addedKeys).toContain('age')
318
- expect(removedKeys).toContain('email')
319
- })
320
-
321
- test('hooks can be removed', () => {
322
- const user = createStore({ name: 'John' })
323
- let notificationCount = 0
324
- const listener = () => {
325
- notificationCount++
326
- }
327
- const off = user.on('change', listener)
328
- user.name.set('Jane')
329
- expect(notificationCount).toBe(1)
330
-
331
- off()
332
- user.name.set('Bob')
333
- expect(notificationCount).toBe(1)
334
- })
335
- })
336
-
337
- describe('reactivity', () => {
338
- test('store-level get() is reactive', () => {
344
+ describe('Reactivity', () => {
345
+ test('should react to property changes via get()', () => {
339
346
  const user = createStore({
340
347
  name: 'John',
341
348
  email: 'john@example.com',
@@ -344,765 +351,206 @@ describe('store', () => {
344
351
  createEffect(() => {
345
352
  lastValue = user.get()
346
353
  })
347
-
348
354
  expect(lastValue).toEqual({
349
355
  name: 'John',
350
356
  email: 'john@example.com',
351
357
  })
352
358
 
353
359
  user.name.set('Jane')
354
- user.email.set('jane@example.com')
355
-
356
360
  expect(lastValue).toEqual({
357
361
  name: 'Jane',
358
- email: 'jane@example.com',
362
+ email: 'john@example.com',
359
363
  })
360
364
  })
361
365
 
362
- test('individual signal reactivity works', () => {
366
+ test('should support granular property-level subscriptions', () => {
363
367
  const user = createStore({
364
368
  name: 'John',
365
369
  email: 'john@example.com',
366
370
  })
367
- let lastName = ''
368
- let nameEffectRuns = 0
371
+ let nameRuns = 0
369
372
  createEffect(() => {
370
- lastName = user.name.get()
371
- nameEffectRuns++
373
+ user.name.get()
374
+ nameRuns++
372
375
  })
376
+ expect(nameRuns).toBe(1)
373
377
 
374
- expect(lastName).toBe('John')
375
- expect(nameEffectRuns).toBe(1)
378
+ user.email.set('new@example.com')
379
+ expect(nameRuns).toBe(1) // name effect not triggered
376
380
 
377
381
  user.name.set('Jane')
378
- expect(lastName).toBe('Jane')
379
- expect(nameEffectRuns).toBe(2)
382
+ expect(nameRuns).toBe(2)
380
383
  })
381
384
 
382
- test('nested store changes propagate to parent', () => {
385
+ test('should propagate nested store changes to parent', () => {
383
386
  const user = createStore({
384
- preferences: {
385
- theme: 'light',
386
- },
387
+ preferences: { theme: 'light' },
387
388
  })
388
- let effectRuns = 0
389
+ let runs = 0
389
390
  createEffect(() => {
390
391
  user.get()
391
- effectRuns++
392
+ runs++
392
393
  })
394
+ expect(runs).toBe(1)
393
395
 
394
- expect(effectRuns).toBe(1)
395
396
  user.preferences.theme.set('dark')
396
- expect(effectRuns).toBe(2)
397
+ expect(runs).toBe(2)
397
398
  })
398
399
 
399
- test('updates are reactive', () => {
400
- const user = createStore({
401
- name: 'John',
402
- })
403
- let lastValue: {
404
- name: string
405
- email?: string
406
- } = { name: '' }
407
- let effectRuns = 0
400
+ test('should react to update()', () => {
401
+ const user = createStore({ name: 'John' })
402
+ let lastValue: { name: string; email?: string } = { name: '' }
403
+ let runs = 0
408
404
  createEffect(() => {
409
405
  lastValue = user.get()
410
- effectRuns++
406
+ runs++
411
407
  })
412
-
413
- expect(lastValue).toEqual({ name: 'John' })
414
- expect(effectRuns).toBe(1)
408
+ expect(runs).toBe(1)
415
409
 
416
410
  user.update(u => ({ ...u, email: 'john@example.com' }))
417
411
  expect(lastValue).toEqual({
418
412
  name: 'John',
419
413
  email: 'john@example.com',
420
414
  })
421
- expect(effectRuns).toBe(2)
422
- })
423
-
424
- test('remove method is reactive', () => {
425
- const user = createStore({
426
- name: 'John',
427
- email: 'john@example.com',
428
- age: 30,
429
- })
430
- let lastValue: {
431
- name: string
432
- email?: string
433
- age?: number
434
- } = { name: '', email: '', age: 0 }
435
- let effectRuns = 0
436
- createEffect(() => {
437
- lastValue = user.get()
438
- effectRuns++
439
- })
440
-
441
- expect(lastValue).toEqual({
442
- name: 'John',
443
- email: 'john@example.com',
444
- age: 30,
445
- })
446
- expect(effectRuns).toBe(1)
447
-
448
- user.remove('email')
449
- expect(lastValue).toEqual({ name: 'John', age: 30 })
450
- expect(effectRuns).toBe(2)
415
+ expect(runs).toBe(2)
451
416
  })
452
- })
453
417
 
454
- describe('computed integration', () => {
455
- test('works with computed signals', () => {
456
- const user = createStore({
457
- firstName: 'John',
458
- lastName: 'Doe',
459
- })
460
- const fullName = new Memo(
418
+ test('should work with createMemo', () => {
419
+ const user = createStore({ firstName: 'John', lastName: 'Doe' })
420
+ const fullName = createMemo(
461
421
  () => `${user.firstName.get()} ${user.lastName.get()}`,
462
422
  )
463
-
464
423
  expect(fullName.get()).toBe('John Doe')
424
+
465
425
  user.firstName.set('Jane')
466
426
  expect(fullName.get()).toBe('Jane Doe')
467
427
  })
468
428
 
469
- test('computed reacts to nested store changes', () => {
470
- const config = createStore({
471
- ui: {
472
- theme: 'light',
473
- },
474
- })
475
- const themeDisplay = new Memo(
476
- () => `Theme: ${config.ui.theme.get()}`,
477
- )
429
+ test('should work with createMemo on nested stores', () => {
430
+ const config = createStore({ ui: { theme: 'light' } })
431
+ const display = createMemo(() => `Theme: ${config.ui.theme.get()}`)
432
+ expect(display.get()).toBe('Theme: light')
478
433
 
479
- expect(themeDisplay.get()).toBe('Theme: light')
480
434
  config.ui.theme.set('dark')
481
- expect(themeDisplay.get()).toBe('Theme: dark')
435
+ expect(display.get()).toBe('Theme: dark')
482
436
  })
483
437
  })
484
438
 
485
- describe('proxy behavior and enumeration', () => {
486
- test('Object.keys returns property keys', () => {
487
- const user = createStore({
488
- name: 'Alice',
489
- email: 'alice@example.com',
490
- })
491
- const userKeys = Object.keys(user)
492
- expect(userKeys.sort()).toEqual(['email', 'name'])
493
- })
494
-
495
- test('property enumeration works', () => {
496
- const user = createStore({
497
- name: 'Alice',
498
- email: 'alice@example.com',
499
- })
500
- const userKeys: string[] = []
501
- for (const key in user) {
502
- userKeys.push(key)
503
- }
504
- expect(userKeys.sort()).toEqual(['email', 'name'])
505
- })
506
-
507
- test('in operator works', () => {
508
- const user = createStore({ name: 'Alice' })
509
- expect('name' in user).toBe(true)
510
- expect('email' in user).toBe(false)
511
- })
512
-
513
- test('Object.getOwnPropertyDescriptor works', () => {
514
- const user = createStore({ name: 'Alice' })
515
- const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
516
- expect(nameDescriptor).toEqual({
517
- enumerable: true,
518
- configurable: true,
519
- writable: true,
520
- value: user.name,
521
- })
522
- })
523
- })
524
-
525
- describe('byKey() method', () => {
526
- test('works with property keys', () => {
527
- const user = createStore({
528
- name: 'Alice',
529
- email: 'alice@example.com',
530
- age: 30,
531
- })
532
-
533
- const nameSignal = user.byKey('name')
534
- const emailSignal = user.byKey('email')
535
- const ageSignal = user.byKey('age')
536
- // @ts-expect-error deliberate access for nonexistent key
537
- const nonexistentSignal = user.byKey('nonexistent')
538
-
539
- expect(nameSignal?.get()).toBe('Alice')
540
- expect(emailSignal?.get()).toBe('alice@example.com')
541
- expect(ageSignal?.get()).toBe(30)
542
- expect(nonexistentSignal).toBeUndefined()
543
-
544
- // Verify these are the same signals as property access
545
- expect(nameSignal).toBe(user.name)
546
- expect(emailSignal).toBe(user.email)
547
- expect(ageSignal).toBe(user.age)
548
- })
549
-
550
- test('works with nested stores', () => {
551
- const app = createStore({
552
- config: {
553
- version: '1.0.0',
554
- },
555
- })
556
-
557
- const configStore = app.byKey('config')
558
- expect(configStore?.get()).toEqual({ version: '1.0.0' })
559
- expect(configStore).toBe(app.config)
560
- })
561
-
562
- test('is reactive and works with computed signals', () => {
563
- const user = createStore<{
564
- name: string
565
- age: number
566
- }>({
567
- name: 'Alice',
568
- age: 30,
569
- })
570
-
571
- const nameSignal = user.byKey('name')
572
- const displayName = new Memo(() =>
573
- nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
574
- )
575
-
576
- expect(displayName.get()).toBe('Hello, Alice!')
577
- nameSignal?.set('Bob')
578
- expect(displayName.get()).toBe('Hello, Bob!')
579
- })
580
- })
581
-
582
- describe('UNSET and edge cases', () => {
583
- test('handles UNSET values', () => {
584
- const store = createStore({ value: UNSET })
585
- expect(store.get()).toEqual({ value: UNSET })
586
- })
587
-
588
- test('handles primitive values', () => {
589
- const store = createStore({
590
- str: 'hello',
591
- num: 42,
592
- bool: true,
593
- })
594
- expect(store.str.get()).toBe('hello')
595
- expect(store.num.get()).toBe(42)
596
- expect(store.bool.get()).toBe(true)
597
- })
598
-
599
- test('handles empty stores correctly', () => {
600
- const empty = createStore({})
601
- expect(empty.get()).toEqual({})
602
- })
603
- })
604
-
605
- describe('JSON integration and serialization', () => {
606
- test('seamless JSON integration', () => {
607
- const jsonData = {
439
+ describe('Serialization', () => {
440
+ test('should round-trip through JSON', () => {
441
+ const data = {
608
442
  user: { name: 'Alice', preferences: { theme: 'dark' } },
609
443
  settings: { timeout: 5000 },
610
444
  }
611
- const store = createStore(jsonData)
612
-
613
- expect(store.user.name.get()).toBe('Alice')
614
- expect(store.user.preferences.theme.get()).toBe('dark')
615
- expect(store.settings.timeout.get()).toBe(5000)
616
-
617
- const serialized = JSON.stringify(store.get())
618
- const parsed = JSON.parse(serialized)
619
- expect(parsed).toEqual(jsonData)
620
- })
621
-
622
- test('handles complex nested structures from JSON', () => {
623
- type Dashboard = {
624
- dashboard: {
625
- widgets: Array<{
626
- id: string
627
- type: string
628
- config: { color?: string; rows?: number }
629
- }>
630
- }
631
- }
632
-
633
- const complexData: Dashboard = {
634
- dashboard: {
635
- widgets: [
636
- { id: '1', type: 'chart', config: { color: 'blue' } },
637
- { id: '2', type: 'table', config: { rows: 10 } },
638
- ],
639
- },
640
- }
641
-
642
- const store = createStore(complexData)
643
- expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
644
- 'blue',
645
- )
646
- expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
445
+ const store = createStore(data)
446
+ const parsed = JSON.parse(JSON.stringify(store.get()))
447
+ expect(parsed).toEqual(data)
647
448
  })
648
449
  })
649
450
 
650
- describe('type conversion and nested stores', () => {
651
- test('nested objects become nested stores', () => {
652
- const config = createStore({
653
- database: {
654
- host: 'localhost',
655
- port: 5432,
656
- },
657
- })
658
-
659
- expect(isStore(config.database)).toBe(true)
660
- expect(config.database.host.get()).toBe('localhost')
661
- expect(config.database.port.get()).toBe(5432)
662
- })
663
- })
664
-
665
- describe('HOOK_WATCH - Store Hierarchy Resource Management', () => {
666
- test('Store HOOK_WATCH triggers for all nested stores when accessing parent', async () => {
667
- const store = createStore({
668
- app: {
669
- database: {
670
- host: 'localhost',
671
- port: 5432,
672
- },
673
- cache: {
674
- ttl: 3600,
675
- },
676
- },
677
- })
678
-
679
- let appCounter = 0
680
- let databaseCounter = 0
681
- let cacheCounter = 0
682
-
683
- const appCleanup = store.app.on(HOOK_WATCH, () => {
684
- appCounter++
685
- return () => {
686
- appCounter--
687
- }
688
- })
689
-
690
- const databaseCleanup = store.app.database.on(HOOK_WATCH, () => {
691
- databaseCounter++
692
- return () => {
693
- databaseCounter--
694
- }
695
- })
696
-
697
- const cacheCleanup = store.app.cache.on(HOOK_WATCH, () => {
698
- cacheCounter++
699
- return () => {
700
- cacheCounter--
701
- }
702
- })
703
-
704
- // Initially no watchers
705
- expect(appCounter).toBe(0)
706
- expect(databaseCounter).toBe(0)
707
- expect(cacheCounter).toBe(0)
708
-
709
- // Access app store - should trigger ALL nested HOOK_WATCH callbacks
710
- const appEffect = createEffect(() => {
711
- store.app.get()
712
- })
713
-
714
- expect(appCounter).toBe(1)
715
- expect(databaseCounter).toBe(1)
716
- expect(cacheCounter).toBe(1)
717
-
718
- // Cleanup should reset all counters
719
- appEffect()
720
- expect(appCounter).toBe(0)
721
- expect(databaseCounter).toBe(0)
722
- expect(cacheCounter).toBe(0)
723
-
724
- appCleanup()
725
- databaseCleanup()
726
- cacheCleanup()
727
- })
728
-
729
- test('Nested store cleanup only happens when all levels are unwatched', async () => {
730
- const store = createStore({
731
- user: {
732
- profile: {
733
- settings: {
734
- theme: 'dark',
735
- },
736
- },
737
- },
738
- })
739
-
740
- let counter = 0
741
- let intervalId: Timer | undefined
742
-
743
- // Add HOOK_WATCH to deepest nested store
744
- const settingsCleanup = store.user.profile.settings.on(
745
- HOOK_WATCH,
746
- () => {
747
- intervalId = setInterval(() => {
748
- counter++
749
- }, 10)
750
-
751
- return () => {
752
- if (intervalId) {
753
- clearInterval(intervalId)
754
- intervalId = undefined
451
+ describe('options.watched', () => {
452
+ test('should activate on first effect and clean up on last', () => {
453
+ let watchCount = 0
454
+ const store = createStore(
455
+ { users: {} as Record<string, { name: string }> },
456
+ {
457
+ watched: () => {
458
+ watchCount++
459
+ return () => {
460
+ watchCount--
755
461
  }
756
- }
462
+ },
757
463
  },
758
464
  )
465
+ expect(watchCount).toBe(0)
759
466
 
760
- expect(counter).toBe(0)
761
-
762
- // Access parent store - should trigger settings HOOK_WATCH
763
- const parentEffect = createEffect(() => {
764
- store.user.get()
467
+ const cleanup1 = createEffect(() => {
468
+ store.get()
765
469
  })
470
+ expect(watchCount).toBe(1)
766
471
 
767
- await new Promise(resolve => setTimeout(resolve, 50))
768
- expect(counter).toBeGreaterThan(0)
769
- expect(intervalId).toBeDefined()
770
-
771
- // Access intermediate store - settings should still be active
772
- const profileEffect = createEffect(() => {
773
- store.user.profile.get()
472
+ const cleanup2 = createEffect(() => {
473
+ store.get()
774
474
  })
475
+ expect(watchCount).toBe(1) // still 1
775
476
 
776
- const counterAfterProfile = counter
777
- await new Promise(resolve => setTimeout(resolve, 50))
778
- expect(counter).toBeGreaterThan(counterAfterProfile)
779
- expect(intervalId).toBeDefined()
780
-
781
- // Remove parent watcher, but profile watcher still active
782
- parentEffect()
783
-
784
- const counterAfterParentRemoval = counter
785
- await new Promise(resolve => setTimeout(resolve, 50))
786
- expect(counter).toBeGreaterThan(counterAfterParentRemoval)
787
- expect(intervalId).toBeDefined() // Still running
788
-
789
- // Remove profile watcher - now should cleanup
790
- profileEffect()
791
-
792
- const counterAfterAllRemoval = counter
793
- await new Promise(resolve => setTimeout(resolve, 50))
794
- expect(counter).toBe(counterAfterAllRemoval) // Stopped
795
- expect(intervalId).toBeUndefined()
796
-
797
- settingsCleanup()
798
- })
477
+ cleanup2()
478
+ expect(watchCount).toBe(1) // still active
799
479
 
800
- test('Root store HOOK_WATCH triggered only by direct store access', async () => {
801
- const store = createStore({
802
- user: {
803
- name: 'John',
804
- profile: {
805
- email: 'john@example.com',
480
+ cleanup1()
481
+ expect(watchCount).toBe(0) // cleaned up
482
+ })
483
+
484
+ test('should not activate for nested property access only', async () => {
485
+ let activated = false
486
+ const store = createStore(
487
+ { user: { name: 'John' } },
488
+ {
489
+ watched: () => {
490
+ activated = true
491
+ return () => {
492
+ activated = false
493
+ }
806
494
  },
807
495
  },
808
- })
809
-
810
- let rootStoreCounter = 0
811
- let intervalId: Timer | undefined
812
-
813
- // Add HOOK_WATCH callback to root store
814
- const cleanupHookCallback = store.on(HOOK_WATCH, () => {
815
- intervalId = setInterval(() => {
816
- rootStoreCounter++
817
- }, 10)
818
-
819
- return () => {
820
- if (intervalId) {
821
- clearInterval(intervalId)
822
- intervalId = undefined
823
- }
824
- }
825
- })
826
-
827
- expect(rootStoreCounter).toBe(0)
828
- await new Promise(resolve => setTimeout(resolve, 50))
829
- expect(rootStoreCounter).toBe(0)
496
+ )
830
497
 
831
- // Access nested property directly - should NOT trigger root HOOK_WATCH
832
- const nestedEffectCleanup = createEffect(() => {
498
+ const cleanup = createEffect(() => {
833
499
  store.user.name.get()
834
500
  })
501
+ await new Promise(resolve => setTimeout(resolve, 10))
502
+ expect(activated).toBe(false)
835
503
 
836
- await new Promise(resolve => setTimeout(resolve, 50))
837
- expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
838
- expect(intervalId).toBeUndefined()
839
-
840
- // Access root store directly - should trigger HOOK_WATCH
841
- const rootEffectCleanup = createEffect(() => {
842
- store.get()
843
- })
844
-
845
- await new Promise(resolve => setTimeout(resolve, 50))
846
- expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
847
- expect(intervalId).toBeDefined()
848
-
849
- // Cleanup
850
- rootEffectCleanup()
851
- nestedEffectCleanup()
852
- await new Promise(resolve => setTimeout(resolve, 50))
853
- expect(intervalId).toBeUndefined()
854
-
855
- cleanupHookCallback()
504
+ cleanup()
856
505
  })
857
506
 
858
- test('Each store level manages its own HOOK_WATCH independently', async () => {
859
- const store = createStore({
860
- config: {
861
- database: {
862
- host: 'localhost',
863
- port: 5432,
507
+ test('should activate when keys() is called in an effect', () => {
508
+ let watchCount = 0
509
+ const store = createStore<{ a: number; b?: number }>(
510
+ { a: 1 },
511
+ {
512
+ watched: () => {
513
+ watchCount++
514
+ return () => {
515
+ watchCount--
516
+ }
864
517
  },
865
518
  },
866
- })
867
-
868
- let rootCounter = 0
869
- let configCounter = 0
870
- let databaseCounter = 0
871
-
872
- // Add HOOK_WATCH to each level
873
- const rootCleanup = store.on(HOOK_WATCH, () => {
874
- rootCounter++
875
- return () => {
876
- rootCounter--
877
- }
878
- })
879
-
880
- const configCleanup = store.config.on(HOOK_WATCH, () => {
881
- configCounter++
882
- return () => {
883
- configCounter--
884
- }
885
- })
886
-
887
- const databaseCleanup = store.config.database.on(HOOK_WATCH, () => {
888
- databaseCounter++
889
- return () => {
890
- databaseCounter--
891
- }
892
- })
893
-
894
- // All should start at 0
895
- expect(rootCounter).toBe(0)
896
- expect(configCounter).toBe(0)
897
- expect(databaseCounter).toBe(0)
898
-
899
- // Access deepest level - should NOT trigger any store HOOK_WATCH
900
- // because we're only accessing the State signal, not calling .get() on stores
901
- const deepEffectCleanup = createEffect(() => {
902
- store.config.database.host.get()
903
- })
904
-
905
- expect(rootCounter).toBe(0)
906
- expect(configCounter).toBe(0)
907
- expect(databaseCounter).toBe(0)
908
-
909
- // Access config level - should trigger config AND database HOOK_WATCH
910
- const configEffectCleanup = createEffect(() => {
911
- store.config.get()
912
- })
913
-
914
- expect(rootCounter).toBe(0)
915
- expect(configCounter).toBe(1)
916
- expect(databaseCounter).toBe(1) // Triggered by parent access
519
+ )
520
+ expect(watchCount).toBe(0)
917
521
 
918
- // Access root level - should trigger root HOOK_WATCH (config/database already active)
919
- const rootEffectCleanup = createEffect(() => {
920
- store.get()
522
+ const cleanup = createEffect(() => {
523
+ Array.from(store.keys()).forEach(() => {})
921
524
  })
525
+ expect(watchCount).toBe(1)
922
526
 
923
- expect(rootCounter).toBe(1)
924
- expect(configCounter).toBe(1)
925
- expect(databaseCounter).toBe(1)
926
-
927
- // Cleanup in reverse order - database should stay active until config is cleaned up
928
- rootEffectCleanup()
929
- expect(rootCounter).toBe(0)
930
- expect(configCounter).toBe(1)
931
- expect(databaseCounter).toBe(1) // Still active due to config watcher
932
-
933
- configEffectCleanup()
934
- expect(rootCounter).toBe(0)
935
- expect(configCounter).toBe(0)
936
- expect(databaseCounter).toBe(0) // Now cleaned up
937
-
938
- deepEffectCleanup()
939
- expect(rootCounter).toBe(0)
940
- expect(configCounter).toBe(0)
941
- expect(databaseCounter).toBe(0)
942
-
943
- // Cleanup hooks
944
- rootCleanup()
945
- configCleanup()
946
- databaseCleanup()
527
+ cleanup()
528
+ expect(watchCount).toBe(0)
947
529
  })
530
+ })
948
531
 
949
- test('Store HOOK_WATCH with multiple watchers at same level', async () => {
950
- const store = createStore({
951
- data: {
952
- items: [] as string[],
953
- count: 0,
954
- },
955
- })
956
-
957
- let dataStoreCounter = 0
958
-
959
- const dataCleanup = store.data.on(HOOK_WATCH, () => {
960
- dataStoreCounter++
961
- return () => {
962
- dataStoreCounter--
963
- }
964
- })
965
-
966
- expect(dataStoreCounter).toBe(0)
967
-
968
- // Create multiple effects watching the data store
969
- const effect1 = createEffect(() => {
970
- store.data.get()
971
- })
972
- const effect2 = createEffect(() => {
973
- store.data.get()
974
- })
975
-
976
- // Should only trigger once (shared resources)
977
- expect(dataStoreCounter).toBe(1)
978
-
979
- // Stop one effect
980
- effect1()
981
- expect(dataStoreCounter).toBe(1) // Still active
982
-
983
- // Stop second effect
984
- effect2()
985
- expect(dataStoreCounter).toBe(0) // Now cleaned up
986
-
987
- dataCleanup()
532
+ describe('Input Validation', () => {
533
+ test('should throw for null initial value', () => {
534
+ // @ts-expect-error testing null
535
+ expect(() => createStore(null)).toThrow()
988
536
  })
989
537
 
990
- test('Store property addition/removal affects individual store HOOK_WATCH', async () => {
991
- const store = createStore({
992
- users: {} as Record<string, { name: string }>,
993
- })
994
-
995
- let usersStoreCounter = 0
996
-
997
- const usersCleanup = store.users.on(HOOK_WATCH, () => {
998
- usersStoreCounter++
999
- return () => {
1000
- usersStoreCounter--
1001
- }
1002
- })
1003
-
1004
- expect(usersStoreCounter).toBe(0)
1005
-
1006
- // Watch the users store
1007
- const usersEffect = createEffect(() => {
1008
- store.users.get()
1009
- })
1010
- expect(usersStoreCounter).toBe(1)
1011
-
1012
- // Add a user - this modifies the users store content but doesn't affect HOOK_WATCH
1013
- store.users.add('user1', { name: 'Alice' })
1014
- expect(usersStoreCounter).toBe(1) // Still 1
1015
-
1016
- // Watch a specific user property - this doesn't trigger users store HOOK_WATCH
1017
- const userEffect = createEffect(() => {
1018
- store.users.user1?.name.get()
1019
- })
1020
- expect(usersStoreCounter).toBe(1) // Still 1
1021
-
1022
- // Cleanup user effect
1023
- userEffect()
1024
- expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
1025
-
1026
- // Cleanup users effect
1027
- usersEffect()
1028
- expect(usersStoreCounter).toBe(0) // Now cleaned up
1029
-
1030
- usersCleanup()
538
+ test('should throw for undefined initial value', () => {
539
+ // @ts-expect-error testing undefined
540
+ expect(() => createStore(undefined)).toThrow()
1031
541
  })
1032
542
 
1033
- test('Exception handling in store HOOK_WATCH callbacks', async () => {
1034
- const store = createStore({
1035
- config: { theme: 'dark' },
1036
- })
1037
-
1038
- let successfulCallbackCalled = false
1039
- let throwingCallbackCalled = false
1040
-
1041
- // Add throwing callback
1042
- const cleanup1 = store.on(HOOK_WATCH, () => {
1043
- throwingCallbackCalled = true
1044
- throw new Error('Test error in store HOOK_WATCH')
1045
- })
1046
-
1047
- // Add successful callback
1048
- const cleanup2 = store.on(HOOK_WATCH, () => {
1049
- successfulCallbackCalled = true
1050
- return () => {
1051
- // cleanup
1052
- }
1053
- })
1054
-
1055
- // Trigger callbacks through direct store access - should throw
1056
- expect(() => store.get()).toThrow('Test error in store HOOK_WATCH')
1057
-
1058
- // Both callbacks should have been called
1059
- expect(throwingCallbackCalled).toBe(true)
1060
- expect(successfulCallbackCalled).toBe(true)
1061
-
1062
- cleanup1()
1063
- cleanup2()
543
+ test('should throw for non-object initial value', () => {
544
+ // @ts-expect-error testing primitive
545
+ expect(() => createStore('hello')).toThrow()
1064
546
  })
1065
547
 
1066
- test('Nested store HOOK_WATCH with computed signals', async () => {
1067
- const store = createStore({
1068
- user: {
1069
- firstName: 'John',
1070
- lastName: 'Doe',
1071
- },
1072
- })
1073
-
1074
- let userStoreCounter = 0
1075
-
1076
- const userCleanup = store.user.on(HOOK_WATCH, () => {
1077
- userStoreCounter++
1078
- return () => {
1079
- userStoreCounter--
1080
- }
1081
- })
1082
-
1083
- expect(userStoreCounter).toBe(0)
1084
-
1085
- // Access user store directly - should trigger user store HOOK_WATCH
1086
- const userEffect = createEffect(() => {
1087
- store.user.get()
1088
- })
1089
- expect(userStoreCounter).toBe(1)
1090
-
1091
- // Access individual properties - should NOT trigger user store HOOK_WATCH again
1092
- const nameEffect = createEffect(() => {
1093
- store.user.firstName.get()
548
+ test('should throw for null value in add()', () => {
549
+ const store = createStore<{ name: string; email?: string }>({
550
+ name: 'John',
1094
551
  })
1095
- expect(userStoreCounter).toBe(1) // Still 1
1096
-
1097
- // Cleanup individual property effect first
1098
- nameEffect()
1099
- expect(userStoreCounter).toBe(1) // Still active due to user store effect
1100
-
1101
- // Cleanup user store effect - now should be cleaned up
1102
- userEffect()
1103
- expect(userStoreCounter).toBe(0) // Now cleaned up
1104
-
1105
- userCleanup()
552
+ // @ts-expect-error testing null
553
+ expect(() => store.add('email', null)).toThrow()
1106
554
  })
1107
555
  })
1108
556
  })