@zeix/cause-effect 0.17.2 → 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 (94) hide show
  1. package/.ai-context.md +163 -226
  2. package/.cursorrules +41 -35
  3. package/.github/copilot-instructions.md +166 -116
  4. package/.zed/settings.json +3 -0
  5. package/ARCHITECTURE.md +274 -0
  6. package/CLAUDE.md +197 -202
  7. package/COLLECTION_REFACTORING.md +161 -0
  8. package/GUIDE.md +298 -0
  9. package/README.md +241 -220
  10. package/REQUIREMENTS.md +100 -0
  11. package/bench/reactivity.bench.ts +577 -0
  12. package/index.dev.js +1326 -1174
  13. package/index.js +1 -1
  14. package/index.ts +58 -85
  15. package/package.json +9 -6
  16. package/src/errors.ts +118 -70
  17. package/src/graph.ts +601 -0
  18. package/src/nodes/collection.ts +474 -0
  19. package/src/nodes/effect.ts +149 -0
  20. package/src/nodes/list.ts +588 -0
  21. package/src/nodes/memo.ts +120 -0
  22. package/src/nodes/sensor.ts +139 -0
  23. package/src/nodes/state.ts +135 -0
  24. package/src/nodes/store.ts +383 -0
  25. package/src/nodes/task.ts +146 -0
  26. package/src/signal.ts +112 -64
  27. package/src/util.ts +26 -57
  28. package/test/batch.test.ts +96 -69
  29. package/test/benchmark.test.ts +473 -485
  30. package/test/collection.test.ts +455 -955
  31. package/test/effect.test.ts +293 -696
  32. package/test/list.test.ts +332 -857
  33. package/test/memo.test.ts +380 -0
  34. package/test/regression.test.ts +156 -0
  35. package/test/scope.test.ts +191 -0
  36. package/test/sensor.test.ts +454 -0
  37. package/test/signal.test.ts +220 -213
  38. package/test/state.test.ts +217 -271
  39. package/test/store.test.ts +346 -898
  40. package/test/task.test.ts +395 -0
  41. package/test/untrack.test.ts +167 -0
  42. package/test/util/dependency-graph.ts +2 -2
  43. package/tsconfig.build.json +11 -0
  44. package/tsconfig.json +5 -7
  45. package/types/index.d.ts +13 -15
  46. package/types/src/errors.d.ts +73 -19
  47. package/types/src/graph.d.ts +208 -0
  48. package/types/src/nodes/collection.d.ts +64 -0
  49. package/types/src/nodes/effect.d.ts +48 -0
  50. package/types/src/nodes/list.d.ts +65 -0
  51. package/types/src/nodes/memo.d.ts +57 -0
  52. package/types/src/nodes/sensor.d.ts +75 -0
  53. package/types/src/nodes/state.d.ts +78 -0
  54. package/types/src/nodes/store.d.ts +51 -0
  55. package/types/src/nodes/task.d.ts +73 -0
  56. package/types/src/signal.d.ts +43 -28
  57. package/types/src/util.d.ts +9 -16
  58. package/archive/benchmark.ts +0 -688
  59. package/archive/collection.ts +0 -310
  60. package/archive/computed.ts +0 -198
  61. package/archive/list.ts +0 -544
  62. package/archive/memo.ts +0 -140
  63. package/archive/state.ts +0 -90
  64. package/archive/store.ts +0 -357
  65. package/archive/task.ts +0 -191
  66. package/src/classes/collection.ts +0 -298
  67. package/src/classes/composite.ts +0 -171
  68. package/src/classes/computed.ts +0 -392
  69. package/src/classes/list.ts +0 -310
  70. package/src/classes/ref.ts +0 -96
  71. package/src/classes/state.ts +0 -131
  72. package/src/classes/store.ts +0 -227
  73. package/src/diff.ts +0 -138
  74. package/src/effect.ts +0 -96
  75. package/src/match.ts +0 -45
  76. package/src/resolve.ts +0 -49
  77. package/src/system.ts +0 -275
  78. package/test/computed.test.ts +0 -1126
  79. package/test/diff.test.ts +0 -955
  80. package/test/match.test.ts +0 -388
  81. package/test/ref.test.ts +0 -381
  82. package/test/resolve.test.ts +0 -154
  83. package/types/src/classes/collection.d.ts +0 -47
  84. package/types/src/classes/composite.d.ts +0 -15
  85. package/types/src/classes/computed.d.ts +0 -114
  86. package/types/src/classes/list.d.ts +0 -41
  87. package/types/src/classes/ref.d.ts +0 -48
  88. package/types/src/classes/state.d.ts +0 -61
  89. package/types/src/classes/store.d.ts +0 -51
  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 -81
@@ -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
+ })