@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
@@ -10,6 +10,7 @@ import {
10
10
  Task,
11
11
  UNSET,
12
12
  } from '../index.ts'
13
+ import { HOOK_WATCH } from '../src/system'
13
14
 
14
15
  /* === Utility Functions === */
15
16
 
@@ -429,49 +430,49 @@ describe('Computed', () => {
429
430
  expect(() => {
430
431
  // @ts-expect-error - Testing invalid input
431
432
  new Memo(null)
432
- }).toThrow('Invalid memo callback null')
433
+ }).toThrow('Invalid Memo callback null')
433
434
 
434
435
  expect(() => {
435
436
  // @ts-expect-error - Testing invalid input
436
437
  new Memo(undefined)
437
- }).toThrow('Invalid memo callback undefined')
438
+ }).toThrow('Invalid Memo callback undefined')
438
439
 
439
440
  expect(() => {
440
441
  // @ts-expect-error - Testing invalid input
441
442
  new Memo(42)
442
- }).toThrow('Invalid memo callback 42')
443
+ }).toThrow('Invalid Memo callback 42')
443
444
 
444
445
  expect(() => {
445
446
  // @ts-expect-error - Testing invalid input
446
447
  new Memo('not a function')
447
- }).toThrow('Invalid memo callback "not a function"')
448
+ }).toThrow('Invalid Memo callback "not a function"')
448
449
 
449
450
  expect(() => {
450
451
  // @ts-expect-error - Testing invalid input
451
452
  new Memo({ not: 'a function' })
452
- }).toThrow('Invalid memo callback {"not":"a function"}')
453
+ }).toThrow('Invalid Memo callback {"not":"a function"}')
453
454
 
454
455
  expect(() => {
455
456
  // @ts-expect-error - Testing invalid input
456
457
  new Memo((_a: unknown, _b: unknown, _c: unknown) => 42)
457
- }).toThrow('Invalid memo callback (_a, _b, _c) => 42')
458
+ }).toThrow('Invalid Memo callback (_a, _b, _c) => 42')
458
459
 
459
460
  expect(() => {
460
461
  // @ts-expect-error - Testing invalid input
461
462
  new Memo(async (_a: unknown, _b: unknown) => 42)
462
- }).toThrow('Invalid memo callback async (_a, _b) => 42')
463
+ }).toThrow('Invalid Memo callback async (_a, _b) => 42')
463
464
 
464
465
  expect(() => {
465
466
  // @ts-expect-error - Testing invalid input
466
467
  new Task((_a: unknown) => 42)
467
- }).toThrow('Invalid task callback (_a) => 42')
468
+ }).toThrow('Invalid Task callback (_a) => 42')
468
469
  })
469
470
 
470
471
  test('should throw NullishSignalValueError when initialValue is null', () => {
471
472
  expect(() => {
472
473
  // @ts-expect-error - Testing invalid input
473
474
  new Memo(() => 42, null)
474
- }).toThrow('Nullish signal values are not allowed in memo')
475
+ }).toThrow('Nullish signal values are not allowed in Memo')
475
476
  })
476
477
 
477
478
  test('should throw specific error types for invalid inputs', () => {
@@ -482,7 +483,7 @@ describe('Computed', () => {
482
483
  } catch (error) {
483
484
  expect(error).toBeInstanceOf(TypeError)
484
485
  expect(error.name).toBe('InvalidCallbackError')
485
- expect(error.message).toBe('Invalid memo callback null')
486
+ expect(error.message).toBe('Invalid Memo callback null')
486
487
  }
487
488
 
488
489
  try {
@@ -493,7 +494,7 @@ describe('Computed', () => {
493
494
  expect(error).toBeInstanceOf(TypeError)
494
495
  expect(error.name).toBe('NullishSignalValueError')
495
496
  expect(error.message).toBe(
496
- 'Nullish signal values are not allowed in memo',
497
+ 'Nullish signal values are not allowed in Memo',
497
498
  )
498
499
  }
499
500
  })
@@ -866,4 +867,260 @@ describe('Computed', () => {
866
867
  expect(accumulator.get()).toBe(115) // Final accumulated value
867
868
  })
868
869
  })
870
+
871
+ describe('HOOK_WATCH - Lazy Resource Management', () => {
872
+ test('Memo - should manage external resources lazily', async () => {
873
+ const source = new State(1)
874
+ let counter = 0
875
+ let intervalId: Timer | undefined
876
+
877
+ // Create memo that depends on source
878
+ const computed = new Memo((oldValue: number) => {
879
+ return source.get() * 2 + (oldValue || 0)
880
+ }, 0)
881
+
882
+ // Add HOOK_WATCH callback that starts interval
883
+ const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
884
+ intervalId = setInterval(() => {
885
+ counter++
886
+ }, 10) // Fast interval for testing
887
+
888
+ // Return cleanup function
889
+ return () => {
890
+ if (intervalId) {
891
+ clearInterval(intervalId)
892
+ intervalId = undefined
893
+ }
894
+ }
895
+ })
896
+
897
+ // Counter should not be running yet
898
+ expect(counter).toBe(0)
899
+ await wait(50)
900
+ expect(counter).toBe(0)
901
+ expect(intervalId).toBeUndefined()
902
+
903
+ // Effect subscribes to computed, triggering HOOK_WATCH
904
+ const effectCleanup = createEffect(() => {
905
+ computed.get()
906
+ })
907
+
908
+ // Counter should now be running
909
+ await wait(50)
910
+ expect(counter).toBeGreaterThan(0)
911
+ expect(intervalId).toBeDefined()
912
+
913
+ // Stop effect, should cleanup resources
914
+ effectCleanup()
915
+ const counterAfterStop = counter
916
+
917
+ // Counter should stop incrementing
918
+ await wait(50)
919
+ expect(counter).toBe(counterAfterStop)
920
+ expect(intervalId).toBeUndefined()
921
+
922
+ // Cleanup
923
+ cleanupHookCallback()
924
+ })
925
+
926
+ test('Task - should manage external resources lazily', async () => {
927
+ const source = new State('initial')
928
+ let counter = 0
929
+ let intervalId: Timer | undefined
930
+
931
+ // Create task that depends on source
932
+ const computed = new Task(
933
+ async (oldValue: string, abort: AbortSignal) => {
934
+ const value = source.get()
935
+ await wait(10) // Simulate async work
936
+
937
+ if (abort.aborted) throw new Error('Aborted')
938
+
939
+ return `${value}-processed-${oldValue || 'none'}`
940
+ },
941
+ 'default',
942
+ )
943
+
944
+ // Add HOOK_WATCH callback
945
+ const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
946
+ intervalId = setInterval(() => {
947
+ counter++
948
+ }, 10)
949
+
950
+ return () => {
951
+ if (intervalId) {
952
+ clearInterval(intervalId)
953
+ intervalId = undefined
954
+ }
955
+ }
956
+ })
957
+
958
+ // Counter should not be running yet
959
+ expect(counter).toBe(0)
960
+ await wait(50)
961
+ expect(counter).toBe(0)
962
+ expect(intervalId).toBeUndefined()
963
+
964
+ // Effect subscribes to computed
965
+ const effectCleanup = createEffect(() => {
966
+ computed.get()
967
+ })
968
+
969
+ // Wait for async computation and counter to start
970
+ await wait(100)
971
+ expect(counter).toBeGreaterThan(0)
972
+ expect(intervalId).toBeDefined()
973
+
974
+ // Stop effect
975
+ effectCleanup()
976
+ const counterAfterStop = counter
977
+
978
+ // Counter should stop incrementing
979
+ await wait(50)
980
+ expect(counter).toBe(counterAfterStop)
981
+ expect(intervalId).toBeUndefined()
982
+
983
+ // Cleanup
984
+ cleanupHookCallback()
985
+ })
986
+
987
+ test('Memo - multiple watchers should share resources', async () => {
988
+ const source = new State(10)
989
+ let subscriptionCount = 0
990
+
991
+ const computed = new Memo((oldValue: number) => {
992
+ return source.get() + (oldValue || 0)
993
+ }, 0)
994
+
995
+ // HOOK_WATCH should only be called once for multiple watchers
996
+ const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
997
+ subscriptionCount++
998
+ return () => {
999
+ subscriptionCount--
1000
+ }
1001
+ })
1002
+
1003
+ expect(subscriptionCount).toBe(0)
1004
+
1005
+ // Create multiple effects
1006
+ const effect1 = createEffect(() => {
1007
+ computed.get()
1008
+ })
1009
+ const effect2 = createEffect(() => {
1010
+ computed.get()
1011
+ })
1012
+
1013
+ // Should only increment once
1014
+ expect(subscriptionCount).toBe(1)
1015
+
1016
+ // Stop first effect
1017
+ effect1()
1018
+ expect(subscriptionCount).toBe(1) // Still active due to second watcher
1019
+
1020
+ // Stop second effect
1021
+ effect2()
1022
+ expect(subscriptionCount).toBe(0) // Now cleaned up
1023
+
1024
+ // Cleanup
1025
+ cleanupHookCallback()
1026
+ })
1027
+
1028
+ test('Task - should handle abort signals in external resources', async () => {
1029
+ const source = new State('test')
1030
+ const abortedControllers: AbortController[] = []
1031
+
1032
+ const computed = new Task(
1033
+ async (oldValue: string, abort: AbortSignal) => {
1034
+ await wait(20)
1035
+ if (abort.aborted) throw new Error('Aborted')
1036
+ return `${source.get()}-${oldValue || 'initial'}`
1037
+ },
1038
+ 'default',
1039
+ )
1040
+
1041
+ // HOOK_WATCH that creates external resources with abort handling
1042
+ const cleanupHookCallback = computed.on(HOOK_WATCH, () => {
1043
+ const controller = new AbortController()
1044
+
1045
+ // Simulate external async operation (catch rejections to avoid unhandled errors)
1046
+ new Promise(resolve => {
1047
+ const timeout = setTimeout(() => {
1048
+ if (controller.signal.aborted) {
1049
+ resolve('External operation aborted')
1050
+ } else {
1051
+ resolve('External operation completed')
1052
+ }
1053
+ }, 50)
1054
+
1055
+ controller.signal.addEventListener('abort', () => {
1056
+ clearTimeout(timeout)
1057
+ resolve('External operation aborted')
1058
+ })
1059
+ }).catch(() => {
1060
+ // Ignore promise rejections in test
1061
+ })
1062
+
1063
+ return () => {
1064
+ controller.abort()
1065
+ abortedControllers.push(controller)
1066
+ }
1067
+ })
1068
+
1069
+ const effect1 = createEffect(() => {
1070
+ computed.get()
1071
+ })
1072
+
1073
+ // Change source to trigger recomputation
1074
+ source.set('updated')
1075
+
1076
+ // Stop effect to trigger cleanup
1077
+ effect1()
1078
+
1079
+ // Wait for cleanup to complete
1080
+ await wait(100)
1081
+
1082
+ // Should have aborted external controllers
1083
+ expect(abortedControllers.length).toBeGreaterThan(0)
1084
+ expect(abortedControllers[0].signal.aborted).toBe(true)
1085
+
1086
+ // Cleanup
1087
+ cleanupHookCallback()
1088
+ })
1089
+
1090
+ test('Exception handling in computed HOOK_WATCH callbacks', async () => {
1091
+ const source = new State(1)
1092
+ const computed = new Memo(() => source.get() * 2)
1093
+
1094
+ let successfulCallbackCalled = false
1095
+ let throwingCallbackCalled = false
1096
+
1097
+ // Add throwing callback
1098
+ const cleanup1 = computed.on(HOOK_WATCH, () => {
1099
+ throwingCallbackCalled = true
1100
+ throw new Error('Test error in computed HOOK_WATCH')
1101
+ })
1102
+
1103
+ // Add successful callback
1104
+ const cleanup2 = computed.on(HOOK_WATCH, () => {
1105
+ successfulCallbackCalled = true
1106
+ return () => {
1107
+ // cleanup
1108
+ }
1109
+ })
1110
+
1111
+ // Trigger callbacks - should throw due to exception in callback
1112
+ expect(() => computed.get()).toThrow(
1113
+ 'Test error in computed HOOK_WATCH',
1114
+ )
1115
+
1116
+ // Throwing callback should have been called
1117
+ expect(throwingCallbackCalled).toBe(true)
1118
+ // Successful callback should also have been called (resilient collection)
1119
+ expect(successfulCallbackCalled).toBe(true)
1120
+
1121
+ // Cleanup
1122
+ cleanup1()
1123
+ cleanup2()
1124
+ })
1125
+ })
869
1126
  })
@@ -223,7 +223,7 @@ describe('Effect', () => {
223
223
 
224
224
  // Check if console.error was called with the error message
225
225
  expect(mockConsoleError).toHaveBeenCalledWith(
226
- 'Effect callback error:',
226
+ 'Error in effect callback:',
227
227
  expect.any(Error),
228
228
  )
229
229
  } finally {
@@ -507,7 +507,7 @@ describe('Effect - Async with AbortSignal', () => {
507
507
 
508
508
  // Should have logged the async error
509
509
  expect(mockConsoleError).toHaveBeenCalledWith(
510
- 'Async effect error:',
510
+ 'Error in async effect callback:',
511
511
  expect.any(Error),
512
512
  )
513
513
  } finally {
package/test/list.test.ts CHANGED
@@ -130,15 +130,15 @@ describe('list', () => {
130
130
  expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
131
131
  })
132
132
 
133
- test('emits sort notification with new order', () => {
133
+ test('triggers HOOK_SORT with new order', () => {
134
134
  const numbers = new List([3, 1, 2])
135
- let sortNotification: readonly string[] = []
135
+ let order: readonly string[] | undefined
136
136
  numbers.on('sort', sort => {
137
- sortNotification = sort
137
+ order = sort
138
138
  })
139
139
  numbers.sort()
140
- expect(sortNotification).toHaveLength(3)
141
- expect(sortNotification).toEqual(['1', '2', '0'])
140
+ expect(order).toHaveLength(3)
141
+ expect(order).toEqual(['1', '2', '0'])
142
142
  })
143
143
 
144
144
  test('sort is reactive - watchers are notified', () => {
@@ -302,43 +302,43 @@ describe('list', () => {
302
302
  })
303
303
  })
304
304
 
305
- describe('change notifications', () => {
306
- test('emits add notifications', () => {
305
+ describe('Hooks', () => {
306
+ test('trigger HOOK_ADD when adding items', () => {
307
307
  const numbers = new List([1, 2])
308
- let arrayAddNotification: readonly string[] = []
308
+ let addedKeys: readonly string[] | undefined
309
309
  let newArray: number[] = []
310
310
  numbers.on('add', add => {
311
- arrayAddNotification = add
311
+ addedKeys = add
312
312
  newArray = numbers.get()
313
313
  })
314
314
  numbers.add(3)
315
- expect(arrayAddNotification).toHaveLength(1)
315
+ expect(addedKeys).toHaveLength(1)
316
316
  expect(newArray).toEqual([1, 2, 3])
317
317
  })
318
318
 
319
- test('emits change notifications when properties are modified', () => {
319
+ test('triggers HOOK_CHANGE when properties are modified', () => {
320
320
  const items = new List([{ value: 10 }])
321
- let arrayChangeNotification: readonly string[] = []
321
+ let changedKeys: readonly string[] | undefined
322
322
  let newArray: { value: number }[] = []
323
323
  items.on('change', change => {
324
- arrayChangeNotification = change
324
+ changedKeys = change
325
325
  newArray = items.get()
326
326
  })
327
327
  items.at(0)?.set({ value: 20 })
328
- expect(arrayChangeNotification).toHaveLength(1)
328
+ expect(changedKeys).toHaveLength(1)
329
329
  expect(newArray).toEqual([{ value: 20 }])
330
330
  })
331
331
 
332
- test('emits remove notifications when items are removed', () => {
332
+ test('triggers HOOK_REMOVE when items are removed', () => {
333
333
  const items = new List([1, 2, 3])
334
- let arrayRemoveNotification: readonly string[] = []
334
+ let removedKeys: readonly string[] | undefined
335
335
  let newArray: number[] = []
336
336
  items.on('remove', remove => {
337
- arrayRemoveNotification = remove
337
+ removedKeys = remove
338
338
  newArray = items.get()
339
339
  })
340
340
  items.remove(1)
341
- expect(arrayRemoveNotification).toHaveLength(1)
341
+ expect(removedKeys).toHaveLength(1)
342
342
  expect(newArray).toEqual([1, 3])
343
343
  })
344
344
  })
@@ -670,7 +670,7 @@ describe('list', () => {
670
670
  (value: number) => value * 2,
671
671
  )
672
672
 
673
- let addedKeys: readonly string[] = []
673
+ let addedKeys: readonly string[] | undefined
674
674
  doubled.on('add', keys => {
675
675
  addedKeys = keys
676
676
  })
@@ -685,7 +685,7 @@ describe('list', () => {
685
685
  (value: number) => value * 2,
686
686
  )
687
687
 
688
- let removedKeys: readonly string[] = []
688
+ let removedKeys: readonly string[] | undefined
689
689
  doubled.on('remove', keys => {
690
690
  removedKeys = keys
691
691
  })
@@ -700,7 +700,7 @@ describe('list', () => {
700
700
  (value: number) => value * 2,
701
701
  )
702
702
 
703
- let sortedKeys: readonly string[] = []
703
+ let sortedKeys: readonly string[] | undefined
704
704
  doubled.on('sort', keys => {
705
705
  sortedKeys = keys
706
706
  })
@@ -751,4 +751,232 @@ describe('list', () => {
751
751
  })
752
752
  })
753
753
  })
754
+
755
+ describe('hooks system', () => {
756
+ test('List HOOK_WATCH is called when effect accesses list.get()', () => {
757
+ const numbers = new List([10, 20, 30])
758
+ let listHookWatchCalled = false
759
+ let listUnwatchCalled = false
760
+
761
+ // Set up HOOK_WATCH callback on the list itself
762
+ numbers.on('watch', () => {
763
+ listHookWatchCalled = true
764
+ return () => {
765
+ listUnwatchCalled = true
766
+ }
767
+ })
768
+
769
+ expect(listHookWatchCalled).toBe(false)
770
+
771
+ // Access list via list.get() - this should trigger list's HOOK_WATCH
772
+ let effectValue: number[] = []
773
+ const cleanup = createEffect(() => {
774
+ effectValue = numbers.get()
775
+ })
776
+
777
+ expect(listHookWatchCalled).toBe(true)
778
+ expect(effectValue).toEqual([10, 20, 30])
779
+ expect(listUnwatchCalled).toBe(false)
780
+
781
+ // Cleanup effect - should trigger unwatch
782
+ cleanup()
783
+ expect(listUnwatchCalled).toBe(true)
784
+ })
785
+
786
+ test('individual State signals have independent HOOK_WATCH when accessed via list.at().get()', () => {
787
+ const items = new List(['first', 'second'])
788
+ let firstItemHookCalled = false
789
+ let firstItemUnwatchCalled = false
790
+
791
+ // Get the first item signal and set up its HOOK_WATCH
792
+ // biome-ignore lint/style/noNonNullAssertion: test
793
+ const firstItemSignal = items.at(0)!
794
+ firstItemSignal.on('watch', () => {
795
+ firstItemHookCalled = true
796
+ return () => {
797
+ firstItemUnwatchCalled = true
798
+ }
799
+ })
800
+
801
+ expect(firstItemHookCalled).toBe(false)
802
+
803
+ // Access first item via signal.get() - this should trigger the State signal's HOOK_WATCH
804
+ let effectValue: string | undefined
805
+ const cleanup = createEffect(() => {
806
+ effectValue = firstItemSignal.get()
807
+ })
808
+
809
+ expect(firstItemHookCalled).toBe(true)
810
+ expect(effectValue).toBe('first')
811
+ expect(firstItemUnwatchCalled).toBe(false)
812
+
813
+ // Cleanup effect - should trigger State signal's unwatch
814
+ cleanup()
815
+ expect(firstItemUnwatchCalled).toBe(true)
816
+ })
817
+
818
+ test('State signal unwatch is called when item gets removed from list', () => {
819
+ const items = new List(['first', 'second'])
820
+ let firstItemUnwatchCalled = false
821
+
822
+ // Get the first item signal and set up its HOOK_WATCH
823
+ // biome-ignore lint/style/noNonNullAssertion: test
824
+ const firstItemSignal = items.at(0)!
825
+ firstItemSignal.on('watch', () => {
826
+ return () => {
827
+ firstItemUnwatchCalled = true
828
+ }
829
+ })
830
+
831
+ let effectValue: string | undefined
832
+ const cleanup = createEffect(() => {
833
+ effectValue = firstItemSignal.get()
834
+ })
835
+
836
+ expect(effectValue).toBe('first')
837
+ expect(firstItemUnwatchCalled).toBe(false)
838
+
839
+ // Remove the first item (index 0) - the State signal still exists but the list changed
840
+ items.remove(0)
841
+
842
+ // The State signal should still work (it's not automatically cleaned up)
843
+ expect(effectValue).toBe('first') // State signal retains its value
844
+ expect(firstItemUnwatchCalled).toBe(false) // Unwatch only happens when effect cleanup
845
+
846
+ // Cleanup the effect - this should call the State signal's unwatch
847
+ cleanup()
848
+ expect(firstItemUnwatchCalled).toBe(true)
849
+ })
850
+
851
+ test('new items added to list get independent State signals with their own hooks', () => {
852
+ const numbers = new List<number>([])
853
+
854
+ // Start with empty list - create effect that tries to access first item
855
+ let effectValue: number | undefined
856
+ const cleanup = createEffect(() => {
857
+ const firstItem = numbers.at(0)
858
+ effectValue = firstItem?.get()
859
+ })
860
+
861
+ // No items yet
862
+ expect(effectValue).toBe(undefined)
863
+
864
+ // Add first item
865
+ const key = numbers.add(42)
866
+ // biome-ignore lint/style/noNonNullAssertion: test
867
+ const newItemSignal = numbers.byKey(key)!
868
+
869
+ let newItemHookCalled = false
870
+ let newItemUnwatchCalled = false
871
+ newItemSignal.on('watch', () => {
872
+ newItemHookCalled = true
873
+ return () => {
874
+ newItemUnwatchCalled = true
875
+ }
876
+ })
877
+
878
+ // Create new effect to access the new item
879
+ let newEffectValue: number | undefined
880
+ const newCleanup = createEffect(() => {
881
+ newEffectValue = newItemSignal.get()
882
+ })
883
+
884
+ expect(newItemHookCalled).toBe(true)
885
+ expect(newEffectValue).toBe(42)
886
+ expect(newItemUnwatchCalled).toBe(false)
887
+
888
+ // Cleanup should trigger unwatch
889
+ newCleanup()
890
+ expect(newItemUnwatchCalled).toBe(true)
891
+
892
+ cleanup()
893
+ })
894
+
895
+ test('List length access triggers List HOOK_WATCH', () => {
896
+ const numbers = new List([1, 2, 3])
897
+ let listHookWatchCalled = false
898
+ let listUnwatchCalled = false
899
+
900
+ numbers.on('watch', () => {
901
+ listHookWatchCalled = true
902
+ return () => {
903
+ listUnwatchCalled = true
904
+ }
905
+ })
906
+
907
+ // Access via list.length - this should trigger list's HOOK_WATCH
908
+ let effectValue: number = 0
909
+ const cleanup = createEffect(() => {
910
+ effectValue = numbers.length
911
+ })
912
+
913
+ expect(listHookWatchCalled).toBe(true)
914
+ expect(effectValue).toBe(3)
915
+ expect(listUnwatchCalled).toBe(false)
916
+
917
+ cleanup()
918
+ expect(listUnwatchCalled).toBe(true)
919
+ })
920
+
921
+ test('exact scenario: List HOOK_WATCH triggered by list.at(0).get(), unwatch on item removal, restart on new item', () => {
922
+ const list = new List<number>([42])
923
+ let listHookWatchCallCount = 0
924
+ let listUnwatchCallCount = 0
925
+
926
+ // Set up List's HOOK_WATCH (this is triggered by list-level access like get() or length)
927
+ list.on('watch', () => {
928
+ listHookWatchCallCount++
929
+ return () => {
930
+ listUnwatchCallCount++
931
+ }
932
+ })
933
+
934
+ // Scenario 1: The list's HOOK_WATCH is called when an effect accesses the first item
935
+ // Note: list.at(0).get() accesses the State signal, not the list itself
936
+ // But if we access list.get() or list.length, it triggers the list's HOOK_WATCH
937
+ let effectValue: number | undefined
938
+ const cleanup1 = createEffect(() => {
939
+ // Access list first to trigger list HOOK_WATCH
940
+ const length = list.length
941
+ if (length > 0) {
942
+ effectValue = list.at(0)?.get()
943
+ } else {
944
+ effectValue = undefined
945
+ }
946
+ })
947
+
948
+ expect(listHookWatchCallCount).toBe(1) // List HOOK_WATCH called due to list.length access
949
+ expect(effectValue).toBe(42)
950
+
951
+ // Scenario 2: The list's unwatch callback is called when the only item with active subscription gets removed
952
+ list.remove(0)
953
+ // The effect should re-run due to list.length change and effectValue should now be undefined
954
+ expect(effectValue).toBe(undefined)
955
+
956
+ // The list unwatch is not called yet because the effect is still active (watching an empty list)
957
+ expect(listUnwatchCallCount).toBe(0)
958
+
959
+ // Clean up the first effect
960
+ cleanup1()
961
+ expect(listUnwatchCallCount).toBe(1) // Now unwatch is called
962
+
963
+ // Scenario 3: The list's HOOK_WATCH is restarted after a new item has been added that gets accessed by an effect
964
+ list.add(100)
965
+
966
+ const cleanup2 = createEffect(() => {
967
+ const length = list.length
968
+ if (length > 0) {
969
+ effectValue = list.at(0)?.get()
970
+ } else {
971
+ effectValue = undefined
972
+ }
973
+ })
974
+
975
+ expect(listHookWatchCallCount).toBe(2) // List HOOK_WATCH called again
976
+ expect(effectValue).toBe(100)
977
+
978
+ cleanup2()
979
+ expect(listUnwatchCallCount).toBe(2) // Second unwatch called
980
+ })
981
+ })
754
982
  })