@zeix/cause-effect 0.17.2 → 0.17.3

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 +11 -5
  2. package/.github/copilot-instructions.md +1 -1
  3. package/.zed/settings.json +3 -0
  4. package/CLAUDE.md +18 -79
  5. package/README.md +23 -37
  6. package/archive/benchmark.ts +0 -5
  7. package/archive/collection.ts +5 -62
  8. package/archive/composite.ts +85 -0
  9. package/archive/computed.ts +17 -20
  10. package/archive/list.ts +6 -67
  11. package/archive/memo.ts +13 -14
  12. package/archive/store.ts +7 -66
  13. package/archive/task.ts +18 -20
  14. package/index.dev.js +438 -614
  15. package/index.js +1 -1
  16. package/index.ts +8 -19
  17. package/package.json +6 -6
  18. package/src/classes/collection.ts +59 -112
  19. package/src/classes/computed.ts +146 -189
  20. package/src/classes/list.ts +138 -105
  21. package/src/classes/ref.ts +16 -42
  22. package/src/classes/state.ts +16 -45
  23. package/src/classes/store.ts +107 -72
  24. package/src/effect.ts +9 -12
  25. package/src/errors.ts +12 -8
  26. package/src/signal.ts +3 -1
  27. package/src/system.ts +136 -154
  28. package/test/batch.test.ts +4 -11
  29. package/test/benchmark.test.ts +4 -2
  30. package/test/collection.test.ts +46 -306
  31. package/test/computed.test.ts +205 -223
  32. package/test/list.test.ts +35 -303
  33. package/test/ref.test.ts +38 -66
  34. package/test/state.test.ts +6 -12
  35. package/test/store.test.ts +37 -489
  36. package/test/util/dependency-graph.ts +2 -2
  37. package/tsconfig.build.json +11 -0
  38. package/tsconfig.json +5 -7
  39. package/types/index.d.ts +2 -2
  40. package/types/src/classes/collection.d.ts +4 -6
  41. package/types/src/classes/computed.d.ts +17 -37
  42. package/types/src/classes/list.d.ts +8 -6
  43. package/types/src/classes/ref.d.ts +7 -20
  44. package/types/src/classes/state.d.ts +5 -17
  45. package/types/src/classes/store.d.ts +12 -11
  46. package/types/src/errors.d.ts +2 -4
  47. package/types/src/signal.d.ts +3 -2
  48. package/types/src/system.d.ts +41 -44
  49. package/src/classes/composite.ts +0 -171
  50. package/types/src/classes/composite.d.ts +0 -15
@@ -104,7 +104,7 @@ describe('collection', () => {
104
104
  test('returns undefined for non-existent properties', () => {
105
105
  const items = new List([1, 2])
106
106
  const collection = new DerivedCollection(items, (x: number) => x)
107
- expect(collection[5]).toBeUndefined()
107
+ expect(collection.at(5)).toBeUndefined()
108
108
  })
109
109
 
110
110
  test('supports numeric key access', () => {
@@ -230,50 +230,6 @@ describe('collection', () => {
230
230
  })
231
231
  })
232
232
 
233
- describe('Hooks', () => {
234
- test('triggers HOOK_ADD when items are added', () => {
235
- const numbers = new List([1, 2])
236
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
237
-
238
- let addedKeys: readonly string[] | undefined
239
- doubled.on('add', keys => {
240
- addedKeys = keys
241
- })
242
-
243
- numbers.add(3)
244
- expect(addedKeys).toHaveLength(1)
245
- const doubledKey = addedKeys?.[0]
246
- if (doubledKey) expect(doubled.byKey(doubledKey)?.get()).toBe(6)
247
- })
248
-
249
- test('triggers HOOK_REMOVE when items are removed', () => {
250
- const items = new List([1, 2, 3])
251
- const doubled = new DerivedCollection(items, (x: number) => x * 2)
252
-
253
- let removedKeys: readonly string[] | undefined
254
- doubled.on('remove', keys => {
255
- removedKeys = keys
256
- })
257
-
258
- items.remove(1)
259
- expect(removedKeys).toHaveLength(1)
260
- })
261
-
262
- test('triggers HOOK_SORT when source is sorted', () => {
263
- const numbers = new List([3, 1, 2])
264
- const doubled = new DerivedCollection(numbers, (x: number) => x * 2)
265
-
266
- let order: readonly string[] | undefined
267
- doubled.on('sort', newOrder => {
268
- order = newOrder
269
- })
270
-
271
- numbers.sort((a, b) => a - b)
272
- expect(order).toHaveLength(3)
273
- expect(doubled.get()).toEqual([2, 4, 6])
274
- })
275
- })
276
-
277
233
  describe('edge cases', () => {
278
234
  test('handles empty collections correctly', () => {
279
235
  const empty = new List<number>([])
@@ -721,69 +677,6 @@ describe('collection', () => {
721
677
  })
722
678
  })
723
679
 
724
- describe('derived collection event handling', () => {
725
- test('emits add events when source adds items', () => {
726
- const numbers = new List([1, 2])
727
- const doubled = new DerivedCollection(
728
- numbers,
729
- (x: number) => x * 2,
730
- )
731
- const quadrupled = doubled.deriveCollection(
732
- (x: number) => x * 2,
733
- )
734
-
735
- let addedKeys: readonly string[] | undefined
736
- quadrupled.on('add', keys => {
737
- addedKeys = keys
738
- })
739
-
740
- numbers.add(3)
741
- expect(addedKeys).toHaveLength(1)
742
- const quadrupledKey = addedKeys?.[0]
743
- if (quadrupledKey)
744
- expect(quadrupled.byKey(quadrupledKey)?.get()).toBe(12)
745
- })
746
-
747
- test('emits remove events when source removes items', () => {
748
- const numbers = new List([1, 2, 3])
749
- const doubled = new DerivedCollection(
750
- numbers,
751
- (x: number) => x * 2,
752
- )
753
- const quadrupled = doubled.deriveCollection(
754
- (x: number) => x * 2,
755
- )
756
-
757
- let removedKeys: readonly string[] | undefined
758
- quadrupled.on('remove', keys => {
759
- removedKeys = keys
760
- })
761
-
762
- numbers.remove(1)
763
- expect(removedKeys).toHaveLength(1)
764
- })
765
-
766
- test('emits sort events when source is sorted', () => {
767
- const numbers = new List([3, 1, 2])
768
- const doubled = new DerivedCollection(
769
- numbers,
770
- (x: number) => x * 2,
771
- )
772
- const quadrupled = doubled.deriveCollection(
773
- (x: number) => x * 2,
774
- )
775
-
776
- let sortedKeys: readonly string[] | undefined
777
- quadrupled.on('sort', newOrder => {
778
- sortedKeys = newOrder
779
- })
780
-
781
- numbers.sort((a, b) => a - b)
782
- expect(sortedKeys).toHaveLength(3)
783
- expect(quadrupled.get()).toEqual([4, 8, 12])
784
- })
785
- })
786
-
787
680
  describe('edge cases', () => {
788
681
  test('handles empty collection derivation', () => {
789
682
  const empty = new List<number>([])
@@ -852,31 +745,30 @@ describe('collection', () => {
852
745
  })
853
746
  })
854
747
 
855
- describe('hooks system', () => {
856
- test('Collection HOOK_WATCH is called when effect accesses collection.get()', () => {
748
+ describe('Watch Callbacks', () => {
749
+ test('Collection watched callback is called when effect accesses collection.get()', () => {
857
750
  const numbers = new List([10, 20, 30])
858
- const doubled = numbers.deriveCollection(x => x * 2)
859
751
 
860
- let collectionHookWatchCalled = false
752
+ let collectionWatchedCalled = false
861
753
  let collectionUnwatchCalled = false
862
-
863
- // Set up HOOK_WATCH callback on the collection itself
864
- doubled.on('watch', () => {
865
- collectionHookWatchCalled = true
866
- return () => {
754
+ const doubled = numbers.deriveCollection(x => x * 2, {
755
+ watched: () => {
756
+ collectionWatchedCalled = true
757
+ },
758
+ unwatched: () => {
867
759
  collectionUnwatchCalled = true
868
- }
760
+ },
869
761
  })
870
762
 
871
- expect(collectionHookWatchCalled).toBe(false)
763
+ expect(collectionWatchedCalled).toBe(false)
872
764
 
873
- // Access collection via collection.get() - this should trigger collection's HOOK_WATCH
765
+ // Access collection via collection.get() - this should trigger collection's watched callback
874
766
  let effectValue: number[] = []
875
767
  const cleanup = createEffect(() => {
876
768
  effectValue = doubled.get()
877
769
  })
878
770
 
879
- expect(collectionHookWatchCalled).toBe(true)
771
+ expect(collectionWatchedCalled).toBe(true)
880
772
  expect(effectValue).toEqual([20, 40, 60])
881
773
  expect(collectionUnwatchCalled).toBe(false)
882
774
 
@@ -885,71 +777,33 @@ describe('collection', () => {
885
777
  expect(collectionUnwatchCalled).toBe(true)
886
778
  })
887
779
 
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
- }
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
+ },
934
786
  })
935
787
 
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
- }
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
796
+ },
943
797
  })
944
798
 
945
- // Effect 1: Access collection-level data - triggers both hooks
799
+ // Effect 1: Access collection-level data - triggers both watched callbacks
946
800
  let collectionValue: string[] = []
947
801
  const collectionCleanup = createEffect(() => {
948
802
  collectionValue = uppercased.get()
949
803
  })
950
804
 
951
- expect(collectionHookCalled).toBe(true)
952
- expect(sourceItemHookCalled).toBe(true) // Source items accessed by collection.get()
805
+ expect(collectionWatchedCalled).toBe(true)
806
+ expect(sourceWatchedCalled).toBe(true) // Source items accessed by collection.get()
953
807
  expect(collectionValue).toEqual(['HELLO', 'WORLD'])
954
808
 
955
809
  // Effect 2: Access individual collection item independently
@@ -962,151 +816,37 @@ describe('collection', () => {
962
816
 
963
817
  // Clean up effects
964
818
  collectionCleanup()
965
- expect(collectionUnwatchCalled).toBe(true)
819
+ expect(collectionUnwatchedCalled).toBe(true)
966
820
 
967
821
  itemCleanup()
968
822
  })
969
823
 
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', () => {
824
+ test('Collection length access triggers Collection watched callback', () => {
1053
825
  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
826
 
1059
- doubled.on('watch', () => {
1060
- collectionHookWatchCalled = true
1061
- return () => {
1062
- collectionUnwatchCalled = true
1063
- }
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
+ },
1064
836
  })
1065
837
 
1066
- // Access via collection.length - this should trigger collection's HOOK_WATCH
838
+ // Access via collection.length - this should trigger collection's watched callback
1067
839
  let effectValue: number = 0
1068
840
  const cleanup = createEffect(() => {
1069
841
  effectValue = doubled.length
1070
842
  })
1071
843
 
1072
- expect(collectionHookWatchCalled).toBe(true)
844
+ expect(collectionWatchedCalled).toBe(true)
1073
845
  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
846
+ expect(collectionUnwatchedCalled).toBe(false)
1108
847
 
1109
848
  cleanup()
849
+ expect(collectionUnwatchedCalled).toBe(true)
1110
850
  })
1111
851
  })
1112
852
  })