@zeix/cause-effect 0.17.3 → 0.18.1
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 +169 -227
- package/.cursorrules +41 -35
- package/.github/copilot-instructions.md +176 -116
- package/ARCHITECTURE.md +276 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +201 -143
- package/GUIDE.md +298 -0
- package/README.md +246 -193
- package/REQUIREMENTS.md +100 -0
- package/bench/reactivity.bench.ts +577 -0
- package/context7.json +4 -0
- package/examples/events-sensor.ts +187 -0
- package/examples/selector-sensor.ts +173 -0
- package/index.dev.js +1390 -1008
- package/index.js +1 -1
- package/index.ts +60 -74
- package/package.json +5 -2
- package/skills/changelog-keeper/SKILL.md +59 -0
- package/skills/changelog-keeper/agents/openai.yaml +4 -0
- package/src/errors.ts +118 -74
- package/src/graph.ts +612 -0
- package/src/nodes/collection.ts +512 -0
- package/src/nodes/effect.ts +149 -0
- package/src/nodes/list.ts +589 -0
- package/src/nodes/memo.ts +148 -0
- package/src/nodes/sensor.ts +149 -0
- package/src/nodes/state.ts +135 -0
- package/src/nodes/store.ts +378 -0
- package/src/nodes/task.ts +174 -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 +456 -707
- package/test/effect.test.ts +293 -696
- package/test/list.test.ts +335 -592
- package/test/memo.test.ts +574 -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 +529 -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 +218 -0
- package/types/src/nodes/collection.d.ts +69 -0
- package/types/src/nodes/effect.d.ts +48 -0
- package/types/src/nodes/list.d.ts +66 -0
- package/types/src/nodes/memo.d.ts +63 -0
- package/types/src/nodes/sensor.d.ts +81 -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 +79 -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
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createScope,
|
|
6
|
+
createState,
|
|
7
|
+
createTask,
|
|
8
|
+
isMemo,
|
|
9
|
+
isTask,
|
|
10
|
+
UnsetSignalValueError,
|
|
11
|
+
} from '../index.ts'
|
|
12
|
+
|
|
13
|
+
/* === Utility Functions === */
|
|
14
|
+
|
|
15
|
+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
16
|
+
|
|
17
|
+
/* === Tests === */
|
|
18
|
+
|
|
19
|
+
describe('Task', () => {
|
|
20
|
+
describe('createTask', () => {
|
|
21
|
+
test('should resolve async computation', async () => {
|
|
22
|
+
const task = createTask(
|
|
23
|
+
async () => {
|
|
24
|
+
await wait(50)
|
|
25
|
+
return 42
|
|
26
|
+
},
|
|
27
|
+
{ value: 0 },
|
|
28
|
+
)
|
|
29
|
+
expect(task.get()).toBe(0)
|
|
30
|
+
await wait(60)
|
|
31
|
+
expect(task.get()).toBe(42)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('should have Symbol.toStringTag of "Task"', () => {
|
|
35
|
+
const task = createTask(async () => 1, { value: 0 })
|
|
36
|
+
expect(task[Symbol.toStringTag]).toBe('Task')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('should throw UnsetSignalValueError before resolution with no initial value', () => {
|
|
40
|
+
const task = createTask(async () => {
|
|
41
|
+
await wait(50)
|
|
42
|
+
return 42
|
|
43
|
+
})
|
|
44
|
+
expect(() => task.get()).toThrow(UnsetSignalValueError)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('isTask', () => {
|
|
49
|
+
test('should identify task signals', () => {
|
|
50
|
+
expect(isTask(createTask(async () => 1, { value: 0 }))).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should return false for non-task values', () => {
|
|
54
|
+
expect(isTask(42)).toBe(false)
|
|
55
|
+
expect(isTask(null)).toBe(false)
|
|
56
|
+
expect(isTask({})).toBe(false)
|
|
57
|
+
expect(isMemo(createTask(async () => 1, { value: 0 }))).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('isPending', () => {
|
|
62
|
+
test('should return true while computation is in-flight', async () => {
|
|
63
|
+
const task = createTask(
|
|
64
|
+
async () => {
|
|
65
|
+
await wait(50)
|
|
66
|
+
return 42
|
|
67
|
+
},
|
|
68
|
+
{ value: 0 },
|
|
69
|
+
)
|
|
70
|
+
task.get() // trigger computation
|
|
71
|
+
expect(task.isPending()).toBe(true)
|
|
72
|
+
await wait(60)
|
|
73
|
+
task.get() // read resolved value
|
|
74
|
+
expect(task.isPending()).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('should return false before first get()', () => {
|
|
78
|
+
const task = createTask(async () => 42, { value: 0 })
|
|
79
|
+
expect(task.isPending()).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('abort', () => {
|
|
84
|
+
test('should abort the current computation', async () => {
|
|
85
|
+
let completed = false
|
|
86
|
+
const task = createTask(
|
|
87
|
+
async (_prev, signal) => {
|
|
88
|
+
await wait(50)
|
|
89
|
+
if (!signal.aborted) completed = true
|
|
90
|
+
return 42
|
|
91
|
+
},
|
|
92
|
+
{ value: 0 },
|
|
93
|
+
)
|
|
94
|
+
task.get() // trigger computation
|
|
95
|
+
expect(task.isPending()).toBe(true)
|
|
96
|
+
task.abort()
|
|
97
|
+
expect(task.isPending()).toBe(false)
|
|
98
|
+
await wait(60)
|
|
99
|
+
expect(completed).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('Dependency Tracking', () => {
|
|
104
|
+
test('should re-execute when dependencies change', async () => {
|
|
105
|
+
const source = createState(1)
|
|
106
|
+
const task = createTask(
|
|
107
|
+
async () => {
|
|
108
|
+
const val = source.get() // dependency tracked before await
|
|
109
|
+
await wait(50)
|
|
110
|
+
return val * 2
|
|
111
|
+
},
|
|
112
|
+
{ value: 0 },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
let result = 0
|
|
116
|
+
createEffect(() => {
|
|
117
|
+
result = task.get()
|
|
118
|
+
})
|
|
119
|
+
expect(result).toBe(0)
|
|
120
|
+
await wait(60)
|
|
121
|
+
expect(result).toBe(2)
|
|
122
|
+
|
|
123
|
+
source.set(5)
|
|
124
|
+
await wait(60)
|
|
125
|
+
expect(result).toBe(10)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('should work with downstream memos', async () => {
|
|
129
|
+
const status = createState('pending')
|
|
130
|
+
const task = createTask(async () => {
|
|
131
|
+
await wait(50)
|
|
132
|
+
status.set('success')
|
|
133
|
+
return 42
|
|
134
|
+
})
|
|
135
|
+
const derived = createMemo(() => {
|
|
136
|
+
try {
|
|
137
|
+
return task.get() + 1
|
|
138
|
+
} catch {
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
expect(derived.get()).toBe(0)
|
|
143
|
+
expect(status.get()).toBe('pending')
|
|
144
|
+
await wait(60)
|
|
145
|
+
expect(derived.get()).toBe(43)
|
|
146
|
+
expect(status.get()).toBe('success')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('should run tasks in parallel without waterfalls', async () => {
|
|
150
|
+
const a = createTask(
|
|
151
|
+
async () => {
|
|
152
|
+
await wait(80)
|
|
153
|
+
return 10
|
|
154
|
+
},
|
|
155
|
+
{ value: 0 },
|
|
156
|
+
)
|
|
157
|
+
const b = createTask(
|
|
158
|
+
async () => {
|
|
159
|
+
await wait(80)
|
|
160
|
+
return 20
|
|
161
|
+
},
|
|
162
|
+
{ value: 0 },
|
|
163
|
+
)
|
|
164
|
+
const sum = createMemo(() => a.get() + b.get(), { value: 0 })
|
|
165
|
+
expect(sum.get()).toBe(0)
|
|
166
|
+
await wait(90)
|
|
167
|
+
expect(sum.get()).toBe(30)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('AbortSignal', () => {
|
|
172
|
+
test('should signal abort when dependency changes during computation', async () => {
|
|
173
|
+
const source = createState(1)
|
|
174
|
+
let wasAborted = false
|
|
175
|
+
const task = createTask(
|
|
176
|
+
async (_prev, signal) => {
|
|
177
|
+
const val = source.get()
|
|
178
|
+
await wait(100)
|
|
179
|
+
if (signal.aborted) wasAborted = true
|
|
180
|
+
return val
|
|
181
|
+
},
|
|
182
|
+
{ value: 0 },
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
task.get() // start computation
|
|
186
|
+
await wait(10)
|
|
187
|
+
source.set(2) // change dependency mid-flight
|
|
188
|
+
|
|
189
|
+
await wait(110)
|
|
190
|
+
expect(wasAborted).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('should coalesce multiple rapid changes into one recomputation', async () => {
|
|
194
|
+
const source = createState(1)
|
|
195
|
+
let computationCount = 0
|
|
196
|
+
const task = createTask(
|
|
197
|
+
async () => {
|
|
198
|
+
computationCount++
|
|
199
|
+
await wait(100)
|
|
200
|
+
return source.get()
|
|
201
|
+
},
|
|
202
|
+
{ value: 0 },
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
task.get()
|
|
206
|
+
expect(computationCount).toBe(1)
|
|
207
|
+
|
|
208
|
+
source.set(2)
|
|
209
|
+
source.set(3)
|
|
210
|
+
source.set(4)
|
|
211
|
+
await wait(210)
|
|
212
|
+
|
|
213
|
+
expect(task.get()).toBe(4)
|
|
214
|
+
expect(computationCount).toBe(1)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('Error Handling', () => {
|
|
219
|
+
test('should propagate async errors on get()', async () => {
|
|
220
|
+
const task = createTask(
|
|
221
|
+
async () => {
|
|
222
|
+
await wait(50)
|
|
223
|
+
throw new Error('async failure')
|
|
224
|
+
},
|
|
225
|
+
{ value: 0 },
|
|
226
|
+
)
|
|
227
|
+
task.get()
|
|
228
|
+
await wait(60)
|
|
229
|
+
expect(() => task.get()).toThrow('async failure')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('should recover from errors when dependency changes', async () => {
|
|
233
|
+
const source = createState(1)
|
|
234
|
+
const task = createTask(
|
|
235
|
+
async () => {
|
|
236
|
+
const value = source.get()
|
|
237
|
+
await wait(50)
|
|
238
|
+
if (value === 2) throw new Error('bad value')
|
|
239
|
+
return value
|
|
240
|
+
},
|
|
241
|
+
{ value: 0 },
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
task.get()
|
|
245
|
+
await wait(60)
|
|
246
|
+
expect(task.get()).toBe(1)
|
|
247
|
+
|
|
248
|
+
source.set(2)
|
|
249
|
+
task.get()
|
|
250
|
+
await wait(60)
|
|
251
|
+
expect(() => task.get()).toThrow('bad value')
|
|
252
|
+
|
|
253
|
+
source.set(3)
|
|
254
|
+
task.get()
|
|
255
|
+
await wait(60)
|
|
256
|
+
expect(task.get()).toBe(3)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('options.value (prev)', () => {
|
|
261
|
+
test('should return initial value before resolution', () => {
|
|
262
|
+
const task = createTask(
|
|
263
|
+
async () => {
|
|
264
|
+
await wait(50)
|
|
265
|
+
return 42
|
|
266
|
+
},
|
|
267
|
+
{ value: 10 },
|
|
268
|
+
)
|
|
269
|
+
expect(task.get()).toBe(10)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('should pass initial value as prev to first computation', async () => {
|
|
273
|
+
let receivedPrev: number | undefined
|
|
274
|
+
const task = createTask(
|
|
275
|
+
async prev => {
|
|
276
|
+
receivedPrev = prev
|
|
277
|
+
await wait(50)
|
|
278
|
+
return prev + 5
|
|
279
|
+
},
|
|
280
|
+
{ value: 10 },
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
expect(task.get()).toBe(10)
|
|
284
|
+
await wait(60)
|
|
285
|
+
expect(task.get()).toBe(15)
|
|
286
|
+
expect(receivedPrev).toBe(10)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('should pass previous resolved value on recomputation', async () => {
|
|
290
|
+
const source = createState(1)
|
|
291
|
+
const receivedPrevs: number[] = []
|
|
292
|
+
const task = createTask(
|
|
293
|
+
async prev => {
|
|
294
|
+
const val = source.get() // dependency tracked before await
|
|
295
|
+
receivedPrevs.push(prev)
|
|
296
|
+
await wait(50)
|
|
297
|
+
return val + prev
|
|
298
|
+
},
|
|
299
|
+
{ value: 0 },
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
let result = 0
|
|
303
|
+
createEffect(() => {
|
|
304
|
+
result = task.get()
|
|
305
|
+
})
|
|
306
|
+
await wait(60)
|
|
307
|
+
expect(result).toBe(1) // 0 + 1
|
|
308
|
+
|
|
309
|
+
source.set(2)
|
|
310
|
+
await wait(60)
|
|
311
|
+
expect(result).toBe(3) // 1 + 2
|
|
312
|
+
expect(receivedPrevs).toEqual([0, 1])
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('options.equals', () => {
|
|
317
|
+
test('should use custom equality to skip propagation after resolution', async () => {
|
|
318
|
+
const source = createState(1)
|
|
319
|
+
let effectCount = 0
|
|
320
|
+
const task = createTask(
|
|
321
|
+
async () => {
|
|
322
|
+
const val = source.get() // dependency tracked before await
|
|
323
|
+
await wait(50)
|
|
324
|
+
return { x: val % 2 }
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
value: { x: -1 },
|
|
328
|
+
equals: (a, b) => a.x === b.x,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
createEffect(() => {
|
|
333
|
+
task.get()
|
|
334
|
+
effectCount++
|
|
335
|
+
})
|
|
336
|
+
await wait(60) // first resolution: { x: 1 }
|
|
337
|
+
|
|
338
|
+
source.set(3) // still odd — result will be { x: 1 }, structurally equal
|
|
339
|
+
await wait(60)
|
|
340
|
+
const countAfterEqual = effectCount
|
|
341
|
+
|
|
342
|
+
source.set(2) // now even — result will be { x: 0 }, different
|
|
343
|
+
await wait(60)
|
|
344
|
+
|
|
345
|
+
// After the structurally different result resolves, effect should run again
|
|
346
|
+
expect(effectCount).toBeGreaterThan(countAfterEqual)
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
describe('options.guard', () => {
|
|
351
|
+
test('should validate initial value against guard', () => {
|
|
352
|
+
expect(() => {
|
|
353
|
+
createTask(async () => 42, {
|
|
354
|
+
// @ts-expect-error - Testing invalid input
|
|
355
|
+
value: 'foo',
|
|
356
|
+
guard: (v): v is number => typeof v === 'number',
|
|
357
|
+
})
|
|
358
|
+
}).toThrow('[Task] Signal value "foo" is invalid')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('should accept initial value that passes guard', () => {
|
|
362
|
+
const task = createTask(async () => 42, {
|
|
363
|
+
value: 10,
|
|
364
|
+
guard: (v): v is number => typeof v === 'number',
|
|
365
|
+
})
|
|
366
|
+
expect(task.get()).toBe(10)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('Input Validation', () => {
|
|
371
|
+
test('should throw InvalidCallbackError for sync callback', () => {
|
|
372
|
+
expect(() => {
|
|
373
|
+
// @ts-expect-error - Testing invalid input
|
|
374
|
+
createTask((_a: unknown) => 42)
|
|
375
|
+
}).toThrow('[Task] Callback (_a) => 42 is invalid')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test('should throw InvalidCallbackError for non-function callback', () => {
|
|
379
|
+
// @ts-expect-error - Testing invalid input
|
|
380
|
+
expect(() => createTask(null)).toThrow(
|
|
381
|
+
'[Task] Callback null is invalid',
|
|
382
|
+
)
|
|
383
|
+
// @ts-expect-error - Testing invalid input
|
|
384
|
+
expect(() => createTask(42)).toThrow(
|
|
385
|
+
'[Task] Callback 42 is invalid',
|
|
386
|
+
)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('should throw NullishSignalValueError for null initial value', () => {
|
|
390
|
+
expect(() => {
|
|
391
|
+
// @ts-expect-error - Testing invalid input
|
|
392
|
+
createTask(async () => 42, { value: null })
|
|
393
|
+
}).toThrow('[Task] Signal value cannot be null or undefined')
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
describe('options.watched', () => {
|
|
398
|
+
test('should call watched on first effect access', () => {
|
|
399
|
+
let watchedCount = 0
|
|
400
|
+
|
|
401
|
+
const task = createTask(
|
|
402
|
+
async () => {
|
|
403
|
+
await wait(10)
|
|
404
|
+
return 1
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
value: 0,
|
|
408
|
+
watched: _invalidate => {
|
|
409
|
+
watchedCount++
|
|
410
|
+
return () => {}
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
expect(watchedCount).toBe(0)
|
|
416
|
+
|
|
417
|
+
const dispose = createScope(() => {
|
|
418
|
+
createEffect(() => {
|
|
419
|
+
void task.get()
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
expect(watchedCount).toBe(1)
|
|
424
|
+
dispose()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('should call cleanup when last effect stops watching', () => {
|
|
428
|
+
let cleanedUp = false
|
|
429
|
+
|
|
430
|
+
const task = createTask(
|
|
431
|
+
async () => {
|
|
432
|
+
await wait(10)
|
|
433
|
+
return 1
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
value: 0,
|
|
437
|
+
watched: _invalidate => {
|
|
438
|
+
return () => {
|
|
439
|
+
cleanedUp = true
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
const dispose = createScope(() => {
|
|
446
|
+
createEffect(() => {
|
|
447
|
+
void task.get()
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
expect(cleanedUp).toBe(false)
|
|
452
|
+
dispose()
|
|
453
|
+
expect(cleanedUp).toBe(true)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('should re-execute task when invalidate is called', async () => {
|
|
457
|
+
let externalValue = 10
|
|
458
|
+
let computeCount = 0
|
|
459
|
+
let invalidate!: () => void
|
|
460
|
+
|
|
461
|
+
const task = createTask(
|
|
462
|
+
async () => {
|
|
463
|
+
computeCount++
|
|
464
|
+
await wait(10)
|
|
465
|
+
return externalValue
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
value: 0,
|
|
469
|
+
watched: inv => {
|
|
470
|
+
invalidate = inv
|
|
471
|
+
return () => {}
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
let observed = 0
|
|
477
|
+
const dispose = createScope(() => {
|
|
478
|
+
createEffect(() => {
|
|
479
|
+
observed = task.get()
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
await wait(20)
|
|
484
|
+
expect(observed).toBe(10)
|
|
485
|
+
expect(computeCount).toBe(1)
|
|
486
|
+
|
|
487
|
+
externalValue = 20
|
|
488
|
+
invalidate()
|
|
489
|
+
await wait(20)
|
|
490
|
+
expect(observed).toBe(20)
|
|
491
|
+
expect(computeCount).toBe(2)
|
|
492
|
+
|
|
493
|
+
dispose()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('should abort in-flight task when invalidate is called', async () => {
|
|
497
|
+
let wasAborted = false
|
|
498
|
+
let invalidate!: () => void
|
|
499
|
+
|
|
500
|
+
const task = createTask(
|
|
501
|
+
async (_prev, signal) => {
|
|
502
|
+
await wait(100)
|
|
503
|
+
if (signal.aborted) wasAborted = true
|
|
504
|
+
return 1
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
value: 0,
|
|
508
|
+
watched: inv => {
|
|
509
|
+
invalidate = inv
|
|
510
|
+
return () => {}
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
const dispose = createScope(() => {
|
|
516
|
+
createEffect(() => {
|
|
517
|
+
void task.get()
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
await wait(10) // task is in-flight
|
|
522
|
+
invalidate() // should trigger re-execution, aborting the current one
|
|
523
|
+
await wait(110)
|
|
524
|
+
expect(wasAborted).toBe(true)
|
|
525
|
+
|
|
526
|
+
dispose()
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
createEffect,
|
|
4
|
+
createMemo,
|
|
5
|
+
createScope,
|
|
6
|
+
createState,
|
|
7
|
+
untrack,
|
|
8
|
+
} from '../index.ts'
|
|
9
|
+
|
|
10
|
+
/* === Tests === */
|
|
11
|
+
|
|
12
|
+
describe('untrack', () => {
|
|
13
|
+
test('should return the value of the callback', () => {
|
|
14
|
+
const result = untrack(() => 42)
|
|
15
|
+
expect(result).toBe(42)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('should read a signal without tracking it', () => {
|
|
19
|
+
const tracked = createState('tracked')
|
|
20
|
+
const untracked = createState('untracked')
|
|
21
|
+
let count = 0
|
|
22
|
+
createEffect((): undefined => {
|
|
23
|
+
tracked.get()
|
|
24
|
+
untrack(() => untracked.get())
|
|
25
|
+
count++
|
|
26
|
+
})
|
|
27
|
+
expect(count).toBe(1)
|
|
28
|
+
|
|
29
|
+
// changing tracked signal should re-run the effect
|
|
30
|
+
tracked.set('changed')
|
|
31
|
+
expect(count).toBe(2)
|
|
32
|
+
|
|
33
|
+
// changing untracked signal should not re-run the effect
|
|
34
|
+
untracked.set('changed')
|
|
35
|
+
expect(count).toBe(2)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should not track dependencies in memos', () => {
|
|
39
|
+
const a = createState(1)
|
|
40
|
+
const b = createState(2)
|
|
41
|
+
const sum = createMemo(() => a.get() + untrack(() => b.get()))
|
|
42
|
+
expect(sum.get()).toBe(3)
|
|
43
|
+
|
|
44
|
+
// changing a should recompute
|
|
45
|
+
a.set(10)
|
|
46
|
+
expect(sum.get()).toBe(12)
|
|
47
|
+
|
|
48
|
+
// changing b should not recompute (stale value of b used)
|
|
49
|
+
b.set(20)
|
|
50
|
+
expect(sum.get()).toBe(12)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should prevent dependency pollution from subcomponent creation', () => {
|
|
54
|
+
const parentSignal = createState('parent')
|
|
55
|
+
let parentRuns = 0
|
|
56
|
+
let childRuns = 0
|
|
57
|
+
|
|
58
|
+
const dispose = createScope(() => {
|
|
59
|
+
createEffect((): undefined => {
|
|
60
|
+
parentSignal.get()
|
|
61
|
+
parentRuns++
|
|
62
|
+
|
|
63
|
+
// Simulate subcomponent: create local state + effect
|
|
64
|
+
// Without untrack, childSignal.get() in the child effect
|
|
65
|
+
// would link to the parent effect during initial run
|
|
66
|
+
untrack(() => {
|
|
67
|
+
const childSignal = createState('child')
|
|
68
|
+
createEffect((): undefined => {
|
|
69
|
+
childSignal.get()
|
|
70
|
+
childRuns++
|
|
71
|
+
})
|
|
72
|
+
childSignal.set('updated')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(parentRuns).toBe(1)
|
|
78
|
+
expect(childRuns).toBe(2) // initial + update
|
|
79
|
+
|
|
80
|
+
// parent should re-run when its own signal changes
|
|
81
|
+
parentSignal.set('changed')
|
|
82
|
+
expect(parentRuns).toBe(2)
|
|
83
|
+
|
|
84
|
+
dispose()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('should prevent parent effect from re-running on child signal changes', () => {
|
|
88
|
+
const show = createState(true)
|
|
89
|
+
let parentRuns = 0
|
|
90
|
+
let childValue = ''
|
|
91
|
+
|
|
92
|
+
const dispose = createScope(() => {
|
|
93
|
+
createEffect((): undefined => {
|
|
94
|
+
parentRuns++
|
|
95
|
+
if (show.get()) {
|
|
96
|
+
// Subcomponent with its own reactive state
|
|
97
|
+
untrack(() => {
|
|
98
|
+
const label = createState('hello')
|
|
99
|
+
createEffect((): undefined => {
|
|
100
|
+
childValue = label.get()
|
|
101
|
+
})
|
|
102
|
+
label.set('world')
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(parentRuns).toBe(1)
|
|
109
|
+
expect(childValue).toBe('world')
|
|
110
|
+
|
|
111
|
+
// toggling show re-runs parent (it's tracked)
|
|
112
|
+
show.set(false)
|
|
113
|
+
expect(parentRuns).toBe(2)
|
|
114
|
+
|
|
115
|
+
dispose()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should nest correctly', () => {
|
|
119
|
+
const a = createState(1)
|
|
120
|
+
const b = createState(2)
|
|
121
|
+
const c = createState(3)
|
|
122
|
+
let count = 0
|
|
123
|
+
createEffect((): undefined => {
|
|
124
|
+
a.get()
|
|
125
|
+
untrack(() => {
|
|
126
|
+
b.get()
|
|
127
|
+
untrack(() => {
|
|
128
|
+
c.get()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
count++
|
|
132
|
+
})
|
|
133
|
+
expect(count).toBe(1)
|
|
134
|
+
|
|
135
|
+
a.set(10)
|
|
136
|
+
expect(count).toBe(2)
|
|
137
|
+
|
|
138
|
+
b.set(20)
|
|
139
|
+
expect(count).toBe(2)
|
|
140
|
+
|
|
141
|
+
c.set(30)
|
|
142
|
+
expect(count).toBe(2)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('should restore tracking after untrack completes', () => {
|
|
146
|
+
const before = createState('before')
|
|
147
|
+
const during = createState('during')
|
|
148
|
+
const after = createState('after')
|
|
149
|
+
let count = 0
|
|
150
|
+
createEffect((): undefined => {
|
|
151
|
+
before.get()
|
|
152
|
+
untrack(() => during.get())
|
|
153
|
+
after.get()
|
|
154
|
+
count++
|
|
155
|
+
})
|
|
156
|
+
expect(count).toBe(1)
|
|
157
|
+
|
|
158
|
+
before.set('x')
|
|
159
|
+
expect(count).toBe(2)
|
|
160
|
+
|
|
161
|
+
during.set('x')
|
|
162
|
+
expect(count).toBe(2)
|
|
163
|
+
|
|
164
|
+
after.set('x')
|
|
165
|
+
expect(count).toBe(3)
|
|
166
|
+
})
|
|
167
|
+
})
|