@zeix/cause-effect 0.15.1 → 0.16.0

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.
Files changed (48) hide show
  1. package/.ai-context.md +254 -0
  2. package/.cursorrules +54 -0
  3. package/.github/copilot-instructions.md +132 -0
  4. package/CLAUDE.md +319 -0
  5. package/README.md +167 -159
  6. package/eslint.config.js +1 -1
  7. package/index.dev.js +528 -407
  8. package/index.js +1 -1
  9. package/index.ts +36 -25
  10. package/package.json +1 -1
  11. package/src/computed.ts +41 -30
  12. package/src/diff.ts +57 -44
  13. package/src/effect.ts +15 -16
  14. package/src/errors.ts +64 -0
  15. package/src/match.ts +2 -2
  16. package/src/resolve.ts +2 -2
  17. package/src/signal.ts +27 -49
  18. package/src/state.ts +27 -19
  19. package/src/store.ts +410 -209
  20. package/src/system.ts +122 -0
  21. package/src/util.ts +45 -6
  22. package/test/batch.test.ts +18 -11
  23. package/test/benchmark.test.ts +4 -4
  24. package/test/computed.test.ts +508 -72
  25. package/test/diff.test.ts +321 -4
  26. package/test/effect.test.ts +61 -61
  27. package/test/match.test.ts +38 -28
  28. package/test/resolve.test.ts +16 -16
  29. package/test/signal.test.ts +19 -147
  30. package/test/state.test.ts +212 -25
  31. package/test/store.test.ts +1370 -134
  32. package/test/util/dependency-graph.ts +1 -1
  33. package/types/index.d.ts +10 -9
  34. package/types/src/collection.d.ts +26 -0
  35. package/types/src/computed.d.ts +9 -9
  36. package/types/src/diff.d.ts +5 -3
  37. package/types/src/effect.d.ts +3 -3
  38. package/types/src/errors.d.ts +22 -0
  39. package/types/src/match.d.ts +1 -1
  40. package/types/src/resolve.d.ts +1 -1
  41. package/types/src/signal.d.ts +12 -19
  42. package/types/src/state.d.ts +5 -5
  43. package/types/src/store.d.ts +40 -36
  44. package/types/src/system.d.ts +44 -0
  45. package/types/src/util.d.ts +7 -5
  46. package/index.d.ts +0 -36
  47. package/src/scheduler.ts +0 -172
  48. package/types/test-new-effect.d.ts +0 -1
@@ -1,35 +1,35 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- computed,
4
- effect,
3
+ createComputed,
4
+ createEffect,
5
+ createState,
6
+ createStore,
5
7
  isStore,
6
- type StoreAddEvent,
7
- type StoreChangeEvent,
8
- type StoreRemoveEvent,
9
- state,
10
- store,
11
- toSignal,
8
+ type State,
12
9
  UNSET,
13
10
  } from '..'
14
11
 
15
12
  describe('store', () => {
16
13
  describe('creation and basic operations', () => {
17
14
  test('creates a store with initial values', () => {
18
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
15
+ const user = createStore({
16
+ name: 'Hannah',
17
+ 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
22
  })
23
23
 
24
24
  test('has Symbol.toStringTag of Store', () => {
25
- const s = store({ a: 1 })
25
+ const s = createStore({ a: 1 })
26
26
  expect(s[Symbol.toStringTag]).toBe('Store')
27
27
  })
28
28
 
29
29
  test('isStore identifies store instances correctly', () => {
30
- const s = store({ a: 1 })
31
- const st = state(1)
32
- const c = computed(() => 1)
30
+ const s = createStore({ a: 1 })
31
+ const st = createState(1)
32
+ const c = createComputed(() => 1)
33
33
 
34
34
  expect(isStore(s)).toBe(true)
35
35
  expect(isStore(st)).toBe(false)
@@ -39,19 +39,19 @@ describe('store', () => {
39
39
  })
40
40
 
41
41
  test('get() returns the complete store value', () => {
42
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
42
+ const user = createStore({
43
+ name: 'Hannah',
44
+ email: 'hannah@example.com',
45
+ })
43
46
 
44
47
  expect(user.get()).toEqual({
45
48
  name: 'Hannah',
46
49
  email: 'hannah@example.com',
47
50
  })
48
51
 
49
- /**
50
- * store() only accepts object map types for arrays
51
- */
52
- const participants = store<{
53
- [x: number]: { name: string; tags: string[] }
54
- }>([
52
+ const participants = createStore<
53
+ { name: string; tags: string[] }[]
54
+ >([
55
55
  { name: 'Alice', tags: ['friends', 'mates'] },
56
56
  { name: 'Bob', tags: ['friends'] },
57
57
  ])
@@ -59,24 +59,12 @@ describe('store', () => {
59
59
  { name: 'Alice', tags: ['friends', 'mates'] },
60
60
  { name: 'Bob', tags: ['friends'] },
61
61
  ])
62
-
63
- /**
64
- * toSignal() converts arrays to object map types when creating stores
65
- */
66
- const participants2 = toSignal<{ name: string; tags: string[] }[]>([
67
- { name: 'Alice', tags: ['friends', 'mates'] },
68
- { name: 'Bob', tags: ['friends'] },
69
- ])
70
- expect(participants2.get()).toEqual([
71
- { name: 'Alice', tags: ['friends', 'mates'] },
72
- { name: 'Bob', tags: ['friends'] },
73
- ])
74
62
  })
75
63
  })
76
64
 
77
65
  describe('proxy data access and modification', () => {
78
66
  test('properties can be accessed and modified via signals', () => {
79
- const user = store({ name: 'Hannah', age: 25 })
67
+ const user = createStore({ name: 'Hannah', age: 25 })
80
68
 
81
69
  // Get signals from store proxy
82
70
  expect(user.name.get()).toBe('Hannah')
@@ -91,14 +79,14 @@ describe('store', () => {
91
79
  })
92
80
 
93
81
  test('returns undefined for non-existent properties', () => {
94
- const user = store({ name: 'Hannah' })
82
+ const user = createStore({ name: 'Hannah' })
95
83
 
96
84
  // @ts-expect-error accessing non-existent property
97
85
  expect(user.nonExistent).toBeUndefined()
98
86
  })
99
87
 
100
88
  test('supports numeric key access', () => {
101
- const items = store({ '0': 'first', '1': 'second' })
89
+ const items = createStore({ '0': 'first', '1': 'second' })
102
90
 
103
91
  expect(items[0].get()).toBe('first')
104
92
  expect(items['0'].get()).toBe('first')
@@ -107,7 +95,7 @@ describe('store', () => {
107
95
  })
108
96
 
109
97
  test('can add new properties via add method', () => {
110
- const user = store<{ name: string; email?: string }>({
98
+ const user = createStore<{ name: string; email?: string }>({
111
99
  name: 'Hannah',
112
100
  })
113
101
 
@@ -121,7 +109,7 @@ describe('store', () => {
121
109
  })
122
110
 
123
111
  test('can remove existing properties via remove method', () => {
124
- const user = store<{ name: string; email?: string }>({
112
+ const user = createStore<{ name: string; email?: string }>({
125
113
  name: 'Hannah',
126
114
  email: 'hannah@example.com',
127
115
  })
@@ -135,11 +123,24 @@ describe('store', () => {
135
123
  name: 'Hannah',
136
124
  })
137
125
  })
126
+
127
+ test('add method prevents null values', () => {
128
+ const user = createStore<{ name: string; tags?: string[] }>({
129
+ name: 'Alice',
130
+ })
131
+
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
+ )
138
+ })
138
139
  })
139
140
 
140
141
  describe('nested stores', () => {
141
142
  test('creates nested stores for object properties', () => {
142
- const user = store({
143
+ const user = createStore({
143
144
  name: 'Hannah',
144
145
  preferences: {
145
146
  theme: 'dark',
@@ -153,7 +154,7 @@ describe('store', () => {
153
154
  })
154
155
 
155
156
  test('nested properties are reactive', () => {
156
- const user = store({
157
+ const user = createStore({
157
158
  preferences: {
158
159
  theme: 'dark',
159
160
  },
@@ -165,7 +166,7 @@ describe('store', () => {
165
166
  })
166
167
 
167
168
  test('deeply nested stores work correctly', () => {
168
- const config = store({
169
+ const config = createStore({
169
170
  ui: {
170
171
  theme: {
171
172
  colors: {
@@ -183,7 +184,10 @@ describe('store', () => {
183
184
 
184
185
  describe('set() and update() methods', () => {
185
186
  test('set() replaces entire store value', () => {
186
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
187
+ const user = createStore({
188
+ name: 'Hannah',
189
+ email: 'hannah@example.com',
190
+ })
187
191
 
188
192
  user.set({ name: 'Alice', email: 'alice@example.com' })
189
193
 
@@ -194,7 +198,7 @@ describe('store', () => {
194
198
  })
195
199
 
196
200
  test('update() modifies store using function', () => {
197
- const user = store({ name: 'Hannah', age: 25 })
201
+ const user = createStore({ name: 'Hannah', age: 25 })
198
202
 
199
203
  user.update(prev => ({ ...prev, age: prev.age + 1 }))
200
204
 
@@ -207,7 +211,7 @@ describe('store', () => {
207
211
 
208
212
  describe('iterator protocol', () => {
209
213
  test('supports for...of iteration', () => {
210
- const user = store({ name: 'Hannah', age: 25 })
214
+ const user = createStore({ name: 'Hannah', age: 25 })
211
215
  const entries: Array<[string, unknown & {}]> = []
212
216
 
213
217
  for (const [key, signal] of user) {
@@ -221,7 +225,7 @@ describe('store', () => {
221
225
 
222
226
  describe('change tracking', () => {
223
227
  test('tracks size changes', () => {
224
- const user = store<{ name: string; email?: string }>({
228
+ const user = createStore<{ name: string; email?: string }>({
225
229
  name: 'Hannah',
226
230
  })
227
231
 
@@ -234,149 +238,324 @@ describe('store', () => {
234
238
  expect(user.size.get()).toBe(1)
235
239
  })
236
240
 
237
- test('dispatches store-add event on initial creation', async () => {
238
- let addEvent: StoreAddEvent<{ name: string }> | null = null
239
- const user = store({ name: 'Hannah' })
241
+ test('emits an add notification on initial creation', async () => {
242
+ let addNotification: Record<string, string> | null = null
243
+ const user = createStore({ name: 'Hannah' })
240
244
 
241
- user.addEventListener('store-add', event => {
242
- addEvent = event
245
+ user.on('add', change => {
246
+ addNotification = change
243
247
  })
244
248
 
245
249
  // Wait for the async initial event
246
250
  await new Promise(resolve => setTimeout(resolve, 10))
247
251
 
248
- expect(addEvent).toBeTruthy()
252
+ expect(addNotification).toBeTruthy()
249
253
  // biome-ignore lint/style/noNonNullAssertion: test
250
- expect(addEvent!.detail).toEqual({ name: 'Hannah' })
254
+ expect(addNotification!).toEqual({ name: 'Hannah' })
251
255
  })
252
256
 
253
- test('dispatches store-add event for new properties', () => {
254
- const user = store<{ name: string; email?: string }>({
257
+ test('emits an add notification for new properties', () => {
258
+ const user = createStore<{ name: string; email?: string }>({
255
259
  name: 'Hannah',
256
260
  })
257
261
 
258
- let addEvent: StoreAddEvent<{
259
- name: string
260
- email?: string
261
- }> | null = null
262
- user.addEventListener('store-add', event => {
263
- addEvent = event
262
+ let addNotification: Record<string, string> | null = null
263
+ user.on('add', change => {
264
+ addNotification = change
264
265
  })
265
266
 
266
267
  user.update(v => ({ ...v, email: 'hannah@example.com' }))
267
268
 
268
- expect(addEvent).toBeTruthy()
269
+ expect(addNotification).toBeTruthy()
269
270
  // biome-ignore lint/style/noNonNullAssertion: test
270
- expect(addEvent!.detail).toEqual({
271
+ expect(addNotification!).toEqual({
271
272
  email: 'hannah@example.com',
272
273
  })
273
274
  })
274
275
 
275
- test('dispatches store-change event for property changes', () => {
276
- const user = store({ name: 'Hannah' })
276
+ test('emits a change notification for property changes', () => {
277
+ const user = createStore({ name: 'Hannah' })
277
278
 
278
- let changeEvent: StoreChangeEvent<{ name: string }> | null = null
279
- user.addEventListener('store-change', event => {
280
- changeEvent = event
279
+ let changeNotification: Record<string, string> | null = null
280
+ user.on('change', change => {
281
+ changeNotification = change
281
282
  })
282
283
 
283
284
  user.set({ name: 'Alice' })
284
285
 
285
- expect(changeEvent).toBeTruthy()
286
+ expect(changeNotification).toBeTruthy()
286
287
  // biome-ignore lint/style/noNonNullAssertion: test
287
- expect(changeEvent!.detail).toEqual({
288
+ expect(changeNotification!).toEqual({
288
289
  name: 'Alice',
289
290
  })
290
291
  })
291
292
 
292
- test('dispatches store-change event for signal changes', () => {
293
- const user = store({ name: 'Hannah' })
293
+ test('emits a change notification for signal changes', () => {
294
+ const user = createStore({ name: 'Hannah' })
294
295
 
295
- let changeEvent: StoreChangeEvent<{ name: string }> | null = null
296
- user.addEventListener('store-change', event => {
297
- changeEvent = event
296
+ let changeNotification: Record<string, string> | null = null
297
+ user.on('change', change => {
298
+ changeNotification = change
298
299
  })
299
300
 
300
301
  user.name.set('Bob')
301
302
 
302
- expect(changeEvent).toBeTruthy()
303
+ expect(changeNotification).toBeTruthy()
303
304
  // biome-ignore lint/style/noNonNullAssertion: test
304
- expect(changeEvent!.detail).toEqual({
305
+ expect(changeNotification!).toEqual({
305
306
  name: 'Bob',
306
307
  })
307
308
  })
308
309
 
309
- test('dispatches store-remove event for removed properties', () => {
310
- const user = store<{ name: string; email?: string }>({
310
+ test('emits a change notification when nested properties change', () => {
311
+ const user = createStore({
312
+ name: 'Hannah',
313
+ preferences: {
314
+ theme: 'dark',
315
+ notifications: true,
316
+ },
317
+ })
318
+
319
+ let changeNotification: Record<string, unknown> = {}
320
+ user.on('change', change => {
321
+ changeNotification = change
322
+ })
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
+ },
348
+ })
349
+
350
+ let changeNotification: Record<string, unknown> = {}
351
+ config.on('change', change => {
352
+ changeNotification = change
353
+ })
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
+ },
370
+ })
371
+ })
372
+
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
+ },
386
+ })
387
+
388
+ let removeNotification: Record<string, unknown> = {}
389
+ user.on('remove', remove => {
390
+ removeNotification = remove
391
+ })
392
+
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
400
+ })
401
+ })
402
+
403
+ test('set() correctly handles mixed changes, additions, and removals', () => {
404
+ const user = createStore<{
405
+ name: string
406
+ email?: string
407
+ preferences?: {
408
+ theme: string
409
+ notifications?: boolean
410
+ }
411
+ age?: number
412
+ }>({
311
413
  name: 'Hannah',
312
414
  email: 'hannah@example.com',
415
+ preferences: {
416
+ theme: 'dark',
417
+ },
418
+ })
419
+
420
+ let changeNotification: Record<string, unknown> = {}
421
+ let addNotification: Record<string, unknown> = {}
422
+ let removeNotification: Record<string, unknown> = {}
423
+
424
+ user.on('change', change => {
425
+ changeNotification = change
426
+ })
427
+ user.on('add', add => {
428
+ addNotification = add
429
+ })
430
+ user.on('remove', remove => {
431
+ removeNotification = remove
432
+ })
433
+
434
+ // Perform a set() that changes name, removes email, adds age, and keeps preferences
435
+ user.set({
436
+ name: 'Alice', // changed
437
+ preferences: {
438
+ theme: 'dark', // unchanged nested
439
+ },
440
+ age: 30, // added
441
+ // email removed
313
442
  })
314
443
 
315
- let removeEvent: StoreRemoveEvent<{
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<{
316
462
  name: string
317
463
  email?: string
318
- }> | null = null
319
- user.addEventListener('store-remove', event => {
320
- removeEvent = event
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
321
503
  })
322
504
 
323
505
  user.remove('email')
324
506
 
325
- expect(removeEvent).toBeTruthy()
507
+ expect(removeNotification).toBeTruthy()
326
508
  // biome-ignore lint/style/noNonNullAssertion: test
327
- expect(removeEvent!.detail.email).toBe(UNSET)
509
+ expect(removeNotification!.email).toBe(UNSET)
328
510
  })
329
511
 
330
- test('dispatches store-add event when using add method', () => {
331
- const user = store<{ name: string; email?: string }>({
512
+ test('emits an add notification when using add method', () => {
513
+ const user = createStore<{ name: string; email?: string }>({
332
514
  name: 'Hannah',
333
515
  })
334
516
 
335
- let addEvent: StoreAddEvent<{
336
- name: string
337
- email?: string
338
- }> | null = null
339
- user.addEventListener('store-add', event => {
340
- addEvent = event
517
+ let addNotification: Record<string, string> | null = null
518
+ user.on('add', change => {
519
+ addNotification = change
341
520
  })
342
521
 
343
522
  user.add('email', 'hannah@example.com')
344
523
 
345
- expect(addEvent).toBeTruthy()
524
+ expect(addNotification).toBeTruthy()
346
525
  // biome-ignore lint/style/noNonNullAssertion: test
347
- expect(addEvent!.detail).toEqual({
526
+ expect(addNotification!).toEqual({
348
527
  email: 'hannah@example.com',
349
528
  })
350
529
  })
351
530
 
352
- test('can remove event listeners', () => {
353
- const user = store({ name: 'Hannah' })
531
+ test('can remove notification listeners', () => {
532
+ const user = createStore({ name: 'Hannah' })
354
533
 
355
- let eventCount = 0
534
+ let notificationCount = 0
356
535
  const listener = () => {
357
- eventCount++
536
+ notificationCount++
358
537
  }
359
538
 
360
- user.addEventListener('store-change', listener)
539
+ const off = user.on('change', listener)
361
540
  user.name.set('Alice')
362
- expect(eventCount).toBe(1)
541
+ expect(notificationCount).toBe(1)
363
542
 
364
- user.removeEventListener('store-change', listener)
543
+ off()
365
544
  user.name.set('Bob')
366
- expect(eventCount).toBe(1) // Should not increment
545
+ expect(notificationCount).toBe(1) // Should not increment
367
546
  })
368
547
 
369
- test('supports multiple event listeners for the same event', () => {
370
- const user = store({ name: 'Hannah' })
548
+ test('supports multiple notification listeners for the same type', () => {
549
+ const user = createStore({ name: 'Hannah' })
371
550
 
372
551
  let listener1Called = false
373
552
  let listener2Called = false
374
553
 
375
- user.addEventListener('store-change', () => {
554
+ user.on('change', () => {
376
555
  listener1Called = true
377
556
  })
378
557
 
379
- user.addEventListener('store-change', () => {
558
+ user.on('change', () => {
380
559
  listener2Called = true
381
560
  })
382
561
 
@@ -389,10 +568,13 @@ describe('store', () => {
389
568
 
390
569
  describe('reactivity', () => {
391
570
  test('store-level get() is reactive', () => {
392
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
571
+ const user = createStore({
572
+ name: 'Hannah',
573
+ email: 'hannah@example.com',
574
+ })
393
575
  let lastValue = { name: '', email: '' }
394
576
 
395
- effect(() => {
577
+ createEffect(() => {
396
578
  lastValue = user.get()
397
579
  })
398
580
 
@@ -405,14 +587,17 @@ describe('store', () => {
405
587
  })
406
588
 
407
589
  test('individual signal reactivity works', () => {
408
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
590
+ const user = createStore({
591
+ name: 'Hannah',
592
+ email: 'hannah@example.com',
593
+ })
409
594
  let lastName = ''
410
595
  let nameEffectRuns = 0
411
596
 
412
597
  // Get signal for name property directly
413
598
  const nameSignal = user.name
414
599
 
415
- effect(() => {
600
+ createEffect(() => {
416
601
  lastName = nameSignal.get()
417
602
  nameEffectRuns++
418
603
  })
@@ -424,14 +609,14 @@ describe('store', () => {
424
609
  })
425
610
 
426
611
  test('nested store changes propagate to parent', () => {
427
- const user = store({
612
+ const user = createStore({
428
613
  preferences: {
429
614
  theme: 'dark',
430
615
  },
431
616
  })
432
617
  let effectRuns = 0
433
618
 
434
- effect(() => {
619
+ createEffect(() => {
435
620
  user.get() // Watch entire store
436
621
  effectRuns++
437
622
  })
@@ -441,13 +626,13 @@ describe('store', () => {
441
626
  })
442
627
 
443
628
  test('updates are reactive', () => {
444
- const user = store<{ name: string; email?: string }>({
629
+ const user = createStore<{ name: string; email?: string }>({
445
630
  name: 'Hannah',
446
631
  })
447
632
  let lastValue = {}
448
633
  let effectRuns = 0
449
634
 
450
- effect(() => {
635
+ createEffect(() => {
451
636
  lastValue = user.get()
452
637
  effectRuns++
453
638
  })
@@ -461,14 +646,14 @@ describe('store', () => {
461
646
  })
462
647
 
463
648
  test('remove method is reactive', () => {
464
- const user = store<{ name: string; email?: string }>({
649
+ const user = createStore<{ name: string; email?: string }>({
465
650
  name: 'Hannah',
466
651
  email: 'hannah@example.com',
467
652
  })
468
653
  let lastValue = {}
469
654
  let effectRuns = 0
470
655
 
471
- effect(() => {
656
+ createEffect(() => {
472
657
  lastValue = user.get()
473
658
  effectRuns++
474
659
  })
@@ -483,20 +668,25 @@ describe('store', () => {
483
668
  })
484
669
 
485
670
  test('add method does not overwrite existing properties', () => {
486
- const user = store<{ name: string; email?: string }>({
671
+ const user = createStore<{ name: string; email?: string }>({
487
672
  name: 'Hannah',
488
673
  email: 'original@example.com',
489
674
  })
490
675
 
491
676
  const originalSize = user.size.get()
492
- user.add('email', 'new@example.com')
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
+ )
493
683
 
494
684
  expect(user.email?.get()).toBe('original@example.com')
495
685
  expect(user.size.get()).toBe(originalSize)
496
686
  })
497
687
 
498
688
  test('remove method has no effect on non-existent properties', () => {
499
- const user = store<{ name: string; email?: string }>({
689
+ const user = createStore<{ name: string; email?: string }>({
500
690
  name: 'Hannah',
501
691
  })
502
692
 
@@ -509,9 +699,9 @@ describe('store', () => {
509
699
 
510
700
  describe('computed integration', () => {
511
701
  test('works with computed signals', () => {
512
- const user = store({ firstName: 'Hannah', lastName: 'Smith' })
702
+ const user = createStore({ firstName: 'Hannah', lastName: 'Smith' })
513
703
 
514
- const fullName = computed(() => {
704
+ const fullName = createComputed(() => {
515
705
  return `${user.firstName.get()} ${user.lastName.get()}`
516
706
  })
517
707
 
@@ -522,13 +712,13 @@ describe('store', () => {
522
712
  })
523
713
 
524
714
  test('computed reacts to nested store changes', () => {
525
- const config = store({
715
+ const config = createStore({
526
716
  ui: {
527
717
  theme: 'dark',
528
718
  },
529
719
  })
530
720
 
531
- const themeDisplay = computed(() => {
721
+ const themeDisplay = createComputed(() => {
532
722
  return `Theme: ${config.ui.theme.get()}`
533
723
  })
534
724
 
@@ -539,9 +729,192 @@ describe('store', () => {
539
729
  })
540
730
  })
541
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
+
785
+ const sum = createComputed(() => {
786
+ const array = numbers.get()
787
+ if (!Array.isArray(array)) return 0
788
+ return array.reduce((acc, num) => acc + num, 0)
789
+ })
790
+
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)
799
+
800
+ // Change the only element
801
+ numbers[0].set(100)
802
+ expect(sum.get()).toBe(100)
803
+
804
+ // Remove the only element
805
+ numbers.remove(0)
806
+ expect(sum.get()).toBe(0)
807
+ expect(numbers.size.get()).toBe(0)
808
+ })
809
+
810
+ test('computed sum using store iteration with size tracking', () => {
811
+ const numbers = createStore([10, 20, 30])
812
+
813
+ // Use iteration but also track size to ensure reactivity to additions/removals
814
+ const sum = createComputed(() => {
815
+ // Access size to subscribe to structural changes
816
+ numbers.size.get()
817
+ let total = 0
818
+ for (const signal of numbers) {
819
+ total += signal.get()
820
+ }
821
+ return total
822
+ })
823
+
824
+ expect(sum.get()).toBe(60)
825
+
826
+ // Add more numbers
827
+ numbers.add(40)
828
+ expect(sum.get()).toBe(100)
829
+
830
+ // Modify existing values
831
+ numbers[1].set(25) // Change 20 to 25
832
+ expect(sum.get()).toBe(105) // 10 + 25 + 30 + 40
833
+
834
+ // Remove a value
835
+ numbers.remove(2) // Remove 30
836
+ expect(sum.get()).toBe(75) // 10 + 25 + 40
837
+ })
838
+
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])
842
+
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
+ })
853
+
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
+ })
860
+
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
887
+ })
888
+
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)
899
+
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])
906
+
907
+ // With the fix: sparse array replacement now works correctly
908
+ const result = numbers.get()
909
+
910
+ // The fix ensures proper sparse array replacement
911
+ expect(result).toEqual([100, 200]) // This now passes with the diff fix!
912
+ })
913
+ })
914
+
542
915
  describe('arrays and edge cases', () => {
543
916
  test('handles arrays as store values', () => {
544
- const data = store({ items: [1, 2, 3] })
917
+ const data = createStore({ items: [1, 2, 3] })
545
918
 
546
919
  // Arrays become stores with string indices
547
920
  expect(isStore(data.items)).toBe(true)
@@ -551,7 +924,7 @@ describe('store', () => {
551
924
  })
552
925
 
553
926
  test('array-derived nested stores have correct type inference', () => {
554
- const todoApp = store({
927
+ const todoApp = createStore({
555
928
  todos: ['Buy milk', 'Walk the dog', 'Write code'],
556
929
  users: [
557
930
  { name: 'Alice', active: true },
@@ -610,7 +983,7 @@ describe('store', () => {
610
983
  })
611
984
 
612
985
  test('handles UNSET values', () => {
613
- const data = store({ value: UNSET as string })
986
+ const data = createStore({ value: UNSET as string })
614
987
 
615
988
  expect(data.value.get()).toBe(UNSET)
616
989
  data.value.set('some string')
@@ -618,7 +991,7 @@ describe('store', () => {
618
991
  })
619
992
 
620
993
  test('handles primitive values', () => {
621
- const data = store({
994
+ const data = createStore({
622
995
  str: 'hello',
623
996
  num: 42,
624
997
  bool: true,
@@ -632,13 +1005,19 @@ describe('store', () => {
632
1005
 
633
1006
  describe('proxy behavior', () => {
634
1007
  test('Object.keys returns property keys', () => {
635
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
1008
+ const user = createStore({
1009
+ name: 'Hannah',
1010
+ email: 'hannah@example.com',
1011
+ })
636
1012
 
637
1013
  expect(Object.keys(user)).toEqual(['name', 'email'])
638
1014
  })
639
1015
 
640
1016
  test('property enumeration works', () => {
641
- const user = store({ name: 'Hannah', email: 'hannah@example.com' })
1017
+ const user = createStore({
1018
+ name: 'Hannah',
1019
+ email: 'hannah@example.com',
1020
+ })
642
1021
  const keys: string[] = []
643
1022
 
644
1023
  for (const key in user) {
@@ -649,14 +1028,14 @@ describe('store', () => {
649
1028
  })
650
1029
 
651
1030
  test('in operator works', () => {
652
- const user = store({ name: 'Hannah' })
1031
+ const user = createStore({ name: 'Hannah' })
653
1032
 
654
1033
  expect('name' in user).toBe(true)
655
1034
  expect('email' in user).toBe(false)
656
1035
  })
657
1036
 
658
1037
  test('Object.getOwnPropertyDescriptor works', () => {
659
- const user = store({ name: 'Hannah' })
1038
+ const user = createStore({ name: 'Hannah' })
660
1039
 
661
1040
  const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
662
1041
  expect(descriptor).toEqual({
@@ -670,7 +1049,7 @@ describe('store', () => {
670
1049
 
671
1050
  describe('type conversion via toSignal', () => {
672
1051
  test('arrays are converted to stores', () => {
673
- const fruits = store({ items: ['apple', 'banana', 'cherry'] })
1052
+ const fruits = createStore({ items: ['apple', 'banana', 'cherry'] })
674
1053
 
675
1054
  expect(isStore(fruits.items)).toBe(true)
676
1055
  expect(fruits.items['0'].get()).toBe('apple')
@@ -679,7 +1058,7 @@ describe('store', () => {
679
1058
  })
680
1059
 
681
1060
  test('nested objects become nested stores', () => {
682
- const config = store({
1061
+ const config = createStore({
683
1062
  database: {
684
1063
  host: 'localhost',
685
1064
  port: 5432,
@@ -694,7 +1073,7 @@ describe('store', () => {
694
1073
 
695
1074
  describe('spread operator behavior', () => {
696
1075
  test('spreading store spreads individual signals', () => {
697
- const user = store({ name: 'Hannah', age: 25, active: true })
1076
+ const user = createStore({ name: 'Hannah', age: 25, active: true })
698
1077
 
699
1078
  // Spread the store - should get individual signals
700
1079
  const spread = { ...user }
@@ -723,7 +1102,7 @@ describe('store', () => {
723
1102
  })
724
1103
 
725
1104
  test('spreading nested store works correctly', () => {
726
- const config = store({
1105
+ const config = createStore({
727
1106
  app: { name: 'MyApp', version: '1.0' },
728
1107
  settings: { theme: 'dark', debug: false },
729
1108
  })
@@ -743,4 +1122,861 @@ describe('store', () => {
743
1122
  expect(spread.app.name.get()).toBe('UpdatedApp')
744
1123
  })
745
1124
  })
1125
+
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
+ ])
1207
+
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"')
1248
+ })
1249
+
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<{
1273
+ dashboard: {
1274
+ widgets: {
1275
+ id: number
1276
+ 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[]
1288
+ }
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(),
1301
+ dashboard: {
1302
+ ...dashboardStore.dashboard.get(),
1303
+ widgets: [
1304
+ ...dashboardStore.dashboard.widgets.get(),
1305
+ { id: 3, type: 'graph', config: { animate: true } },
1306
+ ],
1307
+ },
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
+ }
1378
+
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({})
1627
+
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)
1651
+ })
1652
+ })
1653
+
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'])
1686
+ })
1687
+
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 },
1693
+ })
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)
1713
+ })
1714
+
1715
+ test('emits a sort notification with new order', () => {
1716
+ const numbers = createStore([30, 10, 20])
1717
+ let sortNotification: string[] | null = null
1718
+
1719
+ numbers.on('sort', change => {
1720
+ sortNotification = change
1721
+ })
1722
+
1723
+ numbers.sort((a, b) => a - b)
1724
+
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'])
1729
+ })
1730
+
1731
+ test('sort is reactive - watchers are notified', () => {
1732
+ const numbers = createStore([3, 1, 2])
1733
+ let effectCount = 0
1734
+ let lastValue: number[] = []
1735
+
1736
+ createEffect(() => {
1737
+ lastValue = numbers.get()
1738
+ effectCount++
1739
+ })
1740
+
1741
+ // Initial effect run
1742
+ expect(effectCount).toBe(1)
1743
+ expect(lastValue).toEqual([3, 1, 2])
1744
+
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
+ ])
1805
+
1806
+ // Verify nested reactivity still works
1807
+ posts[0].meta.likes.set(15)
1808
+ expect(posts.get()[0].meta.likes).toBe(15)
1809
+ })
1810
+
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
+ })
1823
+
1824
+ test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
1825
+ const items = createStore(['banana', 'cherry', 'apple', '10', '2'])
1826
+
1827
+ items.sort()
1828
+
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
+ )
1833
+ })
1834
+
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
+ })
1844
+
1845
+ test('default sort handles mixed values with proper string conversion', () => {
1846
+ const mixed = createStore(['b', 0, 'a', '', 'c'])
1847
+
1848
+ mixed.sort()
1849
+
1850
+ // String conversion: '' < '0' < 'a' < 'b' < 'c'
1851
+ expect(mixed.get()).toEqual(['', 0, 'a', 'b', 'c'])
1852
+ })
1853
+
1854
+ test('multiple sorts work correctly', () => {
1855
+ const numbers = createStore([3, 1, 4, 1, 5])
1856
+
1857
+ // Sort ascending
1858
+ numbers.sort((a, b) => a - b)
1859
+ expect(numbers.get()).toEqual([1, 1, 3, 4, 5])
1860
+
1861
+ // Sort descending
1862
+ numbers.sort((a, b) => b - a)
1863
+ expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
1864
+ })
1865
+
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
+ })
1872
+
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'])
1884
+ })
1885
+ })
1886
+
1887
+ describe('cross-component communication pattern', () => {
1888
+ test('event bus with UNSET initialization - type-safe pattern', () => {
1889
+ // Component B (the owner) declares the shape of events it will emit
1890
+ type EventBusSchema = {
1891
+ userLogin: { userId: number; timestamp: number }
1892
+ userLogout: { userId: number }
1893
+ userUpdate: { userId: number; profile: { name: string } }
1894
+ }
1895
+
1896
+ // Initialize the event bus with proper typing
1897
+ const eventBus = createStore<EventBusSchema>({
1898
+ userLogin: UNSET,
1899
+ userLogout: UNSET,
1900
+ userUpdate: UNSET,
1901
+ })
1902
+
1903
+ // Simple type-safe on functions
1904
+ const on = (
1905
+ event: keyof EventBusSchema,
1906
+ callback: (data: EventBusSchema[keyof EventBusSchema]) => void,
1907
+ ) =>
1908
+ createEffect(() => {
1909
+ const data = eventBus[event].get()
1910
+ if (data !== UNSET) callback(data)
1911
+ })
1912
+
1913
+ // Test the pattern with properly typed variables
1914
+ let receivedLogin: unknown = null
1915
+ let receivedLogout: unknown = null
1916
+ let receivedUpdate: unknown = null
1917
+
1918
+ // Component A listens for events
1919
+ on('userLogin', data => {
1920
+ receivedLogin = data
1921
+ })
1922
+
1923
+ on('userLogout', data => {
1924
+ receivedLogout = data
1925
+ })
1926
+
1927
+ on('userUpdate', data => {
1928
+ receivedUpdate = data
1929
+ })
1930
+
1931
+ // Initially nothing should be received (all UNSET)
1932
+ expect(receivedLogin).toBe(null)
1933
+ expect(receivedLogout).toBe(null)
1934
+ expect(receivedUpdate).toBe(null)
1935
+
1936
+ // Component B emits user events with full type safety
1937
+ const loginData: EventBusSchema['userLogin'] = {
1938
+ userId: 123,
1939
+ timestamp: Date.now(),
1940
+ }
1941
+ eventBus.userLogin.set(loginData)
1942
+
1943
+ expect(receivedLogin).toEqual(loginData)
1944
+ expect(receivedLogout).toBe(null) // Should not have triggered
1945
+ expect(receivedUpdate).toBe(null) // Should not have triggered
1946
+
1947
+ // Test second event
1948
+ const logoutData: EventBusSchema['userLogout'] = { userId: 123 }
1949
+ eventBus.userLogout.set(logoutData)
1950
+
1951
+ expect(receivedLogout).toEqual(logoutData)
1952
+ expect(receivedLogin).toEqual(loginData) // Should remain unchanged
1953
+
1954
+ // Test third event
1955
+ const updateData: EventBusSchema['userUpdate'] = {
1956
+ userId: 456,
1957
+ profile: { name: 'Alice' },
1958
+ }
1959
+ eventBus.userUpdate.set(updateData)
1960
+
1961
+ 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
1980
+ })
1981
+ })
746
1982
  })