@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
package/test/list.test.ts CHANGED
@@ -1,982 +1,457 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  createEffect,
4
- List,
5
- createStore,
6
- isCollection,
4
+ createList,
5
+ createMemo,
7
6
  isList,
8
- isStore,
9
- Memo,
10
- State,
11
- UNSET,
7
+ isMemo,
12
8
  } from '../index.ts'
13
9
 
14
- describe('list', () => {
15
- describe('creation and basic operations', () => {
16
- test('creates lists with initial values', () => {
17
- const numbers = new List([1, 2, 3])
18
- expect(numbers.at(0)?.get()).toBe(1)
19
- expect(numbers.at(1)?.get()).toBe(2)
20
- expect(numbers.at(2)?.get()).toBe(3)
10
+ describe('List', () => {
11
+ describe('createList', () => {
12
+ test('should return initial values from get()', () => {
13
+ const list = createList([1, 2, 3])
14
+ expect(list.get()).toEqual([1, 2, 3])
21
15
  })
22
16
 
23
- test('has Symbol.toStringTag of List', () => {
24
- const list = new List([1, 2])
25
- expect(list[Symbol.toStringTag]).toBe('List')
26
- })
27
-
28
- test('isList identifies list instances correctly', () => {
29
- const store = createStore({ a: 1 })
30
- const list = new List([1])
31
- const state = new State(1)
32
- const computed = new Memo(() => 1)
33
-
34
- expect(isList(list)).toBe(true)
35
- expect(isStore(store)).toBe(true)
36
- expect(isList(state)).toBe(false)
37
- expect(isList(computed)).toBe(false)
38
- expect(isList({})).toBe(false)
39
- })
40
-
41
- test('get() returns the complete list value', () => {
42
- const numbers = new List([1, 2, 3])
43
- expect(numbers.get()).toEqual([1, 2, 3])
44
-
45
- // Nested structures
46
- const participants = new List([
17
+ test('should work with object items', () => {
18
+ const list = createList([
47
19
  { name: 'Alice', tags: ['admin'] },
48
20
  { name: 'Bob', tags: ['user'] },
49
21
  ])
50
- expect(participants.get()).toEqual([
22
+ expect(list.get()).toEqual([
51
23
  { name: 'Alice', tags: ['admin'] },
52
24
  { name: 'Bob', tags: ['user'] },
53
25
  ])
54
26
  })
55
- })
56
27
 
57
- describe('length property and sizing', () => {
58
- test('length property works for lists', () => {
59
- const numbers = new List([1, 2, 3])
60
- expect(numbers.length).toBe(3)
61
- expect(typeof numbers.length).toBe('number')
28
+ test('should handle empty initial array', () => {
29
+ const list = createList<number>([])
30
+ expect(list.get()).toEqual([])
31
+ expect(list.length).toBe(0)
62
32
  })
63
33
 
64
- test('length is reactive and updates with changes', () => {
65
- const items = new List([1, 2])
66
- expect(items.length).toBe(2)
67
- items.add(3)
68
- expect(items.length).toBe(3)
69
- items.remove(1)
70
- expect(items.length).toBe(2)
34
+ test('should have Symbol.toStringTag of "List"', () => {
35
+ const list = createList([1])
36
+ expect(list[Symbol.toStringTag]).toBe('List')
71
37
  })
72
- })
73
38
 
74
- describe('data access and modification', () => {
75
- test('items can be accessed and modified via signals', () => {
76
- const items = new List(['a', 'b'])
77
- expect(items.at(0)?.get()).toBe('a')
78
- expect(items.at(1)?.get()).toBe('b')
79
- items.at(0)?.set('alpha')
80
- items.at(1)?.set('beta')
81
- expect(items.at(0)?.get()).toBe('alpha')
82
- expect(items.at(1)?.get()).toBe('beta')
39
+ test('should have Symbol.isConcatSpreadable set to true', () => {
40
+ const list = createList([1])
41
+ expect(list[Symbol.isConcatSpreadable]).toBe(true)
83
42
  })
43
+ })
84
44
 
85
- test('returns undefined for non-existent properties', () => {
86
- const items = new List(['a'])
87
- expect(items.at(5)).toBeUndefined()
45
+ describe('isList', () => {
46
+ test('should identify list signals', () => {
47
+ expect(isList(createList([1]))).toBe(true)
88
48
  })
89
- })
90
49
 
91
- describe('add() and remove() methods', () => {
92
- test('add() method appends to end', () => {
93
- const fruits = new List(['apple', 'banana'])
94
- fruits.add('cherry')
95
- expect(fruits.at(2)?.get()).toBe('cherry')
50
+ test('should return false for non-list values', () => {
51
+ expect(isList(42)).toBe(false)
52
+ expect(isList(null)).toBe(false)
53
+ expect(isList({})).toBe(false)
54
+ expect(isMemo(createList([1]))).toBe(false)
96
55
  })
56
+ })
97
57
 
98
- test('remove() method removes by index', () => {
99
- const items = new List(['a', 'b', 'c'])
100
- items.remove(1) // Remove 'b'
101
- expect(items.get()).toEqual(['a', 'c'])
102
- expect(items.length).toBe(2)
58
+ describe('at', () => {
59
+ test('should return State signal at index', () => {
60
+ const list = createList(['a', 'b', 'c'])
61
+ expect(list.at(0)?.get()).toBe('a')
62
+ expect(list.at(1)?.get()).toBe('b')
63
+ expect(list.at(2)?.get()).toBe('c')
103
64
  })
104
65
 
105
- test('add method prevents null values', () => {
106
- const items = new List([1])
107
- // @ts-expect-error testing null values
108
- expect(() => items.add(null)).toThrow()
66
+ test('should return undefined for out-of-bounds index', () => {
67
+ const list = createList(['a'])
68
+ expect(list.at(5)).toBeUndefined()
109
69
  })
110
70
 
111
- test('remove method handles non-existent indices gracefully', () => {
112
- const items = new List(['a'])
113
- expect(() => items.remove(5)).not.toThrow()
114
- expect(items.get()).toEqual(['a'])
71
+ test('should allow mutation via returned State signal', () => {
72
+ const list = createList(['a', 'b'])
73
+ list.at(0)?.set('alpha')
74
+ expect(list.at(0)?.get()).toBe('alpha')
115
75
  })
116
76
  })
117
77
 
118
- describe('sort() method', () => {
119
- test('sorts lists with different compare functions', () => {
120
- const numbers = new List([3, 1, 2])
78
+ describe('set', () => {
79
+ test('should replace entire array', () => {
80
+ const list = createList([1, 2, 3])
81
+ list.set([4, 5])
82
+ expect(list.get()).toEqual([4, 5])
83
+ expect(list.length).toBe(2)
84
+ })
85
+
86
+ test('should diff and update changed items', () => {
87
+ const list = createList([1, 2, 3])
88
+ const signal0 = list.at(0)
89
+ list.set([10, 2, 3])
90
+ // Same signal reference, updated value
91
+ expect(signal0?.get()).toBe(10)
92
+ })
93
+
94
+ test('should keep stable keys when reordering with content-based keyConfig', () => {
95
+ type Item = { id: string; val: number }
96
+ const list = createList<Item>(
97
+ [
98
+ { id: 'a', val: 1 },
99
+ { id: 'b', val: 2 },
100
+ { id: 'c', val: 3 },
101
+ ],
102
+ { keyConfig: item => item.id },
103
+ )
121
104
 
122
- numbers.sort()
123
- expect(numbers.get()).toEqual([1, 2, 3])
105
+ // Grab signal references by key before reorder
106
+ const signalA = list.byKey('a')
107
+ const signalB = list.byKey('b')
108
+ const signalC = list.byKey('c')
124
109
 
125
- numbers.sort((a, b) => b - a)
126
- expect(numbers.get()).toEqual([3, 2, 1])
110
+ // Reverse order
111
+ list.set([
112
+ { id: 'c', val: 3 },
113
+ { id: 'b', val: 2 },
114
+ { id: 'a', val: 1 },
115
+ ])
127
116
 
128
- const names = new List(['Charlie', 'Alice', 'Bob'])
129
- names.sort((a, b) => a.localeCompare(b))
130
- expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
131
- })
117
+ // Keys should follow items, not positions
118
+ expect(list.byKey('a')?.get()).toEqual({ id: 'a', val: 1 })
119
+ expect(list.byKey('b')?.get()).toEqual({ id: 'b', val: 2 })
120
+ expect(list.byKey('c')?.get()).toEqual({ id: 'c', val: 3 })
132
121
 
133
- test('triggers HOOK_SORT with new order', () => {
134
- const numbers = new List([3, 1, 2])
135
- let order: readonly string[] | undefined
136
- numbers.on('sort', sort => {
137
- order = sort
138
- })
139
- numbers.sort()
140
- expect(order).toHaveLength(3)
141
- expect(order).toEqual(['1', '2', '0'])
122
+ // Signal references should be preserved (same State objects)
123
+ expect(list.byKey('a')).toBe(signalA)
124
+ expect(list.byKey('b')).toBe(signalB)
125
+ expect(list.byKey('c')).toBe(signalC)
126
+
127
+ // Key order should match new array order
128
+ expect([...list.keys()]).toEqual(['c', 'b', 'a'])
142
129
  })
143
130
 
144
- test('sort is reactive - watchers are notified', () => {
145
- const numbers = new List([3, 1, 2])
146
- let effectCount = 0
147
- let lastValue: number[] = []
148
- createEffect(() => {
149
- lastValue = numbers.get()
150
- effectCount++
131
+ test('should detect duplicates in set() with content-based keyConfig', () => {
132
+ const list = createList([{ id: 'a', val: 1 }], {
133
+ keyConfig: item => item.id,
151
134
  })
152
-
153
- expect(effectCount).toBe(1)
154
- expect(lastValue).toEqual([3, 1, 2])
155
-
156
- numbers.sort()
157
- expect(effectCount).toBe(2)
158
- expect(lastValue).toEqual([1, 2, 3])
135
+ expect(() =>
136
+ list.set([
137
+ { id: 'a', val: 1 },
138
+ { id: 'a', val: 2 },
139
+ ]),
140
+ ).toThrow('already exists')
159
141
  })
160
142
  })
161
143
 
162
- describe('splice() method', () => {
163
- test('splice() removes elements without adding new ones', () => {
164
- const numbers = new List([1, 2, 3, 4])
165
- const deleted = numbers.splice(1, 2)
166
- expect(deleted).toEqual([2, 3])
167
- expect(numbers.get()).toEqual([1, 4])
144
+ describe('update', () => {
145
+ test('should update via callback', () => {
146
+ const list = createList([1, 2])
147
+ list.update(arr => [...arr, 3])
148
+ expect(list.get()).toEqual([1, 2, 3])
168
149
  })
150
+ })
169
151
 
170
- test('splice() adds elements without removing any', () => {
171
- const numbers = new List([1, 3])
172
- const deleted = numbers.splice(1, 0, 2)
173
- expect(deleted).toEqual([])
174
- expect(numbers.get()).toEqual([1, 2, 3])
152
+ describe('add', () => {
153
+ test('should append item and return key', () => {
154
+ const list = createList(['apple', 'banana'])
155
+ const key = list.add('cherry')
156
+ expect(typeof key).toBe('string')
157
+ expect(list.at(2)?.get()).toBe('cherry')
158
+ expect(list.byKey(key)?.get()).toBe('cherry')
175
159
  })
176
160
 
177
- test('splice() replaces elements (remove and add)', () => {
178
- const numbers = new List([1, 2, 3])
179
- const deleted = numbers.splice(1, 1, 4, 5)
180
- expect(deleted).toEqual([2])
181
- expect(numbers.get()).toEqual([1, 4, 5, 3])
161
+ test('should throw for null value', () => {
162
+ const list = createList([1])
163
+ // @ts-expect-error - Testing invalid input
164
+ expect(() => list.add(null)).toThrow()
182
165
  })
183
166
 
184
- test('splice() handles negative start index', () => {
185
- const numbers = new List([1, 2, 3])
186
- const deleted = numbers.splice(-1, 1, 4)
187
- expect(deleted).toEqual([3])
188
- expect(numbers.get()).toEqual([1, 2, 4])
167
+ test('should throw DuplicateKeyError for duplicate keys', () => {
168
+ const list = createList([{ id: 'a', val: 1 }], {
169
+ keyConfig: item => item.id,
170
+ })
171
+ expect(() => list.add({ id: 'a', val: 2 })).toThrow(
172
+ 'already exists',
173
+ )
189
174
  })
190
175
  })
191
176
 
192
- describe('reactivity', () => {
193
- test('list-level get() is reactive', () => {
194
- const numbers = new List([1, 2, 3])
195
- let lastArray: number[] = []
196
- createEffect(() => {
197
- lastArray = numbers.get()
198
- })
199
-
200
- expect(lastArray).toEqual([1, 2, 3])
201
- numbers.add(4)
202
- expect(lastArray).toEqual([1, 2, 3, 4])
177
+ describe('remove', () => {
178
+ test('should remove by index', () => {
179
+ const list = createList(['a', 'b', 'c'])
180
+ list.remove(1)
181
+ expect(list.get()).toEqual(['a', 'c'])
182
+ expect(list.length).toBe(2)
183
+ })
184
+
185
+ test('should remove by key', () => {
186
+ const list = createList(
187
+ [
188
+ { id: 'x', val: 1 },
189
+ { id: 'y', val: 2 },
190
+ ],
191
+ { keyConfig: item => item.id },
192
+ )
193
+ list.remove('x')
194
+ expect(list.get()).toEqual([{ id: 'y', val: 2 }])
203
195
  })
204
196
 
205
- test('individual signal reactivity works', () => {
206
- const items = new List([{ count: 5 }])
207
- let lastItem = 0
208
- let itemEffectRuns = 0
209
- createEffect(() => {
210
- lastItem = items.at(0)?.get().count ?? 0
211
- itemEffectRuns++
212
- })
197
+ test('should handle non-existent index gracefully', () => {
198
+ const list = createList(['a'])
199
+ expect(() => list.remove(5)).not.toThrow()
200
+ expect(list.get()).toEqual(['a'])
201
+ })
202
+ })
213
203
 
214
- expect(lastItem).toBe(5)
215
- expect(itemEffectRuns).toBe(1)
204
+ describe('sort', () => {
205
+ test('should sort with default string comparison', () => {
206
+ const list = createList([3, 1, 2])
207
+ list.sort()
208
+ expect(list.get()).toEqual([1, 2, 3])
209
+ })
216
210
 
217
- items.at(0)?.set({ count: 10 })
218
- expect(lastItem).toBe(10)
219
- expect(itemEffectRuns).toBe(2)
211
+ test('should sort with custom compare function', () => {
212
+ const list = createList([3, 1, 2])
213
+ list.sort((a, b) => b - a)
214
+ expect(list.get()).toEqual([3, 2, 1])
220
215
  })
221
216
 
222
- test('updates are reactive', () => {
223
- const numbers = new List([1, 2])
224
- let lastArray: number[] = []
225
- let arrayEffectRuns = 0
217
+ test('should trigger effects on sort', () => {
218
+ const list = createList([3, 1, 2])
219
+ let effectCount = 0
220
+ let lastValue: number[] = []
226
221
  createEffect(() => {
227
- lastArray = numbers.get()
228
- arrayEffectRuns++
222
+ lastValue = list.get()
223
+ effectCount++
229
224
  })
230
225
 
231
- expect(lastArray).toEqual([1, 2])
232
- expect(arrayEffectRuns).toBe(1)
233
-
234
- numbers.update(arr => [...arr, 3])
235
- expect(lastArray).toEqual([1, 2, 3])
236
- expect(arrayEffectRuns).toBe(2)
226
+ expect(effectCount).toBe(1)
227
+ list.sort()
228
+ expect(effectCount).toBe(2)
229
+ expect(lastValue).toEqual([1, 2, 3])
237
230
  })
238
231
  })
239
232
 
240
- describe('computed integration', () => {
241
- test('works with computed signals', () => {
242
- const numbers = new List([1, 2, 3])
243
- const sum = new Memo(() =>
244
- numbers.get().reduce((acc, n) => acc + n, 0),
245
- )
246
-
247
- expect(sum.get()).toBe(6)
248
- numbers.add(4)
249
- expect(sum.get()).toBe(10)
233
+ describe('splice', () => {
234
+ test('should remove elements', () => {
235
+ const list = createList([1, 2, 3, 4])
236
+ const deleted = list.splice(1, 2)
237
+ expect(deleted).toEqual([2, 3])
238
+ expect(list.get()).toEqual([1, 4])
250
239
  })
251
240
 
252
- test('computed handles additions and removals', () => {
253
- const numbers = new List([1, 2, 3])
254
- const sum = new Memo(() => {
255
- const array = numbers.get()
256
- return array.reduce((total, n) => total + n, 0)
257
- })
258
-
259
- expect(sum.get()).toBe(6)
260
-
261
- numbers.add(4)
262
- expect(sum.get()).toBe(10)
263
-
264
- numbers.remove(0)
265
- const finalArray = numbers.get()
266
- expect(finalArray).toEqual([2, 3, 4])
267
- expect(sum.get()).toBe(9)
241
+ test('should insert elements', () => {
242
+ const list = createList([1, 3])
243
+ const deleted = list.splice(1, 0, 2)
244
+ expect(deleted).toEqual([])
245
+ expect(list.get()).toEqual([1, 2, 3])
268
246
  })
269
247
 
270
- test('computed sum using list iteration with length tracking', () => {
271
- const numbers = new List([1, 2, 3])
248
+ test('should replace elements', () => {
249
+ const list = createList([1, 2, 3])
250
+ const deleted = list.splice(1, 1, 4, 5)
251
+ expect(deleted).toEqual([2])
252
+ expect(list.get()).toEqual([1, 4, 5, 3])
253
+ })
272
254
 
273
- const sum = new Memo(() => {
274
- // Access length to make it reactive
275
- const _length = numbers.length
276
- let total = 0
277
- for (const signal of numbers) {
278
- total += signal.get()
279
- }
280
- return total
281
- })
255
+ test('should handle negative start index', () => {
256
+ const list = createList([1, 2, 3])
257
+ const deleted = list.splice(-1, 1, 4)
258
+ expect(deleted).toEqual([3])
259
+ expect(list.get()).toEqual([1, 2, 4])
260
+ })
282
261
 
283
- expect(sum.get()).toBe(6)
284
- numbers.add(4)
285
- expect(sum.get()).toBe(10)
262
+ test('should throw DuplicateKeyError for duplicate keys on insert', () => {
263
+ const list = createList(
264
+ [
265
+ { id: 'a', val: 1 },
266
+ { id: 'b', val: 2 },
267
+ ],
268
+ { keyConfig: item => item.id },
269
+ )
270
+ expect(() => list.splice(1, 0, { id: 'a', val: 3 })).toThrow(
271
+ 'already exists',
272
+ )
286
273
  })
287
274
  })
288
275
 
289
- describe('iteration and spreading', () => {
290
- test('supports for...of iteration', () => {
291
- const numbers = new List([10, 20, 30])
292
- const signals = [...numbers]
293
- expect(signals).toHaveLength(3)
294
- expect(signals[0].get()).toBe(10)
295
- expect(signals[1].get()).toBe(20)
296
- expect(signals[2].get()).toBe(30)
276
+ describe('length', () => {
277
+ test('should return item count', () => {
278
+ const list = createList([1, 2, 3])
279
+ expect(list.length).toBe(3)
297
280
  })
298
281
 
299
- test('Symbol.isConcatSpreadable is true', () => {
300
- const numbers = new List([1, 2, 3])
301
- expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
282
+ test('should update reactively with add and remove', () => {
283
+ const list = createList([1, 2])
284
+ expect(list.length).toBe(2)
285
+ list.add(3)
286
+ expect(list.length).toBe(3)
287
+ list.remove(0)
288
+ expect(list.length).toBe(2)
302
289
  })
303
290
  })
304
291
 
305
- describe('Hooks', () => {
306
- test('trigger HOOK_ADD when adding items', () => {
307
- const numbers = new List([1, 2])
308
- let addedKeys: readonly string[] | undefined
309
- let newArray: number[] = []
310
- numbers.on('add', add => {
311
- addedKeys = add
312
- newArray = numbers.get()
313
- })
314
- numbers.add(3)
315
- expect(addedKeys).toHaveLength(1)
316
- expect(newArray).toEqual([1, 2, 3])
317
- })
318
-
319
- test('triggers HOOK_CHANGE when properties are modified', () => {
320
- const items = new List([{ value: 10 }])
321
- let changedKeys: readonly string[] | undefined
322
- let newArray: { value: number }[] = []
323
- items.on('change', change => {
324
- changedKeys = change
325
- newArray = items.get()
326
- })
327
- items.at(0)?.set({ value: 20 })
328
- expect(changedKeys).toHaveLength(1)
329
- expect(newArray).toEqual([{ value: 20 }])
330
- })
331
-
332
- test('triggers HOOK_REMOVE when items are removed', () => {
333
- const items = new List([1, 2, 3])
334
- let removedKeys: readonly string[] | undefined
335
- let newArray: number[] = []
336
- items.on('remove', remove => {
337
- removedKeys = remove
338
- newArray = items.get()
339
- })
340
- items.remove(1)
341
- expect(removedKeys).toHaveLength(1)
342
- expect(newArray).toEqual([1, 3])
292
+ describe('Key-based Access', () => {
293
+ test('keyAt should return key at index', () => {
294
+ const list = createList([10, 20, 30])
295
+ const key0 = list.keyAt(0)
296
+ expect(key0).toBeDefined()
297
+ expect(typeof key0).toBe('string')
343
298
  })
344
- })
345
299
 
346
- describe('edge cases', () => {
347
- test('handles empty lists correctly', () => {
348
- const empty = new List([])
349
- expect(empty.get()).toEqual([])
350
- expect(empty.length).toBe(0)
300
+ test('indexOfKey should return index for key', () => {
301
+ const list = createList([10, 20])
302
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
303
+ const key = list.keyAt(0)!
304
+ expect(list.indexOfKey(key)).toBe(0)
351
305
  })
352
306
 
353
- test('handles UNSET values', () => {
354
- const list = new List([UNSET, 'valid'])
355
- expect(list.get()).toEqual([UNSET, 'valid'])
307
+ test('byKey should return State signal for key', () => {
308
+ const list = createList([10, 20])
309
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
310
+ const key = list.keyAt(0)!
311
+ expect(list.byKey(key)?.get()).toBe(10)
356
312
  })
357
313
 
358
- test('handles primitive values', () => {
359
- const list = new List([42, 'text', true])
360
- expect(list.at(0)?.get()).toBe(42)
361
- expect(list.at(1)?.get()).toBe('text')
362
- expect(list.at(2)?.get()).toBe(true)
314
+ test('keys should return iterator of all keys', () => {
315
+ const list = createList([10, 20, 30])
316
+ const allKeys = [...list.keys()]
317
+ expect(allKeys).toHaveLength(3)
318
+ expect(list.byKey(allKeys[0])?.get()).toBe(10)
363
319
  })
364
320
  })
365
321
 
366
- describe('deriveCollection() method', () => {
367
- describe('synchronous transformations', () => {
368
- test('transforms list values with sync callback', () => {
369
- const numbers = new List([1, 2, 3])
370
- const doubled = numbers.deriveCollection(
371
- (value: number) => value * 2,
372
- )
373
-
374
- expect(isCollection(doubled)).toBe(true)
375
- expect(doubled.length).toBe(3)
376
- expect(doubled.get()).toEqual([2, 4, 6])
377
- })
378
-
379
- test('transforms object values with sync callback', () => {
380
- const users = new List([
381
- { name: 'Alice', age: 25 },
382
- { name: 'Bob', age: 30 },
383
- ])
384
- const userInfo = users.deriveCollection(user => ({
385
- displayName: user.name.toUpperCase(),
386
- isAdult: user.age >= 18,
387
- }))
388
-
389
- expect(userInfo.length).toBe(2)
390
- expect(userInfo.get()).toEqual([
391
- { displayName: 'ALICE', isAdult: true },
392
- { displayName: 'BOB', isAdult: true },
393
- ])
394
- })
395
-
396
- test('transforms string values to different types', () => {
397
- const words = new List(['hello', 'world', 'test'])
398
- const wordLengths = words.deriveCollection((word: string) => ({
399
- word,
400
- length: word.length,
401
- }))
402
-
403
- expect(wordLengths.get()).toEqual([
404
- { word: 'hello', length: 5 },
405
- { word: 'world', length: 5 },
406
- { word: 'test', length: 4 },
407
- ])
408
- })
409
-
410
- test('collection reactivity with sync transformations', () => {
411
- const numbers = new List([1, 2])
412
- const doubled = numbers.deriveCollection(
413
- (value: number) => value * 2,
414
- )
415
-
416
- let collectionValue: number[] = []
417
- let effectRuns = 0
418
- createEffect(() => {
419
- collectionValue = doubled.get()
420
- effectRuns++
421
- })
422
-
423
- expect(collectionValue).toEqual([2, 4])
424
- expect(effectRuns).toBe(1)
425
-
426
- // Add new item
427
- numbers.add(3)
428
- expect(collectionValue).toEqual([2, 4, 6])
429
- expect(effectRuns).toBe(2)
430
-
431
- // Modify existing item
432
- numbers.at(0)?.set(5)
433
- expect(collectionValue).toEqual([10, 4, 6])
434
- expect(effectRuns).toBe(3)
435
- })
436
-
437
- test('collection responds to source removal', () => {
438
- const numbers = new List([1, 2, 3])
439
- const doubled = numbers.deriveCollection(
440
- (value: number) => value * 2,
441
- )
442
-
443
- expect(doubled.get()).toEqual([2, 4, 6])
444
-
445
- numbers.remove(1) // Remove middle item (2)
446
- expect(doubled.get()).toEqual([2, 6])
447
- expect(doubled.length).toBe(2)
448
- })
322
+ describe('options.keyConfig', () => {
323
+ test('should use function to generate keys', () => {
324
+ const list = createList(
325
+ [
326
+ { id: 'a', value: 1 },
327
+ { id: 'b', value: 2 },
328
+ ],
329
+ { keyConfig: item => item.id },
330
+ )
331
+ expect(list.byKey('a')?.get()).toEqual({ id: 'a', value: 1 })
332
+ expect(list.byKey('b')?.get()).toEqual({ id: 'b', value: 2 })
449
333
  })
450
334
 
451
- describe('asynchronous transformations', () => {
452
- test('transforms values with async callback', async () => {
453
- const numbers = new List([1, 2, 3])
454
- const asyncDoubled = numbers.deriveCollection(
455
- async (value: number, abort: AbortSignal) => {
456
- // Simulate async operation
457
- await new Promise(resolve => setTimeout(resolve, 10))
458
- if (abort.aborted) throw new Error('Aborted')
459
- return value * 2
460
- },
461
- )
462
-
463
- // Trigger initial computation by accessing the collection
464
- const initialLength = asyncDoubled.length
465
- expect(initialLength).toBe(3)
466
-
467
- // Access each computed signal to trigger computation
468
- for (let i = 0; i < asyncDoubled.length; i++) {
469
- asyncDoubled.at(i)?.get()
470
- }
471
-
472
- // Allow async operations to complete
473
- await new Promise(resolve => setTimeout(resolve, 50))
474
-
475
- expect(asyncDoubled.get()).toEqual([2, 4, 6])
476
- })
477
-
478
- test('async collection with object transformation', async () => {
479
- const users = new List([
480
- { id: 1, name: 'Alice' },
481
- { id: 2, name: 'Bob' },
482
- ])
483
-
484
- const enrichedUsers = users.deriveCollection(
485
- async (user, abort: AbortSignal) => {
486
- // Simulate API call
487
- await new Promise(resolve => setTimeout(resolve, 10))
488
- if (abort.aborted) throw new Error('Aborted')
489
-
490
- return {
491
- ...user,
492
- slug: user.name.toLowerCase(),
493
- timestamp: Date.now(),
494
- }
495
- },
496
- )
497
-
498
- // Trigger initial computation by accessing each computed signal
499
- for (let i = 0; i < enrichedUsers.length; i++) {
500
- enrichedUsers.at(i)?.get()
501
- }
502
-
503
- // Allow async operations to complete
504
- await new Promise(resolve => setTimeout(resolve, 50))
505
-
506
- const result = enrichedUsers.get()
507
- expect(result).toHaveLength(2)
508
- expect(result[0].slug).toBe('alice')
509
- expect(result[1].slug).toBe('bob')
510
- expect(typeof result[0].timestamp).toBe('number')
511
- })
512
-
513
- test('async collection reactivity', async () => {
514
- const numbers = new List([1, 2])
515
- const asyncDoubled = numbers.deriveCollection(
516
- async (value: number, abort: AbortSignal) => {
517
- await new Promise(resolve => setTimeout(resolve, 5))
518
- if (abort.aborted) throw new Error('Aborted')
519
- return value * 2
520
- },
521
- )
522
-
523
- const effectValues: number[][] = []
524
-
525
- // Set up effect to track changes reactively
526
- createEffect(() => {
527
- const currentValue = asyncDoubled.get()
528
- effectValues.push([...currentValue])
529
- })
530
-
531
- // Allow initial computation
532
- await new Promise(resolve => setTimeout(resolve, 20))
533
- expect(effectValues[effectValues.length - 1]).toEqual([2, 4])
534
-
535
- // Add new item
536
- numbers.add(3)
537
- await new Promise(resolve => setTimeout(resolve, 20))
538
- expect(effectValues[effectValues.length - 1]).toEqual([2, 4, 6])
539
-
540
- // Modify existing item
541
- numbers.at(0)?.set(5)
542
- await new Promise(resolve => setTimeout(resolve, 20))
543
- expect(effectValues[effectValues.length - 1]).toEqual([
544
- 10, 4, 6,
545
- ])
546
- })
547
-
548
- test('handles AbortSignal cancellation', async () => {
549
- const numbers = new List([1])
550
- let abortCalled = false
551
-
552
- const slowCollection = numbers.deriveCollection(
553
- async (value: number, abort: AbortSignal) => {
554
- return new Promise<number>((resolve, reject) => {
555
- const timeout = setTimeout(
556
- () => resolve(value * 2),
557
- 50,
558
- )
559
- abort.addEventListener('abort', () => {
560
- clearTimeout(timeout)
561
- abortCalled = true
562
- reject(new Error('Aborted'))
563
- })
564
- })
565
- },
566
- )
567
-
568
- // Trigger initial computation
569
- slowCollection.at(0)?.get()
570
-
571
- // Change the value to trigger cancellation of the first computation
572
- numbers.at(0)?.set(2)
573
-
574
- // Allow some time for operations
575
- await new Promise(resolve => setTimeout(resolve, 100))
576
-
577
- expect(abortCalled).toBe(true)
578
- expect(slowCollection.get()).toEqual([4]) // Last value (2 * 2)
579
- })
335
+ test('should use string prefix for auto-generated keys', () => {
336
+ const list = createList([1, 2, 3], { keyConfig: 'item-' })
337
+ expect(list.keyAt(0)).toBe('item-0')
338
+ expect(list.keyAt(1)).toBe('item-1')
339
+ expect(list.keyAt(2)).toBe('item-2')
580
340
  })
341
+ })
581
342
 
582
- describe('derived collection chaining', () => {
583
- test('chains multiple sync derivations', () => {
584
- const numbers = new List([1, 2, 3])
585
- const doubled = numbers.deriveCollection(
586
- (value: number) => value * 2,
587
- )
588
- const quadrupled = doubled.deriveCollection(
589
- (value: number) => value * 2,
590
- )
591
-
592
- expect(quadrupled.get()).toEqual([4, 8, 12])
593
-
594
- numbers.add(4)
595
- expect(quadrupled.get()).toEqual([4, 8, 12, 16])
596
- })
597
-
598
- test('chains sync and async derivations', async () => {
599
- const numbers = new List([1, 2])
600
- const doubled = numbers.deriveCollection(
601
- (value: number) => value * 2,
602
- )
603
- const asyncSquared = doubled.deriveCollection(
604
- async (value: number, abort: AbortSignal) => {
605
- await new Promise(resolve => setTimeout(resolve, 10))
606
- if (abort.aborted) throw new Error('Aborted')
607
- return value * value
608
- },
609
- )
610
-
611
- // Trigger initial computation by accessing each computed signal
612
- for (let i = 0; i < asyncSquared.length; i++) {
613
- asyncSquared.at(i)?.get()
614
- }
615
-
616
- await new Promise(resolve => setTimeout(resolve, 50))
617
- expect(asyncSquared.get()).toEqual([4, 16]) // (1*2)^2, (2*2)^2
618
- })
343
+ describe('Iteration', () => {
344
+ test('should support for...of via Symbol.iterator', () => {
345
+ const list = createList([10, 20, 30])
346
+ const signals = [...list]
347
+ expect(signals).toHaveLength(3)
348
+ expect(signals[0].get()).toBe(10)
349
+ expect(signals[1].get()).toBe(20)
350
+ expect(signals[2].get()).toBe(30)
619
351
  })
352
+ })
620
353
 
621
- describe('collection access methods', () => {
622
- test('supports index-based access to computed signals', () => {
623
- const numbers = new List([1, 2, 3])
624
- const doubled = numbers.deriveCollection(
625
- (value: number) => value * 2,
626
- )
627
-
628
- expect(doubled.at(0)?.get()).toBe(2)
629
- expect(doubled.at(1)?.get()).toBe(4)
630
- expect(doubled.at(2)?.get()).toBe(6)
631
- expect(doubled.at(3)).toBeUndefined()
632
- })
633
-
634
- test('supports key-based access', () => {
635
- const numbers = new List([10, 20])
636
- const doubled = numbers.deriveCollection(
637
- (value: number) => value * 2,
638
- )
639
-
640
- const key0 = numbers.keyAt(0)
641
- const key1 = numbers.keyAt(1)
642
-
643
- expect(key0).toBeDefined()
644
- expect(key1).toBeDefined()
645
-
646
- if (key0 && key1) {
647
- expect(doubled.byKey(key0)?.get()).toBe(20)
648
- expect(doubled.byKey(key1)?.get()).toBe(40)
649
- }
354
+ describe('Reactivity', () => {
355
+ test('get() should trigger effects on structural changes', () => {
356
+ const list = createList([1, 2, 3])
357
+ let lastArray: number[] = []
358
+ createEffect(() => {
359
+ lastArray = list.get()
650
360
  })
651
361
 
652
- test('supports iteration', () => {
653
- const numbers = new List([1, 2, 3])
654
- const doubled = numbers.deriveCollection(
655
- (value: number) => value * 2,
656
- )
657
-
658
- const signals = [...doubled]
659
- expect(signals).toHaveLength(3)
660
- expect(signals[0].get()).toBe(2)
661
- expect(signals[1].get()).toBe(4)
662
- expect(signals[2].get()).toBe(6)
663
- })
362
+ expect(lastArray).toEqual([1, 2, 3])
363
+ list.add(4)
364
+ expect(lastArray).toEqual([1, 2, 3, 4])
664
365
  })
665
366
 
666
- describe('collection event handling', () => {
667
- test('emits add events when source adds items', () => {
668
- const numbers = new List([1, 2])
669
- const doubled = numbers.deriveCollection(
670
- (value: number) => value * 2,
671
- )
672
-
673
- let addedKeys: readonly string[] | undefined
674
- doubled.on('add', keys => {
675
- addedKeys = keys
676
- })
677
-
678
- numbers.add(3)
679
- expect(addedKeys).toHaveLength(1)
680
- })
681
-
682
- test('emits remove events when source removes items', () => {
683
- const numbers = new List([1, 2, 3])
684
- const doubled = numbers.deriveCollection(
685
- (value: number) => value * 2,
686
- )
687
-
688
- let removedKeys: readonly string[] | undefined
689
- doubled.on('remove', keys => {
690
- removedKeys = keys
691
- })
692
-
693
- numbers.remove(1)
694
- expect(removedKeys).toHaveLength(1)
367
+ test('individual item signals should trigger effects', () => {
368
+ const list = createList([{ count: 5 }])
369
+ let lastCount = 0
370
+ let effectCount = 0
371
+ createEffect(() => {
372
+ lastCount = list.at(0)?.get().count ?? 0
373
+ effectCount++
695
374
  })
696
375
 
697
- test('emits sort events when source is sorted', () => {
698
- const numbers = new List([3, 1, 2])
699
- const doubled = numbers.deriveCollection(
700
- (value: number) => value * 2,
701
- )
702
-
703
- let sortedKeys: readonly string[] | undefined
704
- doubled.on('sort', keys => {
705
- sortedKeys = keys
706
- })
376
+ expect(lastCount).toBe(5)
377
+ expect(effectCount).toBe(1)
707
378
 
708
- numbers.sort()
709
- expect(sortedKeys).toHaveLength(3)
710
- expect(doubled.get()).toEqual([2, 4, 6]) // Sorted and doubled
711
- })
379
+ list.at(0)?.set({ count: 10 })
380
+ expect(lastCount).toBe(10)
381
+ expect(effectCount).toBe(2)
712
382
  })
713
383
 
714
- describe('edge cases', () => {
715
- test('handles empty list derivation', () => {
716
- const empty = new List<number>([])
717
- const doubled = empty.deriveCollection(
718
- (value: number) => value * 2,
719
- )
720
-
721
- expect(doubled.get()).toEqual([])
722
- expect(doubled.length).toBe(0)
723
- })
724
-
725
- test('handles UNSET values in transformation', () => {
726
- const list = new List([1, UNSET, 3])
727
- const processed = list.deriveCollection(value => {
728
- return value === UNSET ? 0 : value * 2
729
- })
730
-
731
- // UNSET values are filtered out before transformation
732
- expect(processed.get()).toEqual([2, 6])
733
- })
384
+ test('computed signals should react to list changes', () => {
385
+ const list = createList([1, 2, 3])
386
+ const sum = createMemo(() =>
387
+ list.get().reduce((acc, n) => acc + n, 0),
388
+ )
734
389
 
735
- test('handles complex object transformations', () => {
736
- const items = new List([
737
- { id: 1, data: { value: 10, active: true } },
738
- { id: 2, data: { value: 20, active: false } },
739
- ])
740
-
741
- const transformed = items.deriveCollection(item => ({
742
- itemId: item.id,
743
- processedValue: item.data.value * 2,
744
- status: item.data.active ? 'enabled' : 'disabled',
745
- }))
746
-
747
- expect(transformed.get()).toEqual([
748
- { itemId: 1, processedValue: 20, status: 'enabled' },
749
- { itemId: 2, processedValue: 40, status: 'disabled' },
750
- ])
751
- })
390
+ expect(sum.get()).toBe(6)
391
+ list.add(4)
392
+ expect(sum.get()).toBe(10)
393
+ list.remove(0)
394
+ expect(sum.get()).toBe(9)
752
395
  })
753
396
  })
754
397
 
755
- describe('hooks system', () => {
756
- test('List HOOK_WATCH is called when effect accesses list.get()', () => {
757
- const numbers = new List([10, 20, 30])
758
- let listHookWatchCalled = false
759
- let listUnwatchCalled = false
760
-
761
- // Set up HOOK_WATCH callback on the list itself
762
- numbers.on('watch', () => {
763
- listHookWatchCalled = true
764
- return () => {
765
- listUnwatchCalled = true
766
- }
398
+ describe('options.watched', () => {
399
+ test('should call watched on first subscriber and cleanup on last unsubscribe', () => {
400
+ let watchedCalled = false
401
+ let unwatchedCalled = false
402
+ const list = createList([10, 20], {
403
+ watched: () => {
404
+ watchedCalled = true
405
+ return () => {
406
+ unwatchedCalled = true
407
+ }
408
+ },
767
409
  })
768
410
 
769
- expect(listHookWatchCalled).toBe(false)
411
+ expect(watchedCalled).toBe(false)
770
412
 
771
- // Access list via list.get() - this should trigger list's HOOK_WATCH
772
- let effectValue: number[] = []
773
- const cleanup = createEffect(() => {
774
- effectValue = numbers.get()
413
+ const dispose = createEffect(() => {
414
+ list.get()
775
415
  })
776
416
 
777
- expect(listHookWatchCalled).toBe(true)
778
- expect(effectValue).toEqual([10, 20, 30])
779
- expect(listUnwatchCalled).toBe(false)
417
+ expect(watchedCalled).toBe(true)
418
+ expect(unwatchedCalled).toBe(false)
780
419
 
781
- // Cleanup effect - should trigger unwatch
782
- cleanup()
783
- expect(listUnwatchCalled).toBe(true)
420
+ dispose()
421
+ expect(unwatchedCalled).toBe(true)
784
422
  })
785
423
 
786
- test('individual State signals have independent HOOK_WATCH when accessed via list.at().get()', () => {
787
- const items = new List(['first', 'second'])
788
- let firstItemHookCalled = false
789
- let firstItemUnwatchCalled = false
790
-
791
- // Get the first item signal and set up its HOOK_WATCH
792
- // biome-ignore lint/style/noNonNullAssertion: test
793
- const firstItemSignal = items.at(0)!
794
- firstItemSignal.on('watch', () => {
795
- firstItemHookCalled = true
796
- return () => {
797
- firstItemUnwatchCalled = true
798
- }
424
+ test('should activate on length access', () => {
425
+ let watchedCalled = false
426
+ const list = createList([1, 2], {
427
+ watched: () => {
428
+ watchedCalled = true
429
+ return () => {}
430
+ },
799
431
  })
800
432
 
801
- expect(firstItemHookCalled).toBe(false)
802
-
803
- // Access first item via signal.get() - this should trigger the State signal's HOOK_WATCH
804
- let effectValue: string | undefined
805
- const cleanup = createEffect(() => {
806
- effectValue = firstItemSignal.get()
433
+ const dispose = createEffect(() => {
434
+ void list.length
807
435
  })
808
436
 
809
- expect(firstItemHookCalled).toBe(true)
810
- expect(effectValue).toBe('first')
811
- expect(firstItemUnwatchCalled).toBe(false)
812
-
813
- // Cleanup effect - should trigger State signal's unwatch
814
- cleanup()
815
- expect(firstItemUnwatchCalled).toBe(true)
437
+ expect(watchedCalled).toBe(true)
438
+ dispose()
816
439
  })
440
+ })
817
441
 
818
- test('State signal unwatch is called when item gets removed from list', () => {
819
- const items = new List(['first', 'second'])
820
- let firstItemUnwatchCalled = false
821
-
822
- // Get the first item signal and set up its HOOK_WATCH
823
- // biome-ignore lint/style/noNonNullAssertion: test
824
- const firstItemSignal = items.at(0)!
825
- firstItemSignal.on('watch', () => {
826
- return () => {
827
- firstItemUnwatchCalled = true
828
- }
829
- })
830
-
831
- let effectValue: string | undefined
832
- const cleanup = createEffect(() => {
833
- effectValue = firstItemSignal.get()
834
- })
835
-
836
- expect(effectValue).toBe('first')
837
- expect(firstItemUnwatchCalled).toBe(false)
838
-
839
- // Remove the first item (index 0) - the State signal still exists but the list changed
840
- items.remove(0)
841
-
842
- // The State signal should still work (it's not automatically cleaned up)
843
- expect(effectValue).toBe('first') // State signal retains its value
844
- expect(firstItemUnwatchCalled).toBe(false) // Unwatch only happens when effect cleanup
845
-
846
- // Cleanup the effect - this should call the State signal's unwatch
847
- cleanup()
848
- expect(firstItemUnwatchCalled).toBe(true)
849
- })
850
-
851
- test('new items added to list get independent State signals with their own hooks', () => {
852
- const numbers = new List<number>([])
853
-
854
- // Start with empty list - create effect that tries to access first item
855
- let effectValue: number | undefined
856
- const cleanup = createEffect(() => {
857
- const firstItem = numbers.at(0)
858
- effectValue = firstItem?.get()
859
- })
860
-
861
- // No items yet
862
- expect(effectValue).toBe(undefined)
863
-
864
- // Add first item
865
- const key = numbers.add(42)
866
- // biome-ignore lint/style/noNonNullAssertion: test
867
- const newItemSignal = numbers.byKey(key)!
868
-
869
- let newItemHookCalled = false
870
- let newItemUnwatchCalled = false
871
- newItemSignal.on('watch', () => {
872
- newItemHookCalled = true
873
- return () => {
874
- newItemUnwatchCalled = true
875
- }
876
- })
877
-
878
- // Create new effect to access the new item
879
- let newEffectValue: number | undefined
880
- const newCleanup = createEffect(() => {
881
- newEffectValue = newItemSignal.get()
882
- })
883
-
884
- expect(newItemHookCalled).toBe(true)
885
- expect(newEffectValue).toBe(42)
886
- expect(newItemUnwatchCalled).toBe(false)
887
-
888
- // Cleanup should trigger unwatch
889
- newCleanup()
890
- expect(newItemUnwatchCalled).toBe(true)
891
-
892
- cleanup()
893
- })
894
-
895
- test('List length access triggers List HOOK_WATCH', () => {
896
- const numbers = new List([1, 2, 3])
897
- let listHookWatchCalled = false
898
- let listUnwatchCalled = false
899
-
900
- numbers.on('watch', () => {
901
- listHookWatchCalled = true
902
- return () => {
903
- listUnwatchCalled = true
904
- }
905
- })
906
-
907
- // Access via list.length - this should trigger list's HOOK_WATCH
908
- let effectValue: number = 0
909
- const cleanup = createEffect(() => {
910
- effectValue = numbers.length
911
- })
912
-
913
- expect(listHookWatchCalled).toBe(true)
914
- expect(effectValue).toBe(3)
915
- expect(listUnwatchCalled).toBe(false)
916
-
917
- cleanup()
918
- expect(listUnwatchCalled).toBe(true)
442
+ describe('Input Validation', () => {
443
+ test('should throw for non-array initial value', () => {
444
+ expect(() => {
445
+ // @ts-expect-error - Testing invalid input
446
+ createList('not an array')
447
+ }).toThrow()
919
448
  })
920
449
 
921
- test('exact scenario: List HOOK_WATCH triggered by list.at(0).get(), unwatch on item removal, restart on new item', () => {
922
- const list = new List<number>([42])
923
- let listHookWatchCallCount = 0
924
- let listUnwatchCallCount = 0
925
-
926
- // Set up List's HOOK_WATCH (this is triggered by list-level access like get() or length)
927
- list.on('watch', () => {
928
- listHookWatchCallCount++
929
- return () => {
930
- listUnwatchCallCount++
931
- }
932
- })
933
-
934
- // Scenario 1: The list's HOOK_WATCH is called when an effect accesses the first item
935
- // Note: list.at(0).get() accesses the State signal, not the list itself
936
- // But if we access list.get() or list.length, it triggers the list's HOOK_WATCH
937
- let effectValue: number | undefined
938
- const cleanup1 = createEffect(() => {
939
- // Access list first to trigger list HOOK_WATCH
940
- const length = list.length
941
- if (length > 0) {
942
- effectValue = list.at(0)?.get()
943
- } else {
944
- effectValue = undefined
945
- }
946
- })
947
-
948
- expect(listHookWatchCallCount).toBe(1) // List HOOK_WATCH called due to list.length access
949
- expect(effectValue).toBe(42)
950
-
951
- // Scenario 2: The list's unwatch callback is called when the only item with active subscription gets removed
952
- list.remove(0)
953
- // The effect should re-run due to list.length change and effectValue should now be undefined
954
- expect(effectValue).toBe(undefined)
955
-
956
- // The list unwatch is not called yet because the effect is still active (watching an empty list)
957
- expect(listUnwatchCallCount).toBe(0)
958
-
959
- // Clean up the first effect
960
- cleanup1()
961
- expect(listUnwatchCallCount).toBe(1) // Now unwatch is called
962
-
963
- // Scenario 3: The list's HOOK_WATCH is restarted after a new item has been added that gets accessed by an effect
964
- list.add(100)
965
-
966
- const cleanup2 = createEffect(() => {
967
- const length = list.length
968
- if (length > 0) {
969
- effectValue = list.at(0)?.get()
970
- } else {
971
- effectValue = undefined
972
- }
973
- })
974
-
975
- expect(listHookWatchCallCount).toBe(2) // List HOOK_WATCH called again
976
- expect(effectValue).toBe(100)
977
-
978
- cleanup2()
979
- expect(listUnwatchCallCount).toBe(2) // Second unwatch called
450
+ test('should throw for null initial value', () => {
451
+ expect(() => {
452
+ // @ts-expect-error - Testing invalid input
453
+ createList(null)
454
+ }).toThrow()
980
455
  })
981
456
  })
982
457
  })