@zeix/cause-effect 0.17.3 → 0.18.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 +163 -232
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +166 -116
- package/ARCHITECTURE.md +274 -0
- package/CLAUDE.md +199 -143
- package/COLLECTION_REFACTORING.md +161 -0
- package/GUIDE.md +298 -0
- package/README.md +232 -197
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/index.dev.js +1325 -997
- package/index.js +1 -1
- package/index.ts +58 -74
- package/package.json +4 -1
- package/src/errors.ts +118 -74
- package/src/graph.ts +601 -0
- package/src/nodes/collection.ts +474 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +588 -0
- package/src/nodes/memo.ts +120 -0
- package/src/nodes/sensor.ts +139 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +383 -0
- package/src/nodes/task.ts +146 -0
- package/src/signal.ts +112 -66
- package/src/util.ts +26 -57
- package/test/batch.test.ts +96 -62
- package/test/benchmark.test.ts +473 -487
- package/test/collection.test.ts +466 -706
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +380 -0
- package/test/regression.test.ts +156 -0
- package/test/scope.test.ts +191 -0
- package/test/sensor.test.ts +454 -0
- package/test/signal.test.ts +220 -213
- package/test/state.test.ts +217 -265
- package/test/store.test.ts +346 -446
- package/test/task.test.ts +395 -0
- package/test/untrack.test.ts +167 -0
- package/types/index.d.ts +13 -15
- package/types/src/errors.d.ts +73 -17
- package/types/src/graph.d.ts +208 -0
- package/types/src/nodes/collection.d.ts +64 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +65 -0
- package/types/src/nodes/memo.d.ts +57 -0
- package/types/src/nodes/sensor.d.ts +75 -0
- package/types/src/nodes/state.d.ts +78 -0
- package/types/src/nodes/store.d.ts +51 -0
- package/types/src/nodes/task.d.ts +73 -0
- package/types/src/signal.d.ts +43 -29
- package/types/src/util.d.ts +9 -16
- package/archive/benchmark.ts +0 -683
- package/archive/collection.ts +0 -253
- package/archive/composite.ts +0 -85
- package/archive/computed.ts +0 -195
- package/archive/list.ts +0 -483
- package/archive/memo.ts +0 -139
- package/archive/state.ts +0 -90
- package/archive/store.ts +0 -298
- package/archive/task.ts +0 -189
- package/src/classes/collection.ts +0 -245
- package/src/classes/computed.ts +0 -349
- package/src/classes/list.ts +0 -343
- package/src/classes/ref.ts +0 -70
- package/src/classes/state.ts +0 -102
- package/src/classes/store.ts +0 -262
- package/src/diff.ts +0 -138
- package/src/effect.ts +0 -93
- package/src/match.ts +0 -45
- package/src/resolve.ts +0 -49
- package/src/system.ts +0 -257
- package/test/computed.test.ts +0 -1108
- package/test/diff.test.ts +0 -955
- package/test/match.test.ts +0 -388
- package/test/ref.test.ts +0 -353
- package/test/resolve.test.ts +0 -154
- package/types/src/classes/collection.d.ts +0 -45
- package/types/src/classes/computed.d.ts +0 -94
- package/types/src/classes/list.d.ts +0 -43
- package/types/src/classes/ref.d.ts +0 -35
- package/types/src/classes/state.d.ts +0 -49
- package/types/src/classes/store.d.ts +0 -52
- package/types/src/diff.d.ts +0 -28
- package/types/src/effect.d.ts +0 -15
- package/types/src/match.d.ts +0 -21
- package/types/src/resolve.d.ts +0 -29
- package/types/src/system.d.ts +0 -78
package/test/computed.test.ts
DELETED
|
@@ -1,1108 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
createEffect,
|
|
4
|
-
isComputed,
|
|
5
|
-
isState,
|
|
6
|
-
Memo,
|
|
7
|
-
match,
|
|
8
|
-
resolve,
|
|
9
|
-
State,
|
|
10
|
-
Task,
|
|
11
|
-
UNSET,
|
|
12
|
-
} from '../index.ts'
|
|
13
|
-
|
|
14
|
-
/* === Utility Functions === */
|
|
15
|
-
|
|
16
|
-
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
17
|
-
const increment = (n: number) => (Number.isFinite(n) ? n + 1 : UNSET)
|
|
18
|
-
|
|
19
|
-
/* === Tests === */
|
|
20
|
-
|
|
21
|
-
describe('Computed', () => {
|
|
22
|
-
test('should identify computed signals with isComputed()', () => {
|
|
23
|
-
const count = new State(42)
|
|
24
|
-
const doubled = new Memo(() => count.get() * 2)
|
|
25
|
-
expect(isComputed(doubled)).toBe(true)
|
|
26
|
-
expect(isState(doubled)).toBe(false)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('should compute a function', () => {
|
|
30
|
-
const derived = new Memo(() => 1 + 2)
|
|
31
|
-
expect(derived.get()).toBe(3)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
test('should compute function dependent on a signal', () => {
|
|
35
|
-
const cause = new State(42)
|
|
36
|
-
const derived = new Memo(() => cause.get() + 1)
|
|
37
|
-
expect(derived.get()).toBe(43)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('should compute function dependent on an updated signal', () => {
|
|
41
|
-
const cause = new State(42)
|
|
42
|
-
const derived = new Memo(() => cause.get() + 1)
|
|
43
|
-
cause.set(24)
|
|
44
|
-
expect(derived.get()).toBe(25)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('should compute function dependent on an async signal', async () => {
|
|
48
|
-
const status = new State('pending')
|
|
49
|
-
const promised = new Task(async () => {
|
|
50
|
-
await wait(100)
|
|
51
|
-
status.set('success')
|
|
52
|
-
return 42
|
|
53
|
-
})
|
|
54
|
-
const derived = new Memo(() => increment(promised.get()))
|
|
55
|
-
expect(derived.get()).toBe(UNSET)
|
|
56
|
-
expect(status.get()).toBe('pending')
|
|
57
|
-
await wait(110)
|
|
58
|
-
expect(derived.get()).toBe(43)
|
|
59
|
-
expect(status.get()).toBe('success')
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
test('should handle errors from an async signal gracefully', async () => {
|
|
63
|
-
const status = new State('pending')
|
|
64
|
-
const error = new State('')
|
|
65
|
-
const promised = new Task(async () => {
|
|
66
|
-
await wait(100)
|
|
67
|
-
status.set('error')
|
|
68
|
-
error.set('error occurred')
|
|
69
|
-
return 0
|
|
70
|
-
})
|
|
71
|
-
const derived = new Memo(() => increment(promised.get()))
|
|
72
|
-
expect(derived.get()).toBe(UNSET)
|
|
73
|
-
expect(status.get()).toBe('pending')
|
|
74
|
-
await wait(110)
|
|
75
|
-
expect(error.get()).toBe('error occurred')
|
|
76
|
-
expect(status.get()).toBe('error')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('should compute task signals in parallel without waterfalls', async () => {
|
|
80
|
-
const a = new Task(async () => {
|
|
81
|
-
await wait(100)
|
|
82
|
-
return 10
|
|
83
|
-
})
|
|
84
|
-
const b = new Task(async () => {
|
|
85
|
-
await wait(100)
|
|
86
|
-
return 20
|
|
87
|
-
})
|
|
88
|
-
const c = new Memo(() => {
|
|
89
|
-
const aValue = a.get()
|
|
90
|
-
const bValue = b.get()
|
|
91
|
-
return aValue === UNSET || bValue === UNSET
|
|
92
|
-
? UNSET
|
|
93
|
-
: aValue + bValue
|
|
94
|
-
})
|
|
95
|
-
expect(c.get()).toBe(UNSET)
|
|
96
|
-
await wait(110)
|
|
97
|
-
expect(c.get()).toBe(30)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('should compute function dependent on a chain of computed states dependent on a signal', () => {
|
|
101
|
-
const x = new State(42)
|
|
102
|
-
const a = new Memo(() => x.get() + 1)
|
|
103
|
-
const b = new Memo(() => a.get() * 2)
|
|
104
|
-
const c = new Memo(() => b.get() + 1)
|
|
105
|
-
expect(c.get()).toBe(87)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('should compute function dependent on a chain of computed states dependent on an updated signal', () => {
|
|
109
|
-
const x = new State(42)
|
|
110
|
-
const a = new Memo(() => x.get() + 1)
|
|
111
|
-
const b = new Memo(() => a.get() * 2)
|
|
112
|
-
const c = new Memo(() => b.get() + 1)
|
|
113
|
-
x.set(24)
|
|
114
|
-
expect(c.get()).toBe(51)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('should drop X->B->X updates', () => {
|
|
118
|
-
let count = 0
|
|
119
|
-
const x = new State(2)
|
|
120
|
-
const a = new Memo(() => x.get() - 1)
|
|
121
|
-
const b = new Memo(() => x.get() + a.get())
|
|
122
|
-
const c = new Memo(() => {
|
|
123
|
-
count++
|
|
124
|
-
return `c: ${b.get()}`
|
|
125
|
-
})
|
|
126
|
-
expect(c.get()).toBe('c: 3')
|
|
127
|
-
expect(count).toBe(1)
|
|
128
|
-
x.set(4)
|
|
129
|
-
expect(c.get()).toBe('c: 7')
|
|
130
|
-
expect(count).toBe(2)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('should only update every signal once (diamond graph)', () => {
|
|
134
|
-
let count = 0
|
|
135
|
-
const x = new State('a')
|
|
136
|
-
const a = new Memo(() => x.get())
|
|
137
|
-
const b = new Memo(() => x.get())
|
|
138
|
-
const c = new Memo(() => {
|
|
139
|
-
count++
|
|
140
|
-
return `${a.get()} ${b.get()}`
|
|
141
|
-
})
|
|
142
|
-
expect(c.get()).toBe('a a')
|
|
143
|
-
expect(count).toBe(1)
|
|
144
|
-
x.set('aa')
|
|
145
|
-
// flush()
|
|
146
|
-
expect(c.get()).toBe('aa aa')
|
|
147
|
-
expect(count).toBe(2)
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
test('should only update every signal once (diamond graph + tail)', () => {
|
|
151
|
-
let count = 0
|
|
152
|
-
const x = new State('a')
|
|
153
|
-
const a = new Memo(() => x.get())
|
|
154
|
-
const b = new Memo(() => x.get())
|
|
155
|
-
const c = new Memo(() => `${a.get()} ${b.get()}`)
|
|
156
|
-
const d = new Memo(() => {
|
|
157
|
-
count++
|
|
158
|
-
return c.get()
|
|
159
|
-
})
|
|
160
|
-
expect(d.get()).toBe('a a')
|
|
161
|
-
expect(count).toBe(1)
|
|
162
|
-
x.set('aa')
|
|
163
|
-
expect(d.get()).toBe('aa aa')
|
|
164
|
-
expect(count).toBe(2)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
test('should update multiple times after multiple state changes', () => {
|
|
168
|
-
const a = new State(3)
|
|
169
|
-
const b = new State(4)
|
|
170
|
-
let count = 0
|
|
171
|
-
const sum = new Memo(() => {
|
|
172
|
-
count++
|
|
173
|
-
return a.get() + b.get()
|
|
174
|
-
})
|
|
175
|
-
expect(sum.get()).toBe(7)
|
|
176
|
-
a.set(6)
|
|
177
|
-
expect(sum.get()).toBe(10)
|
|
178
|
-
b.set(8)
|
|
179
|
-
expect(sum.get()).toBe(14)
|
|
180
|
-
expect(count).toBe(3)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
/*
|
|
184
|
-
* Note for the next two tests:
|
|
185
|
-
*
|
|
186
|
-
* Due to the lazy evaluation strategy, unchanged computed signals may propagate
|
|
187
|
-
* change notifications one additional time before stabilizing. This is a
|
|
188
|
-
* one-time performance cost that allows for efficient memoization and
|
|
189
|
-
* error handling in most cases.
|
|
190
|
-
*/
|
|
191
|
-
test('should bail out if result is the same', () => {
|
|
192
|
-
let count = 0
|
|
193
|
-
const x = new State('a')
|
|
194
|
-
const a = new Memo(() => {
|
|
195
|
-
x.get()
|
|
196
|
-
return 'foo'
|
|
197
|
-
})
|
|
198
|
-
const b = new Memo(() => {
|
|
199
|
-
count++
|
|
200
|
-
return a.get()
|
|
201
|
-
})
|
|
202
|
-
expect(b.get()).toBe('foo')
|
|
203
|
-
expect(count).toBe(1)
|
|
204
|
-
x.set('aa')
|
|
205
|
-
x.set('aaa')
|
|
206
|
-
x.set('aaaa')
|
|
207
|
-
expect(b.get()).toBe('foo')
|
|
208
|
-
expect(count).toBe(2)
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
test('should block if result remains unchanged', () => {
|
|
212
|
-
let count = 0
|
|
213
|
-
const x = new State(42)
|
|
214
|
-
const a = new Memo(() => x.get() % 2)
|
|
215
|
-
const b = new Memo(() => (a.get() ? 'odd' : 'even'))
|
|
216
|
-
const c = new Memo(() => {
|
|
217
|
-
count++
|
|
218
|
-
return `c: ${b.get()}`
|
|
219
|
-
})
|
|
220
|
-
expect(c.get()).toBe('c: even')
|
|
221
|
-
expect(count).toBe(1)
|
|
222
|
-
x.set(44)
|
|
223
|
-
x.set(46)
|
|
224
|
-
x.set(48)
|
|
225
|
-
expect(c.get()).toBe('c: even')
|
|
226
|
-
expect(count).toBe(2)
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
test('should detect and throw error for circular dependencies', () => {
|
|
230
|
-
const a = new State(1)
|
|
231
|
-
const b = new Memo(() => c.get() + 1)
|
|
232
|
-
const c = new Memo((): number => b.get() + a.get())
|
|
233
|
-
expect(() => {
|
|
234
|
-
b.get() // This should trigger the circular dependency
|
|
235
|
-
}).toThrow('Circular dependency detected in memo')
|
|
236
|
-
expect(a.get()).toBe(1)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
test('should propagate error if an error occurred', () => {
|
|
240
|
-
let okCount = 0
|
|
241
|
-
let errCount = 0
|
|
242
|
-
const x = new State(0)
|
|
243
|
-
const a = new Memo(() => {
|
|
244
|
-
if (x.get() === 1) throw new Error('Calculation error')
|
|
245
|
-
return 1
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
// Replace matcher with try/catch in a computed
|
|
249
|
-
const b = new Memo(() => {
|
|
250
|
-
try {
|
|
251
|
-
a.get() // just check if it works
|
|
252
|
-
return `c: success`
|
|
253
|
-
} catch (_error) {
|
|
254
|
-
errCount++
|
|
255
|
-
return `c: recovered`
|
|
256
|
-
}
|
|
257
|
-
})
|
|
258
|
-
const c = new Memo(() => {
|
|
259
|
-
okCount++
|
|
260
|
-
return b.get()
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
expect(a.get()).toBe(1)
|
|
264
|
-
expect(c.get()).toBe('c: success')
|
|
265
|
-
expect(okCount).toBe(1)
|
|
266
|
-
try {
|
|
267
|
-
x.set(1)
|
|
268
|
-
expect(a.get()).toBe(1)
|
|
269
|
-
expect(true).toBe(false) // This line should not be reached
|
|
270
|
-
} catch (error) {
|
|
271
|
-
expect((error as Error).message).toBe('Calculation error')
|
|
272
|
-
} finally {
|
|
273
|
-
expect(c.get()).toBe('c: recovered')
|
|
274
|
-
expect(okCount).toBe(2)
|
|
275
|
-
expect(errCount).toBe(1)
|
|
276
|
-
}
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
test('should create an effect that reacts on async computed changes', async () => {
|
|
280
|
-
const cause = new State(42)
|
|
281
|
-
const derived = new Task(async () => {
|
|
282
|
-
await wait(100)
|
|
283
|
-
return cause.get() + 1
|
|
284
|
-
})
|
|
285
|
-
let okCount = 0
|
|
286
|
-
let nilCount = 0
|
|
287
|
-
let result: number = 0
|
|
288
|
-
createEffect(() => {
|
|
289
|
-
const resolved = resolve({ derived })
|
|
290
|
-
match(resolved, {
|
|
291
|
-
ok: ({ derived: v }) => {
|
|
292
|
-
result = v
|
|
293
|
-
okCount++
|
|
294
|
-
},
|
|
295
|
-
nil: () => {
|
|
296
|
-
nilCount++
|
|
297
|
-
},
|
|
298
|
-
})
|
|
299
|
-
})
|
|
300
|
-
cause.set(43)
|
|
301
|
-
expect(okCount).toBe(0)
|
|
302
|
-
expect(nilCount).toBe(1)
|
|
303
|
-
expect(result).toBe(0)
|
|
304
|
-
|
|
305
|
-
await wait(110)
|
|
306
|
-
expect(okCount).toBe(1) // not +1 because initial state never made it here
|
|
307
|
-
expect(nilCount).toBe(1)
|
|
308
|
-
expect(result).toBe(44)
|
|
309
|
-
})
|
|
310
|
-
|
|
311
|
-
test('should handle complex computed signal with error and async dependencies', async () => {
|
|
312
|
-
const toggleState = new State(true)
|
|
313
|
-
const errorProne = new Memo(() => {
|
|
314
|
-
if (toggleState.get()) throw new Error('Intentional error')
|
|
315
|
-
return 42
|
|
316
|
-
})
|
|
317
|
-
const asyncValue = new Task(async () => {
|
|
318
|
-
await wait(50)
|
|
319
|
-
return 10
|
|
320
|
-
})
|
|
321
|
-
let okCount = 0
|
|
322
|
-
let nilCount = 0
|
|
323
|
-
let errCount = 0
|
|
324
|
-
// let _result: number = 0
|
|
325
|
-
|
|
326
|
-
const complexComputed = new Memo(() => {
|
|
327
|
-
try {
|
|
328
|
-
const x = errorProne.get()
|
|
329
|
-
const y = asyncValue.get()
|
|
330
|
-
if (y === UNSET) {
|
|
331
|
-
// not ready yet
|
|
332
|
-
nilCount++
|
|
333
|
-
return 0
|
|
334
|
-
} else {
|
|
335
|
-
// happy path
|
|
336
|
-
okCount++
|
|
337
|
-
return x + y
|
|
338
|
-
}
|
|
339
|
-
} catch (_error) {
|
|
340
|
-
// error path
|
|
341
|
-
errCount++
|
|
342
|
-
return -1
|
|
343
|
-
}
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
for (let i = 0; i < 10; i++) {
|
|
347
|
-
toggleState.set(!!(i % 2))
|
|
348
|
-
await wait(10)
|
|
349
|
-
complexComputed.get()
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Adjusted expectations to be more flexible
|
|
353
|
-
expect(nilCount + okCount + errCount).toBe(10)
|
|
354
|
-
expect(okCount).toBeGreaterThan(0)
|
|
355
|
-
expect(errCount).toBeGreaterThan(0)
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
test('should handle signal changes during async computation', async () => {
|
|
359
|
-
const source = new State(1)
|
|
360
|
-
let computationCount = 0
|
|
361
|
-
const derived = new Task(async (_, abort) => {
|
|
362
|
-
computationCount++
|
|
363
|
-
expect(abort?.aborted).toBe(false)
|
|
364
|
-
await wait(100)
|
|
365
|
-
return source.get()
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
// Start first computation
|
|
369
|
-
expect(derived.get()).toBe(UNSET)
|
|
370
|
-
expect(computationCount).toBe(1)
|
|
371
|
-
|
|
372
|
-
// Change source before first computation completes
|
|
373
|
-
source.set(2)
|
|
374
|
-
await wait(210)
|
|
375
|
-
expect(derived.get()).toBe(2)
|
|
376
|
-
expect(computationCount).toBe(1)
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
test('should handle multiple rapid changes during async computation', async () => {
|
|
380
|
-
const source = new State(1)
|
|
381
|
-
let computationCount = 0
|
|
382
|
-
const derived = new Task(async (_, abort) => {
|
|
383
|
-
computationCount++
|
|
384
|
-
expect(abort?.aborted).toBe(false)
|
|
385
|
-
await wait(100)
|
|
386
|
-
return source.get()
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
// Start first computation
|
|
390
|
-
expect(derived.get()).toBe(UNSET)
|
|
391
|
-
expect(computationCount).toBe(1)
|
|
392
|
-
|
|
393
|
-
// Make multiple rapid changes
|
|
394
|
-
source.set(2)
|
|
395
|
-
source.set(3)
|
|
396
|
-
source.set(4)
|
|
397
|
-
await wait(210)
|
|
398
|
-
|
|
399
|
-
// Should have computed twice (initial + final change)
|
|
400
|
-
expect(derived.get()).toBe(4)
|
|
401
|
-
expect(computationCount).toBe(1)
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
test('should handle errors in aborted computations', async () => {
|
|
405
|
-
const source = new State(1)
|
|
406
|
-
const derived = new Task(async () => {
|
|
407
|
-
await wait(100)
|
|
408
|
-
const value = source.get()
|
|
409
|
-
if (value === 2) throw new Error('Intentional error')
|
|
410
|
-
return value
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
// Start first computation
|
|
414
|
-
expect(derived.get()).toBe(UNSET)
|
|
415
|
-
|
|
416
|
-
// Change to error state before first computation completes
|
|
417
|
-
source.set(2)
|
|
418
|
-
await wait(110)
|
|
419
|
-
expect(() => derived.get()).toThrow('Intentional error')
|
|
420
|
-
|
|
421
|
-
// Change to normal state before second computation completes
|
|
422
|
-
source.set(3)
|
|
423
|
-
await wait(100)
|
|
424
|
-
expect(derived.get()).toBe(3)
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
describe('Input Validation', () => {
|
|
428
|
-
test('should throw InvalidCallbackError when callback is not a function', () => {
|
|
429
|
-
expect(() => {
|
|
430
|
-
// @ts-expect-error - Testing invalid input
|
|
431
|
-
new Memo(null)
|
|
432
|
-
}).toThrow('Invalid Memo callback null')
|
|
433
|
-
|
|
434
|
-
expect(() => {
|
|
435
|
-
// @ts-expect-error - Testing invalid input
|
|
436
|
-
new Memo(undefined)
|
|
437
|
-
}).toThrow('Invalid Memo callback undefined')
|
|
438
|
-
|
|
439
|
-
expect(() => {
|
|
440
|
-
// @ts-expect-error - Testing invalid input
|
|
441
|
-
new Memo(42)
|
|
442
|
-
}).toThrow('Invalid Memo callback 42')
|
|
443
|
-
|
|
444
|
-
expect(() => {
|
|
445
|
-
// @ts-expect-error - Testing invalid input
|
|
446
|
-
new Memo('not a function')
|
|
447
|
-
}).toThrow('Invalid Memo callback "not a function"')
|
|
448
|
-
|
|
449
|
-
expect(() => {
|
|
450
|
-
// @ts-expect-error - Testing invalid input
|
|
451
|
-
new Memo({ not: 'a function' })
|
|
452
|
-
}).toThrow('Invalid Memo callback {"not":"a function"}')
|
|
453
|
-
|
|
454
|
-
expect(() => {
|
|
455
|
-
// @ts-expect-error - Testing invalid input
|
|
456
|
-
new Memo((_a: unknown, _b: unknown, _c: unknown) => 42)
|
|
457
|
-
}).toThrow('Invalid Memo callback (_a, _b, _c) => 42')
|
|
458
|
-
|
|
459
|
-
expect(() => {
|
|
460
|
-
// @ts-expect-error - Testing invalid input
|
|
461
|
-
new Memo(async (_a: unknown, _b: unknown) => 42)
|
|
462
|
-
}).toThrow('Invalid Memo callback async (_a, _b) => 42')
|
|
463
|
-
|
|
464
|
-
expect(() => {
|
|
465
|
-
// @ts-expect-error - Testing invalid input
|
|
466
|
-
new Task((_a: unknown) => 42)
|
|
467
|
-
}).toThrow('Invalid Task callback (_a) => 42')
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
test('should expect type error if null is passed for options.initialValue', () => {
|
|
471
|
-
expect(() => {
|
|
472
|
-
// @ts-expect-error - Testing invalid input
|
|
473
|
-
new Memo(() => 42, { initialValue: null })
|
|
474
|
-
}).not.toThrow()
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
test('should allow valid callbacks and non-nullish initialValues', () => {
|
|
478
|
-
// These should not throw
|
|
479
|
-
expect(() => {
|
|
480
|
-
new Memo(() => 42)
|
|
481
|
-
}).not.toThrow()
|
|
482
|
-
|
|
483
|
-
expect(() => {
|
|
484
|
-
new Memo(() => 42, { initialValue: 0 })
|
|
485
|
-
}).not.toThrow()
|
|
486
|
-
|
|
487
|
-
expect(() => {
|
|
488
|
-
new Memo(() => 'foo', { initialValue: '' })
|
|
489
|
-
}).not.toThrow()
|
|
490
|
-
|
|
491
|
-
expect(() => {
|
|
492
|
-
new Memo(() => true, { initialValue: false })
|
|
493
|
-
}).not.toThrow()
|
|
494
|
-
|
|
495
|
-
expect(() => {
|
|
496
|
-
new Task(async () => ({ id: 42, name: 'John' }), {
|
|
497
|
-
initialValue: UNSET,
|
|
498
|
-
})
|
|
499
|
-
}).not.toThrow()
|
|
500
|
-
})
|
|
501
|
-
})
|
|
502
|
-
|
|
503
|
-
describe('Initial Value and Old Value', () => {
|
|
504
|
-
test('should use initialValue when provided', () => {
|
|
505
|
-
const computed = new Memo((oldValue: number) => oldValue + 1, {
|
|
506
|
-
initialValue: 10,
|
|
507
|
-
})
|
|
508
|
-
expect(computed.get()).toBe(11)
|
|
509
|
-
})
|
|
510
|
-
|
|
511
|
-
test('should pass current value as oldValue to callback', () => {
|
|
512
|
-
const state = new State(5)
|
|
513
|
-
let receivedOldValue: number | undefined
|
|
514
|
-
const computed = new Memo(
|
|
515
|
-
(oldValue: number) => {
|
|
516
|
-
receivedOldValue = oldValue
|
|
517
|
-
return state.get() * 2
|
|
518
|
-
},
|
|
519
|
-
{ initialValue: 0 },
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
expect(computed.get()).toBe(10)
|
|
523
|
-
expect(receivedOldValue).toBe(0)
|
|
524
|
-
|
|
525
|
-
state.set(3)
|
|
526
|
-
expect(computed.get()).toBe(6)
|
|
527
|
-
expect(receivedOldValue).toBe(10)
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
test('should work as reducer function with oldValue', () => {
|
|
531
|
-
const increment = new State(0)
|
|
532
|
-
const sum = new Memo(
|
|
533
|
-
(oldValue: number) => {
|
|
534
|
-
const inc = increment.get()
|
|
535
|
-
return inc === 0 ? oldValue : oldValue + inc
|
|
536
|
-
},
|
|
537
|
-
{ initialValue: 0 },
|
|
538
|
-
)
|
|
539
|
-
|
|
540
|
-
expect(sum.get()).toBe(0)
|
|
541
|
-
|
|
542
|
-
increment.set(5)
|
|
543
|
-
expect(sum.get()).toBe(5)
|
|
544
|
-
|
|
545
|
-
increment.set(3)
|
|
546
|
-
expect(sum.get()).toBe(8)
|
|
547
|
-
|
|
548
|
-
increment.set(2)
|
|
549
|
-
expect(sum.get()).toBe(10)
|
|
550
|
-
})
|
|
551
|
-
|
|
552
|
-
test('should handle array accumulation with oldValue', () => {
|
|
553
|
-
const item = new State('')
|
|
554
|
-
const items = new Memo(
|
|
555
|
-
(oldValue: string[]) => {
|
|
556
|
-
const newItem = item.get()
|
|
557
|
-
return newItem === '' ? oldValue : [...oldValue, newItem]
|
|
558
|
-
},
|
|
559
|
-
{ initialValue: [] as string[] },
|
|
560
|
-
)
|
|
561
|
-
|
|
562
|
-
expect(items.get()).toEqual([])
|
|
563
|
-
|
|
564
|
-
item.set('first')
|
|
565
|
-
expect(items.get()).toEqual(['first'])
|
|
566
|
-
|
|
567
|
-
item.set('second')
|
|
568
|
-
expect(items.get()).toEqual(['first', 'second'])
|
|
569
|
-
|
|
570
|
-
item.set('third')
|
|
571
|
-
expect(items.get()).toEqual(['first', 'second', 'third'])
|
|
572
|
-
})
|
|
573
|
-
|
|
574
|
-
test('should handle counter with oldValue and multiple dependencies', () => {
|
|
575
|
-
const reset = new State(false)
|
|
576
|
-
const add = new State(0)
|
|
577
|
-
const counter = new Memo(
|
|
578
|
-
(oldValue: number) => {
|
|
579
|
-
if (reset.get()) return 0
|
|
580
|
-
const increment = add.get()
|
|
581
|
-
return increment === 0 ? oldValue : oldValue + increment
|
|
582
|
-
},
|
|
583
|
-
{
|
|
584
|
-
initialValue: 0,
|
|
585
|
-
},
|
|
586
|
-
)
|
|
587
|
-
|
|
588
|
-
expect(counter.get()).toBe(0)
|
|
589
|
-
|
|
590
|
-
add.set(5)
|
|
591
|
-
expect(counter.get()).toBe(5)
|
|
592
|
-
|
|
593
|
-
add.set(3)
|
|
594
|
-
expect(counter.get()).toBe(8)
|
|
595
|
-
|
|
596
|
-
reset.set(true)
|
|
597
|
-
expect(counter.get()).toBe(0)
|
|
598
|
-
|
|
599
|
-
reset.set(false)
|
|
600
|
-
add.set(2)
|
|
601
|
-
expect(counter.get()).toBe(2)
|
|
602
|
-
})
|
|
603
|
-
|
|
604
|
-
test('should pass UNSET as oldValue when no initialValue provided', () => {
|
|
605
|
-
let receivedOldValue: number | undefined
|
|
606
|
-
const state = new State(42)
|
|
607
|
-
const computed = new Memo((oldValue: number) => {
|
|
608
|
-
receivedOldValue = oldValue
|
|
609
|
-
return state.get()
|
|
610
|
-
})
|
|
611
|
-
|
|
612
|
-
expect(computed.get()).toBe(42)
|
|
613
|
-
expect(receivedOldValue).toBe(UNSET)
|
|
614
|
-
})
|
|
615
|
-
|
|
616
|
-
test('should work with async computation and oldValue', async () => {
|
|
617
|
-
let receivedOldValue: number | undefined
|
|
618
|
-
|
|
619
|
-
const asyncComputed = new Task(
|
|
620
|
-
async (oldValue: number) => {
|
|
621
|
-
receivedOldValue = oldValue
|
|
622
|
-
await wait(50)
|
|
623
|
-
return oldValue + 5
|
|
624
|
-
},
|
|
625
|
-
{
|
|
626
|
-
initialValue: 10,
|
|
627
|
-
},
|
|
628
|
-
)
|
|
629
|
-
|
|
630
|
-
// Initially returns initialValue before async computation completes
|
|
631
|
-
expect(asyncComputed.get()).toBe(10)
|
|
632
|
-
|
|
633
|
-
// Wait for async computation to complete
|
|
634
|
-
await wait(60)
|
|
635
|
-
expect(asyncComputed.get()).toBe(15) // 10 + 5
|
|
636
|
-
expect(receivedOldValue).toBe(10)
|
|
637
|
-
})
|
|
638
|
-
|
|
639
|
-
test('should handle object updates with oldValue', () => {
|
|
640
|
-
const key = new State('')
|
|
641
|
-
const value = new State('')
|
|
642
|
-
const obj = new Memo(
|
|
643
|
-
(oldValue: Record<string, string>) => {
|
|
644
|
-
const k = key.get()
|
|
645
|
-
const v = value.get()
|
|
646
|
-
if (k === '' || v === '') return oldValue
|
|
647
|
-
return { ...oldValue, [k]: v }
|
|
648
|
-
},
|
|
649
|
-
{ initialValue: {} as Record<string, string> },
|
|
650
|
-
)
|
|
651
|
-
|
|
652
|
-
expect(obj.get()).toEqual({})
|
|
653
|
-
|
|
654
|
-
key.set('name')
|
|
655
|
-
value.set('Alice')
|
|
656
|
-
expect(obj.get()).toEqual({ name: 'Alice' })
|
|
657
|
-
|
|
658
|
-
key.set('age')
|
|
659
|
-
value.set('30')
|
|
660
|
-
expect(obj.get()).toEqual({ name: 'Alice', age: '30' })
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
test('should handle async computation with AbortSignal and oldValue', async () => {
|
|
664
|
-
const source = new State(1)
|
|
665
|
-
let computationCount = 0
|
|
666
|
-
const receivedOldValues: number[] = []
|
|
667
|
-
|
|
668
|
-
const asyncComputed = new Task(
|
|
669
|
-
async (oldValue: number, abort: AbortSignal) => {
|
|
670
|
-
computationCount++
|
|
671
|
-
receivedOldValues.push(oldValue)
|
|
672
|
-
|
|
673
|
-
// Simulate async work
|
|
674
|
-
await wait(100)
|
|
675
|
-
|
|
676
|
-
// Check if computation was aborted
|
|
677
|
-
if (abort.aborted) {
|
|
678
|
-
return oldValue
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
return source.get() + oldValue
|
|
682
|
-
},
|
|
683
|
-
{
|
|
684
|
-
initialValue: 0,
|
|
685
|
-
},
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
// Initial computation
|
|
689
|
-
expect(asyncComputed.get()).toBe(0) // Returns initialValue immediately
|
|
690
|
-
|
|
691
|
-
// Change source before first computation completes
|
|
692
|
-
source.set(2)
|
|
693
|
-
|
|
694
|
-
// Wait for computation to complete
|
|
695
|
-
await wait(110)
|
|
696
|
-
|
|
697
|
-
// Should have the result from the computation that wasn't aborted
|
|
698
|
-
expect(asyncComputed.get()).toBe(2) // 2 + 0 (initialValue was used as oldValue)
|
|
699
|
-
expect(computationCount).toBe(1) // Only one computation completed
|
|
700
|
-
expect(receivedOldValues).toEqual([0])
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
test('should work with error handling and oldValue', () => {
|
|
704
|
-
const shouldError = new State(false)
|
|
705
|
-
const counter = new State(1)
|
|
706
|
-
|
|
707
|
-
const computed = new Memo(
|
|
708
|
-
(oldValue: number) => {
|
|
709
|
-
if (shouldError.get()) {
|
|
710
|
-
throw new Error('Computation failed')
|
|
711
|
-
}
|
|
712
|
-
// Handle UNSET case by treating it as 0
|
|
713
|
-
const safeOldValue = oldValue === UNSET ? 0 : oldValue
|
|
714
|
-
return safeOldValue + counter.get()
|
|
715
|
-
},
|
|
716
|
-
{
|
|
717
|
-
initialValue: 10,
|
|
718
|
-
},
|
|
719
|
-
)
|
|
720
|
-
|
|
721
|
-
expect(computed.get()).toBe(11) // 10 + 1
|
|
722
|
-
|
|
723
|
-
counter.set(5)
|
|
724
|
-
expect(computed.get()).toBe(16) // 11 + 5
|
|
725
|
-
|
|
726
|
-
// Trigger error
|
|
727
|
-
shouldError.set(true)
|
|
728
|
-
expect(() => computed.get()).toThrow('Computation failed')
|
|
729
|
-
|
|
730
|
-
// Recover from error
|
|
731
|
-
shouldError.set(false)
|
|
732
|
-
counter.set(2)
|
|
733
|
-
|
|
734
|
-
// After error, oldValue should be UNSET, so we treat it as 0 and get 0 + 2 = 2
|
|
735
|
-
expect(computed.get()).toBe(2)
|
|
736
|
-
})
|
|
737
|
-
|
|
738
|
-
test('should work with complex state transitions using oldValue', () => {
|
|
739
|
-
const action = new State<
|
|
740
|
-
'increment' | 'decrement' | 'reset' | 'multiply'
|
|
741
|
-
>('increment')
|
|
742
|
-
const amount = new State(1)
|
|
743
|
-
|
|
744
|
-
const calculator = new Memo(
|
|
745
|
-
(oldValue: number) => {
|
|
746
|
-
const act = action.get()
|
|
747
|
-
const amt = amount.get()
|
|
748
|
-
|
|
749
|
-
switch (act) {
|
|
750
|
-
case 'increment':
|
|
751
|
-
return oldValue + amt
|
|
752
|
-
case 'decrement':
|
|
753
|
-
return oldValue - amt
|
|
754
|
-
case 'multiply':
|
|
755
|
-
return oldValue * amt
|
|
756
|
-
case 'reset':
|
|
757
|
-
return 0
|
|
758
|
-
default:
|
|
759
|
-
return oldValue
|
|
760
|
-
}
|
|
761
|
-
},
|
|
762
|
-
{
|
|
763
|
-
initialValue: 0,
|
|
764
|
-
},
|
|
765
|
-
)
|
|
766
|
-
|
|
767
|
-
expect(calculator.get()).toBe(1) // 0 + 1
|
|
768
|
-
|
|
769
|
-
amount.set(5)
|
|
770
|
-
expect(calculator.get()).toBe(6) // 1 + 5
|
|
771
|
-
|
|
772
|
-
action.set('multiply')
|
|
773
|
-
amount.set(2)
|
|
774
|
-
expect(calculator.get()).toBe(12) // 6 * 2
|
|
775
|
-
|
|
776
|
-
action.set('decrement')
|
|
777
|
-
amount.set(3)
|
|
778
|
-
expect(calculator.get()).toBe(9) // 12 - 3
|
|
779
|
-
|
|
780
|
-
action.set('reset')
|
|
781
|
-
expect(calculator.get()).toBe(0)
|
|
782
|
-
})
|
|
783
|
-
|
|
784
|
-
test('should handle edge cases with initialValue and oldValue', () => {
|
|
785
|
-
// Test with null/undefined-like values
|
|
786
|
-
const nullishComputed = new Memo(
|
|
787
|
-
oldValue => `${oldValue} updated`,
|
|
788
|
-
{ initialValue: '' },
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
expect(nullishComputed.get()).toBe(' updated')
|
|
792
|
-
|
|
793
|
-
// Test with complex object initialValue
|
|
794
|
-
interface StateObject {
|
|
795
|
-
count: number
|
|
796
|
-
items: string[]
|
|
797
|
-
meta: { created: Date }
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const now = new Date()
|
|
801
|
-
const objectComputed = new Memo(
|
|
802
|
-
(oldValue: StateObject) => ({
|
|
803
|
-
...oldValue,
|
|
804
|
-
count: oldValue.count + 1,
|
|
805
|
-
items: [...oldValue.items, `item${oldValue.count + 1}`],
|
|
806
|
-
}),
|
|
807
|
-
{
|
|
808
|
-
initialValue: {
|
|
809
|
-
count: 0,
|
|
810
|
-
items: [] as string[],
|
|
811
|
-
meta: { created: now },
|
|
812
|
-
},
|
|
813
|
-
},
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
const result = objectComputed.get()
|
|
817
|
-
expect(result.count).toBe(1)
|
|
818
|
-
expect(result.items).toEqual(['item1'])
|
|
819
|
-
expect(result.meta.created).toBe(now)
|
|
820
|
-
})
|
|
821
|
-
|
|
822
|
-
test('should preserve initialValue type consistency', () => {
|
|
823
|
-
// Test that oldValue type is consistent with initialValue
|
|
824
|
-
const stringComputed = new Memo(
|
|
825
|
-
(oldValue: string) => {
|
|
826
|
-
expect(typeof oldValue).toBe('string')
|
|
827
|
-
return oldValue.toUpperCase()
|
|
828
|
-
},
|
|
829
|
-
{
|
|
830
|
-
initialValue: 'hello',
|
|
831
|
-
},
|
|
832
|
-
)
|
|
833
|
-
|
|
834
|
-
expect(stringComputed.get()).toBe('HELLO')
|
|
835
|
-
|
|
836
|
-
const numberComputed = new Memo(
|
|
837
|
-
(oldValue: number) => {
|
|
838
|
-
expect(typeof oldValue).toBe('number')
|
|
839
|
-
expect(Number.isFinite(oldValue)).toBe(true)
|
|
840
|
-
return oldValue * 2
|
|
841
|
-
},
|
|
842
|
-
{
|
|
843
|
-
initialValue: 5,
|
|
844
|
-
},
|
|
845
|
-
)
|
|
846
|
-
|
|
847
|
-
expect(numberComputed.get()).toBe(10)
|
|
848
|
-
})
|
|
849
|
-
|
|
850
|
-
test('should work with chained computed using oldValue', () => {
|
|
851
|
-
const source = new State(1)
|
|
852
|
-
|
|
853
|
-
const first = new Memo(
|
|
854
|
-
(oldValue: number) => oldValue + source.get(),
|
|
855
|
-
{
|
|
856
|
-
initialValue: 10,
|
|
857
|
-
},
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
const second = new Memo(
|
|
861
|
-
(oldValue: number) => oldValue + first.get(),
|
|
862
|
-
{
|
|
863
|
-
initialValue: 20,
|
|
864
|
-
},
|
|
865
|
-
)
|
|
866
|
-
|
|
867
|
-
expect(first.get()).toBe(11) // 10 + 1
|
|
868
|
-
expect(second.get()).toBe(31) // 20 + 11
|
|
869
|
-
|
|
870
|
-
source.set(5)
|
|
871
|
-
expect(first.get()).toBe(16) // 11 + 5
|
|
872
|
-
expect(second.get()).toBe(47) // 31 + 16
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
test('should handle frequent updates with oldValue correctly', () => {
|
|
876
|
-
const trigger = new State(0)
|
|
877
|
-
let computationCount = 0
|
|
878
|
-
|
|
879
|
-
const accumulator = new Memo(
|
|
880
|
-
(oldValue: number) => {
|
|
881
|
-
computationCount++
|
|
882
|
-
return oldValue + trigger.get()
|
|
883
|
-
},
|
|
884
|
-
{
|
|
885
|
-
initialValue: 100,
|
|
886
|
-
},
|
|
887
|
-
)
|
|
888
|
-
|
|
889
|
-
expect(accumulator.get()).toBe(100) // 100 + 0
|
|
890
|
-
expect(computationCount).toBe(1)
|
|
891
|
-
|
|
892
|
-
// Make rapid changes
|
|
893
|
-
for (let i = 1; i <= 5; i++) {
|
|
894
|
-
trigger.set(i)
|
|
895
|
-
accumulator.get() // Force evaluation
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
expect(computationCount).toBe(6) // Initial + 5 updates
|
|
899
|
-
expect(accumulator.get()).toBe(115) // Final accumulated value
|
|
900
|
-
})
|
|
901
|
-
})
|
|
902
|
-
|
|
903
|
-
describe('Signal Options - Lazy Resource Management', () => {
|
|
904
|
-
test('Memo - should manage external resources lazily', async () => {
|
|
905
|
-
const source = new State(1)
|
|
906
|
-
let counter = 0
|
|
907
|
-
let intervalId: Timer | undefined
|
|
908
|
-
|
|
909
|
-
// Create memo that depends on source
|
|
910
|
-
const computed = new Memo(oldValue => source.get() * 2 + oldValue, {
|
|
911
|
-
initialValue: 0,
|
|
912
|
-
watched: () => {
|
|
913
|
-
intervalId = setInterval(() => {
|
|
914
|
-
counter++
|
|
915
|
-
}, 10)
|
|
916
|
-
},
|
|
917
|
-
unwatched: () => {
|
|
918
|
-
if (intervalId) {
|
|
919
|
-
clearInterval(intervalId)
|
|
920
|
-
intervalId = undefined
|
|
921
|
-
}
|
|
922
|
-
},
|
|
923
|
-
})
|
|
924
|
-
|
|
925
|
-
// Counter should not be running yet
|
|
926
|
-
expect(counter).toBe(0)
|
|
927
|
-
await wait(50)
|
|
928
|
-
expect(counter).toBe(0)
|
|
929
|
-
expect(intervalId).toBeUndefined()
|
|
930
|
-
|
|
931
|
-
// Effect subscribes to computed, triggering watched callback
|
|
932
|
-
const effectCleanup = createEffect(() => {
|
|
933
|
-
computed.get()
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
// Counter should now be running
|
|
937
|
-
await wait(50)
|
|
938
|
-
expect(counter).toBeGreaterThan(0)
|
|
939
|
-
expect(intervalId).toBeDefined()
|
|
940
|
-
|
|
941
|
-
// Stop effect, should cleanup resources
|
|
942
|
-
effectCleanup()
|
|
943
|
-
const counterAfterStop = counter
|
|
944
|
-
|
|
945
|
-
// Counter should stop incrementing
|
|
946
|
-
await wait(50)
|
|
947
|
-
expect(counter).toBe(counterAfterStop)
|
|
948
|
-
expect(intervalId).toBeUndefined()
|
|
949
|
-
})
|
|
950
|
-
|
|
951
|
-
test('Task - should manage external resources lazily', async () => {
|
|
952
|
-
const source = new State('initial')
|
|
953
|
-
let counter = 0
|
|
954
|
-
let intervalId: Timer | undefined
|
|
955
|
-
|
|
956
|
-
// Create task that depends on source
|
|
957
|
-
const computed = new Task(
|
|
958
|
-
async (oldValue: string, abort: AbortSignal) => {
|
|
959
|
-
const value = source.get()
|
|
960
|
-
await wait(10) // Simulate async work
|
|
961
|
-
|
|
962
|
-
if (abort.aborted) throw new Error('Aborted')
|
|
963
|
-
|
|
964
|
-
return `${value}-processed-${oldValue || 'none'}`
|
|
965
|
-
},
|
|
966
|
-
{
|
|
967
|
-
initialValue: 'default',
|
|
968
|
-
watched: () => {
|
|
969
|
-
intervalId = setInterval(() => {
|
|
970
|
-
counter++
|
|
971
|
-
}, 10)
|
|
972
|
-
},
|
|
973
|
-
unwatched: () => {
|
|
974
|
-
if (intervalId) {
|
|
975
|
-
clearInterval(intervalId)
|
|
976
|
-
intervalId = undefined
|
|
977
|
-
}
|
|
978
|
-
},
|
|
979
|
-
},
|
|
980
|
-
)
|
|
981
|
-
|
|
982
|
-
// Counter should not be running yet
|
|
983
|
-
expect(counter).toBe(0)
|
|
984
|
-
await wait(50)
|
|
985
|
-
expect(counter).toBe(0)
|
|
986
|
-
expect(intervalId).toBeUndefined()
|
|
987
|
-
|
|
988
|
-
// Effect subscribes to computed
|
|
989
|
-
const effectCleanup = createEffect(() => {
|
|
990
|
-
computed.get()
|
|
991
|
-
})
|
|
992
|
-
|
|
993
|
-
// Wait for async computation and counter to start
|
|
994
|
-
await wait(100)
|
|
995
|
-
expect(counter).toBeGreaterThan(0)
|
|
996
|
-
expect(intervalId).toBeDefined()
|
|
997
|
-
|
|
998
|
-
// Stop effect
|
|
999
|
-
effectCleanup()
|
|
1000
|
-
const counterAfterStop = counter
|
|
1001
|
-
|
|
1002
|
-
// Counter should stop incrementing
|
|
1003
|
-
await wait(50)
|
|
1004
|
-
expect(counter).toBe(counterAfterStop)
|
|
1005
|
-
expect(intervalId).toBeUndefined()
|
|
1006
|
-
})
|
|
1007
|
-
|
|
1008
|
-
test('Memo - multiple watchers should share resources', async () => {
|
|
1009
|
-
const source = new State(10)
|
|
1010
|
-
let subscriptionCount = 0
|
|
1011
|
-
|
|
1012
|
-
const computed = new Memo(
|
|
1013
|
-
(oldValue: number) => source.get() + oldValue,
|
|
1014
|
-
{
|
|
1015
|
-
initialValue: 0,
|
|
1016
|
-
watched: () => {
|
|
1017
|
-
subscriptionCount++
|
|
1018
|
-
},
|
|
1019
|
-
unwatched: () => {
|
|
1020
|
-
subscriptionCount--
|
|
1021
|
-
},
|
|
1022
|
-
},
|
|
1023
|
-
)
|
|
1024
|
-
|
|
1025
|
-
expect(subscriptionCount).toBe(0)
|
|
1026
|
-
|
|
1027
|
-
// Create multiple effects
|
|
1028
|
-
const effect1 = createEffect(() => {
|
|
1029
|
-
computed.get()
|
|
1030
|
-
})
|
|
1031
|
-
const effect2 = createEffect(() => {
|
|
1032
|
-
computed.get()
|
|
1033
|
-
})
|
|
1034
|
-
|
|
1035
|
-
// Should only increment once
|
|
1036
|
-
expect(subscriptionCount).toBe(1)
|
|
1037
|
-
|
|
1038
|
-
// Stop first effect
|
|
1039
|
-
effect1()
|
|
1040
|
-
expect(subscriptionCount).toBe(1) // Still active due to second watcher
|
|
1041
|
-
|
|
1042
|
-
// Stop second effect
|
|
1043
|
-
effect2()
|
|
1044
|
-
expect(subscriptionCount).toBe(0) // Now cleaned up
|
|
1045
|
-
})
|
|
1046
|
-
|
|
1047
|
-
test('Task - should handle abort signals in external resources', async () => {
|
|
1048
|
-
const source = new State('test')
|
|
1049
|
-
let controller: AbortController | undefined
|
|
1050
|
-
const abortedControllers: AbortController[] = []
|
|
1051
|
-
|
|
1052
|
-
const computed = new Task(
|
|
1053
|
-
async (oldValue: string, abort: AbortSignal) => {
|
|
1054
|
-
await wait(20)
|
|
1055
|
-
if (abort.aborted) throw new Error('Aborted')
|
|
1056
|
-
return `${source.get()}-${oldValue || 'initial'}`
|
|
1057
|
-
},
|
|
1058
|
-
{
|
|
1059
|
-
initialValue: 'default',
|
|
1060
|
-
watched: () => {
|
|
1061
|
-
controller = new AbortController()
|
|
1062
|
-
|
|
1063
|
-
// Simulate external async operation (catch rejections to avoid unhandled errors)
|
|
1064
|
-
new Promise(resolve => {
|
|
1065
|
-
const timeout = setTimeout(() => {
|
|
1066
|
-
if (!controller) return
|
|
1067
|
-
if (controller.signal.aborted) {
|
|
1068
|
-
resolve('External operation aborted')
|
|
1069
|
-
} else {
|
|
1070
|
-
resolve('External operation completed')
|
|
1071
|
-
}
|
|
1072
|
-
}, 50)
|
|
1073
|
-
|
|
1074
|
-
controller?.signal.addEventListener('abort', () => {
|
|
1075
|
-
clearTimeout(timeout)
|
|
1076
|
-
resolve('External operation aborted')
|
|
1077
|
-
})
|
|
1078
|
-
}).catch(() => {
|
|
1079
|
-
// Ignore promise rejections in test
|
|
1080
|
-
})
|
|
1081
|
-
},
|
|
1082
|
-
unwatched: () => {
|
|
1083
|
-
if (!controller) return
|
|
1084
|
-
controller.abort()
|
|
1085
|
-
abortedControllers.push(controller)
|
|
1086
|
-
},
|
|
1087
|
-
},
|
|
1088
|
-
)
|
|
1089
|
-
|
|
1090
|
-
const effect1 = createEffect(() => {
|
|
1091
|
-
computed.get()
|
|
1092
|
-
})
|
|
1093
|
-
|
|
1094
|
-
// Change source to trigger recomputation
|
|
1095
|
-
source.set('updated')
|
|
1096
|
-
|
|
1097
|
-
// Stop effect to trigger cleanup
|
|
1098
|
-
effect1()
|
|
1099
|
-
|
|
1100
|
-
// Wait for cleanup to complete
|
|
1101
|
-
await wait(100)
|
|
1102
|
-
|
|
1103
|
-
// Should have aborted external controllers
|
|
1104
|
-
expect(abortedControllers.length).toBeGreaterThan(0)
|
|
1105
|
-
expect(abortedControllers[0].signal.aborted).toBe(true)
|
|
1106
|
-
})
|
|
1107
|
-
})
|
|
1108
|
-
})
|