@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.
- package/.ai-context.md +254 -0
- package/.cursorrules +54 -0
- package/.github/copilot-instructions.md +132 -0
- package/CLAUDE.md +319 -0
- package/README.md +167 -159
- package/eslint.config.js +1 -1
- package/index.dev.js +528 -407
- package/index.js +1 -1
- package/index.ts +36 -25
- package/package.json +1 -1
- package/src/computed.ts +41 -30
- package/src/diff.ts +57 -44
- package/src/effect.ts +15 -16
- package/src/errors.ts +64 -0
- package/src/match.ts +2 -2
- package/src/resolve.ts +2 -2
- package/src/signal.ts +27 -49
- package/src/state.ts +27 -19
- package/src/store.ts +410 -209
- package/src/system.ts +122 -0
- package/src/util.ts +45 -6
- package/test/batch.test.ts +18 -11
- package/test/benchmark.test.ts +4 -4
- package/test/computed.test.ts +508 -72
- package/test/diff.test.ts +321 -4
- package/test/effect.test.ts +61 -61
- package/test/match.test.ts +38 -28
- package/test/resolve.test.ts +16 -16
- package/test/signal.test.ts +19 -147
- package/test/state.test.ts +212 -25
- package/test/store.test.ts +1370 -134
- package/test/util/dependency-graph.ts +1 -1
- package/types/index.d.ts +10 -9
- package/types/src/collection.d.ts +26 -0
- package/types/src/computed.d.ts +9 -9
- package/types/src/diff.d.ts +5 -3
- package/types/src/effect.d.ts +3 -3
- package/types/src/errors.d.ts +22 -0
- package/types/src/match.d.ts +1 -1
- package/types/src/resolve.d.ts +1 -1
- package/types/src/signal.d.ts +12 -19
- package/types/src/state.d.ts +5 -5
- package/types/src/store.d.ts +40 -36
- package/types/src/system.d.ts +44 -0
- package/types/src/util.d.ts +7 -5
- package/index.d.ts +0 -36
- package/src/scheduler.ts +0 -172
- package/types/test-new-effect.d.ts +0 -1
package/test/store.test.ts
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
createComputed,
|
|
4
|
+
createEffect,
|
|
5
|
+
createState,
|
|
6
|
+
createStore,
|
|
5
7
|
isStore,
|
|
6
|
-
type
|
|
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 =
|
|
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 =
|
|
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 =
|
|
31
|
-
const st =
|
|
32
|
-
const c =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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('
|
|
238
|
-
let
|
|
239
|
-
const user =
|
|
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.
|
|
242
|
-
|
|
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(
|
|
252
|
+
expect(addNotification).toBeTruthy()
|
|
249
253
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
250
|
-
expect(
|
|
254
|
+
expect(addNotification!).toEqual({ name: 'Hannah' })
|
|
251
255
|
})
|
|
252
256
|
|
|
253
|
-
test('
|
|
254
|
-
const user =
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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(
|
|
269
|
+
expect(addNotification).toBeTruthy()
|
|
269
270
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
270
|
-
expect(
|
|
271
|
+
expect(addNotification!).toEqual({
|
|
271
272
|
email: 'hannah@example.com',
|
|
272
273
|
})
|
|
273
274
|
})
|
|
274
275
|
|
|
275
|
-
test('
|
|
276
|
-
const user =
|
|
276
|
+
test('emits a change notification for property changes', () => {
|
|
277
|
+
const user = createStore({ name: 'Hannah' })
|
|
277
278
|
|
|
278
|
-
let
|
|
279
|
-
user.
|
|
280
|
-
|
|
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(
|
|
286
|
+
expect(changeNotification).toBeTruthy()
|
|
286
287
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
287
|
-
expect(
|
|
288
|
+
expect(changeNotification!).toEqual({
|
|
288
289
|
name: 'Alice',
|
|
289
290
|
})
|
|
290
291
|
})
|
|
291
292
|
|
|
292
|
-
test('
|
|
293
|
-
const user =
|
|
293
|
+
test('emits a change notification for signal changes', () => {
|
|
294
|
+
const user = createStore({ name: 'Hannah' })
|
|
294
295
|
|
|
295
|
-
let
|
|
296
|
-
user.
|
|
297
|
-
|
|
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(
|
|
303
|
+
expect(changeNotification).toBeTruthy()
|
|
303
304
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
304
|
-
expect(
|
|
305
|
+
expect(changeNotification!).toEqual({
|
|
305
306
|
name: 'Bob',
|
|
306
307
|
})
|
|
307
308
|
})
|
|
308
309
|
|
|
309
|
-
test('
|
|
310
|
-
const user =
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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(
|
|
507
|
+
expect(removeNotification).toBeTruthy()
|
|
326
508
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
327
|
-
expect(
|
|
509
|
+
expect(removeNotification!.email).toBe(UNSET)
|
|
328
510
|
})
|
|
329
511
|
|
|
330
|
-
test('
|
|
331
|
-
const user =
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
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(
|
|
524
|
+
expect(addNotification).toBeTruthy()
|
|
346
525
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
347
|
-
expect(
|
|
526
|
+
expect(addNotification!).toEqual({
|
|
348
527
|
email: 'hannah@example.com',
|
|
349
528
|
})
|
|
350
529
|
})
|
|
351
530
|
|
|
352
|
-
test('can remove
|
|
353
|
-
const user =
|
|
531
|
+
test('can remove notification listeners', () => {
|
|
532
|
+
const user = createStore({ name: 'Hannah' })
|
|
354
533
|
|
|
355
|
-
let
|
|
534
|
+
let notificationCount = 0
|
|
356
535
|
const listener = () => {
|
|
357
|
-
|
|
536
|
+
notificationCount++
|
|
358
537
|
}
|
|
359
538
|
|
|
360
|
-
user.
|
|
539
|
+
const off = user.on('change', listener)
|
|
361
540
|
user.name.set('Alice')
|
|
362
|
-
expect(
|
|
541
|
+
expect(notificationCount).toBe(1)
|
|
363
542
|
|
|
364
|
-
|
|
543
|
+
off()
|
|
365
544
|
user.name.set('Bob')
|
|
366
|
-
expect(
|
|
545
|
+
expect(notificationCount).toBe(1) // Should not increment
|
|
367
546
|
})
|
|
368
547
|
|
|
369
|
-
test('supports multiple
|
|
370
|
-
const user =
|
|
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.
|
|
554
|
+
user.on('change', () => {
|
|
376
555
|
listener1Called = true
|
|
377
556
|
})
|
|
378
557
|
|
|
379
|
-
user.
|
|
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 =
|
|
571
|
+
const user = createStore({
|
|
572
|
+
name: 'Hannah',
|
|
573
|
+
email: 'hannah@example.com',
|
|
574
|
+
})
|
|
393
575
|
let lastValue = { name: '', email: '' }
|
|
394
576
|
|
|
395
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
612
|
+
const user = createStore({
|
|
428
613
|
preferences: {
|
|
429
614
|
theme: 'dark',
|
|
430
615
|
},
|
|
431
616
|
})
|
|
432
617
|
let effectRuns = 0
|
|
433
618
|
|
|
434
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
702
|
+
const user = createStore({ firstName: 'Hannah', lastName: 'Smith' })
|
|
513
703
|
|
|
514
|
-
const fullName =
|
|
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 =
|
|
715
|
+
const config = createStore({
|
|
526
716
|
ui: {
|
|
527
717
|
theme: 'dark',
|
|
528
718
|
},
|
|
529
719
|
})
|
|
530
720
|
|
|
531
|
-
const themeDisplay =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
})
|