@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.
- package/.ai-context.md +26 -5
- package/.cursorrules +8 -3
- package/.github/copilot-instructions.md +13 -4
- package/CLAUDE.md +191 -262
- package/README.md +268 -420
- package/archive/collection.ts +23 -25
- package/archive/computed.ts +5 -4
- package/archive/list.ts +21 -28
- package/archive/memo.ts +4 -2
- package/archive/state.ts +2 -1
- package/archive/store.ts +21 -32
- package/archive/task.ts +6 -9
- package/index.dev.js +411 -220
- package/index.js +1 -1
- package/index.ts +25 -8
- package/package.json +1 -1
- package/src/classes/collection.ts +103 -77
- package/src/classes/composite.ts +28 -33
- package/src/classes/computed.ts +90 -31
- package/src/classes/list.ts +39 -33
- package/src/classes/ref.ts +96 -0
- 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 +31 -1
- package/src/match.ts +5 -12
- package/src/resolve.ts +3 -2
- package/src/signal.ts +0 -1
- package/src/system.ts +159 -43
- package/src/util.ts +0 -10
- package/test/collection.test.ts +383 -67
- 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 +381 -0
- package/test/state.test.ts +13 -13
- package/test/store.test.ts +473 -28
- package/types/index.d.ts +6 -5
- package/types/src/classes/collection.d.ts +27 -12
- 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 +6 -6
- package/types/src/classes/ref.d.ts +48 -0
- 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 +9 -1
- package/types/src/system.d.ts +40 -24
- package/types/src/util.d.ts +1 -3
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
|
})
|
package/test/effect.test.ts
CHANGED
|
@@ -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
|
-
'
|
|
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
|
-
'
|
|
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('
|
|
133
|
+
test('triggers HOOK_SORT with new order', () => {
|
|
134
134
|
const numbers = new List([3, 1, 2])
|
|
135
|
-
let
|
|
135
|
+
let order: readonly string[] | undefined
|
|
136
136
|
numbers.on('sort', sort => {
|
|
137
|
-
|
|
137
|
+
order = sort
|
|
138
138
|
})
|
|
139
139
|
numbers.sort()
|
|
140
|
-
expect(
|
|
141
|
-
expect(
|
|
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('
|
|
306
|
-
test('
|
|
305
|
+
describe('Hooks', () => {
|
|
306
|
+
test('trigger HOOK_ADD when adding items', () => {
|
|
307
307
|
const numbers = new List([1, 2])
|
|
308
|
-
let
|
|
308
|
+
let addedKeys: readonly string[] | undefined
|
|
309
309
|
let newArray: number[] = []
|
|
310
310
|
numbers.on('add', add => {
|
|
311
|
-
|
|
311
|
+
addedKeys = add
|
|
312
312
|
newArray = numbers.get()
|
|
313
313
|
})
|
|
314
314
|
numbers.add(3)
|
|
315
|
-
expect(
|
|
315
|
+
expect(addedKeys).toHaveLength(1)
|
|
316
316
|
expect(newArray).toEqual([1, 2, 3])
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
-
test('
|
|
319
|
+
test('triggers HOOK_CHANGE when properties are modified', () => {
|
|
320
320
|
const items = new List([{ value: 10 }])
|
|
321
|
-
let
|
|
321
|
+
let changedKeys: readonly string[] | undefined
|
|
322
322
|
let newArray: { value: number }[] = []
|
|
323
323
|
items.on('change', change => {
|
|
324
|
-
|
|
324
|
+
changedKeys = change
|
|
325
325
|
newArray = items.get()
|
|
326
326
|
})
|
|
327
327
|
items.at(0)?.set({ value: 20 })
|
|
328
|
-
expect(
|
|
328
|
+
expect(changedKeys).toHaveLength(1)
|
|
329
329
|
expect(newArray).toEqual([{ value: 20 }])
|
|
330
330
|
})
|
|
331
331
|
|
|
332
|
-
test('
|
|
332
|
+
test('triggers HOOK_REMOVE when items are removed', () => {
|
|
333
333
|
const items = new List([1, 2, 3])
|
|
334
|
-
let
|
|
334
|
+
let removedKeys: readonly string[] | undefined
|
|
335
335
|
let newArray: number[] = []
|
|
336
336
|
items.on('remove', remove => {
|
|
337
|
-
|
|
337
|
+
removedKeys = remove
|
|
338
338
|
newArray = items.get()
|
|
339
339
|
})
|
|
340
340
|
items.remove(1)
|
|
341
|
-
expect(
|
|
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
|
})
|