mutts 1.0.2 → 1.0.4

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 (104) hide show
  1. package/README.md +14 -6
  2. package/dist/chunks/{_tslib-C-cuVLvZ.js → _tslib-BgjropY9.js} +9 -1
  3. package/dist/chunks/_tslib-BgjropY9.js.map +1 -0
  4. package/dist/chunks/{_tslib-CMEnd0VE.esm.js → _tslib-Mzh1rNsX.esm.js} +9 -2
  5. package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +1 -0
  6. package/dist/chunks/{decorator-D4DU97Zg.js → decorator-DLvrD0UF.js} +42 -19
  7. package/dist/chunks/decorator-DLvrD0UF.js.map +1 -0
  8. package/dist/chunks/{decorator-GnHw1Az7.esm.js → decorator-DqiszP7i.esm.js} +42 -19
  9. package/dist/chunks/decorator-DqiszP7i.esm.js.map +1 -0
  10. package/dist/chunks/index-79Kk8D6e.esm.js +4857 -0
  11. package/dist/chunks/index-79Kk8D6e.esm.js.map +1 -0
  12. package/dist/chunks/index-GRBSx0mB.js +4908 -0
  13. package/dist/chunks/index-GRBSx0mB.js.map +1 -0
  14. package/dist/decorator.esm.js +1 -1
  15. package/dist/decorator.js +1 -1
  16. package/dist/destroyable.d.ts +1 -1
  17. package/dist/destroyable.esm.js +1 -1
  18. package/dist/destroyable.esm.js.map +1 -1
  19. package/dist/destroyable.js +1 -1
  20. package/dist/destroyable.js.map +1 -1
  21. package/dist/devtools/devtools.html +9 -0
  22. package/dist/devtools/devtools.js +5 -0
  23. package/dist/devtools/devtools.js.map +1 -0
  24. package/dist/devtools/manifest.json +8 -0
  25. package/dist/devtools/panel.css +72 -0
  26. package/dist/devtools/panel.html +31 -0
  27. package/dist/devtools/panel.js +13048 -0
  28. package/dist/devtools/panel.js.map +1 -0
  29. package/dist/eventful.esm.js +1 -1
  30. package/dist/eventful.js +1 -1
  31. package/dist/index.d.ts +18 -63
  32. package/dist/index.esm.js +4 -4
  33. package/dist/index.js +37 -11
  34. package/dist/index.js.map +1 -1
  35. package/dist/indexable.d.ts +187 -1
  36. package/dist/indexable.esm.js +197 -3
  37. package/dist/indexable.esm.js.map +1 -1
  38. package/dist/indexable.js +198 -2
  39. package/dist/indexable.js.map +1 -1
  40. package/dist/mutts.umd.js +1 -1
  41. package/dist/mutts.umd.js.map +1 -1
  42. package/dist/mutts.umd.min.js +1 -1
  43. package/dist/mutts.umd.min.js.map +1 -1
  44. package/dist/promiseChain.esm.js.map +1 -1
  45. package/dist/promiseChain.js.map +1 -1
  46. package/dist/reactive.d.ts +602 -97
  47. package/dist/reactive.esm.js +3 -3
  48. package/dist/reactive.js +32 -10
  49. package/dist/reactive.js.map +1 -1
  50. package/dist/std-decorators.esm.js +1 -1
  51. package/dist/std-decorators.js +1 -1
  52. package/docs/ai/api-reference.md +133 -0
  53. package/docs/ai/manual.md +105 -0
  54. package/docs/iterableWeak.md +646 -0
  55. package/docs/reactive/advanced.md +1280 -0
  56. package/docs/reactive/collections.md +767 -0
  57. package/docs/reactive/core.md +973 -0
  58. package/docs/reactive.md +21 -9545
  59. package/package.json +18 -5
  60. package/src/decorator.ts +266 -0
  61. package/src/destroyable.ts +199 -0
  62. package/src/eventful.ts +77 -0
  63. package/src/index.d.ts +9 -0
  64. package/src/index.ts +9 -0
  65. package/src/indexable.ts +484 -0
  66. package/src/introspection.ts +59 -0
  67. package/src/iterableWeak.ts +233 -0
  68. package/src/mixins.ts +123 -0
  69. package/src/promiseChain.ts +110 -0
  70. package/src/reactive/array.ts +414 -0
  71. package/src/reactive/change.ts +134 -0
  72. package/src/reactive/debug.ts +517 -0
  73. package/src/reactive/deep-touch.ts +268 -0
  74. package/src/reactive/deep-watch-state.ts +82 -0
  75. package/src/reactive/deep-watch.ts +168 -0
  76. package/src/reactive/effect-context.ts +94 -0
  77. package/src/reactive/effects.ts +1345 -0
  78. package/src/reactive/index.ts +76 -0
  79. package/src/reactive/interface.ts +223 -0
  80. package/src/reactive/map.ts +171 -0
  81. package/src/reactive/mapped.ts +130 -0
  82. package/src/reactive/memoize.ts +107 -0
  83. package/src/reactive/non-reactive-state.ts +49 -0
  84. package/src/reactive/non-reactive.ts +43 -0
  85. package/src/reactive/project.project.md +93 -0
  86. package/src/reactive/project.ts +335 -0
  87. package/src/reactive/proxy-state.ts +27 -0
  88. package/src/reactive/proxy.ts +289 -0
  89. package/src/reactive/record.ts +196 -0
  90. package/src/reactive/register.ts +421 -0
  91. package/src/reactive/set.ts +144 -0
  92. package/src/reactive/tracking.ts +101 -0
  93. package/src/reactive/types.ts +358 -0
  94. package/src/reactive/zone.ts +208 -0
  95. package/src/std-decorators.ts +217 -0
  96. package/src/utils.ts +117 -0
  97. package/dist/chunks/_tslib-C-cuVLvZ.js.map +0 -1
  98. package/dist/chunks/_tslib-CMEnd0VE.esm.js.map +0 -1
  99. package/dist/chunks/decorator-D4DU97Zg.js.map +0 -1
  100. package/dist/chunks/decorator-GnHw1Az7.esm.js.map +0 -1
  101. package/dist/chunks/index-DBScoeCX.esm.js +0 -1960
  102. package/dist/chunks/index-DBScoeCX.esm.js.map +0 -1
  103. package/dist/chunks/index-DOTmXL89.js +0 -1983
  104. package/dist/chunks/index-DOTmXL89.js.map +0 -1
@@ -0,0 +1,1345 @@
1
+ import { decorator } from '../decorator'
2
+ import { IterableWeakSet } from '../iterableWeak'
3
+ import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from './debug'
4
+ import {
5
+ captureEffectStack,
6
+ effectStack,
7
+ getActiveEffect,
8
+ withEffect,
9
+ withEffectStack,
10
+ } from './effect-context'
11
+ import {
12
+ effectChildren,
13
+ effectParent,
14
+ effectToReactiveObjects,
15
+ getRoot,
16
+ getTrackingDisabled,
17
+ markWithRoot,
18
+ setTrackingDisabled,
19
+ watchers,
20
+ } from './tracking'
21
+ import {
22
+ type DependencyAccess,
23
+ type EffectOptions,
24
+ type Evolution,
25
+ options,
26
+ ReactiveError,
27
+ ReactiveErrorCode,
28
+ // type AsyncExecutionMode,
29
+ type ScopedCallback,
30
+ } from './types'
31
+
32
+ /**
33
+ * Finds a cycle in a sequence of functions by looking for the first repetition
34
+ */
35
+ function findCycleInChain(roots: Function[]): Function[] | null {
36
+ const seen = new Map<Function, number>()
37
+ for (let i = 0; i < roots.length; i++) {
38
+ const root = roots[i]
39
+ if (seen.has(root)) {
40
+ return roots.slice(seen.get(root)!)
41
+ }
42
+ seen.set(root, i)
43
+ }
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * Formats a list of function roots into a readable trace
49
+ */
50
+ function formatRoots(roots: Function[], limit = 20): string {
51
+ const names = roots.map((r) => r.name || '<anonymous>')
52
+ if (names.length <= limit) return names.join(' → ')
53
+ const start = names.slice(0, 5)
54
+ const end = names.slice(-10)
55
+ return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`
56
+ }
57
+
58
+ import { ensureZoneHooked } from './zone'
59
+
60
+ type EffectTracking = (obj: any, evolution: Evolution, prop: any) => void
61
+
62
+ export { captureEffectStack, withEffectStack, getActiveEffect, effectStack }
63
+ /**
64
+ * Registers a debug callback that is called when the current effect is triggered by a dependency change
65
+ *
66
+ * This function is useful for debugging purposes as it pin-points exactly which reactive property
67
+ * change triggered the effect. The callback receives information about:
68
+ * - The object that changed
69
+ * - The type of change (evolution)
70
+ * - The specific property that changed
71
+ *
72
+ * **Note:** The tracker callback is automatically removed after being called once. If you need
73
+ * to track multiple triggers, call `trackEffect` again within the effect.
74
+ *
75
+ * @param onTouch - Callback function that receives (obj, evolution, prop) when the effect is triggered
76
+ * @throws {Error} If called outside of an effect context
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * const state = reactive({ count: 0, name: 'John' })
81
+ *
82
+ * effect(() => {
83
+ * // Register a tracker to see what triggers this effect
84
+ * trackEffect((obj, evolution, prop) => {
85
+ * console.log(`Effect triggered by:`, {
86
+ * object: obj,
87
+ * change: evolution.type,
88
+ * property: prop
89
+ * })
90
+ * })
91
+ *
92
+ * // Access reactive properties
93
+ * console.log(state.count, state.name)
94
+ * })
95
+ *
96
+ * state.count = 5
97
+ * // Logs: Effect triggered by: { object: state, change: 'set', property: 'count' }
98
+ * ```
99
+ */
100
+ export function trackEffect(onTouch: EffectTracking) {
101
+ const activeEffect = getActiveEffect()
102
+ if (!activeEffect) throw new Error('Not in an effect')
103
+ if (!effectTrackers.has(activeEffect)) effectTrackers.set(activeEffect, new Set([onTouch]))
104
+ else effectTrackers.get(activeEffect)!.add(onTouch)
105
+ }
106
+
107
+ const effectTrackers = new WeakMap<ScopedCallback, Set<EffectTracking>>()
108
+
109
+ export const opaqueEffects = new WeakSet<ScopedCallback>()
110
+
111
+ // Dependency graph: tracks which effects trigger which other effects
112
+ // Uses roots (Function) as keys for consistency
113
+ const effectTriggers = new WeakMap<Function, IterableWeakSet<Function>>()
114
+ const effectTriggeredBy = new WeakMap<Function, IterableWeakSet<Function>>()
115
+
116
+ // Transitive closures: track all indirect relationships
117
+ // causesClosure: for each effect, all effects that trigger it (directly or indirectly)
118
+ // consequencesClosure: for each effect, all effects that it triggers (directly or indirectly)
119
+ const causesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
120
+ const consequencesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
121
+
122
+ // Debug: Capture where an effect was created
123
+ export const effectCreationStacks = new WeakMap<Function, string>()
124
+
125
+ /**
126
+ * Gets or creates an IterableWeakSet for a closure map
127
+ */
128
+ function getOrCreateClosure(
129
+ closure: WeakMap<Function, IterableWeakSet<Function>>,
130
+ root: Function
131
+ ): IterableWeakSet<Function> {
132
+ let set = closure.get(root)
133
+ if (!set) {
134
+ set = new IterableWeakSet()
135
+ closure.set(root, set)
136
+ }
137
+ return set
138
+ }
139
+
140
+ /**
141
+ * Adds an edge to the dependency graph: callerRoot → targetRoot
142
+ * Also maintains transitive closures
143
+ * @param callerRoot - Root function of the effect that triggers
144
+ * @param targetRoot - Root function of the effect being triggered
145
+ */
146
+ function addGraphEdge(callerRoot: Function, targetRoot: Function) {
147
+ // Skip if edge already exists
148
+ const triggers = effectTriggers.get(callerRoot)
149
+ if (triggers?.has(targetRoot)) {
150
+ return // Edge already exists
151
+ }
152
+
153
+ // Add to forward graph: callerRoot → targetRoot
154
+ if (!triggers) {
155
+ const newTriggers = new IterableWeakSet<Function>()
156
+ newTriggers.add(targetRoot)
157
+ effectTriggers.set(callerRoot, newTriggers)
158
+ } else {
159
+ triggers.add(targetRoot)
160
+ }
161
+
162
+ // Add to reverse graph: targetRoot ← callerRoot
163
+ let triggeredBy = effectTriggeredBy.get(targetRoot)
164
+ if (!triggeredBy) {
165
+ triggeredBy = new IterableWeakSet()
166
+ effectTriggeredBy.set(targetRoot, triggeredBy)
167
+ }
168
+ triggeredBy.add(callerRoot)
169
+
170
+ // Update transitive closures
171
+ // When U→V is added, we need to propagate the relationship:
172
+ // 1. Add U to causesClosure(V) and V to consequencesClosure(U) (direct relationship)
173
+ // 2. For each X in causesClosure(U): add V to consequencesClosure(X) and X to causesClosure(V)
174
+ // 3. For each Y in consequencesClosure(V): add U to causesClosure(Y) and Y to consequencesClosure(U)
175
+ // Note: Self-loops (U→U) are not added to closures - if an effect appears in its own closure,
176
+ // it means there's an indirect cycle that should be detected
177
+
178
+ // Self-loops are explicitly ignored - an effect reading and writing the same property
179
+ // (e.g., obj.prop++) should not create a dependency relationship or appear in closures
180
+ if (callerRoot === targetRoot) {
181
+ return
182
+ }
183
+
184
+ const uConsequences = getOrCreateClosure(consequencesClosure, callerRoot)
185
+ const vCauses = getOrCreateClosure(causesClosure, targetRoot)
186
+
187
+ // 1. Add direct relationship
188
+ uConsequences.add(targetRoot)
189
+ vCauses.add(callerRoot)
190
+
191
+ // 2. For each X in causesClosure(U): X→U→V means X→V
192
+ const uCausesSet = causesClosure.get(callerRoot)
193
+ if (uCausesSet) {
194
+ for (const x of uCausesSet) {
195
+ // Skip if this would create a self-loop
196
+ if (x === targetRoot) continue
197
+ const xConsequences = getOrCreateClosure(consequencesClosure, x)
198
+ xConsequences.add(targetRoot)
199
+ vCauses.add(x)
200
+ }
201
+ }
202
+
203
+ // 3. For each Y in consequencesClosure(V): U→V→Y means U→Y
204
+ const vConsequencesSet = consequencesClosure.get(targetRoot)
205
+ if (vConsequencesSet) {
206
+ for (const y of vConsequencesSet) {
207
+ // Skip if this would create a self-loop
208
+ if (y === callerRoot) continue
209
+ const yCauses = getOrCreateClosure(causesClosure, y)
210
+ yCauses.add(callerRoot)
211
+ uConsequences.add(y)
212
+ }
213
+ }
214
+
215
+ // 4. Cross-product: for each X in causesClosure(U) and Y in consequencesClosure(V): X→Y
216
+ if (uCausesSet && vConsequencesSet) {
217
+ for (const x of uCausesSet) {
218
+ const xConsequences = getOrCreateClosure(consequencesClosure, x)
219
+ for (const y of vConsequencesSet) {
220
+ // Skip if this would create a self-loop
221
+ if (x === y) continue
222
+ xConsequences.add(y)
223
+ const yCauses = getOrCreateClosure(causesClosure, y)
224
+ yCauses.add(x)
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Checks if there's a path from start to end in the dependency graph, excluding a specific node
232
+ * Uses BFS to find any path that doesn't go through the excluded node
233
+ * @param start - Starting node
234
+ * @param end - Target node
235
+ * @param exclude - Node to exclude from the path
236
+ * @returns true if a path exists without going through the excluded node
237
+ */
238
+ function hasPathExcluding(start: Function, end: Function, exclude: Function): boolean {
239
+ if (start === end) return true
240
+ if (start === exclude) return false
241
+
242
+ const visited = new Set<Function>()
243
+ const queue: Function[] = [start]
244
+ visited.add(start)
245
+ visited.add(exclude) // Pre-mark excluded node as visited to skip it
246
+
247
+ while (queue.length > 0) {
248
+ const current = queue.shift()!
249
+ const triggers = effectTriggers.get(current)
250
+ if (!triggers) continue
251
+
252
+ for (const next of triggers) {
253
+ if (next === end) return true
254
+ if (!visited.has(next)) {
255
+ visited.add(next)
256
+ queue.push(next)
257
+ }
258
+ }
259
+ }
260
+
261
+ return false
262
+ }
263
+
264
+ /**
265
+ * Removes all edges involving the given effect from the dependency graph
266
+ * Also cleans up transitive closures by propagating cleanup to all affected effects
267
+ * Called when an effect is stopped/cleaned up
268
+ * @param effect - The effect being cleaned up
269
+ */
270
+ function cleanupEffectFromGraph(effect: ScopedCallback) {
271
+ const root = getRoot(effect)
272
+
273
+ // Get closures before removing direct edges (needed for propagation)
274
+ const rootCauses = causesClosure.get(root)
275
+ const rootConsequences = consequencesClosure.get(root)
276
+
277
+ // Remove from effectTriggers (outgoing edges)
278
+ const triggers = effectTriggers.get(root)
279
+ if (triggers) {
280
+ // Remove this root from all targets' effectTriggeredBy sets
281
+ for (const targetRoot of triggers) {
282
+ const triggeredBy = effectTriggeredBy.get(targetRoot)
283
+ triggeredBy?.delete(root)
284
+ }
285
+ effectTriggers.delete(root)
286
+ }
287
+
288
+ // Remove from effectTriggeredBy (incoming edges)
289
+ const triggeredBy = effectTriggeredBy.get(root)
290
+ if (triggeredBy) {
291
+ // Remove this root from all sources' effectTriggers sets
292
+ for (const sourceRoot of triggeredBy) {
293
+ const triggers = effectTriggers.get(sourceRoot)
294
+ triggers?.delete(root)
295
+ }
296
+ effectTriggeredBy.delete(root)
297
+ }
298
+
299
+ // Propagate closure cleanup to all affected effects
300
+ // When removing B from A → B → C:
301
+ // - Remove B from causesClosure(C) and consequencesClosure(A)
302
+ // - For each X in causesClosure(B): remove C from consequencesClosure(X) if B was the only path
303
+ // - For each Y in consequencesClosure(B): remove A from causesClosure(Y) if B was the only path
304
+ // - Remove transitive relationships that depended on B
305
+
306
+ if (rootCauses) {
307
+ // For each X that triggers root: remove root from X's consequences
308
+ // Only remove root's consequences if no alternate path exists
309
+ for (const causeRoot of rootCauses) {
310
+ const causeConsequences = consequencesClosure.get(causeRoot)
311
+ if (causeConsequences) {
312
+ // Remove root itself (it's being cleaned up)
313
+ causeConsequences.delete(root)
314
+ // Only remove consequences of root if there's no alternate path from causeRoot to them
315
+ if (rootConsequences) {
316
+ for (const consequence of rootConsequences) {
317
+ // Check if causeRoot can still reach consequence without going through root
318
+ if (!hasPathExcluding(causeRoot, consequence, root)) {
319
+ causeConsequences.delete(consequence)
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ if (rootConsequences) {
328
+ // For each Y that root triggers: remove root from Y's causes
329
+ // Only remove root's causes if no alternate path exists
330
+ for (const consequenceRoot of rootConsequences) {
331
+ const consequenceCauses = causesClosure.get(consequenceRoot)
332
+ if (consequenceCauses) {
333
+ // Remove root itself (it's being cleaned up)
334
+ consequenceCauses.delete(root)
335
+ // Only remove causes of root if there's no alternate path from them to consequenceRoot
336
+ if (rootCauses) {
337
+ for (const cause of rootCauses) {
338
+ // Check if cause can still reach consequenceRoot without going through root
339
+ if (!hasPathExcluding(cause, consequenceRoot, root)) {
340
+ consequenceCauses.delete(cause)
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ // Cross-product cleanup: for each X in causesClosure(B) and Y in consequencesClosure(B),
349
+ // remove X→Y if B was the only path connecting them
350
+ if (rootCauses && rootConsequences) {
351
+ for (const x of rootCauses) {
352
+ const xConsequences = consequencesClosure.get(x)
353
+ if (xConsequences) {
354
+ for (const y of rootConsequences) {
355
+ // Check if there's still a path from X to Y without going through root
356
+ // Use BFS to find any path that doesn't include root
357
+ if (!hasPathExcluding(x, y, root)) {
358
+ xConsequences.delete(y)
359
+ const yCauses = causesClosure.get(y)
360
+ yCauses?.delete(x)
361
+ }
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ // Finally, delete the closures for this effect
368
+ causesClosure.delete(root)
369
+ consequencesClosure.delete(root)
370
+ }
371
+
372
+ // Batch queue structure - optimized with cached in-degrees
373
+ interface BatchQueue {
374
+ // All effects in the current batch that still need to be executed (todos)
375
+ all: Map<Function, ScopedCallback> // root → effect
376
+ // Cached in-degrees for each effect in the batch (number of causes in batch)
377
+ inDegrees: Map<Function, number> // root → in-degree count
378
+ }
379
+
380
+ // Track currently executing effects to prevent re-execution
381
+ // These are all the effects triggered under `activeEffect`
382
+ let batchQueue: BatchQueue | undefined
383
+ const batchCleanups = new Set<ScopedCallback>()
384
+
385
+ /**
386
+ * Computes and caches in-degrees for all effects in the batch
387
+ * Called once when batch starts or when new effects are added
388
+ */
389
+ function computeAllInDegrees(batch: BatchQueue): void {
390
+ const activeEffect = getActiveEffect()
391
+ const activeRoot = activeEffect ? getRoot(activeEffect) : null
392
+
393
+ // Reset all in-degrees
394
+ batch.inDegrees.clear()
395
+
396
+ for (const [root] of batch.all) {
397
+ let inDegree = 0
398
+ const causes = causesClosure.get(root)
399
+ if (causes) {
400
+ for (const causeRoot of causes) {
401
+ // Only count if it's in the batch and not the active/self effect
402
+ if (batch.all.has(causeRoot) && causeRoot !== activeRoot && causeRoot !== root) {
403
+ inDegree++
404
+ }
405
+ }
406
+ }
407
+ batch.inDegrees.set(root, inDegree)
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Decrements in-degrees of all effects that depend on the executed effect
413
+ * Called after an effect is executed to update the cached in-degrees
414
+ */
415
+ function decrementInDegreesForExecuted(batch: BatchQueue, executedRoot: Function): void {
416
+ // Get all effects that this executed effect triggers
417
+ const consequences = consequencesClosure.get(executedRoot)
418
+ if (!consequences) return
419
+
420
+ for (const consequenceRoot of consequences) {
421
+ // Only update if it's still in the batch
422
+ if (batch.all.has(consequenceRoot)) {
423
+ const currentDegree = batch.inDegrees.get(consequenceRoot) ?? 0
424
+ if (currentDegree > 0) {
425
+ batch.inDegrees.set(consequenceRoot, currentDegree - 1)
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Computes the in-degree (number of dependencies) for an effect in the current batch
433
+ * Uses causesClosure to count all effects (directly or indirectly) that trigger this effect
434
+ * @param root - Root function of the effect
435
+ * @param batchEffects - Map of all effects in current batch (todos - effects that still need execution)
436
+ * @returns Number of effects in batch that trigger this effect (directly or indirectly)
437
+ *
438
+ * TODO: Optimization - For large graphs with small batches, iterating over all causes in the closure
439
+ * can be expensive. Consider maintaining a separate "batch causes" set or caching in-degrees.
440
+ */
441
+ /* function computeInDegreeInBatch(
442
+ root: Function,
443
+ batchEffects: Map<Function, ScopedCallback>
444
+ ): number {
445
+ let inDegree = 0
446
+ const activeEffect = getActiveEffect()
447
+ const activeRoot = activeEffect ? getRoot(activeEffect) : null
448
+
449
+ // Count effects in batch that trigger this effect (directly or indirectly)
450
+ // Using causesClosure which contains all transitive causes
451
+ // Note: batchEffects only contains effects that still need execution (todos),
452
+ // so we don't need to check if causes have been executed - they're not in the map if executed
453
+ const causes = causesClosure.get(root)
454
+ if (causes) {
455
+ for (const causeRoot of causes) {
456
+ // Only count if it's in the batch (still needs execution)
457
+ // BUT: don't count the currently executing effect (active effect)
458
+ // This handles the case where an effect is triggered during another effect's execution
459
+ // Note: Self-loops are ignored - they should not appear in closures, but we check to be safe
460
+ if (batchEffects.has(causeRoot) && causeRoot !== activeRoot && causeRoot !== root) {
461
+ inDegree++
462
+ }
463
+ }
464
+ }
465
+
466
+ return inDegree
467
+ }
468
+
469
+ /**
470
+ * Finds a path from startRoot to endRoot in the dependency graph
471
+ * Uses DFS to find the path through direct edges
472
+ * @param startRoot - Starting effect root
473
+ * @param endRoot - Target effect root
474
+ * @param visited - Set of visited nodes (for recursion)
475
+ * @param path - Current path being explored
476
+ * @returns Path from startRoot to endRoot, or empty array if no path exists
477
+ */
478
+ function findPath(
479
+ startRoot: Function,
480
+ endRoot: Function,
481
+ visited: Set<Function> = new Set(),
482
+ path: Function[] = []
483
+ ): Function[] {
484
+ if (startRoot === endRoot) {
485
+ return [...path, endRoot]
486
+ }
487
+
488
+ if (visited.has(startRoot)) {
489
+ return []
490
+ }
491
+
492
+ visited.add(startRoot)
493
+ const newPath = [...path, startRoot]
494
+
495
+ const triggers = effectTriggers.get(startRoot)
496
+ if (triggers) {
497
+ for (const targetRoot of triggers) {
498
+ const result = findPath(targetRoot, endRoot, visited, newPath)
499
+ if (result.length > 0) {
500
+ return result
501
+ }
502
+ }
503
+ }
504
+
505
+ return []
506
+ }
507
+
508
+ /**
509
+ * Gets the cycle path when adding an edge would create a cycle
510
+ * @param callerRoot - Root of the effect that triggers
511
+ * @param targetRoot - Root of the effect being triggered
512
+ * @returns Array of effect roots forming the cycle, or empty array if no cycle
513
+ */
514
+ function getCyclePathForEdge(callerRoot: Function, targetRoot: Function): Function[] {
515
+ // Find path from targetRoot back to callerRoot (this is the existing path)
516
+ // Then adding callerRoot -> targetRoot completes the cycle
517
+ const path = findPath(targetRoot, callerRoot)
518
+ if (path.length > 0) {
519
+ // The cycle is: callerRoot -> targetRoot -> ... -> callerRoot
520
+ return [callerRoot, ...path]
521
+ }
522
+ return []
523
+ }
524
+
525
+ /**
526
+ * Checks if adding an edge would create a cycle
527
+ * Uses causesClosure to check if callerRoot is already a cause of targetRoot
528
+ * Self-loops (callerRoot === targetRoot) are explicitly ignored and return false
529
+ * @param callerRoot - Root of the effect that triggers
530
+ * @param targetRoot - Root of the effect being triggered
531
+ * @returns true if adding this edge would create a cycle
532
+ */
533
+ function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
534
+ // Self-loops are explicitly ignored - an effect reading and writing the same property
535
+ // (e.g., obj.prop++) should not create a dependency relationship
536
+ if (callerRoot === targetRoot) {
537
+ return false
538
+ }
539
+
540
+ // Check if targetRoot already triggers callerRoot (directly or indirectly)
541
+ // This would create a cycle: callerRoot -> targetRoot -> ... -> callerRoot
542
+ // Using consequencesClosure: if targetRoot triggers callerRoot, then callerRoot is in consequencesClosure(targetRoot)
543
+ const targetConsequences = consequencesClosure.get(targetRoot)
544
+ if (targetConsequences?.has(callerRoot)) {
545
+ return true // Cycle detected: targetRoot -> ... -> callerRoot, and we're adding callerRoot -> targetRoot
546
+ }
547
+
548
+ return false
549
+ }
550
+
551
+ /**
552
+ * Adds an effect to the batch queue
553
+ * @param effect - The effect to add
554
+ * @param caller - The active effect that triggered this one (optional)
555
+ * @param immediate - If true, don't create edges in the dependency graph
556
+ */
557
+ function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?: boolean) {
558
+ if (!batchQueue) return
559
+
560
+ const root = getRoot(effect)
561
+
562
+ // 1. Add to batch first (needed for cycle detection)
563
+ batchQueue.all.set(root, effect)
564
+
565
+ // 2. Add to global graph (if caller exists and not immediate) - USE ROOTS ONLY
566
+ // When immediate is true, don't create edges - the effect is not considered as a consequence
567
+ if (caller && !immediate) {
568
+ const callerRoot = getRoot(caller)
569
+
570
+ // Check for cycle BEFORE adding edge
571
+ // We check if adding callerRoot -> root would create a cycle
572
+ // This means checking if root already triggers callerRoot (directly or transitively)
573
+ if (wouldCreateCycle(callerRoot, root)) {
574
+ // Cycle detected! Get the full cycle path for debugging
575
+ const cyclePath = getCyclePathForEdge(callerRoot, root)
576
+ const cycleMessage =
577
+ cyclePath.length > 0
578
+ ? `Cycle detected: ${cyclePath.map((r) => r.name || r.toString()).join(' → ')}`
579
+ : `Cycle detected: ${callerRoot.name || callerRoot.toString()} → ${root.name || root.toString()} (and back)`
580
+
581
+ const cycleHandling = options.cycleHandling
582
+
583
+ // In strict mode, we throw immediately on detection
584
+ if (cycleHandling === 'strict') {
585
+ batchQueue.all.delete(root)
586
+ const causalChain = getTriggerChain(effect)
587
+ const creationStack = effectCreationStacks.get(root)
588
+
589
+ throw new ReactiveError(`[reactive] Strict Cycle Prevention: ${cycleMessage}`, {
590
+ code: ReactiveErrorCode.CycleDetected,
591
+ cycle: cyclePath.map((r) => r.name || r.toString()),
592
+ details: cycleMessage,
593
+ causalChain,
594
+ creationStack,
595
+ })
596
+ }
597
+
598
+ switch (cycleHandling) {
599
+ case 'throw': {
600
+ // Remove from batch before throwing
601
+ batchQueue.all.delete(root)
602
+ const causalChain = getTriggerChain(effect)
603
+ const creationStack = effectCreationStacks.get(root)
604
+
605
+ throw new ReactiveError(`[reactive] ${cycleMessage}`, {
606
+ code: ReactiveErrorCode.CycleDetected,
607
+ cycle: cyclePath.map((r) => r.name || r.toString()),
608
+ details: cycleMessage,
609
+ causalChain,
610
+ creationStack,
611
+ })
612
+ }
613
+ case 'warn':
614
+ options.warn(`[reactive] ${cycleMessage}`)
615
+ // Don't add the edge, break the cycle
616
+ batchQueue.all.delete(root)
617
+ return
618
+ case 'break':
619
+ // Silently break cycle, don't add the edge
620
+ batchQueue.all.delete(root)
621
+ return
622
+ }
623
+ }
624
+
625
+ addGraphEdge(callerRoot, root) // Add to persistent graph using roots
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Adds a cleanup function to be called when the current batch of effects completes
631
+ * @param cleanup - The cleanup function to add
632
+ */
633
+ export function addBatchCleanup(cleanup: ScopedCallback) {
634
+ if (!batchQueue) cleanup()
635
+ else batchCleanups.add(cleanup)
636
+ }
637
+
638
+ /**
639
+ * Semantic alias for `addBatchCleanup` - defers work to the end of the current reactive batch.
640
+ *
641
+ * Use this when an effect needs to perform an action that would modify state the effect depends on,
642
+ * which would create a reactive cycle. The deferred callback runs after all effects complete.
643
+ *
644
+ * @param callback - The callback to defer until after the current batch completes
645
+ *
646
+ * @example
647
+ * ```typescript
648
+ * effect(() => {
649
+ * processData()
650
+ *
651
+ * // Defer to avoid cycle (createMovement modifies state this effect reads)
652
+ * defer(() => {
653
+ * createMovement(data)
654
+ * })
655
+ * })
656
+ * ```
657
+ */
658
+ export const defer = addBatchCleanup
659
+
660
+ /**
661
+ * Gets a cycle path for debugging
662
+ * Uses DFS to find cycles in the batch
663
+ * @param batchQueue - The batch queue
664
+ * @returns Array of effect roots forming a cycle
665
+ */
666
+ function getCyclePath(batchQueue: BatchQueue): Function[] {
667
+ // If all effects have in-degree > 0, there must be a cycle
668
+ // Use DFS to find it
669
+ const visited = new Set<Function>()
670
+ const recursionStack = new Set<Function>()
671
+ const path: Function[] = []
672
+
673
+ for (const [root] of batchQueue.all) {
674
+ if (visited.has(root)) continue
675
+ const cycle = findCycle(root, visited, recursionStack, path, batchQueue)
676
+ if (cycle.length > 0) {
677
+ return cycle
678
+ }
679
+ }
680
+
681
+ return []
682
+ }
683
+
684
+ function findCycle(
685
+ root: Function,
686
+ visited: Set<Function>,
687
+ recursionStack: Set<Function>,
688
+ path: Function[],
689
+ batchQueue: BatchQueue
690
+ ): Function[] {
691
+ if (recursionStack.has(root)) {
692
+ // Found a cycle! Return the path from the cycle start to root
693
+ const cycleStart = path.indexOf(root)
694
+ return path.slice(cycleStart).concat([root])
695
+ }
696
+
697
+ if (visited.has(root)) {
698
+ return []
699
+ }
700
+
701
+ visited.add(root)
702
+ recursionStack.add(root)
703
+ path.push(root)
704
+
705
+ // Follow edges to effects in the batch
706
+ // Use direct edges (effectTriggers) for cycle detection
707
+ const triggers = effectTriggers.get(root)
708
+ if (triggers) {
709
+ for (const targetRoot of triggers) {
710
+ if (batchQueue.all.has(targetRoot)) {
711
+ const cycle = findCycle(targetRoot, visited, recursionStack, path, batchQueue)
712
+ if (cycle.length > 0) {
713
+ return cycle
714
+ }
715
+ }
716
+ }
717
+ }
718
+
719
+ path.pop()
720
+ recursionStack.delete(root)
721
+ return []
722
+ }
723
+
724
+ /**
725
+ * Executes the next effect in dependency order (using cached in-degrees)
726
+ * Finds an effect with in-degree 0 and executes it
727
+ * @returns The return value of the executed effect, or null if batch is complete
728
+ */
729
+ function executeNext(effectuatedRoots: Function[]): any {
730
+ // Find an effect with in-degree 0 using cached values
731
+ let nextEffect: ScopedCallback | null = null
732
+ let nextRoot: Function | null = null
733
+
734
+ // Find an effect with in-degree 0 (no dependencies in batch that still need execution)
735
+ // Using cached in-degrees for O(n) lookup instead of O(n²)
736
+ for (const [root, effect] of batchQueue!.all) {
737
+ const inDegree = batchQueue!.inDegrees.get(root) ?? 0
738
+ if (inDegree === 0) {
739
+ nextEffect = effect
740
+ nextRoot = root
741
+ break
742
+ }
743
+ }
744
+
745
+ if (!nextEffect) {
746
+ // No effect with in-degree 0 - there must be a cycle
747
+ // If all effects have dependencies, it means there's a circular dependency
748
+ if (batchQueue!.all.size > 0) {
749
+ let cycle = getCyclePath(batchQueue!)
750
+ // If we couldn't find a cycle path using direct edges, try using closures
751
+ // (transitive relationships) - if all effects have in-degree > 0, there must be a cycle
752
+ if (cycle.length === 0) {
753
+ // Try to find a cycle using consequencesClosure (transitive relationships)
754
+ // Note: Self-loops are ignored - we only look for cycles between different effects
755
+ for (const [root] of batchQueue!.all) {
756
+ const consequences = consequencesClosure.get(root)
757
+ if (consequences) {
758
+ // Check if any consequence in the batch also has root as a consequence
759
+ for (const consequence of consequences) {
760
+ // Skip self-loops - they are ignored
761
+ if (consequence === root) continue
762
+ if (batchQueue!.all.has(consequence)) {
763
+ const consequenceConsequences = consequencesClosure.get(consequence)
764
+ if (consequenceConsequences?.has(root)) {
765
+ // Found cycle: root -> consequence -> root
766
+ cycle = [root, consequence, root]
767
+ break
768
+ }
769
+ }
770
+ }
771
+ if (cycle.length > 0) break
772
+ }
773
+ }
774
+ }
775
+ const cycleMessage =
776
+ cycle.length > 0
777
+ ? `Cycle detected: ${cycle.map((r) => r.name || '<anonymous>').join(' → ')}`
778
+ : 'Cycle detected in effect batch - all effects have dependencies that prevent execution'
779
+
780
+ const cycleHandling = options.cycleHandling
781
+ switch (cycleHandling) {
782
+ case 'throw':
783
+ throw new ReactiveError(`[reactive] ${cycleMessage}`)
784
+ case 'warn': {
785
+ options.warn(`[reactive] ${cycleMessage}`)
786
+ // Break the cycle by executing one effect anyway
787
+ const firstEffect = batchQueue!.all.values().next().value
788
+ if (firstEffect) {
789
+ const firstRoot = getRoot(firstEffect)
790
+ batchQueue!.all.delete(firstRoot)
791
+ batchQueue!.inDegrees.delete(firstRoot)
792
+ return firstEffect()
793
+ }
794
+ break
795
+ }
796
+ case 'break': {
797
+ // Silently break cycle
798
+ const firstEffect2 = batchQueue!.all.values().next().value
799
+ if (firstEffect2) {
800
+ const firstRoot2 = getRoot(firstEffect2)
801
+ batchQueue!.all.delete(firstRoot2)
802
+ batchQueue!.inDegrees.delete(firstRoot2)
803
+ return firstEffect2()
804
+ }
805
+ break
806
+ }
807
+ }
808
+ }
809
+ return null // Batch complete
810
+ }
811
+
812
+ effectuatedRoots.push(getRoot(nextEffect))
813
+ // Execute the effect
814
+ const result = nextEffect()
815
+
816
+ // Remove from batch and update in-degrees of dependents
817
+ batchQueue!.all.delete(nextRoot!)
818
+ batchQueue!.inDegrees.delete(nextRoot!)
819
+ decrementInDegreesForExecuted(batchQueue!, nextRoot!)
820
+
821
+ return result
822
+ }
823
+
824
+ // Track which sub-effects have been executed to prevent infinite loops
825
+ // These are all the effects triggered under `activeEffect` and all their sub-effects
826
+ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'immediate') {
827
+ if (!Array.isArray(effect)) effect = [effect]
828
+ const roots = effect.map(getRoot)
829
+
830
+ if (batchQueue) {
831
+ // Nested batch - add to existing
832
+ options?.chain(roots, getRoot(getActiveEffect()))
833
+ const caller = getActiveEffect()
834
+ for (let i = 0; i < effect.length; i++) {
835
+ addToBatch(effect[i], caller, immediate === 'immediate')
836
+ }
837
+ if (immediate) {
838
+ const firstReturn: { value?: any } = {}
839
+ // Execute immediately (before batch returns)
840
+ for (let i = 0; i < effect.length; i++) {
841
+ try {
842
+ const rv = effect[i]()
843
+ if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
844
+ } finally {
845
+ const root = getRoot(effect[i])
846
+ batchQueue.all.delete(root)
847
+ }
848
+ }
849
+ return firstReturn.value
850
+ }
851
+ // Otherwise, effects will be picked up in next executeNext() call
852
+ } else {
853
+ // New batch - initialize
854
+ options.beginChain(roots)
855
+ batchQueue = {
856
+ all: new Map(),
857
+ inDegrees: new Map(),
858
+ }
859
+
860
+ // Add initial effects
861
+ const caller = getActiveEffect()
862
+ for (let i = 0; i < effect.length; i++) {
863
+ addToBatch(effect[i], caller, immediate === 'immediate')
864
+ }
865
+
866
+ const effectuatedRoots: ScopedCallback[] = []
867
+ computeAllInDegrees(batchQueue)
868
+ if (immediate) {
869
+ // Execute immediately (before batch returns)
870
+ const firstReturn: { value?: any } = {}
871
+ try {
872
+ for (let i = 0; i < effect.length; i++) {
873
+ try {
874
+ const rv = effect[i]()
875
+ if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
876
+ } finally {
877
+ const root = getRoot(effect[i])
878
+ batchQueue.all.delete(root)
879
+ }
880
+ }
881
+ // After immediate execution, execute any effects that were triggered during execution
882
+ // This is important for @atomic decorator - effects triggered inside should still run
883
+ while (batchQueue.all.size > 0) {
884
+ if (effectuatedRoots.length > options.maxEffectChain) {
885
+ const cycle = findCycleInChain(effectuatedRoots as any)
886
+ const trace = formatRoots(effectuatedRoots as any)
887
+ const message = cycle
888
+ ? `Max effect chain reached (cycle detected: ${formatRoots(cycle)})`
889
+ : `Max effect chain reached (trace: ${trace})`
890
+
891
+ const queuedRoots = batchQueue ? Array.from(batchQueue.all.keys()) : []
892
+ const queued = queuedRoots.map((r) => r.name || '<anonymous>')
893
+ const debugInfo = {
894
+ code: ReactiveErrorCode.MaxDepthExceeded,
895
+ effectuatedRoots,
896
+ cycle,
897
+ trace,
898
+ maxEffectChain: options.maxEffectChain,
899
+ queued: queued.slice(0, 50),
900
+ queuedCount: queued.length,
901
+ // Try to get causation for the last effect
902
+ causalChain:
903
+ effectuatedRoots.length > 0
904
+ ? getTriggerChain(
905
+ batchQueue.all.get(effectuatedRoots[effectuatedRoots.length - 1])!
906
+ )
907
+ : [],
908
+ }
909
+ switch (options.maxEffectReaction) {
910
+ case 'throw':
911
+ throw new ReactiveError(`[reactive] ${message}`, debugInfo)
912
+ case 'debug':
913
+ // biome-ignore lint/suspicious/noDebugger: This is the whole point here
914
+ debugger
915
+ throw new ReactiveError(`[reactive] ${message}`, debugInfo)
916
+ case 'warn':
917
+ options.warn(
918
+ `[reactive] ${message} (queued: ${queued.slice(0, 10).join(', ')}${queued.length > 10 ? ', …' : ''})`
919
+ )
920
+ break
921
+ }
922
+ }
923
+ if (!batchQueue || batchQueue.all.size === 0) break
924
+ const rv = executeNext(effectuatedRoots)
925
+ // If executeNext() returned null but batch is not empty, it means a cycle was detected
926
+ // and an error was thrown, so we won't reach here
927
+ if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
928
+ }
929
+ const cleanups = Array.from(batchCleanups)
930
+ batchCleanups.clear()
931
+ for (const cleanup of cleanups) cleanup()
932
+ return firstReturn.value
933
+ } finally {
934
+ batchQueue = undefined
935
+ options.endChain()
936
+ }
937
+ } else {
938
+ // Execute in dependency order
939
+ const firstReturn: { value?: any } = {}
940
+ try {
941
+ // Outer loop: continue while there are effects OR cleanups pending.
942
+ // This ensures effects triggered by cleanups are not lost.
943
+ while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
944
+ // Inner loop: execute all pending effects
945
+ while (batchQueue.all.size > 0) {
946
+ if (effectuatedRoots.length > options.maxEffectChain) {
947
+ const cycle = findCycleInChain(effectuatedRoots as any)
948
+ const trace = formatRoots(effectuatedRoots as any)
949
+ const message = cycle
950
+ ? `Max effect chain reached (cycle detected: ${formatRoots(cycle)})`
951
+ : `Max effect chain reached (trace: ${trace})`
952
+
953
+ const queuedRoots = batchQueue ? Array.from(batchQueue.all.keys()) : []
954
+ const queued = queuedRoots.map((r) => r.name || '<anonymous>')
955
+ const debugInfo = {
956
+ code: ReactiveErrorCode.MaxDepthExceeded,
957
+ effectuatedRoots,
958
+ cycle,
959
+ trace,
960
+ maxEffectChain: options.maxEffectChain,
961
+ queued: queued.slice(0, 50),
962
+ queuedCount: queued.length,
963
+ // Try to get causation for the last effect
964
+ causalChain:
965
+ effectuatedRoots.length > 0
966
+ ? getTriggerChain(
967
+ batchQueue.all.get(effectuatedRoots[effectuatedRoots.length - 1])!
968
+ )
969
+ : [],
970
+ }
971
+ switch (options.maxEffectReaction) {
972
+ case 'throw':
973
+ throw new ReactiveError(`[reactive] ${message}`, debugInfo)
974
+ case 'debug':
975
+ // biome-ignore lint/suspicious/noDebugger: This is the whole point here
976
+ debugger
977
+ throw new ReactiveError(`[reactive] ${message}`, debugInfo)
978
+ case 'warn':
979
+ options.warn(
980
+ `[reactive] ${message} (queued: ${queued.slice(0, 10).join(', ')}${queued.length > 10 ? ', …' : ''})`
981
+ )
982
+ break
983
+ }
984
+ }
985
+ const rv = executeNext(effectuatedRoots)
986
+ // executeNext() returns null when batch is complete or cycle detected (throws error)
987
+ // But functions can legitimately return null, so we check batchQueue.all.size instead
988
+ if (batchQueue.all.size === 0) {
989
+ // Batch complete
990
+ break
991
+ }
992
+ // If executeNext() returned null but batch is not empty, it means a cycle was detected
993
+ // and an error was thrown, so we won't reach here
994
+ if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
995
+ // Note: executeNext() already removed it from batchQueue, so we track by count
996
+ }
997
+ // Process cleanups. If they trigger new effects, the outer loop will catch them.
998
+ if (batchCleanups.size > 0) {
999
+ const cleanups = Array.from(batchCleanups)
1000
+ batchCleanups.clear()
1001
+ for (const cleanup of cleanups) cleanup()
1002
+ }
1003
+ }
1004
+ return firstReturn.value
1005
+ } finally {
1006
+ batchQueue = undefined
1007
+ options.endChain()
1008
+ }
1009
+ }
1010
+
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Decorator that makes methods atomic - batches all effects triggered within the method
1016
+ */
1017
+ export const atomic = decorator({
1018
+ method(original) {
1019
+ return function (...args: any[]) {
1020
+ return batch(
1021
+ markWithRoot(() => original.apply(this, args), original),
1022
+ 'immediate'
1023
+ )
1024
+ }
1025
+ },
1026
+ default<Args extends any[], Return>(
1027
+ original: (...args: Args) => Return
1028
+ ): (...args: Args) => Return {
1029
+ return function (this: any, ...args: Args) {
1030
+ return batch(
1031
+ markWithRoot(() => original.apply(this, args), original),
1032
+ 'immediate'
1033
+ )
1034
+ }
1035
+ },
1036
+ })
1037
+
1038
+ const fr = new FinalizationRegistry<() => void>((f) => f())
1039
+
1040
+ /**
1041
+ * @param fn - The effect function to run - provides the cleaner
1042
+ * @returns The cleanup function
1043
+ */
1044
+ /**
1045
+ * Creates a reactive effect that automatically re-runs when dependencies change
1046
+ * @param fn - The effect function that provides dependencies and may return a cleanup function or Promise
1047
+ * @param options - Options for effect execution
1048
+ * @returns A cleanup function to stop the effect
1049
+ */
1050
+ export function effect(
1051
+ //biome-ignore lint/suspicious/noConfusingVoidType: We have to
1052
+ fn: (access: DependencyAccess) => ScopedCallback | undefined | void | Promise<any>,
1053
+ effectOptions?: EffectOptions
1054
+ ): ScopedCallback {
1055
+ // Ensure zone is hooked if asyncZone option is enabled (lazy initialization)
1056
+ // Inject batch function to allow atomic game loops in requestAnimationFrame
1057
+ ensureZoneHooked(batch)
1058
+
1059
+ // Use per-effect asyncMode or fall back to global option
1060
+ const asyncMode = effectOptions?.asyncMode ?? options.asyncMode ?? 'cancel'
1061
+ if (options.introspection.enableHistory) {
1062
+ const stack = new Error().stack
1063
+ if (stack) {
1064
+ // Clean up the stack trace to remove internal frames
1065
+ const cleanStack = stack.split('\n').slice(2).join('\n')
1066
+ effectCreationStacks.set(getRoot(fn), cleanStack)
1067
+ }
1068
+ }
1069
+ let cleanup: (() => void) | null = null
1070
+ // capture the parent effect at creation time for ascend
1071
+ const parentsForAscend = captureEffectStack()
1072
+ const tracked = markWithRoot(<T>(cb: () => T) => withEffect(runEffect, cb), fn)
1073
+ const ascend = <T>(cb: () => T) => withEffectStack(parentsForAscend, cb)
1074
+ let effectStopped = false
1075
+ let hasReacted = false
1076
+ let runningPromise: Promise<any> | null = null
1077
+ let cancelPrevious: (() => void) | null = null
1078
+
1079
+ function runEffect() {
1080
+ // Clear previous dependencies
1081
+ if (cleanup) {
1082
+ const prevCleanup = cleanup
1083
+ cleanup = null
1084
+ withEffect(undefined, () => prevCleanup())
1085
+ }
1086
+
1087
+ // Handle async modes when effect is retriggered
1088
+ if (runningPromise) {
1089
+ if (asyncMode === 'cancel' && cancelPrevious) {
1090
+ // Cancel previous execution
1091
+ cancelPrevious()
1092
+ cancelPrevious = null
1093
+ runningPromise = null
1094
+ } else if (asyncMode === 'ignore') {
1095
+ // Ignore new execution while async work is running
1096
+ return
1097
+ }
1098
+ // Note: 'queue' mode not yet implemented
1099
+ }
1100
+
1101
+ // The effect has been stopped after having been planned
1102
+ if (effectStopped) return
1103
+
1104
+ options.enter(getRoot(fn))
1105
+ let reactionCleanup: ScopedCallback | undefined
1106
+ let result: any
1107
+ try {
1108
+ result = withEffect(runEffect, () => fn({ tracked, ascend, reaction: hasReacted }))
1109
+ if (
1110
+ result &&
1111
+ typeof result !== 'function' &&
1112
+ (typeof result !== 'object' || !('then' in result))
1113
+ )
1114
+ throw new ReactiveError(`[reactive] Effect returned a non-function value: ${result}`)
1115
+ // Check if result is a Promise (async effect)
1116
+ if (result && typeof result === 'object' && typeof result.then === 'function') {
1117
+ const originalPromise = result as Promise<any>
1118
+
1119
+ // Create a cancellation promise that we can reject
1120
+ let cancelReject: ((reason: any) => void) | null = null
1121
+ const cancelPromise = new Promise<never>((_, reject) => {
1122
+ cancelReject = reject
1123
+ })
1124
+
1125
+ const cancelError = new ReactiveError('[reactive] Effect canceled due to dependency change')
1126
+
1127
+ // Race between the actual promise and cancellation
1128
+ // If canceled, the race rejects, which will propagate through any promise chain
1129
+ runningPromise = Promise.race([originalPromise, cancelPromise])
1130
+
1131
+ // Store the cancellation function
1132
+ cancelPrevious = () => {
1133
+ if (cancelReject) {
1134
+ cancelReject(cancelError)
1135
+ }
1136
+ }
1137
+
1138
+ // Wrap the original promise chain so cancellation propagates
1139
+ // This ensures that when we cancel, the original promise's .catch() handlers are triggered
1140
+ // We do this by rejecting the race promise, which makes the original promise chain see the rejection
1141
+ // through the zone-wrapped .then()/.catch() handlers
1142
+ } else {
1143
+ // Synchronous result - treat as cleanup function
1144
+ reactionCleanup = result as undefined | ScopedCallback
1145
+ }
1146
+ } finally {
1147
+ hasReacted = true
1148
+ options.leave(fn)
1149
+ }
1150
+
1151
+ // Create cleanup function for next run
1152
+ cleanup = () => {
1153
+ cleanup = null
1154
+ reactionCleanup?.()
1155
+ // Remove this effect from all reactive objects it's watching
1156
+ const effectObjects = effectToReactiveObjects.get(runEffect)
1157
+ if (effectObjects) {
1158
+ for (const reactiveObj of effectObjects) {
1159
+ const objectWatchers = watchers.get(reactiveObj)
1160
+ if (objectWatchers) {
1161
+ for (const [prop, deps] of objectWatchers.entries()) {
1162
+ deps.delete(runEffect)
1163
+ if (deps.size === 0) {
1164
+ objectWatchers.delete(prop)
1165
+ }
1166
+ }
1167
+ if (objectWatchers.size === 0) {
1168
+ watchers.delete(reactiveObj)
1169
+ }
1170
+ }
1171
+ }
1172
+ effectToReactiveObjects.delete(runEffect)
1173
+ }
1174
+ // Invoke all child stops (recursive via subEffectCleanup calling its own mainCleanup)
1175
+ const children = effectChildren.get(runEffect)
1176
+ if (children) {
1177
+ for (const childCleanup of children) childCleanup()
1178
+ effectChildren.delete(runEffect)
1179
+ }
1180
+ }
1181
+ }
1182
+ // Mark the runEffect callback with the original function as its root
1183
+ markWithRoot(runEffect, fn)
1184
+
1185
+ // Register strict mode if enabled
1186
+ if (effectOptions?.opaque) {
1187
+ opaqueEffects.add(runEffect)
1188
+ }
1189
+
1190
+ if (isDevtoolsEnabled()) {
1191
+ registerEffectForDebug(runEffect)
1192
+ }
1193
+
1194
+ batch(runEffect, 'immediate')
1195
+
1196
+ const parent = parentsForAscend[0]
1197
+ // Store parent relationship for hierarchy traversal
1198
+ effectParent.set(runEffect, parent)
1199
+ // Only ROOT effects are registered for GC cleanup and zone tracking
1200
+ const isRootEffect = !parent
1201
+
1202
+ const stopEffect = (): void => {
1203
+ if (effectStopped) return
1204
+ effectStopped = true
1205
+ // Cancel any running async work
1206
+ if (cancelPrevious) {
1207
+ cancelPrevious()
1208
+ cancelPrevious = null
1209
+ runningPromise = null
1210
+ }
1211
+ cleanup?.()
1212
+ // Clean up dependency graph edges
1213
+ cleanupEffectFromGraph(runEffect)
1214
+ fr.unregister(stopEffect)
1215
+ }
1216
+ if (isRootEffect) {
1217
+ const callIfCollected = () => stopEffect()
1218
+ fr.register(
1219
+ callIfCollected,
1220
+ () => {
1221
+ stopEffect()
1222
+ options.garbageCollected(fn)
1223
+ },
1224
+ stopEffect
1225
+ )
1226
+ return callIfCollected
1227
+ }
1228
+ // Register this effect to be stopped when the parent effect is cleaned up
1229
+ let children = effectChildren.get(parent)
1230
+ if (!children) {
1231
+ children = new Set()
1232
+ effectChildren.set(parent, children)
1233
+ }
1234
+ const subEffectCleanup = (): void => {
1235
+ children.delete(subEffectCleanup)
1236
+ if (children.size === 0) {
1237
+ effectChildren.delete(parent)
1238
+ }
1239
+ // Execute this child effect cleanup (which triggers its own mainCleanup)
1240
+ stopEffect()
1241
+ }
1242
+ children.add(subEffectCleanup)
1243
+ return subEffectCleanup
1244
+ }
1245
+
1246
+ /**
1247
+ * Executes a function without tracking dependencies but maintains parent cleanup relationship
1248
+ * Effects created inside will still be cleaned up when the parent effect is destroyed
1249
+ * @param fn - The function to execute
1250
+ */
1251
+ export function untracked<T>(fn: () => T): T {
1252
+ // Store current tracking state and temporarily disable it
1253
+ // This prevents the parent effect from tracking dependencies during fn execution
1254
+ const wasTrackingDisabled = getTrackingDisabled()
1255
+ setTrackingDisabled(true)
1256
+
1257
+ try {
1258
+ return fn()
1259
+ } finally {
1260
+ // Restore tracking state
1261
+ setTrackingDisabled(wasTrackingDisabled)
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Executes a function from a virgin/root context - no parent effect, no tracking
1267
+ * Creates completely independent effects that won't be cleaned up by any parent
1268
+ * @param fn - The function to execute
1269
+ */
1270
+ export function root<T>(fn: () => T): T {
1271
+ let rv!: T
1272
+ withEffect(undefined, () => {
1273
+ rv = fn()
1274
+ })
1275
+ return rv
1276
+ }
1277
+
1278
+ export { effectTrackers }
1279
+
1280
+ /**
1281
+ * Creates a bidirectional binding between a reactive value and a non-reactive external value
1282
+ * Prevents infinite loops by automatically suppressing circular notifications
1283
+ *
1284
+ * @param received - Function called when the reactive value changes (external setter)
1285
+ * @param get - Getter for the reactive value OR an object with `{ get, set }` properties
1286
+ * @param set - Setter for the reactive value (required if `get` is a function)
1287
+ * @returns A function to manually provide updates from the external side
1288
+ *
1289
+ * @example
1290
+ * ```typescript
1291
+ * const model = reactive({ value: '' })
1292
+ * const input = { value: '' }
1293
+ *
1294
+ * // Bidirectional binding
1295
+ * const provide = biDi(
1296
+ * (v) => input.value = v, // external setter
1297
+ * () => model.value, // reactive getter
1298
+ * (v) => model.value = v // reactive setter
1299
+ * )
1300
+ *
1301
+ * // External notification (e.g., from input event)
1302
+ * provide('new value') // Updates model.value, doesn't trigger circular loop
1303
+ * ```
1304
+ *
1305
+ * @example Using object syntax
1306
+ * ```typescript
1307
+ * const provide = biDi(
1308
+ * (v) => setHTMLValue(v),
1309
+ * { get: () => reactiveObj.value, set: (v) => reactiveObj.value = v }
1310
+ * )
1311
+ * ```
1312
+ */
1313
+ export function biDi<T>(
1314
+ received: (value: T) => void,
1315
+ value: { get: () => T; set: (value: T) => void }
1316
+ ): (value: T) => void
1317
+ export function biDi<T>(
1318
+ received: (value: T) => void,
1319
+ get: () => T,
1320
+ set: (value: T) => void
1321
+ ): (value: T) => void
1322
+ export function biDi<T>(
1323
+ received: (value: T) => void,
1324
+ get: (() => T) | { get: () => T; set: (value: T) => void },
1325
+ set?: (value: T) => void
1326
+ ): (value: T) => void {
1327
+ if (typeof get !== 'function') {
1328
+ set = get.set
1329
+ get = get.get
1330
+ }
1331
+ const root = getRoot(received)
1332
+ effect(
1333
+ markWithRoot(() => {
1334
+ received(get())
1335
+ }, root)
1336
+ )
1337
+ return atomic((value: T) => {
1338
+ set!(value)
1339
+ if (batchQueue?.all.has(root)) {
1340
+ // Remove the effect from the batch queue so it doesn't execute
1341
+ // This prevents circular updates in bidirectional bindings
1342
+ batchQueue.all.delete(root)
1343
+ }
1344
+ })
1345
+ }