@zeix/cause-effect 0.17.3 → 0.18.1

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 +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  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 -78
@@ -1,852 +1,601 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
+ batch,
4
+ type CollectionChanges,
5
+ createCollection,
3
6
  createEffect,
4
- createStore,
5
- DerivedCollection,
7
+ createList,
8
+ createScope,
9
+ createState,
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
- )
14
+ /* === Utility Functions === */
19
15
 
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
- })
16
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
25
17
 
26
- test('creates collection from function source', () => {
27
- const doubled = new DerivedCollection(
28
- () => new List([10, 20, 30]),
29
- (value: number) => value * 2,
30
- )
18
+ /* === Tests === */
31
19
 
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
- })
20
+ describe('Collection', () => {
21
+ describe('createCollection', () => {
22
+ test('should create a collection with initial values', () => {
23
+ const col = createCollection(() => () => {}, { value: [1, 2, 3] })
37
24
 
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
- )
25
+ expect(col.get()).toEqual([1, 2, 3])
26
+ expect(col.length).toBe(3)
27
+ expect(isCollection(col)).toBe(true)
44
28
  })
45
29
 
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)
30
+ test('should create an empty collection', () => {
31
+ const col = createCollection<number>(() => () => {})
50
32
 
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)
33
+ expect(col.get()).toEqual([])
34
+ expect(col.length).toBe(0)
56
35
  })
57
36
 
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
- )
64
-
65
- const result = doubled.get()
66
- expect(result).toEqual([2, 4, 6])
67
- expect(Array.isArray(result)).toBe(true)
37
+ test('should have Symbol.toStringTag of "Collection"', () => {
38
+ const col = createCollection(() => () => {}, { value: [1] })
39
+ expect(col[Symbol.toStringTag]).toBe('Collection')
68
40
  })
69
- })
70
41
 
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)
42
+ test('should have Symbol.isConcatSpreadable set to true', () => {
43
+ const col = createCollection(() => () => {}, { value: [1] })
44
+ expect(col[Symbol.isConcatSpreadable]).toBe(true)
79
45
  })
80
46
 
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
- )
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
+ })
87
55
 
88
- expect(collection.length).toBe(2)
89
- items.add(3)
90
- expect(collection.length).toBe(3)
56
+ expect(col.keyAt(0)).toBe('a')
57
+ expect(col.keyAt(1)).toBe('b')
58
+ expect(col.indexOfKey('b')).toBe(1)
59
+ // biome-ignore lint/style/noNonNullAssertion: test
60
+ expect(col.byKey('a')!.get()).toEqual({ id: 'a', name: 'Alice' })
61
+ // biome-ignore lint/style/noNonNullAssertion: test
62
+ expect(col.at(1)!.get()).toEqual({ id: 'b', name: 'Bob' })
91
63
  })
92
- })
93
64
 
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)
65
+ test('should support iteration', () => {
66
+ const col = createCollection(() => () => {}, {
67
+ value: [10, 20, 30],
68
+ })
98
69
 
99
- expect(doubled.at(0)?.get()).toBe(20)
100
- expect(doubled.at(1)?.get()).toBe(40)
101
- expect(doubled.at(2)?.get()).toBe(60)
70
+ const values = []
71
+ for (const signal of col) values.push(signal.get())
72
+ expect(values).toEqual([10, 20, 30])
102
73
  })
103
74
 
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.at(5)).toBeUndefined()
108
- })
75
+ test('should support custom key config with string prefix', () => {
76
+ const col = createCollection(() => () => {}, {
77
+ value: [10, 20],
78
+ keyConfig: 'item-',
79
+ })
109
80
 
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)
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)
117
85
  })
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
86
 
125
- const key0 = numbers.keyAt(0)
126
- const key1 = numbers.keyAt(1)
87
+ test('should support custom createItem factory', () => {
88
+ let guardCalled = false
89
+ const col = createCollection(() => () => {}, {
90
+ value: [5, 10],
91
+ createItem: value =>
92
+ createState(value, {
93
+ guard: (v): v is number => {
94
+ guardCalled = true
95
+ return typeof v === 'number'
96
+ },
97
+ }),
98
+ })
127
99
 
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)
100
+ expect(col.get()).toEqual([5, 10])
101
+ expect(guardCalled).toBe(true)
138
102
  })
103
+ })
139
104
 
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)
105
+ describe('isCollection', () => {
106
+ test('should identify collection signals', () => {
107
+ const col = createCollection(() => () => {}, { value: [1] })
108
+ expect(isCollection(col)).toBe(true)
109
+ })
146
110
 
147
- expect(key0).toBeDefined()
148
- expect(key1).toBeDefined()
149
- // biome-ignore lint/style/noNonNullAssertion: test
150
- expect(doubled.indexOfKey(key0!)).toBe(0)
151
- // biome-ignore lint/style/noNonNullAssertion: test
152
- expect(doubled.indexOfKey(key1!)).toBe(1)
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)
153
118
  })
154
119
  })
155
120
 
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)
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
+ )
160
135
 
161
- let lastArray: number[] = []
162
- createEffect(() => {
163
- lastArray = doubled.get()
136
+ expect(started).toBe(false)
137
+
138
+ const dispose = createScope(() => {
139
+ createEffect(() => {
140
+ void col.length
141
+ })
164
142
  })
165
143
 
166
- expect(lastArray).toEqual([2, 4, 6])
167
- numbers.add(4)
168
- expect(lastArray).toEqual([2, 4, 6, 8])
144
+ expect(started).toBe(true)
145
+ expect(cleaned).toBe(false)
146
+
147
+ dispose()
148
+ expect(cleaned).toBe(true)
169
149
  })
170
150
 
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 }),
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] },
176
159
  )
177
160
 
178
- let lastItem: { count: number } | undefined
179
- let itemEffectRuns = 0
180
- createEffect(() => {
181
- lastItem = doubled.at(0)?.get()
182
- itemEffectRuns++
161
+ expect(started).toBe(false)
162
+
163
+ const dispose = createScope(() => {
164
+ createEffect(() => {
165
+ void Array.from(col.keys())
166
+ })
183
167
  })
184
168
 
185
- expect(lastItem).toEqual({ count: 2 })
186
- expect(itemEffectRuns).toBe(1)
169
+ expect(started).toBe(true)
187
170
 
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)
171
+ dispose()
192
172
  })
173
+ })
193
174
 
194
- test('updates are reactive', () => {
195
- const numbers = new List([1, 2, 3])
196
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
175
+ describe('applyChanges', () => {
176
+ test('should add items', () => {
177
+ let apply:
178
+ | ((changes: CollectionChanges<number>) => void)
179
+ | undefined
180
+ const col = createCollection<number>(applyChanges => {
181
+ apply = applyChanges
182
+ return () => {}
183
+ })
197
184
 
198
- let lastArray: number[] = []
199
- let arrayEffectRuns = 0
200
- createEffect(() => {
201
- lastArray = doubled.get()
202
- arrayEffectRuns++
185
+ const values: number[][] = []
186
+ const dispose = createScope(() => {
187
+ createEffect(() => {
188
+ values.push(col.get())
189
+ })
203
190
  })
204
191
 
205
- expect(lastArray).toEqual([2, 4, 6])
206
- expect(arrayEffectRuns).toBe(1)
192
+ expect(values).toEqual([[]])
207
193
 
208
- numbers.at(1)?.set(10)
209
- expect(lastArray).toEqual([2, 20, 6])
210
- expect(arrayEffectRuns).toBe(2)
211
- })
212
- })
194
+ // biome-ignore lint/style/noNonNullAssertion: test
195
+ apply!({ add: [1, 2] })
213
196
 
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]
197
+ expect(values.length).toBe(2)
198
+ expect(values[1]).toEqual([1, 2])
199
+ expect(col.length).toBe(2)
219
200
 
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)
201
+ dispose()
224
202
  })
225
203
 
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)
230
- })
231
- })
232
-
233
- describe('edge cases', () => {
234
- test('handles empty collections correctly', () => {
235
- const empty = new List<number>([])
236
- const collection = new DerivedCollection(
237
- empty,
238
- (x: number) => x * 2,
204
+ test('should change item values', () => {
205
+ let apply:
206
+ | ((
207
+ changes: CollectionChanges<{ id: string; val: number }>,
208
+ ) => void)
209
+ | undefined
210
+ const col = createCollection(
211
+ applyChanges => {
212
+ apply = applyChanges
213
+ return () => {}
214
+ },
215
+ {
216
+ value: [{ id: 'x', val: 1 }],
217
+ keyConfig: item => item.id,
218
+ },
239
219
  )
240
- expect(collection.length).toBe(0)
241
- expect(collection.get()).toEqual([])
242
- })
243
220
 
244
- test('handles UNSET values', () => {
245
- const list = new List([1, 2, 3])
246
- const processed = new DerivedCollection(list, (x: number) =>
247
- x > 2 ? x : UNSET,
248
- )
221
+ const values: { id: string; val: number }[][] = []
222
+ const dispose = createScope(() => {
223
+ createEffect(() => {
224
+ values.push(col.get())
225
+ })
226
+ })
249
227
 
250
- // UNSET values should be filtered out
251
- expect(processed.get()).toEqual([3])
252
- })
228
+ expect(values[0]).toEqual([{ id: 'x', val: 1 }])
253
229
 
254
- test('handles primitive values', () => {
255
- const list = new List(['hello', 'world'])
256
- const lengths = new DerivedCollection(list, (str: string) => ({
257
- length: str.length,
258
- }))
230
+ // biome-ignore lint/style/noNonNullAssertion: test
231
+ apply!({ change: [{ id: 'x', val: 42 }] })
259
232
 
260
- expect(lengths.at(0)?.get()).toEqual({ length: 5 })
261
- expect(lengths.at(1)?.get()).toEqual({ length: 5 })
262
- })
263
- })
233
+ expect(values.length).toBe(2)
234
+ expect(values[1]).toEqual([{ id: 'x', val: 42 }])
264
235
 
265
- describe('deriveCollection() method', () => {
266
- describe('synchronous transformations', () => {
267
- test('transforms collection values with sync callback', () => {
268
- const numbers = new List([1, 2, 3])
269
- const doubled = new DerivedCollection(
270
- numbers,
271
- (x: number) => x * 2,
272
- )
273
- const quadrupled = doubled.deriveCollection(
274
- (x: number) => x * 2,
275
- )
276
-
277
- expect(quadrupled.length).toBe(3)
278
- expect(quadrupled.at(0)?.get()).toBe(4)
279
- expect(quadrupled.at(1)?.get()).toBe(8)
280
- expect(quadrupled.at(2)?.get()).toBe(12)
281
- })
236
+ dispose()
237
+ })
282
238
 
283
- test('transforms object values with sync callback', () => {
284
- const users = new List([
285
- { name: 'Alice', age: 25 },
286
- { name: 'Bob', age: 30 },
287
- ])
288
- const basicInfo = new DerivedCollection(
289
- users,
290
- (user: { name: string; age: number }) => ({
291
- displayName: user.name.toUpperCase(),
292
- isAdult: user.age >= 18,
293
- }),
294
- )
295
- const detailedInfo = basicInfo.deriveCollection(
296
- (info: { displayName: string; isAdult: boolean }) => ({
297
- ...info,
298
- category: info.isAdult ? 'adult' : 'minor',
299
- }),
300
- )
239
+ test('should remove items', () => {
240
+ let apply:
241
+ | ((
242
+ changes: CollectionChanges<{ id: string; v: number }>,
243
+ ) => void)
244
+ | 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
+ )
301
259
 
302
- expect(detailedInfo.at(0)?.get()).toEqual({
303
- displayName: 'ALICE',
304
- isAdult: true,
305
- category: 'adult',
306
- })
307
- expect(detailedInfo.at(1)?.get()).toEqual({
308
- displayName: 'BOB',
309
- isAdult: true,
310
- category: 'adult',
260
+ const values: { id: string; v: number }[][] = []
261
+ const dispose = createScope(() => {
262
+ createEffect(() => {
263
+ values.push(col.get())
311
264
  })
312
265
  })
313
266
 
314
- test('transforms string values to different types', () => {
315
- const words = new List(['hello', 'world', 'test'])
316
- const wordInfo = new DerivedCollection(
317
- words,
318
- (word: string) => ({
319
- word,
320
- length: word.length,
321
- }),
322
- )
323
- const analysis = wordInfo.deriveCollection(
324
- (info: { word: string; length: number }) => ({
325
- ...info,
326
- isLong: info.length > 4,
327
- }),
328
- )
329
-
330
- expect(analysis.at(0)?.get().word).toBe('hello')
331
- expect(analysis.at(0)?.get().length).toBe(5)
332
- expect(analysis.at(0)?.get().isLong).toBe(true)
333
- expect(analysis.at(1)?.get().word).toBe('world')
334
- expect(analysis.at(1)?.get().length).toBe(5)
335
- expect(analysis.at(1)?.get().isLong).toBe(true)
336
- expect(analysis.at(2)?.get().word).toBe('test')
337
- expect(analysis.at(2)?.get().length).toBe(4)
338
- expect(analysis.at(2)?.get().isLong).toBe(false)
339
- })
267
+ expect(values[0]).toEqual([
268
+ { id: 'a', v: 1 },
269
+ { id: 'b', v: 2 },
270
+ { id: 'c', v: 3 },
271
+ ])
340
272
 
341
- test('derived collection reactivity with sync transformations', () => {
342
- const numbers = new List([1, 2, 3])
343
- const doubled = new DerivedCollection(
344
- numbers,
345
- (x: number) => x * 2,
346
- )
347
- const quadrupled = doubled.deriveCollection(
348
- (x: number) => x * 2,
349
- )
350
-
351
- let collectionValue: number[] = []
352
- let effectRuns = 0
353
- createEffect(() => {
354
- collectionValue = quadrupled.get()
355
- effectRuns++
356
- })
273
+ // biome-ignore lint/style/noNonNullAssertion: test
274
+ apply!({ remove: [{ id: 'b', v: 2 }] })
275
+
276
+ expect(values.length).toBe(2)
277
+ expect(values[1]).toEqual([
278
+ { id: 'a', v: 1 },
279
+ { id: 'c', v: 3 },
280
+ ])
281
+ expect(col.length).toBe(2)
357
282
 
358
- expect(collectionValue).toEqual([4, 8, 12])
359
- expect(effectRuns).toBe(1)
283
+ dispose()
284
+ })
360
285
 
361
- numbers.add(4)
362
- expect(collectionValue).toEqual([4, 8, 12, 16])
363
- expect(effectRuns).toBe(2)
286
+ test('should handle mixed add/change/remove', () => {
287
+ let apply:
288
+ | ((
289
+ changes: CollectionChanges<{ id: string; v: number }>,
290
+ ) => void)
291
+ | undefined
292
+ const col = createCollection(
293
+ applyChanges => {
294
+ apply = applyChanges
295
+ return () => {}
296
+ },
297
+ {
298
+ value: [
299
+ { id: 'a', v: 1 },
300
+ { id: 'b', v: 2 },
301
+ ],
302
+ keyConfig: item => item.id,
303
+ },
304
+ )
364
305
 
365
- numbers.at(1)?.set(5)
366
- expect(collectionValue).toEqual([4, 20, 12, 16])
367
- expect(effectRuns).toBe(3)
306
+ const values: { id: string; v: number }[][] = []
307
+ const dispose = createScope(() => {
308
+ createEffect(() => {
309
+ values.push(col.get())
310
+ })
368
311
  })
369
312
 
370
- test('derived collection responds to source removal', () => {
371
- const numbers = new List([1, 2, 3, 4])
372
- const doubled = new DerivedCollection(
373
- numbers,
374
- (x: number) => x * 2,
375
- )
376
- const quadrupled = doubled.deriveCollection(
377
- (x: number) => x * 2,
378
- )
313
+ // biome-ignore lint/style/noNonNullAssertion: test
314
+ apply!({
315
+ add: [{ id: 'c', v: 3 }],
316
+ change: [{ id: 'a', v: 10 }],
317
+ remove: [{ id: 'b', v: 2 }],
318
+ })
379
319
 
380
- expect(quadrupled.get()).toEqual([4, 8, 12, 16])
320
+ expect(values.length).toBe(2)
321
+ expect(values[1]).toEqual([
322
+ { id: 'a', v: 10 },
323
+ { id: 'c', v: 3 },
324
+ ])
381
325
 
382
- numbers.remove(1)
383
- expect(quadrupled.get()).toEqual([4, 12, 16])
384
- })
326
+ dispose()
385
327
  })
386
328
 
387
- describe('asynchronous transformations', () => {
388
- test('transforms values with async callback', async () => {
389
- const numbers = new List([1, 2, 3])
390
- const doubled = new DerivedCollection(
391
- numbers,
392
- (x: number) => x * 2,
393
- )
394
-
395
- const asyncQuadrupled = doubled.deriveCollection(
396
- async (x: number, abort: AbortSignal) => {
397
- await new Promise(resolve => setTimeout(resolve, 10))
398
- if (abort.aborted) throw new Error('Aborted')
399
- return x * 2
400
- },
401
- )
329
+ test('should skip when no changes provided', () => {
330
+ let apply:
331
+ | ((changes: CollectionChanges<number>) => void)
332
+ | undefined
333
+ const col = createCollection(
334
+ applyChanges => {
335
+ apply = applyChanges
336
+ return () => {}
337
+ },
338
+ { value: [1] },
339
+ )
402
340
 
403
- const initialLength = asyncQuadrupled.length
404
- expect(initialLength).toBe(3)
341
+ let callCount = 0
342
+ const dispose = createScope(() => {
343
+ createEffect(() => {
344
+ void col.get()
345
+ callCount++
346
+ })
347
+ })
405
348
 
406
- // Initially, async computations return UNSET
407
- expect(asyncQuadrupled.at(0)).toBeDefined()
408
- expect(asyncQuadrupled.at(1)).toBeDefined()
409
- expect(asyncQuadrupled.at(2)).toBeDefined()
349
+ expect(callCount).toBe(1)
410
350
 
411
- // Use effects to test async reactivity
412
- const results: number[] = []
413
- let effectRuns = 0
351
+ // biome-ignore lint/style/noNonNullAssertion: test
352
+ apply!({})
414
353
 
415
- createEffect(() => {
416
- const values = asyncQuadrupled.get()
417
- results.push(...values)
418
- effectRuns++
419
- })
354
+ expect(callCount).toBe(1)
420
355
 
421
- // Wait for async computations to complete
422
- await new Promise(resolve => setTimeout(resolve, 50))
356
+ dispose()
357
+ })
423
358
 
424
- // Should have received the computed values
425
- expect(results.slice(-3)).toEqual([4, 8, 12])
426
- expect(effectRuns).toBeGreaterThanOrEqual(1)
359
+ test('should trigger effects on structural changes', () => {
360
+ let apply:
361
+ | ((changes: CollectionChanges<string>) => void)
362
+ | undefined
363
+ const col = createCollection<string>(applyChanges => {
364
+ apply = applyChanges
365
+ return () => {}
427
366
  })
428
367
 
429
- test('async derived collection with object transformation', async () => {
430
- const users = new List([
431
- { id: 1, name: 'Alice' },
432
- { id: 2, name: 'Bob' },
433
- ])
434
- const basicInfo = new DerivedCollection(
435
- users,
436
- (user: { id: number; name: string }) => ({
437
- userId: user.id,
438
- displayName: user.name.toUpperCase(),
439
- }),
440
- )
441
-
442
- const enrichedUsers = basicInfo.deriveCollection(
443
- async (
444
- info: { userId: number; displayName: string },
445
- abort: AbortSignal,
446
- ) => {
447
- // Simulate async enrichment
448
- await new Promise(resolve => setTimeout(resolve, 10))
449
- if (abort.aborted) throw new Error('Aborted')
450
-
451
- return {
452
- ...info,
453
- slug: info.displayName
454
- .toLowerCase()
455
- .replace(/\s+/g, '-'),
456
- timestamp: Date.now(),
457
- }
458
- },
459
- )
460
-
461
- // Use effect to test async behavior
462
- let enrichedResults: Array<{
463
- userId: number
464
- displayName: string
465
- slug: string
466
- timestamp: number
467
- }> = []
468
-
368
+ let effectCount = 0
369
+ const dispose = createScope(() => {
469
370
  createEffect(() => {
470
- enrichedResults = enrichedUsers.get()
371
+ void col.length
372
+ effectCount++
471
373
  })
374
+ })
472
375
 
473
- // Wait for async computations to complete
474
- await new Promise(resolve => setTimeout(resolve, 50))
376
+ expect(effectCount).toBe(1)
475
377
 
476
- expect(enrichedResults).toHaveLength(2)
378
+ // biome-ignore lint/style/noNonNullAssertion: test
379
+ apply!({ add: ['hello'] })
477
380
 
478
- const [result1, result2] = enrichedResults
381
+ expect(effectCount).toBe(2)
382
+ expect(col.length).toBe(1)
479
383
 
480
- expect(result1?.userId).toBe(1)
481
- expect(result1?.displayName).toBe('ALICE')
482
- expect(result1?.slug).toBe('alice')
483
- expect(typeof result1?.timestamp).toBe('number')
384
+ dispose()
385
+ })
484
386
 
485
- expect(result2?.userId).toBe(2)
486
- expect(result2?.displayName).toBe('BOB')
487
- expect(result2?.slug).toBe('bob')
488
- expect(typeof result2?.timestamp).toBe('number')
387
+ test('should batch multiple calls', () => {
388
+ let apply:
389
+ | ((changes: CollectionChanges<number>) => void)
390
+ | undefined
391
+ const col = createCollection<number>(applyChanges => {
392
+ apply = applyChanges
393
+ return () => {}
489
394
  })
490
395
 
491
- test('async derived collection reactivity', async () => {
492
- const numbers = new List([1, 2, 3])
493
- const doubled = new DerivedCollection(
494
- numbers,
495
- (x: number) => x * 2,
496
- )
497
- const asyncQuadrupled = doubled.deriveCollection(
498
- async (x: number, abort: AbortSignal) => {
499
- await new Promise(resolve => setTimeout(resolve, 10))
500
- if (abort.aborted) throw new Error('Aborted')
501
- return x * 2
502
- },
503
- )
504
-
505
- const effectValues: number[][] = []
396
+ let effectCount = 0
397
+ const dispose = createScope(() => {
506
398
  createEffect(() => {
507
- // Access all values to trigger reactive behavior
508
- const currentValue = asyncQuadrupled.get()
509
- effectValues.push(currentValue)
399
+ void col.get()
400
+ effectCount++
510
401
  })
402
+ })
511
403
 
512
- // Wait for initial effect
513
- await new Promise(resolve => setTimeout(resolve, 50))
404
+ expect(effectCount).toBe(1)
514
405
 
515
- // Initial empty array (async values not resolved yet)
516
- expect(effectValues[0]).toEqual([])
406
+ batch(() => {
407
+ // biome-ignore lint/style/noNonNullAssertion: test
408
+ apply!({ add: [1] })
409
+ // biome-ignore lint/style/noNonNullAssertion: test
410
+ apply!({ add: [2] })
411
+ })
517
412
 
518
- // Trigger individual computations
519
- asyncQuadrupled.at(0)?.get()
520
- asyncQuadrupled.at(1)?.get()
521
- asyncQuadrupled.at(2)?.get()
413
+ expect(effectCount).toBe(2)
414
+ expect(col.get()).toEqual([1, 2])
522
415
 
523
- // Wait for effects to process
524
- await new Promise(resolve => setTimeout(resolve, 50))
416
+ dispose()
417
+ })
418
+ })
525
419
 
526
- // Should have the computed values now
527
- const lastValue = effectValues[effectValues.length - 1]
528
- expect(lastValue).toEqual([4, 8, 12])
529
- })
420
+ describe('deriveCollection', () => {
421
+ test('should transform list values with sync callback', () => {
422
+ const numbers = createList([1, 2, 3])
423
+ const doubled = numbers.deriveCollection((v: number) => v * 2)
530
424
 
531
- test('handles AbortSignal cancellation', async () => {
532
- const numbers = new List([1, 2, 3])
533
- const doubled = new DerivedCollection(
534
- numbers,
535
- (x: number) => x * 2,
536
- )
537
- let abortCalled = false
538
-
539
- const slowCollection = doubled.deriveCollection(
540
- async (x: number, abort: AbortSignal) => {
541
- abort.addEventListener('abort', () => {
542
- abortCalled = true
543
- })
544
-
545
- // Long delay to allow cancellation
546
- const timeout = new Promise(resolve =>
547
- setTimeout(resolve, 100),
548
- )
549
- await timeout
550
-
551
- if (abort.aborted) throw new Error('Aborted')
552
- return x * 2
553
- },
554
- )
555
-
556
- // Start computation
557
- const _awaited = slowCollection.at(0)?.get()
558
-
559
- // Change source to trigger cancellation
560
- numbers.at(0)?.set(10)
561
-
562
- // Wait for potential abort
563
- await new Promise(resolve => setTimeout(resolve, 50))
564
-
565
- expect(abortCalled).toBe(true)
566
- })
425
+ expect(doubled.get()).toEqual([2, 4, 6])
426
+ expect(doubled.length).toBe(3)
567
427
  })
568
428
 
569
- describe('derived collection chaining', () => {
570
- test('chains multiple sync derivations', () => {
571
- const numbers = new List([1, 2, 3])
572
- const doubled = new DerivedCollection(
573
- numbers,
574
- (x: number) => x * 2,
575
- )
576
- const quadrupled = doubled.deriveCollection(
577
- (x: number) => x * 2,
578
- )
579
- const octupled = quadrupled.deriveCollection(
580
- (x: number) => x * 2,
581
- )
582
-
583
- expect(octupled.at(0)?.get()).toBe(8)
584
- expect(octupled.at(1)?.get()).toBe(16)
585
- expect(octupled.at(2)?.get()).toBe(24)
586
- })
429
+ test('should transform values with async callback', async () => {
430
+ const numbers = createList([1, 2, 3])
431
+ const doubled = numbers.deriveCollection(
432
+ async (v: number, abort: AbortSignal) => {
433
+ await wait(10)
434
+ if (abort.aborted) throw new Error('Aborted')
435
+ return v * 2
436
+ },
437
+ )
587
438
 
588
- test('chains sync and async derivations', async () => {
589
- const numbers = new List([1, 2, 3])
590
- const doubled = new DerivedCollection(
591
- numbers,
592
- (x: number) => x * 2,
593
- )
594
- const quadrupled = doubled.deriveCollection(
595
- (x: number) => x * 2,
596
- )
597
-
598
- const asyncOctupled = quadrupled.deriveCollection(
599
- async (x: number, abort: AbortSignal) => {
600
- await new Promise(resolve => setTimeout(resolve, 10))
601
- if (abort.aborted) throw new Error('Aborted')
602
- return x * 2
603
- },
604
- )
605
-
606
- // Use effect to test chained async behavior
607
- let chainedResults: number[] = []
439
+ // Trigger computation
440
+ for (let i = 0; i < doubled.length; i++) {
441
+ try {
442
+ doubled.at(i)?.get()
443
+ } catch {
444
+ // UnsetSignalValueError before resolution
445
+ }
446
+ }
447
+
448
+ await wait(50)
449
+ expect(doubled.get()).toEqual([2, 4, 6])
450
+ })
608
451
 
609
- createEffect(() => {
610
- chainedResults = asyncOctupled.get()
611
- })
452
+ test('should handle empty source list', () => {
453
+ const empty = createList<number>([])
454
+ const doubled = empty.deriveCollection((v: number) => v * 2)
612
455
 
613
- // Wait for async computations to complete
614
- await new Promise(resolve => setTimeout(resolve, 50))
456
+ expect(doubled.get()).toEqual([])
457
+ expect(doubled.length).toBe(0)
458
+ })
615
459
 
616
- expect(chainedResults).toEqual([8, 16, 24])
617
- })
460
+ test('should return Signal at index', () => {
461
+ const list = createList([1, 2, 3])
462
+ const doubled = list.deriveCollection((v: number) => v * 2)
463
+
464
+ expect(doubled.at(0)?.get()).toBe(2)
465
+ expect(doubled.at(1)?.get()).toBe(4)
466
+ expect(doubled.at(2)?.get()).toBe(6)
467
+ expect(doubled.at(5)).toBeUndefined()
618
468
  })
619
469
 
620
- describe('derived collection access methods', () => {
621
- test('provides index-based access to computed signals', () => {
622
- const numbers = new List([1, 2, 3])
623
- const doubled = new DerivedCollection(
624
- numbers,
625
- (x: number) => x * 2,
626
- )
627
- const quadrupled = doubled.deriveCollection(
628
- (x: number) => x * 2,
629
- )
630
-
631
- expect(quadrupled.at(0)?.get()).toBe(4)
632
- expect(quadrupled.at(1)?.get()).toBe(8)
633
- expect(quadrupled.at(2)?.get()).toBe(12)
634
- expect(quadrupled.at(10)).toBeUndefined()
635
- })
470
+ test('should return Signal by source key', () => {
471
+ const list = createList([10, 20])
472
+ const doubled = list.deriveCollection((v: number) => v * 2)
636
473
 
637
- test('supports key-based access', () => {
638
- const numbers = new List([1, 2, 3])
639
- const doubled = new DerivedCollection(
640
- numbers,
641
- (x: number) => x * 2,
642
- )
643
- const quadrupled = doubled.deriveCollection(
644
- (x: number) => x * 2,
645
- )
646
-
647
- const key0 = quadrupled.keyAt(0)
648
- const key1 = quadrupled.keyAt(1)
649
-
650
- expect(key0).toBeDefined()
651
- expect(key1).toBeDefined()
652
- // biome-ignore lint/style/noNonNullAssertion: test
653
- expect(quadrupled.byKey(key0!)).toBeDefined()
654
- // biome-ignore lint/style/noNonNullAssertion: test
655
- expect(quadrupled.byKey(key1!)).toBeDefined()
656
- // biome-ignore lint/style/noNonNullAssertion: test
657
- expect(quadrupled.byKey(key0!)?.get()).toBe(4)
658
- // biome-ignore lint/style/noNonNullAssertion: test
659
- expect(quadrupled.byKey(key1!)?.get()).toBe(8)
660
- })
474
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
475
+ const key0 = list.keyAt(0)!
476
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
477
+ const key1 = list.keyAt(1)!
661
478
 
662
- test('supports iteration', () => {
663
- const numbers = new List([1, 2, 3])
664
- const doubled = new DerivedCollection(
665
- numbers,
666
- (x: number) => x * 2,
667
- )
668
- const quadrupled = doubled.deriveCollection(
669
- (x: number) => x * 2,
670
- )
671
-
672
- const signals = [...quadrupled]
673
- expect(signals).toHaveLength(3)
674
- expect(signals[0].get()).toBe(4)
675
- expect(signals[1].get()).toBe(8)
676
- expect(signals[2].get()).toBe(12)
677
- })
479
+ expect(doubled.byKey(key0)?.get()).toBe(20)
480
+ expect(doubled.byKey(key1)?.get()).toBe(40)
678
481
  })
679
482
 
680
- describe('edge cases', () => {
681
- test('handles empty collection derivation', () => {
682
- const empty = new List<number>([])
683
- const emptyCollection = new DerivedCollection(
684
- empty,
685
- (x: number) => x * 2,
686
- )
687
- const derived = emptyCollection.deriveCollection(
688
- (x: number) => x * 2,
689
- )
690
-
691
- expect(derived.length).toBe(0)
692
- expect(derived.get()).toEqual([])
693
- })
483
+ test('should support keyAt, indexOfKey, and keys', () => {
484
+ const list = createList([10, 20, 30])
485
+ const col = list.deriveCollection((v: number) => v)
694
486
 
695
- test('handles UNSET values in transformation', () => {
696
- const list = new List([1, 2, 3])
697
- const filtered = new DerivedCollection(list, (x: number) =>
698
- x > 1 ? { value: x } : UNSET,
699
- )
700
- const doubled = filtered.deriveCollection(
701
- (x: { value: number }) => ({ value: x.value * 2 }),
702
- )
487
+ const key0 = col.keyAt(0)
488
+ expect(key0).toBeDefined()
489
+ expect(typeof key0).toBe('string')
490
+ // biome-ignore lint/style/noNonNullAssertion: index is within bounds
491
+ expect(col.indexOfKey(key0!)).toBe(0)
492
+ expect([...col.keys()]).toHaveLength(3)
493
+ })
703
494
 
704
- expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
705
- })
495
+ test('should support for...of via Symbol.iterator', () => {
496
+ const list = createList([1, 2, 3])
497
+ const doubled = list.deriveCollection((v: number) => v * 2)
706
498
 
707
- test('handles complex object transformations', () => {
708
- const items = new List([
709
- { id: 1, data: { value: 10, active: true } },
710
- { id: 2, data: { value: 20, active: false } },
711
- ])
712
-
713
- const processed = new DerivedCollection(
714
- items,
715
- (item: {
716
- id: number
717
- data: { value: number; active: boolean }
718
- }) => ({
719
- itemId: item.id,
720
- processedValue: item.data.value * 2,
721
- status: item.data.active ? 'active' : 'inactive',
722
- }),
723
- )
724
-
725
- const enhanced = processed.deriveCollection(
726
- (item: {
727
- itemId: number
728
- processedValue: number
729
- status: string
730
- }) => ({
731
- ...item,
732
- category: item.processedValue > 15 ? 'high' : 'low',
733
- }),
734
- )
735
-
736
- expect(enhanced.at(0)?.get().itemId).toBe(1)
737
- expect(enhanced.at(0)?.get().processedValue).toBe(20)
738
- expect(enhanced.at(0)?.get().status).toBe('active')
739
- expect(enhanced.at(0)?.get().category).toBe('high')
740
- expect(enhanced.at(1)?.get().itemId).toBe(2)
741
- expect(enhanced.at(1)?.get().processedValue).toBe(40)
742
- expect(enhanced.at(1)?.get().status).toBe('inactive')
743
- expect(enhanced.at(1)?.get().category).toBe('high')
744
- })
499
+ const signals = [...doubled]
500
+ expect(signals).toHaveLength(3)
501
+ expect(signals[0].get()).toBe(2)
502
+ expect(signals[1].get()).toBe(4)
503
+ expect(signals[2].get()).toBe(6)
745
504
  })
746
- })
747
505
 
748
- describe('Watch Callbacks', () => {
749
- test('Collection watched callback is called when effect accesses collection.get()', () => {
750
- const numbers = new List([10, 20, 30])
506
+ test('should react to source additions', () => {
507
+ const list = createList([1, 2])
508
+ const doubled = list.deriveCollection((v: number) => v * 2)
751
509
 
752
- let collectionWatchedCalled = false
753
- let collectionUnwatchCalled = false
754
- const doubled = numbers.deriveCollection(x => x * 2, {
755
- watched: () => {
756
- collectionWatchedCalled = true
757
- },
758
- unwatched: () => {
759
- collectionUnwatchCalled = true
760
- },
510
+ let result: number[] = []
511
+ let effectCount = 0
512
+ createEffect(() => {
513
+ result = doubled.get()
514
+ effectCount++
761
515
  })
762
516
 
763
- expect(collectionWatchedCalled).toBe(false)
517
+ expect(result).toEqual([2, 4])
518
+ expect(effectCount).toBe(1)
764
519
 
765
- // Access collection via collection.get() - this should trigger collection's watched callback
766
- let effectValue: number[] = []
767
- const cleanup = createEffect(() => {
768
- effectValue = doubled.get()
769
- })
520
+ list.add(3)
521
+ expect(result).toEqual([2, 4, 6])
522
+ expect(effectCount).toBe(2)
523
+ })
770
524
 
771
- expect(collectionWatchedCalled).toBe(true)
772
- expect(effectValue).toEqual([20, 40, 60])
773
- expect(collectionUnwatchCalled).toBe(false)
525
+ test('should react to source removals', () => {
526
+ const list = createList([1, 2, 3])
527
+ const doubled = list.deriveCollection((v: number) => v * 2)
774
528
 
775
- // Cleanup effect - should trigger unwatch
776
- cleanup()
777
- expect(collectionUnwatchCalled).toBe(true)
529
+ expect(doubled.get()).toEqual([2, 4, 6])
530
+ list.remove(1)
531
+ expect(doubled.get()).toEqual([2, 6])
532
+ expect(doubled.length).toBe(2)
778
533
  })
779
534
 
780
- test('Collection and List watched callbacks work independently', () => {
781
- let sourceWatchedCalled = false
782
- const items = new List(['hello', 'world'], {
783
- watched: () => {
784
- sourceWatchedCalled = true
785
- },
535
+ test('should react to item mutations', () => {
536
+ const list = createList([1, 2])
537
+ const doubled = list.deriveCollection((v: number) => v * 2)
538
+
539
+ let result: number[] = []
540
+ createEffect(() => {
541
+ result = doubled.get()
786
542
  })
787
543
 
788
- let collectionWatchedCalled = false
789
- let collectionUnwatchedCalled = false
790
- const uppercased = items.deriveCollection(x => x.toUpperCase(), {
791
- watched: () => {
792
- collectionWatchedCalled = true
793
- },
794
- unwatched: () => {
795
- collectionUnwatchedCalled = true
544
+ expect(result).toEqual([2, 4])
545
+ list.at(0)?.set(5)
546
+ expect(result).toEqual([10, 4])
547
+ })
548
+
549
+ test('async collection should react to changes', async () => {
550
+ const list = createList([1, 2])
551
+ const doubled = list.deriveCollection(
552
+ async (v: number, abort: AbortSignal) => {
553
+ await wait(5)
554
+ if (abort.aborted) throw new Error('Aborted')
555
+ return v * 2
796
556
  },
797
- })
557
+ )
798
558
 
799
- // Effect 1: Access collection-level data - triggers both watched callbacks
800
- let collectionValue: string[] = []
801
- const collectionCleanup = createEffect(() => {
802
- collectionValue = uppercased.get()
559
+ const values: number[][] = []
560
+ createEffect(() => {
561
+ values.push([...doubled.get()])
803
562
  })
804
563
 
805
- expect(collectionWatchedCalled).toBe(true)
806
- expect(sourceWatchedCalled).toBe(true) // Source items accessed by collection.get()
807
- expect(collectionValue).toEqual(['HELLO', 'WORLD'])
564
+ await wait(20)
565
+ expect(values[values.length - 1]).toEqual([2, 4])
808
566
 
809
- // Effect 2: Access individual collection item independently
810
- let itemValue: string | undefined
811
- const itemCleanup = createEffect(() => {
812
- itemValue = uppercased.at(0)?.get()
813
- })
567
+ list.add(3)
568
+ await wait(20)
569
+ expect(values[values.length - 1]).toEqual([2, 4, 6])
570
+ })
814
571
 
815
- expect(itemValue).toBe('HELLO')
572
+ test('should chain from collection', () => {
573
+ const list = createList([1, 2, 3])
574
+ const doubled = list.deriveCollection((v: number) => v * 2)
575
+ const quadrupled = doubled.deriveCollection((v: number) => v * 2)
816
576
 
817
- // Clean up effects
818
- collectionCleanup()
819
- expect(collectionUnwatchedCalled).toBe(true)
577
+ expect(quadrupled.get()).toEqual([4, 8, 12])
820
578
 
821
- itemCleanup()
579
+ list.add(4)
580
+ expect(quadrupled.get()).toEqual([4, 8, 12, 16])
822
581
  })
823
582
 
824
- test('Collection length access triggers Collection watched callback', () => {
825
- const numbers = new List([1, 2, 3])
583
+ test('should chain from createCollection source', () => {
584
+ const col = createCollection(() => () => {}, { value: [1, 2, 3] })
585
+ const doubled = col.deriveCollection((v: number) => v * 2)
826
586
 
827
- let collectionWatchedCalled = false
828
- let collectionUnwatchedCalled = false
829
- const doubled = numbers.deriveCollection(x => x * 2, {
830
- watched: () => {
831
- collectionWatchedCalled = true
832
- },
833
- unwatched: () => {
834
- collectionUnwatchedCalled = true
835
- },
836
- })
587
+ expect(doubled.get()).toEqual([2, 4, 6])
588
+ expect(isCollection(doubled)).toBe(true)
589
+ })
837
590
 
838
- // Access via collection.length - this should trigger collection's watched callback
839
- let effectValue: number = 0
840
- const cleanup = createEffect(() => {
841
- effectValue = doubled.length
591
+ test('should propagate errors from per-item memos', () => {
592
+ const list = createList([1, 2, 3])
593
+ const mapped = list.deriveCollection((v: number) => {
594
+ if (v === 2) throw new Error('bad item')
595
+ return v * 2
842
596
  })
843
597
 
844
- expect(collectionWatchedCalled).toBe(true)
845
- expect(effectValue).toBe(3)
846
- expect(collectionUnwatchedCalled).toBe(false)
847
-
848
- cleanup()
849
- expect(collectionUnwatchedCalled).toBe(true)
598
+ expect(() => mapped.get()).toThrow('bad item')
850
599
  })
851
600
  })
852
601
  })