@zeix/cause-effect 0.15.0 → 0.15.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.
@@ -3,9 +3,11 @@ import {
3
3
  computed,
4
4
  effect,
5
5
  isStore,
6
+ type State,
6
7
  type StoreAddEvent,
7
8
  type StoreChangeEvent,
8
9
  type StoreRemoveEvent,
10
+ type StoreSortEvent,
9
11
  state,
10
12
  store,
11
13
  UNSET,
@@ -44,6 +46,15 @@ describe('store', () => {
44
46
  name: 'Hannah',
45
47
  email: 'hannah@example.com',
46
48
  })
49
+
50
+ const participants = store<{ name: string; tags: string[] }[]>([
51
+ { name: 'Alice', tags: ['friends', 'mates'] },
52
+ { name: 'Bob', tags: ['friends'] },
53
+ ])
54
+ expect(participants.get()).toEqual([
55
+ { name: 'Alice', tags: ['friends', 'mates'] },
56
+ { name: 'Bob', tags: ['friends'] },
57
+ ])
47
58
  })
48
59
  })
49
60
 
@@ -108,6 +119,19 @@ describe('store', () => {
108
119
  name: 'Hannah',
109
120
  })
110
121
  })
122
+
123
+ test('add method prevents null values', () => {
124
+ const user = store<{ name: string; tags?: string[] }>({
125
+ name: 'Alice',
126
+ })
127
+
128
+ expect(() => {
129
+ // @ts-expect-error deliberate test that null values are not allowed
130
+ user.add('tags', null)
131
+ }).toThrow(
132
+ 'Nullish signal values are not allowed in store for key "tags"',
133
+ )
134
+ })
111
135
  })
112
136
 
113
137
  describe('nested stores', () => {
@@ -462,7 +486,12 @@ describe('store', () => {
462
486
  })
463
487
 
464
488
  const originalSize = user.size.get()
465
- user.add('email', 'new@example.com')
489
+
490
+ expect(() => {
491
+ user.add('email', 'new@example.com')
492
+ }).toThrow(
493
+ 'Could not add store key "email" with value "new@example.com" because it already exists',
494
+ )
466
495
 
467
496
  expect(user.email?.get()).toBe('original@example.com')
468
497
  expect(user.size.get()).toBe(originalSize)
@@ -512,6 +541,189 @@ describe('store', () => {
512
541
  })
513
542
  })
514
543
 
544
+ describe('array-derived stores with computed sum', () => {
545
+ test('computes sum correctly and updates when items are added, removed, or changed', () => {
546
+ // Create a store with an array of numbers
547
+ const numbers = store([1, 2, 3, 4, 5])
548
+
549
+ // Create a computed that calculates the sum by accessing the array via .get()
550
+ // This ensures reactivity to both value changes and structural changes
551
+ const sum = computed(() => {
552
+ const array = numbers.get()
553
+ if (!Array.isArray(array)) return 0
554
+ return array.reduce((acc, num) => acc + num, 0)
555
+ })
556
+
557
+ // Initial sum should be 15 (1+2+3+4+5)
558
+ expect(sum.get()).toBe(15)
559
+ expect(numbers.size.get()).toBe(5)
560
+
561
+ // Test adding items
562
+ numbers.add(6) // Add 6 at index 5
563
+ expect(sum.get()).toBe(21) // 15 + 6 = 21
564
+ expect(numbers.size.get()).toBe(6)
565
+
566
+ numbers.add(7) // Add 7 at index 6
567
+ expect(sum.get()).toBe(28) // 21 + 7 = 28
568
+ expect(numbers.size.get()).toBe(7)
569
+
570
+ // Test changing a single value
571
+ numbers[2].set(10) // Change index 2 from 3 to 10
572
+ expect(sum.get()).toBe(35) // 28 - 3 + 10 = 35
573
+
574
+ // Test another value change
575
+ numbers[0].set(5) // Change index 0 from 1 to 5
576
+ expect(sum.get()).toBe(39) // 35 - 1 + 5 = 39
577
+
578
+ // Test removing items
579
+ numbers.remove(6) // Remove index 6 (value 7)
580
+ expect(sum.get()).toBe(32) // 39 - 7 = 32
581
+ expect(numbers.size.get()).toBe(6)
582
+
583
+ numbers.remove(0) // Remove index 0 (value 5)
584
+ expect(sum.get()).toBe(27) // 32 - 5 = 27
585
+ expect(numbers.size.get()).toBe(5)
586
+
587
+ // Verify the final array structure using .get()
588
+ const finalArray = numbers.get()
589
+ expect(Array.isArray(finalArray)).toBe(true)
590
+ expect(finalArray).toEqual([2, 10, 4, 5, 6])
591
+ })
592
+
593
+ test('handles empty array and single element operations', () => {
594
+ // Start with empty array
595
+ const numbers = store<number[]>([])
596
+
597
+ const sum = computed(() => {
598
+ const array = numbers.get()
599
+ if (!Array.isArray(array)) return 0
600
+ return array.reduce((acc, num) => acc + num, 0)
601
+ })
602
+
603
+ // Empty array sum should be 0
604
+ expect(sum.get()).toBe(0)
605
+ expect(numbers.size.get()).toBe(0)
606
+
607
+ // Add first element
608
+ numbers.add(42)
609
+ expect(sum.get()).toBe(42)
610
+ expect(numbers.size.get()).toBe(1)
611
+
612
+ // Change the only element
613
+ numbers[0].set(100)
614
+ expect(sum.get()).toBe(100)
615
+
616
+ // Remove the only element
617
+ numbers.remove(0)
618
+ expect(sum.get()).toBe(0)
619
+ expect(numbers.size.get()).toBe(0)
620
+ })
621
+
622
+ test('computed sum using store iteration with size tracking', () => {
623
+ const numbers = store([10, 20, 30])
624
+
625
+ // Use iteration but also track size to ensure reactivity to additions/removals
626
+ const sum = computed(() => {
627
+ // Access size to subscribe to structural changes
628
+ numbers.size.get()
629
+ let total = 0
630
+ for (const signal of numbers) {
631
+ total += signal.get()
632
+ }
633
+ return total
634
+ })
635
+
636
+ expect(sum.get()).toBe(60)
637
+
638
+ // Add more numbers
639
+ numbers.add(40)
640
+ expect(sum.get()).toBe(100)
641
+
642
+ // Modify existing values
643
+ numbers[1].set(25) // Change 20 to 25
644
+ expect(sum.get()).toBe(105) // 10 + 25 + 30 + 40
645
+
646
+ // Remove a value
647
+ numbers.remove(2) // Remove 30
648
+ expect(sum.get()).toBe(75) // 10 + 25 + 40
649
+ })
650
+
651
+ test('demonstrates array compaction behavior with remove operations', () => {
652
+ // Create a store with an array
653
+ const numbers = store([10, 20, 30, 40, 50])
654
+
655
+ // Create a computed using iteration approach with size tracking
656
+ const sumWithIteration = computed(() => {
657
+ // Access size to subscribe to structural changes
658
+ numbers.size.get()
659
+ let total = 0
660
+ for (const signal of numbers) {
661
+ total += signal.get()
662
+ }
663
+ return total
664
+ })
665
+
666
+ // Create a computed using .get() approach for comparison
667
+ const sumWithGet = computed(() => {
668
+ const array = numbers.get()
669
+ if (!Array.isArray(array)) return 0
670
+ return array.reduce((acc, num) => acc + num, 0)
671
+ })
672
+
673
+ // Initial state: [10, 20, 30, 40, 50], keys [0,1,2,3,4]
674
+ expect(sumWithIteration.get()).toBe(150)
675
+ expect(sumWithGet.get()).toBe(150)
676
+ expect(numbers.size.get()).toBe(5)
677
+
678
+ // Remove items - arrays should compact (not create sparse holes)
679
+ numbers.remove(1) // Remove 20, array becomes [10, 30, 40, 50]
680
+ expect(numbers.size.get()).toBe(4)
681
+ expect(numbers.get()).toEqual([10, 30, 40, 50])
682
+ expect(sumWithIteration.get()).toBe(130) // 10 + 30 + 40 + 50
683
+ expect(sumWithGet.get()).toBe(130)
684
+
685
+ numbers.remove(2) // Remove 40, array becomes [10, 30, 50]
686
+ expect(numbers.size.get()).toBe(3)
687
+ expect(numbers.get()).toEqual([10, 30, 50])
688
+ expect(sumWithIteration.get()).toBe(90) // 10 + 30 + 50
689
+ expect(sumWithGet.get()).toBe(90)
690
+
691
+ // Set a new array of same size (3 elements)
692
+ numbers.set([100, 200, 300])
693
+ expect(numbers.size.get()).toBe(3)
694
+ expect(numbers.get()).toEqual([100, 200, 300])
695
+
696
+ // Both approaches work correctly with compacted arrays
697
+ expect(sumWithGet.get()).toBe(600) // 100 + 200 + 300
698
+ expect(sumWithIteration.get()).toBe(600) // Both work correctly
699
+ })
700
+
701
+ test('verifies root cause: diff works on array representation but reconcile uses sparse keys', () => {
702
+ // Create a sparse array scenario
703
+ const numbers = store([10, 20, 30])
704
+
705
+ // Remove middle element to create sparse structure
706
+ numbers.remove(1) // Now has keys ["0", "2"] with values [10, 30]
707
+
708
+ // Verify the sparse structure
709
+ expect(numbers.get()).toEqual([10, 30])
710
+ expect(numbers.size.get()).toBe(2)
711
+
712
+ // Now set a new array of same length
713
+ // The diff should see [10, 30] -> [100, 200] as:
714
+ // - index 0: 10 -> 100 (change)
715
+ // - index 1: 30 -> 200 (change)
716
+ // But internally the keys are ["0", "2"], not ["0", "1"]
717
+ numbers.set([100, 200])
718
+
719
+ // With the fix: sparse array replacement now works correctly
720
+ const result = numbers.get()
721
+
722
+ // The fix ensures proper sparse array replacement
723
+ expect(result).toEqual([100, 200]) // This now passes with the diff fix!
724
+ })
725
+ })
726
+
515
727
  describe('arrays and edge cases', () => {
516
728
  test('handles arrays as store values', () => {
517
729
  const data = store({ items: [1, 2, 3] })
@@ -716,4 +928,762 @@ describe('store', () => {
716
928
  expect(spread.app.name.get()).toBe('UpdatedApp')
717
929
  })
718
930
  })
931
+
932
+ describe('JSON integration', () => {
933
+ test('seamless integration with JSON.parse() and JSON.stringify() for API workflows', async () => {
934
+ // Simulate loading data from a JSON API response
935
+ const jsonResponse = `{
936
+ "user": {
937
+ "id": 123,
938
+ "name": "John Doe",
939
+ "email": "john@example.com",
940
+ "preferences": {
941
+ "theme": "dark",
942
+ "notifications": true,
943
+ "language": "en"
944
+ }
945
+ },
946
+ "settings": {
947
+ "autoSave": true,
948
+ "timeout": 5000
949
+ },
950
+ "tags": ["developer", "javascript", "typescript"]
951
+ }`
952
+
953
+ // Parse JSON and create store - works seamlessly
954
+ const apiData = JSON.parse(jsonResponse)
955
+ const userStore = store<{
956
+ user: {
957
+ id: number
958
+ name: string
959
+ email: string
960
+ preferences: {
961
+ theme: string
962
+ notifications: boolean
963
+ language: string
964
+ fontSize?: number
965
+ }
966
+ }
967
+ settings: {
968
+ autoSave: boolean
969
+ timeout: number
970
+ }
971
+ tags: string[]
972
+ lastLogin?: Date
973
+ }>(apiData)
974
+
975
+ // Verify initial data is accessible and reactive
976
+ expect(userStore.user.name.get()).toBe('John Doe')
977
+ expect(userStore.user.preferences.theme.get()).toBe('dark')
978
+ expect(userStore.settings.autoSave.get()).toBe(true)
979
+ expect(userStore.get().tags).toEqual([
980
+ 'developer',
981
+ 'javascript',
982
+ 'typescript',
983
+ ])
984
+
985
+ // Simulate user interactions - update preferences
986
+ userStore.user.preferences.theme.set('light')
987
+ userStore.user.preferences.notifications.set(false)
988
+
989
+ // Add new preference
990
+ userStore.user.preferences.add('fontSize', 14)
991
+
992
+ // Update settings
993
+ userStore.settings.timeout.set(10000)
994
+
995
+ // Add new top-level property
996
+ userStore.add('lastLogin', new Date('2024-01-15T10:30:00Z'))
997
+
998
+ // Verify changes are reflected
999
+ expect(userStore.user.preferences.theme.get()).toBe('light')
1000
+ expect(userStore.user.preferences.notifications.get()).toBe(false)
1001
+ expect(userStore.settings.timeout.get()).toBe(10000)
1002
+
1003
+ // Get current state and verify it's JSON-serializable
1004
+ const currentState = userStore.get()
1005
+ expect(currentState.user.preferences.theme).toBe('light')
1006
+ expect(currentState.user.preferences.notifications).toBe(false)
1007
+ expect(currentState.settings.timeout).toBe(10000)
1008
+ expect(currentState.tags).toEqual([
1009
+ 'developer',
1010
+ 'javascript',
1011
+ 'typescript',
1012
+ ])
1013
+
1014
+ // Convert back to JSON - seamless serialization
1015
+ const jsonPayload = JSON.stringify(currentState)
1016
+
1017
+ // Verify the JSON contains our updates
1018
+ const parsedBack = JSON.parse(jsonPayload)
1019
+ expect(parsedBack.user.preferences.theme).toBe('light')
1020
+ expect(parsedBack.user.preferences.notifications).toBe(false)
1021
+ expect(parsedBack.user.preferences.fontSize).toBe(14)
1022
+ expect(parsedBack.settings.timeout).toBe(10000)
1023
+ expect(parsedBack.lastLogin).toBe('2024-01-15T10:30:00.000Z')
1024
+
1025
+ // Demonstrate update() for bulk changes
1026
+ userStore.update(data => ({
1027
+ ...data,
1028
+ user: {
1029
+ ...data.user,
1030
+ email: 'john.doe@newcompany.com',
1031
+ preferences: {
1032
+ ...data.user.preferences,
1033
+ theme: 'auto',
1034
+ language: 'fr',
1035
+ },
1036
+ },
1037
+ settings: {
1038
+ ...data.settings,
1039
+ autoSave: false,
1040
+ },
1041
+ }))
1042
+
1043
+ // Verify bulk update worked
1044
+ expect(userStore.user.email.get()).toBe('john.doe@newcompany.com')
1045
+ expect(userStore.user.preferences.theme.get()).toBe('auto')
1046
+ expect(userStore.user.preferences.language.get()).toBe('fr')
1047
+ expect(userStore.settings.autoSave.get()).toBe(false)
1048
+
1049
+ // Final JSON serialization for sending to server
1050
+ const finalPayload = JSON.stringify(userStore.get())
1051
+ expect(typeof finalPayload).toBe('string')
1052
+ expect(finalPayload).toContain('john.doe@newcompany.com')
1053
+ expect(finalPayload).toContain('"theme":"auto"')
1054
+ })
1055
+
1056
+ test('handles complex nested structures and arrays from JSON', () => {
1057
+ const complexJson = `{
1058
+ "dashboard": {
1059
+ "widgets": [
1060
+ {"id": 1, "type": "chart", "config": {"color": "blue"}},
1061
+ {"id": 2, "type": "table", "config": {"rows": 10}}
1062
+ ],
1063
+ "layout": {
1064
+ "columns": 3,
1065
+ "responsive": true
1066
+ }
1067
+ },
1068
+ "metadata": {
1069
+ "version": "1.0.0",
1070
+ "created": "2024-01-01T00:00:00Z",
1071
+ "tags": null
1072
+ }
1073
+ }`
1074
+
1075
+ const data = JSON.parse(complexJson)
1076
+
1077
+ // Test that null values in initial JSON are filtered out (treated as UNSET)
1078
+ const dashboardStore = store<{
1079
+ dashboard: {
1080
+ widgets: {
1081
+ id: number
1082
+ type: string
1083
+ config: Record<string, string | number | boolean>
1084
+ }[]
1085
+ layout: {
1086
+ columns: number
1087
+ responsive: boolean
1088
+ }
1089
+ }
1090
+ metadata: {
1091
+ version: string
1092
+ created: string
1093
+ tags?: string[]
1094
+ }
1095
+ }>(data)
1096
+
1097
+ // Access nested array elements
1098
+ expect(dashboardStore.dashboard.widgets.get()).toHaveLength(2)
1099
+ expect(dashboardStore.dashboard.widgets[0].type.get()).toBe('chart')
1100
+ expect(dashboardStore.dashboard.widgets[1].config.rows.get()).toBe(
1101
+ 10,
1102
+ )
1103
+
1104
+ // Update array element
1105
+ dashboardStore.set({
1106
+ ...dashboardStore.get(),
1107
+ dashboard: {
1108
+ ...dashboardStore.dashboard.get(),
1109
+ widgets: [
1110
+ ...dashboardStore.dashboard.widgets.get(),
1111
+ { id: 3, type: 'graph', config: { animate: true } },
1112
+ ],
1113
+ },
1114
+ })
1115
+
1116
+ // Verify array update
1117
+ expect(dashboardStore.get().dashboard.widgets).toHaveLength(3)
1118
+ expect(dashboardStore.get().dashboard.widgets[2].type).toBe('graph')
1119
+
1120
+ // Test that individual null additions are still prevented via add()
1121
+ expect(() => {
1122
+ // @ts-expect-error deliberate test case
1123
+ dashboardStore.add('newProp', null)
1124
+ }).toThrow(
1125
+ 'Nullish signal values are not allowed in store for key "newProp"',
1126
+ )
1127
+
1128
+ // Test that individual property .set() operations prevent null values
1129
+ expect(() => {
1130
+ dashboardStore.update(data => ({
1131
+ ...data,
1132
+ metadata: {
1133
+ ...data.metadata,
1134
+ // @ts-expect-error deliberate test case
1135
+ tags: null,
1136
+ },
1137
+ }))
1138
+ }).toThrow(
1139
+ 'Nullish signal values are not allowed in store for key "tags"',
1140
+ )
1141
+
1142
+ // Update null to actual value (this should work)
1143
+ dashboardStore.update(data => ({
1144
+ ...data,
1145
+ metadata: {
1146
+ ...data.metadata,
1147
+ tags: ['production', 'v1'],
1148
+ },
1149
+ }))
1150
+
1151
+ expect(dashboardStore.get().metadata.tags).toEqual([
1152
+ 'production',
1153
+ 'v1',
1154
+ ])
1155
+
1156
+ // Verify JSON round-trip
1157
+ const serialized = JSON.stringify(dashboardStore.get())
1158
+ const reparsed = JSON.parse(serialized)
1159
+ expect(reparsed.dashboard.widgets).toHaveLength(3)
1160
+ expect(reparsed.metadata.tags).toEqual(['production', 'v1'])
1161
+ })
1162
+
1163
+ test('demonstrates real-world form data management', () => {
1164
+ // Simulate form data loaded from API
1165
+ const formData = {
1166
+ profile: {
1167
+ firstName: '',
1168
+ lastName: '',
1169
+ email: '',
1170
+ bio: '',
1171
+ },
1172
+ preferences: {
1173
+ emailNotifications: true,
1174
+ pushNotifications: false,
1175
+ marketing: false,
1176
+ },
1177
+ address: {
1178
+ street: '',
1179
+ city: '',
1180
+ country: 'US',
1181
+ zipCode: '',
1182
+ },
1183
+ }
1184
+
1185
+ const formStore = store<{
1186
+ profile: {
1187
+ id?: number
1188
+ createdAt?: string
1189
+ firstName: string
1190
+ lastName: string
1191
+ email: string
1192
+ bio: string
1193
+ }
1194
+ preferences: {
1195
+ emailNotifications: boolean
1196
+ pushNotifications: boolean
1197
+ marketing: boolean
1198
+ }
1199
+ address: {
1200
+ street: string
1201
+ city: string
1202
+ country: string
1203
+ zipCode: string
1204
+ }
1205
+ }>(formData)
1206
+
1207
+ // Simulate user filling out form
1208
+ formStore.profile.firstName.set('Jane')
1209
+ formStore.profile.lastName.set('Smith')
1210
+ formStore.profile.email.set('jane.smith@example.com')
1211
+ formStore.profile.bio.set(
1212
+ 'Full-stack developer with 5 years experience',
1213
+ )
1214
+
1215
+ // Update address
1216
+ formStore.address.street.set('123 Main St')
1217
+ formStore.address.city.set('San Francisco')
1218
+ formStore.address.zipCode.set('94105')
1219
+
1220
+ // Toggle preferences
1221
+ formStore.preferences.pushNotifications.set(true)
1222
+ formStore.preferences.marketing.set(true)
1223
+
1224
+ // Get form data for submission - ready for JSON.stringify
1225
+ const submissionData = formStore.get()
1226
+
1227
+ expect(submissionData.profile.firstName).toBe('Jane')
1228
+ expect(submissionData.profile.email).toBe('jane.smith@example.com')
1229
+ expect(submissionData.address.city).toBe('San Francisco')
1230
+ expect(submissionData.preferences.pushNotifications).toBe(true)
1231
+
1232
+ // Simulate sending to API
1233
+ const jsonPayload = JSON.stringify(submissionData)
1234
+ expect(jsonPayload).toContain('jane.smith@example.com')
1235
+ expect(jsonPayload).toContain('San Francisco')
1236
+
1237
+ // Simulate receiving updated data back from server
1238
+ const serverResponse = {
1239
+ ...submissionData,
1240
+ profile: {
1241
+ ...submissionData.profile,
1242
+ id: 456,
1243
+ createdAt: '2024-01-15T12:00:00Z',
1244
+ },
1245
+ }
1246
+
1247
+ // Update store with server response
1248
+ formStore.set(serverResponse)
1249
+
1250
+ // Verify server data is integrated
1251
+ expect(formStore.profile.id?.get()).toBe(456)
1252
+ expect(formStore.profile.createdAt?.get()).toBe(
1253
+ '2024-01-15T12:00:00Z',
1254
+ )
1255
+ expect(formStore.get().profile.firstName).toBe('Jane') // Original data preserved
1256
+ })
1257
+
1258
+ describe('Symbol.isConcatSpreadable and polymorphic behavior', () => {
1259
+ test('array-like stores have Symbol.isConcatSpreadable true and length property', () => {
1260
+ const numbers = store([1, 2, 3])
1261
+
1262
+ // Should be concat spreadable
1263
+ expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
1264
+
1265
+ // Should have length property
1266
+ expect(numbers.length).toBe(3)
1267
+ expect(typeof numbers.length).toBe('number')
1268
+
1269
+ // Add an item and verify length updates
1270
+ numbers.add(4)
1271
+ expect(numbers.length).toBe(4)
1272
+ })
1273
+
1274
+ test('object-like stores have Symbol.isConcatSpreadable false and no length property', () => {
1275
+ const user = store({ name: 'John', age: 25 })
1276
+
1277
+ // Should not be concat spreadable
1278
+ expect(user[Symbol.isConcatSpreadable]).toBe(false)
1279
+
1280
+ // Should not have length property
1281
+ // @ts-expect-error deliberately accessing non-existent length property
1282
+ expect(user.length).toBeUndefined()
1283
+ expect('length' in user).toBe(false)
1284
+ })
1285
+
1286
+ test('array-like stores iterate over signals only', () => {
1287
+ const numbers = store([10, 20, 30])
1288
+ const signals = [...numbers]
1289
+
1290
+ // Should yield signals, not [key, signal] pairs
1291
+ expect(signals).toHaveLength(3)
1292
+ expect(signals[0].get()).toBe(10)
1293
+ expect(signals[1].get()).toBe(20)
1294
+ expect(signals[2].get()).toBe(30)
1295
+
1296
+ // Verify they are signal objects
1297
+ signals.forEach(signal => {
1298
+ expect(typeof signal.get).toBe('function')
1299
+ })
1300
+ })
1301
+
1302
+ test('object-like stores iterate over [key, signal] pairs', () => {
1303
+ const user = store({ name: 'Alice', age: 30 })
1304
+ const entries = [...user]
1305
+
1306
+ // Should yield [key, signal] pairs
1307
+ expect(entries).toHaveLength(2)
1308
+
1309
+ // Find the name entry
1310
+ const nameEntry = entries.find(([key]) => key === 'name')
1311
+ expect(nameEntry).toBeDefined()
1312
+ expect(nameEntry?.[0]).toBe('name')
1313
+ expect(nameEntry?.[1].get()).toBe('Alice')
1314
+
1315
+ // Find the age entry
1316
+ const ageEntry = entries.find(([key]) => key === 'age')
1317
+ expect(ageEntry).toBeDefined()
1318
+ expect(ageEntry?.[0]).toBe('age')
1319
+ expect(ageEntry?.[1].get()).toBe(30)
1320
+ })
1321
+
1322
+ test('array-like stores support single-parameter add() method', () => {
1323
+ const fruits = store(['apple', 'banana'])
1324
+
1325
+ // Should add to end without specifying key
1326
+ fruits.add('cherry')
1327
+
1328
+ const result = fruits.get()
1329
+ expect(result).toEqual(['apple', 'banana', 'cherry'])
1330
+ expect(fruits.length).toBe(3)
1331
+ })
1332
+
1333
+ test('object-like stores require key parameter for add() method', () => {
1334
+ const config = store<{ debug: boolean; timeout?: number }>({
1335
+ debug: true,
1336
+ })
1337
+
1338
+ // Should require both key and value
1339
+ config.add('timeout', 5000)
1340
+
1341
+ expect(config.get()).toEqual({ debug: true, timeout: 5000 })
1342
+ })
1343
+
1344
+ test('concat works correctly with array-like stores', () => {
1345
+ const numbers = store([2, 3])
1346
+ const prefix = [state(1)]
1347
+ const suffix = [state(4), state(5)]
1348
+
1349
+ // Should spread signals when concat-ed
1350
+ const combined = prefix.concat(
1351
+ numbers as unknown as ConcatArray<State<number>>,
1352
+ suffix,
1353
+ )
1354
+
1355
+ expect(combined).toHaveLength(5)
1356
+ expect(combined[0].get()).toBe(1)
1357
+ expect(combined[1].get()).toBe(2) // from store
1358
+ expect(combined[2].get()).toBe(3) // from store
1359
+ expect(combined[3].get()).toBe(4)
1360
+ expect(combined[4].get()).toBe(5)
1361
+ })
1362
+
1363
+ test('spread operator works correctly with array-like stores', () => {
1364
+ const numbers = store([10, 20])
1365
+
1366
+ // Should spread signals
1367
+ const spread = [state(5), ...numbers, state(30)]
1368
+
1369
+ expect(spread).toHaveLength(4)
1370
+ expect(spread[0].get()).toBe(5)
1371
+ expect(spread[1].get()).toBe(10) // from store
1372
+ expect(spread[2].get()).toBe(20) // from store
1373
+ expect(spread[3].get()).toBe(30)
1374
+ })
1375
+
1376
+ test('array-like stores maintain numeric key ordering', () => {
1377
+ const items = store(['first', 'second', 'third'])
1378
+
1379
+ // Get the keys
1380
+ const keys = Object.keys(items)
1381
+ expect(keys).toEqual(['0', '1', '2', 'length'])
1382
+
1383
+ // Iteration should be in order
1384
+ const signals = [...items]
1385
+ expect(signals[0].get()).toBe('first')
1386
+ expect(signals[1].get()).toBe('second')
1387
+ expect(signals[2].get()).toBe('third')
1388
+ })
1389
+
1390
+ test('polymorphic behavior is determined at creation time', () => {
1391
+ // Created as array - stays array-like
1392
+ const arrayStore = store([1, 2])
1393
+ expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1394
+ expect(arrayStore.length).toBe(2)
1395
+
1396
+ // Created as object - stays object-like
1397
+ const objectStore = store<{ a: number; b: number; c?: number }>(
1398
+ {
1399
+ a: 1,
1400
+ b: 2,
1401
+ },
1402
+ )
1403
+ expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
1404
+ // @ts-expect-error deliberate access to non-existent length property
1405
+ expect(objectStore.length).toBeUndefined()
1406
+
1407
+ // Even after modifications, behavior doesn't change
1408
+ arrayStore.add(3)
1409
+ expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1410
+
1411
+ objectStore.add('c', 3)
1412
+ expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
1413
+ })
1414
+
1415
+ test('runtime type detection using typeof length', () => {
1416
+ const arrayStore = store([1, 2, 3])
1417
+ const objectStore = store({ x: 1, y: 2 })
1418
+
1419
+ // Can distinguish at runtime
1420
+ expect(typeof arrayStore.length === 'number').toBe(true)
1421
+ // @ts-expect-error deliberately accessing non-existent length property
1422
+ expect(typeof objectStore.length === 'number').toBe(false)
1423
+ })
1424
+
1425
+ test('empty stores behave correctly', () => {
1426
+ const emptyArray = store([])
1427
+ const emptyObject = store({})
1428
+
1429
+ // Empty array store
1430
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1431
+ expect(emptyArray.length).toBe(0)
1432
+ expect([...emptyArray]).toEqual([])
1433
+
1434
+ // Empty object store
1435
+ expect(emptyObject[Symbol.isConcatSpreadable]).toBe(false)
1436
+ // @ts-expect-error deliberately accessing non-existent length property
1437
+ expect(emptyObject.length).toBeUndefined()
1438
+ expect([...emptyObject]).toEqual([])
1439
+ })
1440
+ })
1441
+
1442
+ test('debug length property issue', () => {
1443
+ const numbers = store([1, 2, 3])
1444
+
1445
+ // Test length in computed context
1446
+ const lengthComputed = computed(() => numbers.length)
1447
+ numbers.add(4)
1448
+
1449
+ // Test if length property is actually reactive
1450
+ expect(numbers.length).toBe(4)
1451
+ expect(lengthComputed.get()).toBe(4)
1452
+ })
1453
+ })
1454
+
1455
+ describe('sort() method', () => {
1456
+ test('sorts array-like store with numeric compareFn', () => {
1457
+ const numbers = store([3, 1, 4, 1, 5])
1458
+
1459
+ // Capture old signal references
1460
+ const oldSignals = [
1461
+ numbers[0],
1462
+ numbers[1],
1463
+ numbers[2],
1464
+ numbers[3],
1465
+ numbers[4],
1466
+ ]
1467
+
1468
+ numbers.sort((a, b) => a - b)
1469
+
1470
+ // Check sorted order
1471
+ expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1472
+
1473
+ // Verify signal references are preserved (moved, not recreated)
1474
+ expect(numbers[0]).toBe(oldSignals[1]) // first 1 was at index 1
1475
+ expect(numbers[1]).toBe(oldSignals[3]) // second 1 was at index 3
1476
+ expect(numbers[2]).toBe(oldSignals[0]) // 3 was at index 0
1477
+ expect(numbers[3]).toBe(oldSignals[2]) // 4 was at index 2
1478
+ expect(numbers[4]).toBe(oldSignals[4]) // 5 was at index 4
1479
+ })
1480
+
1481
+ test('sorts array-like store with string compareFn', () => {
1482
+ const names = store(['Charlie', 'Alice', 'Bob'])
1483
+
1484
+ names.sort((a, b) => a.localeCompare(b))
1485
+
1486
+ expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
1487
+ })
1488
+
1489
+ test('sorts record-like store by value', () => {
1490
+ const users = store({
1491
+ user1: { name: 'Charlie', age: 25 },
1492
+ user2: { name: 'Alice', age: 30 },
1493
+ user3: { name: 'Bob', age: 20 },
1494
+ })
1495
+
1496
+ // Capture old signal references
1497
+ const oldSignals = {
1498
+ user1: users.user1,
1499
+ user2: users.user2,
1500
+ user3: users.user3,
1501
+ }
1502
+
1503
+ // Sort by age
1504
+ users.sort((a, b) => a.age - b.age)
1505
+
1506
+ // Check order via iteration
1507
+ const keys = Array.from(users, ([key]) => key)
1508
+ expect(keys).toEqual(['user3', 'user1', 'user2'])
1509
+
1510
+ // Verify signal references are preserved
1511
+ expect(users.user1).toBe(oldSignals.user1)
1512
+ expect(users.user2).toBe(oldSignals.user2)
1513
+ expect(users.user3).toBe(oldSignals.user3)
1514
+ })
1515
+
1516
+ test('emits store-sort event with new order', () => {
1517
+ const numbers = store([30, 10, 20])
1518
+ let sortEvent: StoreSortEvent | null = null
1519
+
1520
+ numbers.addEventListener('store-sort', event => {
1521
+ sortEvent = event
1522
+ })
1523
+
1524
+ numbers.sort((a, b) => a - b)
1525
+
1526
+ expect(sortEvent).not.toBeNull()
1527
+ // biome-ignore lint/style/noNonNullAssertion: test
1528
+ expect(sortEvent!.type).toBe('store-sort')
1529
+ // Keys in new sorted order: [10, 20, 30] came from indices [1, 2, 0]
1530
+ // biome-ignore lint/style/noNonNullAssertion: test
1531
+ expect(sortEvent!.detail).toEqual(['1', '2', '0'])
1532
+ })
1533
+
1534
+ test('sort is reactive - watchers are notified', () => {
1535
+ const numbers = store([3, 1, 2])
1536
+ let effectCount = 0
1537
+ let lastValue: number[] = []
1538
+
1539
+ effect(() => {
1540
+ lastValue = numbers.get()
1541
+ effectCount++
1542
+ })
1543
+
1544
+ // Initial effect run
1545
+ expect(effectCount).toBe(1)
1546
+ expect(lastValue).toEqual([3, 1, 2])
1547
+
1548
+ numbers.sort((a, b) => a - b)
1549
+
1550
+ // Effect should run again after sort
1551
+ expect(effectCount).toBe(2)
1552
+ expect(lastValue).toEqual([1, 2, 3])
1553
+ })
1554
+
1555
+ test('nested signals remain reactive after sorting', () => {
1556
+ const items = store([
1557
+ { name: 'Charlie', score: 85 },
1558
+ { name: 'Alice', score: 95 },
1559
+ { name: 'Bob', score: 75 },
1560
+ ])
1561
+
1562
+ // Sort by score
1563
+ items.sort((a, b) => b.score - a.score) // descending
1564
+
1565
+ // Verify order
1566
+ expect(items.get().map(item => item.name)).toEqual([
1567
+ 'Alice',
1568
+ 'Charlie',
1569
+ 'Bob',
1570
+ ])
1571
+
1572
+ // Modify a nested property
1573
+ items[1].score.set(100) // Charlie's score
1574
+
1575
+ // Verify the change is reflected
1576
+ expect(items.get()[1].score).toBe(100)
1577
+ expect(items[1].name.get()).toBe('Charlie')
1578
+ })
1579
+
1580
+ test('sort with complex nested structures', () => {
1581
+ const posts = store([
1582
+ {
1583
+ id: 'post1',
1584
+ title: 'Hello World',
1585
+ meta: { views: 100, likes: 5 },
1586
+ },
1587
+ {
1588
+ id: 'post2',
1589
+ title: 'Getting Started',
1590
+ meta: { views: 50, likes: 10 },
1591
+ },
1592
+ {
1593
+ id: 'post3',
1594
+ title: 'Advanced Topics',
1595
+ meta: { views: 200, likes: 3 },
1596
+ },
1597
+ ])
1598
+
1599
+ // Sort by likes (ascending)
1600
+ posts.sort((a, b) => a.meta.likes - b.meta.likes)
1601
+
1602
+ const sortedTitles = posts.get().map(post => post.title)
1603
+ expect(sortedTitles).toEqual([
1604
+ 'Advanced Topics',
1605
+ 'Hello World',
1606
+ 'Getting Started',
1607
+ ])
1608
+
1609
+ // Verify nested reactivity still works
1610
+ posts[0].meta.likes.set(15)
1611
+ expect(posts.get()[0].meta.likes).toBe(15)
1612
+ })
1613
+
1614
+ test('sort preserves array length and size', () => {
1615
+ const arr = store([5, 2, 8, 1])
1616
+
1617
+ expect(arr.length).toBe(4)
1618
+ expect(arr.size.get()).toBe(4)
1619
+
1620
+ arr.sort((a, b) => a - b)
1621
+
1622
+ expect(arr.length).toBe(4)
1623
+ expect(arr.size.get()).toBe(4)
1624
+ expect(arr.get()).toEqual([1, 2, 5, 8])
1625
+ })
1626
+
1627
+ test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
1628
+ const items = store(['banana', 'cherry', 'apple', '10', '2'])
1629
+
1630
+ items.sort()
1631
+
1632
+ // Default sorting converts to strings and compares in UTF-16 order
1633
+ expect(items.get()).toEqual(
1634
+ ['banana', 'cherry', 'apple', '10', '2'].sort(),
1635
+ )
1636
+ })
1637
+
1638
+ test('default sort handles numbers as strings like Array.prototype.sort()', () => {
1639
+ const numbers = store([80, 9, 100])
1640
+
1641
+ numbers.sort()
1642
+
1643
+ // Numbers are converted to strings: "100", "80", "9"
1644
+ // In UTF-16 order: "100" < "80" < "9"
1645
+ expect(numbers.get()).toEqual([80, 9, 100].sort())
1646
+ })
1647
+
1648
+ test('default sort handles mixed values with proper string conversion', () => {
1649
+ const mixed = store(['b', 0, 'a', '', 'c'])
1650
+
1651
+ mixed.sort()
1652
+
1653
+ // String conversion: '' < '0' < 'a' < 'b' < 'c'
1654
+ expect(mixed.get()).toEqual(['', 0, 'a', 'b', 'c'])
1655
+ })
1656
+
1657
+ test('multiple sorts work correctly', () => {
1658
+ const numbers = store([3, 1, 4, 1, 5])
1659
+
1660
+ // Sort ascending
1661
+ numbers.sort((a, b) => a - b)
1662
+ expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1663
+
1664
+ // Sort descending
1665
+ numbers.sort((a, b) => b - a)
1666
+ expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
1667
+ })
1668
+
1669
+ test('sort event contains correct movement mapping for records', () => {
1670
+ const users = store({
1671
+ alice: { age: 30 },
1672
+ bob: { age: 20 },
1673
+ charlie: { age: 25 },
1674
+ })
1675
+
1676
+ let sortEvent: StoreSortEvent | null = null
1677
+ users.addEventListener('store-sort', event => {
1678
+ sortEvent = event
1679
+ })
1680
+
1681
+ // Sort by age
1682
+ users.sort((a, b) => b.age - a.age)
1683
+
1684
+ expect(sortEvent).not.toBeNull()
1685
+ // biome-ignore lint/style/noNonNullAssertion: test
1686
+ expect(sortEvent!.detail).toEqual(['alice', 'charlie', 'bob'])
1687
+ })
1688
+ })
719
1689
  })