@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.
- package/.ai-context.md +7 -0
- package/.github/copilot-instructions.md +4 -0
- package/CLAUDE.md +96 -1
- package/README.md +44 -7
- package/archive/collection.ts +23 -25
- package/archive/computed.ts +3 -2
- package/archive/list.ts +21 -28
- package/archive/memo.ts +2 -1
- package/archive/state.ts +2 -1
- package/archive/store.ts +21 -32
- package/archive/task.ts +4 -7
- package/index.dev.js +356 -198
- package/index.js +1 -1
- package/index.ts +15 -6
- package/package.json +1 -1
- package/src/classes/collection.ts +69 -53
- package/src/classes/composite.ts +28 -33
- package/src/classes/computed.ts +87 -28
- package/src/classes/list.ts +31 -26
- package/src/classes/ref.ts +33 -5
- package/src/classes/state.ts +41 -8
- package/src/classes/store.ts +47 -30
- package/src/diff.ts +2 -1
- package/src/effect.ts +19 -9
- package/src/errors.ts +10 -1
- package/src/resolve.ts +1 -1
- package/src/signal.ts +0 -1
- package/src/system.ts +159 -43
- package/src/util.ts +0 -6
- package/test/collection.test.ts +279 -20
- package/test/computed.test.ts +268 -11
- package/test/effect.test.ts +2 -2
- package/test/list.test.ts +249 -21
- package/test/ref.test.ts +154 -0
- package/test/state.test.ts +13 -13
- package/test/store.test.ts +473 -28
- package/types/index.d.ts +3 -3
- package/types/src/classes/collection.d.ts +8 -7
- package/types/src/classes/composite.d.ts +4 -4
- package/types/src/classes/computed.d.ts +17 -0
- package/types/src/classes/list.d.ts +2 -2
- package/types/src/classes/ref.d.ts +10 -1
- package/types/src/classes/state.d.ts +9 -0
- package/types/src/classes/store.d.ts +4 -4
- package/types/src/effect.d.ts +1 -2
- package/types/src/errors.d.ts +4 -1
- package/types/src/system.d.ts +40 -24
- package/types/src/util.d.ts +1 -2
package/test/collection.test.ts
CHANGED
|
@@ -230,46 +230,46 @@ describe('collection', () => {
|
|
|
230
230
|
})
|
|
231
231
|
})
|
|
232
232
|
|
|
233
|
-
describe('
|
|
234
|
-
test('
|
|
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
|
|
238
|
+
let addedKeys: readonly string[] | undefined
|
|
239
239
|
doubled.on('add', keys => {
|
|
240
|
-
|
|
240
|
+
addedKeys = keys
|
|
241
241
|
})
|
|
242
242
|
|
|
243
243
|
numbers.add(3)
|
|
244
|
-
expect(
|
|
245
|
-
|
|
246
|
-
expect(doubled.byKey(
|
|
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('
|
|
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
|
|
253
|
+
let removedKeys: readonly string[] | undefined
|
|
254
254
|
doubled.on('remove', keys => {
|
|
255
|
-
|
|
255
|
+
removedKeys = keys
|
|
256
256
|
})
|
|
257
257
|
|
|
258
258
|
items.remove(1)
|
|
259
|
-
expect(
|
|
259
|
+
expect(removedKeys).toHaveLength(1)
|
|
260
260
|
})
|
|
261
261
|
|
|
262
|
-
test('
|
|
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
|
|
266
|
+
let order: readonly string[] | undefined
|
|
267
267
|
doubled.on('sort', newOrder => {
|
|
268
|
-
|
|
268
|
+
order = newOrder
|
|
269
269
|
})
|
|
270
270
|
|
|
271
271
|
numbers.sort((a, b) => a - b)
|
|
272
|
-
expect(
|
|
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
|
-
|
|
743
|
-
|
|
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
|
})
|
package/test/computed.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
})
|