@zeix/cause-effect 0.16.0 → 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.
@@ -11,158 +11,274 @@ import {
11
11
 
12
12
  describe('store', () => {
13
13
  describe('creation and basic operations', () => {
14
- test('creates a store with initial values', () => {
14
+ test('creates stores with initial values', () => {
15
+ // Record store
15
16
  const user = createStore({
16
17
  name: 'Hannah',
17
18
  email: 'hannah@example.com',
18
19
  })
19
-
20
20
  expect(user.name.get()).toBe('Hannah')
21
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)
22
28
  })
23
29
 
24
30
  test('has Symbol.toStringTag of Store', () => {
25
- const s = createStore({ a: 1 })
26
- 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')
27
36
  })
28
37
 
29
38
  test('isStore identifies store instances correctly', () => {
30
- const s = createStore({ a: 1 })
31
- const st = createState(1)
32
- const c = createComputed(() => 1)
33
-
34
- expect(isStore(s)).toBe(true)
35
- expect(isStore(st)).toBe(false)
36
- 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)
37
48
  expect(isStore({})).toBe(false)
38
49
  expect(isStore(null)).toBe(false)
39
50
  })
40
51
 
41
52
  test('get() returns the complete store value', () => {
53
+ // Record store
42
54
  const user = createStore({
43
55
  name: 'Hannah',
44
56
  email: 'hannah@example.com',
45
57
  })
46
-
47
58
  expect(user.get()).toEqual({
48
59
  name: 'Hannah',
49
60
  email: 'hannah@example.com',
50
61
  })
51
62
 
52
- const participants = createStore<
53
- { name: string; tags: string[] }[]
54
- >([
55
- { name: 'Alice', tags: ['friends', 'mates'] },
56
- { name: 'Bob', tags: ['friends'] },
57
- ])
58
- expect(participants.get()).toEqual([
59
- { name: 'Alice', tags: ['friends', 'mates'] },
60
- { 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'] },
61
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'])
62
76
  })
63
77
  })
64
78
 
65
- describe('proxy data access and modification', () => {
66
- test('properties can be accessed and modified via signals', () => {
67
- const user = createStore({ name: 'Hannah', age: 25 })
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')
68
85
 
69
- // Get signals from store proxy
70
- expect(user.name.get()).toBe('Hannah')
71
- expect(user.age.get()).toBe(25)
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)
72
102
 
73
- // Set values via signals
74
- user.name.set('Alice')
75
- user.age.set(30)
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)
110
+ })
111
+ })
76
112
 
113
+ describe('proxy data access and modification', () => {
114
+ test('properties can be accessed and modified via signals', () => {
115
+ // Record store
116
+ const user = createStore({ name: 'Alice', age: 30 })
77
117
  expect(user.name.get()).toBe('Alice')
78
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')
79
132
  })
80
133
 
81
134
  test('returns undefined for non-existent properties', () => {
82
- const user = createStore({ name: 'Hannah' })
83
-
135
+ // Record store
136
+ const user = createStore({ name: 'Alice' })
84
137
  // @ts-expect-error accessing non-existent property
85
- expect(user.nonExistent).toBeUndefined()
138
+ expect(user.nonexistent).toBeUndefined()
139
+
140
+ // Array store
141
+ const items = createStore(['a'])
142
+ expect(items[5]).toBeUndefined()
86
143
  })
87
144
 
88
- test('supports numeric key access', () => {
89
- const items = createStore({ '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')
90
150
 
91
- expect(items[0].get()).toBe('first')
92
- expect(items['0'].get()).toBe('first')
93
- expect(items[1].get()).toBe('second')
94
- 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)
95
155
  })
156
+ })
96
157
 
97
- test('can add new properties via add method', () => {
158
+ describe('add() and remove() methods', () => {
159
+ test('add() method behavior differs between store types', () => {
160
+ // Record store - requires key parameter
98
161
  const user = createStore<{ name: string; email?: string }>({
99
- name: 'Hannah',
162
+ name: 'John',
100
163
  })
164
+ user.add('email', 'john@example.com')
165
+ expect(user.email?.get()).toBe('john@example.com')
166
+ expect(user.length).toBe(2)
101
167
 
102
- 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
+ })
103
175
 
104
- expect(user.email?.get()).toBe('hannah@example.com')
105
- expect(user.get()).toEqual({
106
- name: 'Hannah',
107
- 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',
108
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)
109
192
  })
110
193
 
111
- test('can remove existing properties via remove method', () => {
194
+ test('add method prevents null values for both store types', () => {
195
+ // Record store
112
196
  const user = createStore<{ name: string; email?: string }>({
113
- name: 'Hannah',
114
- email: 'hannah@example.com',
197
+ name: 'John',
115
198
  })
199
+ // @ts-expect-error testing null values
200
+ expect(() => user.add('email', null)).toThrow()
116
201
 
117
- expect(user.email?.get()).toBe('hannah@example.com')
118
-
119
- 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
+ })
120
207
 
121
- expect(user.email).toBeUndefined()
122
- expect(user.get()).toEqual({
123
- 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',
124
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')
125
217
  })
126
218
 
127
- test('add method prevents null values', () => {
128
- const user = createStore<{ name: string; tags?: string[] }>({
129
- name: 'Alice',
130
- })
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)
131
226
 
132
- expect(() => {
133
- // @ts-expect-error deliberate test that null values are not allowed
134
- user.add('tags', null)
135
- }).toThrow(
136
- 'Nullish signal values are not allowed in store for key "tags"',
137
- )
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()
138
231
  })
139
232
  })
140
233
 
141
234
  describe('nested stores', () => {
142
- test('creates nested stores for object properties', () => {
235
+ test('creates nested stores for object properties in both store types', () => {
236
+ // Record store
143
237
  const user = createStore({
144
- name: 'Hannah',
238
+ name: 'Alice',
145
239
  preferences: {
146
240
  theme: 'dark',
147
241
  notifications: true,
148
242
  },
149
243
  })
150
-
151
244
  expect(isStore(user.preferences)).toBe(true)
152
- expect(user.preferences.theme?.get()).toBe('dark')
153
- 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)
154
256
  })
155
257
 
156
258
  test('nested properties are reactive', () => {
259
+ // Record store
157
260
  const user = createStore({
158
261
  preferences: {
159
- theme: 'dark',
262
+ theme: 'light',
160
263
  },
161
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')
162
272
 
163
- user.preferences.theme.set('light')
164
- expect(user.preferences.theme.get()).toBe('light')
165
- 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')
166
282
  })
167
283
 
168
284
  test('deeply nested stores work correctly', () => {
@@ -170,234 +286,203 @@ describe('store', () => {
170
286
  ui: {
171
287
  theme: {
172
288
  colors: {
173
- primary: 'blue',
289
+ primary: '#blue',
174
290
  },
175
291
  },
176
292
  },
177
293
  })
178
-
179
- expect(config.ui.theme.colors.primary.get()).toBe('blue')
180
- config.ui.theme.colors.primary.set('red')
181
- 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')
182
297
  })
183
298
  })
184
299
 
185
300
  describe('set() and update() methods', () => {
186
- test('set() replaces entire store value', () => {
301
+ test('set() replaces entire store value for both store types', () => {
302
+ // Record store
187
303
  const user = createStore({
188
- name: 'Hannah',
189
- email: 'hannah@example.com',
304
+ name: 'John',
305
+ email: 'john@example.com',
190
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')
191
310
 
192
- user.set({ name: 'Alice', email: 'alice@example.com' })
193
-
194
- expect(user.get()).toEqual({
195
- name: 'Alice',
196
- email: 'alice@example.com',
197
- })
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)
198
316
  })
199
317
 
200
- test('update() modifies store using function', () => {
201
- const user = createStore({ name: 'Hannah', age: 25 })
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)
202
324
 
203
- user.update(prev => ({ ...prev, age: prev.age + 1 }))
204
-
205
- expect(user.get()).toEqual({
206
- name: 'Hannah',
207
- age: 26,
208
- })
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])
209
329
  })
210
330
  })
211
331
 
212
- describe('iterator protocol', () => {
213
- test('supports for...of iteration', () => {
214
- const user = createStore({ name: 'Hannah', age: 25 })
215
- const entries: Array<[string, unknown & {}]> = []
216
-
217
- for (const [key, signal] of user) {
218
- entries.push([key, signal.get()])
219
- }
220
-
221
- expect(entries).toContainEqual(['name', 'Hannah'])
222
- expect(entries).toContainEqual(['age', 25])
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)
223
350
  })
224
- })
225
-
226
- describe('change tracking', () => {
227
- test('tracks size changes', () => {
228
- const user = createStore<{ name: string; email?: string }>({
229
- name: 'Hannah',
230
- })
231
351
 
232
- expect(user.size.get()).toBe(1)
233
-
234
- user.add('email', 'hannah@example.com')
235
- expect(user.size.get()).toBe(2)
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)
236
356
 
237
- user.remove('email')
238
- expect(user.size.get()).toBe(1)
357
+ // Record store - not spreadable
358
+ const user = createStore({ name: 'John', age: 25 })
359
+ expect(user[Symbol.isConcatSpreadable]).toBe(false)
239
360
  })
240
361
 
241
- test('emits an add notification on initial creation', async () => {
242
- let addNotification: Record<string, string> | null = null
243
- const user = createStore({ name: 'Hannah' })
244
-
245
- user.on('add', change => {
246
- addNotification = change
247
- })
248
-
249
- // Wait for the async initial event
250
- await new Promise(resolve => setTimeout(resolve, 10))
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'])
251
368
 
252
- expect(addNotification).toBeTruthy()
253
- // biome-ignore lint/style/noNonNullAssertion: test
254
- expect(addNotification!).toEqual({ name: 'Hannah' })
369
+ const signals = [...items]
370
+ expect(signals.map(s => s.get())).toEqual([
371
+ 'first',
372
+ 'second',
373
+ 'third',
374
+ ])
255
375
  })
376
+ })
256
377
 
257
- test('emits an add notification for new properties', () => {
258
- const user = createStore<{ name: string; email?: string }>({
259
- name: 'Hannah',
260
- })
261
-
262
- let addNotification: Record<string, string> | null = null
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' })
263
383
  user.on('add', change => {
264
384
  addNotification = change
265
385
  })
266
386
 
267
- user.update(v => ({ ...v, email: 'hannah@example.com' }))
268
-
269
- expect(addNotification).toBeTruthy()
270
- // biome-ignore lint/style/noNonNullAssertion: test
271
- expect(addNotification!).toEqual({
272
- email: 'hannah@example.com',
273
- })
274
- })
275
-
276
- test('emits a change notification for property changes', () => {
277
- const user = createStore({ name: 'Hannah' })
387
+ // Wait for initial add event
388
+ setTimeout(() => {
389
+ expect(addNotification.name).toBe('John')
390
+ }, 0)
278
391
 
279
- let changeNotification: Record<string, string> | null = null
280
- user.on('change', change => {
281
- changeNotification = change
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
282
399
  })
400
+ userWithEmail.add('email', 'john@example.com')
401
+ expect(newAddNotification.email).toBe('john@example.com')
283
402
 
284
- user.set({ name: 'Alice' })
285
-
286
- expect(changeNotification).toBeTruthy()
287
- // biome-ignore lint/style/noNonNullAssertion: test
288
- expect(changeNotification!).toEqual({
289
- name: 'Alice',
403
+ // Array store
404
+ const numbers = createStore([1, 2])
405
+ let arrayAddNotification = {}
406
+ numbers.on('add', change => {
407
+ arrayAddNotification = change
290
408
  })
409
+ numbers.add(3)
410
+ expect(arrayAddNotification[2]).toBe(3)
291
411
  })
292
412
 
293
- test('emits a change notification for signal changes', () => {
294
- const user = createStore({ name: 'Hannah' })
295
-
296
- let changeNotification: Record<string, string> | null = null
413
+ test('emits change notifications when properties are modified', () => {
414
+ // Record store
415
+ const user = createStore({ name: 'John' })
416
+ let changeNotification: Record<string, unknown> = {}
297
417
  user.on('change', change => {
298
418
  changeNotification = change
299
419
  })
420
+ user.name.set('Jane')
421
+ expect(changeNotification.name).toBe('Jane')
300
422
 
301
- user.name.set('Bob')
302
-
303
- expect(changeNotification).toBeTruthy()
304
- // biome-ignore lint/style/noNonNullAssertion: test
305
- expect(changeNotification!).toEqual({
306
- name: 'Bob',
423
+ // Array store
424
+ const items = createStore(['a', 'b'])
425
+ let arrayChangeNotification = {}
426
+ items.on('change', change => {
427
+ arrayChangeNotification = change
307
428
  })
429
+ items[0].set('alpha')
430
+ expect(arrayChangeNotification[0]).toBe('alpha')
308
431
  })
309
432
 
310
- test('emits a change notification when nested properties change', () => {
433
+ test('emits change notifications for nested property changes', () => {
434
+ // Record store
311
435
  const user = createStore({
312
- name: 'Hannah',
436
+ name: 'John',
313
437
  preferences: {
314
- theme: 'dark',
438
+ theme: 'light',
315
439
  notifications: true,
316
440
  },
317
441
  })
318
-
319
442
  let changeNotification: Record<string, unknown> = {}
320
443
  user.on('change', change => {
321
444
  changeNotification = change
322
445
  })
323
-
324
- // Change a nested property
325
- user.preferences.theme.set('light')
326
-
327
- expect(changeNotification).toBeTruthy()
328
- // Should notify about the direct child "preferences" that contains the changed nested property
329
- expect(changeNotification).toEqual({
330
- preferences: {
331
- theme: 'light',
332
- notifications: true,
333
- },
334
- })
335
- })
336
-
337
- test('emits a change notification for deeply nested property changes', () => {
338
- const config = createStore({
339
- ui: {
340
- theme: {
341
- colors: {
342
- primary: 'blue',
343
- secondary: 'green',
344
- },
345
- mode: 'dark',
346
- },
347
- },
446
+ user.preferences.theme.set('dark')
447
+ expect(changeNotification.preferences).toEqual({
448
+ theme: 'dark',
449
+ notifications: true,
348
450
  })
349
451
 
350
- let changeNotification: Record<string, unknown> = {}
351
- config.on('change', change => {
352
- changeNotification = change
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
353
457
  })
354
-
355
- // Change a deeply nested property (3 levels deep)
356
- config.ui.theme.colors.primary.set('red')
357
-
358
- expect(changeNotification).toBeTruthy()
359
- // Should notify about the direct child "ui" that contains the changed nested structure
360
- expect(changeNotification).toEqual({
361
- ui: {
362
- theme: {
363
- colors: {
364
- primary: 'red',
365
- secondary: 'green',
366
- },
367
- mode: 'dark',
368
- },
369
- },
458
+ users[0].name.set('Alicia')
459
+ expect(arrayChangeNotification[0]).toEqual({
460
+ name: 'Alicia',
461
+ role: 'admin',
370
462
  })
371
463
  })
372
464
 
373
- test('emits a remove notification when nested properties are removed', () => {
374
- const user = createStore<{
375
- name: string
376
- preferences?: {
377
- theme: string
378
- notifications: boolean
379
- }
380
- }>({
381
- name: 'Hannah',
382
- preferences: {
383
- theme: 'dark',
384
- notifications: true,
385
- },
465
+ test('emits remove notifications when properties are removed', () => {
466
+ // Record store
467
+ const user = createStore({
468
+ name: 'John',
469
+ email: 'john@example.com',
386
470
  })
387
-
388
471
  let removeNotification: Record<string, unknown> = {}
389
- user.on('remove', remove => {
390
- removeNotification = remove
472
+ user.on('remove', change => {
473
+ removeNotification = change
391
474
  })
475
+ user.remove('email')
476
+ expect(removeNotification.email).toBe(UNSET)
392
477
 
393
- // Remove the entire preferences object by setting the store without it
394
- user.set({ name: 'Hannah' })
395
-
396
- expect(removeNotification).toBeTruthy()
397
- // Should notify about the removal of the preferences property
398
- expect(removeNotification).toEqual({
399
- preferences: expect.anything(), // The actual structure doesn't matter, just that it was removed
478
+ // Array store
479
+ const items = createStore(['a', 'b', 'c'])
480
+ let arrayRemoveNotification: Record<number, unknown> = []
481
+ items.on('remove', change => {
482
+ arrayRemoveNotification = change
400
483
  })
484
+ items.remove(1)
485
+ expect(arrayRemoveNotification[2]).toBe(UNSET) // Last item gets removed in compaction
401
486
  })
402
487
 
403
488
  test('set() correctly handles mixed changes, additions, and removals', () => {
@@ -413,407 +498,265 @@ describe('store', () => {
413
498
  name: 'Hannah',
414
499
  email: 'hannah@example.com',
415
500
  preferences: {
416
- theme: 'dark',
501
+ theme: 'light', // will change
417
502
  },
418
503
  })
419
504
 
420
- let changeNotification: Record<string, unknown> = {}
421
- let addNotification: Record<string, unknown> = {}
422
- let removeNotification: Record<string, unknown> = {}
423
-
505
+ let changeNotification: Record<string, unknown> | undefined
506
+ let addNotification: Record<string, unknown> | undefined
507
+ let removeNotification: Record<string, unknown> | undefined
424
508
  user.on('change', change => {
425
509
  changeNotification = change
426
510
  })
427
- user.on('add', add => {
428
- addNotification = add
511
+ user.on('add', change => {
512
+ addNotification = change
429
513
  })
430
- user.on('remove', remove => {
431
- removeNotification = remove
514
+ user.on('remove', change => {
515
+ removeNotification = change
432
516
  })
433
517
 
434
- // Perform a set() that changes name, removes email, adds age, and keeps preferences
435
518
  user.set({
436
- name: 'Alice', // changed
519
+ name: 'Jane', // changed
437
520
  preferences: {
438
- theme: 'dark', // unchanged nested
521
+ theme: 'dark', // changed
439
522
  },
440
523
  age: 30, // added
441
- // email removed
442
- })
443
-
444
- // Should emit change notification for changed properties
445
- expect(changeNotification).toEqual({
446
- name: 'Alice',
447
- })
448
-
449
- // Should emit add notification for new properties
450
- expect(addNotification).toEqual({
451
- age: 30,
452
- })
453
-
454
- // Should emit remove notification for removed properties
455
- expect(removeNotification).toEqual({
456
- email: expect.anything(),
457
- })
458
- })
459
-
460
- test('set() with only removals emits only remove notifications', () => {
461
- const user = createStore<{
462
- name: string
463
- email?: string
464
- age?: number
465
- }>({
466
- name: 'Hannah',
467
- email: 'hannah@example.com',
468
- age: 25,
469
- })
470
-
471
- let changeNotification: Record<string, unknown> | null = null
472
- let removeNotification: Record<string, unknown> = {}
473
-
474
- user.on('change', change => {
475
- changeNotification = change
476
- })
477
- user.on('remove', remove => {
478
- removeNotification = remove
479
- })
480
-
481
- // Set to a subset that only removes properties (no changes)
482
- user.set({ name: 'Hannah' }) // same name, removes email and age
483
-
484
- // Should NOT emit change notification since name didn't change
485
- expect(changeNotification).toBe(null)
486
-
487
- // Should emit remove notification for removed properties
488
- expect(removeNotification).toEqual({
489
- email: expect.anything(),
490
- age: expect.anything(),
491
- })
492
- })
493
-
494
- test('emits a remove notification for removed properties', () => {
495
- const user = createStore<{ name: string; email?: string }>({
496
- name: 'Hannah',
497
- email: 'hannah@example.com',
498
- })
499
-
500
- let removeNotification: Record<string, string> | null = null
501
- user.on('remove', change => {
502
- removeNotification = change
503
- })
504
-
505
- user.remove('email')
506
-
507
- expect(removeNotification).toBeTruthy()
508
- // biome-ignore lint/style/noNonNullAssertion: test
509
- expect(removeNotification!.email).toBe(UNSET)
510
- })
511
-
512
- test('emits an add notification when using add method', () => {
513
- const user = createStore<{ name: string; email?: string }>({
514
- name: 'Hannah',
515
- })
524
+ } as { name: string; preferences: { theme: string }; age: number })
516
525
 
517
- let addNotification: Record<string, string> | null = null
518
- user.on('add', change => {
519
- addNotification = change
520
- })
521
-
522
- user.add('email', 'hannah@example.com')
523
-
524
- expect(addNotification).toBeTruthy()
525
- // biome-ignore lint/style/noNonNullAssertion: test
526
- expect(addNotification!).toEqual({
527
- email: 'hannah@example.com',
528
- })
526
+ expect(changeNotification?.preferences).toEqual({ theme: 'dark' })
527
+ expect(addNotification?.age).toBe(30)
528
+ expect(removeNotification?.email).toBe(UNSET)
529
529
  })
530
530
 
531
- test('can remove notification listeners', () => {
532
- const user = createStore({ name: 'Hannah' })
533
-
531
+ test('notification listeners can be removed', () => {
532
+ const user = createStore({ name: 'John' })
534
533
  let notificationCount = 0
535
534
  const listener = () => {
536
535
  notificationCount++
537
536
  }
538
-
539
537
  const off = user.on('change', listener)
540
- user.name.set('Alice')
538
+ user.name.set('Jane')
541
539
  expect(notificationCount).toBe(1)
542
-
543
540
  off()
544
- user.name.set('Bob')
541
+ user.name.set('Jack')
545
542
  expect(notificationCount).toBe(1) // Should not increment
546
543
  })
547
-
548
- test('supports multiple notification listeners for the same type', () => {
549
- const user = createStore({ name: 'Hannah' })
550
-
551
- let listener1Called = false
552
- let listener2Called = false
553
-
554
- user.on('change', () => {
555
- listener1Called = true
556
- })
557
-
558
- user.on('change', () => {
559
- listener2Called = true
560
- })
561
-
562
- user.name.set('Alice')
563
-
564
- expect(listener1Called).toBe(true)
565
- expect(listener2Called).toBe(true)
566
- })
567
544
  })
568
545
 
569
546
  describe('reactivity', () => {
570
- test('store-level get() is reactive', () => {
547
+ test('store-level get() is reactive for both store types', () => {
548
+ // Record store
571
549
  const user = createStore({
572
- name: 'Hannah',
573
- email: 'hannah@example.com',
550
+ name: 'John',
551
+ email: 'john@example.com',
574
552
  })
575
553
  let lastValue = { name: '', email: '' }
576
-
577
554
  createEffect(() => {
578
555
  lastValue = user.get()
579
556
  })
580
-
581
- user.name.set('Alice')
582
-
583
557
  expect(lastValue).toEqual({
584
- name: 'Alice',
585
- 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()
586
570
  })
571
+ expect(lastArray).toEqual([1, 2, 3])
572
+ numbers[0].set(10)
573
+ expect(lastArray).toEqual([10, 2, 3])
587
574
  })
588
575
 
589
- test('individual signal reactivity works', () => {
576
+ test('individual signal reactivity works for both store types', () => {
577
+ // Record store
590
578
  const user = createStore({
591
- name: 'Hannah',
592
- email: 'hannah@example.com',
579
+ name: 'John',
580
+ email: 'john@example.com',
593
581
  })
594
582
  let lastName = ''
595
583
  let nameEffectRuns = 0
596
-
597
- // Get signal for name property directly
598
- const nameSignal = user.name
599
-
600
584
  createEffect(() => {
601
- lastName = nameSignal.get()
602
585
  nameEffectRuns++
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()
603
604
  })
604
-
605
- // Change name should trigger effect
606
- user.name.set('Alice')
607
- expect(lastName).toBe('Alice')
608
- expect(nameEffectRuns).toBe(2) // Initial + update
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)
609
613
  })
610
614
 
611
615
  test('nested store changes propagate to parent', () => {
612
616
  const user = createStore({
613
617
  preferences: {
614
- theme: 'dark',
618
+ theme: 'light',
615
619
  },
616
620
  })
617
621
  let effectRuns = 0
618
-
619
622
  createEffect(() => {
620
- user.get() // Watch entire store
621
623
  effectRuns++
624
+ user.get() // Subscribe to entire store
622
625
  })
623
-
624
- user.preferences.theme.set('light')
625
- expect(effectRuns).toBe(2) // Initial + nested change
626
+ expect(effectRuns).toBe(1)
627
+ user.preferences.theme.set('dark')
628
+ expect(effectRuns).toBe(2)
626
629
  })
627
630
 
628
- test('updates are reactive', () => {
631
+ test('updates are reactive for both store types', () => {
632
+ // Record store
629
633
  const user = createStore<{ name: string; email?: string }>({
630
- name: 'Hannah',
634
+ name: 'John',
631
635
  })
632
- let lastValue = {}
636
+ let lastValue: Record<string, unknown> = {}
633
637
  let effectRuns = 0
634
-
635
638
  createEffect(() => {
636
- lastValue = user.get()
637
639
  effectRuns++
640
+ lastValue = user.get()
638
641
  })
642
+ expect(effectRuns).toBe(1)
643
+ user.update(u => ({ ...u, email: 'john@example.com' }))
644
+ expect(effectRuns).toBe(2)
645
+ expect(lastValue.name).toBe('John')
646
+ expect(lastValue.email).toBe('john@example.com')
639
647
 
640
- user.add('email', 'hannah@example.com')
641
- expect(lastValue).toEqual({
642
- name: 'Hannah',
643
- email: 'hannah@example.com',
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()
644
655
  })
645
- expect(effectRuns).toBe(2)
656
+ expect(arrayEffectRuns).toBe(1)
657
+ numbers.update(arr => [...arr, 3])
658
+ expect(arrayEffectRuns).toBe(2)
659
+ expect(lastArray).toEqual([1, 2, 3])
646
660
  })
647
661
 
648
- test('remove method is reactive', () => {
649
- const user = createStore<{ name: string; email?: string }>({
650
- name: 'Hannah',
651
- email: 'hannah@example.com',
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',
652
670
  })
653
- let lastValue = {}
671
+ let lastValue: Record<string, unknown> = {}
654
672
  let effectRuns = 0
655
-
656
673
  createEffect(() => {
657
- lastValue = user.get()
658
674
  effectRuns++
675
+ lastValue = user.get()
659
676
  })
660
-
661
677
  expect(effectRuns).toBe(1)
662
-
663
678
  user.remove('email')
664
- expect(lastValue).toEqual({
665
- name: 'Hannah',
666
- })
667
679
  expect(effectRuns).toBe(2)
668
- })
669
-
670
- test('add method does not overwrite existing properties', () => {
671
- const user = createStore<{ name: string; email?: string }>({
672
- name: 'Hannah',
673
- email: 'original@example.com',
674
- })
675
-
676
- const originalSize = user.size.get()
677
-
678
- expect(() => {
679
- user.add('email', 'new@example.com')
680
- }).toThrow(
681
- 'Could not add store key "email" with value "new@example.com" because it already exists',
682
- )
680
+ expect(lastValue.name).toBe('John')
681
+ expect(lastValue.email).toBeUndefined()
683
682
 
684
- expect(user.email?.get()).toBe('original@example.com')
685
- expect(user.size.get()).toBe(originalSize)
686
- })
687
-
688
- test('remove method has no effect on non-existent properties', () => {
689
- const user = createStore<{ name: string; email?: string }>({
690
- name: 'Hannah',
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()
691
690
  })
692
-
693
- const originalSize = user.size.get()
694
- user.remove('email')
695
-
696
- expect(user.size.get()).toBe(originalSize)
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'])
697
696
  })
698
697
  })
699
698
 
700
699
  describe('computed integration', () => {
701
- test('works with computed signals', () => {
702
- const user = createStore({ firstName: 'Hannah', lastName: 'Smith' })
703
-
700
+ test('works with computed signals for both store types', () => {
701
+ // Record store
702
+ const user = createStore({ firstName: 'John', lastName: 'Doe' })
704
703
  const fullName = createComputed(() => {
705
704
  return `${user.firstName.get()} ${user.lastName.get()}`
706
705
  })
706
+ expect(fullName.get()).toBe('John Doe')
707
+ user.firstName.set('Jane')
708
+ expect(fullName.get()).toBe('Jane Doe')
707
709
 
708
- expect(fullName.get()).toBe('Hannah Smith')
709
-
710
- user.firstName.set('Alice')
711
- 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)
712
718
  })
713
719
 
714
720
  test('computed reacts to nested store changes', () => {
715
721
  const config = createStore({
716
722
  ui: {
717
- theme: 'dark',
723
+ theme: 'light',
718
724
  },
719
725
  })
720
-
721
726
  const themeDisplay = createComputed(() => {
722
727
  return `Theme: ${config.ui.theme.get()}`
723
728
  })
724
-
725
- expect(themeDisplay.get()).toBe('Theme: dark')
726
-
727
- config.ui.theme.set('light')
728
729
  expect(themeDisplay.get()).toBe('Theme: light')
730
+ config.ui.theme.set('dark')
731
+ expect(themeDisplay.get()).toBe('Theme: dark')
729
732
  })
730
- })
731
-
732
- describe('array-derived stores with computed sum', () => {
733
- test('computes sum correctly and updates when items are added, removed, or changed', () => {
734
- // Create a store with an array of numbers
735
- const numbers = createStore([1, 2, 3, 4, 5])
736
-
737
- // Create a computed that calculates the sum by accessing the array via .get()
738
- // This ensures reactivity to both value changes and structural changes
739
- const sum = createComputed(() => {
740
- const array = numbers.get()
741
- if (!Array.isArray(array)) return 0
742
- return array.reduce((acc, num) => acc + num, 0)
743
- })
744
-
745
- // Initial sum should be 15 (1+2+3+4+5)
746
- expect(sum.get()).toBe(15)
747
- expect(numbers.size.get()).toBe(5)
748
-
749
- // Test adding items
750
- numbers.add(6) // Add 6 at index 5
751
- expect(sum.get()).toBe(21) // 15 + 6 = 21
752
- expect(numbers.size.get()).toBe(6)
753
-
754
- numbers.add(7) // Add 7 at index 6
755
- expect(sum.get()).toBe(28) // 21 + 7 = 28
756
- expect(numbers.size.get()).toBe(7)
757
-
758
- // Test changing a single value
759
- numbers[2].set(10) // Change index 2 from 3 to 10
760
- expect(sum.get()).toBe(35) // 28 - 3 + 10 = 35
761
-
762
- // Test another value change
763
- numbers[0].set(5) // Change index 0 from 1 to 5
764
- expect(sum.get()).toBe(39) // 35 - 1 + 5 = 39
765
-
766
- // Test removing items
767
- numbers.remove(6) // Remove index 6 (value 7)
768
- expect(sum.get()).toBe(32) // 39 - 7 = 32
769
- expect(numbers.size.get()).toBe(6)
770
-
771
- numbers.remove(0) // Remove index 0 (value 5)
772
- expect(sum.get()).toBe(27) // 32 - 5 = 27
773
- expect(numbers.size.get()).toBe(5)
774
-
775
- // Verify the final array structure using .get()
776
- const finalArray = numbers.get()
777
- expect(Array.isArray(finalArray)).toBe(true)
778
- expect(finalArray).toEqual([2, 10, 4, 5, 6])
779
- })
780
-
781
- test('handles empty array and single element operations', () => {
782
- // Start with empty array
783
- const numbers = createStore<number[]>([])
784
733
 
734
+ test('computed with array stores handles additions and removals', () => {
735
+ const numbers = createStore([1, 2, 3])
785
736
  const sum = createComputed(() => {
786
737
  const array = numbers.get()
787
- if (!Array.isArray(array)) return 0
788
- return array.reduce((acc, num) => acc + num, 0)
738
+ return array.reduce((acc, n) => acc + n, 0)
789
739
  })
790
740
 
791
- // Empty array sum should be 0
792
- expect(sum.get()).toBe(0)
793
- expect(numbers.size.get()).toBe(0)
794
-
795
- // Add first element
796
- numbers.add(42)
797
- expect(sum.get()).toBe(42)
798
- expect(numbers.size.get()).toBe(1)
741
+ expect(sum.get()).toBe(6)
799
742
 
800
- // Change the only element
801
- numbers[0].set(100)
802
- expect(sum.get()).toBe(100)
743
+ // Add a number
744
+ numbers.add(4)
745
+ expect(sum.get()).toBe(10)
803
746
 
804
- // Remove the only element
747
+ // Remove a number
805
748
  numbers.remove(0)
806
- expect(sum.get()).toBe(0)
807
- expect(numbers.size.get()).toBe(0)
749
+ const finalArray = numbers.get()
750
+ expect(finalArray).toEqual([2, 3, 4])
751
+ expect(sum.get()).toBe(9)
808
752
  })
809
753
 
810
- test('computed sum using store iteration with size tracking', () => {
811
- const numbers = createStore([10, 20, 30])
754
+ test('computed sum using store iteration with length tracking', () => {
755
+ const numbers = createStore([1, 2, 3])
812
756
 
813
- // Use iteration but also track size to ensure reactivity to additions/removals
814
757
  const sum = createComputed(() => {
815
- // Access size to subscribe to structural changes
816
- numbers.size.get()
758
+ // Access length to ensure reactivity
759
+ const _length = numbers.length
817
760
  let total = 0
818
761
  for (const signal of numbers) {
819
762
  total += signal.get()
@@ -821,1086 +764,522 @@ describe('store', () => {
821
764
  return total
822
765
  })
823
766
 
824
- expect(sum.get()).toBe(60)
825
-
826
- // Add more numbers
827
- numbers.add(40)
828
- expect(sum.get()).toBe(100)
767
+ expect(sum.get()).toBe(6)
829
768
 
830
- // Modify existing values
831
- numbers[1].set(25) // Change 20 to 25
832
- expect(sum.get()).toBe(105) // 10 + 25 + 30 + 40
769
+ // Add item
770
+ numbers.add(4)
771
+ expect(sum.get()).toBe(10)
833
772
 
834
- // Remove a value
835
- numbers.remove(2) // Remove 30
836
- 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)
837
776
  })
777
+ })
838
778
 
839
- test('demonstrates array compaction behavior with remove operations', () => {
840
- // Create a store with an array
841
- const numbers = createStore([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
+ ]
842
790
 
843
- // Create a computed using iteration approach with size tracking
844
- const sumWithIteration = createComputed(() => {
845
- // Access size to subscribe to structural changes
846
- numbers.size.get()
847
- let total = 0
848
- for (const signal of numbers) {
849
- total += signal.get()
850
- }
851
- return total
852
- })
791
+ numbers.sort((a, b) => a - b)
792
+ expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
853
793
 
854
- // Create a computed using .get() approach for comparison
855
- const sumWithGet = createComputed(() => {
856
- const array = numbers.get()
857
- if (!Array.isArray(array)) return 0
858
- return array.reduce((acc, num) => acc + num, 0)
859
- })
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'
860
798
 
861
- // Initial state: [10, 20, 30, 40, 50], keys [0,1,2,3,4]
862
- expect(sumWithIteration.get()).toBe(150)
863
- expect(sumWithGet.get()).toBe(150)
864
- expect(numbers.size.get()).toBe(5)
865
-
866
- // Remove items - arrays should compact (not create sparse holes)
867
- numbers.remove(1) // Remove 20, array becomes [10, 30, 40, 50]
868
- expect(numbers.size.get()).toBe(4)
869
- expect(numbers.get()).toEqual([10, 30, 40, 50])
870
- expect(sumWithIteration.get()).toBe(130) // 10 + 30 + 40 + 50
871
- expect(sumWithGet.get()).toBe(130)
872
-
873
- numbers.remove(2) // Remove 40, array becomes [10, 30, 50]
874
- expect(numbers.size.get()).toBe(3)
875
- expect(numbers.get()).toEqual([10, 30, 50])
876
- expect(sumWithIteration.get()).toBe(90) // 10 + 30 + 50
877
- expect(sumWithGet.get()).toBe(90)
878
-
879
- // Set a new array of same size (3 elements)
880
- numbers.set([100, 200, 300])
881
- expect(numbers.size.get()).toBe(3)
882
- expect(numbers.get()).toEqual([100, 200, 300])
883
-
884
- // Both approaches work correctly with compacted arrays
885
- expect(sumWithGet.get()).toBe(600) // 100 + 200 + 300
886
- 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'])
887
803
  })
888
804
 
889
- test('verifies root cause: diff works on array representation but reconcile uses sparse keys', () => {
890
- // Create a sparse array scenario
891
- const numbers = createStore([10, 20, 30])
892
-
893
- // Remove middle element to create sparse structure
894
- numbers.remove(1) // Now has keys ["0", "2"] with values [10, 30]
895
-
896
- // Verify the sparse structure
897
- expect(numbers.get()).toEqual([10, 30])
898
- 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
+ })
899
811
 
900
- // Now set a new array of same length
901
- // The diff should see [10, 30] -> [100, 200] as:
902
- // - index 0: 10 -> 100 (change)
903
- // - index 1: 30 -> 200 (change)
904
- // But internally the keys are ["0", "2"], not ["0", "1"]
905
- numbers.set([100, 200])
812
+ const _oldSignals = {
813
+ user1: users.user1,
814
+ user2: users.user2,
815
+ user3: users.user3,
816
+ }
906
817
 
907
- // With the fix: sparse array replacement now works correctly
908
- const result = numbers.get()
818
+ users.sort((a, b) => a.name.localeCompare(b.name))
909
819
 
910
- // The fix ensures proper sparse array replacement
911
- 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')
912
826
  })
913
- })
914
827
 
915
- describe('arrays and edge cases', () => {
916
- test('handles arrays as store values', () => {
917
- const data = createStore({ 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
+ })
918
834
 
919
- // Arrays become stores with string indices
920
- expect(isStore(data.items)).toBe(true)
921
- expect(data.items['0'].get()).toBe(1)
922
- expect(data.items['1'].get()).toBe(2)
923
- 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
924
837
  })
925
838
 
926
- test('array-derived nested stores have correct type inference', () => {
927
- const todoApp = createStore({
928
- todos: ['Buy milk', 'Walk the dog', 'Write code'],
929
- users: [
930
- { name: 'Alice', active: true },
931
- { name: 'Bob', active: false },
932
- ],
933
- numbers: [1, 2, 3, 4, 5],
839
+ test('sort is reactive - watchers are notified', () => {
840
+ const numbers = createStore([3, 1, 2])
841
+ let effectCount = 0
842
+ let lastValue: number[] = []
843
+
844
+ createEffect(() => {
845
+ effectCount++
846
+ lastValue = numbers.get()
934
847
  })
935
848
 
936
- // Arrays should become stores
937
- expect(isStore(todoApp.todos)).toBe(true)
938
- expect(isStore(todoApp.users)).toBe(true)
939
- expect(isStore(todoApp.numbers)).toBe(true)
849
+ expect(effectCount).toBe(1)
850
+ expect(lastValue).toEqual([3, 1, 2])
940
851
 
941
- // String array elements should be State<string>
942
- expect(todoApp.todos['0'].get()).toBe('Buy milk')
943
- expect(todoApp.todos['1'].get()).toBe('Walk the dog')
944
- expect(todoApp.todos['2'].get()).toBe('Write code')
852
+ numbers.sort((a, b) => a - b)
853
+ expect(effectCount).toBe(2)
854
+ expect(lastValue).toEqual([1, 2, 3])
855
+ })
945
856
 
946
- // Should be able to modify string elements
947
- todoApp.todos['0'].set('Buy groceries')
948
- expect(todoApp.todos['0'].get()).toBe('Buy groceries')
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
+ ])
949
863
 
950
- // Object array elements should be Store<T>
951
- expect(isStore(todoApp.users[0])).toBe(true)
952
- expect(isStore(todoApp.users[1])).toBe(true)
864
+ items.sort((a, b) => a.score - b.score)
953
865
 
954
- // Should be able to access nested properties in object array elements
955
- expect(todoApp.users[0].name.get()).toBe('Alice')
956
- expect(todoApp.users[0].active.get()).toBe(true)
957
- expect(todoApp.users[1].name.get()).toBe('Bob')
958
- expect(todoApp.users[1].active.get()).toBe(false)
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')
959
870
 
960
- // Should be able to modify nested properties
961
- todoApp.users[0].name.set('Alice Smith')
962
- todoApp.users[0].active.set(false)
963
- expect(todoApp.users[0].name.get()).toBe('Alice Smith')
964
- expect(todoApp.users[0].active.get()).toBe(false)
965
-
966
- // Number array elements should be State<number>
967
- expect(todoApp.numbers[0].get()).toBe(1)
968
- expect(todoApp.numbers[4].get()).toBe(5)
969
-
970
- // Should be able to modify number elements
971
- todoApp.numbers[0].set(10)
972
- todoApp.numbers[4].set(50)
973
- expect(todoApp.numbers[0].get()).toBe(10)
974
- expect(todoApp.numbers[4].get()).toBe(50)
975
-
976
- // Store-level access should reflect all changes
977
- const currentState = todoApp.get()
978
- expect(currentState.todos[0]).toBe('Buy groceries')
979
- expect(currentState.users[0].name).toBe('Alice Smith')
980
- expect(currentState.users[0].active).toBe(false)
981
- expect(currentState.numbers[0]).toBe(10)
982
- expect(currentState.numbers[4]).toBe(50)
871
+ // Verify signals are still reactive
872
+ items[0].score.set(100)
873
+ expect(items[0].score.get()).toBe(100)
983
874
  })
984
875
 
985
- test('handles UNSET values', () => {
986
- const data = createStore({ value: UNSET as string })
987
-
988
- expect(data.value.get()).toBe(UNSET)
989
- data.value.set('some string')
990
- expect(data.value.get()).toBe('some string')
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"
991
880
  })
992
881
 
993
- test('handles primitive values', () => {
994
- const data = createStore({
995
- str: 'hello',
996
- num: 42,
997
- bool: true,
998
- })
999
-
1000
- expect(data.str.get()).toBe('hello')
1001
- expect(data.num.get()).toBe(42)
1002
- 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])
1003
887
  })
1004
888
  })
1005
889
 
1006
- describe('proxy behavior', () => {
1007
- test('Object.keys returns property keys', () => {
890
+ describe('proxy behavior and enumeration', () => {
891
+ test('Object.keys returns property keys for both store types', () => {
892
+ // Record store
1008
893
  const user = createStore({
1009
- name: 'Hannah',
1010
- email: 'hannah@example.com',
894
+ name: 'John',
895
+ email: 'john@example.com',
1011
896
  })
897
+ const userKeys = Object.keys(user)
898
+ expect(userKeys.sort()).toEqual(['email', 'name'])
1012
899
 
1013
- 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'])
1014
906
  })
1015
907
 
1016
- test('property enumeration works', () => {
908
+ test('property enumeration works for both store types', () => {
909
+ // Record store
1017
910
  const user = createStore({
1018
- name: 'Hannah',
1019
- email: 'hannah@example.com',
911
+ name: 'John',
912
+ email: 'john@example.com',
1020
913
  })
1021
- const keys: string[] = []
1022
-
914
+ const userKeys: string[] = []
1023
915
  for (const key in user) {
1024
- keys.push(key)
916
+ userKeys.push(key)
1025
917
  }
918
+ expect(userKeys.sort()).toEqual(['email', 'name'])
1026
919
 
1027
- 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'])
1028
927
  })
1029
928
 
1030
- test('in operator works', () => {
1031
- const user = createStore({ name: 'Hannah' })
1032
-
929
+ test('in operator works for both store types', () => {
930
+ // Record store
931
+ const user = createStore({ name: 'John' })
1033
932
  expect('name' in user).toBe(true)
1034
933
  expect('email' in user).toBe(false)
1035
- })
934
+ expect('length' in user).toBe(true)
1036
935
 
1037
- test('Object.getOwnPropertyDescriptor works', () => {
1038
- const user = createStore({ 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
+ })
1039
942
 
1040
- const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
1041
- 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({
1042
948
  enumerable: true,
1043
949
  configurable: true,
1044
950
  writable: true,
1045
951
  value: user.name,
1046
952
  })
1047
- })
1048
- })
1049
-
1050
- describe('type conversion via toSignal', () => {
1051
- test('arrays are converted to stores', () => {
1052
- const fruits = createStore({ items: ['apple', 'banana', 'cherry'] })
1053
-
1054
- expect(isStore(fruits.items)).toBe(true)
1055
- expect(fruits.items['0'].get()).toBe('apple')
1056
- expect(fruits.items['1'].get()).toBe('banana')
1057
- expect(fruits.items['2'].get()).toBe('cherry')
1058
- })
1059
953
 
1060
- test('nested objects become nested stores', () => {
1061
- const config = createStore({
1062
- database: {
1063
- host: 'localhost',
1064
- port: 5432,
1065
- },
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],
1066
972
  })
1067
973
 
1068
- expect(isStore(config.database)).toBe(true)
1069
- expect(config.database.host.get()).toBe('localhost')
1070
- 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)
1071
980
  })
1072
981
  })
1073
982
 
1074
983
  describe('spread operator behavior', () => {
1075
- test('spreading store spreads individual signals', () => {
1076
- const user = createStore({ name: 'Hannah', age: 25, active: true })
1077
-
1078
- // Spread the store - should get individual signals
1079
- const spread = { ...user }
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
+ })
1080
1000
 
1081
- // Check that we get the signals themselves
1082
- expect('name' in spread).toBe(true)
1083
- expect('age' in spread).toBe(true)
1084
- expect('active' in spread).toBe(true)
1001
+ test('concat works correctly with array stores', () => {
1002
+ const numbers = createStore([2, 3])
1003
+ const prefix = [createState(1)]
1004
+ const suffix = [createState(4)]
1085
1005
 
1086
- // The spread should contain signals that can be called with .get()
1087
- expect(typeof spread.name?.get).toBe('function')
1088
- expect(typeof spread.age?.get).toBe('function')
1089
- expect(typeof spread.active?.get).toBe('function')
1006
+ const combined = prefix.concat(
1007
+ numbers as unknown as ConcatArray<State<number>>,
1008
+ suffix,
1009
+ )
1090
1010
 
1091
- // The signals should return the correct values
1092
- expect(spread.name?.get()).toBe('Hannah')
1093
- expect(spread.age?.get()).toBe(25)
1094
- 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
+ })
1095
1018
 
1096
- // Modifying the original store should be reflected in the spread signals
1097
- user.name.set('Alice')
1098
- 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')
1099
1026
 
1100
- expect(spread.name?.get()).toBe('Alice')
1101
- 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')
1102
1032
  })
1103
1033
 
1104
- test('spreading nested store works correctly', () => {
1105
- const config = createStore({
1106
- app: { name: 'MyApp', version: '1.0' },
1107
- 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,
1108
1040
  })
1041
+ expect(recordData.str.get()).toBe('hello')
1042
+ expect(recordData.num.get()).toBe(42)
1043
+ expect(recordData.bool.get()).toBe(true)
1109
1044
 
1110
- const spread = { ...config }
1111
-
1112
- // Should get nested store signals
1113
- expect(isStore(spread.app)).toBe(true)
1114
- 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
+ })
1115
1051
 
1116
- // Should be able to access nested properties
1117
- expect(spread.app.name.get()).toBe('MyApp')
1118
- 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([])
1119
1058
 
1120
- // Modifications should be reflected
1121
- config.app.name.set('UpdatedApp')
1122
- 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([])
1123
1064
  })
1124
1065
  })
1125
1066
 
1126
- describe('JSON integration', () => {
1127
- test('seamless integration with JSON.parse() and JSON.stringify() for API workflows', async () => {
1128
- // Simulate loading data from a JSON API response
1129
- const jsonResponse = `{
1130
- "user": {
1131
- "id": 123,
1132
- "name": "John Doe",
1133
- "email": "john@example.com",
1134
- "preferences": {
1135
- "theme": "dark",
1136
- "notifications": true,
1137
- "language": "en"
1138
- }
1139
- },
1140
- "settings": {
1141
- "autoSave": true,
1142
- "timeout": 5000
1143
- },
1144
- "tags": ["developer", "javascript", "typescript"]
1145
- }`
1146
-
1147
- // Parse JSON and create store - works seamlessly
1148
- const apiData = JSON.parse(jsonResponse)
1149
- const userStore = createStore<{
1150
- user: {
1151
- id: number
1152
- name: string
1153
- email: string
1154
- preferences: {
1155
- theme: string
1156
- notifications: boolean
1157
- language: string
1158
- fontSize?: number
1159
- }
1160
- }
1161
- settings: {
1162
- autoSave: boolean
1163
- timeout: number
1164
- }
1165
- tags: string[]
1166
- lastLogin?: Date
1167
- }>(apiData)
1168
-
1169
- // Verify initial data is accessible and reactive
1170
- expect(userStore.user.name.get()).toBe('John Doe')
1171
- expect(userStore.user.preferences.theme.get()).toBe('dark')
1172
- expect(userStore.settings.autoSave.get()).toBe(true)
1173
- expect(userStore.get().tags).toEqual([
1174
- 'developer',
1175
- 'javascript',
1176
- 'typescript',
1177
- ])
1178
-
1179
- // Simulate user interactions - update preferences
1180
- userStore.user.preferences.theme.set('light')
1181
- userStore.user.preferences.notifications.set(false)
1182
-
1183
- // Add new preference
1184
- userStore.user.preferences.add('fontSize', 14)
1185
-
1186
- // Update settings
1187
- userStore.settings.timeout.set(10000)
1188
-
1189
- // Add new top-level property
1190
- userStore.add('lastLogin', new Date('2024-01-15T10:30:00Z'))
1191
-
1192
- // Verify changes are reflected
1193
- expect(userStore.user.preferences.theme.get()).toBe('light')
1194
- expect(userStore.user.preferences.notifications.get()).toBe(false)
1195
- expect(userStore.settings.timeout.get()).toBe(10000)
1196
-
1197
- // Get current state and verify it's JSON-serializable
1198
- const currentState = userStore.get()
1199
- expect(currentState.user.preferences.theme).toBe('light')
1200
- expect(currentState.user.preferences.notifications).toBe(false)
1201
- expect(currentState.settings.timeout).toBe(10000)
1202
- expect(currentState.tags).toEqual([
1203
- 'developer',
1204
- 'javascript',
1205
- 'typescript',
1206
- ])
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')
1207
1093
 
1208
- // Convert back to JSON - seamless serialization
1209
- const jsonPayload = JSON.stringify(currentState)
1210
-
1211
- // Verify the JSON contains our updates
1212
- const parsedBack = JSON.parse(jsonPayload)
1213
- expect(parsedBack.user.preferences.theme).toBe('light')
1214
- expect(parsedBack.user.preferences.notifications).toBe(false)
1215
- expect(parsedBack.user.preferences.fontSize).toBe(14)
1216
- expect(parsedBack.settings.timeout).toBe(10000)
1217
- expect(parsedBack.lastLogin).toBe('2024-01-15T10:30:00.000Z')
1218
-
1219
- // Demonstrate update() for bulk changes
1220
- userStore.update(data => ({
1221
- ...data,
1222
- user: {
1223
- ...data.user,
1224
- email: 'john.doe@newcompany.com',
1225
- preferences: {
1226
- ...data.user.preferences,
1227
- theme: 'auto',
1228
- language: 'fr',
1229
- },
1230
- },
1231
- settings: {
1232
- ...data.settings,
1233
- autoSave: false,
1234
- },
1235
- }))
1236
-
1237
- // Verify bulk update worked
1238
- expect(userStore.user.email.get()).toBe('john.doe@newcompany.com')
1239
- expect(userStore.user.preferences.theme.get()).toBe('auto')
1240
- expect(userStore.user.preferences.language.get()).toBe('fr')
1241
- expect(userStore.settings.autoSave.get()).toBe(false)
1242
-
1243
- // Final JSON serialization for sending to server
1244
- const finalPayload = JSON.stringify(userStore.get())
1245
- expect(typeof finalPayload).toBe('string')
1246
- expect(finalPayload).toContain('john.doe@newcompany.com')
1247
- 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')
1248
1099
  })
1249
1100
 
1250
- test('handles complex nested structures and arrays from JSON', () => {
1251
- const complexJson = `{
1252
- "dashboard": {
1253
- "widgets": [
1254
- {"id": 1, "type": "chart", "config": {"color": "blue"}},
1255
- {"id": 2, "type": "table", "config": {"rows": 10}}
1256
- ],
1257
- "layout": {
1258
- "columns": 3,
1259
- "responsive": true
1260
- }
1261
- },
1262
- "metadata": {
1263
- "version": "1.0.0",
1264
- "created": "2024-01-01T00:00:00Z",
1265
- "tags": null
1266
- }
1267
- }`
1268
-
1269
- const data = JSON.parse(complexJson)
1270
-
1271
- // Test that null values in initial JSON are filtered out (treated as UNSET)
1272
- const dashboardStore = createStore<{
1101
+ test('handles complex nested structures from JSON', () => {
1102
+ type Dashboard = {
1273
1103
  dashboard: {
1274
- widgets: {
1104
+ widgets: Array<{
1275
1105
  id: number
1276
1106
  type: string
1277
- config: Record<string, string | number | boolean>
1278
- }[]
1279
- layout: {
1280
- columns: number
1281
- responsive: boolean
1282
- }
1283
- }
1284
- metadata: {
1285
- version: string
1286
- created: string
1287
- tags?: string[]
1107
+ config: {
1108
+ color?: string
1109
+ rows?: number
1110
+ }
1111
+ }>
1288
1112
  }
1289
- }>(data)
1290
-
1291
- // Access nested array elements
1292
- expect(dashboardStore.dashboard.widgets.get()).toHaveLength(2)
1293
- expect(dashboardStore.dashboard.widgets[0].type.get()).toBe('chart')
1294
- expect(dashboardStore.dashboard.widgets[1].config.rows.get()).toBe(
1295
- 10,
1296
- )
1297
-
1298
- // Update array element
1299
- dashboardStore.set({
1300
- ...dashboardStore.get(),
1113
+ }
1114
+ const complexData = {
1301
1115
  dashboard: {
1302
- ...dashboardStore.dashboard.get(),
1303
1116
  widgets: [
1304
- ...dashboardStore.dashboard.widgets.get(),
1305
- { id: 3, type: 'graph', config: { animate: true } },
1117
+ { id: 1, type: 'chart', config: { color: 'blue' } },
1118
+ { id: 2, type: 'table', config: { rows: 10 } },
1306
1119
  ],
1307
1120
  },
1308
- })
1309
-
1310
- // Verify array update
1311
- expect(dashboardStore.get().dashboard.widgets).toHaveLength(3)
1312
- expect(dashboardStore.get().dashboard.widgets[2].type).toBe('graph')
1313
-
1314
- // Test that individual null additions are still prevented via add()
1315
- expect(() => {
1316
- // @ts-expect-error deliberate test case
1317
- dashboardStore.add('newProp', null)
1318
- }).toThrow(
1319
- 'Nullish signal values are not allowed in store for key "newProp"',
1320
- )
1321
-
1322
- // Test that individual property .set() operations prevent null values
1323
- expect(() => {
1324
- dashboardStore.update(data => ({
1325
- ...data,
1326
- metadata: {
1327
- ...data.metadata,
1328
- // @ts-expect-error deliberate test case
1329
- tags: null,
1330
- },
1331
- }))
1332
- }).toThrow(
1333
- 'Nullish signal values are not allowed in store for key "tags"',
1334
- )
1335
-
1336
- // Update null to actual value (this should work)
1337
- dashboardStore.update(data => ({
1338
- ...data,
1339
- metadata: {
1340
- ...data.metadata,
1341
- tags: ['production', 'v1'],
1342
- },
1343
- }))
1344
-
1345
- expect(dashboardStore.get().metadata.tags).toEqual([
1346
- 'production',
1347
- 'v1',
1348
- ])
1349
-
1350
- // Verify JSON round-trip
1351
- const serialized = JSON.stringify(dashboardStore.get())
1352
- const reparsed = JSON.parse(serialized)
1353
- expect(reparsed.dashboard.widgets).toHaveLength(3)
1354
- expect(reparsed.metadata.tags).toEqual(['production', 'v1'])
1355
- })
1356
-
1357
- test('demonstrates real-world form data management', () => {
1358
- // Simulate form data loaded from API
1359
- const formData = {
1360
- profile: {
1361
- firstName: '',
1362
- lastName: '',
1363
- email: '',
1364
- bio: '',
1365
- },
1366
- preferences: {
1367
- emailNotifications: true,
1368
- pushNotifications: false,
1369
- marketing: false,
1370
- },
1371
- address: {
1372
- street: '',
1373
- city: '',
1374
- country: 'US',
1375
- zipCode: '',
1376
- },
1377
1121
  }
1378
1122
 
1379
- const formStore = createStore<{
1380
- profile: {
1381
- id?: number
1382
- createdAt?: string
1383
- firstName: string
1384
- lastName: string
1385
- email: string
1386
- bio: string
1387
- }
1388
- preferences: {
1389
- emailNotifications: boolean
1390
- pushNotifications: boolean
1391
- marketing: boolean
1392
- }
1393
- address: {
1394
- street: string
1395
- city: string
1396
- country: string
1397
- zipCode: string
1398
- }
1399
- }>(formData)
1400
-
1401
- // Simulate user filling out form
1402
- formStore.profile.firstName.set('Jane')
1403
- formStore.profile.lastName.set('Smith')
1404
- formStore.profile.email.set('jane.smith@example.com')
1405
- formStore.profile.bio.set(
1406
- 'Full-stack developer with 5 years experience',
1407
- )
1408
-
1409
- // Update address
1410
- formStore.address.street.set('123 Main St')
1411
- formStore.address.city.set('San Francisco')
1412
- formStore.address.zipCode.set('94105')
1413
-
1414
- // Toggle preferences
1415
- formStore.preferences.pushNotifications.set(true)
1416
- formStore.preferences.marketing.set(true)
1417
-
1418
- // Get form data for submission - ready for JSON.stringify
1419
- const submissionData = formStore.get()
1420
-
1421
- expect(submissionData.profile.firstName).toBe('Jane')
1422
- expect(submissionData.profile.email).toBe('jane.smith@example.com')
1423
- expect(submissionData.address.city).toBe('San Francisco')
1424
- expect(submissionData.preferences.pushNotifications).toBe(true)
1425
-
1426
- // Simulate sending to API
1427
- const jsonPayload = JSON.stringify(submissionData)
1428
- expect(jsonPayload).toContain('jane.smith@example.com')
1429
- expect(jsonPayload).toContain('San Francisco')
1430
-
1431
- // Simulate receiving updated data back from server
1432
- const serverResponse = {
1433
- ...submissionData,
1434
- profile: {
1435
- ...submissionData.profile,
1436
- id: 456,
1437
- createdAt: '2024-01-15T12:00:00Z',
1438
- },
1439
- }
1440
-
1441
- // Update store with server response
1442
- formStore.set(serverResponse)
1443
-
1444
- // Verify server data is integrated
1445
- expect(formStore.profile.id?.get()).toBe(456)
1446
- expect(formStore.profile.createdAt?.get()).toBe(
1447
- '2024-01-15T12:00:00Z',
1448
- )
1449
- expect(formStore.get().profile.firstName).toBe('Jane') // Original data preserved
1450
- })
1451
-
1452
- describe('Symbol.isConcatSpreadable and polymorphic behavior', () => {
1453
- test('array-like stores have Symbol.isConcatSpreadable true and length property', () => {
1454
- const numbers = createStore([1, 2, 3])
1455
-
1456
- // Should be concat spreadable
1457
- expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
1458
-
1459
- // Should have length property
1460
- expect(numbers.length).toBe(3)
1461
- expect(typeof numbers.length).toBe('number')
1462
-
1463
- // Add an item and verify length updates
1464
- numbers.add(4)
1465
- expect(numbers.length).toBe(4)
1466
- })
1467
-
1468
- test('object-like stores have Symbol.isConcatSpreadable false and no length property', () => {
1469
- const user = createStore({ name: 'John', age: 25 })
1470
-
1471
- // Should not be concat spreadable
1472
- expect(user[Symbol.isConcatSpreadable]).toBe(false)
1473
-
1474
- // Should not have length property
1475
- // @ts-expect-error deliberately accessing non-existent length property
1476
- expect(user.length).toBeUndefined()
1477
- expect('length' in user).toBe(false)
1478
- })
1479
-
1480
- test('array-like stores iterate over signals only', () => {
1481
- const numbers = createStore([10, 20, 30])
1482
- const signals = [...numbers]
1483
-
1484
- // Should yield signals, not [key, signal] pairs
1485
- expect(signals).toHaveLength(3)
1486
- expect(signals[0].get()).toBe(10)
1487
- expect(signals[1].get()).toBe(20)
1488
- expect(signals[2].get()).toBe(30)
1489
-
1490
- // Verify they are signal objects
1491
- signals.forEach(signal => {
1492
- expect(typeof signal.get).toBe('function')
1493
- })
1494
- })
1495
-
1496
- test('object-like stores iterate over [key, signal] pairs', () => {
1497
- const user = createStore({ name: 'Alice', age: 30 })
1498
- const entries = [...user]
1499
-
1500
- // Should yield [key, signal] pairs
1501
- expect(entries).toHaveLength(2)
1502
-
1503
- // Find the name entry
1504
- const nameEntry = entries.find(([key]) => key === 'name')
1505
- expect(nameEntry).toBeDefined()
1506
- expect(nameEntry?.[0]).toBe('name')
1507
- expect(nameEntry?.[1].get()).toBe('Alice')
1508
-
1509
- // Find the age entry
1510
- const ageEntry = entries.find(([key]) => key === 'age')
1511
- expect(ageEntry).toBeDefined()
1512
- expect(ageEntry?.[0]).toBe('age')
1513
- expect(ageEntry?.[1].get()).toBe(30)
1514
- })
1515
-
1516
- test('array-like stores support single-parameter add() method', () => {
1517
- const fruits = createStore(['apple', 'banana'])
1518
-
1519
- // Should add to end without specifying key
1520
- fruits.add('cherry')
1521
-
1522
- const result = fruits.get()
1523
- expect(result).toEqual(['apple', 'banana', 'cherry'])
1524
- expect(fruits.length).toBe(3)
1525
- })
1526
-
1527
- test('object-like stores require key parameter for add() method', () => {
1528
- const config = createStore<{
1529
- debug: boolean
1530
- timeout?: number
1531
- }>({
1532
- debug: true,
1533
- })
1534
-
1535
- // Should require both key and value
1536
- config.add('timeout', 5000)
1537
-
1538
- expect(config.get()).toEqual({ debug: true, timeout: 5000 })
1539
- })
1540
-
1541
- test('concat works correctly with array-like stores', () => {
1542
- const numbers = createStore([2, 3])
1543
- const prefix = [createState(1)]
1544
- const suffix = [createState(4), createState(5)]
1545
-
1546
- // Should spread signals when concat-ed
1547
- const combined = prefix.concat(
1548
- numbers as unknown as ConcatArray<State<number>>,
1549
- suffix,
1550
- )
1551
-
1552
- expect(combined).toHaveLength(5)
1553
- expect(combined[0].get()).toBe(1)
1554
- expect(combined[1].get()).toBe(2) // from store
1555
- expect(combined[2].get()).toBe(3) // from store
1556
- expect(combined[3].get()).toBe(4)
1557
- expect(combined[4].get()).toBe(5)
1558
- })
1559
-
1560
- test('spread operator works correctly with array-like stores', () => {
1561
- const numbers = createStore([10, 20])
1562
-
1563
- // Should spread signals
1564
- const spread = [createState(5), ...numbers, createState(30)]
1565
-
1566
- expect(spread).toHaveLength(4)
1567
- expect(spread[0].get()).toBe(5)
1568
- expect(spread[1].get()).toBe(10) // from store
1569
- expect(spread[2].get()).toBe(20) // from store
1570
- expect(spread[3].get()).toBe(30)
1571
- })
1572
-
1573
- test('array-like stores maintain numeric key ordering', () => {
1574
- const items = createStore(['first', 'second', 'third'])
1575
-
1576
- // Get the keys
1577
- const keys = Object.keys(items)
1578
- expect(keys).toEqual(['0', '1', '2', 'length'])
1579
-
1580
- // Iteration should be in order
1581
- const signals = [...items]
1582
- expect(signals[0].get()).toBe('first')
1583
- expect(signals[1].get()).toBe('second')
1584
- expect(signals[2].get()).toBe('third')
1585
- })
1586
-
1587
- test('polymorphic behavior is determined at creation time', () => {
1588
- // Created as array - stays array-like
1589
- const arrayStore = createStore([1, 2])
1590
- expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1591
- expect(arrayStore.length).toBe(2)
1592
-
1593
- // Created as object - stays object-like
1594
- const objectStore = createStore<{
1595
- a: number
1596
- b: number
1597
- c?: number
1598
- }>({
1599
- a: 1,
1600
- b: 2,
1601
- })
1602
- expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
1603
- // @ts-expect-error deliberate access to non-existent length property
1604
- expect(objectStore.length).toBeUndefined()
1605
-
1606
- // Even after modifications, behavior doesn't change
1607
- arrayStore.add(3)
1608
- expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1609
-
1610
- objectStore.add('c', 3)
1611
- expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
1612
- })
1613
-
1614
- test('runtime type detection using typeof length', () => {
1615
- const arrayStore = createStore([1, 2, 3])
1616
- const objectStore = createStore({ x: 1, y: 2 })
1617
-
1618
- // Can distinguish at runtime
1619
- expect(typeof arrayStore.length === 'number').toBe(true)
1620
- // @ts-expect-error deliberately accessing non-existent length property
1621
- expect(typeof objectStore.length === 'number').toBe(false)
1622
- })
1623
-
1624
- test('empty stores behave correctly', () => {
1625
- const emptyArray = createStore([])
1626
- const emptyObject = createStore({})
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)
1627
1126
 
1628
- // Empty array store
1629
- expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1630
- expect(emptyArray.length).toBe(0)
1631
- expect([...emptyArray]).toEqual([])
1632
-
1633
- // Empty object store
1634
- expect(emptyObject[Symbol.isConcatSpreadable]).toBe(false)
1635
- // @ts-expect-error deliberately accessing non-existent length property
1636
- expect(emptyObject.length).toBeUndefined()
1637
- expect([...emptyObject]).toEqual([])
1638
- })
1639
- })
1640
-
1641
- test('debug length property issue', () => {
1642
- const numbers = createStore([1, 2, 3])
1643
-
1644
- // Test length in computed context
1645
- const lengthComputed = createComputed(() => numbers.length)
1646
- numbers.add(4)
1647
-
1648
- // Test if length property is actually reactive
1649
- expect(numbers.length).toBe(4)
1650
- 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')
1651
1130
  })
1652
1131
  })
1653
1132
 
1654
- describe('sort() method', () => {
1655
- test('sorts array-like store with numeric compareFn', () => {
1656
- const numbers = createStore([3, 1, 4, 1, 5])
1657
-
1658
- // Capture old signal references
1659
- const oldSignals = [
1660
- numbers[0],
1661
- numbers[1],
1662
- numbers[2],
1663
- numbers[3],
1664
- numbers[4],
1665
- ]
1666
-
1667
- numbers.sort((a, b) => a - b)
1668
-
1669
- // Check sorted order
1670
- expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1671
-
1672
- // Verify signal references are preserved (moved, not recreated)
1673
- expect(numbers[0]).toBe(oldSignals[1]) // first 1 was at index 1
1674
- expect(numbers[1]).toBe(oldSignals[3]) // second 1 was at index 3
1675
- expect(numbers[2]).toBe(oldSignals[0]) // 3 was at index 0
1676
- expect(numbers[3]).toBe(oldSignals[2]) // 4 was at index 2
1677
- expect(numbers[4]).toBe(oldSignals[4]) // 5 was at index 4
1678
- })
1679
-
1680
- test('sorts array-like store with string compareFn', () => {
1681
- const names = createStore(['Charlie', 'Alice', 'Bob'])
1682
-
1683
- names.sort((a, b) => a.localeCompare(b))
1684
-
1685
- 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)
1686
1140
  })
1687
1141
 
1688
- test('sorts record-like store by value', () => {
1689
- const users = createStore({
1690
- user1: { name: 'Charlie', age: 25 },
1691
- user2: { name: 'Alice', age: 30 },
1692
- 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
+ },
1693
1148
  })
1694
-
1695
- // Capture old signal references
1696
- const oldSignals = {
1697
- user1: users.user1,
1698
- user2: users.user2,
1699
- user3: users.user3,
1700
- }
1701
-
1702
- // Sort by age
1703
- users.sort((a, b) => a.age - b.age)
1704
-
1705
- // Check order via iteration
1706
- const keys = Array.from(users, ([key]) => key)
1707
- expect(keys).toEqual(['user3', 'user1', 'user2'])
1708
-
1709
- // Verify signal references are preserved
1710
- expect(users.user1).toBe(oldSignals.user1)
1711
- expect(users.user2).toBe(oldSignals.user2)
1712
- 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)
1713
1152
  })
1714
1153
 
1715
- test('emits a sort notification with new order', () => {
1716
- const numbers = createStore([30, 10, 20])
1717
- let sortNotification: string[] | 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
+ ])
1718
1159
 
1719
- numbers.on('sort', change => {
1720
- sortNotification = change
1721
- })
1160
+ // Object array elements should be Store<T>
1161
+ expect(isStore(users[0])).toBe(true)
1162
+ expect(isStore(users[1])).toBe(true)
1722
1163
 
1723
- 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)
1724
1169
 
1725
- expect(sortNotification).not.toBeNull()
1726
- // Keys in new sorted order: [10, 20, 30] came from indices [1, 2, 0]
1727
- // biome-ignore lint/style/noNonNullAssertion: test
1728
- expect(sortNotification!).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)
1729
1175
  })
1176
+ })
1730
1177
 
1731
- test('sort is reactive - watchers are notified', () => {
1732
- const numbers = createStore([3, 1, 2])
1733
- let effectCount = 0
1734
- let lastValue: number[] = []
1178
+ describe('advanced array behaviors', () => {
1179
+ test('array compaction with remove operations', () => {
1180
+ const numbers = createStore([10, 20, 30, 40, 50])
1735
1181
 
1736
- createEffect(() => {
1737
- lastValue = numbers.get()
1738
- 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)
1739
1186
  })
1740
1187
 
1741
- // Initial effect run
1742
- expect(effectCount).toBe(1)
1743
- expect(lastValue).toEqual([3, 1, 2])
1188
+ expect(sumWithGet.get()).toBe(150) // 10+20+30+40+50
1744
1189
 
1745
- numbers.sort((a, b) => a - b)
1746
-
1747
- // Effect should run again after sort
1748
- expect(effectCount).toBe(2)
1749
- expect(lastValue).toEqual([1, 2, 3])
1750
- })
1751
-
1752
- test('nested signals remain reactive after sorting', () => {
1753
- const items = createStore([
1754
- { name: 'Charlie', score: 85 },
1755
- { name: 'Alice', score: 95 },
1756
- { name: 'Bob', score: 75 },
1757
- ])
1758
-
1759
- // Sort by score
1760
- items.sort((a, b) => b.score - a.score) // descending
1761
-
1762
- // Verify order
1763
- expect(items.get().map(item => item.name)).toEqual([
1764
- 'Alice',
1765
- 'Charlie',
1766
- 'Bob',
1767
- ])
1768
-
1769
- // Modify a nested property
1770
- items[1].score.set(100) // Charlie's score
1771
-
1772
- // Verify the change is reflected
1773
- expect(items.get()[1].score).toBe(100)
1774
- expect(items[1].name.get()).toBe('Charlie')
1775
- })
1776
-
1777
- test('sort with complex nested structures', () => {
1778
- const posts = createStore([
1779
- {
1780
- id: 'post1',
1781
- title: 'Hello World',
1782
- meta: { views: 100, likes: 5 },
1783
- },
1784
- {
1785
- id: 'post2',
1786
- title: 'Getting Started',
1787
- meta: { views: 50, likes: 10 },
1788
- },
1789
- {
1790
- id: 'post3',
1791
- title: 'Advanced Topics',
1792
- meta: { views: 200, likes: 3 },
1793
- },
1794
- ])
1795
-
1796
- // Sort by likes (ascending)
1797
- posts.sort((a, b) => a.meta.likes - b.meta.likes)
1798
-
1799
- const sortedTitles = posts.get().map(post => post.title)
1800
- expect(sortedTitles).toEqual([
1801
- 'Advanced Topics',
1802
- 'Hello World',
1803
- 'Getting Started',
1804
- ])
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
1805
1195
 
1806
- // Verify nested reactivity still works
1807
- posts[0].meta.likes.set(15)
1808
- expect(posts.get()[0].meta.likes).toBe(15)
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
1809
1201
  })
1810
1202
 
1811
- test('sort preserves array length and size', () => {
1812
- const arr = createStore([5, 2, 8, 1])
1813
-
1814
- expect(arr.length).toBe(4)
1815
- expect(arr.size.get()).toBe(4)
1816
-
1817
- arr.sort((a, b) => a - b)
1818
-
1819
- expect(arr.length).toBe(4)
1820
- expect(arr.size.get()).toBe(4)
1821
- expect(arr.get()).toEqual([1, 2, 5, 8])
1822
- })
1203
+ test('sparse array replacement works correctly', () => {
1204
+ const numbers = createStore([10, 20, 30])
1823
1205
 
1824
- test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
1825
- const items = createStore(['banana', 'cherry', 'apple', '10', '2'])
1206
+ // Remove middle element to create sparse structure internally
1207
+ numbers.remove(1) // Remove 20, now [10, 30] with internal keys ["0", "2"]
1826
1208
 
1827
- items.sort()
1209
+ expect(numbers.get()).toEqual([10, 30])
1210
+ expect(numbers.length).toBe(2)
1828
1211
 
1829
- // Default sorting converts to strings and compares in UTF-16 order
1830
- expect(items.get()).toEqual(
1831
- ['banana', 'cherry', 'apple', '10', '2'].sort(),
1832
- )
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)
1833
1218
  })
1219
+ })
1834
1220
 
1835
- test('default sort handles numbers as strings like Array.prototype.sort()', () => {
1836
- const numbers = createStore([80, 9, 100])
1837
-
1838
- numbers.sort()
1839
-
1840
- // Numbers are converted to strings: "100", "80", "9"
1841
- // In UTF-16 order: "100" < "80" < "9"
1842
- expect(numbers.get()).toEqual([80, 9, 100].sort())
1843
- })
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)
1844
1227
 
1845
- test('default sort handles mixed values with proper string conversion', () => {
1846
- const mixed = createStore(['b', 0, 'a', '', 'c'])
1228
+ // Even after modifications, stays array-like
1229
+ arrayStore.add(3)
1230
+ expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
1231
+ expect(arrayStore.length).toBe(3)
1847
1232
 
1848
- mixed.sort()
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)
1849
1241
 
1850
- // String conversion: '' < '0' < 'a' < 'b' < 'c'
1851
- expect(mixed.get()).toEqual(['', 0, 'a', 'b', 'c'])
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)
1852
1246
  })
1853
1247
 
1854
- test('multiple sorts work correctly', () => {
1855
- const numbers = createStore([3, 1, 4, 1, 5])
1248
+ test('empty stores maintain their type characteristics', () => {
1249
+ const emptyArray = createStore<string[]>([])
1250
+ const emptyRecord = createStore<{ key?: string }>({})
1856
1251
 
1857
- // Sort ascending
1858
- numbers.sort((a, b) => a - b)
1859
- expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1252
+ // Empty array behaves like array
1253
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1254
+ expect(emptyArray.length).toBe(0)
1860
1255
 
1861
- // Sort descending
1862
- numbers.sort((a, b) => b - a)
1863
- expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
1864
- })
1256
+ // Empty record behaves like record
1257
+ expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1258
+ expect(emptyRecord.length).toBe(0)
1865
1259
 
1866
- test('sort notification contains correct movement mapping for records', () => {
1867
- const users = createStore({
1868
- alice: { age: 30 },
1869
- bob: { age: 20 },
1870
- charlie: { age: 25 },
1871
- })
1260
+ // After adding items, they maintain their characteristics
1261
+ emptyArray.add('first')
1262
+ emptyRecord.add('key', 'value')
1872
1263
 
1873
- let sortNotification: string[] | null = null
1874
- users.on('sort', change => {
1875
- sortNotification = change
1876
- })
1877
-
1878
- // Sort by age
1879
- users.sort((a, b) => b.age - a.age)
1880
-
1881
- expect(sortNotification).not.toBeNull()
1882
- // biome-ignore lint/style/noNonNullAssertion: test
1883
- expect(sortNotification!).toEqual(['alice', 'charlie', 'bob'])
1264
+ expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
1265
+ expect(emptyRecord[Symbol.isConcatSpreadable]).toBe(false)
1884
1266
  })
1885
1267
  })
1886
1268
 
1887
1269
  describe('cross-component communication pattern', () => {
1888
1270
  test('event bus with UNSET initialization - type-safe pattern', () => {
1889
- // Component B (the owner) declares the shape of events it will emit
1890
1271
  type EventBusSchema = {
1891
1272
  userLogin: { userId: number; timestamp: number }
1892
1273
  userLogout: { userId: number }
1893
1274
  userUpdate: { userId: number; profile: { name: string } }
1894
1275
  }
1895
1276
 
1896
- // Initialize the event bus with proper typing
1897
1277
  const eventBus = createStore<EventBusSchema>({
1898
1278
  userLogin: UNSET,
1899
1279
  userLogout: UNSET,
1900
1280
  userUpdate: UNSET,
1901
1281
  })
1902
1282
 
1903
- // Simple type-safe on functions
1904
1283
  const on = (
1905
1284
  event: keyof EventBusSchema,
1906
1285
  callback: (data: EventBusSchema[keyof EventBusSchema]) => void,
@@ -1910,30 +1289,26 @@ describe('store', () => {
1910
1289
  if (data !== UNSET) callback(data)
1911
1290
  })
1912
1291
 
1913
- // Test the pattern with properly typed variables
1914
1292
  let receivedLogin: unknown = null
1915
1293
  let receivedLogout: unknown = null
1916
1294
  let receivedUpdate: unknown = null
1917
1295
 
1918
- // Component A listens for events
1919
1296
  on('userLogin', data => {
1920
1297
  receivedLogin = data
1921
1298
  })
1922
-
1923
1299
  on('userLogout', data => {
1924
1300
  receivedLogout = data
1925
1301
  })
1926
-
1927
1302
  on('userUpdate', data => {
1928
1303
  receivedUpdate = data
1929
1304
  })
1930
1305
 
1931
- // Initially nothing should be received (all UNSET)
1306
+ // Initially nothing received
1932
1307
  expect(receivedLogin).toBe(null)
1933
1308
  expect(receivedLogout).toBe(null)
1934
1309
  expect(receivedUpdate).toBe(null)
1935
1310
 
1936
- // Component B emits user events with full type safety
1311
+ // Emit events
1937
1312
  const loginData: EventBusSchema['userLogin'] = {
1938
1313
  userId: 123,
1939
1314
  timestamp: Date.now(),
@@ -1941,17 +1316,15 @@ describe('store', () => {
1941
1316
  eventBus.userLogin.set(loginData)
1942
1317
 
1943
1318
  expect(receivedLogin).toEqual(loginData)
1944
- expect(receivedLogout).toBe(null) // Should not have triggered
1945
- expect(receivedUpdate).toBe(null) // Should not have triggered
1319
+ expect(receivedLogout).toBe(null)
1320
+ expect(receivedUpdate).toBe(null)
1946
1321
 
1947
- // Test second event
1948
1322
  const logoutData: EventBusSchema['userLogout'] = { userId: 123 }
1949
1323
  eventBus.userLogout.set(logoutData)
1950
1324
 
1951
1325
  expect(receivedLogout).toEqual(logoutData)
1952
- expect(receivedLogin).toEqual(loginData) // Should remain unchanged
1326
+ expect(receivedLogin).toEqual(loginData) // unchanged
1953
1327
 
1954
- // Test third event
1955
1328
  const updateData: EventBusSchema['userUpdate'] = {
1956
1329
  userId: 456,
1957
1330
  profile: { name: 'Alice' },
@@ -1959,24 +1332,8 @@ describe('store', () => {
1959
1332
  eventBus.userUpdate.set(updateData)
1960
1333
 
1961
1334
  expect(receivedUpdate).toEqual(updateData)
1962
- expect(receivedLogin).toEqual(loginData) // Should remain unchanged
1963
- expect(receivedLogout).toEqual(logoutData) // Should remain unchanged
1964
-
1965
- // Test updating existing event
1966
- const newLoginData: EventBusSchema['userLogin'] = {
1967
- userId: 789,
1968
- timestamp: Date.now(),
1969
- }
1970
- eventBus.userLogin.set(newLoginData)
1971
-
1972
- expect(receivedLogin).toEqual(newLoginData) // Should update
1973
- expect(receivedLogout).toEqual(logoutData) // Should remain unchanged
1974
- expect(receivedUpdate).toEqual(updateData) // Should remain unchanged
1975
-
1976
- // Compile-time type checking prevents errors:
1977
- // emitUserLogin({ userId: 'invalid' }) // ❌ TypeScript error
1978
- // emitUserLogin({ userId: 123, extraProp: 'invalid' }) // ❌ TypeScript error
1979
- // emitUserLogout({ userId: 123, extraProp: 'invalid' }) // ❌ TypeScript error
1335
+ expect(receivedLogin).toEqual(loginData) // unchanged
1336
+ expect(receivedLogout).toEqual(logoutData) // unchanged
1980
1337
  })
1981
1338
  })
1982
1339
  })