@zeix/cause-effect 0.17.3 → 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 (89) hide show
  1. package/.ai-context.md +163 -232
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/ARCHITECTURE.md +274 -0
  5. package/CLAUDE.md +199 -143
  6. package/COLLECTION_REFACTORING.md +161 -0
  7. package/GUIDE.md +298 -0
  8. package/README.md +232 -197
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/index.dev.js +1325 -997
  12. package/index.js +1 -1
  13. package/index.ts +58 -74
  14. package/package.json +4 -1
  15. package/src/errors.ts +118 -74
  16. package/src/graph.ts +601 -0
  17. package/src/nodes/collection.ts +474 -0
  18. package/src/nodes/effect.ts +149 -0
  19. package/src/nodes/list.ts +588 -0
  20. package/src/nodes/memo.ts +120 -0
  21. package/src/nodes/sensor.ts +139 -0
  22. package/src/nodes/state.ts +135 -0
  23. package/src/nodes/store.ts +383 -0
  24. package/src/nodes/task.ts +146 -0
  25. package/src/signal.ts +112 -66
  26. package/src/util.ts +26 -57
  27. package/test/batch.test.ts +96 -62
  28. package/test/benchmark.test.ts +473 -487
  29. package/test/collection.test.ts +466 -706
  30. package/test/effect.test.ts +293 -696
  31. package/test/list.test.ts +335 -592
  32. package/test/memo.test.ts +380 -0
  33. package/test/regression.test.ts +156 -0
  34. package/test/scope.test.ts +191 -0
  35. package/test/sensor.test.ts +454 -0
  36. package/test/signal.test.ts +220 -213
  37. package/test/state.test.ts +217 -265
  38. package/test/store.test.ts +346 -446
  39. package/test/task.test.ts +395 -0
  40. package/test/untrack.test.ts +167 -0
  41. package/types/index.d.ts +13 -15
  42. package/types/src/errors.d.ts +73 -17
  43. package/types/src/graph.d.ts +208 -0
  44. package/types/src/nodes/collection.d.ts +64 -0
  45. package/types/src/nodes/effect.d.ts +48 -0
  46. package/types/src/nodes/list.d.ts +65 -0
  47. package/types/src/nodes/memo.d.ts +57 -0
  48. package/types/src/nodes/sensor.d.ts +75 -0
  49. package/types/src/nodes/state.d.ts +78 -0
  50. package/types/src/nodes/store.d.ts +51 -0
  51. package/types/src/nodes/task.d.ts +73 -0
  52. package/types/src/signal.d.ts +43 -29
  53. package/types/src/util.d.ts +9 -16
  54. package/archive/benchmark.ts +0 -683
  55. package/archive/collection.ts +0 -253
  56. package/archive/composite.ts +0 -85
  57. package/archive/computed.ts +0 -195
  58. package/archive/list.ts +0 -483
  59. package/archive/memo.ts +0 -139
  60. package/archive/state.ts +0 -90
  61. package/archive/store.ts +0 -298
  62. package/archive/task.ts +0 -189
  63. package/src/classes/collection.ts +0 -245
  64. package/src/classes/computed.ts +0 -349
  65. package/src/classes/list.ts +0 -343
  66. package/src/classes/ref.ts +0 -70
  67. package/src/classes/state.ts +0 -102
  68. package/src/classes/store.ts +0 -262
  69. package/src/diff.ts +0 -138
  70. package/src/effect.ts +0 -93
  71. package/src/match.ts +0 -45
  72. package/src/resolve.ts +0 -49
  73. package/src/system.ts +0 -257
  74. package/test/computed.test.ts +0 -1108
  75. package/test/diff.test.ts +0 -955
  76. package/test/match.test.ts +0 -388
  77. package/test/ref.test.ts +0 -353
  78. package/test/resolve.test.ts +0 -154
  79. package/types/src/classes/collection.d.ts +0 -45
  80. package/types/src/classes/computed.d.ts +0 -94
  81. package/types/src/classes/list.d.ts +0 -43
  82. package/types/src/classes/ref.d.ts +0 -35
  83. package/types/src/classes/state.d.ts +0 -49
  84. package/types/src/classes/store.d.ts +0 -52
  85. package/types/src/diff.d.ts +0 -28
  86. package/types/src/effect.d.ts +0 -15
  87. package/types/src/match.d.ts +0 -21
  88. package/types/src/resolve.d.ts +0 -29
  89. package/types/src/system.d.ts +0 -78
@@ -1,852 +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
- )
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: (_key, 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
+ )
135
+
136
+ expect(started).toBe(false)
160
137
 
161
- let lastArray: number[] = []
162
- createEffect(() => {
163
- lastArray = doubled.get()
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)
197
-
198
- let lastArray: number[] = []
199
- let arrayEffectRuns = 0
200
- createEffect(() => {
201
- lastArray = doubled.get()
202
- arrayEffectRuns++
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 () => {}
203
181
  })
204
182
 
205
- expect(lastArray).toEqual([2, 4, 6])
206
- expect(arrayEffectRuns).toBe(1)
183
+ const values: number[][] = []
184
+ const dispose = createScope(() => {
185
+ createEffect(() => {
186
+ values.push(col.get())
187
+ })
188
+ })
207
189
 
208
- numbers.at(1)?.set(10)
209
- expect(lastArray).toEqual([2, 20, 6])
210
- expect(arrayEffectRuns).toBe(2)
211
- })
212
- })
190
+ expect(values).toEqual([[]])
213
191
 
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]
192
+ // biome-ignore lint/style/noNonNullAssertion: test
193
+ apply!({
194
+ changed: true,
195
+ add: { a: 1, b: 2 },
196
+ change: {},
197
+ remove: {},
198
+ })
219
199
 
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)
224
- })
200
+ expect(values.length).toBe(2)
201
+ expect(values[1]).toEqual([1, 2])
202
+ expect(col.length).toBe(2)
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)
204
+ dispose()
230
205
  })
231
- })
232
206
 
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,
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
+ },
239
218
  )
240
- expect(collection.length).toBe(0)
241
- expect(collection.get()).toEqual([])
242
- })
243
219
 
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
- )
220
+ const values: { id: string; val: number }[][] = []
221
+ const dispose = createScope(() => {
222
+ createEffect(() => {
223
+ values.push(col.get())
224
+ })
225
+ })
249
226
 
250
- // UNSET values should be filtered out
251
- expect(processed.get()).toEqual([3])
252
- })
227
+ expect(values[0]).toEqual([{ id: 'x', val: 1 }])
253
228
 
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
- }))
229
+ // biome-ignore lint/style/noNonNullAssertion: test
230
+ apply!({
231
+ changed: true,
232
+ add: {},
233
+ change: { x: { id: 'x', val: 42 } },
234
+ remove: {},
235
+ })
259
236
 
260
- expect(lengths.at(0)?.get()).toEqual({ length: 5 })
261
- expect(lengths.at(1)?.get()).toEqual({ length: 5 })
262
- })
263
- })
237
+ expect(values.length).toBe(2)
238
+ expect(values[1]).toEqual([{ id: 'x', val: 42 }])
264
239
 
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
- })
240
+ dispose()
241
+ })
282
242
 
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
- )
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
+ )
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)
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 },
339
279
  })
340
280
 
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
- })
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)
357
287
 
358
- expect(collectionValue).toEqual([4, 8, 12])
359
- expect(effectRuns).toBe(1)
288
+ dispose()
289
+ })
360
290
 
361
- numbers.add(4)
362
- expect(collectionValue).toEqual([4, 8, 12, 16])
363
- expect(effectRuns).toBe(2)
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
+ )
364
306
 
365
- numbers.at(1)?.set(5)
366
- expect(collectionValue).toEqual([4, 20, 12, 16])
367
- expect(effectRuns).toBe(3)
307
+ const values: { id: string; v: number }[][] = []
308
+ const dispose = createScope(() => {
309
+ createEffect(() => {
310
+ values.push(col.get())
311
+ })
368
312
  })
369
313
 
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
- )
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
+ })
379
321
 
380
- expect(quadrupled.get()).toEqual([4, 8, 12, 16])
322
+ expect(values.length).toBe(2)
323
+ expect(values[1]).toEqual([
324
+ { id: 'a', v: 10 },
325
+ { id: 'c', v: 3 },
326
+ ])
381
327
 
382
- numbers.remove(1)
383
- expect(quadrupled.get()).toEqual([4, 12, 16])
384
- })
328
+ dispose()
385
329
  })
386
330
 
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
- )
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
+ )
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!({ changed: false, add: {}, change: {}, remove: {} })
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: ((changes: DiffResult) => void) | undefined
361
+ const col = createCollection<string>(applyChanges => {
362
+ apply = applyChanges
363
+ return () => {}
427
364
  })
428
365
 
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
-
366
+ let effectCount = 0
367
+ const dispose = createScope(() => {
469
368
  createEffect(() => {
470
- enrichedResults = enrichedUsers.get()
369
+ void col.length
370
+ effectCount++
471
371
  })
372
+ })
472
373
 
473
- // Wait for async computations to complete
474
- await new Promise(resolve => setTimeout(resolve, 50))
374
+ expect(effectCount).toBe(1)
475
375
 
476
- expect(enrichedResults).toHaveLength(2)
376
+ // biome-ignore lint/style/noNonNullAssertion: test
377
+ apply!({
378
+ changed: true,
379
+ add: { a: 'hello' },
380
+ change: {},
381
+ remove: {},
382
+ })
477
383
 
478
- const [result1, result2] = enrichedResults
384
+ expect(effectCount).toBe(2)
385
+ expect(col.length).toBe(1)
479
386
 
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')
387
+ dispose()
388
+ })
484
389
 
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')
390
+ test('should batch multiple calls', () => {
391
+ let apply: ((changes: DiffResult) => void) | undefined
392
+ const col = createCollection<number>(applyChanges => {
393
+ apply = applyChanges
394
+ return () => {}
489
395
  })
490
396
 
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[][] = []
397
+ let effectCount = 0
398
+ const dispose = createScope(() => {
506
399
  createEffect(() => {
507
- // Access all values to trigger reactive behavior
508
- const currentValue = asyncQuadrupled.get()
509
- effectValues.push(currentValue)
400
+ void col.get()
401
+ effectCount++
510
402
  })
403
+ })
511
404
 
512
- // Wait for initial effect
513
- await new Promise(resolve => setTimeout(resolve, 50))
405
+ expect(effectCount).toBe(1)
514
406
 
515
- // Initial empty array (async values not resolved yet)
516
- expect(effectValues[0]).toEqual([])
407
+ batch(() => {
408
+ // biome-ignore lint/style/noNonNullAssertion: test
409
+ apply!({
410
+ changed: true,
411
+ add: { a: 1 },
412
+ change: {},
413
+ remove: {},
414
+ })
415
+ // biome-ignore lint/style/noNonNullAssertion: test
416
+ apply!({
417
+ changed: true,
418
+ add: { b: 2 },
419
+ change: {},
420
+ remove: {},
421
+ })
422
+ })
517
423
 
518
- // Trigger individual computations
519
- asyncQuadrupled.at(0)?.get()
520
- asyncQuadrupled.at(1)?.get()
521
- asyncQuadrupled.at(2)?.get()
424
+ expect(effectCount).toBe(2)
425
+ expect(col.get()).toEqual([1, 2])
522
426
 
523
- // Wait for effects to process
524
- await new Promise(resolve => setTimeout(resolve, 50))
427
+ dispose()
428
+ })
429
+ })
525
430
 
526
- // Should have the computed values now
527
- const lastValue = effectValues[effectValues.length - 1]
528
- expect(lastValue).toEqual([4, 8, 12])
529
- })
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)
530
435
 
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
- })
436
+ expect(doubled.get()).toEqual([2, 4, 6])
437
+ expect(doubled.length).toBe(3)
567
438
  })
568
439
 
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
- })
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
+ )
587
449
 
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[] = []
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
456
+ }
457
+ }
458
+
459
+ await wait(50)
460
+ expect(doubled.get()).toEqual([2, 4, 6])
461
+ })
608
462
 
609
- createEffect(() => {
610
- chainedResults = asyncOctupled.get()
611
- })
463
+ test('should handle empty source list', () => {
464
+ const empty = createList<number>([])
465
+ const doubled = empty.deriveCollection((v: number) => v * 2)
612
466
 
613
- // Wait for async computations to complete
614
- await new Promise(resolve => setTimeout(resolve, 50))
467
+ expect(doubled.get()).toEqual([])
468
+ expect(doubled.length).toBe(0)
469
+ })
615
470
 
616
- expect(chainedResults).toEqual([8, 16, 24])
617
- })
471
+ test('should return Signal at index', () => {
472
+ const list = createList([1, 2, 3])
473
+ const doubled = list.deriveCollection((v: number) => v * 2)
474
+
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()
618
479
  })
619
480
 
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
- })
481
+ test('should return Signal by source key', () => {
482
+ const list = createList([10, 20])
483
+ const doubled = list.deriveCollection((v: number) => v * 2)
636
484
 
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
- })
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)!
661
489
 
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
- })
490
+ expect(doubled.byKey(key0)?.get()).toBe(20)
491
+ expect(doubled.byKey(key1)?.get()).toBe(40)
678
492
  })
679
493
 
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
- })
494
+ test('should support keyAt, indexOfKey, and keys', () => {
495
+ const list = createList([10, 20, 30])
496
+ const col = list.deriveCollection((v: number) => v)
694
497
 
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
- )
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
+ })
703
505
 
704
- expect(doubled.get()).toEqual([{ value: 4 }, { value: 6 }])
705
- })
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)
706
509
 
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
- })
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)
745
515
  })
746
- })
747
516
 
748
- describe('Watch Callbacks', () => {
749
- test('Collection watched callback is called when effect accesses collection.get()', () => {
750
- const numbers = new List([10, 20, 30])
517
+ test('should react to source additions', () => {
518
+ const list = createList([1, 2])
519
+ const doubled = list.deriveCollection((v: number) => v * 2)
751
520
 
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
- },
521
+ let result: number[] = []
522
+ let effectCount = 0
523
+ createEffect(() => {
524
+ result = doubled.get()
525
+ effectCount++
761
526
  })
762
527
 
763
- expect(collectionWatchedCalled).toBe(false)
528
+ expect(result).toEqual([2, 4])
529
+ expect(effectCount).toBe(1)
764
530
 
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
- })
531
+ list.add(3)
532
+ expect(result).toEqual([2, 4, 6])
533
+ expect(effectCount).toBe(2)
534
+ })
770
535
 
771
- expect(collectionWatchedCalled).toBe(true)
772
- expect(effectValue).toEqual([20, 40, 60])
773
- expect(collectionUnwatchCalled).toBe(false)
536
+ test('should react to source removals', () => {
537
+ const list = createList([1, 2, 3])
538
+ const doubled = list.deriveCollection((v: number) => v * 2)
774
539
 
775
- // Cleanup effect - should trigger unwatch
776
- cleanup()
777
- expect(collectionUnwatchCalled).toBe(true)
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)
778
544
  })
779
545
 
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
- },
546
+ test('should react to item mutations', () => {
547
+ const list = createList([1, 2])
548
+ const doubled = list.deriveCollection((v: number) => v * 2)
549
+
550
+ let result: number[] = []
551
+ createEffect(() => {
552
+ result = doubled.get()
786
553
  })
787
554
 
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
555
+ expect(result).toEqual([2, 4])
556
+ list.at(0)?.set(5)
557
+ expect(result).toEqual([10, 4])
558
+ })
559
+
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
796
567
  },
797
- })
568
+ )
798
569
 
799
- // Effect 1: Access collection-level data - triggers both watched callbacks
800
- let collectionValue: string[] = []
801
- const collectionCleanup = createEffect(() => {
802
- collectionValue = uppercased.get()
570
+ const values: number[][] = []
571
+ createEffect(() => {
572
+ values.push([...doubled.get()])
803
573
  })
804
574
 
805
- expect(collectionWatchedCalled).toBe(true)
806
- expect(sourceWatchedCalled).toBe(true) // Source items accessed by collection.get()
807
- expect(collectionValue).toEqual(['HELLO', 'WORLD'])
575
+ await wait(20)
576
+ expect(values[values.length - 1]).toEqual([2, 4])
808
577
 
809
- // Effect 2: Access individual collection item independently
810
- let itemValue: string | undefined
811
- const itemCleanup = createEffect(() => {
812
- itemValue = uppercased.at(0)?.get()
813
- })
578
+ list.add(3)
579
+ await wait(20)
580
+ expect(values[values.length - 1]).toEqual([2, 4, 6])
581
+ })
814
582
 
815
- expect(itemValue).toBe('HELLO')
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)
816
587
 
817
- // Clean up effects
818
- collectionCleanup()
819
- expect(collectionUnwatchedCalled).toBe(true)
588
+ expect(quadrupled.get()).toEqual([4, 8, 12])
820
589
 
821
- itemCleanup()
590
+ list.add(4)
591
+ expect(quadrupled.get()).toEqual([4, 8, 12, 16])
822
592
  })
823
593
 
824
- test('Collection length access triggers Collection watched callback', () => {
825
- const numbers = new List([1, 2, 3])
594
+ test('should chain from createCollection source', () => {
595
+ const col = createCollection(() => () => {}, { value: [1, 2, 3] })
596
+ const doubled = col.deriveCollection((v: number) => v * 2)
826
597
 
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
- })
598
+ expect(doubled.get()).toEqual([2, 4, 6])
599
+ expect(isCollection(doubled)).toBe(true)
600
+ })
837
601
 
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
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
842
607
  })
843
608
 
844
- expect(collectionWatchedCalled).toBe(true)
845
- expect(effectValue).toBe(3)
846
- expect(collectionUnwatchedCalled).toBe(false)
847
-
848
- cleanup()
849
- expect(collectionUnwatchedCalled).toBe(true)
609
+ expect(() => mapped.get()).toThrow('bad item')
850
610
  })
851
611
  })
852
612
  })