@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.
- package/.ai-context.md +11 -5
- package/.github/copilot-instructions.md +1 -1
- package/.zed/settings.json +3 -0
- package/CLAUDE.md +18 -79
- package/README.md +23 -37
- package/archive/benchmark.ts +0 -5
- package/archive/collection.ts +5 -62
- package/archive/composite.ts +85 -0
- package/archive/computed.ts +17 -20
- package/archive/list.ts +6 -67
- package/archive/memo.ts +13 -14
- package/archive/store.ts +7 -66
- package/archive/task.ts +18 -20
- package/index.dev.js +438 -614
- package/index.js +1 -1
- package/index.ts +8 -19
- package/package.json +6 -6
- package/src/classes/collection.ts +59 -112
- package/src/classes/computed.ts +146 -189
- package/src/classes/list.ts +138 -105
- package/src/classes/ref.ts +16 -42
- package/src/classes/state.ts +16 -45
- package/src/classes/store.ts +107 -72
- package/src/effect.ts +9 -12
- package/src/errors.ts +12 -8
- package/src/signal.ts +3 -1
- package/src/system.ts +136 -154
- package/test/batch.test.ts +4 -11
- package/test/benchmark.test.ts +4 -2
- package/test/collection.test.ts +46 -306
- package/test/computed.test.ts +205 -223
- package/test/list.test.ts +35 -303
- package/test/ref.test.ts +38 -66
- package/test/state.test.ts +6 -12
- package/test/store.test.ts +37 -489
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +2 -2
- package/types/src/classes/collection.d.ts +4 -6
- package/types/src/classes/computed.d.ts +17 -37
- package/types/src/classes/list.d.ts +8 -6
- package/types/src/classes/ref.d.ts +7 -20
- package/types/src/classes/state.d.ts +5 -17
- package/types/src/classes/store.d.ts +12 -11
- package/types/src/errors.d.ts +2 -4
- package/types/src/signal.d.ts +3 -2
- package/types/src/system.d.ts +41 -44
- package/src/classes/composite.ts +0 -171
- package/types/src/classes/composite.d.ts +0 -15
package/test/collection.test.ts
CHANGED
|
@@ -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
|
|
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('
|
|
856
|
-
test('Collection
|
|
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
|
|
752
|
+
let collectionWatchedCalled = false
|
|
861
753
|
let collectionUnwatchCalled = false
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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(
|
|
763
|
+
expect(collectionWatchedCalled).toBe(false)
|
|
872
764
|
|
|
873
|
-
// Access collection via collection.get() - this should trigger collection's
|
|
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(
|
|
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('
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
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(
|
|
952
|
-
expect(
|
|
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(
|
|
819
|
+
expect(collectionUnwatchedCalled).toBe(true)
|
|
966
820
|
|
|
967
821
|
itemCleanup()
|
|
968
822
|
})
|
|
969
823
|
|
|
970
|
-
test('
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
|
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(
|
|
844
|
+
expect(collectionWatchedCalled).toBe(true)
|
|
1073
845
|
expect(effectValue).toBe(3)
|
|
1074
|
-
expect(
|
|
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
|
})
|