@zeix/cause-effect 0.17.1 → 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 (48) hide show
  1. package/.ai-context.md +7 -0
  2. package/.github/copilot-instructions.md +4 -0
  3. package/CLAUDE.md +96 -1
  4. package/README.md +44 -7
  5. package/archive/collection.ts +23 -25
  6. package/archive/computed.ts +3 -2
  7. package/archive/list.ts +21 -28
  8. package/archive/memo.ts +2 -1
  9. package/archive/state.ts +2 -1
  10. package/archive/store.ts +21 -32
  11. package/archive/task.ts +4 -7
  12. package/index.dev.js +356 -198
  13. package/index.js +1 -1
  14. package/index.ts +15 -6
  15. package/package.json +1 -1
  16. package/src/classes/collection.ts +69 -53
  17. package/src/classes/composite.ts +28 -33
  18. package/src/classes/computed.ts +87 -28
  19. package/src/classes/list.ts +31 -26
  20. package/src/classes/ref.ts +33 -5
  21. package/src/classes/state.ts +41 -8
  22. package/src/classes/store.ts +47 -30
  23. package/src/diff.ts +2 -1
  24. package/src/effect.ts +19 -9
  25. package/src/errors.ts +10 -1
  26. package/src/resolve.ts +1 -1
  27. package/src/signal.ts +0 -1
  28. package/src/system.ts +159 -43
  29. package/src/util.ts +0 -6
  30. package/test/collection.test.ts +279 -20
  31. package/test/computed.test.ts +268 -11
  32. package/test/effect.test.ts +2 -2
  33. package/test/list.test.ts +249 -21
  34. package/test/ref.test.ts +154 -0
  35. package/test/state.test.ts +13 -13
  36. package/test/store.test.ts +473 -28
  37. package/types/index.d.ts +3 -3
  38. package/types/src/classes/collection.d.ts +8 -7
  39. package/types/src/classes/composite.d.ts +4 -4
  40. package/types/src/classes/computed.d.ts +17 -0
  41. package/types/src/classes/list.d.ts +2 -2
  42. package/types/src/classes/ref.d.ts +10 -1
  43. package/types/src/classes/state.d.ts +9 -0
  44. package/types/src/classes/store.d.ts +4 -4
  45. package/types/src/effect.d.ts +1 -2
  46. package/types/src/errors.d.ts +4 -1
  47. package/types/src/system.d.ts +40 -24
  48. package/types/src/util.d.ts +1 -2
@@ -230,46 +230,46 @@ describe('collection', () => {
230
230
  })
231
231
  })
232
232
 
233
- describe('change notifications', () => {
234
- test('emits add notifications', () => {
233
+ describe('Hooks', () => {
234
+ test('triggers HOOK_ADD when items are added', () => {
235
235
  const numbers = new List([1, 2])
236
236
  const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
237
237
 
238
- let arrayAddNotification: readonly string[] = []
238
+ let addedKeys: readonly string[] | undefined
239
239
  doubled.on('add', keys => {
240
- arrayAddNotification = keys
240
+ addedKeys = keys
241
241
  })
242
242
 
243
243
  numbers.add(3)
244
- expect(arrayAddNotification).toHaveLength(1)
245
- // biome-ignore lint/style/noNonNullAssertion: test
246
- 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)
247
247
  })
248
248
 
249
- test('emits remove notifications when items are removed', () => {
249
+ test('triggers HOOK_REMOVE when items are removed', () => {
250
250
  const items = new List([1, 2, 3])
251
251
  const doubled = new DerivedCollection(items, (x: number) => x * 2)
252
252
 
253
- let arrayRemoveNotification: readonly string[] = []
253
+ let removedKeys: readonly string[] | undefined
254
254
  doubled.on('remove', keys => {
255
- arrayRemoveNotification = keys
255
+ removedKeys = keys
256
256
  })
257
257
 
258
258
  items.remove(1)
259
- expect(arrayRemoveNotification).toHaveLength(1)
259
+ expect(removedKeys).toHaveLength(1)
260
260
  })
261
261
 
262
- test('emits sort notifications when source is sorted', () => {
262
+ test('triggers HOOK_SORT when source is sorted', () => {
263
263
  const numbers = new List([3, 1, 2])
264
264
  const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
265
265
 
266
- let sortNotification: readonly string[] = []
266
+ let order: readonly string[] | undefined
267
267
  doubled.on('sort', newOrder => {
268
- sortNotification = newOrder
268
+ order = newOrder
269
269
  })
270
270
 
271
271
  numbers.sort((a, b) => a - b)
272
- expect(sortNotification).toHaveLength(3)
272
+ expect(order).toHaveLength(3)
273
273
  expect(doubled.get()).toEqual([2, 4, 6])
274
274
  })
275
275
  })
@@ -732,15 +732,16 @@ describe('collection', () => {
732
732
  (x: number) => x * 2,
733
733
  )
734
734
 
735
- let addedKeys: readonly string[] = []
735
+ let addedKeys: readonly string[] | undefined
736
736
  quadrupled.on('add', keys => {
737
737
  addedKeys = keys
738
738
  })
739
739
 
740
740
  numbers.add(3)
741
741
  expect(addedKeys).toHaveLength(1)
742
- // biome-ignore lint/style/noNonNullAssertion: test
743
- expect(quadrupled.byKey(addedKeys[0]!)?.get()).toBe(12)
742
+ const quadrupledKey = addedKeys?.[0]
743
+ if (quadrupledKey)
744
+ expect(quadrupled.byKey(quadrupledKey)?.get()).toBe(12)
744
745
  })
745
746
 
746
747
  test('emits remove events when source removes items', () => {
@@ -753,7 +754,7 @@ describe('collection', () => {
753
754
  (x: number) => x * 2,
754
755
  )
755
756
 
756
- let removedKeys: readonly string[] = []
757
+ let removedKeys: readonly string[] | undefined
757
758
  quadrupled.on('remove', keys => {
758
759
  removedKeys = keys
759
760
  })
@@ -772,7 +773,7 @@ describe('collection', () => {
772
773
  (x: number) => x * 2,
773
774
  )
774
775
 
775
- let sortedKeys: readonly string[] = []
776
+ let sortedKeys: readonly string[] | undefined
776
777
  quadrupled.on('sort', newOrder => {
777
778
  sortedKeys = newOrder
778
779
  })
@@ -850,4 +851,262 @@ describe('collection', () => {
850
851
  })
851
852
  })
852
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
+ })
853
1112
  })
@@ -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
  })