@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
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
createMemo,
|
|
4
|
+
createState,
|
|
5
|
+
isMemo,
|
|
6
|
+
isState,
|
|
7
|
+
UnsetSignalValueError,
|
|
8
|
+
} from '../index.ts'
|
|
9
|
+
|
|
10
|
+
/* === Tests === */
|
|
11
|
+
|
|
12
|
+
describe('Memo', () => {
|
|
13
|
+
describe('createMemo', () => {
|
|
14
|
+
test('should compute a derived value', () => {
|
|
15
|
+
const derived = createMemo(() => 1 + 2)
|
|
16
|
+
expect(derived.get()).toBe(3)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('should have Symbol.toStringTag of "Memo"', () => {
|
|
20
|
+
const memo = createMemo(() => 0)
|
|
21
|
+
expect(memo[Symbol.toStringTag]).toBe('Memo')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('should evaluate lazily on first get()', () => {
|
|
25
|
+
let computed = false
|
|
26
|
+
const memo = createMemo(() => {
|
|
27
|
+
computed = true
|
|
28
|
+
return 42
|
|
29
|
+
})
|
|
30
|
+
expect(computed).toBe(false)
|
|
31
|
+
memo.get()
|
|
32
|
+
expect(computed).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('should throw UnsetSignalValueError if callback returns undefined', () => {
|
|
36
|
+
const memo = createMemo(() => undefined as unknown as number)
|
|
37
|
+
expect(() => memo.get()).toThrow(UnsetSignalValueError)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('isMemo', () => {
|
|
42
|
+
test('should identify memo signals', () => {
|
|
43
|
+
expect(isMemo(createMemo(() => 0))).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('should return false for non-memo values', () => {
|
|
47
|
+
expect(isMemo(42)).toBe(false)
|
|
48
|
+
expect(isMemo(null)).toBe(false)
|
|
49
|
+
expect(isMemo({})).toBe(false)
|
|
50
|
+
expect(isState(createMemo(() => 0))).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('Dependency Tracking', () => {
|
|
55
|
+
test('should recompute when a dependency changes', () => {
|
|
56
|
+
const source = createState(42)
|
|
57
|
+
const derived = createMemo(() => source.get() + 1)
|
|
58
|
+
expect(derived.get()).toBe(43)
|
|
59
|
+
source.set(24)
|
|
60
|
+
expect(derived.get()).toBe(25)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('should track through a chain of memos', () => {
|
|
64
|
+
const x = createState(42)
|
|
65
|
+
const a = createMemo(() => x.get() + 1)
|
|
66
|
+
const b = createMemo(() => a.get() * 2)
|
|
67
|
+
const c = createMemo(() => b.get() + 1)
|
|
68
|
+
expect(c.get()).toBe(87)
|
|
69
|
+
x.set(24)
|
|
70
|
+
expect(c.get()).toBe(51)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('should recompute after multiple state changes', () => {
|
|
74
|
+
const a = createState(3)
|
|
75
|
+
const b = createState(4)
|
|
76
|
+
let count = 0
|
|
77
|
+
const sum = createMemo(() => {
|
|
78
|
+
count++
|
|
79
|
+
return a.get() + b.get()
|
|
80
|
+
})
|
|
81
|
+
expect(sum.get()).toBe(7)
|
|
82
|
+
a.set(6)
|
|
83
|
+
expect(sum.get()).toBe(10)
|
|
84
|
+
b.set(8)
|
|
85
|
+
expect(sum.get()).toBe(14)
|
|
86
|
+
expect(count).toBe(3)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('Memoization', () => {
|
|
91
|
+
test('should skip downstream recomputation when result is unchanged', () => {
|
|
92
|
+
let count = 0
|
|
93
|
+
const x = createState('a')
|
|
94
|
+
const a = createMemo(() => {
|
|
95
|
+
x.get()
|
|
96
|
+
return 'foo'
|
|
97
|
+
})
|
|
98
|
+
const b = createMemo(() => {
|
|
99
|
+
count++
|
|
100
|
+
return a.get()
|
|
101
|
+
})
|
|
102
|
+
expect(b.get()).toBe('foo')
|
|
103
|
+
expect(count).toBe(1)
|
|
104
|
+
x.set('aa')
|
|
105
|
+
x.set('aaa')
|
|
106
|
+
expect(b.get()).toBe('foo')
|
|
107
|
+
expect(count).toBe(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('should not propagate when intermediate result is unchanged', () => {
|
|
111
|
+
let count = 0
|
|
112
|
+
const x = createState(42)
|
|
113
|
+
const a = createMemo(() => x.get() % 2)
|
|
114
|
+
const b = createMemo(() => (a.get() ? 'odd' : 'even'))
|
|
115
|
+
const c = createMemo(() => {
|
|
116
|
+
count++
|
|
117
|
+
return `c: ${b.get()}`
|
|
118
|
+
})
|
|
119
|
+
expect(c.get()).toBe('c: even')
|
|
120
|
+
expect(count).toBe(1)
|
|
121
|
+
x.set(44)
|
|
122
|
+
x.set(46)
|
|
123
|
+
expect(c.get()).toBe('c: even')
|
|
124
|
+
expect(count).toBe(1)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('Diamond Graph', () => {
|
|
129
|
+
test('should compute each memo only once', () => {
|
|
130
|
+
let count = 0
|
|
131
|
+
const x = createState('a')
|
|
132
|
+
const a = createMemo(() => x.get())
|
|
133
|
+
const b = createMemo(() => x.get())
|
|
134
|
+
const c = createMemo(() => {
|
|
135
|
+
count++
|
|
136
|
+
return `${a.get()} ${b.get()}`
|
|
137
|
+
})
|
|
138
|
+
expect(c.get()).toBe('a a')
|
|
139
|
+
expect(count).toBe(1)
|
|
140
|
+
x.set('aa')
|
|
141
|
+
expect(c.get()).toBe('aa aa')
|
|
142
|
+
expect(count).toBe(2)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('should compute each memo only once with tail', () => {
|
|
146
|
+
let count = 0
|
|
147
|
+
const x = createState('a')
|
|
148
|
+
const a = createMemo(() => x.get())
|
|
149
|
+
const b = createMemo(() => x.get())
|
|
150
|
+
const c = createMemo(() => `${a.get()} ${b.get()}`)
|
|
151
|
+
const d = createMemo(() => {
|
|
152
|
+
count++
|
|
153
|
+
return c.get()
|
|
154
|
+
})
|
|
155
|
+
expect(d.get()).toBe('a a')
|
|
156
|
+
expect(count).toBe(1)
|
|
157
|
+
x.set('aa')
|
|
158
|
+
expect(d.get()).toBe('aa aa')
|
|
159
|
+
expect(count).toBe(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('should drop X->B->X updates', () => {
|
|
163
|
+
let count = 0
|
|
164
|
+
const x = createState(2)
|
|
165
|
+
const a = createMemo(() => x.get() - 1)
|
|
166
|
+
const b = createMemo(() => x.get() + a.get())
|
|
167
|
+
const c = createMemo(() => {
|
|
168
|
+
count++
|
|
169
|
+
return `c: ${b.get()}`
|
|
170
|
+
})
|
|
171
|
+
expect(c.get()).toBe('c: 3')
|
|
172
|
+
expect(count).toBe(1)
|
|
173
|
+
x.set(4)
|
|
174
|
+
expect(c.get()).toBe('c: 7')
|
|
175
|
+
expect(count).toBe(2)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('Error Handling', () => {
|
|
180
|
+
test('should detect and throw for circular dependencies', () => {
|
|
181
|
+
const a = createState(1)
|
|
182
|
+
const b = createMemo(() => c.get() + 1)
|
|
183
|
+
const c = createMemo((): number => b.get() + a.get())
|
|
184
|
+
expect(() => b.get()).toThrow('[Memo] Circular dependency detected')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('should propagate errors from computation', () => {
|
|
188
|
+
const x = createState(0)
|
|
189
|
+
const a = createMemo(() => {
|
|
190
|
+
if (x.get() === 1) throw new Error('Computation failed')
|
|
191
|
+
return 1
|
|
192
|
+
})
|
|
193
|
+
expect(a.get()).toBe(1)
|
|
194
|
+
x.set(1)
|
|
195
|
+
expect(() => a.get()).toThrow('Computation failed')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('should allow downstream memos to recover from errors', () => {
|
|
199
|
+
const x = createState(0)
|
|
200
|
+
let errCount = 0
|
|
201
|
+
const a = createMemo(() => {
|
|
202
|
+
if (x.get() === 1) throw new Error('Computation failed')
|
|
203
|
+
return 1
|
|
204
|
+
})
|
|
205
|
+
const b = createMemo(() => {
|
|
206
|
+
try {
|
|
207
|
+
return `ok: ${a.get()}`
|
|
208
|
+
} catch (_e) {
|
|
209
|
+
errCount++
|
|
210
|
+
return 'recovered'
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(b.get()).toBe('ok: 1')
|
|
215
|
+
x.set(1)
|
|
216
|
+
expect(b.get()).toBe('recovered')
|
|
217
|
+
expect(errCount).toBe(1)
|
|
218
|
+
|
|
219
|
+
x.set(0)
|
|
220
|
+
expect(b.get()).toBe('ok: 1')
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('options.value (prev)', () => {
|
|
225
|
+
test('should pass initial value as prev to first computation', () => {
|
|
226
|
+
let receivedPrev: number | undefined
|
|
227
|
+
const memo = createMemo(
|
|
228
|
+
prev => {
|
|
229
|
+
receivedPrev = prev
|
|
230
|
+
return prev + 1
|
|
231
|
+
},
|
|
232
|
+
{ value: 10 },
|
|
233
|
+
)
|
|
234
|
+
expect(memo.get()).toBe(11)
|
|
235
|
+
expect(receivedPrev).toBe(10)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('should pass undefined as prev when no initial value', () => {
|
|
239
|
+
let receivedPrev: unknown = 999
|
|
240
|
+
const memo = createMemo((prev: number | undefined) => {
|
|
241
|
+
receivedPrev = prev
|
|
242
|
+
return 42
|
|
243
|
+
})
|
|
244
|
+
memo.get()
|
|
245
|
+
expect(receivedPrev).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('should pass previous computed value on recomputation', () => {
|
|
249
|
+
const source = createState(5)
|
|
250
|
+
let receivedPrev: number | undefined
|
|
251
|
+
const memo = createMemo(
|
|
252
|
+
prev => {
|
|
253
|
+
receivedPrev = prev
|
|
254
|
+
return source.get() * 2
|
|
255
|
+
},
|
|
256
|
+
{ value: 0 },
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
expect(memo.get()).toBe(10)
|
|
260
|
+
expect(receivedPrev).toBe(0)
|
|
261
|
+
|
|
262
|
+
source.set(3)
|
|
263
|
+
expect(memo.get()).toBe(6)
|
|
264
|
+
expect(receivedPrev).toBe(10)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('should work as a reducer', () => {
|
|
268
|
+
const increment = createState(0)
|
|
269
|
+
const sum = createMemo(
|
|
270
|
+
prev => {
|
|
271
|
+
const inc = increment.get()
|
|
272
|
+
return inc === 0 ? prev : prev + inc
|
|
273
|
+
},
|
|
274
|
+
{ value: 0 },
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(sum.get()).toBe(0)
|
|
278
|
+
increment.set(5)
|
|
279
|
+
expect(sum.get()).toBe(5)
|
|
280
|
+
increment.set(3)
|
|
281
|
+
expect(sum.get()).toBe(8)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('should preserve prev value across errors', () => {
|
|
285
|
+
const shouldError = createState(false)
|
|
286
|
+
const counter = createState(1)
|
|
287
|
+
const memo = createMemo(
|
|
288
|
+
prev => {
|
|
289
|
+
if (shouldError.get()) throw new Error('fail')
|
|
290
|
+
return prev + counter.get()
|
|
291
|
+
},
|
|
292
|
+
{ value: 10 },
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
expect(memo.get()).toBe(11) // 10 + 1
|
|
296
|
+
counter.set(5)
|
|
297
|
+
expect(memo.get()).toBe(16) // 11 + 5
|
|
298
|
+
|
|
299
|
+
shouldError.set(true)
|
|
300
|
+
expect(() => memo.get()).toThrow('fail')
|
|
301
|
+
|
|
302
|
+
shouldError.set(false)
|
|
303
|
+
counter.set(2)
|
|
304
|
+
expect(memo.get()).toBe(18) // 16 + 2
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('options.equals', () => {
|
|
309
|
+
test('should use custom equality to skip propagation', () => {
|
|
310
|
+
const source = createState(1)
|
|
311
|
+
let downstream = 0
|
|
312
|
+
const memo = createMemo(() => ({ x: source.get() % 2 }), {
|
|
313
|
+
value: { x: -1 },
|
|
314
|
+
equals: (a, b) => a.x === b.x,
|
|
315
|
+
})
|
|
316
|
+
const tail = createMemo(() => {
|
|
317
|
+
downstream++
|
|
318
|
+
return memo.get()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
tail.get()
|
|
322
|
+
expect(downstream).toBe(1)
|
|
323
|
+
|
|
324
|
+
source.set(3) // still odd, structurally equal
|
|
325
|
+
tail.get()
|
|
326
|
+
expect(downstream).toBe(1)
|
|
327
|
+
|
|
328
|
+
source.set(2) // now even, different
|
|
329
|
+
tail.get()
|
|
330
|
+
expect(downstream).toBe(2)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('options.guard', () => {
|
|
335
|
+
test('should validate initial value against guard', () => {
|
|
336
|
+
expect(() => {
|
|
337
|
+
createMemo(() => 42, {
|
|
338
|
+
value: -1,
|
|
339
|
+
guard: (v): v is number => typeof v === 'number' && v >= 0,
|
|
340
|
+
})
|
|
341
|
+
}).toThrow('[Memo] Signal value -1 is invalid')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('should accept initial value that passes guard', () => {
|
|
345
|
+
const memo = createMemo(prev => prev + 1, {
|
|
346
|
+
value: 0,
|
|
347
|
+
guard: (v): v is number => typeof v === 'number' && v >= 0,
|
|
348
|
+
})
|
|
349
|
+
expect(memo.get()).toBe(1)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('Input Validation', () => {
|
|
354
|
+
test('should throw InvalidCallbackError for non-function callback', () => {
|
|
355
|
+
// @ts-expect-error - Testing invalid input
|
|
356
|
+
expect(() => createMemo(null)).toThrow(
|
|
357
|
+
'[Memo] Callback null is invalid',
|
|
358
|
+
)
|
|
359
|
+
// @ts-expect-error - Testing invalid input
|
|
360
|
+
expect(() => createMemo(42)).toThrow(
|
|
361
|
+
'[Memo] Callback 42 is invalid',
|
|
362
|
+
)
|
|
363
|
+
// @ts-expect-error - Testing invalid input
|
|
364
|
+
expect(() => createMemo('str')).toThrow(
|
|
365
|
+
'[Memo] Callback "str" is invalid',
|
|
366
|
+
)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('should throw InvalidCallbackError for async callback', () => {
|
|
370
|
+
expect(() => createMemo(async () => 42)).toThrow()
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('should throw NullishSignalValueError for null initial value', () => {
|
|
374
|
+
expect(() => {
|
|
375
|
+
// @ts-expect-error - Testing invalid input
|
|
376
|
+
createMemo(() => 42, { value: null })
|
|
377
|
+
}).toThrow('[Memo] Signal value cannot be null or undefined')
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createEffect, createScope, createState } from '../index.ts'
|
|
3
|
+
|
|
4
|
+
/* === Tests === */
|
|
5
|
+
|
|
6
|
+
describe('createScope', () => {
|
|
7
|
+
test('should return a dispose function', () => {
|
|
8
|
+
const dispose = createScope(() => {})
|
|
9
|
+
expect(typeof dispose).toBe('function')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('should run the callback immediately', () => {
|
|
13
|
+
let ran = false
|
|
14
|
+
createScope(() => {
|
|
15
|
+
ran = true
|
|
16
|
+
})
|
|
17
|
+
expect(ran).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('should call returned cleanup on dispose', () => {
|
|
21
|
+
let cleaned = false
|
|
22
|
+
const dispose = createScope(() => {
|
|
23
|
+
return () => {
|
|
24
|
+
cleaned = true
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
expect(cleaned).toBe(false)
|
|
28
|
+
dispose()
|
|
29
|
+
expect(cleaned).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('should dispose child effects', () => {
|
|
33
|
+
const source = createState(0)
|
|
34
|
+
let count = 0
|
|
35
|
+
const dispose = createScope(() => {
|
|
36
|
+
createEffect((): undefined => {
|
|
37
|
+
source.get()
|
|
38
|
+
count++
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
expect(count).toBe(1)
|
|
42
|
+
source.set(1)
|
|
43
|
+
expect(count).toBe(2)
|
|
44
|
+
dispose()
|
|
45
|
+
source.set(2)
|
|
46
|
+
expect(count).toBe(2) // effect should no longer run
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('should dispose multiple child effects', () => {
|
|
50
|
+
const a = createState(0)
|
|
51
|
+
const b = createState(0)
|
|
52
|
+
let countA = 0
|
|
53
|
+
let countB = 0
|
|
54
|
+
const dispose = createScope(() => {
|
|
55
|
+
createEffect((): undefined => {
|
|
56
|
+
a.get()
|
|
57
|
+
countA++
|
|
58
|
+
})
|
|
59
|
+
createEffect((): undefined => {
|
|
60
|
+
b.get()
|
|
61
|
+
countB++
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
expect(countA).toBe(1)
|
|
65
|
+
expect(countB).toBe(1)
|
|
66
|
+
dispose()
|
|
67
|
+
a.set(1)
|
|
68
|
+
b.set(1)
|
|
69
|
+
expect(countA).toBe(1)
|
|
70
|
+
expect(countB).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('should call returned cleanup and dispose child effects', () => {
|
|
74
|
+
const source = createState(0)
|
|
75
|
+
let effectCount = 0
|
|
76
|
+
let cleaned = false
|
|
77
|
+
const dispose = createScope(() => {
|
|
78
|
+
createEffect((): undefined => {
|
|
79
|
+
source.get()
|
|
80
|
+
effectCount++
|
|
81
|
+
})
|
|
82
|
+
return () => {
|
|
83
|
+
cleaned = true
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
expect(effectCount).toBe(1)
|
|
87
|
+
expect(cleaned).toBe(false)
|
|
88
|
+
dispose()
|
|
89
|
+
expect(cleaned).toBe(true)
|
|
90
|
+
source.set(1)
|
|
91
|
+
expect(effectCount).toBe(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('should handle nested scopes independently', () => {
|
|
95
|
+
const source = createState(0)
|
|
96
|
+
let outerCount = 0
|
|
97
|
+
let innerCount = 0
|
|
98
|
+
let innerDispose!: () => void
|
|
99
|
+
const outerDispose = createScope(() => {
|
|
100
|
+
createEffect((): undefined => {
|
|
101
|
+
source.get()
|
|
102
|
+
outerCount++
|
|
103
|
+
})
|
|
104
|
+
innerDispose = createScope(() => {
|
|
105
|
+
createEffect((): undefined => {
|
|
106
|
+
source.get()
|
|
107
|
+
innerCount++
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
expect(outerCount).toBe(1)
|
|
112
|
+
expect(innerCount).toBe(1)
|
|
113
|
+
source.set(1)
|
|
114
|
+
expect(outerCount).toBe(2)
|
|
115
|
+
expect(innerCount).toBe(2)
|
|
116
|
+
|
|
117
|
+
// disposing inner scope should not affect outer
|
|
118
|
+
innerDispose()
|
|
119
|
+
source.set(2)
|
|
120
|
+
expect(outerCount).toBe(3)
|
|
121
|
+
expect(innerCount).toBe(2)
|
|
122
|
+
|
|
123
|
+
// disposing outer scope should have no further effect
|
|
124
|
+
outerDispose()
|
|
125
|
+
source.set(3)
|
|
126
|
+
expect(outerCount).toBe(3)
|
|
127
|
+
expect(innerCount).toBe(2)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('should dispose nested scopes when parent is disposed', () => {
|
|
131
|
+
const source = createState(0)
|
|
132
|
+
let innerCount = 0
|
|
133
|
+
const outerDispose = createScope(() => {
|
|
134
|
+
createScope(() => {
|
|
135
|
+
createEffect((): undefined => {
|
|
136
|
+
source.get()
|
|
137
|
+
innerCount++
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
expect(innerCount).toBe(1)
|
|
142
|
+
source.set(1)
|
|
143
|
+
expect(innerCount).toBe(2)
|
|
144
|
+
|
|
145
|
+
// disposing outer should also dispose inner
|
|
146
|
+
outerDispose()
|
|
147
|
+
source.set(2)
|
|
148
|
+
expect(innerCount).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('should call nested cleanup functions on parent dispose', () => {
|
|
152
|
+
let outerCleaned = false
|
|
153
|
+
let innerCleaned = false
|
|
154
|
+
const dispose = createScope(() => {
|
|
155
|
+
createScope(() => {
|
|
156
|
+
return () => {
|
|
157
|
+
innerCleaned = true
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
return () => {
|
|
161
|
+
outerCleaned = true
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
expect(outerCleaned).toBe(false)
|
|
165
|
+
expect(innerCleaned).toBe(false)
|
|
166
|
+
dispose()
|
|
167
|
+
expect(outerCleaned).toBe(true)
|
|
168
|
+
expect(innerCleaned).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('should be safe to call dispose multiple times', () => {
|
|
172
|
+
let cleanCount = 0
|
|
173
|
+
const dispose = createScope(() => {
|
|
174
|
+
return () => {
|
|
175
|
+
cleanCount++
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
dispose()
|
|
179
|
+
expect(cleanCount).toBe(1)
|
|
180
|
+
dispose()
|
|
181
|
+
// cleanup should only run once since it's nulled after first run
|
|
182
|
+
expect(cleanCount).toBe(1)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('should handle scope with no cleanup return', () => {
|
|
186
|
+
const dispose = createScope(() => {
|
|
187
|
+
// no return
|
|
188
|
+
})
|
|
189
|
+
expect(() => dispose()).not.toThrow()
|
|
190
|
+
})
|
|
191
|
+
})
|