@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/store.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
State,
|
|
9
9
|
UNSET,
|
|
10
10
|
} from '../index.ts'
|
|
11
|
+
import { HOOK_WATCH } from '../src/system'
|
|
11
12
|
|
|
12
13
|
describe('store', () => {
|
|
13
14
|
describe('creation and basic operations', () => {
|
|
@@ -225,54 +226,54 @@ describe('store', () => {
|
|
|
225
226
|
})
|
|
226
227
|
})
|
|
227
228
|
|
|
228
|
-
describe('
|
|
229
|
-
test('
|
|
230
|
-
let
|
|
229
|
+
describe('Hooks', () => {
|
|
230
|
+
test('triggers HOOK_ADD when properties are added', () => {
|
|
231
|
+
let addedKeys: readonly string[] | undefined
|
|
231
232
|
const user = createStore<{ name: string; email?: string }>({
|
|
232
233
|
name: 'John',
|
|
233
234
|
})
|
|
234
235
|
user.on('add', add => {
|
|
235
|
-
|
|
236
|
+
addedKeys = add
|
|
236
237
|
})
|
|
237
238
|
user.add('email', 'john@example.com')
|
|
238
|
-
expect(
|
|
239
|
+
expect(addedKeys).toContain('email')
|
|
239
240
|
})
|
|
240
241
|
|
|
241
|
-
test('
|
|
242
|
+
test('triggers HOOK_CHANGE when properties are modified', () => {
|
|
242
243
|
const user = createStore({ name: 'John' })
|
|
243
|
-
let
|
|
244
|
+
let changedKeys: readonly string[] | undefined
|
|
244
245
|
user.on('change', change => {
|
|
245
|
-
|
|
246
|
+
changedKeys = change
|
|
246
247
|
})
|
|
247
248
|
user.name.set('Jane')
|
|
248
|
-
expect(
|
|
249
|
+
expect(changedKeys).toContain('name')
|
|
249
250
|
})
|
|
250
251
|
|
|
251
|
-
test('
|
|
252
|
+
test('triggers HOOK_CHANGE for nested property changes', () => {
|
|
252
253
|
const user = createStore({
|
|
253
254
|
preferences: {
|
|
254
255
|
theme: 'light',
|
|
255
256
|
},
|
|
256
257
|
})
|
|
257
|
-
let
|
|
258
|
+
let changedKeys: readonly string[] | undefined
|
|
258
259
|
user.on('change', change => {
|
|
259
|
-
|
|
260
|
+
changedKeys = change
|
|
260
261
|
})
|
|
261
262
|
user.preferences.theme.set('dark')
|
|
262
|
-
expect(
|
|
263
|
+
expect(changedKeys).toContain('preferences')
|
|
263
264
|
})
|
|
264
265
|
|
|
265
|
-
test('
|
|
266
|
+
test('triggers HOOK_REMOVE when properties are removed', () => {
|
|
266
267
|
const user = createStore({
|
|
267
268
|
name: 'John',
|
|
268
269
|
email: 'john@example.com',
|
|
269
270
|
})
|
|
270
|
-
let
|
|
271
|
+
let removedKeys: readonly string[] | undefined
|
|
271
272
|
user.on('remove', remove => {
|
|
272
|
-
|
|
273
|
+
removedKeys = remove
|
|
273
274
|
})
|
|
274
275
|
user.remove('email')
|
|
275
|
-
expect(
|
|
276
|
+
expect(removedKeys).toContain('email')
|
|
276
277
|
})
|
|
277
278
|
|
|
278
279
|
test('set() correctly handles mixed changes, additions, and removals', () => {
|
|
@@ -289,18 +290,18 @@ describe('store', () => {
|
|
|
289
290
|
},
|
|
290
291
|
})
|
|
291
292
|
|
|
292
|
-
let
|
|
293
|
-
let
|
|
294
|
-
let
|
|
293
|
+
let changedKeys: readonly string[] | undefined
|
|
294
|
+
let addedKeys: readonly string[] | undefined
|
|
295
|
+
let removedKeys: readonly string[] | undefined
|
|
295
296
|
|
|
296
297
|
user.on('change', change => {
|
|
297
|
-
|
|
298
|
+
changedKeys = change
|
|
298
299
|
})
|
|
299
300
|
user.on('add', add => {
|
|
300
|
-
|
|
301
|
+
addedKeys = add
|
|
301
302
|
})
|
|
302
303
|
user.on('remove', remove => {
|
|
303
|
-
|
|
304
|
+
removedKeys = remove
|
|
304
305
|
})
|
|
305
306
|
|
|
306
307
|
user.set({
|
|
@@ -311,13 +312,13 @@ describe('store', () => {
|
|
|
311
312
|
age: 30,
|
|
312
313
|
})
|
|
313
314
|
|
|
314
|
-
expect(
|
|
315
|
-
expect(
|
|
316
|
-
expect(
|
|
317
|
-
expect(
|
|
315
|
+
expect(changedKeys).toContain('name')
|
|
316
|
+
expect(changedKeys).toContain('preferences')
|
|
317
|
+
expect(addedKeys).toContain('age')
|
|
318
|
+
expect(removedKeys).toContain('email')
|
|
318
319
|
})
|
|
319
320
|
|
|
320
|
-
test('
|
|
321
|
+
test('hooks can be removed', () => {
|
|
321
322
|
const user = createStore({ name: 'John' })
|
|
322
323
|
let notificationCount = 0
|
|
323
324
|
const listener = () => {
|
|
@@ -660,4 +661,448 @@ describe('store', () => {
|
|
|
660
661
|
expect(config.database.port.get()).toBe(5432)
|
|
661
662
|
})
|
|
662
663
|
})
|
|
664
|
+
|
|
665
|
+
describe('HOOK_WATCH - Store Hierarchy Resource Management', () => {
|
|
666
|
+
test('Store HOOK_WATCH triggers for all nested stores when accessing parent', async () => {
|
|
667
|
+
const store = createStore({
|
|
668
|
+
app: {
|
|
669
|
+
database: {
|
|
670
|
+
host: 'localhost',
|
|
671
|
+
port: 5432,
|
|
672
|
+
},
|
|
673
|
+
cache: {
|
|
674
|
+
ttl: 3600,
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
let appCounter = 0
|
|
680
|
+
let databaseCounter = 0
|
|
681
|
+
let cacheCounter = 0
|
|
682
|
+
|
|
683
|
+
const appCleanup = store.app.on(HOOK_WATCH, () => {
|
|
684
|
+
appCounter++
|
|
685
|
+
return () => {
|
|
686
|
+
appCounter--
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
const databaseCleanup = store.app.database.on(HOOK_WATCH, () => {
|
|
691
|
+
databaseCounter++
|
|
692
|
+
return () => {
|
|
693
|
+
databaseCounter--
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
const cacheCleanup = store.app.cache.on(HOOK_WATCH, () => {
|
|
698
|
+
cacheCounter++
|
|
699
|
+
return () => {
|
|
700
|
+
cacheCounter--
|
|
701
|
+
}
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// Initially no watchers
|
|
705
|
+
expect(appCounter).toBe(0)
|
|
706
|
+
expect(databaseCounter).toBe(0)
|
|
707
|
+
expect(cacheCounter).toBe(0)
|
|
708
|
+
|
|
709
|
+
// Access app store - should trigger ALL nested HOOK_WATCH callbacks
|
|
710
|
+
const appEffect = createEffect(() => {
|
|
711
|
+
store.app.get()
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
expect(appCounter).toBe(1)
|
|
715
|
+
expect(databaseCounter).toBe(1)
|
|
716
|
+
expect(cacheCounter).toBe(1)
|
|
717
|
+
|
|
718
|
+
// Cleanup should reset all counters
|
|
719
|
+
appEffect()
|
|
720
|
+
expect(appCounter).toBe(0)
|
|
721
|
+
expect(databaseCounter).toBe(0)
|
|
722
|
+
expect(cacheCounter).toBe(0)
|
|
723
|
+
|
|
724
|
+
appCleanup()
|
|
725
|
+
databaseCleanup()
|
|
726
|
+
cacheCleanup()
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
test('Nested store cleanup only happens when all levels are unwatched', async () => {
|
|
730
|
+
const store = createStore({
|
|
731
|
+
user: {
|
|
732
|
+
profile: {
|
|
733
|
+
settings: {
|
|
734
|
+
theme: 'dark',
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
let counter = 0
|
|
741
|
+
let intervalId: Timer | undefined
|
|
742
|
+
|
|
743
|
+
// Add HOOK_WATCH to deepest nested store
|
|
744
|
+
const settingsCleanup = store.user.profile.settings.on(
|
|
745
|
+
HOOK_WATCH,
|
|
746
|
+
() => {
|
|
747
|
+
intervalId = setInterval(() => {
|
|
748
|
+
counter++
|
|
749
|
+
}, 10)
|
|
750
|
+
|
|
751
|
+
return () => {
|
|
752
|
+
if (intervalId) {
|
|
753
|
+
clearInterval(intervalId)
|
|
754
|
+
intervalId = undefined
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
expect(counter).toBe(0)
|
|
761
|
+
|
|
762
|
+
// Access parent store - should trigger settings HOOK_WATCH
|
|
763
|
+
const parentEffect = createEffect(() => {
|
|
764
|
+
store.user.get()
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
768
|
+
expect(counter).toBeGreaterThan(0)
|
|
769
|
+
expect(intervalId).toBeDefined()
|
|
770
|
+
|
|
771
|
+
// Access intermediate store - settings should still be active
|
|
772
|
+
const profileEffect = createEffect(() => {
|
|
773
|
+
store.user.profile.get()
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const counterAfterProfile = counter
|
|
777
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
778
|
+
expect(counter).toBeGreaterThan(counterAfterProfile)
|
|
779
|
+
expect(intervalId).toBeDefined()
|
|
780
|
+
|
|
781
|
+
// Remove parent watcher, but profile watcher still active
|
|
782
|
+
parentEffect()
|
|
783
|
+
|
|
784
|
+
const counterAfterParentRemoval = counter
|
|
785
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
786
|
+
expect(counter).toBeGreaterThan(counterAfterParentRemoval)
|
|
787
|
+
expect(intervalId).toBeDefined() // Still running
|
|
788
|
+
|
|
789
|
+
// Remove profile watcher - now should cleanup
|
|
790
|
+
profileEffect()
|
|
791
|
+
|
|
792
|
+
const counterAfterAllRemoval = counter
|
|
793
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
794
|
+
expect(counter).toBe(counterAfterAllRemoval) // Stopped
|
|
795
|
+
expect(intervalId).toBeUndefined()
|
|
796
|
+
|
|
797
|
+
settingsCleanup()
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
test('Root store HOOK_WATCH triggered only by direct store access', async () => {
|
|
801
|
+
const store = createStore({
|
|
802
|
+
user: {
|
|
803
|
+
name: 'John',
|
|
804
|
+
profile: {
|
|
805
|
+
email: 'john@example.com',
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
let rootStoreCounter = 0
|
|
811
|
+
let intervalId: Timer | undefined
|
|
812
|
+
|
|
813
|
+
// Add HOOK_WATCH callback to root store
|
|
814
|
+
const cleanupHookCallback = store.on(HOOK_WATCH, () => {
|
|
815
|
+
intervalId = setInterval(() => {
|
|
816
|
+
rootStoreCounter++
|
|
817
|
+
}, 10)
|
|
818
|
+
|
|
819
|
+
return () => {
|
|
820
|
+
if (intervalId) {
|
|
821
|
+
clearInterval(intervalId)
|
|
822
|
+
intervalId = undefined
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
expect(rootStoreCounter).toBe(0)
|
|
828
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
829
|
+
expect(rootStoreCounter).toBe(0)
|
|
830
|
+
|
|
831
|
+
// Access nested property directly - should NOT trigger root HOOK_WATCH
|
|
832
|
+
const nestedEffectCleanup = createEffect(() => {
|
|
833
|
+
store.user.name.get()
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
837
|
+
expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
|
|
838
|
+
expect(intervalId).toBeUndefined()
|
|
839
|
+
|
|
840
|
+
// Access root store directly - should trigger HOOK_WATCH
|
|
841
|
+
const rootEffectCleanup = createEffect(() => {
|
|
842
|
+
store.get()
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
846
|
+
expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
|
|
847
|
+
expect(intervalId).toBeDefined()
|
|
848
|
+
|
|
849
|
+
// Cleanup
|
|
850
|
+
rootEffectCleanup()
|
|
851
|
+
nestedEffectCleanup()
|
|
852
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
853
|
+
expect(intervalId).toBeUndefined()
|
|
854
|
+
|
|
855
|
+
cleanupHookCallback()
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
test('Each store level manages its own HOOK_WATCH independently', async () => {
|
|
859
|
+
const store = createStore({
|
|
860
|
+
config: {
|
|
861
|
+
database: {
|
|
862
|
+
host: 'localhost',
|
|
863
|
+
port: 5432,
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
let rootCounter = 0
|
|
869
|
+
let configCounter = 0
|
|
870
|
+
let databaseCounter = 0
|
|
871
|
+
|
|
872
|
+
// Add HOOK_WATCH to each level
|
|
873
|
+
const rootCleanup = store.on(HOOK_WATCH, () => {
|
|
874
|
+
rootCounter++
|
|
875
|
+
return () => {
|
|
876
|
+
rootCounter--
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
const configCleanup = store.config.on(HOOK_WATCH, () => {
|
|
881
|
+
configCounter++
|
|
882
|
+
return () => {
|
|
883
|
+
configCounter--
|
|
884
|
+
}
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
const databaseCleanup = store.config.database.on(HOOK_WATCH, () => {
|
|
888
|
+
databaseCounter++
|
|
889
|
+
return () => {
|
|
890
|
+
databaseCounter--
|
|
891
|
+
}
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
// All should start at 0
|
|
895
|
+
expect(rootCounter).toBe(0)
|
|
896
|
+
expect(configCounter).toBe(0)
|
|
897
|
+
expect(databaseCounter).toBe(0)
|
|
898
|
+
|
|
899
|
+
// Access deepest level - should NOT trigger any store HOOK_WATCH
|
|
900
|
+
// because we're only accessing the State signal, not calling .get() on stores
|
|
901
|
+
const deepEffectCleanup = createEffect(() => {
|
|
902
|
+
store.config.database.host.get()
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
expect(rootCounter).toBe(0)
|
|
906
|
+
expect(configCounter).toBe(0)
|
|
907
|
+
expect(databaseCounter).toBe(0)
|
|
908
|
+
|
|
909
|
+
// Access config level - should trigger config AND database HOOK_WATCH
|
|
910
|
+
const configEffectCleanup = createEffect(() => {
|
|
911
|
+
store.config.get()
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
expect(rootCounter).toBe(0)
|
|
915
|
+
expect(configCounter).toBe(1)
|
|
916
|
+
expect(databaseCounter).toBe(1) // Triggered by parent access
|
|
917
|
+
|
|
918
|
+
// Access root level - should trigger root HOOK_WATCH (config/database already active)
|
|
919
|
+
const rootEffectCleanup = createEffect(() => {
|
|
920
|
+
store.get()
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
expect(rootCounter).toBe(1)
|
|
924
|
+
expect(configCounter).toBe(1)
|
|
925
|
+
expect(databaseCounter).toBe(1)
|
|
926
|
+
|
|
927
|
+
// Cleanup in reverse order - database should stay active until config is cleaned up
|
|
928
|
+
rootEffectCleanup()
|
|
929
|
+
expect(rootCounter).toBe(0)
|
|
930
|
+
expect(configCounter).toBe(1)
|
|
931
|
+
expect(databaseCounter).toBe(1) // Still active due to config watcher
|
|
932
|
+
|
|
933
|
+
configEffectCleanup()
|
|
934
|
+
expect(rootCounter).toBe(0)
|
|
935
|
+
expect(configCounter).toBe(0)
|
|
936
|
+
expect(databaseCounter).toBe(0) // Now cleaned up
|
|
937
|
+
|
|
938
|
+
deepEffectCleanup()
|
|
939
|
+
expect(rootCounter).toBe(0)
|
|
940
|
+
expect(configCounter).toBe(0)
|
|
941
|
+
expect(databaseCounter).toBe(0)
|
|
942
|
+
|
|
943
|
+
// Cleanup hooks
|
|
944
|
+
rootCleanup()
|
|
945
|
+
configCleanup()
|
|
946
|
+
databaseCleanup()
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
test('Store HOOK_WATCH with multiple watchers at same level', async () => {
|
|
950
|
+
const store = createStore({
|
|
951
|
+
data: {
|
|
952
|
+
items: [] as string[],
|
|
953
|
+
count: 0,
|
|
954
|
+
},
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
let dataStoreCounter = 0
|
|
958
|
+
|
|
959
|
+
const dataCleanup = store.data.on(HOOK_WATCH, () => {
|
|
960
|
+
dataStoreCounter++
|
|
961
|
+
return () => {
|
|
962
|
+
dataStoreCounter--
|
|
963
|
+
}
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
expect(dataStoreCounter).toBe(0)
|
|
967
|
+
|
|
968
|
+
// Create multiple effects watching the data store
|
|
969
|
+
const effect1 = createEffect(() => {
|
|
970
|
+
store.data.get()
|
|
971
|
+
})
|
|
972
|
+
const effect2 = createEffect(() => {
|
|
973
|
+
store.data.get()
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// Should only trigger once (shared resources)
|
|
977
|
+
expect(dataStoreCounter).toBe(1)
|
|
978
|
+
|
|
979
|
+
// Stop one effect
|
|
980
|
+
effect1()
|
|
981
|
+
expect(dataStoreCounter).toBe(1) // Still active
|
|
982
|
+
|
|
983
|
+
// Stop second effect
|
|
984
|
+
effect2()
|
|
985
|
+
expect(dataStoreCounter).toBe(0) // Now cleaned up
|
|
986
|
+
|
|
987
|
+
dataCleanup()
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
test('Store property addition/removal affects individual store HOOK_WATCH', async () => {
|
|
991
|
+
const store = createStore({
|
|
992
|
+
users: {} as Record<string, { name: string }>,
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
let usersStoreCounter = 0
|
|
996
|
+
|
|
997
|
+
const usersCleanup = store.users.on(HOOK_WATCH, () => {
|
|
998
|
+
usersStoreCounter++
|
|
999
|
+
return () => {
|
|
1000
|
+
usersStoreCounter--
|
|
1001
|
+
}
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
expect(usersStoreCounter).toBe(0)
|
|
1005
|
+
|
|
1006
|
+
// Watch the users store
|
|
1007
|
+
const usersEffect = createEffect(() => {
|
|
1008
|
+
store.users.get()
|
|
1009
|
+
})
|
|
1010
|
+
expect(usersStoreCounter).toBe(1)
|
|
1011
|
+
|
|
1012
|
+
// Add a user - this modifies the users store content but doesn't affect HOOK_WATCH
|
|
1013
|
+
store.users.add('user1', { name: 'Alice' })
|
|
1014
|
+
expect(usersStoreCounter).toBe(1) // Still 1
|
|
1015
|
+
|
|
1016
|
+
// Watch a specific user property - this doesn't trigger users store HOOK_WATCH
|
|
1017
|
+
const userEffect = createEffect(() => {
|
|
1018
|
+
store.users.user1?.name.get()
|
|
1019
|
+
})
|
|
1020
|
+
expect(usersStoreCounter).toBe(1) // Still 1
|
|
1021
|
+
|
|
1022
|
+
// Cleanup user effect
|
|
1023
|
+
userEffect()
|
|
1024
|
+
expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
|
|
1025
|
+
|
|
1026
|
+
// Cleanup users effect
|
|
1027
|
+
usersEffect()
|
|
1028
|
+
expect(usersStoreCounter).toBe(0) // Now cleaned up
|
|
1029
|
+
|
|
1030
|
+
usersCleanup()
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
test('Exception handling in store HOOK_WATCH callbacks', async () => {
|
|
1034
|
+
const store = createStore({
|
|
1035
|
+
config: { theme: 'dark' },
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
let successfulCallbackCalled = false
|
|
1039
|
+
let throwingCallbackCalled = false
|
|
1040
|
+
|
|
1041
|
+
// Add throwing callback
|
|
1042
|
+
const cleanup1 = store.on(HOOK_WATCH, () => {
|
|
1043
|
+
throwingCallbackCalled = true
|
|
1044
|
+
throw new Error('Test error in store HOOK_WATCH')
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
// Add successful callback
|
|
1048
|
+
const cleanup2 = store.on(HOOK_WATCH, () => {
|
|
1049
|
+
successfulCallbackCalled = true
|
|
1050
|
+
return () => {
|
|
1051
|
+
// cleanup
|
|
1052
|
+
}
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
// Trigger callbacks through direct store access - should throw
|
|
1056
|
+
expect(() => store.get()).toThrow('Test error in store HOOK_WATCH')
|
|
1057
|
+
|
|
1058
|
+
// Both callbacks should have been called
|
|
1059
|
+
expect(throwingCallbackCalled).toBe(true)
|
|
1060
|
+
expect(successfulCallbackCalled).toBe(true)
|
|
1061
|
+
|
|
1062
|
+
cleanup1()
|
|
1063
|
+
cleanup2()
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test('Nested store HOOK_WATCH with computed signals', async () => {
|
|
1067
|
+
const store = createStore({
|
|
1068
|
+
user: {
|
|
1069
|
+
firstName: 'John',
|
|
1070
|
+
lastName: 'Doe',
|
|
1071
|
+
},
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
let userStoreCounter = 0
|
|
1075
|
+
|
|
1076
|
+
const userCleanup = store.user.on(HOOK_WATCH, () => {
|
|
1077
|
+
userStoreCounter++
|
|
1078
|
+
return () => {
|
|
1079
|
+
userStoreCounter--
|
|
1080
|
+
}
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
expect(userStoreCounter).toBe(0)
|
|
1084
|
+
|
|
1085
|
+
// Access user store directly - should trigger user store HOOK_WATCH
|
|
1086
|
+
const userEffect = createEffect(() => {
|
|
1087
|
+
store.user.get()
|
|
1088
|
+
})
|
|
1089
|
+
expect(userStoreCounter).toBe(1)
|
|
1090
|
+
|
|
1091
|
+
// Access individual properties - should NOT trigger user store HOOK_WATCH again
|
|
1092
|
+
const nameEffect = createEffect(() => {
|
|
1093
|
+
store.user.firstName.get()
|
|
1094
|
+
})
|
|
1095
|
+
expect(userStoreCounter).toBe(1) // Still 1
|
|
1096
|
+
|
|
1097
|
+
// Cleanup individual property effect first
|
|
1098
|
+
nameEffect()
|
|
1099
|
+
expect(userStoreCounter).toBe(1) // Still active due to user store effect
|
|
1100
|
+
|
|
1101
|
+
// Cleanup user store effect - now should be cleaned up
|
|
1102
|
+
userEffect()
|
|
1103
|
+
expect(userStoreCounter).toBe(0) // Now cleaned up
|
|
1104
|
+
|
|
1105
|
+
userCleanup()
|
|
1106
|
+
})
|
|
1107
|
+
})
|
|
663
1108
|
})
|
package/types/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @name Cause & Effect
|
|
3
|
-
* @version 0.17.
|
|
3
|
+
* @version 0.17.2
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
6
|
export { type Collection, type CollectionCallback, type CollectionSource, DerivedCollection, isCollection, TYPE_COLLECTION, } from './src/classes/collection';
|
|
@@ -15,5 +15,5 @@ export { CircularDependencyError, createError, DuplicateKeyError, type Guard, gu
|
|
|
15
15
|
export { type MatchHandlers, match } from './src/match';
|
|
16
16
|
export { type ResolveResult, resolve } from './src/resolve';
|
|
17
17
|
export { createSignal, isMutableSignal, isSignal, type Signal, type SignalValues, type UnknownSignalRecord, } from './src/signal';
|
|
18
|
-
export { batchSignalWrites, type Cleanup, createWatcher,
|
|
19
|
-
export { isAbortError, isAsyncFunction, isFunction, isNumber, isObjectOfType, isRecord, isRecordOrArray, isString, isSymbol,
|
|
18
|
+
export { batchSignalWrites, type Cleanup, createWatcher, flushPendingReactions, HOOK_ADD, HOOK_CHANGE, HOOK_CLEANUP, HOOK_REMOVE, HOOK_SORT, HOOK_WATCH, type Hook, type CleanupHook, type WatchHook, type HookCallback, type HookCallbacks, isHandledHook, notifyWatchers, subscribeActiveWatcher, trackSignalReads, triggerHook, UNSET, type Watcher, } from './src/system';
|
|
19
|
+
export { isAbortError, isAsyncFunction, isFunction, isNumber, isObjectOfType, isRecord, isRecordOrArray, isString, isSymbol, valueString, } from './src/util';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Signal } from '../signal';
|
|
2
|
-
import { type Cleanup, type
|
|
2
|
+
import { type Cleanup, type Hook, type HookCallback } from '../system';
|
|
3
3
|
import { type Computed } from './computed';
|
|
4
4
|
import { type List } from './list';
|
|
5
5
|
type CollectionSource<T extends {}> = List<T> | Collection<T>;
|
|
@@ -8,12 +8,13 @@ type Collection<T extends {}> = {
|
|
|
8
8
|
readonly [Symbol.toStringTag]: 'Collection';
|
|
9
9
|
readonly [Symbol.isConcatSpreadable]: true;
|
|
10
10
|
[Symbol.iterator](): IterableIterator<Signal<T>>;
|
|
11
|
+
keys(): IterableIterator<string>;
|
|
11
12
|
get: () => T[];
|
|
12
13
|
at: (index: number) => Signal<T> | undefined;
|
|
13
14
|
byKey: (key: string) => Signal<T> | undefined;
|
|
14
15
|
keyAt: (index: number) => string | undefined;
|
|
15
16
|
indexOfKey: (key: string) => number | undefined;
|
|
16
|
-
on: <K extends
|
|
17
|
+
on: <K extends Hook>(type: K, callback: HookCallback) => Cleanup;
|
|
17
18
|
deriveCollection: <R extends {}>(callback: CollectionCallback<R, T>) => DerivedCollection<R, T>;
|
|
18
19
|
readonly length: number;
|
|
19
20
|
};
|
|
@@ -24,23 +25,23 @@ declare class DerivedCollection<T extends {}, U extends {}> implements Collectio
|
|
|
24
25
|
get [Symbol.toStringTag](): 'Collection';
|
|
25
26
|
get [Symbol.isConcatSpreadable](): true;
|
|
26
27
|
[Symbol.iterator](): IterableIterator<Computed<T>>;
|
|
27
|
-
|
|
28
|
+
keys(): IterableIterator<string>;
|
|
28
29
|
get(): T[];
|
|
29
30
|
at(index: number): Computed<T> | undefined;
|
|
30
|
-
keys(): IterableIterator<string>;
|
|
31
31
|
byKey(key: string): Computed<T> | undefined;
|
|
32
32
|
keyAt(index: number): string | undefined;
|
|
33
33
|
indexOfKey(key: string): number;
|
|
34
|
-
on
|
|
34
|
+
on(type: Hook, callback: HookCallback): Cleanup;
|
|
35
35
|
deriveCollection<R extends {}>(callback: (sourceValue: T) => R): DerivedCollection<R, T>;
|
|
36
36
|
deriveCollection<R extends {}>(callback: (sourceValue: T, abort: AbortSignal) => Promise<R>): DerivedCollection<R, T>;
|
|
37
|
+
get length(): number;
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* Check if a value is a collection signal
|
|
40
41
|
*
|
|
41
|
-
* @since 0.17.
|
|
42
|
+
* @since 0.17.2
|
|
42
43
|
* @param {unknown} value - Value to check
|
|
43
44
|
* @returns {boolean} - True if value is a collection signal, false otherwise
|
|
44
45
|
*/
|
|
45
|
-
declare const isCollection: <T extends {}
|
|
46
|
+
declare const isCollection: <T extends {}>(value: unknown) => value is Collection<T>;
|
|
46
47
|
export { type Collection, type CollectionSource, type CollectionCallback, DerivedCollection, isCollection, TYPE_COLLECTION, };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DiffResult, UnknownRecord } from '../diff';
|
|
2
2
|
import type { Signal } from '../signal';
|
|
3
|
-
import { type Cleanup, type
|
|
4
|
-
type
|
|
3
|
+
import { type Cleanup, type HookCallback } from '../system';
|
|
4
|
+
type CompositeHook = 'add' | 'change' | 'remove';
|
|
5
5
|
declare class Composite<T extends UnknownRecord, S extends Signal<T[keyof T] & {}>> {
|
|
6
6
|
#private;
|
|
7
7
|
signals: Map<string, S>;
|
|
@@ -10,6 +10,6 @@ declare class Composite<T extends UnknownRecord, S extends Signal<T[keyof T] & {
|
|
|
10
10
|
remove<K extends keyof T & string>(key: K): boolean;
|
|
11
11
|
change(changes: DiffResult, initialRun?: boolean): boolean;
|
|
12
12
|
clear(): boolean;
|
|
13
|
-
on
|
|
13
|
+
on(type: CompositeHook, callback: HookCallback): Cleanup;
|
|
14
14
|
}
|
|
15
|
-
export { Composite, type CompositeListeners };
|
|
15
|
+
export { Composite, type CompositeHook as CompositeListeners };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Cleanup, type HookCallback, type WatchHook } from '../system';
|
|
1
2
|
type Computed<T extends {}> = {
|
|
2
3
|
readonly [Symbol.toStringTag]: 'Computed';
|
|
3
4
|
get(): T;
|
|
@@ -34,6 +35,14 @@ declare class Memo<T extends {}> {
|
|
|
34
35
|
* @throws {Error} If an error occurs during computation
|
|
35
36
|
*/
|
|
36
37
|
get(): T;
|
|
38
|
+
/**
|
|
39
|
+
* Register a callback to be called when HOOK_WATCH is triggered.
|
|
40
|
+
*
|
|
41
|
+
* @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
|
|
42
|
+
* @param {HookCallback} callback - The callback to register
|
|
43
|
+
* @returns {Cleanup} - A function to unregister the callback
|
|
44
|
+
*/
|
|
45
|
+
on(type: WatchHook, callback: HookCallback): Cleanup;
|
|
37
46
|
}
|
|
38
47
|
/**
|
|
39
48
|
* Create a new task signals that memoizes the result of an asynchronous function.
|
|
@@ -60,6 +69,14 @@ declare class Task<T extends {}> {
|
|
|
60
69
|
* @throws {Error} If an error occurs during computation
|
|
61
70
|
*/
|
|
62
71
|
get(): T;
|
|
72
|
+
/**
|
|
73
|
+
* Register a callback to be called when HOOK_WATCH is triggered.
|
|
74
|
+
*
|
|
75
|
+
* @param {WatchHook} type - The type of hook to register the callback for; only HOOK_WATCH is supported
|
|
76
|
+
* @param {HookCallback} callback - The callback to register
|
|
77
|
+
* @returns {Cleanup} - A function to unregister the callback
|
|
78
|
+
*/
|
|
79
|
+
on(type: WatchHook, callback: HookCallback): Cleanup;
|
|
63
80
|
}
|
|
64
81
|
/**
|
|
65
82
|
* Create a derived signal from existing signals
|