@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,1112 +1,612 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
+ batch,
4
+ createCollection,
3
5
  createEffect,
4
- createStore,
5
- DerivedCollection,
6
+ createList,
7
+ createScope,
8
+ createState,
9
+ type DiffResult,
6
10
  isCollection,
7
- List,
8
- UNSET,
11
+ isList,
9
12
  } from '../index.ts'
10
13
 
11
- describe('collection', () => {
12
- describe('creation and basic operations', () => {
13
- test('creates collection with initial values from list', () => {
14
- const numbers = new List([1, 2, 3])
15
- const doubled = new DerivedCollection(
16
- numbers,
17
- (value: number) => value * 2,
18
- )
19
-
20
- expect(doubled.length).toBe(3)
21
- expect(doubled.at(0)?.get()).toBe(2)
22
- expect(doubled.at(1)?.get()).toBe(4)
23
- expect(doubled.at(2)?.get()).toBe(6)
24
- })
25
-
26
- test('creates collection from function source', () => {
27
- const doubled = new DerivedCollection(
28
- () => new List([10, 20, 30]),
29
- (value: number) => value * 2,
30
- )
31
-
32
- expect(doubled.length).toBe(3)
33
- expect(doubled.at(0)?.get()).toBe(20)
34
- expect(doubled.at(1)?.get()).toBe(40)
35
- expect(doubled.at(2)?.get()).toBe(60)
36
- })
37
-
38
- test('has Symbol.toStringTag of Collection', () => {
39
- const list = new List([1, 2, 3])
40
- const collection = new DerivedCollection(list, (x: number) => x)
41
- expect(Object.prototype.toString.call(collection)).toBe(
42
- '[object Collection]',
43
- )
44
- })
45
-
46
- test('isCollection identifies collection instances correctly', () => {
47
- const store = createStore({ a: 1 })
48
- const list = new List([1, 2, 3])
49
- const collection = new DerivedCollection(list, (x: number) => x)
50
-
51
- expect(isCollection(collection)).toBe(true)
52
- expect(isCollection(list)).toBe(false)
53
- expect(isCollection(store)).toBe(false)
54
- expect(isCollection({})).toBe(false)
55
- expect(isCollection(null)).toBe(false)
56
- })
57
-
58
- test('get() returns the complete collection value', () => {
59
- const numbers = new List([1, 2, 3])
60
- const doubled = new DerivedCollection(
61
- numbers,
62
- (value: number) => value * 2,
63
- )
14
+ /* === Utility Functions === */
64
15
 
65
- const result = doubled.get()
66
- expect(result).toEqual([2, 4, 6])
67
- expect(Array.isArray(result)).toBe(true)
68
- })
69
- })
16
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
70
17
 
71
- describe('length property and sizing', () => {
72
- test('length property works for collections', () => {
73
- const numbers = new List([1, 2, 3, 4, 5])
74
- const collection = new DerivedCollection(
75
- numbers,
76
- (x: number) => x * 2,
77
- )
78
- expect(collection.length).toBe(5)
79
- })
18
+ /* === Tests === */
80
19
 
81
- test('length is reactive and updates with changes', () => {
82
- const items = new List([1, 2])
83
- const collection = new DerivedCollection(
84
- items,
85
- (x: number) => x * 2,
86
- )
20
+ describe('Collection', () => {
21
+ describe('createCollection', () => {
22
+ test('should create a collection with initial values', () => {
23
+ const col = createCollection(() => () => {}, { value: [1, 2, 3] })
87
24
 
88
- expect(collection.length).toBe(2)
89
- items.add(3)
90
- expect(collection.length).toBe(3)
25
+ expect(col.get()).toEqual([1, 2, 3])
26
+ expect(col.length).toBe(3)
27
+ expect(isCollection(col)).toBe(true)
91
28
  })
92
- })
93
-
94
- describe('index-based access', () => {
95
- test('properties can be accessed via computed signals', () => {
96
- const items = new List([10, 20, 30])
97
- const doubled = new DerivedCollection(items, (x: number) => x * 2)
98
29
 
99
- expect(doubled.at(0)?.get()).toBe(20)
100
- expect(doubled.at(1)?.get()).toBe(40)
101
- expect(doubled.at(2)?.get()).toBe(60)
102
- })
30
+ test('should create an empty collection', () => {
31
+ const col = createCollection<number>(() => () => {})
103
32
 
104
- test('returns undefined for non-existent properties', () => {
105
- const items = new List([1, 2])
106
- const collection = new DerivedCollection(items, (x: number) => x)
107
- expect(collection[5]).toBeUndefined()
33
+ expect(col.get()).toEqual([])
34
+ expect(col.length).toBe(0)
108
35
  })
109
36
 
110
- test('supports numeric key access', () => {
111
- const numbers = new List([1, 2, 3])
112
- const collection = new DerivedCollection(
113
- numbers,
114
- (x: number) => x * 2,
115
- )
116
- expect(collection.at(1)?.get()).toBe(4)
37
+ test('should have Symbol.toStringTag of "Collection"', () => {
38
+ const col = createCollection(() => () => {}, { value: [1] })
39
+ expect(col[Symbol.toStringTag]).toBe('Collection')
117
40
  })
118
- })
119
-
120
- describe('key-based access methods', () => {
121
- test('byKey() returns computed signal for existing keys', () => {
122
- const numbers = new List([1, 2, 3])
123
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
124
-
125
- const key0 = numbers.keyAt(0)
126
- const key1 = numbers.keyAt(1)
127
41
 
128
- expect(key0).toBeDefined()
129
- expect(key1).toBeDefined()
130
- // biome-ignore lint/style/noNonNullAssertion: test
131
- expect(doubled.byKey(key0!)).toBeDefined()
132
- // biome-ignore lint/style/noNonNullAssertion: test
133
- expect(doubled.byKey(key1!)).toBeDefined()
134
- // biome-ignore lint/style/noNonNullAssertion: test
135
- expect(doubled.byKey(key0!)?.get()).toBe(2)
136
- // biome-ignore lint/style/noNonNullAssertion: test
137
- expect(doubled.byKey(key1!)?.get()).toBe(4)
42
+ test('should have Symbol.isConcatSpreadable set to true', () => {
43
+ const col = createCollection(() => () => {}, { value: [1] })
44
+ expect(col[Symbol.isConcatSpreadable]).toBe(true)
138
45
  })
139
46
 
140
- test('keyAt() and indexOfKey() work correctly', () => {
141
- const numbers = new List([5, 10, 15])
142
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
143
-
144
- const key0 = doubled.keyAt(0)
145
- const key1 = doubled.keyAt(1)
47
+ test('should support at(), byKey(), keyAt(), indexOfKey()', () => {
48
+ const col = createCollection(() => () => {}, {
49
+ value: [
50
+ { id: 'a', name: 'Alice' },
51
+ { id: 'b', name: 'Bob' },
52
+ ],
53
+ keyConfig: item => item.id,
54
+ })
146
55
 
147
- expect(key0).toBeDefined()
148
- expect(key1).toBeDefined()
56
+ expect(col.keyAt(0)).toBe('a')
57
+ expect(col.keyAt(1)).toBe('b')
58
+ expect(col.indexOfKey('b')).toBe(1)
149
59
  // biome-ignore lint/style/noNonNullAssertion: test
150
- expect(doubled.indexOfKey(key0!)).toBe(0)
60
+ expect(col.byKey('a')!.get()).toEqual({ id: 'a', name: 'Alice' })
151
61
  // biome-ignore lint/style/noNonNullAssertion: test
152
- expect(doubled.indexOfKey(key1!)).toBe(1)
62
+ expect(col.at(1)!.get()).toEqual({ id: 'b', name: 'Bob' })
153
63
  })
154
- })
155
64
 
156
- describe('reactivity', () => {
157
- test('collection-level get() is reactive', () => {
158
- const numbers = new List([1, 2, 3])
159
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
160
-
161
- let lastArray: number[] = []
162
- createEffect(() => {
163
- lastArray = doubled.get()
65
+ test('should support iteration', () => {
66
+ const col = createCollection(() => () => {}, {
67
+ value: [10, 20, 30],
164
68
  })
165
69
 
166
- expect(lastArray).toEqual([2, 4, 6])
167
- numbers.add(4)
168
- expect(lastArray).toEqual([2, 4, 6, 8])
70
+ const values = []
71
+ for (const signal of col) values.push(signal.get())
72
+ expect(values).toEqual([10, 20, 30])
169
73
  })
170
74
 
171
- test('individual signal reactivity works', () => {
172
- const items = new List([{ count: 1 }, { count: 2 }])
173
- const doubled = new DerivedCollection(
174
- items,
175
- (item: { count: number }) => ({ count: item.count * 2 }),
176
- )
177
-
178
- let lastItem: { count: number } | undefined
179
- let itemEffectRuns = 0
180
- createEffect(() => {
181
- lastItem = doubled.at(0)?.get()
182
- itemEffectRuns++
75
+ test('should support custom key config with string prefix', () => {
76
+ const col = createCollection(() => () => {}, {
77
+ value: [10, 20],
78
+ keyConfig: 'item-',
183
79
  })
184
80
 
185
- expect(lastItem).toEqual({ count: 2 })
186
- expect(itemEffectRuns).toBe(1)
187
-
188
- items.at(0)?.set({ count: 5 })
189
- expect(lastItem).toEqual({ count: 10 })
190
- // Effect runs twice: once initially, once for change
191
- expect(itemEffectRuns).toEqual(2)
192
- })
193
-
194
- test('updates are reactive', () => {
195
- const numbers = new List([1, 2, 3])
196
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
197
-
198
- let lastArray: number[] = []
199
- let arrayEffectRuns = 0
200
- createEffect(() => {
201
- lastArray = doubled.get()
202
- arrayEffectRuns++
81
+ expect(col.keyAt(0)).toBe('item-0')
82
+ expect(col.keyAt(1)).toBe('item-1')
83
+ // biome-ignore lint/style/noNonNullAssertion: test
84
+ expect(col.byKey('item-0')!.get()).toBe(10)
85
+ })
86
+
87
+ test('should support custom createItem factory', () => {
88
+ let guardCalled = false
89
+ const col = createCollection(() => () => {}, {
90
+ value: [5, 10],
91
+ createItem: (_key, value) =>
92
+ createState(value, {
93
+ guard: (v): v is number => {
94
+ guardCalled = true
95
+ return typeof v === 'number'
96
+ },
97
+ }),
203
98
  })
204
99
 
205
- expect(lastArray).toEqual([2, 4, 6])
206
- expect(arrayEffectRuns).toBe(1)
207
-
208
- numbers.at(1)?.set(10)
209
- expect(lastArray).toEqual([2, 20, 6])
210
- expect(arrayEffectRuns).toBe(2)
100
+ expect(col.get()).toEqual([5, 10])
101
+ expect(guardCalled).toBe(true)
211
102
  })
212
103
  })
213
104
 
214
- describe('iteration and spreading', () => {
215
- test('supports for...of iteration', () => {
216
- const numbers = new List([1, 2, 3])
217
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
218
- const signals = [...doubled]
219
-
220
- expect(signals).toHaveLength(3)
221
- expect(signals[0].get()).toBe(2)
222
- expect(signals[1].get()).toBe(4)
223
- expect(signals[2].get()).toBe(6)
105
+ describe('isCollection', () => {
106
+ test('should identify collection signals', () => {
107
+ const col = createCollection(() => () => {}, { value: [1] })
108
+ expect(isCollection(col)).toBe(true)
224
109
  })
225
110
 
226
- test('Symbol.isConcatSpreadable is true', () => {
227
- const numbers = new List([1, 2, 3])
228
- const collection = new DerivedCollection(numbers, (x: number) => x)
229
- expect(collection[Symbol.isConcatSpreadable]).toBe(true)
111
+ test('should return false for non-collection values', () => {
112
+ expect(isCollection(42)).toBe(false)
113
+ expect(isCollection(null)).toBe(false)
114
+ expect(isCollection({})).toBe(false)
115
+ expect(
116
+ isList(createCollection(() => () => {}, { value: [1] })),
117
+ ).toBe(false)
230
118
  })
231
119
  })
232
120
 
233
- describe('Hooks', () => {
234
- test('triggers HOOK_ADD when items are added', () => {
235
- const numbers = new List([1, 2])
236
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
237
-
238
- let addedKeys: readonly string[] | undefined
239
- doubled.on('add', keys => {
240
- addedKeys = keys
241
- })
242
-
243
- numbers.add(3)
244
- expect(addedKeys).toHaveLength(1)
245
- const doubledKey = addedKeys?.[0]
246
- if (doubledKey) expect(doubled.byKey(doubledKey)?.get()).toBe(6)
247
- })
121
+ describe('Watched Lifecycle', () => {
122
+ test('should call start callback on first effect access', () => {
123
+ let started = false
124
+ let cleaned = false
125
+
126
+ const col = createCollection(
127
+ () => {
128
+ started = true
129
+ return () => {
130
+ cleaned = true
131
+ }
132
+ },
133
+ { value: [1] },
134
+ )
248
135
 
249
- test('triggers HOOK_REMOVE when items are removed', () => {
250
- const items = new List([1, 2, 3])
251
- const doubled = new DerivedCollection(items, (x: number) => x * 2)
136
+ expect(started).toBe(false)
252
137
 
253
- let removedKeys: readonly string[] | undefined
254
- doubled.on('remove', keys => {
255
- removedKeys = keys
138
+ const dispose = createScope(() => {
139
+ createEffect(() => {
140
+ void col.length
141
+ })
256
142
  })
257
143
 
258
- items.remove(1)
259
- expect(removedKeys).toHaveLength(1)
260
- })
261
-
262
- test('triggers HOOK_SORT when source is sorted', () => {
263
- const numbers = new List([3, 1, 2])
264
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
144
+ expect(started).toBe(true)
145
+ expect(cleaned).toBe(false)
265
146
 
266
- let order: readonly string[] | undefined
267
- doubled.on('sort', newOrder => {
268
- order = newOrder
269
- })
270
-
271
- numbers.sort((a, b) => a - b)
272
- expect(order).toHaveLength(3)
273
- expect(doubled.get()).toEqual([2, 4, 6])
147
+ dispose()
148
+ expect(cleaned).toBe(true)
274
149
  })
275
- })
276
150
 
277
- describe('edge cases', () => {
278
- test('handles empty collections correctly', () => {
279
- const empty = new List<number>([])
280
- const collection = new DerivedCollection(
281
- empty,
282
- (x: number) => x * 2,
151
+ test('should activate via keys() access in effect', () => {
152
+ let started = false
153
+ const col = createCollection(
154
+ () => {
155
+ started = true
156
+ return () => {}
157
+ },
158
+ { value: [1] },
283
159
  )
284
- expect(collection.length).toBe(0)
285
- expect(collection.get()).toEqual([])
286
- })
287
160
 
288
- test('handles UNSET values', () => {
289
- const list = new List([1, 2, 3])
290
- const processed = new DerivedCollection(list, (x: number) =>
291
- x > 2 ? x : UNSET,
292
- )
161
+ expect(started).toBe(false)
293
162
 
294
- // UNSET values should be filtered out
295
- expect(processed.get()).toEqual([3])
296
- })
163
+ const dispose = createScope(() => {
164
+ createEffect(() => {
165
+ void Array.from(col.keys())
166
+ })
167
+ })
297
168
 
298
- test('handles primitive values', () => {
299
- const list = new List(['hello', 'world'])
300
- const lengths = new DerivedCollection(list, (str: string) => ({
301
- length: str.length,
302
- }))
169
+ expect(started).toBe(true)
303
170
 
304
- expect(lengths.at(0)?.get()).toEqual({ length: 5 })
305
- expect(lengths.at(1)?.get()).toEqual({ length: 5 })
171
+ dispose()
306
172
  })
307
173
  })
308
174
 
309
- describe('deriveCollection() method', () => {
310
- describe('synchronous transformations', () => {
311
- test('transforms collection values with sync callback', () => {
312
- const numbers = new List([1, 2, 3])
313
- const doubled = new DerivedCollection(
314
- numbers,
315
- (x: number) => x * 2,
316
- )
317
- const quadrupled = doubled.deriveCollection(
318
- (x: number) => x * 2,
319
- )
320
-
321
- expect(quadrupled.length).toBe(3)
322
- expect(quadrupled.at(0)?.get()).toBe(4)
323
- expect(quadrupled.at(1)?.get()).toBe(8)
324
- expect(quadrupled.at(2)?.get()).toBe(12)
175
+ describe('applyChanges', () => {
176
+ test('should add items', () => {
177
+ let apply: ((changes: DiffResult) => void) | undefined
178
+ const col = createCollection<number>(applyChanges => {
179
+ apply = applyChanges
180
+ return () => {}
325
181
  })
326
182
 
327
- test('transforms object values with sync callback', () => {
328
- const users = new List([
329
- { name: 'Alice', age: 25 },
330
- { name: 'Bob', age: 30 },
331
- ])
332
- const basicInfo = new DerivedCollection(
333
- users,
334
- (user: { name: string; age: number }) => ({
335
- displayName: user.name.toUpperCase(),
336
- isAdult: user.age >= 18,
337
- }),
338
- )
339
- const detailedInfo = basicInfo.deriveCollection(
340
- (info: { displayName: string; isAdult: boolean }) => ({
341
- ...info,
342
- category: info.isAdult ? 'adult' : 'minor',
343
- }),
344
- )
345
-
346
- expect(detailedInfo.at(0)?.get()).toEqual({
347
- displayName: 'ALICE',
348
- isAdult: true,
349
- category: 'adult',
350
- })
351
- expect(detailedInfo.at(1)?.get()).toEqual({
352
- displayName: 'BOB',
353
- isAdult: true,
354
- category: 'adult',
183
+ const values: number[][] = []
184
+ const dispose = createScope(() => {
185
+ createEffect(() => {
186
+ values.push(col.get())
355
187
  })
356
188
  })
357
189
 
358
- test('transforms string values to different types', () => {
359
- const words = new List(['hello', 'world', 'test'])
360
- const wordInfo = new DerivedCollection(
361
- words,
362
- (word: string) => ({
363
- word,
364
- length: word.length,
365
- }),
366
- )
367
- const analysis = wordInfo.deriveCollection(
368
- (info: { word: string; length: number }) => ({
369
- ...info,
370
- isLong: info.length > 4,
371
- }),
372
- )
373
-
374
- expect(analysis.at(0)?.get().word).toBe('hello')
375
- expect(analysis.at(0)?.get().length).toBe(5)
376
- expect(analysis.at(0)?.get().isLong).toBe(true)
377
- expect(analysis.at(1)?.get().word).toBe('world')
378
- expect(analysis.at(1)?.get().length).toBe(5)
379
- expect(analysis.at(1)?.get().isLong).toBe(true)
380
- expect(analysis.at(2)?.get().word).toBe('test')
381
- expect(analysis.at(2)?.get().length).toBe(4)
382
- expect(analysis.at(2)?.get().isLong).toBe(false)
383
- })
190
+ expect(values).toEqual([[]])
191
+
192
+ // biome-ignore lint/style/noNonNullAssertion: test
193
+ apply!({
194
+ changed: true,
195
+ add: { a: 1, b: 2 },
196
+ change: {},
197
+ remove: {},
198
+ })
199
+
200
+ expect(values.length).toBe(2)
201
+ expect(values[1]).toEqual([1, 2])
202
+ expect(col.length).toBe(2)
203
+
204
+ dispose()
205
+ })
206
+
207
+ test('should change item values', () => {
208
+ let apply: ((changes: DiffResult) => void) | undefined
209
+ const col = createCollection(
210
+ applyChanges => {
211
+ apply = applyChanges
212
+ return () => {}
213
+ },
214
+ {
215
+ value: [{ id: 'x', val: 1 }],
216
+ keyConfig: item => item.id,
217
+ },
218
+ )
384
219
 
385
- test('derived collection reactivity with sync transformations', () => {
386
- const numbers = new List([1, 2, 3])
387
- const doubled = new DerivedCollection(
388
- numbers,
389
- (x: number) => x * 2,
390
- )
391
- const quadrupled = doubled.deriveCollection(
392
- (x: number) => x * 2,
393
- )
394
-
395
- let collectionValue: number[] = []
396
- let effectRuns = 0
220
+ const values: { id: string; val: number }[][] = []
221
+ const dispose = createScope(() => {
397
222
  createEffect(() => {
398
- collectionValue = quadrupled.get()
399
- effectRuns++
223
+ values.push(col.get())
400
224
  })
401
-
402
- expect(collectionValue).toEqual([4, 8, 12])
403
- expect(effectRuns).toBe(1)
404
-
405
- numbers.add(4)
406
- expect(collectionValue).toEqual([4, 8, 12, 16])
407
- expect(effectRuns).toBe(2)
408
-
409
- numbers.at(1)?.set(5)
410
- expect(collectionValue).toEqual([4, 20, 12, 16])
411
- expect(effectRuns).toBe(3)
412
225
  })
413
226
 
414
- test('derived collection responds to source removal', () => {
415
- const numbers = new List([1, 2, 3, 4])
416
- const doubled = new DerivedCollection(
417
- numbers,
418
- (x: number) => x * 2,
419
- )
420
- const quadrupled = doubled.deriveCollection(
421
- (x: number) => x * 2,
422
- )
227
+ expect(values[0]).toEqual([{ id: 'x', val: 1 }])
423
228
 
424
- expect(quadrupled.get()).toEqual([4, 8, 12, 16])
229
+ // biome-ignore lint/style/noNonNullAssertion: test
230
+ apply!({
231
+ changed: true,
232
+ add: {},
233
+ change: { x: { id: 'x', val: 42 } },
234
+ remove: {},
235
+ })
236
+
237
+ expect(values.length).toBe(2)
238
+ expect(values[1]).toEqual([{ id: 'x', val: 42 }])
239
+
240
+ dispose()
241
+ })
242
+
243
+ test('should remove items', () => {
244
+ let apply: ((changes: DiffResult) => void) | undefined
245
+ const col = createCollection(
246
+ applyChanges => {
247
+ apply = applyChanges
248
+ return () => {}
249
+ },
250
+ {
251
+ value: [
252
+ { id: 'a', v: 1 },
253
+ { id: 'b', v: 2 },
254
+ { id: 'c', v: 3 },
255
+ ],
256
+ keyConfig: item => item.id,
257
+ },
258
+ )
425
259
 
426
- numbers.remove(1)
427
- expect(quadrupled.get()).toEqual([4, 12, 16])
260
+ const values: { id: string; v: number }[][] = []
261
+ const dispose = createScope(() => {
262
+ createEffect(() => {
263
+ values.push(col.get())
264
+ })
428
265
  })
429
- })
430
266
 
431
- describe('asynchronous transformations', () => {
432
- test('transforms values with async callback', async () => {
433
- const numbers = new List([1, 2, 3])
434
- const doubled = new DerivedCollection(
435
- numbers,
436
- (x: number) => x * 2,
437
- )
438
-
439
- const asyncQuadrupled = doubled.deriveCollection(
440
- async (x: number, abort: AbortSignal) => {
441
- await new Promise(resolve => setTimeout(resolve, 10))
442
- if (abort.aborted) throw new Error('Aborted')
443
- return x * 2
444
- },
445
- )
446
-
447
- const initialLength = asyncQuadrupled.length
448
- expect(initialLength).toBe(3)
449
-
450
- // Initially, async computations return UNSET
451
- expect(asyncQuadrupled.at(0)).toBeDefined()
452
- expect(asyncQuadrupled.at(1)).toBeDefined()
453
- expect(asyncQuadrupled.at(2)).toBeDefined()
454
-
455
- // Use effects to test async reactivity
456
- const results: number[] = []
457
- let effectRuns = 0
267
+ expect(values[0]).toEqual([
268
+ { id: 'a', v: 1 },
269
+ { id: 'b', v: 2 },
270
+ { id: 'c', v: 3 },
271
+ ])
272
+
273
+ // biome-ignore lint/style/noNonNullAssertion: test
274
+ apply!({
275
+ changed: true,
276
+ add: {},
277
+ change: {},
278
+ remove: { b: null },
279
+ })
280
+
281
+ expect(values.length).toBe(2)
282
+ expect(values[1]).toEqual([
283
+ { id: 'a', v: 1 },
284
+ { id: 'c', v: 3 },
285
+ ])
286
+ expect(col.length).toBe(2)
287
+
288
+ dispose()
289
+ })
290
+
291
+ test('should handle mixed add/change/remove', () => {
292
+ let apply: ((changes: DiffResult) => void) | undefined
293
+ const col = createCollection(
294
+ applyChanges => {
295
+ apply = applyChanges
296
+ return () => {}
297
+ },
298
+ {
299
+ value: [
300
+ { id: 'a', v: 1 },
301
+ { id: 'b', v: 2 },
302
+ ],
303
+ keyConfig: item => item.id,
304
+ },
305
+ )
458
306
 
307
+ const values: { id: string; v: number }[][] = []
308
+ const dispose = createScope(() => {
459
309
  createEffect(() => {
460
- const values = asyncQuadrupled.get()
461
- results.push(...values)
462
- effectRuns++
310
+ values.push(col.get())
463
311
  })
464
-
465
- // Wait for async computations to complete
466
- await new Promise(resolve => setTimeout(resolve, 50))
467
-
468
- // Should have received the computed values
469
- expect(results.slice(-3)).toEqual([4, 8, 12])
470
- expect(effectRuns).toBeGreaterThanOrEqual(1)
471
312
  })
472
313
 
473
- test('async derived collection with object transformation', async () => {
474
- const users = new List([
475
- { id: 1, name: 'Alice' },
476
- { id: 2, name: 'Bob' },
477
- ])
478
- const basicInfo = new DerivedCollection(
479
- users,
480
- (user: { id: number; name: string }) => ({
481
- userId: user.id,
482
- displayName: user.name.toUpperCase(),
483
- }),
484
- )
485
-
486
- const enrichedUsers = basicInfo.deriveCollection(
487
- async (
488
- info: { userId: number; displayName: string },
489
- abort: AbortSignal,
490
- ) => {
491
- // Simulate async enrichment
492
- await new Promise(resolve => setTimeout(resolve, 10))
493
- if (abort.aborted) throw new Error('Aborted')
494
-
495
- return {
496
- ...info,
497
- slug: info.displayName
498
- .toLowerCase()
499
- .replace(/\s+/g, '-'),
500
- timestamp: Date.now(),
501
- }
502
- },
503
- )
504
-
505
- // Use effect to test async behavior
506
- let enrichedResults: Array<{
507
- userId: number
508
- displayName: string
509
- slug: string
510
- timestamp: number
511
- }> = []
314
+ // biome-ignore lint/style/noNonNullAssertion: test
315
+ apply!({
316
+ changed: true,
317
+ add: { c: { id: 'c', v: 3 } },
318
+ change: { a: { id: 'a', v: 10 } },
319
+ remove: { b: null },
320
+ })
321
+
322
+ expect(values.length).toBe(2)
323
+ expect(values[1]).toEqual([
324
+ { id: 'a', v: 10 },
325
+ { id: 'c', v: 3 },
326
+ ])
327
+
328
+ dispose()
329
+ })
330
+
331
+ test('should skip when changed is false', () => {
332
+ let apply: ((changes: DiffResult) => void) | undefined
333
+ const col = createCollection(
334
+ applyChanges => {
335
+ apply = applyChanges
336
+ return () => {}
337
+ },
338
+ { value: [1] },
339
+ )
512
340
 
341
+ let callCount = 0
342
+ const dispose = createScope(() => {
513
343
  createEffect(() => {
514
- enrichedResults = enrichedUsers.get()
344
+ void col.get()
345
+ callCount++
515
346
  })
347
+ })
516
348
 
517
- // Wait for async computations to complete
518
- await new Promise(resolve => setTimeout(resolve, 50))
349
+ expect(callCount).toBe(1)
519
350
 
520
- expect(enrichedResults).toHaveLength(2)
351
+ // biome-ignore lint/style/noNonNullAssertion: test
352
+ apply!({ changed: false, add: {}, change: {}, remove: {} })
521
353
 
522
- const [result1, result2] = enrichedResults
354
+ expect(callCount).toBe(1)
523
355
 
524
- expect(result1?.userId).toBe(1)
525
- expect(result1?.displayName).toBe('ALICE')
526
- expect(result1?.slug).toBe('alice')
527
- expect(typeof result1?.timestamp).toBe('number')
356
+ dispose()
357
+ })
528
358
 
529
- expect(result2?.userId).toBe(2)
530
- expect(result2?.displayName).toBe('BOB')
531
- expect(result2?.slug).toBe('bob')
532
- expect(typeof result2?.timestamp).toBe('number')
359
+ test('should trigger effects on structural changes', () => {
360
+ let apply: ((changes: DiffResult) => void) | undefined
361
+ const col = createCollection<string>(applyChanges => {
362
+ apply = applyChanges
363
+ return () => {}
533
364
  })
534
365
 
535
- test('async derived collection reactivity', async () => {
536
- const numbers = new List([1, 2, 3])
537
- const doubled = new DerivedCollection(
538
- numbers,
539
- (x: number) => x * 2,
540
- )
541
- const asyncQuadrupled = doubled.deriveCollection(
542
- async (x: number, abort: AbortSignal) => {
543
- await new Promise(resolve => setTimeout(resolve, 10))
544
- if (abort.aborted) throw new Error('Aborted')
545
- return x * 2
546
- },
547
- )
548
-
549
- const effectValues: number[][] = []
366
+ let effectCount = 0
367
+ const dispose = createScope(() => {
550
368
  createEffect(() => {
551
- // Access all values to trigger reactive behavior
552
- const currentValue = asyncQuadrupled.get()
553
- effectValues.push(currentValue)
369
+ void col.length
370
+ effectCount++
554
371
  })
372
+ })
555
373
 
556
- // Wait for initial effect
557
- await new Promise(resolve => setTimeout(resolve, 50))
558
-
559
- // Initial empty array (async values not resolved yet)
560
- expect(effectValues[0]).toEqual([])
561
-
562
- // Trigger individual computations
563
- asyncQuadrupled.at(0)?.get()
564
- asyncQuadrupled.at(1)?.get()
565
- asyncQuadrupled.at(2)?.get()
566
-
567
- // Wait for effects to process
568
- await new Promise(resolve => setTimeout(resolve, 50))
374
+ expect(effectCount).toBe(1)
569
375
 
570
- // Should have the computed values now
571
- const lastValue = effectValues[effectValues.length - 1]
572
- expect(lastValue).toEqual([4, 8, 12])
376
+ // biome-ignore lint/style/noNonNullAssertion: test
377
+ apply!({
378
+ changed: true,
379
+ add: { a: 'hello' },
380
+ change: {},
381
+ remove: {},
573
382
  })
574
383
 
575
- test('handles AbortSignal cancellation', async () => {
576
- const numbers = new List([1, 2, 3])
577
- const doubled = new DerivedCollection(
578
- numbers,
579
- (x: number) => x * 2,
580
- )
581
- let abortCalled = false
582
-
583
- const slowCollection = doubled.deriveCollection(
584
- async (x: number, abort: AbortSignal) => {
585
- abort.addEventListener('abort', () => {
586
- abortCalled = true
587
- })
588
-
589
- // Long delay to allow cancellation
590
- const timeout = new Promise(resolve =>
591
- setTimeout(resolve, 100),
592
- )
593
- await timeout
594
-
595
- if (abort.aborted) throw new Error('Aborted')
596
- return x * 2
597
- },
598
- )
599
-
600
- // Start computation
601
- const _awaited = slowCollection.at(0)?.get()
602
-
603
- // Change source to trigger cancellation
604
- numbers.at(0)?.set(10)
605
-
606
- // Wait for potential abort
607
- await new Promise(resolve => setTimeout(resolve, 50))
608
-
609
- expect(abortCalled).toBe(true)
610
- })
384
+ expect(effectCount).toBe(2)
385
+ expect(col.length).toBe(1)
386
+
387
+ dispose()
611
388
  })
612
389
 
613
- describe('derived collection chaining', () => {
614
- test('chains multiple sync derivations', () => {
615
- const numbers = new List([1, 2, 3])
616
- const doubled = new DerivedCollection(
617
- numbers,
618
- (x: number) => x * 2,
619
- )
620
- const quadrupled = doubled.deriveCollection(
621
- (x: number) => x * 2,
622
- )
623
- const octupled = quadrupled.deriveCollection(
624
- (x: number) => x * 2,
625
- )
626
-
627
- expect(octupled.at(0)?.get()).toBe(8)
628
- expect(octupled.at(1)?.get()).toBe(16)
629
- expect(octupled.at(2)?.get()).toBe(24)
390
+ test('should batch multiple calls', () => {
391
+ let apply: ((changes: DiffResult) => void) | undefined
392
+ const col = createCollection<number>(applyChanges => {
393
+ apply = applyChanges
394
+ return () => {}
630
395
  })
631
396
 
632
- test('chains sync and async derivations', async () => {
633
- const numbers = new List([1, 2, 3])
634
- const doubled = new DerivedCollection(
635
- numbers,
636
- (x: number) => x * 2,
637
- )
638
- const quadrupled = doubled.deriveCollection(
639
- (x: number) => x * 2,
640
- )
641
-
642
- const asyncOctupled = quadrupled.deriveCollection(
643
- async (x: number, abort: AbortSignal) => {
644
- await new Promise(resolve => setTimeout(resolve, 10))
645
- if (abort.aborted) throw new Error('Aborted')
646
- return x * 2
647
- },
648
- )
649
-
650
- // Use effect to test chained async behavior
651
- let chainedResults: number[] = []
652
-
397
+ let effectCount = 0
398
+ const dispose = createScope(() => {
653
399
  createEffect(() => {
654
- chainedResults = asyncOctupled.get()
400
+ void col.get()
401
+ effectCount++
655
402
  })
656
-
657
- // Wait for async computations to complete
658
- await new Promise(resolve => setTimeout(resolve, 50))
659
-
660
- expect(chainedResults).toEqual([8, 16, 24])
661
403
  })
662
- })
663
404
 
664
- describe('derived collection access methods', () => {
665
- test('provides index-based access to computed signals', () => {
666
- const numbers = new List([1, 2, 3])
667
- const doubled = new DerivedCollection(
668
- numbers,
669
- (x: number) => x * 2,
670
- )
671
- const quadrupled = doubled.deriveCollection(
672
- (x: number) => x * 2,
673
- )
674
-
675
- expect(quadrupled.at(0)?.get()).toBe(4)
676
- expect(quadrupled.at(1)?.get()).toBe(8)
677
- expect(quadrupled.at(2)?.get()).toBe(12)
678
- expect(quadrupled.at(10)).toBeUndefined()
679
- })
405
+ expect(effectCount).toBe(1)
680
406
 
681
- test('supports key-based access', () => {
682
- const numbers = new List([1, 2, 3])
683
- const doubled = new DerivedCollection(
684
- numbers,
685
- (x: number) => x * 2,
686
- )
687
- const quadrupled = doubled.deriveCollection(
688
- (x: number) => x * 2,
689
- )
690
-
691
- const key0 = quadrupled.keyAt(0)
692
- const key1 = quadrupled.keyAt(1)
693
-
694
- expect(key0).toBeDefined()
695
- expect(key1).toBeDefined()
407
+ batch(() => {
696
408
  // biome-ignore lint/style/noNonNullAssertion: test
697
- expect(quadrupled.byKey(key0!)).toBeDefined()
698
- // biome-ignore lint/style/noNonNullAssertion: test
699
- expect(quadrupled.byKey(key1!)).toBeDefined()
700
- // biome-ignore lint/style/noNonNullAssertion: test
701
- expect(quadrupled.byKey(key0!)?.get()).toBe(4)
702
- // biome-ignore lint/style/noNonNullAssertion: test
703
- expect(quadrupled.byKey(key1!)?.get()).toBe(8)
704
- })
705
-
706
- test('supports iteration', () => {
707
- const numbers = new List([1, 2, 3])
708
- const doubled = new DerivedCollection(
709
- numbers,
710
- (x: number) => x * 2,
711
- )
712
- const quadrupled = doubled.deriveCollection(
713
- (x: number) => x * 2,
714
- )
715
-
716
- const signals = [...quadrupled]
717
- expect(signals).toHaveLength(3)
718
- expect(signals[0].get()).toBe(4)
719
- expect(signals[1].get()).toBe(8)
720
- expect(signals[2].get()).toBe(12)
721
- })
722
- })
723
-
724
- describe('derived collection event handling', () => {
725
- test('emits add events when source adds items', () => {
726
- const numbers = new List([1, 2])
727
- const doubled = new DerivedCollection(
728
- numbers,
729
- (x: number) => x * 2,
730
- )
731
- const quadrupled = doubled.deriveCollection(
732
- (x: number) => x * 2,
733
- )
734
-
735
- let addedKeys: readonly string[] | undefined
736
- quadrupled.on('add', keys => {
737
- addedKeys = keys
409
+ apply!({
410
+ changed: true,
411
+ add: { a: 1 },
412
+ change: {},
413
+ remove: {},
738
414
  })
739
-
740
- numbers.add(3)
741
- expect(addedKeys).toHaveLength(1)
742
- const quadrupledKey = addedKeys?.[0]
743
- if (quadrupledKey)
744
- expect(quadrupled.byKey(quadrupledKey)?.get()).toBe(12)
745
- })
746
-
747
- test('emits remove events when source removes items', () => {
748
- const numbers = new List([1, 2, 3])
749
- const doubled = new DerivedCollection(
750
- numbers,
751
- (x: number) => x * 2,
752
- )
753
- const quadrupled = doubled.deriveCollection(
754
- (x: number) => x * 2,
755
- )
756
-
757
- let removedKeys: readonly string[] | undefined
758
- quadrupled.on('remove', keys => {
759
- removedKeys = keys
415
+ // biome-ignore lint/style/noNonNullAssertion: test
416
+ apply!({
417
+ changed: true,
418
+ add: { b: 2 },
419
+ change: {},
420
+ remove: {},
760
421
  })
761
-
762
- numbers.remove(1)
763
- expect(removedKeys).toHaveLength(1)
764
422
  })
765
423
 
766
- test('emits sort events when source is sorted', () => {
767
- const numbers = new List([3, 1, 2])
768
- const doubled = new DerivedCollection(
769
- numbers,
770
- (x: number) => x * 2,
771
- )
772
- const quadrupled = doubled.deriveCollection(
773
- (x: number) => x * 2,
774
- )
775
-
776
- let sortedKeys: readonly string[] | undefined
777
- quadrupled.on('sort', newOrder => {
778
- sortedKeys = newOrder
779
- })
424
+ expect(effectCount).toBe(2)
425
+ expect(col.get()).toEqual([1, 2])
780
426
 
781
- numbers.sort((a, b) => a - b)
782
- expect(sortedKeys).toHaveLength(3)
783
- expect(quadrupled.get()).toEqual([4, 8, 12])
784
- })
427
+ dispose()
785
428
  })
429
+ })
786
430
 
787
- describe('edge cases', () => {
788
- test('handles empty collection derivation', () => {
789
- const empty = new List<number>([])
790
- const emptyCollection = new DerivedCollection(
791
- empty,
792
- (x: number) => x * 2,
793
- )
794
- const derived = emptyCollection.deriveCollection(
795
- (x: number) => x * 2,
796
- )
797
-
798
- expect(derived.length).toBe(0)
799
- expect(derived.get()).toEqual([])
800
- })
801
-
802
- test('handles UNSET values in transformation', () => {
803
- const list = new List([1, 2, 3])
804
- const filtered = new DerivedCollection(list, (x: number) =>
805
- x > 1 ? { value: x } : UNSET,
806
- )
807
- const doubled = filtered.deriveCollection(
808
- (x: { value: number }) => ({ value: x.value * 2 }),
809
- )
810
-
811
- expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
812
- })
431
+ describe('deriveCollection', () => {
432
+ test('should transform list values with sync callback', () => {
433
+ const numbers = createList([1, 2, 3])
434
+ const doubled = numbers.deriveCollection((v: number) => v * 2)
813
435
 
814
- test('handles complex object transformations', () => {
815
- const items = new List([
816
- { id: 1, data: { value: 10, active: true } },
817
- { id: 2, data: { value: 20, active: false } },
818
- ])
819
-
820
- const processed = new DerivedCollection(
821
- items,
822
- (item: {
823
- id: number
824
- data: { value: number; active: boolean }
825
- }) => ({
826
- itemId: item.id,
827
- processedValue: item.data.value * 2,
828
- status: item.data.active ? 'active' : 'inactive',
829
- }),
830
- )
831
-
832
- const enhanced = processed.deriveCollection(
833
- (item: {
834
- itemId: number
835
- processedValue: number
836
- status: string
837
- }) => ({
838
- ...item,
839
- category: item.processedValue > 15 ? 'high' : 'low',
840
- }),
841
- )
842
-
843
- expect(enhanced.at(0)?.get().itemId).toBe(1)
844
- expect(enhanced.at(0)?.get().processedValue).toBe(20)
845
- expect(enhanced.at(0)?.get().status).toBe('active')
846
- expect(enhanced.at(0)?.get().category).toBe('high')
847
- expect(enhanced.at(1)?.get().itemId).toBe(2)
848
- expect(enhanced.at(1)?.get().processedValue).toBe(40)
849
- expect(enhanced.at(1)?.get().status).toBe('inactive')
850
- expect(enhanced.at(1)?.get().category).toBe('high')
851
- })
436
+ expect(doubled.get()).toEqual([2, 4, 6])
437
+ expect(doubled.length).toBe(3)
852
438
  })
853
- })
854
-
855
- describe('hooks system', () => {
856
- test('Collection HOOK_WATCH is called when effect accesses collection.get()', () => {
857
- const numbers = new List([10, 20, 30])
858
- const doubled = numbers.deriveCollection(x => x * 2)
859
439
 
860
- let collectionHookWatchCalled = false
861
- let collectionUnwatchCalled = false
440
+ test('should transform values with async callback', async () => {
441
+ const numbers = createList([1, 2, 3])
442
+ const doubled = numbers.deriveCollection(
443
+ async (v: number, abort: AbortSignal) => {
444
+ await wait(10)
445
+ if (abort.aborted) throw new Error('Aborted')
446
+ return v * 2
447
+ },
448
+ )
862
449
 
863
- // Set up HOOK_WATCH callback on the collection itself
864
- doubled.on('watch', () => {
865
- collectionHookWatchCalled = true
866
- return () => {
867
- collectionUnwatchCalled = true
450
+ // Trigger computation
451
+ for (let i = 0; i < doubled.length; i++) {
452
+ try {
453
+ doubled.at(i)?.get()
454
+ } catch {
455
+ // UnsetSignalValueError before resolution
868
456
  }
869
- })
870
-
871
- expect(collectionHookWatchCalled).toBe(false)
457
+ }
872
458
 
873
- // Access collection via collection.get() - this should trigger collection's HOOK_WATCH
874
- let effectValue: number[] = []
875
- const cleanup = createEffect(() => {
876
- effectValue = doubled.get()
877
- })
459
+ await wait(50)
460
+ expect(doubled.get()).toEqual([2, 4, 6])
461
+ })
878
462
 
879
- expect(collectionHookWatchCalled).toBe(true)
880
- expect(effectValue).toEqual([20, 40, 60])
881
- expect(collectionUnwatchCalled).toBe(false)
463
+ test('should handle empty source list', () => {
464
+ const empty = createList<number>([])
465
+ const doubled = empty.deriveCollection((v: number) => v * 2)
882
466
 
883
- // Cleanup effect - should trigger unwatch
884
- cleanup()
885
- expect(collectionUnwatchCalled).toBe(true)
467
+ expect(doubled.get()).toEqual([])
468
+ expect(doubled.length).toBe(0)
886
469
  })
887
470
 
888
- test('List item HOOK_WATCH is triggered when accessing collection items via collection.at().get()', () => {
889
- const numbers = new List([42, 84])
890
- const doubled = numbers.deriveCollection(x => x * 2)
891
-
892
- // Set up hook on source item BEFORE creating the effect
893
- // biome-ignore lint/style/noNonNullAssertion: test
894
- const firstSourceItem = numbers.at(0)!
895
- let sourceItemHookCalled = false
896
-
897
- firstSourceItem.on('watch', () => {
898
- sourceItemHookCalled = true
899
- return () => {
900
- // Note: Unwatch behavior in computed signals is complex and depends on
901
- // internal watcher management. We focus on verifying hook triggering.
902
- }
903
- })
471
+ test('should return Signal at index', () => {
472
+ const list = createList([1, 2, 3])
473
+ const doubled = list.deriveCollection((v: number) => v * 2)
904
474
 
905
- expect(sourceItemHookCalled).toBe(false)
475
+ expect(doubled.at(0)?.get()).toBe(2)
476
+ expect(doubled.at(1)?.get()).toBe(4)
477
+ expect(doubled.at(2)?.get()).toBe(6)
478
+ expect(doubled.at(5)).toBeUndefined()
479
+ })
906
480
 
907
- // Access collection item - the computed signal internally calls sourceItem.get()
908
- let effectValue: number | undefined
909
- const cleanup = createEffect(() => {
910
- const firstCollectionItem = doubled.at(0)
911
- effectValue = firstCollectionItem?.get()
912
- })
481
+ test('should return Signal by source key', () => {
482
+ const list = createList([10, 20])
483
+ const doubled = list.deriveCollection((v: number) => v * 2)
913
484
 
914
- expect(sourceItemHookCalled).toBe(true) // Source item HOOK_WATCH triggered
915
- expect(effectValue).toBe(84) // 42 * 2
485
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
486
+ const key0 = list.keyAt(0)!
487
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
488
+ const key1 = list.keyAt(1)!
916
489
 
917
- cleanup()
490
+ expect(doubled.byKey(key0)?.get()).toBe(20)
491
+ expect(doubled.byKey(key1)?.get()).toBe(40)
918
492
  })
919
493
 
920
- test('Collection and List item hooks work independently', () => {
921
- const items = new List(['hello', 'world'])
922
- const uppercased = items.deriveCollection(x => x.toUpperCase())
494
+ test('should support keyAt, indexOfKey, and keys', () => {
495
+ const list = createList([10, 20, 30])
496
+ const col = list.deriveCollection((v: number) => v)
923
497
 
924
- let collectionHookCalled = false
925
- let collectionUnwatchCalled = false
926
- let sourceItemHookCalled = false
927
-
928
- // Set up hooks on both collection and source item
929
- uppercased.on('watch', () => {
930
- collectionHookCalled = true
931
- return () => {
932
- collectionUnwatchCalled = true
933
- }
934
- })
498
+ const key0 = col.keyAt(0)
499
+ expect(key0).toBeDefined()
500
+ expect(typeof key0).toBe('string')
501
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
502
+ expect(col.indexOfKey(key0!)).toBe(0)
503
+ expect([...col.keys()]).toHaveLength(3)
504
+ })
935
505
 
936
- // biome-ignore lint/style/noNonNullAssertion: test
937
- const firstSourceItem = items.at(0)!
938
- firstSourceItem.on('watch', () => {
939
- sourceItemHookCalled = true
940
- return () => {
941
- // Source item unwatch behavior is complex in computed context
942
- }
943
- })
506
+ test('should support for...of via Symbol.iterator', () => {
507
+ const list = createList([1, 2, 3])
508
+ const doubled = list.deriveCollection((v: number) => v * 2)
944
509
 
945
- // Effect 1: Access collection-level data - triggers both hooks
946
- let collectionValue: string[] = []
947
- const collectionCleanup = createEffect(() => {
948
- collectionValue = uppercased.get()
949
- })
510
+ const signals = [...doubled]
511
+ expect(signals).toHaveLength(3)
512
+ expect(signals[0].get()).toBe(2)
513
+ expect(signals[1].get()).toBe(4)
514
+ expect(signals[2].get()).toBe(6)
515
+ })
950
516
 
951
- expect(collectionHookCalled).toBe(true)
952
- expect(sourceItemHookCalled).toBe(true) // Source items accessed by collection.get()
953
- expect(collectionValue).toEqual(['HELLO', 'WORLD'])
517
+ test('should react to source additions', () => {
518
+ const list = createList([1, 2])
519
+ const doubled = list.deriveCollection((v: number) => v * 2)
954
520
 
955
- // Effect 2: Access individual collection item independently
956
- let itemValue: string | undefined
957
- const itemCleanup = createEffect(() => {
958
- itemValue = uppercased.at(0)?.get()
521
+ let result: number[] = []
522
+ let effectCount = 0
523
+ createEffect(() => {
524
+ result = doubled.get()
525
+ effectCount++
959
526
  })
960
527
 
961
- expect(itemValue).toBe('HELLO')
528
+ expect(result).toEqual([2, 4])
529
+ expect(effectCount).toBe(1)
962
530
 
963
- // Clean up effects
964
- collectionCleanup()
965
- expect(collectionUnwatchCalled).toBe(true)
966
-
967
- itemCleanup()
531
+ list.add(3)
532
+ expect(result).toEqual([2, 4, 6])
533
+ expect(effectCount).toBe(2)
968
534
  })
969
535
 
970
- test('source List item hooks are properly managed when items are removed', () => {
971
- const items = new List(['first', 'second', 'third'])
972
- const processed = items.deriveCollection(x => x.toUpperCase())
973
-
974
- let firstItemHookCalled = false
975
- let secondItemHookCalled = false
976
-
977
- // Set up hooks on multiple source items
978
- // biome-ignore lint/style/noNonNullAssertion: test
979
- const firstSourceItem = items.at(0)!
980
- // biome-ignore lint/style/noNonNullAssertion: test
981
- const secondSourceItem = items.at(1)!
982
-
983
- firstSourceItem.on('watch', () => {
984
- firstItemHookCalled = true
985
- return () => {
986
- // Collection computed signals manage source watching internally
987
- }
988
- })
989
-
990
- secondSourceItem.on('watch', () => {
991
- secondItemHookCalled = true
992
- return () => {
993
- // Collection computed signals manage source watching internally
994
- }
995
- })
536
+ test('should react to source removals', () => {
537
+ const list = createList([1, 2, 3])
538
+ const doubled = list.deriveCollection((v: number) => v * 2)
996
539
 
997
- // Access both collection items to trigger source hooks
998
- let firstValue: string | undefined
999
- let secondValue: string | undefined
540
+ expect(doubled.get()).toEqual([2, 4, 6])
541
+ list.remove(1)
542
+ expect(doubled.get()).toEqual([2, 6])
543
+ expect(doubled.length).toBe(2)
544
+ })
1000
545
 
1001
- const cleanup1 = createEffect(() => {
1002
- firstValue = processed.at(0)?.get()
1003
- })
546
+ test('should react to item mutations', () => {
547
+ const list = createList([1, 2])
548
+ const doubled = list.deriveCollection((v: number) => v * 2)
1004
549
 
1005
- const cleanup2 = createEffect(() => {
1006
- secondValue = processed.at(1)?.get()
550
+ let result: number[] = []
551
+ createEffect(() => {
552
+ result = doubled.get()
1007
553
  })
1008
554
 
1009
- // Both source item hooks should be triggered
1010
- expect(firstItemHookCalled).toBe(true)
1011
- expect(secondItemHookCalled).toBe(true)
1012
- expect(firstValue).toBe('FIRST')
1013
- expect(secondValue).toBe('SECOND')
1014
-
1015
- cleanup1()
1016
- cleanup2()
555
+ expect(result).toEqual([2, 4])
556
+ list.at(0)?.set(5)
557
+ expect(result).toEqual([10, 4])
1017
558
  })
1018
559
 
1019
- test('newly added source List items have hooks triggered through collection access', () => {
1020
- const numbers = new List<number>([])
1021
- const squared = numbers.deriveCollection(x => x * x)
1022
-
1023
- // Add first item to source list
1024
- numbers.add(5)
1025
-
1026
- // Set up hook on the newly added source item
1027
- // biome-ignore lint/style/noNonNullAssertion: test
1028
- const sourceItem = numbers.at(0)!
1029
- let sourceHookCalled = false
1030
-
1031
- sourceItem.on('watch', () => {
1032
- sourceHookCalled = true
1033
- return () => {
1034
- // Hook cleanup managed by computed signal system
1035
- }
1036
- })
1037
-
1038
- expect(sourceHookCalled).toBe(false)
560
+ test('async collection should react to changes', async () => {
561
+ const list = createList([1, 2])
562
+ const doubled = list.deriveCollection(
563
+ async (v: number, abort: AbortSignal) => {
564
+ await wait(5)
565
+ if (abort.aborted) throw new Error('Aborted')
566
+ return v * 2
567
+ },
568
+ )
1039
569
 
1040
- // Access the collection item - should trigger source item hook
1041
- let effectValue: number | undefined
1042
- const cleanup = createEffect(() => {
1043
- effectValue = squared.at(0)?.get()
570
+ const values: number[][] = []
571
+ createEffect(() => {
572
+ values.push([...doubled.get()])
1044
573
  })
1045
574
 
1046
- expect(sourceHookCalled).toBe(true) // Source hook triggered through collection
1047
- expect(effectValue).toBe(25) // 5 * 5
575
+ await wait(20)
576
+ expect(values[values.length - 1]).toEqual([2, 4])
1048
577
 
1049
- cleanup()
578
+ list.add(3)
579
+ await wait(20)
580
+ expect(values[values.length - 1]).toEqual([2, 4, 6])
1050
581
  })
1051
582
 
1052
- test('Collection length access triggers Collection HOOK_WATCH', () => {
1053
- const numbers = new List([1, 2, 3])
1054
- const doubled = numbers.deriveCollection(x => x * 2)
583
+ test('should chain from collection', () => {
584
+ const list = createList([1, 2, 3])
585
+ const doubled = list.deriveCollection((v: number) => v * 2)
586
+ const quadrupled = doubled.deriveCollection((v: number) => v * 2)
1055
587
 
1056
- let collectionHookWatchCalled = false
1057
- let collectionUnwatchCalled = false
588
+ expect(quadrupled.get()).toEqual([4, 8, 12])
1058
589
 
1059
- doubled.on('watch', () => {
1060
- collectionHookWatchCalled = true
1061
- return () => {
1062
- collectionUnwatchCalled = true
1063
- }
1064
- })
1065
-
1066
- // Access via collection.length - this should trigger collection's HOOK_WATCH
1067
- let effectValue: number = 0
1068
- const cleanup = createEffect(() => {
1069
- effectValue = doubled.length
1070
- })
1071
-
1072
- expect(collectionHookWatchCalled).toBe(true)
1073
- expect(effectValue).toBe(3)
1074
- expect(collectionUnwatchCalled).toBe(false)
1075
-
1076
- cleanup()
1077
- expect(collectionUnwatchCalled).toBe(true)
590
+ list.add(4)
591
+ expect(quadrupled.get()).toEqual([4, 8, 12, 16])
1078
592
  })
1079
593
 
1080
- test('chained collections maintain proper hook propagation to original source', () => {
1081
- const numbers = new List([2, 3])
1082
- const doubled = numbers.deriveCollection(x => x * 2)
1083
- const quadrupled = doubled.deriveCollection(x => x * 2)
1084
-
1085
- // Set up hook on original source item
1086
- // biome-ignore lint/style/noNonNullAssertion: test
1087
- const sourceItem = numbers.at(0)!
1088
- let sourceHookCalled = false
1089
-
1090
- sourceItem.on('watch', () => {
1091
- sourceHookCalled = true
1092
- return () => {
1093
- // Chained computed signals manage cleanup through dependency chain
1094
- }
1095
- })
594
+ test('should chain from createCollection source', () => {
595
+ const col = createCollection(() => () => {}, { value: [1, 2, 3] })
596
+ const doubled = col.deriveCollection((v: number) => v * 2)
1096
597
 
1097
- expect(sourceHookCalled).toBe(false)
598
+ expect(doubled.get()).toEqual([2, 4, 6])
599
+ expect(isCollection(doubled)).toBe(true)
600
+ })
1098
601
 
1099
- // Access chained collection item - should trigger original source hook
1100
- // Chain: quadrupled.at(0).get() -> doubled.at(0).get() -> numbers.at(0).get()
1101
- let effectValue: number | undefined
1102
- const cleanup = createEffect(() => {
1103
- effectValue = quadrupled.at(0)?.get()
602
+ test('should propagate errors from per-item memos', () => {
603
+ const list = createList([1, 2, 3])
604
+ const mapped = list.deriveCollection((v: number) => {
605
+ if (v === 2) throw new Error('bad item')
606
+ return v * 2
1104
607
  })
1105
608
 
1106
- expect(sourceHookCalled).toBe(true) // Original source hook triggered through chain
1107
- expect(effectValue).toBe(8) // 2 * 2 * 2
1108
-
1109
- cleanup()
609
+ expect(() => mapped.get()).toThrow('bad item')
1110
610
  })
1111
611
  })
1112
612
  })