@zeix/cause-effect 0.15.2 → 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 +136 -166
- package/eslint.config.js +1 -1
- package/index.dev.js +125 -129
- package/index.js +1 -1
- package/index.ts +22 -22
- package/package.json +1 -1
- package/src/computed.ts +40 -29
- package/src/effect.ts +15 -12
- package/src/errors.ts +8 -0
- package/src/signal.ts +6 -6
- package/src/state.ts +27 -20
- package/src/store.ts +99 -121
- package/src/system.ts +122 -0
- package/src/util.ts +1 -6
- package/test/batch.test.ts +18 -11
- package/test/benchmark.test.ts +4 -4
- package/test/computed.test.ts +507 -71
- package/test/effect.test.ts +60 -60
- package/test/match.test.ts +25 -25
- package/test/resolve.test.ts +16 -16
- package/test/signal.test.ts +7 -7
- package/test/state.test.ts +212 -25
- package/test/store.test.ts +476 -183
- package/test/util/dependency-graph.ts +1 -1
- package/types/index.d.ts +8 -8
- package/types/src/collection.d.ts +26 -0
- package/types/src/computed.d.ts +9 -9
- package/types/src/effect.d.ts +3 -3
- package/types/src/errors.d.ts +4 -1
- package/types/src/state.d.ts +5 -5
- package/types/src/store.d.ts +27 -41
- package/types/src/system.d.ts +44 -0
- package/types/src/util.d.ts +1 -2
- package/src/scheduler.ts +0 -172
package/test/store.test.ts
CHANGED
|
@@ -1,36 +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
8
|
type State,
|
|
7
|
-
type StoreAddEvent,
|
|
8
|
-
type StoreChangeEvent,
|
|
9
|
-
type StoreRemoveEvent,
|
|
10
|
-
type StoreSortEvent,
|
|
11
|
-
state,
|
|
12
|
-
store,
|
|
13
9
|
UNSET,
|
|
14
10
|
} from '..'
|
|
15
11
|
|
|
16
12
|
describe('store', () => {
|
|
17
13
|
describe('creation and basic operations', () => {
|
|
18
14
|
test('creates a store with initial values', () => {
|
|
19
|
-
const user =
|
|
15
|
+
const user = createStore({
|
|
16
|
+
name: 'Hannah',
|
|
17
|
+
email: 'hannah@example.com',
|
|
18
|
+
})
|
|
20
19
|
|
|
21
20
|
expect(user.name.get()).toBe('Hannah')
|
|
22
21
|
expect(user.email.get()).toBe('hannah@example.com')
|
|
23
22
|
})
|
|
24
23
|
|
|
25
24
|
test('has Symbol.toStringTag of Store', () => {
|
|
26
|
-
const s =
|
|
25
|
+
const s = createStore({ a: 1 })
|
|
27
26
|
expect(s[Symbol.toStringTag]).toBe('Store')
|
|
28
27
|
})
|
|
29
28
|
|
|
30
29
|
test('isStore identifies store instances correctly', () => {
|
|
31
|
-
const s =
|
|
32
|
-
const st =
|
|
33
|
-
const c =
|
|
30
|
+
const s = createStore({ a: 1 })
|
|
31
|
+
const st = createState(1)
|
|
32
|
+
const c = createComputed(() => 1)
|
|
34
33
|
|
|
35
34
|
expect(isStore(s)).toBe(true)
|
|
36
35
|
expect(isStore(st)).toBe(false)
|
|
@@ -40,14 +39,19 @@ describe('store', () => {
|
|
|
40
39
|
})
|
|
41
40
|
|
|
42
41
|
test('get() returns the complete store value', () => {
|
|
43
|
-
const user =
|
|
42
|
+
const user = createStore({
|
|
43
|
+
name: 'Hannah',
|
|
44
|
+
email: 'hannah@example.com',
|
|
45
|
+
})
|
|
44
46
|
|
|
45
47
|
expect(user.get()).toEqual({
|
|
46
48
|
name: 'Hannah',
|
|
47
49
|
email: 'hannah@example.com',
|
|
48
50
|
})
|
|
49
51
|
|
|
50
|
-
const participants =
|
|
52
|
+
const participants = createStore<
|
|
53
|
+
{ name: string; tags: string[] }[]
|
|
54
|
+
>([
|
|
51
55
|
{ name: 'Alice', tags: ['friends', 'mates'] },
|
|
52
56
|
{ name: 'Bob', tags: ['friends'] },
|
|
53
57
|
])
|
|
@@ -60,7 +64,7 @@ describe('store', () => {
|
|
|
60
64
|
|
|
61
65
|
describe('proxy data access and modification', () => {
|
|
62
66
|
test('properties can be accessed and modified via signals', () => {
|
|
63
|
-
const user =
|
|
67
|
+
const user = createStore({ name: 'Hannah', age: 25 })
|
|
64
68
|
|
|
65
69
|
// Get signals from store proxy
|
|
66
70
|
expect(user.name.get()).toBe('Hannah')
|
|
@@ -75,14 +79,14 @@ describe('store', () => {
|
|
|
75
79
|
})
|
|
76
80
|
|
|
77
81
|
test('returns undefined for non-existent properties', () => {
|
|
78
|
-
const user =
|
|
82
|
+
const user = createStore({ name: 'Hannah' })
|
|
79
83
|
|
|
80
84
|
// @ts-expect-error accessing non-existent property
|
|
81
85
|
expect(user.nonExistent).toBeUndefined()
|
|
82
86
|
})
|
|
83
87
|
|
|
84
88
|
test('supports numeric key access', () => {
|
|
85
|
-
const items =
|
|
89
|
+
const items = createStore({ '0': 'first', '1': 'second' })
|
|
86
90
|
|
|
87
91
|
expect(items[0].get()).toBe('first')
|
|
88
92
|
expect(items['0'].get()).toBe('first')
|
|
@@ -91,7 +95,7 @@ describe('store', () => {
|
|
|
91
95
|
})
|
|
92
96
|
|
|
93
97
|
test('can add new properties via add method', () => {
|
|
94
|
-
const user =
|
|
98
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
95
99
|
name: 'Hannah',
|
|
96
100
|
})
|
|
97
101
|
|
|
@@ -105,7 +109,7 @@ describe('store', () => {
|
|
|
105
109
|
})
|
|
106
110
|
|
|
107
111
|
test('can remove existing properties via remove method', () => {
|
|
108
|
-
const user =
|
|
112
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
109
113
|
name: 'Hannah',
|
|
110
114
|
email: 'hannah@example.com',
|
|
111
115
|
})
|
|
@@ -121,7 +125,7 @@ describe('store', () => {
|
|
|
121
125
|
})
|
|
122
126
|
|
|
123
127
|
test('add method prevents null values', () => {
|
|
124
|
-
const user =
|
|
128
|
+
const user = createStore<{ name: string; tags?: string[] }>({
|
|
125
129
|
name: 'Alice',
|
|
126
130
|
})
|
|
127
131
|
|
|
@@ -136,7 +140,7 @@ describe('store', () => {
|
|
|
136
140
|
|
|
137
141
|
describe('nested stores', () => {
|
|
138
142
|
test('creates nested stores for object properties', () => {
|
|
139
|
-
const user =
|
|
143
|
+
const user = createStore({
|
|
140
144
|
name: 'Hannah',
|
|
141
145
|
preferences: {
|
|
142
146
|
theme: 'dark',
|
|
@@ -150,7 +154,7 @@ describe('store', () => {
|
|
|
150
154
|
})
|
|
151
155
|
|
|
152
156
|
test('nested properties are reactive', () => {
|
|
153
|
-
const user =
|
|
157
|
+
const user = createStore({
|
|
154
158
|
preferences: {
|
|
155
159
|
theme: 'dark',
|
|
156
160
|
},
|
|
@@ -162,7 +166,7 @@ describe('store', () => {
|
|
|
162
166
|
})
|
|
163
167
|
|
|
164
168
|
test('deeply nested stores work correctly', () => {
|
|
165
|
-
const config =
|
|
169
|
+
const config = createStore({
|
|
166
170
|
ui: {
|
|
167
171
|
theme: {
|
|
168
172
|
colors: {
|
|
@@ -180,7 +184,10 @@ describe('store', () => {
|
|
|
180
184
|
|
|
181
185
|
describe('set() and update() methods', () => {
|
|
182
186
|
test('set() replaces entire store value', () => {
|
|
183
|
-
const user =
|
|
187
|
+
const user = createStore({
|
|
188
|
+
name: 'Hannah',
|
|
189
|
+
email: 'hannah@example.com',
|
|
190
|
+
})
|
|
184
191
|
|
|
185
192
|
user.set({ name: 'Alice', email: 'alice@example.com' })
|
|
186
193
|
|
|
@@ -191,7 +198,7 @@ describe('store', () => {
|
|
|
191
198
|
})
|
|
192
199
|
|
|
193
200
|
test('update() modifies store using function', () => {
|
|
194
|
-
const user =
|
|
201
|
+
const user = createStore({ name: 'Hannah', age: 25 })
|
|
195
202
|
|
|
196
203
|
user.update(prev => ({ ...prev, age: prev.age + 1 }))
|
|
197
204
|
|
|
@@ -204,7 +211,7 @@ describe('store', () => {
|
|
|
204
211
|
|
|
205
212
|
describe('iterator protocol', () => {
|
|
206
213
|
test('supports for...of iteration', () => {
|
|
207
|
-
const user =
|
|
214
|
+
const user = createStore({ name: 'Hannah', age: 25 })
|
|
208
215
|
const entries: Array<[string, unknown & {}]> = []
|
|
209
216
|
|
|
210
217
|
for (const [key, signal] of user) {
|
|
@@ -218,7 +225,7 @@ describe('store', () => {
|
|
|
218
225
|
|
|
219
226
|
describe('change tracking', () => {
|
|
220
227
|
test('tracks size changes', () => {
|
|
221
|
-
const user =
|
|
228
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
222
229
|
name: 'Hannah',
|
|
223
230
|
})
|
|
224
231
|
|
|
@@ -231,149 +238,324 @@ describe('store', () => {
|
|
|
231
238
|
expect(user.size.get()).toBe(1)
|
|
232
239
|
})
|
|
233
240
|
|
|
234
|
-
test('
|
|
235
|
-
let
|
|
236
|
-
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' })
|
|
237
244
|
|
|
238
|
-
user.
|
|
239
|
-
|
|
245
|
+
user.on('add', change => {
|
|
246
|
+
addNotification = change
|
|
240
247
|
})
|
|
241
248
|
|
|
242
249
|
// Wait for the async initial event
|
|
243
250
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
244
251
|
|
|
245
|
-
expect(
|
|
252
|
+
expect(addNotification).toBeTruthy()
|
|
246
253
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
247
|
-
expect(
|
|
254
|
+
expect(addNotification!).toEqual({ name: 'Hannah' })
|
|
248
255
|
})
|
|
249
256
|
|
|
250
|
-
test('
|
|
251
|
-
const user =
|
|
257
|
+
test('emits an add notification for new properties', () => {
|
|
258
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
252
259
|
name: 'Hannah',
|
|
253
260
|
})
|
|
254
261
|
|
|
255
|
-
let
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}> | null = null
|
|
259
|
-
user.addEventListener('store-add', event => {
|
|
260
|
-
addEvent = event
|
|
262
|
+
let addNotification: Record<string, string> | null = null
|
|
263
|
+
user.on('add', change => {
|
|
264
|
+
addNotification = change
|
|
261
265
|
})
|
|
262
266
|
|
|
263
267
|
user.update(v => ({ ...v, email: 'hannah@example.com' }))
|
|
264
268
|
|
|
265
|
-
expect(
|
|
269
|
+
expect(addNotification).toBeTruthy()
|
|
266
270
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
267
|
-
expect(
|
|
271
|
+
expect(addNotification!).toEqual({
|
|
268
272
|
email: 'hannah@example.com',
|
|
269
273
|
})
|
|
270
274
|
})
|
|
271
275
|
|
|
272
|
-
test('
|
|
273
|
-
const user =
|
|
276
|
+
test('emits a change notification for property changes', () => {
|
|
277
|
+
const user = createStore({ name: 'Hannah' })
|
|
274
278
|
|
|
275
|
-
let
|
|
276
|
-
user.
|
|
277
|
-
|
|
279
|
+
let changeNotification: Record<string, string> | null = null
|
|
280
|
+
user.on('change', change => {
|
|
281
|
+
changeNotification = change
|
|
278
282
|
})
|
|
279
283
|
|
|
280
284
|
user.set({ name: 'Alice' })
|
|
281
285
|
|
|
282
|
-
expect(
|
|
286
|
+
expect(changeNotification).toBeTruthy()
|
|
283
287
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
284
|
-
expect(
|
|
288
|
+
expect(changeNotification!).toEqual({
|
|
285
289
|
name: 'Alice',
|
|
286
290
|
})
|
|
287
291
|
})
|
|
288
292
|
|
|
289
|
-
test('
|
|
290
|
-
const user =
|
|
293
|
+
test('emits a change notification for signal changes', () => {
|
|
294
|
+
const user = createStore({ name: 'Hannah' })
|
|
291
295
|
|
|
292
|
-
let
|
|
293
|
-
user.
|
|
294
|
-
|
|
296
|
+
let changeNotification: Record<string, string> | null = null
|
|
297
|
+
user.on('change', change => {
|
|
298
|
+
changeNotification = change
|
|
295
299
|
})
|
|
296
300
|
|
|
297
301
|
user.name.set('Bob')
|
|
298
302
|
|
|
299
|
-
expect(
|
|
303
|
+
expect(changeNotification).toBeTruthy()
|
|
300
304
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
301
|
-
expect(
|
|
305
|
+
expect(changeNotification!).toEqual({
|
|
302
306
|
name: 'Bob',
|
|
303
307
|
})
|
|
304
308
|
})
|
|
305
309
|
|
|
306
|
-
test('
|
|
307
|
-
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
|
+
}>({
|
|
308
413
|
name: 'Hannah',
|
|
309
414
|
email: 'hannah@example.com',
|
|
415
|
+
preferences: {
|
|
416
|
+
theme: 'dark',
|
|
417
|
+
},
|
|
310
418
|
})
|
|
311
419
|
|
|
312
|
-
let
|
|
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
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// Should emit change notification for changed properties
|
|
445
|
+
expect(changeNotification).toEqual({
|
|
446
|
+
name: 'Alice',
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// Should emit add notification for new properties
|
|
450
|
+
expect(addNotification).toEqual({
|
|
451
|
+
age: 30,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// Should emit remove notification for removed properties
|
|
455
|
+
expect(removeNotification).toEqual({
|
|
456
|
+
email: expect.anything(),
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('set() with only removals emits only remove notifications', () => {
|
|
461
|
+
const user = createStore<{
|
|
313
462
|
name: string
|
|
314
463
|
email?: string
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
503
|
})
|
|
319
504
|
|
|
320
505
|
user.remove('email')
|
|
321
506
|
|
|
322
|
-
expect(
|
|
507
|
+
expect(removeNotification).toBeTruthy()
|
|
323
508
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
324
|
-
expect(
|
|
509
|
+
expect(removeNotification!.email).toBe(UNSET)
|
|
325
510
|
})
|
|
326
511
|
|
|
327
|
-
test('
|
|
328
|
-
const user =
|
|
512
|
+
test('emits an add notification when using add method', () => {
|
|
513
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
329
514
|
name: 'Hannah',
|
|
330
515
|
})
|
|
331
516
|
|
|
332
|
-
let
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}> | null = null
|
|
336
|
-
user.addEventListener('store-add', event => {
|
|
337
|
-
addEvent = event
|
|
517
|
+
let addNotification: Record<string, string> | null = null
|
|
518
|
+
user.on('add', change => {
|
|
519
|
+
addNotification = change
|
|
338
520
|
})
|
|
339
521
|
|
|
340
522
|
user.add('email', 'hannah@example.com')
|
|
341
523
|
|
|
342
|
-
expect(
|
|
524
|
+
expect(addNotification).toBeTruthy()
|
|
343
525
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
344
|
-
expect(
|
|
526
|
+
expect(addNotification!).toEqual({
|
|
345
527
|
email: 'hannah@example.com',
|
|
346
528
|
})
|
|
347
529
|
})
|
|
348
530
|
|
|
349
|
-
test('can remove
|
|
350
|
-
const user =
|
|
531
|
+
test('can remove notification listeners', () => {
|
|
532
|
+
const user = createStore({ name: 'Hannah' })
|
|
351
533
|
|
|
352
|
-
let
|
|
534
|
+
let notificationCount = 0
|
|
353
535
|
const listener = () => {
|
|
354
|
-
|
|
536
|
+
notificationCount++
|
|
355
537
|
}
|
|
356
538
|
|
|
357
|
-
user.
|
|
539
|
+
const off = user.on('change', listener)
|
|
358
540
|
user.name.set('Alice')
|
|
359
|
-
expect(
|
|
541
|
+
expect(notificationCount).toBe(1)
|
|
360
542
|
|
|
361
|
-
|
|
543
|
+
off()
|
|
362
544
|
user.name.set('Bob')
|
|
363
|
-
expect(
|
|
545
|
+
expect(notificationCount).toBe(1) // Should not increment
|
|
364
546
|
})
|
|
365
547
|
|
|
366
|
-
test('supports multiple
|
|
367
|
-
const user =
|
|
548
|
+
test('supports multiple notification listeners for the same type', () => {
|
|
549
|
+
const user = createStore({ name: 'Hannah' })
|
|
368
550
|
|
|
369
551
|
let listener1Called = false
|
|
370
552
|
let listener2Called = false
|
|
371
553
|
|
|
372
|
-
user.
|
|
554
|
+
user.on('change', () => {
|
|
373
555
|
listener1Called = true
|
|
374
556
|
})
|
|
375
557
|
|
|
376
|
-
user.
|
|
558
|
+
user.on('change', () => {
|
|
377
559
|
listener2Called = true
|
|
378
560
|
})
|
|
379
561
|
|
|
@@ -386,10 +568,13 @@ describe('store', () => {
|
|
|
386
568
|
|
|
387
569
|
describe('reactivity', () => {
|
|
388
570
|
test('store-level get() is reactive', () => {
|
|
389
|
-
const user =
|
|
571
|
+
const user = createStore({
|
|
572
|
+
name: 'Hannah',
|
|
573
|
+
email: 'hannah@example.com',
|
|
574
|
+
})
|
|
390
575
|
let lastValue = { name: '', email: '' }
|
|
391
576
|
|
|
392
|
-
|
|
577
|
+
createEffect(() => {
|
|
393
578
|
lastValue = user.get()
|
|
394
579
|
})
|
|
395
580
|
|
|
@@ -402,14 +587,17 @@ describe('store', () => {
|
|
|
402
587
|
})
|
|
403
588
|
|
|
404
589
|
test('individual signal reactivity works', () => {
|
|
405
|
-
const user =
|
|
590
|
+
const user = createStore({
|
|
591
|
+
name: 'Hannah',
|
|
592
|
+
email: 'hannah@example.com',
|
|
593
|
+
})
|
|
406
594
|
let lastName = ''
|
|
407
595
|
let nameEffectRuns = 0
|
|
408
596
|
|
|
409
597
|
// Get signal for name property directly
|
|
410
598
|
const nameSignal = user.name
|
|
411
599
|
|
|
412
|
-
|
|
600
|
+
createEffect(() => {
|
|
413
601
|
lastName = nameSignal.get()
|
|
414
602
|
nameEffectRuns++
|
|
415
603
|
})
|
|
@@ -421,14 +609,14 @@ describe('store', () => {
|
|
|
421
609
|
})
|
|
422
610
|
|
|
423
611
|
test('nested store changes propagate to parent', () => {
|
|
424
|
-
const user =
|
|
612
|
+
const user = createStore({
|
|
425
613
|
preferences: {
|
|
426
614
|
theme: 'dark',
|
|
427
615
|
},
|
|
428
616
|
})
|
|
429
617
|
let effectRuns = 0
|
|
430
618
|
|
|
431
|
-
|
|
619
|
+
createEffect(() => {
|
|
432
620
|
user.get() // Watch entire store
|
|
433
621
|
effectRuns++
|
|
434
622
|
})
|
|
@@ -438,13 +626,13 @@ describe('store', () => {
|
|
|
438
626
|
})
|
|
439
627
|
|
|
440
628
|
test('updates are reactive', () => {
|
|
441
|
-
const user =
|
|
629
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
442
630
|
name: 'Hannah',
|
|
443
631
|
})
|
|
444
632
|
let lastValue = {}
|
|
445
633
|
let effectRuns = 0
|
|
446
634
|
|
|
447
|
-
|
|
635
|
+
createEffect(() => {
|
|
448
636
|
lastValue = user.get()
|
|
449
637
|
effectRuns++
|
|
450
638
|
})
|
|
@@ -458,14 +646,14 @@ describe('store', () => {
|
|
|
458
646
|
})
|
|
459
647
|
|
|
460
648
|
test('remove method is reactive', () => {
|
|
461
|
-
const user =
|
|
649
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
462
650
|
name: 'Hannah',
|
|
463
651
|
email: 'hannah@example.com',
|
|
464
652
|
})
|
|
465
653
|
let lastValue = {}
|
|
466
654
|
let effectRuns = 0
|
|
467
655
|
|
|
468
|
-
|
|
656
|
+
createEffect(() => {
|
|
469
657
|
lastValue = user.get()
|
|
470
658
|
effectRuns++
|
|
471
659
|
})
|
|
@@ -480,7 +668,7 @@ describe('store', () => {
|
|
|
480
668
|
})
|
|
481
669
|
|
|
482
670
|
test('add method does not overwrite existing properties', () => {
|
|
483
|
-
const user =
|
|
671
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
484
672
|
name: 'Hannah',
|
|
485
673
|
email: 'original@example.com',
|
|
486
674
|
})
|
|
@@ -498,7 +686,7 @@ describe('store', () => {
|
|
|
498
686
|
})
|
|
499
687
|
|
|
500
688
|
test('remove method has no effect on non-existent properties', () => {
|
|
501
|
-
const user =
|
|
689
|
+
const user = createStore<{ name: string; email?: string }>({
|
|
502
690
|
name: 'Hannah',
|
|
503
691
|
})
|
|
504
692
|
|
|
@@ -511,9 +699,9 @@ describe('store', () => {
|
|
|
511
699
|
|
|
512
700
|
describe('computed integration', () => {
|
|
513
701
|
test('works with computed signals', () => {
|
|
514
|
-
const user =
|
|
702
|
+
const user = createStore({ firstName: 'Hannah', lastName: 'Smith' })
|
|
515
703
|
|
|
516
|
-
const fullName =
|
|
704
|
+
const fullName = createComputed(() => {
|
|
517
705
|
return `${user.firstName.get()} ${user.lastName.get()}`
|
|
518
706
|
})
|
|
519
707
|
|
|
@@ -524,13 +712,13 @@ describe('store', () => {
|
|
|
524
712
|
})
|
|
525
713
|
|
|
526
714
|
test('computed reacts to nested store changes', () => {
|
|
527
|
-
const config =
|
|
715
|
+
const config = createStore({
|
|
528
716
|
ui: {
|
|
529
717
|
theme: 'dark',
|
|
530
718
|
},
|
|
531
719
|
})
|
|
532
720
|
|
|
533
|
-
const themeDisplay =
|
|
721
|
+
const themeDisplay = createComputed(() => {
|
|
534
722
|
return `Theme: ${config.ui.theme.get()}`
|
|
535
723
|
})
|
|
536
724
|
|
|
@@ -544,11 +732,11 @@ describe('store', () => {
|
|
|
544
732
|
describe('array-derived stores with computed sum', () => {
|
|
545
733
|
test('computes sum correctly and updates when items are added, removed, or changed', () => {
|
|
546
734
|
// Create a store with an array of numbers
|
|
547
|
-
const numbers =
|
|
735
|
+
const numbers = createStore([1, 2, 3, 4, 5])
|
|
548
736
|
|
|
549
737
|
// Create a computed that calculates the sum by accessing the array via .get()
|
|
550
738
|
// This ensures reactivity to both value changes and structural changes
|
|
551
|
-
const sum =
|
|
739
|
+
const sum = createComputed(() => {
|
|
552
740
|
const array = numbers.get()
|
|
553
741
|
if (!Array.isArray(array)) return 0
|
|
554
742
|
return array.reduce((acc, num) => acc + num, 0)
|
|
@@ -592,9 +780,9 @@ describe('store', () => {
|
|
|
592
780
|
|
|
593
781
|
test('handles empty array and single element operations', () => {
|
|
594
782
|
// Start with empty array
|
|
595
|
-
const numbers =
|
|
783
|
+
const numbers = createStore<number[]>([])
|
|
596
784
|
|
|
597
|
-
const sum =
|
|
785
|
+
const sum = createComputed(() => {
|
|
598
786
|
const array = numbers.get()
|
|
599
787
|
if (!Array.isArray(array)) return 0
|
|
600
788
|
return array.reduce((acc, num) => acc + num, 0)
|
|
@@ -620,10 +808,10 @@ describe('store', () => {
|
|
|
620
808
|
})
|
|
621
809
|
|
|
622
810
|
test('computed sum using store iteration with size tracking', () => {
|
|
623
|
-
const numbers =
|
|
811
|
+
const numbers = createStore([10, 20, 30])
|
|
624
812
|
|
|
625
813
|
// Use iteration but also track size to ensure reactivity to additions/removals
|
|
626
|
-
const sum =
|
|
814
|
+
const sum = createComputed(() => {
|
|
627
815
|
// Access size to subscribe to structural changes
|
|
628
816
|
numbers.size.get()
|
|
629
817
|
let total = 0
|
|
@@ -650,10 +838,10 @@ describe('store', () => {
|
|
|
650
838
|
|
|
651
839
|
test('demonstrates array compaction behavior with remove operations', () => {
|
|
652
840
|
// Create a store with an array
|
|
653
|
-
const numbers =
|
|
841
|
+
const numbers = createStore([10, 20, 30, 40, 50])
|
|
654
842
|
|
|
655
843
|
// Create a computed using iteration approach with size tracking
|
|
656
|
-
const sumWithIteration =
|
|
844
|
+
const sumWithIteration = createComputed(() => {
|
|
657
845
|
// Access size to subscribe to structural changes
|
|
658
846
|
numbers.size.get()
|
|
659
847
|
let total = 0
|
|
@@ -664,7 +852,7 @@ describe('store', () => {
|
|
|
664
852
|
})
|
|
665
853
|
|
|
666
854
|
// Create a computed using .get() approach for comparison
|
|
667
|
-
const sumWithGet =
|
|
855
|
+
const sumWithGet = createComputed(() => {
|
|
668
856
|
const array = numbers.get()
|
|
669
857
|
if (!Array.isArray(array)) return 0
|
|
670
858
|
return array.reduce((acc, num) => acc + num, 0)
|
|
@@ -700,7 +888,7 @@ describe('store', () => {
|
|
|
700
888
|
|
|
701
889
|
test('verifies root cause: diff works on array representation but reconcile uses sparse keys', () => {
|
|
702
890
|
// Create a sparse array scenario
|
|
703
|
-
const numbers =
|
|
891
|
+
const numbers = createStore([10, 20, 30])
|
|
704
892
|
|
|
705
893
|
// Remove middle element to create sparse structure
|
|
706
894
|
numbers.remove(1) // Now has keys ["0", "2"] with values [10, 30]
|
|
@@ -726,7 +914,7 @@ describe('store', () => {
|
|
|
726
914
|
|
|
727
915
|
describe('arrays and edge cases', () => {
|
|
728
916
|
test('handles arrays as store values', () => {
|
|
729
|
-
const data =
|
|
917
|
+
const data = createStore({ items: [1, 2, 3] })
|
|
730
918
|
|
|
731
919
|
// Arrays become stores with string indices
|
|
732
920
|
expect(isStore(data.items)).toBe(true)
|
|
@@ -736,7 +924,7 @@ describe('store', () => {
|
|
|
736
924
|
})
|
|
737
925
|
|
|
738
926
|
test('array-derived nested stores have correct type inference', () => {
|
|
739
|
-
const todoApp =
|
|
927
|
+
const todoApp = createStore({
|
|
740
928
|
todos: ['Buy milk', 'Walk the dog', 'Write code'],
|
|
741
929
|
users: [
|
|
742
930
|
{ name: 'Alice', active: true },
|
|
@@ -795,7 +983,7 @@ describe('store', () => {
|
|
|
795
983
|
})
|
|
796
984
|
|
|
797
985
|
test('handles UNSET values', () => {
|
|
798
|
-
const data =
|
|
986
|
+
const data = createStore({ value: UNSET as string })
|
|
799
987
|
|
|
800
988
|
expect(data.value.get()).toBe(UNSET)
|
|
801
989
|
data.value.set('some string')
|
|
@@ -803,7 +991,7 @@ describe('store', () => {
|
|
|
803
991
|
})
|
|
804
992
|
|
|
805
993
|
test('handles primitive values', () => {
|
|
806
|
-
const data =
|
|
994
|
+
const data = createStore({
|
|
807
995
|
str: 'hello',
|
|
808
996
|
num: 42,
|
|
809
997
|
bool: true,
|
|
@@ -817,13 +1005,19 @@ describe('store', () => {
|
|
|
817
1005
|
|
|
818
1006
|
describe('proxy behavior', () => {
|
|
819
1007
|
test('Object.keys returns property keys', () => {
|
|
820
|
-
const user =
|
|
1008
|
+
const user = createStore({
|
|
1009
|
+
name: 'Hannah',
|
|
1010
|
+
email: 'hannah@example.com',
|
|
1011
|
+
})
|
|
821
1012
|
|
|
822
1013
|
expect(Object.keys(user)).toEqual(['name', 'email'])
|
|
823
1014
|
})
|
|
824
1015
|
|
|
825
1016
|
test('property enumeration works', () => {
|
|
826
|
-
const user =
|
|
1017
|
+
const user = createStore({
|
|
1018
|
+
name: 'Hannah',
|
|
1019
|
+
email: 'hannah@example.com',
|
|
1020
|
+
})
|
|
827
1021
|
const keys: string[] = []
|
|
828
1022
|
|
|
829
1023
|
for (const key in user) {
|
|
@@ -834,14 +1028,14 @@ describe('store', () => {
|
|
|
834
1028
|
})
|
|
835
1029
|
|
|
836
1030
|
test('in operator works', () => {
|
|
837
|
-
const user =
|
|
1031
|
+
const user = createStore({ name: 'Hannah' })
|
|
838
1032
|
|
|
839
1033
|
expect('name' in user).toBe(true)
|
|
840
1034
|
expect('email' in user).toBe(false)
|
|
841
1035
|
})
|
|
842
1036
|
|
|
843
1037
|
test('Object.getOwnPropertyDescriptor works', () => {
|
|
844
|
-
const user =
|
|
1038
|
+
const user = createStore({ name: 'Hannah' })
|
|
845
1039
|
|
|
846
1040
|
const descriptor = Object.getOwnPropertyDescriptor(user, 'name')
|
|
847
1041
|
expect(descriptor).toEqual({
|
|
@@ -855,7 +1049,7 @@ describe('store', () => {
|
|
|
855
1049
|
|
|
856
1050
|
describe('type conversion via toSignal', () => {
|
|
857
1051
|
test('arrays are converted to stores', () => {
|
|
858
|
-
const fruits =
|
|
1052
|
+
const fruits = createStore({ items: ['apple', 'banana', 'cherry'] })
|
|
859
1053
|
|
|
860
1054
|
expect(isStore(fruits.items)).toBe(true)
|
|
861
1055
|
expect(fruits.items['0'].get()).toBe('apple')
|
|
@@ -864,7 +1058,7 @@ describe('store', () => {
|
|
|
864
1058
|
})
|
|
865
1059
|
|
|
866
1060
|
test('nested objects become nested stores', () => {
|
|
867
|
-
const config =
|
|
1061
|
+
const config = createStore({
|
|
868
1062
|
database: {
|
|
869
1063
|
host: 'localhost',
|
|
870
1064
|
port: 5432,
|
|
@@ -879,7 +1073,7 @@ describe('store', () => {
|
|
|
879
1073
|
|
|
880
1074
|
describe('spread operator behavior', () => {
|
|
881
1075
|
test('spreading store spreads individual signals', () => {
|
|
882
|
-
const user =
|
|
1076
|
+
const user = createStore({ name: 'Hannah', age: 25, active: true })
|
|
883
1077
|
|
|
884
1078
|
// Spread the store - should get individual signals
|
|
885
1079
|
const spread = { ...user }
|
|
@@ -908,7 +1102,7 @@ describe('store', () => {
|
|
|
908
1102
|
})
|
|
909
1103
|
|
|
910
1104
|
test('spreading nested store works correctly', () => {
|
|
911
|
-
const config =
|
|
1105
|
+
const config = createStore({
|
|
912
1106
|
app: { name: 'MyApp', version: '1.0' },
|
|
913
1107
|
settings: { theme: 'dark', debug: false },
|
|
914
1108
|
})
|
|
@@ -952,7 +1146,7 @@ describe('store', () => {
|
|
|
952
1146
|
|
|
953
1147
|
// Parse JSON and create store - works seamlessly
|
|
954
1148
|
const apiData = JSON.parse(jsonResponse)
|
|
955
|
-
const userStore =
|
|
1149
|
+
const userStore = createStore<{
|
|
956
1150
|
user: {
|
|
957
1151
|
id: number
|
|
958
1152
|
name: string
|
|
@@ -1075,7 +1269,7 @@ describe('store', () => {
|
|
|
1075
1269
|
const data = JSON.parse(complexJson)
|
|
1076
1270
|
|
|
1077
1271
|
// Test that null values in initial JSON are filtered out (treated as UNSET)
|
|
1078
|
-
const dashboardStore =
|
|
1272
|
+
const dashboardStore = createStore<{
|
|
1079
1273
|
dashboard: {
|
|
1080
1274
|
widgets: {
|
|
1081
1275
|
id: number
|
|
@@ -1182,7 +1376,7 @@ describe('store', () => {
|
|
|
1182
1376
|
},
|
|
1183
1377
|
}
|
|
1184
1378
|
|
|
1185
|
-
const formStore =
|
|
1379
|
+
const formStore = createStore<{
|
|
1186
1380
|
profile: {
|
|
1187
1381
|
id?: number
|
|
1188
1382
|
createdAt?: string
|
|
@@ -1257,7 +1451,7 @@ describe('store', () => {
|
|
|
1257
1451
|
|
|
1258
1452
|
describe('Symbol.isConcatSpreadable and polymorphic behavior', () => {
|
|
1259
1453
|
test('array-like stores have Symbol.isConcatSpreadable true and length property', () => {
|
|
1260
|
-
const numbers =
|
|
1454
|
+
const numbers = createStore([1, 2, 3])
|
|
1261
1455
|
|
|
1262
1456
|
// Should be concat spreadable
|
|
1263
1457
|
expect(numbers[Symbol.isConcatSpreadable]).toBe(true)
|
|
@@ -1272,7 +1466,7 @@ describe('store', () => {
|
|
|
1272
1466
|
})
|
|
1273
1467
|
|
|
1274
1468
|
test('object-like stores have Symbol.isConcatSpreadable false and no length property', () => {
|
|
1275
|
-
const user =
|
|
1469
|
+
const user = createStore({ name: 'John', age: 25 })
|
|
1276
1470
|
|
|
1277
1471
|
// Should not be concat spreadable
|
|
1278
1472
|
expect(user[Symbol.isConcatSpreadable]).toBe(false)
|
|
@@ -1284,7 +1478,7 @@ describe('store', () => {
|
|
|
1284
1478
|
})
|
|
1285
1479
|
|
|
1286
1480
|
test('array-like stores iterate over signals only', () => {
|
|
1287
|
-
const numbers =
|
|
1481
|
+
const numbers = createStore([10, 20, 30])
|
|
1288
1482
|
const signals = [...numbers]
|
|
1289
1483
|
|
|
1290
1484
|
// Should yield signals, not [key, signal] pairs
|
|
@@ -1300,7 +1494,7 @@ describe('store', () => {
|
|
|
1300
1494
|
})
|
|
1301
1495
|
|
|
1302
1496
|
test('object-like stores iterate over [key, signal] pairs', () => {
|
|
1303
|
-
const user =
|
|
1497
|
+
const user = createStore({ name: 'Alice', age: 30 })
|
|
1304
1498
|
const entries = [...user]
|
|
1305
1499
|
|
|
1306
1500
|
// Should yield [key, signal] pairs
|
|
@@ -1320,7 +1514,7 @@ describe('store', () => {
|
|
|
1320
1514
|
})
|
|
1321
1515
|
|
|
1322
1516
|
test('array-like stores support single-parameter add() method', () => {
|
|
1323
|
-
const fruits =
|
|
1517
|
+
const fruits = createStore(['apple', 'banana'])
|
|
1324
1518
|
|
|
1325
1519
|
// Should add to end without specifying key
|
|
1326
1520
|
fruits.add('cherry')
|
|
@@ -1331,7 +1525,10 @@ describe('store', () => {
|
|
|
1331
1525
|
})
|
|
1332
1526
|
|
|
1333
1527
|
test('object-like stores require key parameter for add() method', () => {
|
|
1334
|
-
const config =
|
|
1528
|
+
const config = createStore<{
|
|
1529
|
+
debug: boolean
|
|
1530
|
+
timeout?: number
|
|
1531
|
+
}>({
|
|
1335
1532
|
debug: true,
|
|
1336
1533
|
})
|
|
1337
1534
|
|
|
@@ -1342,9 +1539,9 @@ describe('store', () => {
|
|
|
1342
1539
|
})
|
|
1343
1540
|
|
|
1344
1541
|
test('concat works correctly with array-like stores', () => {
|
|
1345
|
-
const numbers =
|
|
1346
|
-
const prefix = [
|
|
1347
|
-
const suffix = [
|
|
1542
|
+
const numbers = createStore([2, 3])
|
|
1543
|
+
const prefix = [createState(1)]
|
|
1544
|
+
const suffix = [createState(4), createState(5)]
|
|
1348
1545
|
|
|
1349
1546
|
// Should spread signals when concat-ed
|
|
1350
1547
|
const combined = prefix.concat(
|
|
@@ -1361,10 +1558,10 @@ describe('store', () => {
|
|
|
1361
1558
|
})
|
|
1362
1559
|
|
|
1363
1560
|
test('spread operator works correctly with array-like stores', () => {
|
|
1364
|
-
const numbers =
|
|
1561
|
+
const numbers = createStore([10, 20])
|
|
1365
1562
|
|
|
1366
1563
|
// Should spread signals
|
|
1367
|
-
const spread = [
|
|
1564
|
+
const spread = [createState(5), ...numbers, createState(30)]
|
|
1368
1565
|
|
|
1369
1566
|
expect(spread).toHaveLength(4)
|
|
1370
1567
|
expect(spread[0].get()).toBe(5)
|
|
@@ -1374,7 +1571,7 @@ describe('store', () => {
|
|
|
1374
1571
|
})
|
|
1375
1572
|
|
|
1376
1573
|
test('array-like stores maintain numeric key ordering', () => {
|
|
1377
|
-
const items =
|
|
1574
|
+
const items = createStore(['first', 'second', 'third'])
|
|
1378
1575
|
|
|
1379
1576
|
// Get the keys
|
|
1380
1577
|
const keys = Object.keys(items)
|
|
@@ -1389,17 +1586,19 @@ describe('store', () => {
|
|
|
1389
1586
|
|
|
1390
1587
|
test('polymorphic behavior is determined at creation time', () => {
|
|
1391
1588
|
// Created as array - stays array-like
|
|
1392
|
-
const arrayStore =
|
|
1589
|
+
const arrayStore = createStore([1, 2])
|
|
1393
1590
|
expect(arrayStore[Symbol.isConcatSpreadable]).toBe(true)
|
|
1394
1591
|
expect(arrayStore.length).toBe(2)
|
|
1395
1592
|
|
|
1396
1593
|
// Created as object - stays object-like
|
|
1397
|
-
const objectStore =
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1594
|
+
const objectStore = createStore<{
|
|
1595
|
+
a: number
|
|
1596
|
+
b: number
|
|
1597
|
+
c?: number
|
|
1598
|
+
}>({
|
|
1599
|
+
a: 1,
|
|
1600
|
+
b: 2,
|
|
1601
|
+
})
|
|
1403
1602
|
expect(objectStore[Symbol.isConcatSpreadable]).toBe(false)
|
|
1404
1603
|
// @ts-expect-error deliberate access to non-existent length property
|
|
1405
1604
|
expect(objectStore.length).toBeUndefined()
|
|
@@ -1413,8 +1612,8 @@ describe('store', () => {
|
|
|
1413
1612
|
})
|
|
1414
1613
|
|
|
1415
1614
|
test('runtime type detection using typeof length', () => {
|
|
1416
|
-
const arrayStore =
|
|
1417
|
-
const objectStore =
|
|
1615
|
+
const arrayStore = createStore([1, 2, 3])
|
|
1616
|
+
const objectStore = createStore({ x: 1, y: 2 })
|
|
1418
1617
|
|
|
1419
1618
|
// Can distinguish at runtime
|
|
1420
1619
|
expect(typeof arrayStore.length === 'number').toBe(true)
|
|
@@ -1423,8 +1622,8 @@ describe('store', () => {
|
|
|
1423
1622
|
})
|
|
1424
1623
|
|
|
1425
1624
|
test('empty stores behave correctly', () => {
|
|
1426
|
-
const emptyArray =
|
|
1427
|
-
const emptyObject =
|
|
1625
|
+
const emptyArray = createStore([])
|
|
1626
|
+
const emptyObject = createStore({})
|
|
1428
1627
|
|
|
1429
1628
|
// Empty array store
|
|
1430
1629
|
expect(emptyArray[Symbol.isConcatSpreadable]).toBe(true)
|
|
@@ -1440,10 +1639,10 @@ describe('store', () => {
|
|
|
1440
1639
|
})
|
|
1441
1640
|
|
|
1442
1641
|
test('debug length property issue', () => {
|
|
1443
|
-
const numbers =
|
|
1642
|
+
const numbers = createStore([1, 2, 3])
|
|
1444
1643
|
|
|
1445
1644
|
// Test length in computed context
|
|
1446
|
-
const lengthComputed =
|
|
1645
|
+
const lengthComputed = createComputed(() => numbers.length)
|
|
1447
1646
|
numbers.add(4)
|
|
1448
1647
|
|
|
1449
1648
|
// Test if length property is actually reactive
|
|
@@ -1454,7 +1653,7 @@ describe('store', () => {
|
|
|
1454
1653
|
|
|
1455
1654
|
describe('sort() method', () => {
|
|
1456
1655
|
test('sorts array-like store with numeric compareFn', () => {
|
|
1457
|
-
const numbers =
|
|
1656
|
+
const numbers = createStore([3, 1, 4, 1, 5])
|
|
1458
1657
|
|
|
1459
1658
|
// Capture old signal references
|
|
1460
1659
|
const oldSignals = [
|
|
@@ -1479,7 +1678,7 @@ describe('store', () => {
|
|
|
1479
1678
|
})
|
|
1480
1679
|
|
|
1481
1680
|
test('sorts array-like store with string compareFn', () => {
|
|
1482
|
-
const names =
|
|
1681
|
+
const names = createStore(['Charlie', 'Alice', 'Bob'])
|
|
1483
1682
|
|
|
1484
1683
|
names.sort((a, b) => a.localeCompare(b))
|
|
1485
1684
|
|
|
@@ -1487,7 +1686,7 @@ describe('store', () => {
|
|
|
1487
1686
|
})
|
|
1488
1687
|
|
|
1489
1688
|
test('sorts record-like store by value', () => {
|
|
1490
|
-
const users =
|
|
1689
|
+
const users = createStore({
|
|
1491
1690
|
user1: { name: 'Charlie', age: 25 },
|
|
1492
1691
|
user2: { name: 'Alice', age: 30 },
|
|
1493
1692
|
user3: { name: 'Bob', age: 20 },
|
|
@@ -1513,30 +1712,28 @@ describe('store', () => {
|
|
|
1513
1712
|
expect(users.user3).toBe(oldSignals.user3)
|
|
1514
1713
|
})
|
|
1515
1714
|
|
|
1516
|
-
test('emits
|
|
1517
|
-
const numbers =
|
|
1518
|
-
let
|
|
1715
|
+
test('emits a sort notification with new order', () => {
|
|
1716
|
+
const numbers = createStore([30, 10, 20])
|
|
1717
|
+
let sortNotification: string[] | null = null
|
|
1519
1718
|
|
|
1520
|
-
numbers.
|
|
1521
|
-
|
|
1719
|
+
numbers.on('sort', change => {
|
|
1720
|
+
sortNotification = change
|
|
1522
1721
|
})
|
|
1523
1722
|
|
|
1524
1723
|
numbers.sort((a, b) => a - b)
|
|
1525
1724
|
|
|
1526
|
-
expect(
|
|
1527
|
-
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1528
|
-
expect(sortEvent!.type).toBe('store-sort')
|
|
1725
|
+
expect(sortNotification).not.toBeNull()
|
|
1529
1726
|
// Keys in new sorted order: [10, 20, 30] came from indices [1, 2, 0]
|
|
1530
1727
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1531
|
-
expect(
|
|
1728
|
+
expect(sortNotification!).toEqual(['1', '2', '0'])
|
|
1532
1729
|
})
|
|
1533
1730
|
|
|
1534
1731
|
test('sort is reactive - watchers are notified', () => {
|
|
1535
|
-
const numbers =
|
|
1732
|
+
const numbers = createStore([3, 1, 2])
|
|
1536
1733
|
let effectCount = 0
|
|
1537
1734
|
let lastValue: number[] = []
|
|
1538
1735
|
|
|
1539
|
-
|
|
1736
|
+
createEffect(() => {
|
|
1540
1737
|
lastValue = numbers.get()
|
|
1541
1738
|
effectCount++
|
|
1542
1739
|
})
|
|
@@ -1553,7 +1750,7 @@ describe('store', () => {
|
|
|
1553
1750
|
})
|
|
1554
1751
|
|
|
1555
1752
|
test('nested signals remain reactive after sorting', () => {
|
|
1556
|
-
const items =
|
|
1753
|
+
const items = createStore([
|
|
1557
1754
|
{ name: 'Charlie', score: 85 },
|
|
1558
1755
|
{ name: 'Alice', score: 95 },
|
|
1559
1756
|
{ name: 'Bob', score: 75 },
|
|
@@ -1578,7 +1775,7 @@ describe('store', () => {
|
|
|
1578
1775
|
})
|
|
1579
1776
|
|
|
1580
1777
|
test('sort with complex nested structures', () => {
|
|
1581
|
-
const posts =
|
|
1778
|
+
const posts = createStore([
|
|
1582
1779
|
{
|
|
1583
1780
|
id: 'post1',
|
|
1584
1781
|
title: 'Hello World',
|
|
@@ -1612,7 +1809,7 @@ describe('store', () => {
|
|
|
1612
1809
|
})
|
|
1613
1810
|
|
|
1614
1811
|
test('sort preserves array length and size', () => {
|
|
1615
|
-
const arr =
|
|
1812
|
+
const arr = createStore([5, 2, 8, 1])
|
|
1616
1813
|
|
|
1617
1814
|
expect(arr.length).toBe(4)
|
|
1618
1815
|
expect(arr.size.get()).toBe(4)
|
|
@@ -1625,7 +1822,7 @@ describe('store', () => {
|
|
|
1625
1822
|
})
|
|
1626
1823
|
|
|
1627
1824
|
test('sort with no compareFn uses default string sorting like Array.prototype.sort()', () => {
|
|
1628
|
-
const items =
|
|
1825
|
+
const items = createStore(['banana', 'cherry', 'apple', '10', '2'])
|
|
1629
1826
|
|
|
1630
1827
|
items.sort()
|
|
1631
1828
|
|
|
@@ -1636,7 +1833,7 @@ describe('store', () => {
|
|
|
1636
1833
|
})
|
|
1637
1834
|
|
|
1638
1835
|
test('default sort handles numbers as strings like Array.prototype.sort()', () => {
|
|
1639
|
-
const numbers =
|
|
1836
|
+
const numbers = createStore([80, 9, 100])
|
|
1640
1837
|
|
|
1641
1838
|
numbers.sort()
|
|
1642
1839
|
|
|
@@ -1646,7 +1843,7 @@ describe('store', () => {
|
|
|
1646
1843
|
})
|
|
1647
1844
|
|
|
1648
1845
|
test('default sort handles mixed values with proper string conversion', () => {
|
|
1649
|
-
const mixed =
|
|
1846
|
+
const mixed = createStore(['b', 0, 'a', '', 'c'])
|
|
1650
1847
|
|
|
1651
1848
|
mixed.sort()
|
|
1652
1849
|
|
|
@@ -1655,7 +1852,7 @@ describe('store', () => {
|
|
|
1655
1852
|
})
|
|
1656
1853
|
|
|
1657
1854
|
test('multiple sorts work correctly', () => {
|
|
1658
|
-
const numbers =
|
|
1855
|
+
const numbers = createStore([3, 1, 4, 1, 5])
|
|
1659
1856
|
|
|
1660
1857
|
// Sort ascending
|
|
1661
1858
|
numbers.sort((a, b) => a - b)
|
|
@@ -1666,24 +1863,120 @@ describe('store', () => {
|
|
|
1666
1863
|
expect(numbers.get()).toEqual([5, 4, 3, 1, 1])
|
|
1667
1864
|
})
|
|
1668
1865
|
|
|
1669
|
-
test('sort
|
|
1670
|
-
const users =
|
|
1866
|
+
test('sort notification contains correct movement mapping for records', () => {
|
|
1867
|
+
const users = createStore({
|
|
1671
1868
|
alice: { age: 30 },
|
|
1672
1869
|
bob: { age: 20 },
|
|
1673
1870
|
charlie: { age: 25 },
|
|
1674
1871
|
})
|
|
1675
1872
|
|
|
1676
|
-
let
|
|
1677
|
-
users.
|
|
1678
|
-
|
|
1873
|
+
let sortNotification: string[] | null = null
|
|
1874
|
+
users.on('sort', change => {
|
|
1875
|
+
sortNotification = change
|
|
1679
1876
|
})
|
|
1680
1877
|
|
|
1681
1878
|
// Sort by age
|
|
1682
1879
|
users.sort((a, b) => b.age - a.age)
|
|
1683
1880
|
|
|
1684
|
-
expect(
|
|
1881
|
+
expect(sortNotification).not.toBeNull()
|
|
1685
1882
|
// biome-ignore lint/style/noNonNullAssertion: test
|
|
1686
|
-
expect(
|
|
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
|
|
1687
1980
|
})
|
|
1688
1981
|
})
|
|
1689
1982
|
})
|