@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.
Files changed (89) hide show
  1. package/.ai-context.md +163 -232
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/ARCHITECTURE.md +274 -0
  5. package/CLAUDE.md +199 -143
  6. package/COLLECTION_REFACTORING.md +161 -0
  7. package/GUIDE.md +298 -0
  8. package/README.md +232 -197
  9. package/REQUIREMENTS.md +100 -0
  10. package/bench/reactivity.bench.ts +577 -0
  11. package/index.dev.js +1325 -997
  12. package/index.js +1 -1
  13. package/index.ts +58 -74
  14. package/package.json +4 -1
  15. package/src/errors.ts +118 -74
  16. package/src/graph.ts +601 -0
  17. package/src/nodes/collection.ts +474 -0
  18. package/src/nodes/effect.ts +149 -0
  19. package/src/nodes/list.ts +588 -0
  20. package/src/nodes/memo.ts +120 -0
  21. package/src/nodes/sensor.ts +139 -0
  22. package/src/nodes/state.ts +135 -0
  23. package/src/nodes/store.ts +383 -0
  24. package/src/nodes/task.ts +146 -0
  25. package/src/signal.ts +112 -66
  26. package/src/util.ts +26 -57
  27. package/test/batch.test.ts +96 -62
  28. package/test/benchmark.test.ts +473 -487
  29. package/test/collection.test.ts +466 -706
  30. package/test/effect.test.ts +293 -696
  31. package/test/list.test.ts +335 -592
  32. package/test/memo.test.ts +380 -0
  33. package/test/regression.test.ts +156 -0
  34. package/test/scope.test.ts +191 -0
  35. package/test/sensor.test.ts +454 -0
  36. package/test/signal.test.ts +220 -213
  37. package/test/state.test.ts +217 -265
  38. package/test/store.test.ts +346 -446
  39. package/test/task.test.ts +395 -0
  40. package/test/untrack.test.ts +167 -0
  41. package/types/index.d.ts +13 -15
  42. package/types/src/errors.d.ts +73 -17
  43. package/types/src/graph.d.ts +208 -0
  44. package/types/src/nodes/collection.d.ts +64 -0
  45. package/types/src/nodes/effect.d.ts +48 -0
  46. package/types/src/nodes/list.d.ts +65 -0
  47. package/types/src/nodes/memo.d.ts +57 -0
  48. package/types/src/nodes/sensor.d.ts +75 -0
  49. package/types/src/nodes/state.d.ts +78 -0
  50. package/types/src/nodes/store.d.ts +51 -0
  51. package/types/src/nodes/task.d.ts +73 -0
  52. package/types/src/signal.d.ts +43 -29
  53. package/types/src/util.d.ts +9 -16
  54. package/archive/benchmark.ts +0 -683
  55. package/archive/collection.ts +0 -253
  56. package/archive/composite.ts +0 -85
  57. package/archive/computed.ts +0 -195
  58. package/archive/list.ts +0 -483
  59. package/archive/memo.ts +0 -139
  60. package/archive/state.ts +0 -90
  61. package/archive/store.ts +0 -298
  62. package/archive/task.ts +0 -189
  63. package/src/classes/collection.ts +0 -245
  64. package/src/classes/computed.ts +0 -349
  65. package/src/classes/list.ts +0 -343
  66. package/src/classes/ref.ts +0 -70
  67. package/src/classes/state.ts +0 -102
  68. package/src/classes/store.ts +0 -262
  69. package/src/diff.ts +0 -138
  70. package/src/effect.ts +0 -93
  71. package/src/match.ts +0 -45
  72. package/src/resolve.ts +0 -49
  73. package/src/system.ts +0 -257
  74. package/test/computed.test.ts +0 -1108
  75. package/test/diff.test.ts +0 -955
  76. package/test/match.test.ts +0 -388
  77. package/test/ref.test.ts +0 -353
  78. package/test/resolve.test.ts +0 -154
  79. package/types/src/classes/collection.d.ts +0 -45
  80. package/types/src/classes/computed.d.ts +0 -94
  81. package/types/src/classes/list.d.ts +0 -43
  82. package/types/src/classes/ref.d.ts +0 -35
  83. package/types/src/classes/state.d.ts +0 -49
  84. package/types/src/classes/store.d.ts +0 -52
  85. package/types/src/diff.d.ts +0 -28
  86. package/types/src/effect.d.ts +0 -15
  87. package/types/src/match.d.ts +0 -21
  88. package/types/src/resolve.d.ts +0 -29
  89. 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
+ })