@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.
Files changed (94) hide show
  1. package/.ai-context.md +169 -227
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +176 -116
  4. package/ARCHITECTURE.md +276 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CLAUDE.md +201 -143
  7. package/GUIDE.md +298 -0
  8. package/README.md +246 -193
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/context7.json +4 -0
  12. package/examples/events-sensor.ts +187 -0
  13. package/examples/selector-sensor.ts +173 -0
  14. package/index.dev.js +1390 -1008
  15. package/index.js +1 -1
  16. package/index.ts +60 -74
  17. package/package.json +5 -2
  18. package/skills/changelog-keeper/SKILL.md +59 -0
  19. package/skills/changelog-keeper/agents/openai.yaml +4 -0
  20. package/src/errors.ts +118 -74
  21. package/src/graph.ts +612 -0
  22. package/src/nodes/collection.ts +512 -0
  23. package/src/nodes/effect.ts +149 -0
  24. package/src/nodes/list.ts +589 -0
  25. package/src/nodes/memo.ts +148 -0
  26. package/src/nodes/sensor.ts +149 -0
  27. package/src/nodes/state.ts +135 -0
  28. package/src/nodes/store.ts +378 -0
  29. package/src/nodes/task.ts +174 -0
  30. package/src/signal.ts +112 -66
  31. package/src/util.ts +26 -57
  32. package/test/batch.test.ts +96 -62
  33. package/test/benchmark.test.ts +473 -487
  34. package/test/collection.test.ts +456 -707
  35. package/test/effect.test.ts +293 -696
  36. package/test/list.test.ts +335 -592
  37. package/test/memo.test.ts +574 -0
  38. package/test/regression.test.ts +156 -0
  39. package/test/scope.test.ts +191 -0
  40. package/test/sensor.test.ts +454 -0
  41. package/test/signal.test.ts +220 -213
  42. package/test/state.test.ts +217 -265
  43. package/test/store.test.ts +346 -446
  44. package/test/task.test.ts +529 -0
  45. package/test/untrack.test.ts +167 -0
  46. package/types/index.d.ts +13 -15
  47. package/types/src/errors.d.ts +73 -17
  48. package/types/src/graph.d.ts +218 -0
  49. package/types/src/nodes/collection.d.ts +69 -0
  50. package/types/src/nodes/effect.d.ts +48 -0
  51. package/types/src/nodes/list.d.ts +66 -0
  52. package/types/src/nodes/memo.d.ts +63 -0
  53. package/types/src/nodes/sensor.d.ts +81 -0
  54. package/types/src/nodes/state.d.ts +78 -0
  55. package/types/src/nodes/store.d.ts +51 -0
  56. package/types/src/nodes/task.d.ts +79 -0
  57. package/types/src/signal.d.ts +43 -29
  58. package/types/src/util.d.ts +9 -16
  59. package/archive/benchmark.ts +0 -683
  60. package/archive/collection.ts +0 -253
  61. package/archive/composite.ts +0 -85
  62. package/archive/computed.ts +0 -195
  63. package/archive/list.ts +0 -483
  64. package/archive/memo.ts +0 -139
  65. package/archive/state.ts +0 -90
  66. package/archive/store.ts +0 -298
  67. package/archive/task.ts +0 -189
  68. package/src/classes/collection.ts +0 -245
  69. package/src/classes/computed.ts +0 -349
  70. package/src/classes/list.ts +0 -343
  71. package/src/classes/ref.ts +0 -70
  72. package/src/classes/state.ts +0 -102
  73. package/src/classes/store.ts +0 -262
  74. package/src/diff.ts +0 -138
  75. package/src/effect.ts +0 -93
  76. package/src/match.ts +0 -45
  77. package/src/resolve.ts +0 -49
  78. package/src/system.ts +0 -257
  79. package/test/computed.test.ts +0 -1108
  80. package/test/diff.test.ts +0 -955
  81. package/test/match.test.ts +0 -388
  82. package/test/ref.test.ts +0 -353
  83. package/test/resolve.test.ts +0 -154
  84. package/types/src/classes/collection.d.ts +0 -45
  85. package/types/src/classes/computed.d.ts +0 -94
  86. package/types/src/classes/list.d.ts +0 -43
  87. package/types/src/classes/ref.d.ts +0 -35
  88. package/types/src/classes/state.d.ts +0 -49
  89. package/types/src/classes/store.d.ts +0 -52
  90. package/types/src/diff.d.ts +0 -28
  91. package/types/src/effect.d.ts +0 -15
  92. package/types/src/match.d.ts +0 -21
  93. package/types/src/resolve.d.ts +0 -29
  94. 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
+ })