@zeix/cause-effect 0.17.1 → 0.17.3
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 +13 -0
- package/.github/copilot-instructions.md +4 -0
- package/.zed/settings.json +3 -0
- package/CLAUDE.md +41 -7
- package/README.md +48 -25
- package/archive/benchmark.ts +0 -5
- package/archive/collection.ts +6 -65
- package/archive/composite.ts +85 -0
- package/archive/computed.ts +18 -20
- package/archive/list.ts +7 -75
- package/archive/memo.ts +15 -15
- package/archive/state.ts +2 -1
- package/archive/store.ts +8 -78
- package/archive/task.ts +20 -25
- package/index.dev.js +508 -526
- package/index.js +1 -1
- package/index.ts +9 -11
- package/package.json +6 -6
- package/src/classes/collection.ts +70 -107
- package/src/classes/computed.ts +165 -149
- package/src/classes/list.ts +145 -107
- package/src/classes/ref.ts +19 -17
- package/src/classes/state.ts +21 -17
- package/src/classes/store.ts +125 -73
- package/src/diff.ts +2 -1
- package/src/effect.ts +17 -10
- package/src/errors.ts +14 -1
- package/src/resolve.ts +1 -1
- package/src/signal.ts +3 -2
- package/src/system.ts +159 -61
- package/src/util.ts +0 -6
- package/test/batch.test.ts +4 -11
- package/test/benchmark.test.ts +4 -2
- package/test/collection.test.ts +106 -107
- package/test/computed.test.ts +351 -112
- package/test/effect.test.ts +2 -2
- package/test/list.test.ts +62 -102
- package/test/ref.test.ts +128 -2
- package/test/state.test.ts +16 -22
- package/test/store.test.ts +101 -108
- package/test/util/dependency-graph.ts +2 -2
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +5 -7
- package/types/index.d.ts +3 -3
- package/types/src/classes/collection.d.ts +9 -10
- package/types/src/classes/computed.d.ts +17 -20
- package/types/src/classes/list.d.ts +8 -6
- package/types/src/classes/ref.d.ts +8 -12
- package/types/src/classes/state.d.ts +5 -8
- package/types/src/classes/store.d.ts +14 -13
- package/types/src/effect.d.ts +1 -2
- package/types/src/errors.d.ts +2 -1
- package/types/src/signal.d.ts +3 -2
- package/types/src/system.d.ts +47 -34
- package/types/src/util.d.ts +1 -2
- package/src/classes/composite.ts +0 -176
- package/types/src/classes/composite.d.ts +0 -15
package/test/list.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
createEffect,
|
|
4
|
-
List,
|
|
5
4
|
createStore,
|
|
6
5
|
isCollection,
|
|
7
6
|
isList,
|
|
8
7
|
isStore,
|
|
8
|
+
List,
|
|
9
9
|
Memo,
|
|
10
10
|
State,
|
|
11
11
|
UNSET,
|
|
@@ -130,17 +130,6 @@ describe('list', () => {
|
|
|
130
130
|
expect(names.get()).toEqual(['Alice', 'Bob', 'Charlie'])
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
test('emits sort notification with new order', () => {
|
|
134
|
-
const numbers = new List([3, 1, 2])
|
|
135
|
-
let sortNotification: readonly string[] = []
|
|
136
|
-
numbers.on('sort', sort => {
|
|
137
|
-
sortNotification = sort
|
|
138
|
-
})
|
|
139
|
-
numbers.sort()
|
|
140
|
-
expect(sortNotification).toHaveLength(3)
|
|
141
|
-
expect(sortNotification).toEqual(['1', '2', '0'])
|
|
142
|
-
})
|
|
143
|
-
|
|
144
133
|
test('sort is reactive - watchers are notified', () => {
|
|
145
134
|
const numbers = new List([3, 1, 2])
|
|
146
135
|
let effectCount = 0
|
|
@@ -302,47 +291,6 @@ describe('list', () => {
|
|
|
302
291
|
})
|
|
303
292
|
})
|
|
304
293
|
|
|
305
|
-
describe('change notifications', () => {
|
|
306
|
-
test('emits add notifications', () => {
|
|
307
|
-
const numbers = new List([1, 2])
|
|
308
|
-
let arrayAddNotification: readonly string[] = []
|
|
309
|
-
let newArray: number[] = []
|
|
310
|
-
numbers.on('add', add => {
|
|
311
|
-
arrayAddNotification = add
|
|
312
|
-
newArray = numbers.get()
|
|
313
|
-
})
|
|
314
|
-
numbers.add(3)
|
|
315
|
-
expect(arrayAddNotification).toHaveLength(1)
|
|
316
|
-
expect(newArray).toEqual([1, 2, 3])
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
test('emits change notifications when properties are modified', () => {
|
|
320
|
-
const items = new List([{ value: 10 }])
|
|
321
|
-
let arrayChangeNotification: readonly string[] = []
|
|
322
|
-
let newArray: { value: number }[] = []
|
|
323
|
-
items.on('change', change => {
|
|
324
|
-
arrayChangeNotification = change
|
|
325
|
-
newArray = items.get()
|
|
326
|
-
})
|
|
327
|
-
items.at(0)?.set({ value: 20 })
|
|
328
|
-
expect(arrayChangeNotification).toHaveLength(1)
|
|
329
|
-
expect(newArray).toEqual([{ value: 20 }])
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
test('emits remove notifications when items are removed', () => {
|
|
333
|
-
const items = new List([1, 2, 3])
|
|
334
|
-
let arrayRemoveNotification: readonly string[] = []
|
|
335
|
-
let newArray: number[] = []
|
|
336
|
-
items.on('remove', remove => {
|
|
337
|
-
arrayRemoveNotification = remove
|
|
338
|
-
newArray = items.get()
|
|
339
|
-
})
|
|
340
|
-
items.remove(1)
|
|
341
|
-
expect(arrayRemoveNotification).toHaveLength(1)
|
|
342
|
-
expect(newArray).toEqual([1, 3])
|
|
343
|
-
})
|
|
344
|
-
})
|
|
345
|
-
|
|
346
294
|
describe('edge cases', () => {
|
|
347
295
|
test('handles empty lists correctly', () => {
|
|
348
296
|
const empty = new List([])
|
|
@@ -482,7 +430,10 @@ describe('list', () => {
|
|
|
482
430
|
])
|
|
483
431
|
|
|
484
432
|
const enrichedUsers = users.deriveCollection(
|
|
485
|
-
async (
|
|
433
|
+
async (
|
|
434
|
+
user: { id: number; name: string },
|
|
435
|
+
abort: AbortSignal,
|
|
436
|
+
) => {
|
|
486
437
|
// Simulate API call
|
|
487
438
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
488
439
|
if (abort.aborted) throw new Error('Aborted')
|
|
@@ -663,54 +614,6 @@ describe('list', () => {
|
|
|
663
614
|
})
|
|
664
615
|
})
|
|
665
616
|
|
|
666
|
-
describe('collection event handling', () => {
|
|
667
|
-
test('emits add events when source adds items', () => {
|
|
668
|
-
const numbers = new List([1, 2])
|
|
669
|
-
const doubled = numbers.deriveCollection(
|
|
670
|
-
(value: number) => value * 2,
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
let addedKeys: readonly string[] = []
|
|
674
|
-
doubled.on('add', keys => {
|
|
675
|
-
addedKeys = keys
|
|
676
|
-
})
|
|
677
|
-
|
|
678
|
-
numbers.add(3)
|
|
679
|
-
expect(addedKeys).toHaveLength(1)
|
|
680
|
-
})
|
|
681
|
-
|
|
682
|
-
test('emits remove events when source removes items', () => {
|
|
683
|
-
const numbers = new List([1, 2, 3])
|
|
684
|
-
const doubled = numbers.deriveCollection(
|
|
685
|
-
(value: number) => value * 2,
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
let removedKeys: readonly string[] = []
|
|
689
|
-
doubled.on('remove', keys => {
|
|
690
|
-
removedKeys = keys
|
|
691
|
-
})
|
|
692
|
-
|
|
693
|
-
numbers.remove(1)
|
|
694
|
-
expect(removedKeys).toHaveLength(1)
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
test('emits sort events when source is sorted', () => {
|
|
698
|
-
const numbers = new List([3, 1, 2])
|
|
699
|
-
const doubled = numbers.deriveCollection(
|
|
700
|
-
(value: number) => value * 2,
|
|
701
|
-
)
|
|
702
|
-
|
|
703
|
-
let sortedKeys: readonly string[] = []
|
|
704
|
-
doubled.on('sort', keys => {
|
|
705
|
-
sortedKeys = keys
|
|
706
|
-
})
|
|
707
|
-
|
|
708
|
-
numbers.sort()
|
|
709
|
-
expect(sortedKeys).toHaveLength(3)
|
|
710
|
-
expect(doubled.get()).toEqual([2, 4, 6]) // Sorted and doubled
|
|
711
|
-
})
|
|
712
|
-
})
|
|
713
|
-
|
|
714
617
|
describe('edge cases', () => {
|
|
715
618
|
test('handles empty list derivation', () => {
|
|
716
619
|
const empty = new List<number>([])
|
|
@@ -751,4 +654,61 @@ describe('list', () => {
|
|
|
751
654
|
})
|
|
752
655
|
})
|
|
753
656
|
})
|
|
657
|
+
|
|
658
|
+
describe('Watch Callbacks', () => {
|
|
659
|
+
test('List watched callback is called when effect accesses list.get()', () => {
|
|
660
|
+
let linkWatchedCalled = false
|
|
661
|
+
let listUnwatchedCalled = false
|
|
662
|
+
const numbers = new List([10, 20, 30], {
|
|
663
|
+
watched: () => {
|
|
664
|
+
linkWatchedCalled = true
|
|
665
|
+
},
|
|
666
|
+
unwatched: () => {
|
|
667
|
+
listUnwatchedCalled = true
|
|
668
|
+
},
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
expect(linkWatchedCalled).toBe(false)
|
|
672
|
+
|
|
673
|
+
// Access list via list.get() - this should trigger list's watched callback
|
|
674
|
+
let effectValue: number[] = []
|
|
675
|
+
const cleanup = createEffect(() => {
|
|
676
|
+
effectValue = numbers.get()
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
expect(linkWatchedCalled).toBe(true)
|
|
680
|
+
expect(effectValue).toEqual([10, 20, 30])
|
|
681
|
+
expect(listUnwatchedCalled).toBe(false)
|
|
682
|
+
|
|
683
|
+
// Cleanup effect - should trigger unwatch
|
|
684
|
+
cleanup()
|
|
685
|
+
expect(listUnwatchedCalled).toBe(true)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test('List length access triggers List watched callback', () => {
|
|
689
|
+
let listWatchedCalled = false
|
|
690
|
+
let listUnwatchedCalled = false
|
|
691
|
+
const numbers = new List([1, 2, 3], {
|
|
692
|
+
watched: () => {
|
|
693
|
+
listWatchedCalled = true
|
|
694
|
+
},
|
|
695
|
+
unwatched: () => {
|
|
696
|
+
listUnwatchedCalled = true
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
// Access via list.length - this should trigger list's watched callback
|
|
701
|
+
let effectValue: number = 0
|
|
702
|
+
const cleanup = createEffect(() => {
|
|
703
|
+
effectValue = numbers.length
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
expect(listWatchedCalled).toBe(true)
|
|
707
|
+
expect(effectValue).toBe(3)
|
|
708
|
+
expect(listUnwatchedCalled).toBe(false)
|
|
709
|
+
|
|
710
|
+
cleanup()
|
|
711
|
+
expect(listUnwatchedCalled).toBe(true)
|
|
712
|
+
})
|
|
713
|
+
})
|
|
754
714
|
})
|
package/test/ref.test.ts
CHANGED
|
@@ -34,8 +34,8 @@ test('Ref - validation with guard function', () => {
|
|
|
34
34
|
const validConfig = { host: 'localhost', port: 3000 }
|
|
35
35
|
const invalidConfig = { host: 'localhost' } // missing port
|
|
36
36
|
|
|
37
|
-
expect(() => new Ref(validConfig, isConfig)).not.toThrow()
|
|
38
|
-
expect(() => new Ref(invalidConfig, isConfig)).toThrow()
|
|
37
|
+
expect(() => new Ref(validConfig, { guard: isConfig })).not.toThrow()
|
|
38
|
+
expect(() => new Ref(invalidConfig, { guard: isConfig })).toThrow()
|
|
39
39
|
})
|
|
40
40
|
|
|
41
41
|
test('Ref - reactive subscriptions', () => {
|
|
@@ -225,3 +225,129 @@ test('Ref - handles complex nested objects', () => {
|
|
|
225
225
|
|
|
226
226
|
expect(userCount).toBe(2)
|
|
227
227
|
})
|
|
228
|
+
|
|
229
|
+
test('Ref - options.watched lazy resource management', async () => {
|
|
230
|
+
// 1. Create Ref with current Date
|
|
231
|
+
let counter = 0
|
|
232
|
+
let intervalId: Timer | undefined
|
|
233
|
+
const ref = new Ref(new Date(), {
|
|
234
|
+
watched: () => {
|
|
235
|
+
intervalId = setInterval(() => {
|
|
236
|
+
counter++
|
|
237
|
+
}, 10)
|
|
238
|
+
},
|
|
239
|
+
unwatched: () => {
|
|
240
|
+
if (intervalId) {
|
|
241
|
+
clearInterval(intervalId)
|
|
242
|
+
intervalId = undefined
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// 2. Counter should not be running yet
|
|
248
|
+
expect(counter).toBe(0)
|
|
249
|
+
|
|
250
|
+
// Wait a bit to ensure counter doesn't increment
|
|
251
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
252
|
+
expect(counter).toBe(0)
|
|
253
|
+
expect(intervalId).toBeUndefined()
|
|
254
|
+
|
|
255
|
+
// 3. Effect subscribes by .get()ting the signal value
|
|
256
|
+
const effectCleanup = createEffect(() => {
|
|
257
|
+
ref.get()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// 4. Counter should now be running
|
|
261
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
262
|
+
expect(counter).toBeGreaterThan(0)
|
|
263
|
+
expect(intervalId).toBeDefined()
|
|
264
|
+
|
|
265
|
+
// 5. Call effect cleanup, which should stop internal watcher and unsubscribe
|
|
266
|
+
effectCleanup()
|
|
267
|
+
const counterAfterStop = counter
|
|
268
|
+
|
|
269
|
+
// 6. Ref signal should call #unwatch() and counter should stop incrementing
|
|
270
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
271
|
+
expect(counter).toBe(counterAfterStop) // Counter should not have incremented
|
|
272
|
+
expect(intervalId).toBeUndefined() // Interval should be cleared
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('Ref - options.watched exception handling', async () => {
|
|
276
|
+
const ref = new Ref(
|
|
277
|
+
{ test: 'value' },
|
|
278
|
+
{
|
|
279
|
+
watched: () => {
|
|
280
|
+
throwingCallbackCalled = true
|
|
281
|
+
throw new Error('Test error in watched callback')
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
// Mock console.error to capture error logs
|
|
287
|
+
const originalError = console.error
|
|
288
|
+
const errorSpy = mock(() => {})
|
|
289
|
+
console.error = errorSpy
|
|
290
|
+
|
|
291
|
+
let throwingCallbackCalled = false
|
|
292
|
+
|
|
293
|
+
// Subscribe to trigger watched callback
|
|
294
|
+
const effectCleanup = createEffect(() => {
|
|
295
|
+
ref.get()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// Both callbacks should have been called despite the exception
|
|
299
|
+
expect(throwingCallbackCalled).toBe(true)
|
|
300
|
+
|
|
301
|
+
// Error should have been logged
|
|
302
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
303
|
+
'Error in effect callback:',
|
|
304
|
+
expect.any(Error),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// Cleanup
|
|
308
|
+
effectCleanup()
|
|
309
|
+
console.error = originalError
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('Ref - options.unwatched exception handling', async () => {
|
|
313
|
+
const ref = new Ref(
|
|
314
|
+
{ test: 'value' },
|
|
315
|
+
{
|
|
316
|
+
watched: () => {},
|
|
317
|
+
unwatched: () => {
|
|
318
|
+
cleanup1Called = true
|
|
319
|
+
throw new Error('Test error in cleanup function')
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// Mock console.error to capture error logs
|
|
325
|
+
const originalError = console.error
|
|
326
|
+
const errorSpy = mock(() => {})
|
|
327
|
+
console.error = errorSpy
|
|
328
|
+
|
|
329
|
+
let cleanup1Called = false
|
|
330
|
+
|
|
331
|
+
// Subscribe and then unsubscribe to trigger cleanup
|
|
332
|
+
const effectCleanup = createEffect(() => {
|
|
333
|
+
ref.get()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Unsubscribe to trigger cleanup functions
|
|
337
|
+
effectCleanup()
|
|
338
|
+
|
|
339
|
+
// Wait a bit for cleanup to complete
|
|
340
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
341
|
+
|
|
342
|
+
// Both cleanup functions should have been called despite the exception
|
|
343
|
+
expect(cleanup1Called).toBe(true)
|
|
344
|
+
|
|
345
|
+
// Error should have been logged
|
|
346
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
347
|
+
'Error in effect cleanup:',
|
|
348
|
+
expect.any(Error),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
// Cleanup
|
|
352
|
+
console.error = originalError
|
|
353
|
+
})
|
package/test/state.test.ts
CHANGED
|
@@ -125,12 +125,12 @@ describe('State', () => {
|
|
|
125
125
|
expect(() => {
|
|
126
126
|
// @ts-expect-error - Testing invalid input
|
|
127
127
|
new State(null)
|
|
128
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
128
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
129
129
|
|
|
130
130
|
expect(() => {
|
|
131
131
|
// @ts-expect-error - Testing invalid input
|
|
132
132
|
new State(undefined)
|
|
133
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
133
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
134
134
|
})
|
|
135
135
|
|
|
136
136
|
test('should throw NullishSignalValueError when newValue is nullish in set()', () => {
|
|
@@ -139,12 +139,12 @@ describe('State', () => {
|
|
|
139
139
|
expect(() => {
|
|
140
140
|
// @ts-expect-error - Testing invalid input
|
|
141
141
|
state.set(null)
|
|
142
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
142
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
143
143
|
|
|
144
144
|
expect(() => {
|
|
145
145
|
// @ts-expect-error - Testing invalid input
|
|
146
146
|
state.set(undefined)
|
|
147
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
147
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
148
148
|
})
|
|
149
149
|
|
|
150
150
|
test('should throw specific error types for nullish values', () => {
|
|
@@ -154,10 +154,8 @@ describe('State', () => {
|
|
|
154
154
|
expect(true).toBe(false) // Should not reach here
|
|
155
155
|
} catch (error) {
|
|
156
156
|
expect(error).toBeInstanceOf(TypeError)
|
|
157
|
-
expect(error.name).toBe('NullishSignalValueError')
|
|
158
|
-
expect(error.message).toBe(
|
|
159
|
-
'Nullish signal values are not allowed in state',
|
|
160
|
-
)
|
|
157
|
+
expect((error as Error).name).toBe('NullishSignalValueError')
|
|
158
|
+
expect((error as Error).message).toBe('Nullish signal values are not allowed in State')
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
const state = new State(42)
|
|
@@ -167,10 +165,8 @@ describe('State', () => {
|
|
|
167
165
|
expect(true).toBe(false) // Should not reach here
|
|
168
166
|
} catch (error) {
|
|
169
167
|
expect(error).toBeInstanceOf(TypeError)
|
|
170
|
-
expect(error.name).toBe('NullishSignalValueError')
|
|
171
|
-
expect(error.message).toBe(
|
|
172
|
-
'Nullish signal values are not allowed in state',
|
|
173
|
-
)
|
|
168
|
+
expect((error as Error).name).toBe('NullishSignalValueError')
|
|
169
|
+
expect((error as Error).message).toBe('Nullish signal values are not allowed in State')
|
|
174
170
|
}
|
|
175
171
|
})
|
|
176
172
|
|
|
@@ -213,22 +209,22 @@ describe('State', () => {
|
|
|
213
209
|
expect(() => {
|
|
214
210
|
// @ts-expect-error - Testing invalid input
|
|
215
211
|
state.update(null)
|
|
216
|
-
}).toThrow('Invalid
|
|
212
|
+
}).toThrow('Invalid State update callback null')
|
|
217
213
|
|
|
218
214
|
expect(() => {
|
|
219
215
|
// @ts-expect-error - Testing invalid input
|
|
220
216
|
state.update(undefined)
|
|
221
|
-
}).toThrow('Invalid
|
|
217
|
+
}).toThrow('Invalid State update callback undefined')
|
|
222
218
|
|
|
223
219
|
expect(() => {
|
|
224
220
|
// @ts-expect-error - Testing invalid input
|
|
225
221
|
state.update('not a function')
|
|
226
|
-
}).toThrow('Invalid
|
|
222
|
+
}).toThrow('Invalid State update callback "not a function"')
|
|
227
223
|
|
|
228
224
|
expect(() => {
|
|
229
225
|
// @ts-expect-error - Testing invalid input
|
|
230
226
|
state.update(42)
|
|
231
|
-
}).toThrow('Invalid
|
|
227
|
+
}).toThrow('Invalid State update callback 42')
|
|
232
228
|
})
|
|
233
229
|
|
|
234
230
|
test('should throw specific error type for non-function updater', () => {
|
|
@@ -240,10 +236,8 @@ describe('State', () => {
|
|
|
240
236
|
expect(true).toBe(false) // Should not reach here
|
|
241
237
|
} catch (error) {
|
|
242
238
|
expect(error).toBeInstanceOf(TypeError)
|
|
243
|
-
expect(error.name).toBe('InvalidCallbackError')
|
|
244
|
-
expect(error.message).toBe(
|
|
245
|
-
'Invalid state update callback null',
|
|
246
|
-
)
|
|
239
|
+
expect((error as Error).name).toBe('InvalidCallbackError')
|
|
240
|
+
expect((error as Error).message).toBe('Invalid State update callback null')
|
|
247
241
|
}
|
|
248
242
|
})
|
|
249
243
|
|
|
@@ -266,12 +260,12 @@ describe('State', () => {
|
|
|
266
260
|
expect(() => {
|
|
267
261
|
// @ts-expect-error - Testing invalid return value
|
|
268
262
|
state.update(() => null)
|
|
269
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
263
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
270
264
|
|
|
271
265
|
expect(() => {
|
|
272
266
|
// @ts-expect-error - Testing invalid return value
|
|
273
267
|
state.update(() => undefined)
|
|
274
|
-
}).toThrow('Nullish signal values are not allowed in
|
|
268
|
+
}).toThrow('Nullish signal values are not allowed in State')
|
|
275
269
|
|
|
276
270
|
// State should remain unchanged after error
|
|
277
271
|
expect(state.get()).toBe(42)
|
package/test/store.test.ts
CHANGED
|
@@ -225,114 +225,6 @@ describe('store', () => {
|
|
|
225
225
|
})
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
-
describe('change tracking and notifications', () => {
|
|
229
|
-
test('emits add notifications', () => {
|
|
230
|
-
let addNotification: readonly string[] = []
|
|
231
|
-
const user = createStore<{ name: string; email?: string }>({
|
|
232
|
-
name: 'John',
|
|
233
|
-
})
|
|
234
|
-
user.on('add', add => {
|
|
235
|
-
addNotification = add
|
|
236
|
-
})
|
|
237
|
-
user.add('email', 'john@example.com')
|
|
238
|
-
expect(addNotification).toContain('email')
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
test('emits change notifications when properties are modified', () => {
|
|
242
|
-
const user = createStore({ name: 'John' })
|
|
243
|
-
let changeNotification: readonly string[] = []
|
|
244
|
-
user.on('change', change => {
|
|
245
|
-
changeNotification = change
|
|
246
|
-
})
|
|
247
|
-
user.name.set('Jane')
|
|
248
|
-
expect(changeNotification).toContain('name')
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
test('emits change notifications for nested property changes', () => {
|
|
252
|
-
const user = createStore({
|
|
253
|
-
preferences: {
|
|
254
|
-
theme: 'light',
|
|
255
|
-
},
|
|
256
|
-
})
|
|
257
|
-
let changeNotification: readonly string[] = []
|
|
258
|
-
user.on('change', change => {
|
|
259
|
-
changeNotification = change
|
|
260
|
-
})
|
|
261
|
-
user.preferences.theme.set('dark')
|
|
262
|
-
expect(changeNotification).toContain('preferences')
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
test('emits remove notifications when properties are removed', () => {
|
|
266
|
-
const user = createStore({
|
|
267
|
-
name: 'John',
|
|
268
|
-
email: 'john@example.com',
|
|
269
|
-
})
|
|
270
|
-
let removeNotification: readonly string[] = []
|
|
271
|
-
user.on('remove', remove => {
|
|
272
|
-
removeNotification = remove
|
|
273
|
-
})
|
|
274
|
-
user.remove('email')
|
|
275
|
-
expect(removeNotification).toContain('email')
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
test('set() correctly handles mixed changes, additions, and removals', () => {
|
|
279
|
-
const user = createStore<{
|
|
280
|
-
name: string
|
|
281
|
-
email?: string
|
|
282
|
-
preferences: { theme?: string }
|
|
283
|
-
age?: number
|
|
284
|
-
}>({
|
|
285
|
-
name: 'John',
|
|
286
|
-
email: 'john@example.com',
|
|
287
|
-
preferences: {
|
|
288
|
-
theme: 'light',
|
|
289
|
-
},
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
let changeNotification: readonly string[] = []
|
|
293
|
-
let addNotification: readonly string[] = []
|
|
294
|
-
let removeNotification: readonly string[] = []
|
|
295
|
-
|
|
296
|
-
user.on('change', change => {
|
|
297
|
-
changeNotification = change
|
|
298
|
-
})
|
|
299
|
-
user.on('add', add => {
|
|
300
|
-
addNotification = add
|
|
301
|
-
})
|
|
302
|
-
user.on('remove', remove => {
|
|
303
|
-
removeNotification = remove
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
user.set({
|
|
307
|
-
name: 'Jane',
|
|
308
|
-
preferences: {
|
|
309
|
-
theme: 'dark',
|
|
310
|
-
},
|
|
311
|
-
age: 30,
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
expect(changeNotification).toContain('name')
|
|
315
|
-
expect(changeNotification).toContain('preferences')
|
|
316
|
-
expect(addNotification).toContain('age')
|
|
317
|
-
expect(removeNotification).toContain('email')
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
test('notification listeners can be removed', () => {
|
|
321
|
-
const user = createStore({ name: 'John' })
|
|
322
|
-
let notificationCount = 0
|
|
323
|
-
const listener = () => {
|
|
324
|
-
notificationCount++
|
|
325
|
-
}
|
|
326
|
-
const off = user.on('change', listener)
|
|
327
|
-
user.name.set('Jane')
|
|
328
|
-
expect(notificationCount).toBe(1)
|
|
329
|
-
|
|
330
|
-
off()
|
|
331
|
-
user.name.set('Bob')
|
|
332
|
-
expect(notificationCount).toBe(1)
|
|
333
|
-
})
|
|
334
|
-
})
|
|
335
|
-
|
|
336
228
|
describe('reactivity', () => {
|
|
337
229
|
test('store-level get() is reactive', () => {
|
|
338
230
|
const user = createStore({
|
|
@@ -660,4 +552,105 @@ describe('store', () => {
|
|
|
660
552
|
expect(config.database.port.get()).toBe(5432)
|
|
661
553
|
})
|
|
662
554
|
})
|
|
555
|
+
|
|
556
|
+
describe('Watch Callbacks', () => {
|
|
557
|
+
test('Root store watched callback triggered only by direct store access', async () => {
|
|
558
|
+
let rootStoreCounter = 0
|
|
559
|
+
let intervalId: Timer | undefined
|
|
560
|
+
const store = createStore(
|
|
561
|
+
{
|
|
562
|
+
user: {
|
|
563
|
+
name: 'John',
|
|
564
|
+
profile: {
|
|
565
|
+
email: 'john@example.com',
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
watched: () => {
|
|
571
|
+
intervalId = setInterval(() => {
|
|
572
|
+
rootStoreCounter++
|
|
573
|
+
}, 10)
|
|
574
|
+
},
|
|
575
|
+
unwatched: () => {
|
|
576
|
+
if (intervalId) {
|
|
577
|
+
clearInterval(intervalId)
|
|
578
|
+
intervalId = undefined
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
expect(rootStoreCounter).toBe(0)
|
|
585
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
586
|
+
expect(rootStoreCounter).toBe(0)
|
|
587
|
+
|
|
588
|
+
// Access nested property directly - should NOT trigger root watched callback
|
|
589
|
+
const nestedEffectCleanup = createEffect(() => {
|
|
590
|
+
store.user.name.get()
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
594
|
+
expect(rootStoreCounter).toBe(0) // Still 0 - nested access doesn't trigger root
|
|
595
|
+
expect(intervalId).toBeUndefined()
|
|
596
|
+
|
|
597
|
+
// Access root store directly - should trigger watched callback
|
|
598
|
+
const rootEffectCleanup = createEffect(() => {
|
|
599
|
+
store.get()
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
603
|
+
expect(rootStoreCounter).toBeGreaterThan(0) // Now triggered
|
|
604
|
+
expect(intervalId).toBeDefined()
|
|
605
|
+
|
|
606
|
+
// Cleanup
|
|
607
|
+
rootEffectCleanup()
|
|
608
|
+
nestedEffectCleanup()
|
|
609
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
610
|
+
expect(intervalId).toBeUndefined()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('Store property addition/removal affects store watched callback', async () => {
|
|
614
|
+
let usersStoreCounter = 0
|
|
615
|
+
const store = createStore(
|
|
616
|
+
{
|
|
617
|
+
users: {} as Record<string, { name: string }>,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
watched: () => {
|
|
621
|
+
usersStoreCounter++
|
|
622
|
+
},
|
|
623
|
+
unwatched: () => {
|
|
624
|
+
usersStoreCounter--
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
expect(usersStoreCounter).toBe(0)
|
|
630
|
+
|
|
631
|
+
// Watch the entire store
|
|
632
|
+
const usersEffect = createEffect(() => {
|
|
633
|
+
store.get()
|
|
634
|
+
})
|
|
635
|
+
expect(usersStoreCounter).toBe(1)
|
|
636
|
+
|
|
637
|
+
// Add a user - this modifies the users store content but doesn't affect watched callback
|
|
638
|
+
store.users.add('user1', { name: 'Alice' })
|
|
639
|
+
expect(usersStoreCounter).toBe(1) // Still 1
|
|
640
|
+
|
|
641
|
+
// Watch a specific user property - this doesn't trigger users store watched callback
|
|
642
|
+
const userEffect = createEffect(() => {
|
|
643
|
+
store.users.user1?.name.get()
|
|
644
|
+
})
|
|
645
|
+
expect(usersStoreCounter).toBe(1) // Still 1
|
|
646
|
+
|
|
647
|
+
// Cleanup user effect
|
|
648
|
+
userEffect()
|
|
649
|
+
expect(usersStoreCounter).toBe(1) // Still active due to usersEffect
|
|
650
|
+
|
|
651
|
+
// Cleanup users effect
|
|
652
|
+
usersEffect()
|
|
653
|
+
expect(usersStoreCounter).toBe(0) // Now cleaned up
|
|
654
|
+
})
|
|
655
|
+
})
|
|
663
656
|
})
|
|
@@ -124,7 +124,7 @@ function makeDependentRows(
|
|
|
124
124
|
): Computed<number>[][] {
|
|
125
125
|
let prevRow = sources
|
|
126
126
|
const rand = new Random('seed')
|
|
127
|
-
const rows = []
|
|
127
|
+
const rows: Computed<number>[][] = []
|
|
128
128
|
for (let l = 0; l < numRows; l++) {
|
|
129
129
|
const row = makeRow(
|
|
130
130
|
prevRow,
|
|
@@ -135,7 +135,7 @@ function makeDependentRows(
|
|
|
135
135
|
l,
|
|
136
136
|
rand,
|
|
137
137
|
)
|
|
138
|
-
rows.push(row
|
|
138
|
+
rows.push(row)
|
|
139
139
|
prevRow = row
|
|
140
140
|
}
|
|
141
141
|
return rows
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": false,
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationDir": "./types",
|
|
7
|
+
"emitDeclarationOnly": true,
|
|
8
|
+
},
|
|
9
|
+
"include": ["./index.ts", "./src/**/*.ts"],
|
|
10
|
+
"exclude": ["node_modules", "types", "test", "archive"],
|
|
11
|
+
}
|