@zeix/cause-effect 0.16.0 → 0.17.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 (62) hide show
  1. package/.ai-context.md +71 -21
  2. package/.cursorrules +3 -2
  3. package/.github/copilot-instructions.md +59 -13
  4. package/CLAUDE.md +170 -24
  5. package/LICENSE +1 -1
  6. package/README.md +156 -52
  7. package/archive/benchmark.ts +688 -0
  8. package/archive/collection.ts +312 -0
  9. package/{src → archive}/computed.ts +33 -34
  10. package/archive/list.ts +551 -0
  11. package/archive/memo.ts +138 -0
  12. package/archive/state.ts +89 -0
  13. package/archive/store.ts +368 -0
  14. package/archive/task.ts +194 -0
  15. package/eslint.config.js +1 -0
  16. package/index.dev.js +902 -501
  17. package/index.js +1 -1
  18. package/index.ts +42 -22
  19. package/package.json +1 -1
  20. package/src/classes/collection.ts +272 -0
  21. package/src/classes/composite.ts +176 -0
  22. package/src/classes/computed.ts +333 -0
  23. package/src/classes/list.ts +304 -0
  24. package/src/classes/state.ts +98 -0
  25. package/src/classes/store.ts +210 -0
  26. package/src/diff.ts +28 -52
  27. package/src/effect.ts +9 -9
  28. package/src/errors.ts +50 -25
  29. package/src/signal.ts +58 -41
  30. package/src/system.ts +79 -42
  31. package/src/util.ts +16 -34
  32. package/test/batch.test.ts +15 -17
  33. package/test/benchmark.test.ts +4 -4
  34. package/test/collection.test.ts +796 -0
  35. package/test/computed.test.ts +138 -130
  36. package/test/diff.test.ts +2 -2
  37. package/test/effect.test.ts +36 -35
  38. package/test/list.test.ts +754 -0
  39. package/test/match.test.ts +25 -25
  40. package/test/resolve.test.ts +17 -19
  41. package/test/signal.test.ts +72 -121
  42. package/test/state.test.ts +44 -44
  43. package/test/store.test.ts +344 -1663
  44. package/types/index.d.ts +11 -9
  45. package/types/src/classes/collection.d.ts +32 -0
  46. package/types/src/classes/composite.d.ts +15 -0
  47. package/types/src/classes/computed.d.ts +97 -0
  48. package/types/src/classes/list.d.ts +41 -0
  49. package/types/src/classes/state.d.ts +52 -0
  50. package/types/src/classes/store.d.ts +51 -0
  51. package/types/src/diff.d.ts +8 -12
  52. package/types/src/errors.d.ts +12 -11
  53. package/types/src/signal.d.ts +27 -14
  54. package/types/src/system.d.ts +41 -20
  55. package/types/src/util.d.ts +6 -3
  56. package/src/state.ts +0 -98
  57. package/src/store.ts +0 -525
  58. package/types/src/collection.d.ts +0 -26
  59. package/types/src/computed.d.ts +0 -33
  60. package/types/src/scheduler.d.ts +0 -55
  61. package/types/src/state.d.ts +0 -24
  62. package/types/src/store.d.ts +0 -66
@@ -1,168 +1,160 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import {
3
- createComputed,
3
+ BaseStore,
4
4
  createEffect,
5
- createState,
6
5
  createStore,
7
6
  isStore,
8
- type State,
7
+ Memo,
8
+ State,
9
9
  UNSET,
10
- } from '..'
10
+ } from '../index.ts'
11
11
 
12
12
  describe('store', () => {
13
13
  describe('creation and basic operations', () => {
14
- test('creates a store with initial values', () => {
15
- const user = createStore({
14
+ test('creates BaseStore with initial values', () => {
15
+ const user = new BaseStore({
16
16
  name: 'Hannah',
17
17
  email: 'hannah@example.com',
18
18
  })
19
+ expect(user.byKey('name').get()).toBe('Hannah')
20
+ expect(user.byKey('email').get()).toBe('hannah@example.com')
21
+ })
19
22
 
23
+ test('creates stores with initial values', () => {
24
+ const user = createStore({
25
+ name: 'Hannah',
26
+ email: 'hannah@example.com',
27
+ })
20
28
  expect(user.name.get()).toBe('Hannah')
21
29
  expect(user.email.get()).toBe('hannah@example.com')
22
30
  })
23
31
 
24
32
  test('has Symbol.toStringTag of Store', () => {
25
- const s = createStore({ a: 1 })
26
- expect(s[Symbol.toStringTag]).toBe('Store')
33
+ const store = createStore({ a: 1 })
34
+ expect(store[Symbol.toStringTag]).toBe('Store')
27
35
  })
28
36
 
29
37
  test('isStore identifies store instances correctly', () => {
30
- const s = createStore({ a: 1 })
31
- const st = createState(1)
32
- const c = createComputed(() => 1)
38
+ const store = createStore({ a: 1 })
39
+ const state = new State(1)
40
+ const computed = new Memo(() => 1)
33
41
 
34
- expect(isStore(s)).toBe(true)
35
- expect(isStore(st)).toBe(false)
36
- expect(isStore(c)).toBe(false)
42
+ expect(isStore(store)).toBe(true)
43
+ expect(isStore(state)).toBe(false)
44
+ expect(isStore(computed)).toBe(false)
37
45
  expect(isStore({})).toBe(false)
38
- expect(isStore(null)).toBe(false)
39
46
  })
40
47
 
41
48
  test('get() returns the complete store value', () => {
42
49
  const user = createStore({
43
- name: 'Hannah',
44
- email: 'hannah@example.com',
50
+ name: 'Alice',
51
+ email: 'alice@example.com',
45
52
  })
46
-
47
53
  expect(user.get()).toEqual({
48
- name: 'Hannah',
49
- email: 'hannah@example.com',
54
+ name: 'Alice',
55
+ email: 'alice@example.com',
50
56
  })
51
-
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'] },
61
- ])
62
57
  })
63
58
  })
64
59
 
65
60
  describe('proxy data access and modification', () => {
66
61
  test('properties can be accessed and modified via signals', () => {
67
- const user = createStore({ name: 'Hannah', age: 25 })
68
-
69
- // Get signals from store proxy
70
- expect(user.name.get()).toBe('Hannah')
71
- expect(user.age.get()).toBe(25)
72
-
73
- // Set values via signals
74
- user.name.set('Alice')
75
- user.age.set(30)
76
-
77
- expect(user.name.get()).toBe('Alice')
62
+ const user = createStore({ name: 'John', age: 30 })
63
+ expect(user.name.get()).toBe('John')
78
64
  expect(user.age.get()).toBe(30)
65
+
66
+ user.name.set('Alicia')
67
+ user.age.set(31)
68
+ expect(user.name.get()).toBe('Alicia')
69
+ expect(user.age.get()).toBe(31)
79
70
  })
80
71
 
81
72
  test('returns undefined for non-existent properties', () => {
82
- const user = createStore({ name: 'Hannah' })
83
-
73
+ const user = createStore({ name: 'Alice' })
84
74
  // @ts-expect-error accessing non-existent property
85
- expect(user.nonExistent).toBeUndefined()
75
+ expect(user.nonexistent).toBeUndefined()
86
76
  })
87
77
 
88
- test('supports numeric key access', () => {
89
- const items = createStore({ '0': 'first', '1': 'second' })
90
-
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')
78
+ test('supports string key access', () => {
79
+ const items = createStore({ first: 'alpha', second: 'beta' })
80
+ expect(items.first.get()).toBe('alpha')
81
+ expect(items.second.get()).toBe('beta')
95
82
  })
83
+ })
96
84
 
97
- test('can add new properties via add method', () => {
85
+ describe('add() and remove() methods', () => {
86
+ test('add() method adds new properties', () => {
98
87
  const user = createStore<{ name: string; email?: string }>({
99
- name: 'Hannah',
100
- })
101
-
102
- user.add('email', 'hannah@example.com')
103
-
104
- expect(user.email?.get()).toBe('hannah@example.com')
105
- expect(user.get()).toEqual({
106
- name: 'Hannah',
107
- email: 'hannah@example.com',
88
+ name: 'John',
108
89
  })
90
+ user.add('email', 'john@example.com')
91
+ expect(user.byKey('email')?.get()).toBe('john@example.com')
92
+ expect(user.email?.get()).toBe('john@example.com')
109
93
  })
110
94
 
111
- test('can remove existing properties via remove method', () => {
95
+ test('remove() method removes properties', () => {
112
96
  const user = createStore<{ name: string; email?: string }>({
113
- name: 'Hannah',
114
- email: 'hannah@example.com',
97
+ name: 'John',
98
+ email: 'john@example.com',
115
99
  })
116
-
117
- expect(user.email?.get()).toBe('hannah@example.com')
118
-
119
100
  user.remove('email')
120
-
101
+ expect(user.byKey('email')).toBeUndefined()
102
+ // expect(user.byKey('name').get()).toBe('John')
121
103
  expect(user.email).toBeUndefined()
122
- expect(user.get()).toEqual({
123
- name: 'Hannah',
124
- })
104
+ // expect(user.name.get()).toBe('John')
125
105
  })
126
106
 
127
107
  test('add method prevents null values', () => {
128
- const user = createStore<{ name: string; tags?: string[] }>({
129
- name: 'Alice',
108
+ const user = createStore<{ name: string; email?: string }>({
109
+ name: 'John',
130
110
  })
111
+ // @ts-expect-error testing null values
112
+ expect(() => user.add('email', null)).toThrow()
113
+ })
131
114
 
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
- )
115
+ test('add method prevents overwriting existing properties', () => {
116
+ const user = createStore({
117
+ name: 'John',
118
+ email: 'john@example.com',
119
+ })
120
+ expect(() => user.add('name', 'Jane')).toThrow()
121
+ })
122
+
123
+ test('remove method handles non-existent properties gracefully', () => {
124
+ const user = createStore({ name: 'John' })
125
+ expect(() => user.remove('nonexistent')).not.toThrow()
138
126
  })
139
127
  })
140
128
 
141
129
  describe('nested stores', () => {
142
130
  test('creates nested stores for object properties', () => {
143
131
  const user = createStore({
144
- name: 'Hannah',
132
+ name: 'Alice',
145
133
  preferences: {
146
- theme: 'dark',
134
+ theme: 'light',
147
135
  notifications: true,
148
136
  },
149
137
  })
150
138
 
151
- expect(isStore(user.preferences)).toBe(true)
152
- expect(user.preferences.theme?.get()).toBe('dark')
153
- expect(user.preferences.notifications?.get()).toBe(true)
139
+ expect(user.name.get()).toBe('Alice')
140
+ expect(user.preferences.theme.get()).toBe('light')
141
+ expect(user.preferences.notifications.get()).toBe(true)
154
142
  })
155
143
 
156
144
  test('nested properties are reactive', () => {
157
145
  const user = createStore({
158
146
  preferences: {
159
- theme: 'dark',
147
+ theme: 'light',
160
148
  },
161
149
  })
150
+ let lastTheme = ''
151
+ createEffect(() => {
152
+ lastTheme = user.preferences.theme.get()
153
+ })
162
154
 
163
- user.preferences.theme.set('light')
164
- expect(user.preferences.theme.get()).toBe('light')
165
- expect(user.get().preferences.theme).toBe('light')
155
+ expect(lastTheme).toBe('light')
156
+ user.preferences.theme.set('dark')
157
+ expect(lastTheme).toBe('dark')
166
158
  })
167
159
 
168
160
  test('deeply nested stores work correctly', () => {
@@ -170,256 +162,136 @@ describe('store', () => {
170
162
  ui: {
171
163
  theme: {
172
164
  colors: {
173
- primary: 'blue',
165
+ primary: '#007acc',
174
166
  },
175
167
  },
176
168
  },
177
169
  })
178
170
 
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')
171
+ expect(config.ui.theme.colors.primary.get()).toBe('#007acc')
172
+ config.ui.theme.colors.primary.set('#ff6600')
173
+ expect(config.ui.theme.colors.primary.get()).toBe('#ff6600')
182
174
  })
183
175
  })
184
176
 
185
177
  describe('set() and update() methods', () => {
186
178
  test('set() replaces entire store value', () => {
187
179
  const user = createStore({
188
- name: 'Hannah',
189
- email: 'hannah@example.com',
190
- })
191
-
192
- user.set({ name: 'Alice', email: 'alice@example.com' })
193
-
194
- expect(user.get()).toEqual({
195
- name: 'Alice',
196
- email: 'alice@example.com',
180
+ name: 'John',
181
+ email: 'john@example.com',
197
182
  })
183
+ user.set({ name: 'Jane', email: 'jane@example.com' })
184
+ expect(user.name.get()).toBe('Jane')
185
+ expect(user.email.get()).toBe('jane@example.com')
198
186
  })
199
187
 
200
188
  test('update() modifies store using function', () => {
201
- const user = createStore({ name: 'Hannah', age: 25 })
202
-
203
- user.update(prev => ({ ...prev, age: prev.age + 1 }))
204
-
205
- expect(user.get()).toEqual({
206
- name: 'Hannah',
207
- age: 26,
208
- })
189
+ const user = createStore({ name: 'John', age: 25 })
190
+ user.update(u => ({ ...u, age: u.age + 1 }))
191
+ expect(user.name.get()).toBe('John')
192
+ expect(user.age.get()).toBe(26)
209
193
  })
210
194
  })
211
195
 
212
- describe('iterator protocol', () => {
196
+ describe('iteration protocol', () => {
213
197
  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])
198
+ const user = createStore({ name: 'John', age: 25 })
199
+ const entries = [...user]
200
+ expect(entries).toHaveLength(2)
201
+ expect(entries[0][0]).toBe('name')
202
+ expect(entries[0][1].get()).toBe('John')
203
+ expect(entries[1][0]).toBe('age')
204
+ expect(entries[1][1].get()).toBe(25)
205
+ })
206
+
207
+ test('Symbol.isConcatSpreadable is false', () => {
208
+ const user = createStore({ name: 'John', age: 25 })
209
+ expect(user[Symbol.isConcatSpreadable]).toBe(false)
210
+ })
211
+
212
+ test('maintains property key ordering', () => {
213
+ const config = createStore({ alpha: 1, beta: 2, gamma: 3 })
214
+ const keys = Object.keys(config)
215
+ expect(keys).toEqual(['alpha', 'beta', 'gamma'])
216
+
217
+ const entries = [...config]
218
+ expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual(
219
+ [
220
+ ['alpha', 1],
221
+ ['beta', 2],
222
+ ['gamma', 3],
223
+ ],
224
+ )
223
225
  })
224
226
  })
225
227
 
226
- describe('change tracking', () => {
227
- test('tracks size changes', () => {
228
- const user = createStore<{ name: string; email?: string }>({
229
- name: 'Hannah',
230
- })
231
-
232
- expect(user.size.get()).toBe(1)
233
-
234
- user.add('email', 'hannah@example.com')
235
- expect(user.size.get()).toBe(2)
236
-
237
- user.remove('email')
238
- expect(user.size.get()).toBe(1)
239
- })
240
-
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))
251
-
252
- expect(addNotification).toBeTruthy()
253
- // biome-ignore lint/style/noNonNullAssertion: test
254
- expect(addNotification!).toEqual({ name: 'Hannah' })
255
- })
256
-
257
- test('emits an add notification for new properties', () => {
228
+ describe('change tracking and notifications', () => {
229
+ test('emits add notifications', () => {
230
+ let addNotification: readonly string[] = []
258
231
  const user = createStore<{ name: string; email?: string }>({
259
- name: 'Hannah',
260
- })
261
-
262
- let addNotification: Record<string, string> | null = null
263
- user.on('add', change => {
264
- addNotification = change
265
- })
266
-
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' })
278
-
279
- let changeNotification: Record<string, string> | null = null
280
- user.on('change', change => {
281
- changeNotification = change
232
+ name: 'John',
282
233
  })
283
-
284
- user.set({ name: 'Alice' })
285
-
286
- expect(changeNotification).toBeTruthy()
287
- // biome-ignore lint/style/noNonNullAssertion: test
288
- expect(changeNotification!).toEqual({
289
- name: 'Alice',
234
+ user.on('add', add => {
235
+ addNotification = add
290
236
  })
237
+ user.add('email', 'john@example.com')
238
+ expect(addNotification).toContain('email')
291
239
  })
292
240
 
293
- test('emits a change notification for signal changes', () => {
294
- const user = createStore({ name: 'Hannah' })
295
-
296
- let changeNotification: Record<string, string> | null = null
241
+ test('emits change notifications when properties are modified', () => {
242
+ const user = createStore({ name: 'John' })
243
+ let changeNotification: readonly string[] = []
297
244
  user.on('change', change => {
298
245
  changeNotification = change
299
246
  })
300
-
301
- user.name.set('Bob')
302
-
303
- expect(changeNotification).toBeTruthy()
304
- // biome-ignore lint/style/noNonNullAssertion: test
305
- expect(changeNotification!).toEqual({
306
- name: 'Bob',
307
- })
247
+ user.name.set('Jane')
248
+ expect(changeNotification).toContain('name')
308
249
  })
309
250
 
310
- test('emits a change notification when nested properties change', () => {
251
+ test('emits change notifications for nested property changes', () => {
311
252
  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
253
  preferences: {
331
254
  theme: 'light',
332
- notifications: true,
333
255
  },
334
256
  })
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 => {
257
+ let changeNotification: readonly string[] = []
258
+ user.on('change', change => {
352
259
  changeNotification = change
353
260
  })
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
- })
261
+ user.preferences.theme.set('dark')
262
+ expect(changeNotification).toContain('preferences')
371
263
  })
372
264
 
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
- },
265
+ test('emits remove notifications when properties are removed', () => {
266
+ const user = createStore({
267
+ name: 'John',
268
+ email: 'john@example.com',
386
269
  })
387
-
388
- let removeNotification: Record<string, unknown> = {}
270
+ let removeNotification: readonly string[] = []
389
271
  user.on('remove', remove => {
390
272
  removeNotification = remove
391
273
  })
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
- })
274
+ user.remove('email')
275
+ expect(removeNotification).toContain('email')
401
276
  })
402
277
 
403
278
  test('set() correctly handles mixed changes, additions, and removals', () => {
404
279
  const user = createStore<{
405
280
  name: string
406
281
  email?: string
407
- preferences?: {
408
- theme: string
409
- notifications?: boolean
410
- }
282
+ preferences: { theme?: string }
411
283
  age?: number
412
284
  }>({
413
- name: 'Hannah',
414
- email: 'hannah@example.com',
285
+ name: 'John',
286
+ email: 'john@example.com',
415
287
  preferences: {
416
- theme: 'dark',
288
+ theme: 'light',
417
289
  },
418
290
  })
419
291
 
420
- let changeNotification: Record<string, unknown> = {}
421
- let addNotification: Record<string, unknown> = {}
422
- let removeNotification: Record<string, unknown> = {}
292
+ let changeNotification: readonly string[] = []
293
+ let addNotification: readonly string[] = []
294
+ let removeNotification: readonly string[] = []
423
295
 
424
296
  user.on('change', change => {
425
297
  changeNotification = change
@@ -431,614 +303,216 @@ describe('store', () => {
431
303
  removeNotification = remove
432
304
  })
433
305
 
434
- // Perform a set() that changes name, removes email, adds age, and keeps preferences
435
306
  user.set({
436
- name: 'Alice', // changed
307
+ name: 'Jane',
437
308
  preferences: {
438
- theme: 'dark', // unchanged nested
309
+ theme: 'dark',
439
310
  },
440
- 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
311
  age: 30,
452
312
  })
453
313
 
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
- })
516
-
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
- })
314
+ expect(changeNotification).toContain('name')
315
+ expect(changeNotification).toContain('preferences')
316
+ expect(addNotification).toContain('age')
317
+ expect(removeNotification).toContain('email')
529
318
  })
530
319
 
531
- test('can remove notification listeners', () => {
532
- const user = createStore({ name: 'Hannah' })
533
-
320
+ test('notification listeners can be removed', () => {
321
+ const user = createStore({ name: 'John' })
534
322
  let notificationCount = 0
535
323
  const listener = () => {
536
324
  notificationCount++
537
325
  }
538
-
539
326
  const off = user.on('change', listener)
540
- user.name.set('Alice')
327
+ user.name.set('Jane')
541
328
  expect(notificationCount).toBe(1)
542
329
 
543
330
  off()
544
331
  user.name.set('Bob')
545
- expect(notificationCount).toBe(1) // Should not increment
546
- })
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)
332
+ expect(notificationCount).toBe(1)
566
333
  })
567
334
  })
568
335
 
569
336
  describe('reactivity', () => {
570
337
  test('store-level get() is reactive', () => {
571
338
  const user = createStore({
572
- name: 'Hannah',
573
- email: 'hannah@example.com',
339
+ name: 'John',
340
+ email: 'john@example.com',
574
341
  })
575
342
  let lastValue = { name: '', email: '' }
576
-
577
343
  createEffect(() => {
578
344
  lastValue = user.get()
579
345
  })
580
346
 
581
- user.name.set('Alice')
347
+ expect(lastValue).toEqual({
348
+ name: 'John',
349
+ email: 'john@example.com',
350
+ })
351
+
352
+ user.name.set('Jane')
353
+ user.email.set('jane@example.com')
582
354
 
583
355
  expect(lastValue).toEqual({
584
- name: 'Alice',
585
- email: 'hannah@example.com',
356
+ name: 'Jane',
357
+ email: 'jane@example.com',
586
358
  })
587
359
  })
588
360
 
589
361
  test('individual signal reactivity works', () => {
590
362
  const user = createStore({
591
- name: 'Hannah',
592
- email: 'hannah@example.com',
363
+ name: 'John',
364
+ email: 'john@example.com',
593
365
  })
594
366
  let lastName = ''
595
367
  let nameEffectRuns = 0
596
-
597
- // Get signal for name property directly
598
- const nameSignal = user.name
599
-
600
368
  createEffect(() => {
601
- lastName = nameSignal.get()
369
+ lastName = user.name.get()
602
370
  nameEffectRuns++
603
371
  })
604
372
 
605
- // Change name should trigger effect
606
- user.name.set('Alice')
607
- expect(lastName).toBe('Alice')
608
- expect(nameEffectRuns).toBe(2) // Initial + update
373
+ expect(lastName).toBe('John')
374
+ expect(nameEffectRuns).toBe(1)
375
+
376
+ user.name.set('Jane')
377
+ expect(lastName).toBe('Jane')
378
+ expect(nameEffectRuns).toBe(2)
609
379
  })
610
380
 
611
381
  test('nested store changes propagate to parent', () => {
612
382
  const user = createStore({
613
383
  preferences: {
614
- theme: 'dark',
384
+ theme: 'light',
615
385
  },
616
386
  })
617
387
  let effectRuns = 0
618
-
619
388
  createEffect(() => {
620
- user.get() // Watch entire store
389
+ user.get()
621
390
  effectRuns++
622
391
  })
623
392
 
624
- user.preferences.theme.set('light')
625
- expect(effectRuns).toBe(2) // Initial + nested change
393
+ expect(effectRuns).toBe(1)
394
+ user.preferences.theme.set('dark')
395
+ expect(effectRuns).toBe(2)
626
396
  })
627
397
 
628
398
  test('updates are reactive', () => {
629
- const user = createStore<{ name: string; email?: string }>({
630
- name: 'Hannah',
399
+ const user = createStore({
400
+ name: 'John',
631
401
  })
632
- let lastValue = {}
402
+ let lastValue: {
403
+ name: string
404
+ email?: string
405
+ } = { name: '' }
633
406
  let effectRuns = 0
634
-
635
407
  createEffect(() => {
636
408
  lastValue = user.get()
637
409
  effectRuns++
638
410
  })
639
411
 
640
- user.add('email', 'hannah@example.com')
412
+ expect(lastValue).toEqual({ name: 'John' })
413
+ expect(effectRuns).toBe(1)
414
+
415
+ user.update(u => ({ ...u, email: 'john@example.com' }))
641
416
  expect(lastValue).toEqual({
642
- name: 'Hannah',
643
- email: 'hannah@example.com',
417
+ name: 'John',
418
+ email: 'john@example.com',
644
419
  })
645
420
  expect(effectRuns).toBe(2)
646
421
  })
647
422
 
648
423
  test('remove method is reactive', () => {
649
- const user = createStore<{ name: string; email?: string }>({
650
- name: 'Hannah',
651
- email: 'hannah@example.com',
424
+ const user = createStore({
425
+ name: 'John',
426
+ email: 'john@example.com',
427
+ age: 30,
652
428
  })
653
- let lastValue = {}
429
+ let lastValue: {
430
+ name: string
431
+ email?: string
432
+ age?: number
433
+ } = { name: '', email: '', age: 0 }
654
434
  let effectRuns = 0
655
-
656
435
  createEffect(() => {
657
436
  lastValue = user.get()
658
437
  effectRuns++
659
438
  })
660
439
 
661
- expect(effectRuns).toBe(1)
662
-
663
- user.remove('email')
664
440
  expect(lastValue).toEqual({
665
- name: 'Hannah',
666
- })
667
- 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
- )
683
-
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',
441
+ name: 'John',
442
+ email: 'john@example.com',
443
+ age: 30,
691
444
  })
445
+ expect(effectRuns).toBe(1)
692
446
 
693
- const originalSize = user.size.get()
694
447
  user.remove('email')
695
-
696
- expect(user.size.get()).toBe(originalSize)
448
+ expect(lastValue).toEqual({ name: 'John', age: 30 })
449
+ expect(effectRuns).toBe(2)
697
450
  })
698
451
  })
699
452
 
700
453
  describe('computed integration', () => {
701
454
  test('works with computed signals', () => {
702
- const user = createStore({ firstName: 'Hannah', lastName: 'Smith' })
703
-
704
- const fullName = createComputed(() => {
705
- return `${user.firstName.get()} ${user.lastName.get()}`
455
+ const user = createStore({
456
+ firstName: 'John',
457
+ lastName: 'Doe',
706
458
  })
459
+ const fullName = new Memo(
460
+ () => `${user.firstName.get()} ${user.lastName.get()}`,
461
+ )
707
462
 
708
- expect(fullName.get()).toBe('Hannah Smith')
709
-
710
- user.firstName.set('Alice')
711
- expect(fullName.get()).toBe('Alice Smith')
463
+ expect(fullName.get()).toBe('John Doe')
464
+ user.firstName.set('Jane')
465
+ expect(fullName.get()).toBe('Jane Doe')
712
466
  })
713
467
 
714
468
  test('computed reacts to nested store changes', () => {
715
469
  const config = createStore({
716
470
  ui: {
717
- theme: 'dark',
471
+ theme: 'light',
718
472
  },
719
473
  })
474
+ const themeDisplay = new Memo(
475
+ () => `Theme: ${config.ui.theme.get()}`,
476
+ )
720
477
 
721
- const themeDisplay = createComputed(() => {
722
- return `Theme: ${config.ui.theme.get()}`
723
- })
724
-
725
- expect(themeDisplay.get()).toBe('Theme: dark')
726
-
727
- config.ui.theme.set('light')
728
478
  expect(themeDisplay.get()).toBe('Theme: light')
479
+ config.ui.theme.set('dark')
480
+ expect(themeDisplay.get()).toBe('Theme: dark')
729
481
  })
730
482
  })
731
483
 
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
-
915
- describe('arrays and edge cases', () => {
916
- test('handles arrays as store values', () => {
917
- const data = createStore({ items: [1, 2, 3] })
918
-
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)
924
- })
925
-
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],
934
- })
935
-
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)
940
-
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')
945
-
946
- // Should be able to modify string elements
947
- todoApp.todos['0'].set('Buy groceries')
948
- expect(todoApp.todos['0'].get()).toBe('Buy groceries')
949
-
950
- // Object array elements should be Store<T>
951
- expect(isStore(todoApp.users[0])).toBe(true)
952
- expect(isStore(todoApp.users[1])).toBe(true)
953
-
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)
959
-
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)
983
- })
984
-
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')
991
- })
992
-
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)
1003
- })
1004
- })
1005
-
1006
- describe('proxy behavior', () => {
484
+ describe('proxy behavior and enumeration', () => {
1007
485
  test('Object.keys returns property keys', () => {
1008
486
  const user = createStore({
1009
- name: 'Hannah',
1010
- email: 'hannah@example.com',
487
+ name: 'Alice',
488
+ email: 'alice@example.com',
1011
489
  })
1012
-
1013
- expect(Object.keys(user)).toEqual(['name', 'email'])
490
+ const userKeys = Object.keys(user)
491
+ expect(userKeys.sort()).toEqual(['email', 'name'])
1014
492
  })
1015
493
 
1016
494
  test('property enumeration works', () => {
1017
495
  const user = createStore({
1018
- name: 'Hannah',
1019
- email: 'hannah@example.com',
496
+ name: 'Alice',
497
+ email: 'alice@example.com',
1020
498
  })
1021
- const keys: string[] = []
1022
-
499
+ const userKeys: string[] = []
1023
500
  for (const key in user) {
1024
- keys.push(key)
501
+ userKeys.push(key)
1025
502
  }
1026
-
1027
- expect(keys).toEqual(['name', 'email'])
503
+ expect(userKeys.sort()).toEqual(['email', 'name'])
1028
504
  })
1029
505
 
1030
506
  test('in operator works', () => {
1031
- const user = createStore({ name: 'Hannah' })
1032
-
507
+ const user = createStore({ name: 'Alice' })
1033
508
  expect('name' in user).toBe(true)
1034
509
  expect('email' in user).toBe(false)
1035
510
  })
1036
511
 
1037
512
  test('Object.getOwnPropertyDescriptor works', () => {
1038
- const user = createStore({ name: 'Hannah' })
1039
-
1040
- const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
1041
- expect(descriptor).toEqual({
513
+ const user = createStore({ name: 'Alice' })
514
+ const nameDescriptor = Object.getOwnPropertyDescriptor(user, 'name')
515
+ expect(nameDescriptor).toEqual({
1042
516
  enumerable: true,
1043
517
  configurable: true,
1044
518
  writable: true,
@@ -1047,936 +521,143 @@ describe('store', () => {
1047
521
  })
1048
522
  })
1049
523
 
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
-
1060
- test('nested objects become nested stores', () => {
1061
- const config = createStore({
1062
- database: {
1063
- host: 'localhost',
1064
- port: 5432,
1065
- },
524
+ describe('byKey() method', () => {
525
+ test('works with property keys', () => {
526
+ const user = createStore({
527
+ name: 'Alice',
528
+ email: 'alice@example.com',
529
+ age: 30,
1066
530
  })
1067
531
 
1068
- expect(isStore(config.database)).toBe(true)
1069
- expect(config.database.host.get()).toBe('localhost')
1070
- expect(config.database.port.get()).toBe(5432)
1071
- })
1072
- })
1073
-
1074
- 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 }
1080
-
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)
532
+ const nameSignal = user.byKey('name')
533
+ const emailSignal = user.byKey('email')
534
+ const ageSignal = user.byKey('age')
535
+ // @ts-expect-error deliberate access for nonexistent key
536
+ const nonexistentSignal = user.byKey('nonexistent')
1085
537
 
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')
538
+ expect(nameSignal?.get()).toBe('Alice')
539
+ expect(emailSignal?.get()).toBe('alice@example.com')
540
+ expect(ageSignal?.get()).toBe(30)
541
+ expect(nonexistentSignal).toBeUndefined()
1090
542
 
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)
1095
-
1096
- // Modifying the original store should be reflected in the spread signals
1097
- user.name.set('Alice')
1098
- user.age.set(30)
1099
-
1100
- expect(spread.name?.get()).toBe('Alice')
1101
- expect(spread.age?.get()).toBe(30)
543
+ // Verify these are the same signals as property access
544
+ expect(nameSignal).toBe(user.name)
545
+ expect(emailSignal).toBe(user.email)
546
+ expect(ageSignal).toBe(user.age)
1102
547
  })
1103
548
 
1104
- test('spreading nested store works correctly', () => {
1105
- const config = createStore({
1106
- app: { name: 'MyApp', version: '1.0' },
1107
- settings: { theme: 'dark', debug: false },
549
+ test('works with nested stores', () => {
550
+ const app = createStore({
551
+ config: {
552
+ version: '1.0.0',
553
+ },
1108
554
  })
1109
555
 
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)
1115
-
1116
- // Should be able to access nested properties
1117
- expect(spread.app.name.get()).toBe('MyApp')
1118
- expect(spread.settings.theme.get()).toBe('dark')
1119
-
1120
- // Modifications should be reflected
1121
- config.app.name.set('UpdatedApp')
1122
- expect(spread.app.name.get()).toBe('UpdatedApp')
556
+ const configStore = app.byKey('config')
557
+ expect(configStore?.get()).toEqual({ version: '1.0.0' })
558
+ expect(configStore).toBe(app.config)
1123
559
  })
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
560
 
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
- },
561
+ test('is reactive and works with computed signals', () => {
562
+ const user = createStore<{
563
+ name: string
564
+ age: number
565
+ }>({
566
+ name: 'Alice',
567
+ age: 30,
1308
568
  })
1309
569
 
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"',
570
+ const nameSignal = user.byKey('name')
571
+ const displayName = new Memo(() =>
572
+ nameSignal ? `Hello, ${nameSignal.get()}!` : 'Unknown',
1334
573
  )
1335
574
 
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'])
575
+ expect(displayName.get()).toBe('Hello, Alice!')
576
+ nameSignal?.set('Bob')
577
+ expect(displayName.get()).toBe('Hello, Bob!')
1355
578
  })
579
+ })
1356
580
 
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
581
+ describe('UNSET and edge cases', () => {
582
+ test('handles UNSET values', () => {
583
+ const store = createStore({ value: UNSET })
584
+ expect(store.get()).toEqual({ value: UNSET })
1450
585
  })
1451
586
 
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([])
587
+ test('handles primitive values', () => {
588
+ const store = createStore({
589
+ str: 'hello',
590
+ num: 42,
591
+ bool: true,
1638
592
  })
593
+ expect(store.str.get()).toBe('hello')
594
+ expect(store.num.get()).toBe(42)
595
+ expect(store.bool.get()).toBe(true)
1639
596
  })
1640
597
 
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)
598
+ test('handles empty stores correctly', () => {
599
+ const empty = createStore({})
600
+ expect(empty.get()).toEqual({})
1651
601
  })
1652
602
  })
1653
603
 
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,
604
+ describe('JSON integration and serialization', () => {
605
+ test('seamless JSON integration', () => {
606
+ const jsonData = {
607
+ user: { name: 'Alice', preferences: { theme: 'dark' } },
608
+ settings: { timeout: 5000 },
1700
609
  }
610
+ const store = createStore(jsonData)
1701
611
 
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)
612
+ expect(store.user.name.get()).toBe('Alice')
613
+ expect(store.user.preferences.theme.get()).toBe('dark')
614
+ expect(store.settings.timeout.get()).toBe(5000)
1746
615
 
1747
- // Effect should run again after sort
1748
- expect(effectCount).toBe(2)
1749
- expect(lastValue).toEqual([1, 2, 3])
616
+ const serialized = JSON.stringify(store.get())
617
+ const parsed = JSON.parse(serialized)
618
+ expect(parsed).toEqual(jsonData)
1750
619
  })
1751
620
 
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
- })
621
+ test('handles complex nested structures from JSON', () => {
622
+ type Dashboard = {
623
+ dashboard: {
624
+ widgets: Array<{
625
+ id: string
626
+ type: string
627
+ config: { color?: string; rows?: number }
628
+ }>
629
+ }
630
+ }
1776
631
 
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 },
632
+ const complexData: Dashboard = {
633
+ dashboard: {
634
+ widgets: [
635
+ { id: '1', type: 'chart', config: { color: 'blue' } },
636
+ { id: '2', type: 'table', config: { rows: 10 } },
637
+ ],
1793
638
  },
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()
639
+ }
1828
640
 
1829
- // Default sorting converts to strings and compares in UTF-16 order
1830
- expect(items.get()).toEqual(
1831
- ['banana', 'cherry', 'apple', '10', '2'].sort(),
641
+ const store = createStore(complexData)
642
+ expect(store.dashboard.widgets.at(0)?.get().config.color).toBe(
643
+ 'blue',
1832
644
  )
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'])
645
+ expect(store.dashboard.widgets.at(1)?.get().config.rows).toBe(10)
1884
646
  })
1885
647
  })
1886
648
 
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
649
+ describe('type conversion and nested stores', () => {
650
+ test('nested objects become nested stores', () => {
651
+ const config = createStore({
652
+ database: {
653
+ host: 'localhost',
654
+ port: 5432,
655
+ },
1929
656
  })
1930
657
 
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
658
+ expect(isStore(config.database)).toBe(true)
659
+ expect(config.database.host.get()).toBe('localhost')
660
+ expect(config.database.port.get()).toBe(5432)
1980
661
  })
1981
662
  })
1982
663
  })