@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,574 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
batch,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
createScope,
|
|
7
|
+
createState,
|
|
8
|
+
isMemo,
|
|
9
|
+
isState,
|
|
10
|
+
UnsetSignalValueError,
|
|
11
|
+
} from '../index.ts'
|
|
12
|
+
|
|
13
|
+
/* === Tests === */
|
|
14
|
+
|
|
15
|
+
describe('Memo', () => {
|
|
16
|
+
describe('createMemo', () => {
|
|
17
|
+
test('should compute a derived value', () => {
|
|
18
|
+
const derived = createMemo(() => 1 + 2)
|
|
19
|
+
expect(derived.get()).toBe(3)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('should have Symbol.toStringTag of "Memo"', () => {
|
|
23
|
+
const memo = createMemo(() => 0)
|
|
24
|
+
expect(memo[Symbol.toStringTag]).toBe('Memo')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('should evaluate lazily on first get()', () => {
|
|
28
|
+
let computed = false
|
|
29
|
+
const memo = createMemo(() => {
|
|
30
|
+
computed = true
|
|
31
|
+
return 42
|
|
32
|
+
})
|
|
33
|
+
expect(computed).toBe(false)
|
|
34
|
+
memo.get()
|
|
35
|
+
expect(computed).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('should throw UnsetSignalValueError if callback returns undefined', () => {
|
|
39
|
+
const memo = createMemo(() => undefined as unknown as number)
|
|
40
|
+
expect(() => memo.get()).toThrow(UnsetSignalValueError)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('isMemo', () => {
|
|
45
|
+
test('should identify memo signals', () => {
|
|
46
|
+
expect(isMemo(createMemo(() => 0))).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('should return false for non-memo values', () => {
|
|
50
|
+
expect(isMemo(42)).toBe(false)
|
|
51
|
+
expect(isMemo(null)).toBe(false)
|
|
52
|
+
expect(isMemo({})).toBe(false)
|
|
53
|
+
expect(isState(createMemo(() => 0))).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('Dependency Tracking', () => {
|
|
58
|
+
test('should recompute when a dependency changes', () => {
|
|
59
|
+
const source = createState(42)
|
|
60
|
+
const derived = createMemo(() => source.get() + 1)
|
|
61
|
+
expect(derived.get()).toBe(43)
|
|
62
|
+
source.set(24)
|
|
63
|
+
expect(derived.get()).toBe(25)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('should track through a chain of memos', () => {
|
|
67
|
+
const x = createState(42)
|
|
68
|
+
const a = createMemo(() => x.get() + 1)
|
|
69
|
+
const b = createMemo(() => a.get() * 2)
|
|
70
|
+
const c = createMemo(() => b.get() + 1)
|
|
71
|
+
expect(c.get()).toBe(87)
|
|
72
|
+
x.set(24)
|
|
73
|
+
expect(c.get()).toBe(51)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('should recompute after multiple state changes', () => {
|
|
77
|
+
const a = createState(3)
|
|
78
|
+
const b = createState(4)
|
|
79
|
+
let count = 0
|
|
80
|
+
const sum = createMemo(() => {
|
|
81
|
+
count++
|
|
82
|
+
return a.get() + b.get()
|
|
83
|
+
})
|
|
84
|
+
expect(sum.get()).toBe(7)
|
|
85
|
+
a.set(6)
|
|
86
|
+
expect(sum.get()).toBe(10)
|
|
87
|
+
b.set(8)
|
|
88
|
+
expect(sum.get()).toBe(14)
|
|
89
|
+
expect(count).toBe(3)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('Memoization', () => {
|
|
94
|
+
test('should skip downstream recomputation when result is unchanged', () => {
|
|
95
|
+
let count = 0
|
|
96
|
+
const x = createState('a')
|
|
97
|
+
const a = createMemo(() => {
|
|
98
|
+
x.get()
|
|
99
|
+
return 'foo'
|
|
100
|
+
})
|
|
101
|
+
const b = createMemo(() => {
|
|
102
|
+
count++
|
|
103
|
+
return a.get()
|
|
104
|
+
})
|
|
105
|
+
expect(b.get()).toBe('foo')
|
|
106
|
+
expect(count).toBe(1)
|
|
107
|
+
x.set('aa')
|
|
108
|
+
x.set('aaa')
|
|
109
|
+
expect(b.get()).toBe('foo')
|
|
110
|
+
expect(count).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('should not propagate when intermediate result is unchanged', () => {
|
|
114
|
+
let count = 0
|
|
115
|
+
const x = createState(42)
|
|
116
|
+
const a = createMemo(() => x.get() % 2)
|
|
117
|
+
const b = createMemo(() => (a.get() ? 'odd' : 'even'))
|
|
118
|
+
const c = createMemo(() => {
|
|
119
|
+
count++
|
|
120
|
+
return `c: ${b.get()}`
|
|
121
|
+
})
|
|
122
|
+
expect(c.get()).toBe('c: even')
|
|
123
|
+
expect(count).toBe(1)
|
|
124
|
+
x.set(44)
|
|
125
|
+
x.set(46)
|
|
126
|
+
expect(c.get()).toBe('c: even')
|
|
127
|
+
expect(count).toBe(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('Diamond Graph', () => {
|
|
132
|
+
test('should compute each memo only once', () => {
|
|
133
|
+
let count = 0
|
|
134
|
+
const x = createState('a')
|
|
135
|
+
const a = createMemo(() => x.get())
|
|
136
|
+
const b = createMemo(() => x.get())
|
|
137
|
+
const c = createMemo(() => {
|
|
138
|
+
count++
|
|
139
|
+
return `${a.get()} ${b.get()}`
|
|
140
|
+
})
|
|
141
|
+
expect(c.get()).toBe('a a')
|
|
142
|
+
expect(count).toBe(1)
|
|
143
|
+
x.set('aa')
|
|
144
|
+
expect(c.get()).toBe('aa aa')
|
|
145
|
+
expect(count).toBe(2)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('should compute each memo only once with tail', () => {
|
|
149
|
+
let count = 0
|
|
150
|
+
const x = createState('a')
|
|
151
|
+
const a = createMemo(() => x.get())
|
|
152
|
+
const b = createMemo(() => x.get())
|
|
153
|
+
const c = createMemo(() => `${a.get()} ${b.get()}`)
|
|
154
|
+
const d = createMemo(() => {
|
|
155
|
+
count++
|
|
156
|
+
return c.get()
|
|
157
|
+
})
|
|
158
|
+
expect(d.get()).toBe('a a')
|
|
159
|
+
expect(count).toBe(1)
|
|
160
|
+
x.set('aa')
|
|
161
|
+
expect(d.get()).toBe('aa aa')
|
|
162
|
+
expect(count).toBe(2)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('should drop X->B->X updates', () => {
|
|
166
|
+
let count = 0
|
|
167
|
+
const x = createState(2)
|
|
168
|
+
const a = createMemo(() => x.get() - 1)
|
|
169
|
+
const b = createMemo(() => x.get() + a.get())
|
|
170
|
+
const c = createMemo(() => {
|
|
171
|
+
count++
|
|
172
|
+
return `c: ${b.get()}`
|
|
173
|
+
})
|
|
174
|
+
expect(c.get()).toBe('c: 3')
|
|
175
|
+
expect(count).toBe(1)
|
|
176
|
+
x.set(4)
|
|
177
|
+
expect(c.get()).toBe('c: 7')
|
|
178
|
+
expect(count).toBe(2)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('Error Handling', () => {
|
|
183
|
+
test('should detect and throw for circular dependencies', () => {
|
|
184
|
+
const a = createState(1)
|
|
185
|
+
const b = createMemo(() => c.get() + 1)
|
|
186
|
+
const c = createMemo((): number => b.get() + a.get())
|
|
187
|
+
expect(() => b.get()).toThrow('[Memo] Circular dependency detected')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('should propagate errors from computation', () => {
|
|
191
|
+
const x = createState(0)
|
|
192
|
+
const a = createMemo(() => {
|
|
193
|
+
if (x.get() === 1) throw new Error('Computation failed')
|
|
194
|
+
return 1
|
|
195
|
+
})
|
|
196
|
+
expect(a.get()).toBe(1)
|
|
197
|
+
x.set(1)
|
|
198
|
+
expect(() => a.get()).toThrow('Computation failed')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('should allow downstream memos to recover from errors', () => {
|
|
202
|
+
const x = createState(0)
|
|
203
|
+
let errCount = 0
|
|
204
|
+
const a = createMemo(() => {
|
|
205
|
+
if (x.get() === 1) throw new Error('Computation failed')
|
|
206
|
+
return 1
|
|
207
|
+
})
|
|
208
|
+
const b = createMemo(() => {
|
|
209
|
+
try {
|
|
210
|
+
return `ok: ${a.get()}`
|
|
211
|
+
} catch (_e) {
|
|
212
|
+
errCount++
|
|
213
|
+
return 'recovered'
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(b.get()).toBe('ok: 1')
|
|
218
|
+
x.set(1)
|
|
219
|
+
expect(b.get()).toBe('recovered')
|
|
220
|
+
expect(errCount).toBe(1)
|
|
221
|
+
|
|
222
|
+
x.set(0)
|
|
223
|
+
expect(b.get()).toBe('ok: 1')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('options.value (prev)', () => {
|
|
228
|
+
test('should pass initial value as prev to first computation', () => {
|
|
229
|
+
let receivedPrev: number | undefined
|
|
230
|
+
const memo = createMemo(
|
|
231
|
+
prev => {
|
|
232
|
+
receivedPrev = prev
|
|
233
|
+
return prev + 1
|
|
234
|
+
},
|
|
235
|
+
{ value: 10 },
|
|
236
|
+
)
|
|
237
|
+
expect(memo.get()).toBe(11)
|
|
238
|
+
expect(receivedPrev).toBe(10)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('should pass undefined as prev when no initial value', () => {
|
|
242
|
+
let receivedPrev: unknown = 999
|
|
243
|
+
const memo = createMemo((prev: number | undefined) => {
|
|
244
|
+
receivedPrev = prev
|
|
245
|
+
return 42
|
|
246
|
+
})
|
|
247
|
+
memo.get()
|
|
248
|
+
expect(receivedPrev).toBeUndefined()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('should pass previous computed value on recomputation', () => {
|
|
252
|
+
const source = createState(5)
|
|
253
|
+
let receivedPrev: number | undefined
|
|
254
|
+
const memo = createMemo(
|
|
255
|
+
prev => {
|
|
256
|
+
receivedPrev = prev
|
|
257
|
+
return source.get() * 2
|
|
258
|
+
},
|
|
259
|
+
{ value: 0 },
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
expect(memo.get()).toBe(10)
|
|
263
|
+
expect(receivedPrev).toBe(0)
|
|
264
|
+
|
|
265
|
+
source.set(3)
|
|
266
|
+
expect(memo.get()).toBe(6)
|
|
267
|
+
expect(receivedPrev).toBe(10)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('should work as a reducer', () => {
|
|
271
|
+
const increment = createState(0)
|
|
272
|
+
const sum = createMemo(
|
|
273
|
+
prev => {
|
|
274
|
+
const inc = increment.get()
|
|
275
|
+
return inc === 0 ? prev : prev + inc
|
|
276
|
+
},
|
|
277
|
+
{ value: 0 },
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
expect(sum.get()).toBe(0)
|
|
281
|
+
increment.set(5)
|
|
282
|
+
expect(sum.get()).toBe(5)
|
|
283
|
+
increment.set(3)
|
|
284
|
+
expect(sum.get()).toBe(8)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('should preserve prev value across errors', () => {
|
|
288
|
+
const shouldError = createState(false)
|
|
289
|
+
const counter = createState(1)
|
|
290
|
+
const memo = createMemo(
|
|
291
|
+
prev => {
|
|
292
|
+
if (shouldError.get()) throw new Error('fail')
|
|
293
|
+
return prev + counter.get()
|
|
294
|
+
},
|
|
295
|
+
{ value: 10 },
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
expect(memo.get()).toBe(11) // 10 + 1
|
|
299
|
+
counter.set(5)
|
|
300
|
+
expect(memo.get()).toBe(16) // 11 + 5
|
|
301
|
+
|
|
302
|
+
shouldError.set(true)
|
|
303
|
+
expect(() => memo.get()).toThrow('fail')
|
|
304
|
+
|
|
305
|
+
shouldError.set(false)
|
|
306
|
+
counter.set(2)
|
|
307
|
+
expect(memo.get()).toBe(18) // 16 + 2
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('options.equals', () => {
|
|
312
|
+
test('should use custom equality to skip propagation', () => {
|
|
313
|
+
const source = createState(1)
|
|
314
|
+
let downstream = 0
|
|
315
|
+
const memo = createMemo(() => ({ x: source.get() % 2 }), {
|
|
316
|
+
value: { x: -1 },
|
|
317
|
+
equals: (a, b) => a.x === b.x,
|
|
318
|
+
})
|
|
319
|
+
const tail = createMemo(() => {
|
|
320
|
+
downstream++
|
|
321
|
+
return memo.get()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
tail.get()
|
|
325
|
+
expect(downstream).toBe(1)
|
|
326
|
+
|
|
327
|
+
source.set(3) // still odd, structurally equal
|
|
328
|
+
tail.get()
|
|
329
|
+
expect(downstream).toBe(1)
|
|
330
|
+
|
|
331
|
+
source.set(2) // now even, different
|
|
332
|
+
tail.get()
|
|
333
|
+
expect(downstream).toBe(2)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('options.guard', () => {
|
|
338
|
+
test('should validate initial value against guard', () => {
|
|
339
|
+
expect(() => {
|
|
340
|
+
createMemo(() => 42, {
|
|
341
|
+
value: -1,
|
|
342
|
+
guard: (v): v is number => typeof v === 'number' && v >= 0,
|
|
343
|
+
})
|
|
344
|
+
}).toThrow('[Memo] Signal value -1 is invalid')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('should accept initial value that passes guard', () => {
|
|
348
|
+
const memo = createMemo(prev => prev + 1, {
|
|
349
|
+
value: 0,
|
|
350
|
+
guard: (v): v is number => typeof v === 'number' && v >= 0,
|
|
351
|
+
})
|
|
352
|
+
expect(memo.get()).toBe(1)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('Input Validation', () => {
|
|
357
|
+
test('should throw InvalidCallbackError for non-function callback', () => {
|
|
358
|
+
// @ts-expect-error - Testing invalid input
|
|
359
|
+
expect(() => createMemo(null)).toThrow(
|
|
360
|
+
'[Memo] Callback null is invalid',
|
|
361
|
+
)
|
|
362
|
+
// @ts-expect-error - Testing invalid input
|
|
363
|
+
expect(() => createMemo(42)).toThrow(
|
|
364
|
+
'[Memo] Callback 42 is invalid',
|
|
365
|
+
)
|
|
366
|
+
// @ts-expect-error - Testing invalid input
|
|
367
|
+
expect(() => createMemo('str')).toThrow(
|
|
368
|
+
'[Memo] Callback "str" is invalid',
|
|
369
|
+
)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('should throw InvalidCallbackError for async callback', () => {
|
|
373
|
+
expect(() => createMemo(async () => 42)).toThrow()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('should throw NullishSignalValueError for null initial value', () => {
|
|
377
|
+
expect(() => {
|
|
378
|
+
// @ts-expect-error - Testing invalid input
|
|
379
|
+
createMemo(() => 42, { value: null })
|
|
380
|
+
}).toThrow('[Memo] Signal value cannot be null or undefined')
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
describe('options.watched', () => {
|
|
385
|
+
test('should call watched on first effect access', () => {
|
|
386
|
+
let watchedCount = 0
|
|
387
|
+
const externalValue = 1
|
|
388
|
+
|
|
389
|
+
const memo = createMemo(() => externalValue, {
|
|
390
|
+
value: 0,
|
|
391
|
+
watched: _invalidate => {
|
|
392
|
+
watchedCount++
|
|
393
|
+
return () => {}
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(watchedCount).toBe(0)
|
|
398
|
+
|
|
399
|
+
const dispose = createScope(() => {
|
|
400
|
+
createEffect(() => {
|
|
401
|
+
void memo.get()
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
expect(watchedCount).toBe(1)
|
|
406
|
+
dispose()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('should call cleanup when last effect stops watching', () => {
|
|
410
|
+
let cleanedUp = false
|
|
411
|
+
const externalValue = 1
|
|
412
|
+
|
|
413
|
+
const memo = createMemo(() => externalValue, {
|
|
414
|
+
value: 0,
|
|
415
|
+
watched: _invalidate => {
|
|
416
|
+
return () => {
|
|
417
|
+
cleanedUp = true
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const dispose = createScope(() => {
|
|
423
|
+
createEffect(() => {
|
|
424
|
+
void memo.get()
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
expect(cleanedUp).toBe(false)
|
|
429
|
+
dispose()
|
|
430
|
+
expect(cleanedUp).toBe(true)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('should recompute memo when invalidate is called', () => {
|
|
434
|
+
let externalValue = 10
|
|
435
|
+
let computeCount = 0
|
|
436
|
+
let invalidate!: () => void
|
|
437
|
+
|
|
438
|
+
const memo = createMemo(
|
|
439
|
+
() => {
|
|
440
|
+
computeCount++
|
|
441
|
+
return externalValue
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
value: 0,
|
|
445
|
+
watched: inv => {
|
|
446
|
+
invalidate = inv
|
|
447
|
+
return () => {}
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
let observed = 0
|
|
453
|
+
const dispose = createScope(() => {
|
|
454
|
+
createEffect(() => {
|
|
455
|
+
observed = memo.get()
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
expect(observed).toBe(10)
|
|
460
|
+
expect(computeCount).toBe(1)
|
|
461
|
+
|
|
462
|
+
externalValue = 20
|
|
463
|
+
invalidate()
|
|
464
|
+
expect(observed).toBe(20)
|
|
465
|
+
expect(computeCount).toBe(2)
|
|
466
|
+
|
|
467
|
+
dispose()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test('should defer flush when invalidate is called inside batch', () => {
|
|
471
|
+
let externalValue = 1
|
|
472
|
+
let invalidate!: () => void
|
|
473
|
+
|
|
474
|
+
const memo = createMemo(() => externalValue, {
|
|
475
|
+
value: 0,
|
|
476
|
+
watched: inv => {
|
|
477
|
+
invalidate = inv
|
|
478
|
+
return () => {}
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
let observed = 0
|
|
483
|
+
const dispose = createScope(() => {
|
|
484
|
+
createEffect(() => {
|
|
485
|
+
observed = memo.get()
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
expect(observed).toBe(1)
|
|
490
|
+
|
|
491
|
+
batch(() => {
|
|
492
|
+
externalValue = 2
|
|
493
|
+
invalidate()
|
|
494
|
+
expect(observed).toBe(1) // not yet flushed
|
|
495
|
+
})
|
|
496
|
+
expect(observed).toBe(2) // flushed after batch
|
|
497
|
+
|
|
498
|
+
dispose()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('should re-activate watched after cleanup and new effect access', () => {
|
|
502
|
+
let watchedCount = 0
|
|
503
|
+
const externalValue = 1
|
|
504
|
+
|
|
505
|
+
const memo = createMemo(() => externalValue, {
|
|
506
|
+
value: 0,
|
|
507
|
+
watched: _invalidate => {
|
|
508
|
+
watchedCount++
|
|
509
|
+
return () => {}
|
|
510
|
+
},
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
const dispose1 = createScope(() => {
|
|
514
|
+
createEffect(() => {
|
|
515
|
+
void memo.get()
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
expect(watchedCount).toBe(1)
|
|
519
|
+
dispose1()
|
|
520
|
+
|
|
521
|
+
const dispose2 = createScope(() => {
|
|
522
|
+
createEffect(() => {
|
|
523
|
+
void memo.get()
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
expect(watchedCount).toBe(2)
|
|
527
|
+
dispose2()
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('should work with both tracked dependencies and watched', () => {
|
|
531
|
+
const source = createState(1)
|
|
532
|
+
let externalValue = 100
|
|
533
|
+
let computeCount = 0
|
|
534
|
+
let invalidate!: () => void
|
|
535
|
+
|
|
536
|
+
const memo = createMemo(
|
|
537
|
+
() => {
|
|
538
|
+
computeCount++
|
|
539
|
+
return source.get() + externalValue
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
value: 0,
|
|
543
|
+
watched: inv => {
|
|
544
|
+
invalidate = inv
|
|
545
|
+
return () => {}
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
let observed = 0
|
|
551
|
+
const dispose = createScope(() => {
|
|
552
|
+
createEffect(() => {
|
|
553
|
+
observed = memo.get()
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
expect(observed).toBe(101)
|
|
558
|
+
expect(computeCount).toBe(1)
|
|
559
|
+
|
|
560
|
+
// Tracked dependency triggers recomputation
|
|
561
|
+
source.set(2)
|
|
562
|
+
expect(observed).toBe(102)
|
|
563
|
+
expect(computeCount).toBe(2)
|
|
564
|
+
|
|
565
|
+
// External invalidation triggers recomputation
|
|
566
|
+
externalValue = 200
|
|
567
|
+
invalidate()
|
|
568
|
+
expect(observed).toBe(202)
|
|
569
|
+
expect(computeCount).toBe(3)
|
|
570
|
+
|
|
571
|
+
dispose()
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { gzipSync } from 'node:zlib'
|
|
4
|
+
import { batch, createEffect, createMemo, createState } from '../index.ts'
|
|
5
|
+
|
|
6
|
+
/* === Baseline Management === */
|
|
7
|
+
|
|
8
|
+
type Baseline = {
|
|
9
|
+
bundleMinified: number
|
|
10
|
+
bundleGzipped: number
|
|
11
|
+
deepPropagation: number
|
|
12
|
+
broadPropagation: number
|
|
13
|
+
diamondPropagation: number
|
|
14
|
+
signalCreation: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BASELINE_PATH = `${import.meta.dir}/regression-baseline.json`
|
|
18
|
+
const BUNDLE_MARGIN = 0.1 // 10%
|
|
19
|
+
const PERF_MARGIN = 0.2 // 20%
|
|
20
|
+
const PERF_FLOOR = 2 // minimum absolute tolerance in ms
|
|
21
|
+
|
|
22
|
+
const baseline: Baseline | null = existsSync(BASELINE_PATH)
|
|
23
|
+
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
|
|
24
|
+
: null
|
|
25
|
+
|
|
26
|
+
const current = {} as Baseline
|
|
27
|
+
|
|
28
|
+
function check(
|
|
29
|
+
key: keyof Baseline,
|
|
30
|
+
value: number,
|
|
31
|
+
margin: number,
|
|
32
|
+
unit: string,
|
|
33
|
+
): void {
|
|
34
|
+
current[key] = value
|
|
35
|
+
if (baseline) {
|
|
36
|
+
const relative = baseline[key] * (1 + margin)
|
|
37
|
+
const limit =
|
|
38
|
+
unit === 'ms'
|
|
39
|
+
? Math.max(relative, baseline[key] + PERF_FLOOR)
|
|
40
|
+
: relative
|
|
41
|
+
console.log(
|
|
42
|
+
` ${key}: ${value.toFixed(unit === 'ms' ? 1 : 0)}${unit}` +
|
|
43
|
+
` (baseline: ${baseline[key].toFixed(unit === 'ms' ? 1 : 0)}${unit},` +
|
|
44
|
+
` limit: ${limit.toFixed(unit === 'ms' ? 1 : 0)}${unit})`,
|
|
45
|
+
)
|
|
46
|
+
expect(value).toBeLessThanOrEqual(limit)
|
|
47
|
+
} else {
|
|
48
|
+
console.log(
|
|
49
|
+
` ${key}: ${value.toFixed(unit === 'ms' ? 1 : 0)}${unit} (no baseline, recording)`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
if (!baseline) {
|
|
56
|
+
writeFileSync(BASELINE_PATH, `${JSON.stringify(current, null, '\t')}\n`)
|
|
57
|
+
console.log(`\n Baseline written to ${BASELINE_PATH}`)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/* === Bundle Size Regression Tests === */
|
|
62
|
+
|
|
63
|
+
describe('Bundle size', () => {
|
|
64
|
+
test('minified bundle should not regress', async () => {
|
|
65
|
+
const result = await Bun.build({
|
|
66
|
+
entrypoints: ['./index.ts'],
|
|
67
|
+
minify: true,
|
|
68
|
+
})
|
|
69
|
+
const bytes = await result.outputs[0].arrayBuffer()
|
|
70
|
+
check('bundleMinified', bytes.byteLength, BUNDLE_MARGIN, 'B')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('gzipped bundle should not regress', async () => {
|
|
74
|
+
const result = await Bun.build({
|
|
75
|
+
entrypoints: ['./index.ts'],
|
|
76
|
+
minify: true,
|
|
77
|
+
})
|
|
78
|
+
const bytes = await result.outputs[0].arrayBuffer()
|
|
79
|
+
const gzipped = gzipSync(new Uint8Array(bytes)).byteLength
|
|
80
|
+
check('bundleGzipped', gzipped, BUNDLE_MARGIN, 'B')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/* === Performance Regression Tests === */
|
|
85
|
+
|
|
86
|
+
function measure(setup: () => () => void, iterations: number): number {
|
|
87
|
+
const fn = setup()
|
|
88
|
+
for (let i = 0; i < 100; i++) fn() // warmup
|
|
89
|
+
const start = performance.now()
|
|
90
|
+
for (let i = 0; i < iterations; i++) fn()
|
|
91
|
+
return performance.now() - start
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('Performance', () => {
|
|
95
|
+
test('deep propagation (50 layers, 1000 iterations)', () => {
|
|
96
|
+
const elapsed = measure(() => {
|
|
97
|
+
const head = createState(0)
|
|
98
|
+
let current: { get(): number } = head
|
|
99
|
+
for (let i = 0; i < 50; i++) {
|
|
100
|
+
const c = current
|
|
101
|
+
current = createMemo(() => c.get() + 1)
|
|
102
|
+
}
|
|
103
|
+
createEffect(() => {
|
|
104
|
+
current.get()
|
|
105
|
+
})
|
|
106
|
+
let i = 0
|
|
107
|
+
return () => batch(() => head.set(++i))
|
|
108
|
+
}, 1000)
|
|
109
|
+
check('deepPropagation', elapsed, PERF_MARGIN, 'ms')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('broad propagation (50 effects, 1000 iterations)', () => {
|
|
113
|
+
const elapsed = measure(() => {
|
|
114
|
+
const head = createState(0)
|
|
115
|
+
for (let i = 0; i < 50; i++) {
|
|
116
|
+
const c = createMemo(() => head.get() + i)
|
|
117
|
+
const c2 = createMemo(() => c.get() + 1)
|
|
118
|
+
createEffect(() => {
|
|
119
|
+
c2.get()
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
let i = 0
|
|
123
|
+
return () => batch(() => head.set(++i))
|
|
124
|
+
}, 1000)
|
|
125
|
+
check('broadPropagation', elapsed, PERF_MARGIN, 'ms')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('diamond propagation (width 5, 5000 iterations)', () => {
|
|
129
|
+
const elapsed = measure(() => {
|
|
130
|
+
const head = createState(0)
|
|
131
|
+
const branches: { get(): number }[] = []
|
|
132
|
+
for (let i = 0; i < 5; i++)
|
|
133
|
+
branches.push(createMemo(() => head.get() + 1))
|
|
134
|
+
const sum = createMemo(() =>
|
|
135
|
+
branches.reduce((a, b) => a + b.get(), 0),
|
|
136
|
+
)
|
|
137
|
+
createEffect(() => {
|
|
138
|
+
sum.get()
|
|
139
|
+
})
|
|
140
|
+
let i = 0
|
|
141
|
+
return () => batch(() => head.set(++i))
|
|
142
|
+
}, 5000)
|
|
143
|
+
check('diamondPropagation', elapsed, PERF_MARGIN, 'ms')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('create 1k signals (500 rounds)', () => {
|
|
147
|
+
const fn = () => {
|
|
148
|
+
for (let i = 0; i < 1000; i++) createState(i)
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < 50; i++) fn() // warmup
|
|
151
|
+
const start = performance.now()
|
|
152
|
+
for (let i = 0; i < 500; i++) fn()
|
|
153
|
+
const elapsed = performance.now() - start
|
|
154
|
+
check('signalCreation', elapsed, PERF_MARGIN, 'ms')
|
|
155
|
+
})
|
|
156
|
+
})
|