@zeix/cause-effect 0.15.2 → 0.16.1

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.
@@ -1,631 +1,762 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- computed,
4
- effect,
3
+ createComputed,
4
+ createEffect,
5
+ createState,
6
+ createStore,
5
7
  isStore,
6
8
  type State,
7
- type StoreAddEvent,
8
- type StoreChangeEvent,
9
- type StoreRemoveEvent,
10
- type StoreSortEvent,
11
- state,
12
- store,
13
9
  UNSET,
14
10
  } from '..'
15
11
 
16
12
  describe('store', () => {
17
13
  describe('creation and basic operations', () => {
18
- test('creates a store with initial values', () => {
19
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
20
-
14
+ test('creates stores with initial values', () => {
15
+ // Record store
16
+ const user = createStore({
17
+ name: 'Hannah',
18
+ email: 'hannah@example.com',
19
+ })
21
20
  expect(user.name.get()).toBe('Hannah')
22
21
  expect(user.email.get()).toBe('hannah@example.com')
22
+
23
+ // Array store
24
+ const numbers = createStore([1, 2, 3])
25
+ expect(numbers[0].get()).toBe(1)
26
+ expect(numbers[1].get()).toBe(2)
27
+ expect(numbers[2].get()).toBe(3)
23
28
  })
24
29
 
25
30
  test('has Symbol.toStringTag of Store', () => {
26
- const s = store({ a: 1 })
27
- expect(s[Symbol.toStringTag]).toBe('Store')
31
+ const recordStore = createStore({ a: 1 })
32
+ const arrayStore = createStore([1, 2])
33
+
34
+ expect(recordStore[Symbol.toStringTag]).toBe('Store')
35
+ expect(arrayStore[Symbol.toStringTag]).toBe('Store')
28
36
  })
29
37
 
30
38
  test('isStore identifies store instances correctly', () => {
31
- const s = store({ a: 1 })
32
- const st = state(1)
33
- const c = computed(() => 1)
34
-
35
- expect(isStore(s)).toBe(true)
36
- expect(isStore(st)).toBe(false)
37
- expect(isStore(c)).toBe(false)
39
+ const recordStore = createStore({ a: 1 })
40
+ const arrayStore = createStore([1])
41
+ const state = createState(1)
42
+ const computed = createComputed(() => 1)
43
+
44
+ expect(isStore(recordStore)).toBe(true)
45
+ expect(isStore(arrayStore)).toBe(true)
46
+ expect(isStore(state)).toBe(false)
47
+ expect(isStore(computed)).toBe(false)
38
48
  expect(isStore({})).toBe(false)
39
49
  expect(isStore(null)).toBe(false)
40
50
  })
41
51
 
42
52
  test('get() returns the complete store value', () => {
43
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
44
-
53
+ // Record store
54
+ const user = createStore({
55
+ name: 'Hannah',
56
+ email: 'hannah@example.com',
57
+ })
45
58
  expect(user.get()).toEqual({
46
59
  name: 'Hannah',
47
60
  email: 'hannah@example.com',
48
61
  })
49
62
 
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'] },
63
+ // Array store
64
+ const numbers = createStore([1, 2, 3])
65
+ expect(numbers.get()).toEqual([1, 2, 3])
66
+
67
+ // Nested structures
68
+ const participants = createStore([
69
+ { name: 'Alice', tags: ['admin'] },
70
+ { name: 'Bob', tags: ['user'] },
57
71
  ])
72
+ expect(participants[0].name.get()).toBe('Alice')
73
+ expect(participants[0].tags.get()).toEqual(['admin'])
74
+ expect(participants[1].name.get()).toBe('Bob')
75
+ expect(participants[1].tags.get()).toEqual(['user'])
76
+ })
77
+ })
78
+
79
+ describe('length property and sizing', () => {
80
+ test('length property works for both store types', () => {
81
+ // Record store
82
+ const user = createStore({ name: 'John', age: 25 })
83
+ expect(user.length).toBe(2)
84
+ expect(typeof user.length).toBe('number')
85
+
86
+ // Array store
87
+ const numbers = createStore([1, 2, 3])
88
+ expect(numbers.length).toBe(3)
89
+ expect(typeof numbers.length).toBe('number')
90
+ })
91
+
92
+ test('length is reactive and updates with changes', () => {
93
+ // Record store
94
+ const user = createStore<{ name: string; age?: number }>({
95
+ name: 'John',
96
+ })
97
+ expect(user.length).toBe(1)
98
+ user.add('age', 25)
99
+ expect(user.length).toBe(2)
100
+ user.remove('age')
101
+ expect(user.length).toBe(1)
102
+
103
+ // Array store
104
+ const items = createStore([1, 2])
105
+ expect(items.length).toBe(2)
106
+ items.add(3)
107
+ expect(items.length).toBe(3)
108
+ items.remove(1)
109
+ expect(items.length).toBe(2)
58
110
  })
59
111
  })
60
112
 
61
113
  describe('proxy data access and modification', () => {
62
114
  test('properties can be accessed and modified via signals', () => {
63
- const user = store({ name: 'Hannah', age: 25 })
64
-
65
- // Get signals from store proxy
66
- expect(user.name.get()).toBe('Hannah')
67
- expect(user.age.get()).toBe(25)
68
-
69
- // Set values via signals
70
- user.name.set('Alice')
71
- user.age.set(30)
72
-
115
+ // Record store
116
+ const user = createStore({ name: 'Alice', age: 30 })
73
117
  expect(user.name.get()).toBe('Alice')
74
118
  expect(user.age.get()).toBe(30)
119
+ user.name.set('Alicia')
120
+ user.age.set(31)
121
+ expect(user.name.get()).toBe('Alicia')
122
+ expect(user.age.get()).toBe(31)
123
+
124
+ // Array store
125
+ const items = createStore(['a', 'b'])
126
+ expect(items[0].get()).toBe('a')
127
+ expect(items[1].get()).toBe('b')
128
+ items[0].set('alpha')
129
+ items[1].set('beta')
130
+ expect(items[0].get()).toBe('alpha')
131
+ expect(items[1].get()).toBe('beta')
75
132
  })
76
133
 
77
134
  test('returns undefined for non-existent properties', () => {
78
- const user = store({ name: 'Hannah' })
79
-
135
+ // Record store
136
+ const user = createStore({ name: 'Alice' })
80
137
  // @ts-expect-error accessing non-existent property
81
- expect(user.nonExistent).toBeUndefined()
138
+ expect(user.nonexistent).toBeUndefined()
139
+
140
+ // Array store
141
+ const items = createStore(['a'])
142
+ expect(items[5]).toBeUndefined()
82
143
  })
83
144
 
84
- test('supports numeric key access', () => {
85
- const items = store({ '0': 'first', '1': 'second' })
145
+ test('supports numeric key access for both store types', () => {
146
+ // Record store with numeric keys
147
+ const items = createStore({ 0: 'zero', 1: 'one' })
148
+ expect(items[0].get()).toBe('zero')
149
+ expect(items[1].get()).toBe('one')
86
150
 
87
- expect(items[0].get()).toBe('first')
88
- expect(items['0'].get()).toBe('first')
89
- expect(items[1].get()).toBe('second')
90
- expect(items['1'].get()).toBe('second')
151
+ // Array store with numeric keys
152
+ const numbers = createStore([10, 20])
153
+ expect(numbers[0].get()).toBe(10)
154
+ expect(numbers[1].get()).toBe(20)
91
155
  })
156
+ })
92
157
 
93
- test('can add new properties via add method', () => {
94
- const user = store<{ name: string; email?: string }>({
95
- name: 'Hannah',
158
+ describe('add() and remove() methods', () => {
159
+ test('add() method behavior differs between store types', () => {
160
+ // Record store - requires key parameter
161
+ const user = createStore<{ name: string; email?: string }>({
162
+ name: 'John',
96
163
  })
164
+ user.add('email', 'john@example.com')
165
+ expect(user.email?.get()).toBe('john@example.com')
166
+ expect(user.length).toBe(2)
97
167
 
98
- user.add('email', 'hannah@example.com')
168
+ // Array store - single parameter adds to end
169
+ const fruits = createStore(['apple', 'banana'])
170
+ fruits.add('cherry')
171
+ expect(fruits[2].get()).toBe('cherry')
172
+ expect(fruits.length).toBe(3)
173
+ expect(fruits.get()).toEqual(['apple', 'banana', 'cherry'])
174
+ })
99
175
 
100
- expect(user.email?.get()).toBe('hannah@example.com')
101
- expect(user.get()).toEqual({
102
- name: 'Hannah',
103
- email: 'hannah@example.com',
176
+ test('remove() method behavior differs between store types', () => {
177
+ // Record store - removes by key
178
+ const user = createStore({
179
+ name: 'John',
180
+ email: 'john@example.com',
104
181
  })
182
+ user.remove('email')
183
+ expect(user.email).toBeUndefined()
184
+ expect(user.name.get()).toBe('John')
185
+ expect(user.length).toBe(1)
186
+
187
+ // Array store - removes by index
188
+ const items = createStore(['a', 'b', 'c'])
189
+ items.remove(1) // Remove 'b'
190
+ expect(items.get()).toEqual(['a', 'c'])
191
+ expect(items.length).toBe(2)
105
192
  })
106
193
 
107
- test('can remove existing properties via remove method', () => {
108
- const user = store<{ name: string; email?: string }>({
109
- name: 'Hannah',
110
- email: 'hannah@example.com',
194
+ test('add method prevents null values for both store types', () => {
195
+ // Record store
196
+ const user = createStore<{ name: string; email?: string }>({
197
+ name: 'John',
111
198
  })
199
+ // @ts-expect-error testing null values
200
+ expect(() => user.add('email', null)).toThrow()
112
201
 
113
- expect(user.email?.get()).toBe('hannah@example.com')
114
-
115
- user.remove('email')
202
+ // Array store
203
+ const items = createStore([1])
204
+ // @ts-expect-error testing null values
205
+ expect(() => items.add(null)).toThrow()
206
+ })
116
207
 
117
- expect(user.email).toBeUndefined()
118
- expect(user.get()).toEqual({
119
- name: 'Hannah',
208
+ test('add method prevents overwriting existing properties in record stores', () => {
209
+ const user = createStore<{ name: string; email?: string }>({
210
+ name: 'John',
211
+ email: 'john@example.com',
120
212
  })
213
+ const originalSize = user.length
214
+ expect(() => user.add('name', 'Jane')).toThrow()
215
+ expect(user.length).toBe(originalSize)
216
+ expect(user.name.get()).toBe('John')
121
217
  })
122
218
 
123
- test('add method prevents null values', () => {
124
- const user = store<{ name: string; tags?: string[] }>({
125
- name: 'Alice',
126
- })
219
+ test('remove method handles non-existent properties gracefully', () => {
220
+ // Record store
221
+ const user = createStore<{ name: string }>({ name: 'John' })
222
+ const originalSize = user.length
223
+ // @ts-expect-error deliberate removal of non-existent property
224
+ user.remove('nonexistent')
225
+ expect(user.length).toBe(originalSize)
127
226
 
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
- )
227
+ // Array store - out of bounds throws
228
+ const items = createStore([1, 2])
229
+ expect(() => items.remove(5)).toThrow()
230
+ expect(() => items.remove(-5)).toThrow()
134
231
  })
135
232
  })
136
233
 
137
234
  describe('nested stores', () => {
138
- test('creates nested stores for object properties', () => {
139
- const user = store({
140
- name: 'Hannah',
235
+ test('creates nested stores for object properties in both store types', () => {
236
+ // Record store
237
+ const user = createStore({
238
+ name: 'Alice',
141
239
  preferences: {
142
240
  theme: 'dark',
143
241
  notifications: true,
144
242
  },
145
243
  })
146
-
147
244
  expect(isStore(user.preferences)).toBe(true)
148
- expect(user.preferences.theme?.get()).toBe('dark')
149
- expect(user.preferences.notifications?.get()).toBe(true)
245
+ expect(user.preferences.theme.get()).toBe('dark')
246
+ expect(user.preferences.notifications.get()).toBe(true)
247
+
248
+ // Array store with nested objects
249
+ const users = createStore([
250
+ { name: 'Alice', active: true },
251
+ { name: 'Bob', active: false },
252
+ ])
253
+ expect(isStore(users[0])).toBe(true)
254
+ expect(users[0].name.get()).toBe('Alice')
255
+ expect(users[1].active.get()).toBe(false)
150
256
  })
151
257
 
152
258
  test('nested properties are reactive', () => {
153
- const user = store({
259
+ // Record store
260
+ const user = createStore({
154
261
  preferences: {
155
- theme: 'dark',
262
+ theme: 'light',
156
263
  },
157
264
  })
265
+ let lastTheme = ''
266
+ createEffect(() => {
267
+ lastTheme = user.preferences.theme.get()
268
+ })
269
+ expect(lastTheme).toBe('light')
270
+ user.preferences.theme.set('dark')
271
+ expect(lastTheme).toBe('dark')
158
272
 
159
- user.preferences.theme.set('light')
160
- expect(user.preferences.theme.get()).toBe('light')
161
- expect(user.get().preferences.theme).toBe('light')
273
+ // Array store
274
+ const configs = createStore([{ mode: 'development' }])
275
+ let lastMode = ''
276
+ createEffect(() => {
277
+ lastMode = configs[0].mode.get()
278
+ })
279
+ expect(lastMode).toBe('development')
280
+ configs[0].mode.set('production')
281
+ expect(lastMode).toBe('production')
162
282
  })
163
283
 
164
284
  test('deeply nested stores work correctly', () => {
165
- const config = store({
285
+ const config = createStore({
166
286
  ui: {
167
287
  theme: {
168
288
  colors: {
169
- primary: 'blue',
289
+ primary: '#blue',
170
290
  },
171
291
  },
172
292
  },
173
293
  })
174
-
175
- expect(config.ui.theme.colors.primary.get()).toBe('blue')
176
- config.ui.theme.colors.primary.set('red')
177
- expect(config.ui.theme.colors.primary.get()).toBe('red')
294
+ expect(config.ui.theme.colors.primary.get()).toBe('#blue')
295
+ config.ui.theme.colors.primary.set('#red')
296
+ expect(config.ui.theme.colors.primary.get()).toBe('#red')
178
297
  })
179
298
  })
180
299
 
181
300
  describe('set() and update() methods', () => {
182
- test('set() replaces entire store value', () => {
183
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
184
-
185
- user.set({ name: 'Alice', email: 'alice@example.com' })
186
-
187
- expect(user.get()).toEqual({
188
- name: 'Alice',
189
- email: 'alice@example.com',
190
- })
191
- })
192
-
193
- test('update() modifies store using function', () => {
194
- const user = store({ name: 'Hannah', age: 25 })
195
-
196
- user.update(prev => ({ ...prev, age: prev.age + 1 }))
197
-
198
- expect(user.get()).toEqual({
199
- name: 'Hannah',
200
- age: 26,
201
- })
301
+ test('set() replaces entire store value for both store types', () => {
302
+ // Record store
303
+ const user = createStore({
304
+ name: 'John',
305
+ email: 'john@example.com',
306
+ })
307
+ user.set({ name: 'Jane', email: 'jane@example.com' })
308
+ expect(user.name.get()).toBe('Jane')
309
+ expect(user.email.get()).toBe('jane@example.com')
310
+
311
+ // Array store
312
+ const numbers = createStore([1, 2, 3])
313
+ numbers.set([4, 5])
314
+ expect(numbers.get()).toEqual([4, 5])
315
+ expect(numbers.length).toBe(2)
316
+ })
317
+
318
+ test('update() modifies store using function for both store types', () => {
319
+ // Record store
320
+ const user = createStore({ name: 'John', age: 25 })
321
+ user.update(u => ({ ...u, age: u.age + 1 }))
322
+ expect(user.name.get()).toBe('John')
323
+ expect(user.age.get()).toBe(26)
324
+
325
+ // Array store
326
+ const numbers = createStore([1, 2, 3])
327
+ numbers.update(arr => arr.map(n => n * 2))
328
+ expect(numbers.get()).toEqual([2, 4, 6])
202
329
  })
203
330
  })
204
331
 
205
- describe('iterator protocol', () => {
206
- test('supports for...of iteration', () => {
207
- const user = store({ name: 'Hannah', age: 25 })
208
- const entries: Array<[string, unknown & {}]> = []
209
-
210
- for (const [key, signal] of user) {
211
- entries.push([key, signal.get()])
212
- }
332
+ describe('iteration protocol', () => {
333
+ test('supports for...of iteration with different behaviors', () => {
334
+ // Record store - yields [key, signal] pairs
335
+ const user = createStore({ name: 'John', age: 25 })
336
+ const entries = [...user]
337
+ expect(entries).toHaveLength(2)
338
+ expect(entries[0][0]).toBe('name')
339
+ expect(entries[0][1].get()).toBe('John')
340
+ expect(entries[1][0]).toBe('age')
341
+ expect(entries[1][1].get()).toBe(25)
342
+
343
+ // Array store - yields signals only
344
+ const numbers = createStore([10, 20, 30])
345
+ const signals = [...numbers]
346
+ expect(signals).toHaveLength(3)
347
+ expect(signals[0].get()).toBe(10)
348
+ expect(signals[1].get()).toBe(20)
349
+ expect(signals[2].get()).toBe(30)
350
+ })
351
+
352
+ test('Symbol.isConcatSpreadable behavior differs between store types', () => {
353
+ // Array store - spreadable
354
+ const numbers = createStore([1, 2, 3])
355
+ expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
356
+
357
+ // Record store - not spreadable
358
+ const user = createStore({ name: 'John', age: 25 })
359
+ expect(user[Symbol.isConcatSpreadable]).toBe(false)
360
+ })
361
+
362
+ test('array stores maintain numeric key ordering', () => {
363
+ const items = createStore(['first', 'second', 'third'])
364
+ const keys = Object.keys(items).filter(
365
+ k => !Number.isNaN(Number(k)),
366
+ )
367
+ expect(keys).toEqual(['0', '1', '2'])
213
368
 
214
- expect(entries).toContainEqual(['name', 'Hannah'])
215
- expect(entries).toContainEqual(['age', 25])
369
+ const signals = [...items]
370
+ expect(signals.map(s => s.get())).toEqual([
371
+ 'first',
372
+ 'second',
373
+ 'third',
374
+ ])
216
375
  })
217
376
  })
218
377
 
219
- describe('change tracking', () => {
220
- test('tracks size changes', () => {
221
- const user = store<{ name: string; email?: string }>({
222
- name: 'Hannah',
378
+ describe('change tracking and notifications', () => {
379
+ test('emits add notifications for both store types', () => {
380
+ // Record store - initial creation
381
+ let addNotification: Record<string, unknown>
382
+ const user = createStore({ name: 'John' })
383
+ user.on('add', change => {
384
+ addNotification = change
223
385
  })
224
386
 
225
- expect(user.size.get()).toBe(1)
387
+ // Wait for initial add event
388
+ setTimeout(() => {
389
+ expect(addNotification.name).toBe('John')
390
+ }, 0)
226
391
 
227
- user.add('email', 'hannah@example.com')
228
- expect(user.size.get()).toBe(2)
392
+ // Record store - new property
393
+ const userWithEmail = createStore<{ name: string; email?: string }>(
394
+ { name: 'John' },
395
+ )
396
+ let newAddNotification: Record<string, unknown> = {}
397
+ userWithEmail.on('add', change => {
398
+ newAddNotification = change
399
+ })
400
+ userWithEmail.add('email', 'john@example.com')
401
+ expect(newAddNotification.email).toBe('john@example.com')
229
402
 
230
- user.remove('email')
231
- expect(user.size.get()).toBe(1)
403
+ // Array store
404
+ const numbers = createStore([1, 2])
405
+ let arrayAddNotification = {}
406
+ numbers.on('add', change => {
407
+ arrayAddNotification = change
408
+ })
409
+ numbers.add(3)
410
+ expect(arrayAddNotification[2]).toBe(3)
232
411
  })
233
412
 
234
- test('dispatches store-add event on initial creation', async () => {
235
- let addEvent: StoreAddEvent<{ name: string }> | null = null
236
- const user = store({ name: 'Hannah' })
237
-
238
- user.addEventListener('store-add', event => {
239
- addEvent = event
413
+ test('emits change notifications when properties are modified', () => {
414
+ // Record store
415
+ const user = createStore({ name: 'John' })
416
+ let changeNotification: Record<string, unknown> = {}
417
+ user.on('change', change => {
418
+ changeNotification = change
240
419
  })
420
+ user.name.set('Jane')
421
+ expect(changeNotification.name).toBe('Jane')
241
422
 
242
- // Wait for the async initial event
243
- await new Promise(resolve => setTimeout(resolve, 10))
244
-
245
- expect(addEvent).toBeTruthy()
246
- // biome-ignore lint/style/noNonNullAssertion: test
247
- expect(addEvent!.detail).toEqual({ name: 'Hannah' })
423
+ // Array store
424
+ const items = createStore(['a', 'b'])
425
+ let arrayChangeNotification = {}
426
+ items.on('change', change => {
427
+ arrayChangeNotification = change
428
+ })
429
+ items[0].set('alpha')
430
+ expect(arrayChangeNotification[0]).toBe('alpha')
248
431
  })
249
432
 
250
- test('dispatches store-add event for new properties', () => {
251
- const user = store<{ name: string; email?: string }>({
252
- name: 'Hannah',
433
+ test('emits change notifications for nested property changes', () => {
434
+ // Record store
435
+ const user = createStore({
436
+ name: 'John',
437
+ preferences: {
438
+ theme: 'light',
439
+ notifications: true,
440
+ },
253
441
  })
254
-
255
- let addEvent: StoreAddEvent<{
256
- name: string
257
- email?: string
258
- }> | null = null
259
- user.addEventListener('store-add', event => {
260
- addEvent = event
442
+ let changeNotification: Record<string, unknown> = {}
443
+ user.on('change', change => {
444
+ changeNotification = change
261
445
  })
262
-
263
- user.update(v => ({ ...v, email: 'hannah@example.com' }))
264
-
265
- expect(addEvent).toBeTruthy()
266
- // biome-ignore lint/style/noNonNullAssertion: test
267
- expect(addEvent!.detail).toEqual({
268
- email: 'hannah@example.com',
446
+ user.preferences.theme.set('dark')
447
+ expect(changeNotification.preferences).toEqual({
448
+ theme: 'dark',
449
+ notifications: true,
269
450
  })
270
- })
271
-
272
- test('dispatches store-change event for property changes', () => {
273
- const user = store({ name: 'Hannah' })
274
451
 
275
- let changeEvent: StoreChangeEvent<{ name: string }> | null = null
276
- user.addEventListener('store-change', event => {
277
- changeEvent = event
452
+ // Array store with nested objects
453
+ const users = createStore([{ name: 'Alice', role: 'admin' }])
454
+ let arrayChangeNotification: Record<number, unknown> = []
455
+ users.on('change', change => {
456
+ arrayChangeNotification = change
278
457
  })
279
-
280
- user.set({ name: 'Alice' })
281
-
282
- expect(changeEvent).toBeTruthy()
283
- // biome-ignore lint/style/noNonNullAssertion: test
284
- expect(changeEvent!.detail).toEqual({
285
- name: 'Alice',
458
+ users[0].name.set('Alicia')
459
+ expect(arrayChangeNotification[0]).toEqual({
460
+ name: 'Alicia',
461
+ role: 'admin',
286
462
  })
287
463
  })
288
464
 
289
- test('dispatches store-change event for signal changes', () => {
290
- const user = store({ name: 'Hannah' })
291
-
292
- let changeEvent: StoreChangeEvent<{ name: string }> | null = null
293
- user.addEventListener('store-change', event => {
294
- changeEvent = event
465
+ test('emits remove notifications when properties are removed', () => {
466
+ // Record store
467
+ const user = createStore({
468
+ name: 'John',
469
+ email: 'john@example.com',
295
470
  })
471
+ let removeNotification: Record<string, unknown> = {}
472
+ user.on('remove', change => {
473
+ removeNotification = change
474
+ })
475
+ user.remove('email')
476
+ expect(removeNotification.email).toBe(UNSET)
296
477
 
297
- user.name.set('Bob')
298
-
299
- expect(changeEvent).toBeTruthy()
300
- // biome-ignore lint/style/noNonNullAssertion: test
301
- expect(changeEvent!.detail).toEqual({
302
- name: 'Bob',
478
+ // Array store
479
+ const items = createStore(['a', 'b', 'c'])
480
+ let arrayRemoveNotification: Record<number, unknown> = []
481
+ items.on('remove', change => {
482
+ arrayRemoveNotification = change
303
483
  })
484
+ items.remove(1)
485
+ expect(arrayRemoveNotification[2]).toBe(UNSET) // Last item gets removed in compaction
304
486
  })
305
487
 
306
- test('dispatches store-remove event for removed properties', () => {
307
- const user = store<{ name: string; email?: string }>({
488
+ test('set() correctly handles mixed changes, additions, and removals', () => {
489
+ const user = createStore<{
490
+ name: string
491
+ email?: string
492
+ preferences?: {
493
+ theme: string
494
+ notifications?: boolean
495
+ }
496
+ age?: number
497
+ }>({
308
498
  name: 'Hannah',
309
499
  email: 'hannah@example.com',
500
+ preferences: {
501
+ theme: 'light', // will change
502
+ },
310
503
  })
311
504
 
312
- let removeEvent: StoreRemoveEvent<{
313
- name: string
314
- email?: string
315
- }> | null = null
316
- user.addEventListener('store-remove', event => {
317
- removeEvent = event
505
+ let changeNotification: Record<string, unknown> | undefined
506
+ let addNotification: Record<string, unknown> | undefined
507
+ let removeNotification: Record<string, unknown> | undefined
508
+ user.on('change', change => {
509
+ changeNotification = change
318
510
  })
319
-
320
- user.remove('email')
321
-
322
- expect(removeEvent).toBeTruthy()
323
- // biome-ignore lint/style/noNonNullAssertion: test
324
- expect(removeEvent!.detail.email).toBe(UNSET)
325
- })
326
-
327
- test('dispatches store-add event when using add method', () => {
328
- const user = store<{ name: string; email?: string }>({
329
- name: 'Hannah',
511
+ user.on('add', change => {
512
+ addNotification = change
330
513
  })
331
-
332
- let addEvent: StoreAddEvent<{
333
- name: string
334
- email?: string
335
- }> | null = null
336
- user.addEventListener('store-add', event => {
337
- addEvent = event
514
+ user.on('remove', change => {
515
+ removeNotification = change
338
516
  })
339
517
 
340
- user.add('email', 'hannah@example.com')
518
+ user.set({
519
+ name: 'Jane', // changed
520
+ preferences: {
521
+ theme: 'dark', // changed
522
+ },
523
+ age: 30, // added
524
+ } as { name: string; preferences: { theme: string }; age: number })
341
525
 
342
- expect(addEvent).toBeTruthy()
343
- // biome-ignore lint/style/noNonNullAssertion: test
344
- expect(addEvent!.detail).toEqual({
345
- email: 'hannah@example.com',
346
- })
526
+ expect(changeNotification?.preferences).toEqual({ theme: 'dark' })
527
+ expect(addNotification?.age).toBe(30)
528
+ expect(removeNotification?.email).toBe(UNSET)
347
529
  })
348
530
 
349
- test('can remove event listeners', () => {
350
- const user = store({ name: 'Hannah' })
351
-
352
- let eventCount = 0
531
+ test('notification listeners can be removed', () => {
532
+ const user = createStore({ name: 'John' })
533
+ let notificationCount = 0
353
534
  const listener = () => {
354
- eventCount++
535
+ notificationCount++
355
536
  }
356
-
357
- user.addEventListener('store-change', listener)
358
- user.name.set('Alice')
359
- expect(eventCount).toBe(1)
360
-
361
- user.removeEventListener('store-change', listener)
362
- user.name.set('Bob')
363
- expect(eventCount).toBe(1) // Should not increment
364
- })
365
-
366
- test('supports multiple event listeners for the same event', () => {
367
- const user = store({ name: 'Hannah' })
368
-
369
- let listener1Called = false
370
- let listener2Called = false
371
-
372
- user.addEventListener('store-change', () => {
373
- listener1Called = true
374
- })
375
-
376
- user.addEventListener('store-change', () => {
377
- listener2Called = true
378
- })
379
-
380
- user.name.set('Alice')
381
-
382
- expect(listener1Called).toBe(true)
383
- expect(listener2Called).toBe(true)
537
+ const off = user.on('change', listener)
538
+ user.name.set('Jane')
539
+ expect(notificationCount).toBe(1)
540
+ off()
541
+ user.name.set('Jack')
542
+ expect(notificationCount).toBe(1) // Should not increment
384
543
  })
385
544
  })
386
545
 
387
546
  describe('reactivity', () => {
388
- test('store-level get() is reactive', () => {
389
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
547
+ test('store-level get() is reactive for both store types', () => {
548
+ // Record store
549
+ const user = createStore({
550
+ name: 'John',
551
+ email: 'john@example.com',
552
+ })
390
553
  let lastValue = { name: '', email: '' }
391
-
392
- effect(() => {
554
+ createEffect(() => {
393
555
  lastValue = user.get()
394
556
  })
395
-
396
- user.name.set('Alice')
397
-
398
557
  expect(lastValue).toEqual({
399
- name: 'Alice',
400
- email: 'hannah@example.com',
558
+ name: 'John',
559
+ email: 'john@example.com',
560
+ })
561
+ user.name.set('Jane')
562
+ expect(lastValue.name).toBe('Jane')
563
+ expect(lastValue.email).toBe('john@example.com')
564
+
565
+ // Array store
566
+ const numbers = createStore([1, 2, 3])
567
+ let lastArray: number[] = []
568
+ createEffect(() => {
569
+ lastArray = numbers.get()
401
570
  })
571
+ expect(lastArray).toEqual([1, 2, 3])
572
+ numbers[0].set(10)
573
+ expect(lastArray).toEqual([10, 2, 3])
402
574
  })
403
575
 
404
- test('individual signal reactivity works', () => {
405
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
576
+ test('individual signal reactivity works for both store types', () => {
577
+ // Record store
578
+ const user = createStore({
579
+ name: 'John',
580
+ email: 'john@example.com',
581
+ })
406
582
  let lastName = ''
407
583
  let nameEffectRuns = 0
408
-
409
- // Get signal for name property directly
410
- const nameSignal = user.name
411
-
412
- effect(() => {
413
- lastName = nameSignal.get()
584
+ createEffect(() => {
414
585
  nameEffectRuns++
415
- })
416
-
417
- // Change name should trigger effect
418
- user.name.set('Alice')
419
- expect(lastName).toBe('Alice')
420
- expect(nameEffectRuns).toBe(2) // Initial + update
586
+ lastName = user.name.get()
587
+ })
588
+ expect(lastName).toBe('John')
589
+ expect(nameEffectRuns).toBe(1)
590
+ user.name.set('Jane')
591
+ expect(lastName).toBe('Jane')
592
+ expect(nameEffectRuns).toBe(2)
593
+ // Changing email should not trigger name effect
594
+ user.email.set('jane@example.com')
595
+ expect(nameEffectRuns).toBe(2)
596
+
597
+ // Array store
598
+ const items = createStore(['a', 'b'])
599
+ let lastItem = ''
600
+ let itemEffectRuns = 0
601
+ createEffect(() => {
602
+ itemEffectRuns++
603
+ lastItem = items[0].get()
604
+ })
605
+ expect(lastItem).toBe('a')
606
+ expect(itemEffectRuns).toBe(1)
607
+ items[0].set('alpha')
608
+ expect(lastItem).toBe('alpha')
609
+ expect(itemEffectRuns).toBe(2)
610
+ // Changing other item should not trigger effect
611
+ items[1].set('beta')
612
+ expect(itemEffectRuns).toBe(2)
421
613
  })
422
614
 
423
615
  test('nested store changes propagate to parent', () => {
424
- const user = store({
616
+ const user = createStore({
425
617
  preferences: {
426
- theme: 'dark',
618
+ theme: 'light',
427
619
  },
428
620
  })
429
621
  let effectRuns = 0
430
-
431
- effect(() => {
432
- user.get() // Watch entire store
622
+ createEffect(() => {
433
623
  effectRuns++
624
+ user.get() // Subscribe to entire store
434
625
  })
435
-
436
- user.preferences.theme.set('light')
437
- expect(effectRuns).toBe(2) // Initial + nested change
626
+ expect(effectRuns).toBe(1)
627
+ user.preferences.theme.set('dark')
628
+ expect(effectRuns).toBe(2)
438
629
  })
439
630
 
440
- test('updates are reactive', () => {
441
- const user = store<{ name: string; email?: string }>({
442
- name: 'Hannah',
631
+ test('updates are reactive for both store types', () => {
632
+ // Record store
633
+ const user = createStore<{ name: string; email?: string }>({
634
+ name: 'John',
443
635
  })
444
- let lastValue = {}
636
+ let lastValue: Record<string, unknown> = {}
445
637
  let effectRuns = 0
446
-
447
- effect(() => {
448
- lastValue = user.get()
638
+ createEffect(() => {
449
639
  effectRuns++
640
+ lastValue = user.get()
450
641
  })
451
-
452
- user.add('email', 'hannah@example.com')
453
- expect(lastValue).toEqual({
454
- name: 'Hannah',
455
- email: 'hannah@example.com',
456
- })
642
+ expect(effectRuns).toBe(1)
643
+ user.update(u => ({ ...u, email: 'john@example.com' }))
457
644
  expect(effectRuns).toBe(2)
458
- })
459
-
460
- test('remove method is reactive', () => {
461
- const user = store<{ name: string; email?: string }>({
462
- name: 'Hannah',
463
- email: 'hannah@example.com',
645
+ expect(lastValue.name).toBe('John')
646
+ expect(lastValue.email).toBe('john@example.com')
647
+
648
+ // Array store
649
+ const numbers = createStore([1, 2])
650
+ let lastArray: number[] = []
651
+ let arrayEffectRuns = 0
652
+ createEffect(() => {
653
+ arrayEffectRuns++
654
+ lastArray = numbers.get()
655
+ })
656
+ expect(arrayEffectRuns).toBe(1)
657
+ numbers.update(arr => [...arr, 3])
658
+ expect(arrayEffectRuns).toBe(2)
659
+ expect(lastArray).toEqual([1, 2, 3])
660
+ })
661
+
662
+ test('remove method is reactive for both store types', () => {
663
+ // Record store
664
+ const user = createStore<{
665
+ name: string
666
+ email?: string
667
+ }>({
668
+ name: 'John',
669
+ email: 'john@example.com',
464
670
  })
465
- let lastValue = {}
671
+ let lastValue: Record<string, unknown> = {}
466
672
  let effectRuns = 0
467
-
468
- effect(() => {
469
- lastValue = user.get()
673
+ createEffect(() => {
470
674
  effectRuns++
675
+ lastValue = user.get()
471
676
  })
472
-
473
677
  expect(effectRuns).toBe(1)
474
-
475
678
  user.remove('email')
476
- expect(lastValue).toEqual({
477
- name: 'Hannah',
478
- })
479
679
  expect(effectRuns).toBe(2)
480
- })
481
-
482
- test('add method does not overwrite existing properties', () => {
483
- const user = store<{ name: string; email?: string }>({
484
- name: 'Hannah',
485
- email: 'original@example.com',
486
- })
487
-
488
- const originalSize = user.size.get()
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
- )
495
-
496
- expect(user.email?.get()).toBe('original@example.com')
497
- expect(user.size.get()).toBe(originalSize)
498
- })
499
-
500
- test('remove method has no effect on non-existent properties', () => {
501
- const user = store<{ name: string; email?: string }>({
502
- name: 'Hannah',
503
- })
504
-
505
- const originalSize = user.size.get()
506
- user.remove('email')
507
-
508
- expect(user.size.get()).toBe(originalSize)
680
+ expect(lastValue.name).toBe('John')
681
+ expect(lastValue.email).toBeUndefined()
682
+
683
+ // Array store
684
+ const items = createStore(['a', 'b', 'c'])
685
+ let lastArray: string[] = []
686
+ let arrayEffectRuns = 0
687
+ createEffect(() => {
688
+ arrayEffectRuns++
689
+ lastArray = items.get()
690
+ })
691
+ expect(arrayEffectRuns).toBe(1)
692
+ items.remove(1)
693
+ // Array removal causes multiple reactivity updates due to compaction
694
+ expect(arrayEffectRuns).toBeGreaterThanOrEqual(2)
695
+ expect(lastArray).toEqual(['a', 'c'])
509
696
  })
510
697
  })
511
698
 
512
699
  describe('computed integration', () => {
513
- test('works with computed signals', () => {
514
- const user = store({ firstName: 'Hannah', lastName: 'Smith' })
515
-
516
- const fullName = computed(() => {
700
+ test('works with computed signals for both store types', () => {
701
+ // Record store
702
+ const user = createStore({ firstName: 'John', lastName: 'Doe' })
703
+ const fullName = createComputed(() => {
517
704
  return `${user.firstName.get()} ${user.lastName.get()}`
518
705
  })
706
+ expect(fullName.get()).toBe('John Doe')
707
+ user.firstName.set('Jane')
708
+ expect(fullName.get()).toBe('Jane Doe')
519
709
 
520
- expect(fullName.get()).toBe('Hannah Smith')
521
-
522
- user.firstName.set('Alice')
523
- expect(fullName.get()).toBe('Alice Smith')
710
+ // Array store
711
+ const numbers = createStore([1, 2, 3])
712
+ const sum = createComputed(() => {
713
+ return numbers.get().reduce((acc, n) => acc + n, 0)
714
+ })
715
+ expect(sum.get()).toBe(6)
716
+ numbers[0].set(10)
717
+ expect(sum.get()).toBe(15)
524
718
  })
525
719
 
526
720
  test('computed reacts to nested store changes', () => {
527
- const config = store({
721
+ const config = createStore({
528
722
  ui: {
529
- theme: 'dark',
723
+ theme: 'light',
530
724
  },
531
725
  })
532
-
533
- const themeDisplay = computed(() => {
726
+ const themeDisplay = createComputed(() => {
534
727
  return `Theme: ${config.ui.theme.get()}`
535
728
  })
536
-
537
- expect(themeDisplay.get()).toBe('Theme: dark')
538
-
539
- config.ui.theme.set('light')
540
729
  expect(themeDisplay.get()).toBe('Theme: light')
730
+ config.ui.theme.set('dark')
731
+ expect(themeDisplay.get()).toBe('Theme: dark')
541
732
  })
542
- })
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
733
 
597
- const sum = computed(() => {
734
+ test('computed with array stores handles additions and removals', () => {
735
+ const numbers = createStore([1, 2, 3])
736
+ const sum = createComputed(() => {
598
737
  const array = numbers.get()
599
- if (!Array.isArray(array)) return 0
600
- return array.reduce((acc, num) => acc + num, 0)
738
+ return array.reduce((acc, n) => acc + n, 0)
601
739
  })
602
740
 
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)
741
+ expect(sum.get()).toBe(6)
611
742
 
612
- // Change the only element
613
- numbers[0].set(100)
614
- expect(sum.get()).toBe(100)
743
+ // Add a number
744
+ numbers.add(4)
745
+ expect(sum.get()).toBe(10)
615
746
 
616
- // Remove the only element
747
+ // Remove a number
617
748
  numbers.remove(0)
618
- expect(sum.get()).toBe(0)
619
- expect(numbers.size.get()).toBe(0)
749
+ const finalArray = numbers.get()
750
+ expect(finalArray).toEqual([2, 3, 4])
751
+ expect(sum.get()).toBe(9)
620
752
  })
621
753
 
622
- test('computed sum using store iteration with size tracking', () => {
623
- const numbers = store([10, 20, 30])
754
+ test('computed sum using store iteration with length tracking', () => {
755
+ const numbers = createStore([1, 2, 3])
624
756
 
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()
757
+ const sum = createComputed(() => {
758
+ // Access length to ensure reactivity
759
+ const _length = numbers.length
629
760
  let total = 0
630
761
  for (const signal of numbers) {
631
762
  total += signal.get()
@@ -633,1057 +764,576 @@ describe('store', () => {
633
764
  return total
634
765
  })
635
766
 
636
- expect(sum.get()).toBe(60)
767
+ expect(sum.get()).toBe(6)
637
768
 
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
769
+ // Add item
770
+ numbers.add(4)
771
+ expect(sum.get()).toBe(10)
645
772
 
646
- // Remove a value
647
- numbers.remove(2) // Remove 30
648
- expect(sum.get()).toBe(75) // 10 + 25 + 40
773
+ // Remove item
774
+ numbers.remove(1)
775
+ expect(sum.get()).toBe(8) // 1 + 3 + 4 (middle item removed)
649
776
  })
777
+ })
650
778
 
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])
779
+ describe('sort() method', () => {
780
+ test('sorts array stores with different compare functions', () => {
781
+ // Numeric sort
782
+ const numbers = createStore([3, 1, 4, 1, 5])
783
+ const _oldSignals = [
784
+ numbers[0],
785
+ numbers[1],
786
+ numbers[2],
787
+ numbers[3],
788
+ numbers[4],
789
+ ]
654
790
 
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
- })
791
+ numbers.sort((a, b) => a - b)
792
+ expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
665
793
 
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
- })
794
+ // Verify signals moved correctly
795
+ expect(numbers[0]).toBe(_oldSignals[1]) // First '1'
796
+ expect(numbers[1]).toBe(_oldSignals[3]) // Second '1'
797
+ expect(numbers[2]).toBe(_oldSignals[0]) // '3'
672
798
 
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
799
+ // String sort
800
+ const names = createStore(['Charlie', 'Alice', 'Bob'])
801
+ names.sort()
802
+ expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
699
803
  })
700
804
 
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)
805
+ test('sorts record stores by value', () => {
806
+ const users = createStore({
807
+ user1: { name: 'Charlie', age: 25 },
808
+ user2: { name: 'Alice', age: 30 },
809
+ user3: { name: 'Bob', age: 35 },
810
+ })
711
811
 
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])
812
+ const _oldSignals = {
813
+ user1: users.user1,
814
+ user2: users.user2,
815
+ user3: users.user3,
816
+ }
718
817
 
719
- // With the fix: sparse array replacement now works correctly
720
- const result = numbers.get()
818
+ users.sort((a, b) => a.name.localeCompare(b.name))
721
819
 
722
- // The fix ensures proper sparse array replacement
723
- expect(result).toEqual([100, 200]) // This now passes with the diff fix!
820
+ // After sorting by name: Alice, Bob, Charlie
821
+ // The keys should be reordered based on the sort
822
+ const sortedEntries = [...users]
823
+ expect(sortedEntries[0][1].name.get()).toBe('Alice')
824
+ expect(sortedEntries[1][1].name.get()).toBe('Bob')
825
+ expect(sortedEntries[2][1].name.get()).toBe('Charlie')
724
826
  })
725
- })
726
827
 
727
- describe('arrays and edge cases', () => {
728
- test('handles arrays as store values', () => {
729
- const data = store({ items: [1, 2, 3] })
828
+ test('emits sort notification with new order', () => {
829
+ const numbers = createStore([3, 1, 2])
830
+ let sortNotification: string[] = []
831
+ numbers.on('sort', change => {
832
+ sortNotification = change
833
+ })
730
834
 
731
- // Arrays become stores with string indices
732
- expect(isStore(data.items)).toBe(true)
733
- expect(data.items['0'].get()).toBe(1)
734
- expect(data.items['1'].get()).toBe(2)
735
- expect(data.items['2'].get()).toBe(3)
835
+ numbers.sort((a, b) => a - b)
836
+ expect(sortNotification).toEqual(['1', '2', '0']) // Original indices in new order
736
837
  })
737
838
 
738
- test('array-derived nested stores have correct type inference', () => {
739
- const todoApp = store({
740
- todos: ['Buy milk', 'Walk the dog', 'Write code'],
741
- users: [
742
- { name: 'Alice', active: true },
743
- { name: 'Bob', active: false },
744
- ],
745
- numbers: [1, 2, 3, 4, 5],
746
- })
747
-
748
- // Arrays should become stores
749
- expect(isStore(todoApp.todos)).toBe(true)
750
- expect(isStore(todoApp.users)).toBe(true)
751
- expect(isStore(todoApp.numbers)).toBe(true)
839
+ test('sort is reactive - watchers are notified', () => {
840
+ const numbers = createStore([3, 1, 2])
841
+ let effectCount = 0
842
+ let lastValue: number[] = []
752
843
 
753
- // String array elements should be State<string>
754
- expect(todoApp.todos['0'].get()).toBe('Buy milk')
755
- expect(todoApp.todos['1'].get()).toBe('Walk the dog')
756
- expect(todoApp.todos['2'].get()).toBe('Write code')
844
+ createEffect(() => {
845
+ effectCount++
846
+ lastValue = numbers.get()
847
+ })
757
848
 
758
- // Should be able to modify string elements
759
- todoApp.todos['0'].set('Buy groceries')
760
- expect(todoApp.todos['0'].get()).toBe('Buy groceries')
849
+ expect(effectCount).toBe(1)
850
+ expect(lastValue).toEqual([3, 1, 2])
761
851
 
762
- // Object array elements should be Store<T>
763
- expect(isStore(todoApp.users[0])).toBe(true)
764
- expect(isStore(todoApp.users[1])).toBe(true)
852
+ numbers.sort((a, b) => a - b)
853
+ expect(effectCount).toBe(2)
854
+ expect(lastValue).toEqual([1, 2, 3])
855
+ })
765
856
 
766
- // Should be able to access nested properties in object array elements
767
- expect(todoApp.users[0].name.get()).toBe('Alice')
768
- expect(todoApp.users[0].active.get()).toBe(true)
769
- expect(todoApp.users[1].name.get()).toBe('Bob')
770
- expect(todoApp.users[1].active.get()).toBe(false)
857
+ test('nested signals remain reactive after sorting', () => {
858
+ const items = createStore([
859
+ { name: 'Charlie', score: 85 },
860
+ { name: 'Alice', score: 95 },
861
+ { name: 'Bob', score: 75 },
862
+ ])
771
863
 
772
- // Should be able to modify nested properties
773
- todoApp.users[0].name.set('Alice Smith')
774
- todoApp.users[0].active.set(false)
775
- expect(todoApp.users[0].name.get()).toBe('Alice Smith')
776
- expect(todoApp.users[0].active.get()).toBe(false)
777
-
778
- // Number array elements should be State<number>
779
- expect(todoApp.numbers[0].get()).toBe(1)
780
- expect(todoApp.numbers[4].get()).toBe(5)
781
-
782
- // Should be able to modify number elements
783
- todoApp.numbers[0].set(10)
784
- todoApp.numbers[4].set(50)
785
- expect(todoApp.numbers[0].get()).toBe(10)
786
- expect(todoApp.numbers[4].get()).toBe(50)
787
-
788
- // Store-level access should reflect all changes
789
- const currentState = todoApp.get()
790
- expect(currentState.todos[0]).toBe('Buy groceries')
791
- expect(currentState.users[0].name).toBe('Alice Smith')
792
- expect(currentState.users[0].active).toBe(false)
793
- expect(currentState.numbers[0]).toBe(10)
794
- expect(currentState.numbers[4]).toBe(50)
795
- })
864
+ items.sort((a, b) => a.score - b.score)
796
865
 
797
- test('handles UNSET values', () => {
798
- const data = store({ value: UNSET as string })
866
+ // Verify order: Bob(75), Charlie(85), Alice(95)
867
+ expect(items[0].name.get()).toBe('Bob')
868
+ expect(items[1].name.get()).toBe('Charlie')
869
+ expect(items[2].name.get()).toBe('Alice')
799
870
 
800
- expect(data.value.get()).toBe(UNSET)
801
- data.value.set('some string')
802
- expect(data.value.get()).toBe('some string')
871
+ // Verify signals are still reactive
872
+ items[0].score.set(100)
873
+ expect(items[0].score.get()).toBe(100)
803
874
  })
804
875
 
805
- test('handles primitive values', () => {
806
- const data = store({
807
- str: 'hello',
808
- num: 42,
809
- bool: true,
810
- })
876
+ test('default sort handles numbers as strings like Array.prototype.sort()', () => {
877
+ const numbers = createStore([10, 2, 1])
878
+ numbers.sort()
879
+ expect(numbers.get()).toEqual([1, 10, 2]) // String comparison: "1" < "10" < "2"
880
+ })
811
881
 
812
- expect(data.str.get()).toBe('hello')
813
- expect(data.num.get()).toBe(42)
814
- expect(data.bool.get()).toBe(true)
882
+ test('multiple sorts work correctly', () => {
883
+ const numbers = createStore([3, 1, 2])
884
+ numbers.sort((a, b) => a - b) // [1, 2, 3]
885
+ numbers.sort((a, b) => b - a) // [3, 2, 1]
886
+ expect(numbers.get()).toEqual([3, 2, 1])
815
887
  })
816
888
  })
817
889
 
818
- describe('proxy behavior', () => {
819
- test('Object.keys returns property keys', () => {
820
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
890
+ describe('proxy behavior and enumeration', () => {
891
+ test('Object.keys returns property keys for both store types', () => {
892
+ // Record store
893
+ const user = createStore({
894
+ name: 'John',
895
+ email: 'john@example.com',
896
+ })
897
+ const userKeys = Object.keys(user)
898
+ expect(userKeys.sort()).toEqual(['email', 'name'])
821
899
 
822
- expect(Object.keys(user)).toEqual(['name', 'email'])
900
+ // Array store
901
+ const numbers = createStore([1, 2, 3])
902
+ const numberKeys = Object.keys(numbers).filter(
903
+ k => !Number.isNaN(Number(k)),
904
+ )
905
+ expect(numberKeys).toEqual(['0', '1', '2'])
823
906
  })
824
907
 
825
- test('property enumeration works', () => {
826
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
827
- const keys: string[] = []
828
-
908
+ test('property enumeration works for both store types', () => {
909
+ // Record store
910
+ const user = createStore({
911
+ name: 'John',
912
+ email: 'john@example.com',
913
+ })
914
+ const userKeys: string[] = []
829
915
  for (const key in user) {
830
- keys.push(key)
916
+ userKeys.push(key)
831
917
  }
918
+ expect(userKeys.sort()).toEqual(['email', 'name'])
832
919
 
833
- expect(keys).toEqual(['name', 'email'])
920
+ // Array store
921
+ const numbers = createStore([10, 20])
922
+ const numberKeys: string[] = []
923
+ for (const key in numbers) {
924
+ if (!Number.isNaN(Number(key))) numberKeys.push(key)
925
+ }
926
+ expect(numberKeys).toEqual(['0', '1'])
834
927
  })
835
928
 
836
- test('in operator works', () => {
837
- const user = store({ name: 'Hannah' })
838
-
929
+ test('in operator works for both store types', () => {
930
+ // Record store
931
+ const user = createStore({ name: 'John' })
839
932
  expect('name' in user).toBe(true)
840
933
  expect('email' in user).toBe(false)
841
- })
934
+ expect('length' in user).toBe(true)
842
935
 
843
- test('Object.getOwnPropertyDescriptor works', () => {
844
- const user = store({ name: 'Hannah' })
936
+ // Array store
937
+ const numbers = createStore([1, 2])
938
+ expect(0 in numbers).toBe(true)
939
+ expect(2 in numbers).toBe(false)
940
+ expect('length' in numbers).toBe(true)
941
+ })
845
942
 
846
- const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
847
- expect(descriptor).toEqual({
943
+ test('Object.getOwnPropertyDescriptor works for both store types', () => {
944
+ // Record store
945
+ const user = createStore({ name: 'John' })
946
+ const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
947
+ expect(nameDescriptor).toEqual({
848
948
  enumerable: true,
849
949
  configurable: true,
850
950
  writable: true,
851
951
  value: user.name,
852
952
  })
853
- })
854
- })
855
-
856
- describe('type conversion via toSignal', () => {
857
- test('arrays are converted to stores', () => {
858
- const fruits = store({ items: ['apple', 'banana', 'cherry'] })
859
953
 
860
- expect(isStore(fruits.items)).toBe(true)
861
- expect(fruits.items['0'].get()).toBe('apple')
862
- expect(fruits.items['1'].get()).toBe('banana')
863
- expect(fruits.items['2'].get()).toBe('cherry')
864
- })
865
-
866
- test('nested objects become nested stores', () => {
867
- const config = store({
868
- database: {
869
- host: 'localhost',
870
- port: 5432,
871
- },
954
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(
955
+ user,
956
+ 'length',
957
+ )
958
+ expect(lengthDescriptor?.enumerable).toBe(false)
959
+ expect(lengthDescriptor?.configurable).toBe(false)
960
+
961
+ // Array store
962
+ const numbers = createStore([1, 2])
963
+ const indexDescriptor = Object.getOwnPropertyDescriptor(
964
+ numbers,
965
+ '0',
966
+ )
967
+ expect(indexDescriptor).toEqual({
968
+ enumerable: true,
969
+ configurable: true,
970
+ writable: true,
971
+ value: numbers[0],
872
972
  })
873
973
 
874
- expect(isStore(config.database)).toBe(true)
875
- expect(config.database.host.get()).toBe('localhost')
876
- expect(config.database.port.get()).toBe(5432)
974
+ const arrayLengthDescriptor = Object.getOwnPropertyDescriptor(
975
+ numbers,
976
+ 'length',
977
+ )
978
+ expect(arrayLengthDescriptor?.enumerable).toBe(false)
979
+ expect(arrayLengthDescriptor?.configurable).toBe(false)
877
980
  })
878
981
  })
879
982
 
880
983
  describe('spread operator behavior', () => {
881
- test('spreading store spreads individual signals', () => {
882
- const user = store({ name: 'Hannah', age: 25, active: true })
883
-
884
- // Spread the store - should get individual signals
885
- const spread = { ...user }
886
-
887
- // Check that we get the signals themselves
888
- expect('name' in spread).toBe(true)
889
- expect('age' in spread).toBe(true)
890
- expect('active' in spread).toBe(true)
891
-
892
- // The spread should contain signals that can be called with .get()
893
- expect(typeof spread.name?.get).toBe('function')
894
- expect(typeof spread.age?.get).toBe('function')
895
- expect(typeof spread.active?.get).toBe('function')
984
+ test('spreading stores works differently for each type', () => {
985
+ // Record store - spreads individual signals
986
+ const user = createStore({ name: 'John', age: 25 })
987
+ const userSpread = { ...user }
988
+ expect('name' in userSpread).toBe(true)
989
+ expect('age' in userSpread).toBe(true)
990
+ expect(typeof userSpread.name?.get).toBe('function')
991
+ expect(userSpread.name?.get()).toBe('John')
992
+
993
+ // Array store - spreads signals (not [key, value] pairs)
994
+ const numbers = createStore([1, 2, 3])
995
+ const numberSpread = [...numbers]
996
+ expect(numberSpread).toHaveLength(3)
997
+ expect(typeof numberSpread[0].get).toBe('function')
998
+ expect(numberSpread[0].get()).toBe(1)
999
+ })
1000
+
1001
+ test('concat works correctly with array stores', () => {
1002
+ const numbers = createStore([2, 3])
1003
+ const prefix = [createState(1)]
1004
+ const suffix = [createState(4)]
1005
+
1006
+ const combined = prefix.concat(
1007
+ numbers as unknown as ConcatArray<State<number>>,
1008
+ suffix,
1009
+ )
896
1010
 
897
- // The signals should return the correct values
898
- expect(spread.name?.get()).toBe('Hannah')
899
- expect(spread.age?.get()).toBe(25)
900
- expect(spread.active?.get()).toBe(true)
1011
+ expect(combined).toHaveLength(4)
1012
+ expect(combined[0].get()).toBe(1)
1013
+ expect(combined[1].get()).toBe(2) // from store
1014
+ expect(combined[2].get()).toBe(3) // from store
1015
+ expect(combined[3].get()).toBe(4)
1016
+ })
1017
+ })
901
1018
 
902
- // Modifying the original store should be reflected in the spread signals
903
- user.name.set('Alice')
904
- user.age.set(30)
1019
+ describe('UNSET and edge cases', () => {
1020
+ test('handles UNSET values for both store types', () => {
1021
+ // Record store
1022
+ const recordData = createStore({ value: UNSET as string })
1023
+ expect(recordData.value.get()).toBe(UNSET)
1024
+ recordData.value.set('some string')
1025
+ expect(recordData.value.get()).toBe('some string')
905
1026
 
906
- expect(spread.name?.get()).toBe('Alice')
907
- expect(spread.age?.get()).toBe(30)
1027
+ // Array store
1028
+ const arrayData = createStore([UNSET as string])
1029
+ expect(arrayData[0].get()).toBe(UNSET)
1030
+ arrayData[0].set('some value')
1031
+ expect(arrayData[0].get()).toBe('some value')
908
1032
  })
909
1033
 
910
- test('spreading nested store works correctly', () => {
911
- const config = store({
912
- app: { name: 'MyApp', version: '1.0' },
913
- settings: { theme: 'dark', debug: false },
1034
+ test('handles primitive values in both store types', () => {
1035
+ // Record store
1036
+ const recordData = createStore({
1037
+ str: 'hello',
1038
+ num: 42,
1039
+ bool: true,
914
1040
  })
1041
+ expect(recordData.str.get()).toBe('hello')
1042
+ expect(recordData.num.get()).toBe(42)
1043
+ expect(recordData.bool.get()).toBe(true)
915
1044
 
916
- const spread = { ...config }
917
-
918
- // Should get nested store signals
919
- expect(isStore(spread.app)).toBe(true)
920
- expect(isStore(spread.settings)).toBe(true)
1045
+ // Array store
1046
+ const arrayData = createStore(['hello', 42, true])
1047
+ expect(arrayData[0].get()).toBe('hello')
1048
+ expect(arrayData[1].get()).toBe(42)
1049
+ expect(arrayData[2].get()).toBe(true)
1050
+ })
921
1051
 
922
- // Should be able to access nested properties
923
- expect(spread.app.name.get()).toBe('MyApp')
924
- expect(spread.settings.theme.get()).toBe('dark')
1052
+ test('handles empty stores correctly', () => {
1053
+ // Empty record store
1054
+ const emptyRecord = createStore({})
1055
+ expect(emptyRecord.length).toBe(0)
1056
+ expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1057
+ expect([...emptyRecord]).toEqual([])
925
1058
 
926
- // Modifications should be reflected
927
- config.app.name.set('UpdatedApp')
928
- expect(spread.app.name.get()).toBe('UpdatedApp')
1059
+ // Empty array store
1060
+ const emptyArray = createStore([])
1061
+ expect(emptyArray.length).toBe(0)
1062
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1063
+ expect([...emptyArray]).toEqual([])
929
1064
  })
930
1065
  })
931
1066
 
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
- ])
1067
+ describe('JSON integration and serialization', () => {
1068
+ test('seamless JSON integration for both store types', () => {
1069
+ // Record store from JSON
1070
+ const jsonData = {
1071
+ user: { name: 'John', preferences: { theme: 'dark' } },
1072
+ settings: { timeout: 5000 },
1073
+ }
1074
+ const recordStore = createStore(jsonData)
1075
+ expect(recordStore.user.name.get()).toBe('John')
1076
+ expect(recordStore.user.preferences.theme.get()).toBe('dark')
1077
+
1078
+ // Modify and serialize back
1079
+ recordStore.user.name.set('Jane')
1080
+ recordStore.settings.timeout.set(10000)
1081
+ const serialized = JSON.stringify(recordStore.get())
1082
+ const parsed = JSON.parse(serialized)
1083
+ expect(parsed.user.name).toBe('Jane')
1084
+ expect(parsed.settings.timeout).toBe(10000)
1085
+
1086
+ // Array store from JSON
1087
+ const arrayData = [
1088
+ { id: 1, name: 'Item 1' },
1089
+ { id: 2, name: 'Item 2' },
1090
+ ]
1091
+ const arrayStore = createStore(arrayData)
1092
+ expect(arrayStore[0].name.get()).toBe('Item 1')
1013
1093
 
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"')
1094
+ // Modify and serialize
1095
+ arrayStore[0].name.set('Updated Item')
1096
+ const arraySerialized = JSON.stringify(arrayStore.get())
1097
+ const arrayParsed = JSON.parse(arraySerialized)
1098
+ expect(arrayParsed[0].name).toBe('Updated Item')
1054
1099
  })
1055
1100
 
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<{
1101
+ test('handles complex nested structures from JSON', () => {
1102
+ type Dashboard = {
1079
1103
  dashboard: {
1080
- widgets: {
1104
+ widgets: Array<{
1081
1105
  id: number
1082
1106
  type: string
1083
- config: Record<string, string | number | boolean>
1084
- }[]
1085
- layout: {
1086
- columns: number
1087
- responsive: boolean
1088
- }
1107
+ config: {
1108
+ color?: string
1109
+ rows?: number
1110
+ }
1111
+ }>
1089
1112
  }
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(),
1113
+ }
1114
+ const complexData = {
1107
1115
  dashboard: {
1108
- ...dashboardStore.dashboard.get(),
1109
1116
  widgets: [
1110
- ...dashboardStore.dashboard.widgets.get(),
1111
- { id: 3, type: 'graph', config: { animate: true } },
1117
+ { id: 1, type: 'chart', config: { color: 'blue' } },
1118
+ { id: 2, type: 'table', config: { rows: 10 } },
1112
1119
  ],
1113
1120
  },
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
1121
  }
1184
1122
 
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
- })
1123
+ const store = createStore<Dashboard>(complexData)
1124
+ expect(store.dashboard.widgets[0].type.get()).toBe('chart')
1125
+ expect(store.dashboard.widgets[1].config.rows?.get()).toBe(10)
1375
1126
 
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)
1127
+ // Update nested array element
1128
+ store.dashboard.widgets[0].config.color?.set('red')
1129
+ expect(store.get().dashboard.widgets[0].config.color).toBe('red')
1452
1130
  })
1453
1131
  })
1454
1132
 
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'])
1133
+ describe('type conversion and nested stores', () => {
1134
+ test('arrays are converted to stores when nested', () => {
1135
+ const data = createStore({ items: [1, 2, 3] })
1136
+ expect(isStore(data.items)).toBe(true)
1137
+ expect(data.items[0].get()).toBe(1)
1138
+ expect(data.items[1].get()).toBe(2)
1139
+ expect(data.items[2].get()).toBe(3)
1487
1140
  })
1488
1141
 
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 },
1142
+ test('nested objects become nested stores', () => {
1143
+ const config = createStore({
1144
+ database: {
1145
+ host: 'localhost',
1146
+ port: 5432,
1147
+ },
1494
1148
  })
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)
1149
+ expect(isStore(config.database)).toBe(true)
1150
+ expect(config.database.host.get()).toBe('localhost')
1151
+ expect(config.database.port.get()).toBe(5432)
1514
1152
  })
1515
1153
 
1516
- test('emits store-sort event with new order', () => {
1517
- const numbers = store([30, 10, 20])
1518
- let sortEvent: StoreSortEvent | null = null
1154
+ test('array store with nested objects has correct type inference', () => {
1155
+ const users = createStore([
1156
+ { name: 'Alice', active: true },
1157
+ { name: 'Bob', active: false },
1158
+ ])
1519
1159
 
1520
- numbers.addEventListener('store-sort', event => {
1521
- sortEvent = event
1522
- })
1160
+ // Object array elements should be Store<T>
1161
+ expect(isStore(users[0])).toBe(true)
1162
+ expect(isStore(users[1])).toBe(true)
1523
1163
 
1524
- numbers.sort((a, b) => a - b)
1164
+ // Should be able to access nested properties
1165
+ expect(users[0].name.get()).toBe('Alice')
1166
+ expect(users[0].active.get()).toBe(true)
1167
+ expect(users[1].name.get()).toBe('Bob')
1168
+ expect(users[1].active.get()).toBe(false)
1525
1169
 
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'])
1170
+ // Should be able to modify nested properties
1171
+ users[0].name.set('Alicia')
1172
+ users[0].active.set(false)
1173
+ expect(users[0].name.get()).toBe('Alicia')
1174
+ expect(users[0].active.get()).toBe(false)
1532
1175
  })
1176
+ })
1533
1177
 
1534
- test('sort is reactive - watchers are notified', () => {
1535
- const numbers = store([3, 1, 2])
1536
- let effectCount = 0
1537
- let lastValue: number[] = []
1178
+ describe('advanced array behaviors', () => {
1179
+ test('array compaction with remove operations', () => {
1180
+ const numbers = createStore([10, 20, 30, 40, 50])
1538
1181
 
1539
- effect(() => {
1540
- lastValue = numbers.get()
1541
- effectCount++
1182
+ // Create computed to test both iteration and get() approaches
1183
+ const sumWithGet = createComputed(() => {
1184
+ const array = numbers.get()
1185
+ return array.reduce((acc, num) => acc + num, 0)
1542
1186
  })
1543
1187
 
1544
- // Initial effect run
1545
- expect(effectCount).toBe(1)
1546
- expect(lastValue).toEqual([3, 1, 2])
1188
+ expect(sumWithGet.get()).toBe(150) // 10+20+30+40+50
1547
1189
 
1548
- numbers.sort((a, b) => a - b)
1190
+ // Remove middle element - should compact the array
1191
+ numbers.remove(2) // Remove 30
1192
+ expect(numbers.length).toBe(4)
1193
+ expect(numbers.get()).toEqual([10, 20, 40, 50])
1194
+ expect(sumWithGet.get()).toBe(120) // 10+20+40+50
1549
1195
 
1550
- // Effect should run again after sort
1551
- expect(effectCount).toBe(2)
1552
- expect(lastValue).toEqual([1, 2, 3])
1196
+ // Remove first element
1197
+ numbers.remove(0) // Remove 10
1198
+ expect(numbers.length).toBe(3)
1199
+ expect(numbers.get()).toEqual([20, 40, 50])
1200
+ expect(sumWithGet.get()).toBe(110) // 20+40+50
1553
1201
  })
1554
1202
 
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
- ])
1203
+ test('sparse array replacement works correctly', () => {
1204
+ const numbers = createStore([10, 20, 30])
1561
1205
 
1562
- // Sort by score
1563
- items.sort((a, b) => b.score - a.score) // descending
1206
+ // Remove middle element to create sparse structure internally
1207
+ numbers.remove(1) // Remove 20, now [10, 30] with internal keys ["0", "2"]
1564
1208
 
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
1209
+ expect(numbers.get()).toEqual([10, 30])
1210
+ expect(numbers.length).toBe(2)
1574
1211
 
1575
- // Verify the change is reflected
1576
- expect(items.get()[1].score).toBe(100)
1577
- expect(items[1].name.get()).toBe('Charlie')
1212
+ // Set new array of same length - should work correctly
1213
+ numbers.set([100, 200])
1214
+ expect(numbers.get()).toEqual([100, 200])
1215
+ expect(numbers.length).toBe(2)
1216
+ expect(numbers[0].get()).toBe(100)
1217
+ expect(numbers[1].get()).toBe(200)
1578
1218
  })
1219
+ })
1579
1220
 
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
- ])
1221
+ describe('polymorphic behavior determined at creation', () => {
1222
+ test('store type is determined at creation time and maintained', () => {
1223
+ // Array store stays array-like
1224
+ const arrayStore = createStore([1, 2])
1225
+ expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1226
+ expect(arrayStore.length).toBe(2)
1598
1227
 
1599
- // Sort by likes (ascending)
1600
- posts.sort((a, b) => a.meta.likes - b.meta.likes)
1228
+ // Even after modifications, stays array-like
1229
+ arrayStore.add(3)
1230
+ expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1231
+ expect(arrayStore.length).toBe(3)
1601
1232
 
1602
- const sortedTitles = posts.get().map(post => post.title)
1603
- expect(sortedTitles).toEqual([
1604
- 'Advanced Topics',
1605
- 'Hello World',
1606
- 'Getting Started',
1607
- ])
1233
+ // Record store stays record-like
1234
+ const recordStore = createStore<{
1235
+ a: number
1236
+ b: number
1237
+ c?: number
1238
+ }>({ a: 1, b: 2 })
1239
+ expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
1240
+ expect(recordStore.length).toBe(2)
1608
1241
 
1609
- // Verify nested reactivity still works
1610
- posts[0].meta.likes.set(15)
1611
- expect(posts.get()[0].meta.likes).toBe(15)
1242
+ // Even after modifications, stays record-like
1243
+ recordStore.add('c', 3)
1244
+ expect(recordStore[Symbol.isConcatSpreadable]).toBe(false)
1245
+ expect(recordStore.length).toBe(3)
1612
1246
  })
1613
1247
 
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)
1248
+ test('empty stores maintain their type characteristics', () => {
1249
+ const emptyArray = createStore<string[]>([])
1250
+ const emptyRecord = createStore<{ key?: string }>({})
1619
1251
 
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
- })
1252
+ // Empty array behaves like array
1253
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1254
+ expect(emptyArray.length).toBe(0)
1626
1255
 
1627
- test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
1628
- const items = store(['banana', 'cherry', 'apple', '10', '2'])
1256
+ // Empty record behaves like record
1257
+ expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1258
+ expect(emptyRecord.length).toBe(0)
1629
1259
 
1630
- items.sort()
1260
+ // After adding items, they maintain their characteristics
1261
+ emptyArray.add('first')
1262
+ emptyRecord.add('key', 'value')
1631
1263
 
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
- )
1264
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1265
+ expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1636
1266
  })
1267
+ })
1637
1268
 
1638
- test('default sort handles numbers as strings like Array.prototype.sort()', () => {
1639
- const numbers = store([80, 9, 100])
1640
-
1641
- numbers.sort()
1269
+ describe('cross-component communication pattern', () => {
1270
+ test('event bus with UNSET initialization - type-safe pattern', () => {
1271
+ type EventBusSchema = {
1272
+ userLogin: { userId: number; timestamp: number }
1273
+ userLogout: { userId: number }
1274
+ userUpdate: { userId: number; profile: { name: string } }
1275
+ }
1642
1276
 
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
- })
1277
+ const eventBus = createStore<EventBusSchema>({
1278
+ userLogin: UNSET,
1279
+ userLogout: UNSET,
1280
+ userUpdate: UNSET,
1281
+ })
1647
1282
 
1648
- test('default sort handles mixed values with proper string conversion', () => {
1649
- const mixed = store(['b', 0, 'a', '', 'c'])
1283
+ const on = (
1284
+ event: keyof EventBusSchema,
1285
+ callback: (data: EventBusSchema[keyof EventBusSchema]) => void,
1286
+ ) =>
1287
+ createEffect(() => {
1288
+ const data = eventBus[event].get()
1289
+ if (data !== UNSET) callback(data)
1290
+ })
1650
1291
 
1651
- mixed.sort()
1292
+ let receivedLogin: unknown = null
1293
+ let receivedLogout: unknown = null
1294
+ let receivedUpdate: unknown = null
1652
1295
 
1653
- // String conversion: '' < '0' < 'a' < 'b' < 'c'
1654
- expect(mixed.get()).toEqual(['', 0, 'a', 'b', 'c'])
1655
- })
1296
+ on('userLogin', data => {
1297
+ receivedLogin = data
1298
+ })
1299
+ on('userLogout', data => {
1300
+ receivedLogout = data
1301
+ })
1302
+ on('userUpdate', data => {
1303
+ receivedUpdate = data
1304
+ })
1656
1305
 
1657
- test('multiple sorts work correctly', () => {
1658
- const numbers = store([3, 1, 4, 1, 5])
1306
+ // Initially nothing received
1307
+ expect(receivedLogin).toBe(null)
1308
+ expect(receivedLogout).toBe(null)
1309
+ expect(receivedUpdate).toBe(null)
1659
1310
 
1660
- // Sort ascending
1661
- numbers.sort((a, b) => a - b)
1662
- expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1311
+ // Emit events
1312
+ const loginData: EventBusSchema['userLogin'] = {
1313
+ userId: 123,
1314
+ timestamp: Date.now(),
1315
+ }
1316
+ eventBus.userLogin.set(loginData)
1663
1317
 
1664
- // Sort descending
1665
- numbers.sort((a, b) => b - a)
1666
- expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
1667
- })
1318
+ expect(receivedLogin).toEqual(loginData)
1319
+ expect(receivedLogout).toBe(null)
1320
+ expect(receivedUpdate).toBe(null)
1668
1321
 
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
- })
1322
+ const logoutData: EventBusSchema['userLogout'] = { userId: 123 }
1323
+ eventBus.userLogout.set(logoutData)
1675
1324
 
1676
- let sortEvent: StoreSortEvent | null = null
1677
- users.addEventListener('store-sort', event => {
1678
- sortEvent = event
1679
- })
1325
+ expect(receivedLogout).toEqual(logoutData)
1326
+ expect(receivedLogin).toEqual(loginData) // unchanged
1680
1327
 
1681
- // Sort by age
1682
- users.sort((a, b) => b.age - a.age)
1328
+ const updateData: EventBusSchema['userUpdate'] = {
1329
+ userId: 456,
1330
+ profile: { name: 'Alice' },
1331
+ }
1332
+ eventBus.userUpdate.set(updateData)
1683
1333
 
1684
- expect(sortEvent).not.toBeNull()
1685
- // biome-ignore lint/style/noNonNullAssertion: test
1686
- expect(sortEvent!.detail).toEqual(['alice', 'charlie', 'bob'])
1334
+ expect(receivedUpdate).toEqual(updateData)
1335
+ expect(receivedLogin).toEqual(loginData) // unchanged
1336
+ expect(receivedLogout).toEqual(logoutData) // unchanged
1687
1337
  })
1688
1338
  })
1689
1339
  })