@zeix/cause-effect 0.17.0 → 0.17.2

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 (50) hide show
  1. package/.ai-context.md +26 -5
  2. package/.cursorrules +8 -3
  3. package/.github/copilot-instructions.md +13 -4
  4. package/CLAUDE.md +191 -262
  5. package/README.md +268 -420
  6. package/archive/collection.ts +23 -25
  7. package/archive/computed.ts +5 -4
  8. package/archive/list.ts +21 -28
  9. package/archive/memo.ts +4 -2
  10. package/archive/state.ts +2 -1
  11. package/archive/store.ts +21 -32
  12. package/archive/task.ts +6 -9
  13. package/index.dev.js +411 -220
  14. package/index.js +1 -1
  15. package/index.ts +25 -8
  16. package/package.json +1 -1
  17. package/src/classes/collection.ts +103 -77
  18. package/src/classes/composite.ts +28 -33
  19. package/src/classes/computed.ts +90 -31
  20. package/src/classes/list.ts +39 -33
  21. package/src/classes/ref.ts +96 -0
  22. package/src/classes/state.ts +41 -8
  23. package/src/classes/store.ts +47 -30
  24. package/src/diff.ts +2 -1
  25. package/src/effect.ts +19 -9
  26. package/src/errors.ts +31 -1
  27. package/src/match.ts +5 -12
  28. package/src/resolve.ts +3 -2
  29. package/src/signal.ts +0 -1
  30. package/src/system.ts +159 -43
  31. package/src/util.ts +0 -10
  32. package/test/collection.test.ts +383 -67
  33. package/test/computed.test.ts +268 -11
  34. package/test/effect.test.ts +2 -2
  35. package/test/list.test.ts +249 -21
  36. package/test/ref.test.ts +381 -0
  37. package/test/state.test.ts +13 -13
  38. package/test/store.test.ts +473 -28
  39. package/types/index.d.ts +6 -5
  40. package/types/src/classes/collection.d.ts +27 -12
  41. package/types/src/classes/composite.d.ts +4 -4
  42. package/types/src/classes/computed.d.ts +17 -0
  43. package/types/src/classes/list.d.ts +6 -6
  44. package/types/src/classes/ref.d.ts +48 -0
  45. package/types/src/classes/state.d.ts +9 -0
  46. package/types/src/classes/store.d.ts +4 -4
  47. package/types/src/effect.d.ts +1 -2
  48. package/types/src/errors.d.ts +9 -1
  49. package/types/src/system.d.ts +40 -24
  50. package/types/src/util.d.ts +1 -3
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- Collection,
4
3
  createEffect,
5
4
  createStore,
5
+ DerivedCollection,
6
6
  isCollection,
7
7
  List,
8
8
  UNSET,
@@ -12,7 +12,7 @@ describe('collection', () => {
12
12
  describe('creation and basic operations', () => {
13
13
  test('creates collection with initial values from list', () => {
14
14
  const numbers = new List([1, 2, 3])
15
- const doubled = new Collection(
15
+ const doubled = new DerivedCollection(
16
16
  numbers,
17
17
  (value: number) => value * 2,
18
18
  )
@@ -24,7 +24,7 @@ describe('collection', () => {
24
24
  })
25
25
 
26
26
  test('creates collection from function source', () => {
27
- const doubled = new Collection(
27
+ const doubled = new DerivedCollection(
28
28
  () => new List([10, 20, 30]),
29
29
  (value: number) => value * 2,
30
30
  )
@@ -37,7 +37,7 @@ describe('collection', () => {
37
37
 
38
38
  test('has Symbol.toStringTag of Collection', () => {
39
39
  const list = new List([1, 2, 3])
40
- const collection = new Collection(list, (x: number) => x)
40
+ const collection = new DerivedCollection(list, (x: number) => x)
41
41
  expect(Object.prototype.toString.call(collection)).toBe(
42
42
  '[object Collection]',
43
43
  )
@@ -46,7 +46,7 @@ describe('collection', () => {
46
46
  test('isCollection identifies collection instances correctly', () => {
47
47
  const store = createStore({ a: 1 })
48
48
  const list = new List([1, 2, 3])
49
- const collection = new Collection(list, (x: number) => x)
49
+ const collection = new DerivedCollection(list, (x: number) => x)
50
50
 
51
51
  expect(isCollection(collection)).toBe(true)
52
52
  expect(isCollection(list)).toBe(false)
@@ -57,7 +57,7 @@ describe('collection', () => {
57
57
 
58
58
  test('get() returns the complete collection value', () => {
59
59
  const numbers = new List([1, 2, 3])
60
- const doubled = new Collection(
60
+ const doubled = new DerivedCollection(
61
61
  numbers,
62
62
  (value: number) => value * 2,
63
63
  )
@@ -71,13 +71,19 @@ describe('collection', () => {
71
71
  describe('length property and sizing', () => {
72
72
  test('length property works for collections', () => {
73
73
  const numbers = new List([1, 2, 3, 4, 5])
74
- const collection = new Collection(numbers, (x: number) => x * 2)
74
+ const collection = new DerivedCollection(
75
+ numbers,
76
+ (x: number) => x * 2,
77
+ )
75
78
  expect(collection.length).toBe(5)
76
79
  })
77
80
 
78
81
  test('length is reactive and updates with changes', () => {
79
82
  const items = new List([1, 2])
80
- const collection = new Collection(items, (x: number) => x * 2)
83
+ const collection = new DerivedCollection(
84
+ items,
85
+ (x: number) => x * 2,
86
+ )
81
87
 
82
88
  expect(collection.length).toBe(2)
83
89
  items.add(3)
@@ -88,7 +94,7 @@ describe('collection', () => {
88
94
  describe('index-based access', () => {
89
95
  test('properties can be accessed via computed signals', () => {
90
96
  const items = new List([10, 20, 30])
91
- const doubled = new Collection(items, (x: number) => x * 2)
97
+ const doubled = new DerivedCollection(items, (x: number) => x * 2)
92
98
 
93
99
  expect(doubled.at(0)?.get()).toBe(20)
94
100
  expect(doubled.at(1)?.get()).toBe(40)
@@ -97,13 +103,16 @@ describe('collection', () => {
97
103
 
98
104
  test('returns undefined for non-existent properties', () => {
99
105
  const items = new List([1, 2])
100
- const collection = new Collection(items, (x: number) => x)
106
+ const collection = new DerivedCollection(items, (x: number) => x)
101
107
  expect(collection[5]).toBeUndefined()
102
108
  })
103
109
 
104
110
  test('supports numeric key access', () => {
105
111
  const numbers = new List([1, 2, 3])
106
- const collection = new Collection(numbers, (x: number) => x * 2)
112
+ const collection = new DerivedCollection(
113
+ numbers,
114
+ (x: number) => x * 2,
115
+ )
107
116
  expect(collection.at(1)?.get()).toBe(4)
108
117
  })
109
118
  })
@@ -111,7 +120,7 @@ describe('collection', () => {
111
120
  describe('key-based access methods', () => {
112
121
  test('byKey() returns computed signal for existing keys', () => {
113
122
  const numbers = new List([1, 2, 3])
114
- const doubled = new Collection(numbers, (x: number) => x * 2)
123
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
115
124
 
116
125
  const key0 = numbers.keyAt(0)
117
126
  const key1 = numbers.keyAt(1)
@@ -130,7 +139,7 @@ describe('collection', () => {
130
139
 
131
140
  test('keyAt() and indexOfKey() work correctly', () => {
132
141
  const numbers = new List([5, 10, 15])
133
- const doubled = new Collection(numbers, (x: number) => x * 2)
142
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
134
143
 
135
144
  const key0 = doubled.keyAt(0)
136
145
  const key1 = doubled.keyAt(1)
@@ -147,7 +156,7 @@ describe('collection', () => {
147
156
  describe('reactivity', () => {
148
157
  test('collection-level get() is reactive', () => {
149
158
  const numbers = new List([1, 2, 3])
150
- const doubled = new Collection(numbers, (x: number) => x * 2)
159
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
151
160
 
152
161
  let lastArray: number[] = []
153
162
  createEffect(() => {
@@ -161,7 +170,7 @@ describe('collection', () => {
161
170
 
162
171
  test('individual signal reactivity works', () => {
163
172
  const items = new List([{ count: 1 }, { count: 2 }])
164
- const doubled = new Collection(
173
+ const doubled = new DerivedCollection(
165
174
  items,
166
175
  (item: { count: number }) => ({ count: item.count * 2 }),
167
176
  )
@@ -184,7 +193,7 @@ describe('collection', () => {
184
193
 
185
194
  test('updates are reactive', () => {
186
195
  const numbers = new List([1, 2, 3])
187
- const doubled = new Collection(numbers, (x: number) => x * 2)
196
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
188
197
 
189
198
  let lastArray: number[] = []
190
199
  let arrayEffectRuns = 0
@@ -205,7 +214,7 @@ describe('collection', () => {
205
214
  describe('iteration and spreading', () => {
206
215
  test('supports for...of iteration', () => {
207
216
  const numbers = new List([1, 2, 3])
208
- const doubled = new Collection(numbers, (x: number) => x * 2)
217
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
209
218
  const signals = [...doubled]
210
219
 
211
220
  expect(signals).toHaveLength(3)
@@ -216,51 +225,51 @@ describe('collection', () => {
216
225
 
217
226
  test('Symbol.isConcatSpreadable is true', () => {
218
227
  const numbers = new List([1, 2, 3])
219
- const collection = new Collection(numbers, (x: number) => x)
228
+ const collection = new DerivedCollection(numbers, (x: number) => x)
220
229
  expect(collection[Symbol.isConcatSpreadable]).toBe(true)
221
230
  })
222
231
  })
223
232
 
224
- describe('change notifications', () => {
225
- test('emits add notifications', () => {
233
+ describe('Hooks', () => {
234
+ test('triggers HOOK_ADD when items are added', () => {
226
235
  const numbers = new List([1, 2])
227
- const doubled = new Collection(numbers, (x: number) => x * 2)
236
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
228
237
 
229
- let arrayAddNotification: readonly string[] = []
238
+ let addedKeys: readonly string[] | undefined
230
239
  doubled.on('add', keys => {
231
- arrayAddNotification = keys
240
+ addedKeys = keys
232
241
  })
233
242
 
234
243
  numbers.add(3)
235
- expect(arrayAddNotification).toHaveLength(1)
236
- // biome-ignore lint/style/noNonNullAssertion: test
237
- expect(doubled.byKey(arrayAddNotification[0]!)?.get()).toBe(6)
244
+ expect(addedKeys).toHaveLength(1)
245
+ const doubledKey = addedKeys?.[0]
246
+ if (doubledKey) expect(doubled.byKey(doubledKey)?.get()).toBe(6)
238
247
  })
239
248
 
240
- test('emits remove notifications when items are removed', () => {
249
+ test('triggers HOOK_REMOVE when items are removed', () => {
241
250
  const items = new List([1, 2, 3])
242
- const doubled = new Collection(items, (x: number) => x * 2)
251
+ const doubled = new DerivedCollection(items, (x: number) => x * 2)
243
252
 
244
- let arrayRemoveNotification: readonly string[] = []
253
+ let removedKeys: readonly string[] | undefined
245
254
  doubled.on('remove', keys => {
246
- arrayRemoveNotification = keys
255
+ removedKeys = keys
247
256
  })
248
257
 
249
258
  items.remove(1)
250
- expect(arrayRemoveNotification).toHaveLength(1)
259
+ expect(removedKeys).toHaveLength(1)
251
260
  })
252
261
 
253
- test('emits sort notifications when source is sorted', () => {
262
+ test('triggers HOOK_SORT when source is sorted', () => {
254
263
  const numbers = new List([3, 1, 2])
255
- const doubled = new Collection(numbers, (x: number) => x * 2)
264
+ const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
256
265
 
257
- let sortNotification: readonly string[] = []
266
+ let order: readonly string[] | undefined
258
267
  doubled.on('sort', newOrder => {
259
- sortNotification = newOrder
268
+ order = newOrder
260
269
  })
261
270
 
262
271
  numbers.sort((a, b) => a - b)
263
- expect(sortNotification).toHaveLength(3)
272
+ expect(order).toHaveLength(3)
264
273
  expect(doubled.get()).toEqual([2, 4, 6])
265
274
  })
266
275
  })
@@ -268,14 +277,17 @@ describe('collection', () => {
268
277
  describe('edge cases', () => {
269
278
  test('handles empty collections correctly', () => {
270
279
  const empty = new List<number>([])
271
- const collection = new Collection(empty, (x: number) => x * 2)
280
+ const collection = new DerivedCollection(
281
+ empty,
282
+ (x: number) => x * 2,
283
+ )
272
284
  expect(collection.length).toBe(0)
273
285
  expect(collection.get()).toEqual([])
274
286
  })
275
287
 
276
288
  test('handles UNSET values', () => {
277
289
  const list = new List([1, 2, 3])
278
- const processed = new Collection(list, (x: number) =>
290
+ const processed = new DerivedCollection(list, (x: number) =>
279
291
  x > 2 ? x : UNSET,
280
292
  )
281
293
 
@@ -285,7 +297,7 @@ describe('collection', () => {
285
297
 
286
298
  test('handles primitive values', () => {
287
299
  const list = new List(['hello', 'world'])
288
- const lengths = new Collection(list, (str: string) => ({
300
+ const lengths = new DerivedCollection(list, (str: string) => ({
289
301
  length: str.length,
290
302
  }))
291
303
 
@@ -298,7 +310,10 @@ describe('collection', () => {
298
310
  describe('synchronous transformations', () => {
299
311
  test('transforms collection values with sync callback', () => {
300
312
  const numbers = new List([1, 2, 3])
301
- const doubled = new Collection(numbers, (x: number) => x * 2)
313
+ const doubled = new DerivedCollection(
314
+ numbers,
315
+ (x: number) => x * 2,
316
+ )
302
317
  const quadrupled = doubled.deriveCollection(
303
318
  (x: number) => x * 2,
304
319
  )
@@ -314,7 +329,7 @@ describe('collection', () => {
314
329
  { name: 'Alice', age: 25 },
315
330
  { name: 'Bob', age: 30 },
316
331
  ])
317
- const basicInfo = new Collection(
332
+ const basicInfo = new DerivedCollection(
318
333
  users,
319
334
  (user: { name: string; age: number }) => ({
320
335
  displayName: user.name.toUpperCase(),
@@ -342,10 +357,13 @@ describe('collection', () => {
342
357
 
343
358
  test('transforms string values to different types', () => {
344
359
  const words = new List(['hello', 'world', 'test'])
345
- const wordInfo = new Collection(words, (word: string) => ({
346
- word,
347
- length: word.length,
348
- }))
360
+ const wordInfo = new DerivedCollection(
361
+ words,
362
+ (word: string) => ({
363
+ word,
364
+ length: word.length,
365
+ }),
366
+ )
349
367
  const analysis = wordInfo.deriveCollection(
350
368
  (info: { word: string; length: number }) => ({
351
369
  ...info,
@@ -366,7 +384,10 @@ describe('collection', () => {
366
384
 
367
385
  test('derived collection reactivity with sync transformations', () => {
368
386
  const numbers = new List([1, 2, 3])
369
- const doubled = new Collection(numbers, (x: number) => x * 2)
387
+ const doubled = new DerivedCollection(
388
+ numbers,
389
+ (x: number) => x * 2,
390
+ )
370
391
  const quadrupled = doubled.deriveCollection(
371
392
  (x: number) => x * 2,
372
393
  )
@@ -392,7 +413,10 @@ describe('collection', () => {
392
413
 
393
414
  test('derived collection responds to source removal', () => {
394
415
  const numbers = new List([1, 2, 3, 4])
395
- const doubled = new Collection(numbers, (x: number) => x * 2)
416
+ const doubled = new DerivedCollection(
417
+ numbers,
418
+ (x: number) => x * 2,
419
+ )
396
420
  const quadrupled = doubled.deriveCollection(
397
421
  (x: number) => x * 2,
398
422
  )
@@ -407,7 +431,10 @@ describe('collection', () => {
407
431
  describe('asynchronous transformations', () => {
408
432
  test('transforms values with async callback', async () => {
409
433
  const numbers = new List([1, 2, 3])
410
- const doubled = new Collection(numbers, (x: number) => x * 2)
434
+ const doubled = new DerivedCollection(
435
+ numbers,
436
+ (x: number) => x * 2,
437
+ )
411
438
 
412
439
  const asyncQuadrupled = doubled.deriveCollection(
413
440
  async (x: number, abort: AbortSignal) => {
@@ -448,7 +475,7 @@ describe('collection', () => {
448
475
  { id: 1, name: 'Alice' },
449
476
  { id: 2, name: 'Bob' },
450
477
  ])
451
- const basicInfo = new Collection(
478
+ const basicInfo = new DerivedCollection(
452
479
  users,
453
480
  (user: { id: number; name: string }) => ({
454
481
  userId: user.id,
@@ -507,7 +534,10 @@ describe('collection', () => {
507
534
 
508
535
  test('async derived collection reactivity', async () => {
509
536
  const numbers = new List([1, 2, 3])
510
- const doubled = new Collection(numbers, (x: number) => x * 2)
537
+ const doubled = new DerivedCollection(
538
+ numbers,
539
+ (x: number) => x * 2,
540
+ )
511
541
  const asyncQuadrupled = doubled.deriveCollection(
512
542
  async (x: number, abort: AbortSignal) => {
513
543
  await new Promise(resolve => setTimeout(resolve, 10))
@@ -544,7 +574,10 @@ describe('collection', () => {
544
574
 
545
575
  test('handles AbortSignal cancellation', async () => {
546
576
  const numbers = new List([1, 2, 3])
547
- const doubled = new Collection(numbers, (x: number) => x * 2)
577
+ const doubled = new DerivedCollection(
578
+ numbers,
579
+ (x: number) => x * 2,
580
+ )
548
581
  let abortCalled = false
549
582
 
550
583
  const slowCollection = doubled.deriveCollection(
@@ -580,7 +613,10 @@ describe('collection', () => {
580
613
  describe('derived collection chaining', () => {
581
614
  test('chains multiple sync derivations', () => {
582
615
  const numbers = new List([1, 2, 3])
583
- const doubled = new Collection(numbers, (x: number) => x * 2)
616
+ const doubled = new DerivedCollection(
617
+ numbers,
618
+ (x: number) => x * 2,
619
+ )
584
620
  const quadrupled = doubled.deriveCollection(
585
621
  (x: number) => x * 2,
586
622
  )
@@ -595,7 +631,10 @@ describe('collection', () => {
595
631
 
596
632
  test('chains sync and async derivations', async () => {
597
633
  const numbers = new List([1, 2, 3])
598
- const doubled = new Collection(numbers, (x: number) => x * 2)
634
+ const doubled = new DerivedCollection(
635
+ numbers,
636
+ (x: number) => x * 2,
637
+ )
599
638
  const quadrupled = doubled.deriveCollection(
600
639
  (x: number) => x * 2,
601
640
  )
@@ -625,7 +664,10 @@ describe('collection', () => {
625
664
  describe('derived collection access methods', () => {
626
665
  test('provides index-based access to computed signals', () => {
627
666
  const numbers = new List([1, 2, 3])
628
- const doubled = new Collection(numbers, (x: number) => x * 2)
667
+ const doubled = new DerivedCollection(
668
+ numbers,
669
+ (x: number) => x * 2,
670
+ )
629
671
  const quadrupled = doubled.deriveCollection(
630
672
  (x: number) => x * 2,
631
673
  )
@@ -638,7 +680,10 @@ describe('collection', () => {
638
680
 
639
681
  test('supports key-based access', () => {
640
682
  const numbers = new List([1, 2, 3])
641
- const doubled = new Collection(numbers, (x: number) => x * 2)
683
+ const doubled = new DerivedCollection(
684
+ numbers,
685
+ (x: number) => x * 2,
686
+ )
642
687
  const quadrupled = doubled.deriveCollection(
643
688
  (x: number) => x * 2,
644
689
  )
@@ -660,7 +705,10 @@ describe('collection', () => {
660
705
 
661
706
  test('supports iteration', () => {
662
707
  const numbers = new List([1, 2, 3])
663
- const doubled = new Collection(numbers, (x: number) => x * 2)
708
+ const doubled = new DerivedCollection(
709
+ numbers,
710
+ (x: number) => x * 2,
711
+ )
664
712
  const quadrupled = doubled.deriveCollection(
665
713
  (x: number) => x * 2,
666
714
  )
@@ -676,30 +724,37 @@ describe('collection', () => {
676
724
  describe('derived collection event handling', () => {
677
725
  test('emits add events when source adds items', () => {
678
726
  const numbers = new List([1, 2])
679
- const doubled = new Collection(numbers, (x: number) => x * 2)
727
+ const doubled = new DerivedCollection(
728
+ numbers,
729
+ (x: number) => x * 2,
730
+ )
680
731
  const quadrupled = doubled.deriveCollection(
681
732
  (x: number) => x * 2,
682
733
  )
683
734
 
684
- let addedKeys: readonly string[] = []
735
+ let addedKeys: readonly string[] | undefined
685
736
  quadrupled.on('add', keys => {
686
737
  addedKeys = keys
687
738
  })
688
739
 
689
740
  numbers.add(3)
690
741
  expect(addedKeys).toHaveLength(1)
691
- // biome-ignore lint/style/noNonNullAssertion: test
692
- expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
742
+ const quadrupledKey = addedKeys?.[0]
743
+ if (quadrupledKey)
744
+ expect(quadrupled.byKey(quadrupledKey)?.get()).toBe(12)
693
745
  })
694
746
 
695
747
  test('emits remove events when source removes items', () => {
696
748
  const numbers = new List([1, 2, 3])
697
- const doubled = new Collection(numbers, (x: number) => x * 2)
749
+ const doubled = new DerivedCollection(
750
+ numbers,
751
+ (x: number) => x * 2,
752
+ )
698
753
  const quadrupled = doubled.deriveCollection(
699
754
  (x: number) => x * 2,
700
755
  )
701
756
 
702
- let removedKeys: readonly string[] = []
757
+ let removedKeys: readonly string[] | undefined
703
758
  quadrupled.on('remove', keys => {
704
759
  removedKeys = keys
705
760
  })
@@ -710,12 +765,15 @@ describe('collection', () => {
710
765
 
711
766
  test('emits sort events when source is sorted', () => {
712
767
  const numbers = new List([3, 1, 2])
713
- const doubled = new Collection(numbers, (x: number) => x * 2)
768
+ const doubled = new DerivedCollection(
769
+ numbers,
770
+ (x: number) => x * 2,
771
+ )
714
772
  const quadrupled = doubled.deriveCollection(
715
773
  (x: number) => x * 2,
716
774
  )
717
775
 
718
- let sortedKeys: readonly string[] = []
776
+ let sortedKeys: readonly string[] | undefined
719
777
  quadrupled.on('sort', newOrder => {
720
778
  sortedKeys = newOrder
721
779
  })
@@ -729,7 +787,7 @@ describe('collection', () => {
729
787
  describe('edge cases', () => {
730
788
  test('handles empty collection derivation', () => {
731
789
  const empty = new List<number>([])
732
- const emptyCollection = new Collection(
790
+ const emptyCollection = new DerivedCollection(
733
791
  empty,
734
792
  (x: number) => x * 2,
735
793
  )
@@ -743,7 +801,7 @@ describe('collection', () => {
743
801
 
744
802
  test('handles UNSET values in transformation', () => {
745
803
  const list = new List([1, 2, 3])
746
- const filtered = new Collection(list, (x: number) =>
804
+ const filtered = new DerivedCollection(list, (x: number) =>
747
805
  x > 1 ? { value: x } : UNSET,
748
806
  )
749
807
  const doubled = filtered.deriveCollection(
@@ -759,7 +817,7 @@ describe('collection', () => {
759
817
  { id: 2, data: { value: 20, active: false } },
760
818
  ])
761
819
 
762
- const processed = new Collection(
820
+ const processed = new DerivedCollection(
763
821
  items,
764
822
  (item: {
765
823
  id: number
@@ -793,4 +851,262 @@ describe('collection', () => {
793
851
  })
794
852
  })
795
853
  })
854
+
855
+ describe('hooks system', () => {
856
+ test('Collection HOOK_WATCH is called when effect accesses collection.get()', () => {
857
+ const numbers = new List([10, 20, 30])
858
+ const doubled = numbers.deriveCollection(x => x * 2)
859
+
860
+ let collectionHookWatchCalled = false
861
+ let collectionUnwatchCalled = false
862
+
863
+ // Set up HOOK_WATCH callback on the collection itself
864
+ doubled.on('watch', () => {
865
+ collectionHookWatchCalled = true
866
+ return () => {
867
+ collectionUnwatchCalled = true
868
+ }
869
+ })
870
+
871
+ expect(collectionHookWatchCalled).toBe(false)
872
+
873
+ // Access collection via collection.get() - this should trigger collection's HOOK_WATCH
874
+ let effectValue: number[] = []
875
+ const cleanup = createEffect(() => {
876
+ effectValue = doubled.get()
877
+ })
878
+
879
+ expect(collectionHookWatchCalled).toBe(true)
880
+ expect(effectValue).toEqual([20, 40, 60])
881
+ expect(collectionUnwatchCalled).toBe(false)
882
+
883
+ // Cleanup effect - should trigger unwatch
884
+ cleanup()
885
+ expect(collectionUnwatchCalled).toBe(true)
886
+ })
887
+
888
+ test('List item HOOK_WATCH is triggered when accessing collection items via collection.at().get()', () => {
889
+ const numbers = new List([42, 84])
890
+ const doubled = numbers.deriveCollection(x => x * 2)
891
+
892
+ // Set up hook on source item BEFORE creating the effect
893
+ // biome-ignore lint/style/noNonNullAssertion: test
894
+ const firstSourceItem = numbers.at(0)!
895
+ let sourceItemHookCalled = false
896
+
897
+ firstSourceItem.on('watch', () => {
898
+ sourceItemHookCalled = true
899
+ return () => {
900
+ // Note: Unwatch behavior in computed signals is complex and depends on
901
+ // internal watcher management. We focus on verifying hook triggering.
902
+ }
903
+ })
904
+
905
+ expect(sourceItemHookCalled).toBe(false)
906
+
907
+ // Access collection item - the computed signal internally calls sourceItem.get()
908
+ let effectValue: number | undefined
909
+ const cleanup = createEffect(() => {
910
+ const firstCollectionItem = doubled.at(0)
911
+ effectValue = firstCollectionItem?.get()
912
+ })
913
+
914
+ expect(sourceItemHookCalled).toBe(true) // Source item HOOK_WATCH triggered
915
+ expect(effectValue).toBe(84) // 42 * 2
916
+
917
+ cleanup()
918
+ })
919
+
920
+ test('Collection and List item hooks work independently', () => {
921
+ const items = new List(['hello', 'world'])
922
+ const uppercased = items.deriveCollection(x => x.toUpperCase())
923
+
924
+ let collectionHookCalled = false
925
+ let collectionUnwatchCalled = false
926
+ let sourceItemHookCalled = false
927
+
928
+ // Set up hooks on both collection and source item
929
+ uppercased.on('watch', () => {
930
+ collectionHookCalled = true
931
+ return () => {
932
+ collectionUnwatchCalled = true
933
+ }
934
+ })
935
+
936
+ // biome-ignore lint/style/noNonNullAssertion: test
937
+ const firstSourceItem = items.at(0)!
938
+ firstSourceItem.on('watch', () => {
939
+ sourceItemHookCalled = true
940
+ return () => {
941
+ // Source item unwatch behavior is complex in computed context
942
+ }
943
+ })
944
+
945
+ // Effect 1: Access collection-level data - triggers both hooks
946
+ let collectionValue: string[] = []
947
+ const collectionCleanup = createEffect(() => {
948
+ collectionValue = uppercased.get()
949
+ })
950
+
951
+ expect(collectionHookCalled).toBe(true)
952
+ expect(sourceItemHookCalled).toBe(true) // Source items accessed by collection.get()
953
+ expect(collectionValue).toEqual(['HELLO', 'WORLD'])
954
+
955
+ // Effect 2: Access individual collection item independently
956
+ let itemValue: string | undefined
957
+ const itemCleanup = createEffect(() => {
958
+ itemValue = uppercased.at(0)?.get()
959
+ })
960
+
961
+ expect(itemValue).toBe('HELLO')
962
+
963
+ // Clean up effects
964
+ collectionCleanup()
965
+ expect(collectionUnwatchCalled).toBe(true)
966
+
967
+ itemCleanup()
968
+ })
969
+
970
+ test('source List item hooks are properly managed when items are removed', () => {
971
+ const items = new List(['first', 'second', 'third'])
972
+ const processed = items.deriveCollection(x => x.toUpperCase())
973
+
974
+ let firstItemHookCalled = false
975
+ let secondItemHookCalled = false
976
+
977
+ // Set up hooks on multiple source items
978
+ // biome-ignore lint/style/noNonNullAssertion: test
979
+ const firstSourceItem = items.at(0)!
980
+ // biome-ignore lint/style/noNonNullAssertion: test
981
+ const secondSourceItem = items.at(1)!
982
+
983
+ firstSourceItem.on('watch', () => {
984
+ firstItemHookCalled = true
985
+ return () => {
986
+ // Collection computed signals manage source watching internally
987
+ }
988
+ })
989
+
990
+ secondSourceItem.on('watch', () => {
991
+ secondItemHookCalled = true
992
+ return () => {
993
+ // Collection computed signals manage source watching internally
994
+ }
995
+ })
996
+
997
+ // Access both collection items to trigger source hooks
998
+ let firstValue: string | undefined
999
+ let secondValue: string | undefined
1000
+
1001
+ const cleanup1 = createEffect(() => {
1002
+ firstValue = processed.at(0)?.get()
1003
+ })
1004
+
1005
+ const cleanup2 = createEffect(() => {
1006
+ secondValue = processed.at(1)?.get()
1007
+ })
1008
+
1009
+ // Both source item hooks should be triggered
1010
+ expect(firstItemHookCalled).toBe(true)
1011
+ expect(secondItemHookCalled).toBe(true)
1012
+ expect(firstValue).toBe('FIRST')
1013
+ expect(secondValue).toBe('SECOND')
1014
+
1015
+ cleanup1()
1016
+ cleanup2()
1017
+ })
1018
+
1019
+ test('newly added source List items have hooks triggered through collection access', () => {
1020
+ const numbers = new List<number>([])
1021
+ const squared = numbers.deriveCollection(x => x * x)
1022
+
1023
+ // Add first item to source list
1024
+ numbers.add(5)
1025
+
1026
+ // Set up hook on the newly added source item
1027
+ // biome-ignore lint/style/noNonNullAssertion: test
1028
+ const sourceItem = numbers.at(0)!
1029
+ let sourceHookCalled = false
1030
+
1031
+ sourceItem.on('watch', () => {
1032
+ sourceHookCalled = true
1033
+ return () => {
1034
+ // Hook cleanup managed by computed signal system
1035
+ }
1036
+ })
1037
+
1038
+ expect(sourceHookCalled).toBe(false)
1039
+
1040
+ // Access the collection item - should trigger source item hook
1041
+ let effectValue: number | undefined
1042
+ const cleanup = createEffect(() => {
1043
+ effectValue = squared.at(0)?.get()
1044
+ })
1045
+
1046
+ expect(sourceHookCalled).toBe(true) // Source hook triggered through collection
1047
+ expect(effectValue).toBe(25) // 5 * 5
1048
+
1049
+ cleanup()
1050
+ })
1051
+
1052
+ test('Collection length access triggers Collection HOOK_WATCH', () => {
1053
+ const numbers = new List([1, 2, 3])
1054
+ const doubled = numbers.deriveCollection(x => x * 2)
1055
+
1056
+ let collectionHookWatchCalled = false
1057
+ let collectionUnwatchCalled = false
1058
+
1059
+ doubled.on('watch', () => {
1060
+ collectionHookWatchCalled = true
1061
+ return () => {
1062
+ collectionUnwatchCalled = true
1063
+ }
1064
+ })
1065
+
1066
+ // Access via collection.length - this should trigger collection's HOOK_WATCH
1067
+ let effectValue: number = 0
1068
+ const cleanup = createEffect(() => {
1069
+ effectValue = doubled.length
1070
+ })
1071
+
1072
+ expect(collectionHookWatchCalled).toBe(true)
1073
+ expect(effectValue).toBe(3)
1074
+ expect(collectionUnwatchCalled).toBe(false)
1075
+
1076
+ cleanup()
1077
+ expect(collectionUnwatchCalled).toBe(true)
1078
+ })
1079
+
1080
+ test('chained collections maintain proper hook propagation to original source', () => {
1081
+ const numbers = new List([2, 3])
1082
+ const doubled = numbers.deriveCollection(x => x * 2)
1083
+ const quadrupled = doubled.deriveCollection(x => x * 2)
1084
+
1085
+ // Set up hook on original source item
1086
+ // biome-ignore lint/style/noNonNullAssertion: test
1087
+ const sourceItem = numbers.at(0)!
1088
+ let sourceHookCalled = false
1089
+
1090
+ sourceItem.on('watch', () => {
1091
+ sourceHookCalled = true
1092
+ return () => {
1093
+ // Chained computed signals manage cleanup through dependency chain
1094
+ }
1095
+ })
1096
+
1097
+ expect(sourceHookCalled).toBe(false)
1098
+
1099
+ // Access chained collection item - should trigger original source hook
1100
+ // Chain: quadrupled.at(0).get() -> doubled.at(0).get() -> numbers.at(0).get()
1101
+ let effectValue: number | undefined
1102
+ const cleanup = createEffect(() => {
1103
+ effectValue = quadrupled.at(0)?.get()
1104
+ })
1105
+
1106
+ expect(sourceHookCalled).toBe(true) // Original source hook triggered through chain
1107
+ expect(effectValue).toBe(8) // 2 * 2 * 2
1108
+
1109
+ cleanup()
1110
+ })
1111
+ })
796
1112
  })