mutts 1.0.7 → 1.0.9

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 (191) hide show
  1. package/README.md +72 -17
  2. package/dist/browser.cjs +3111 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.ts +1391 -1
  5. package/dist/browser.esm.js +264 -35
  6. package/dist/browser.esm.js.map +1 -1
  7. package/dist/chunks/async-browser-lvzLOCgk.cjs +297 -0
  8. package/dist/chunks/async-browser-lvzLOCgk.cjs.map +1 -0
  9. package/dist/chunks/async-core-CRLKP3l-.cjs +29 -0
  10. package/dist/chunks/async-core-CRLKP3l-.cjs.map +1 -0
  11. package/dist/chunks/async-node-C3DeIb0y.cjs +96 -0
  12. package/dist/chunks/async-node-C3DeIb0y.cjs.map +1 -0
  13. package/dist/chunks/index-2vea86wD.esm.js +3011 -0
  14. package/dist/chunks/index-2vea86wD.esm.js.map +1 -0
  15. package/dist/chunks/index-CtA2AWl3.esm.js +4376 -0
  16. package/dist/chunks/index-CtA2AWl3.esm.js.map +1 -0
  17. package/dist/chunks/index-VTO-b2vR.cjs +4461 -0
  18. package/dist/chunks/index-VTO-b2vR.cjs.map +1 -0
  19. package/dist/debug/debug.d.ts +122 -0
  20. package/dist/debug/debug.d.ts.map +1 -0
  21. package/dist/debug/index.d.ts +4 -0
  22. package/dist/debug/index.d.ts.map +1 -0
  23. package/dist/debug/lineage-panel.d.ts +5 -0
  24. package/dist/debug/lineage-panel.d.ts.map +1 -0
  25. package/dist/debug/lineage.d.ts +79 -0
  26. package/dist/debug/lineage.d.ts.map +1 -0
  27. package/dist/debug.cjs +35 -0
  28. package/dist/debug.cjs.map +1 -0
  29. package/dist/debug.d.ts +209 -0
  30. package/dist/debug.esm.js +2 -0
  31. package/dist/debug.esm.js.map +1 -0
  32. package/dist/devtools/devtool/devtools.d.ts +1 -0
  33. package/dist/devtools/devtool/devtools.d.ts.map +1 -0
  34. package/dist/devtools/devtool/panel.d.ts +2 -0
  35. package/dist/devtools/devtool/panel.d.ts.map +1 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/mutts.umd.js +1 -1
  38. package/dist/mutts.umd.js.map +1 -1
  39. package/dist/mutts.umd.min.js +1 -1
  40. package/dist/mutts.umd.min.js.map +1 -1
  41. package/dist/node.cjs +107 -0
  42. package/dist/node.cjs.map +1 -0
  43. package/dist/node.d.ts +2 -2
  44. package/dist/node.esm.js +86 -33
  45. package/dist/node.esm.js.map +1 -1
  46. package/dist/src/async/browser.d.ts +2 -0
  47. package/dist/src/async/browser.d.ts.map +1 -0
  48. package/dist/src/async/index.d.ts +19 -0
  49. package/dist/src/async/index.d.ts.map +1 -0
  50. package/dist/src/async/node.d.ts +2 -0
  51. package/dist/src/async/node.d.ts.map +1 -0
  52. package/dist/src/decorator.d.ts +106 -0
  53. package/dist/src/decorator.d.ts.map +1 -0
  54. package/dist/src/destroyable.d.ts +87 -0
  55. package/dist/src/destroyable.d.ts.map +1 -0
  56. package/dist/src/entry-browser.d.ts +3 -0
  57. package/dist/src/entry-browser.d.ts.map +1 -0
  58. package/dist/src/entry-node.d.ts +3 -0
  59. package/dist/src/entry-node.d.ts.map +1 -0
  60. package/dist/src/eventful.d.ts +20 -0
  61. package/dist/src/eventful.d.ts.map +1 -0
  62. package/dist/src/flavored.d.ts +33 -0
  63. package/dist/src/flavored.d.ts.map +1 -0
  64. package/dist/src/index.d.ts +14 -0
  65. package/dist/src/index.d.ts.map +1 -0
  66. package/dist/src/indexable.d.ts +243 -0
  67. package/dist/src/indexable.d.ts.map +1 -0
  68. package/dist/src/introspection.d.ts +27 -0
  69. package/dist/src/introspection.d.ts.map +1 -0
  70. package/dist/src/iterableWeak.d.ts +53 -0
  71. package/dist/src/iterableWeak.d.ts.map +1 -0
  72. package/dist/src/mixins.d.ts +25 -0
  73. package/dist/src/mixins.d.ts.map +1 -0
  74. package/dist/src/promiseChain.d.ts +20 -0
  75. package/dist/src/promiseChain.d.ts.map +1 -0
  76. package/dist/src/reactive/array.d.ts +48 -0
  77. package/dist/src/reactive/array.d.ts.map +1 -0
  78. package/dist/src/reactive/buffer.d.ts +120 -0
  79. package/dist/src/reactive/buffer.d.ts.map +1 -0
  80. package/dist/src/reactive/change.d.ts +29 -0
  81. package/dist/src/reactive/change.d.ts.map +1 -0
  82. package/dist/src/reactive/deep-touch.d.ts +28 -0
  83. package/dist/src/reactive/deep-touch.d.ts.map +1 -0
  84. package/dist/src/reactive/deep-watch-state.d.ts +25 -0
  85. package/dist/src/reactive/deep-watch-state.d.ts.map +1 -0
  86. package/dist/src/reactive/deep-watch.d.ts +20 -0
  87. package/dist/src/reactive/deep-watch.d.ts.map +1 -0
  88. package/dist/src/reactive/describe.d.ts +12 -0
  89. package/dist/src/reactive/describe.d.ts.map +1 -0
  90. package/dist/src/reactive/effect-context.d.ts +34 -0
  91. package/dist/src/reactive/effect-context.d.ts.map +1 -0
  92. package/dist/src/reactive/effects.d.ts +164 -0
  93. package/dist/src/reactive/effects.d.ts.map +1 -0
  94. package/dist/src/reactive/index.d.ts +19 -0
  95. package/dist/src/reactive/index.d.ts.map +1 -0
  96. package/dist/src/reactive/map.d.ts +28 -0
  97. package/dist/src/reactive/map.d.ts.map +1 -0
  98. package/dist/src/reactive/memoize.d.ts +28 -0
  99. package/dist/src/reactive/memoize.d.ts.map +1 -0
  100. package/dist/src/reactive/non-reactive-state.d.ts +9 -0
  101. package/dist/src/reactive/non-reactive-state.d.ts.map +1 -0
  102. package/dist/src/reactive/non-reactive.d.ts +11 -0
  103. package/dist/src/reactive/non-reactive.d.ts.map +1 -0
  104. package/dist/src/reactive/project.d.ts +40 -0
  105. package/dist/src/reactive/project.d.ts.map +1 -0
  106. package/dist/src/reactive/proxy-state.d.ts +8 -0
  107. package/dist/src/reactive/proxy-state.d.ts.map +1 -0
  108. package/dist/src/reactive/proxy.d.ts +23 -0
  109. package/dist/src/reactive/proxy.d.ts.map +1 -0
  110. package/dist/src/reactive/record.d.ts +115 -0
  111. package/dist/src/reactive/record.d.ts.map +1 -0
  112. package/dist/src/reactive/register.d.ts +125 -0
  113. package/dist/src/reactive/register.d.ts.map +1 -0
  114. package/dist/src/reactive/registry.d.ts +21 -0
  115. package/dist/src/reactive/registry.d.ts.map +1 -0
  116. package/dist/src/reactive/set.d.ts +26 -0
  117. package/dist/src/reactive/set.d.ts.map +1 -0
  118. package/dist/src/reactive/tracking.d.ts +7 -0
  119. package/dist/src/reactive/tracking.d.ts.map +1 -0
  120. package/dist/src/reactive/types.d.ts +424 -0
  121. package/dist/src/reactive/types.d.ts.map +1 -0
  122. package/dist/src/reactive/watch.d.ts +48 -0
  123. package/dist/src/reactive/watch.d.ts.map +1 -0
  124. package/dist/src/std-decorators.d.ts +45 -0
  125. package/dist/src/std-decorators.d.ts.map +1 -0
  126. package/dist/src/utils.d.ts +49 -0
  127. package/dist/src/utils.d.ts.map +1 -0
  128. package/dist/src/zone.d.ts +40 -0
  129. package/dist/src/zone.d.ts.map +1 -0
  130. package/dist/types-DaHFfhlN.d.ts +414 -0
  131. package/docs/ai/api-reference.md +1 -1
  132. package/docs/ai/manual.md +443 -17
  133. package/docs/events.md +57 -2
  134. package/docs/flavored.md +210 -0
  135. package/docs/reactive/advanced.md +2 -3
  136. package/docs/reactive/attend.md +143 -0
  137. package/docs/reactive/collections.md +55 -0
  138. package/docs/reactive/core.md +120 -4
  139. package/docs/reactive/debugging.md +8 -10
  140. package/docs/reactive/describe.md +85 -0
  141. package/docs/reactive/error-handling.md +121 -0
  142. package/docs/reactive/project.md +1 -1
  143. package/docs/reactive/scan.md +215 -0
  144. package/docs/reactive.md +2 -0
  145. package/docs/std-decorators.md +171 -52
  146. package/docs/zone.md +9 -6
  147. package/package.json +44 -27
  148. package/src/async/browser.ts +277 -41
  149. package/src/async/index.ts +23 -4
  150. package/src/async/node.ts +78 -32
  151. package/src/entry-browser.ts +5 -0
  152. package/src/entry-node.ts +5 -0
  153. package/src/eventful.ts +82 -49
  154. package/src/flavored.ts +106 -0
  155. package/src/index.d.ts +12 -9
  156. package/src/index.ts +2 -1
  157. package/src/introspection.ts +1 -1
  158. package/src/reactive/array.ts +160 -80
  159. package/src/reactive/buffer.ts +187 -27
  160. package/src/reactive/change.ts +16 -23
  161. package/src/reactive/deep-touch.ts +19 -14
  162. package/src/reactive/deep-watch-state.ts +3 -3
  163. package/src/reactive/deep-watch.ts +7 -4
  164. package/src/reactive/describe.ts +39 -0
  165. package/src/reactive/effect-context.ts +71 -6
  166. package/src/reactive/effects.ts +445 -393
  167. package/src/reactive/index.ts +12 -14
  168. package/src/reactive/map.ts +8 -24
  169. package/src/reactive/memoize.ts +26 -3
  170. package/src/reactive/project.md +107 -0
  171. package/src/reactive/project.ts +48 -71
  172. package/src/reactive/proxy.ts +21 -3
  173. package/src/reactive/record.ts +30 -45
  174. package/src/reactive/register.ts +138 -19
  175. package/src/reactive/registry.ts +22 -9
  176. package/src/reactive/set.ts +7 -20
  177. package/src/reactive/tracking.ts +9 -12
  178. package/src/reactive/types.ts +104 -24
  179. package/src/reactive/{interface.ts → watch.ts} +29 -59
  180. package/src/std-decorators.ts +56 -17
  181. package/src/utils.ts +3 -4
  182. package/src/zone.ts +43 -28
  183. package/dist/browser.js +0 -161
  184. package/dist/browser.js.map +0 -1
  185. package/dist/chunks/index-BFYK02LG.js +0 -6378
  186. package/dist/chunks/index-BFYK02LG.js.map +0 -1
  187. package/dist/chunks/index-CNR6QRUl.esm.js +0 -6291
  188. package/dist/chunks/index-CNR6QRUl.esm.js.map +0 -1
  189. package/dist/node.js +0 -136
  190. package/dist/node.js.map +0 -1
  191. package/src/reactive/debug.ts +0 -517
@@ -1,24 +1,29 @@
1
+ import { named } from '../utils'
2
+ import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from '../../debug/debug'
3
+ import { effectMarker, getStackFrame, type StackFrame } from '../../debug/lineage'
1
4
  import { decorator } from '../decorator'
5
+ import { flavored, flavorOptions } from '../flavored'
2
6
  import { IterableWeakSet } from '../iterableWeak'
3
- import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from './debug'
4
- import {
5
- effectAggregator,
6
- effectHistory,
7
- getActiveEffect,
8
- } from './effect-context'
7
+ import { effectAggregator, effectHistory, getActiveEffect } from './effect-context'
8
+ import { unwrap } from './proxy-state'
9
9
  import {
10
- effectChildren,
11
- effectParent,
12
10
  effectToReactiveObjects,
11
+ getEffectNode,
13
12
  getRoot,
14
13
  markWithRoot,
14
+ resetRegistry,
15
15
  watchers,
16
16
  } from './registry'
17
17
  import {
18
+ type CatchFunction,
18
19
  cleanup as cleanupSymbol,
19
- type DependencyAccess,
20
+ type EffectAccess,
21
+ type EffectCleanup,
22
+ type EffectCloser,
20
23
  type EffectOptions,
24
+ type EffectTrigger,
21
25
  type Evolution,
26
+ optionCall,
22
27
  options,
23
28
  ReactiveError,
24
29
  ReactiveErrorCode,
@@ -27,8 +32,6 @@ import {
27
32
  stopped,
28
33
  } from './types'
29
34
 
30
- import { unwrap } from './proxy-state'
31
-
32
35
  /**
33
36
  * Finds a cycle in a sequence of functions by looking for the first repetition
34
37
  */
@@ -55,10 +58,10 @@ function formatRoots(roots: Function[], limit = 20): string {
55
58
  return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`
56
59
  }
57
60
 
58
- type EffectTracking = (obj: any, evolution: Evolution, prop: any) => void
61
+ type EffectTracking = (obj: any, evolution: Evolution, prop: any, effect: EffectTrigger) => void
59
62
 
60
63
  export interface ActivationRecord {
61
- effect: ScopedCallback
64
+ effect: EffectTrigger
62
65
  obj: any
63
66
  evolution: Evolution
64
67
  prop: any
@@ -71,16 +74,17 @@ let activationRegistry: Map<Function, Map<any, Map<any, number>>> | undefined
71
74
 
72
75
  export const activationLog: Omit<ActivationRecord, 'batchId'>[] = new Array(100)
73
76
 
77
+ /**
78
+ * Returns the activation log containing recent effect activations for debugging.
79
+ * The log is a circular buffer of the last 100 activations.
80
+ *
81
+ * @returns Array of activation records
82
+ */
74
83
  export function getActivationLog() {
75
84
  return activationLog
76
85
  }
77
86
 
78
- export function recordActivation(
79
- effect: ScopedCallback,
80
- obj: any,
81
- evolution: Evolution,
82
- prop: any
83
- ) {
87
+ export function recordActivation(effect: EffectTrigger, obj: any, evolution: Evolution, prop: any) {
84
88
  const root = getRoot(effect)
85
89
 
86
90
  if (!activationRegistry) return
@@ -130,7 +134,7 @@ export function recordActivation(
130
134
  * - The specific property that changed
131
135
  *
132
136
  * **Note:** The tracker callback is automatically removed after being called once. If you need
133
- * to track multiple triggers, call `trackEffect` again within the effect.
137
+ * to track multiple triggers, call `onEffectTrigger` again within the effect.
134
138
  *
135
139
  * @param onTouch - Callback function that receives (obj, evolution, prop) when the effect is triggered
136
140
  * @throws {Error} If called outside of an effect context
@@ -141,7 +145,7 @@ export function recordActivation(
141
145
  *
142
146
  * effect(() => {
143
147
  * // Register a tracker to see what triggers this effect
144
- * trackEffect((obj, evolution, prop) => {
148
+ * onEffectTrigger((obj, evolution, prop) => {
145
149
  * console.log(`Effect triggered by:`, {
146
150
  * object: obj,
147
151
  * change: evolution.type,
@@ -154,33 +158,53 @@ export function recordActivation(
154
158
  * })
155
159
  *
156
160
  * state.count = 5
157
- * // Logs: Effect triggered by: { object: state, change: 'set', property: 'count' }
158
161
  * ```
159
162
  */
160
- export function trackEffect(onTouch: EffectTracking) {
161
- const activeEffect = getActiveEffect()
162
- if (!activeEffect) throw new Error('Not in an effect')
163
- if (!effectTrackers.has(activeEffect)) effectTrackers.set(activeEffect, new Set([onTouch]))
164
- else effectTrackers.get(activeEffect)!.add(onTouch)
163
+ export function onEffectTrigger(onTouch: EffectTracking, effect?: EffectTrigger) {
164
+ effect ??= getActiveEffect()
165
+ if (!effect) throw new Error('Tracking an effect trigger while not in an effect')
166
+ const node = getEffectNode(effect)
167
+ if (!node.trackers) node.trackers = [onTouch]
168
+ else node.trackers.push(onTouch)
165
169
  }
166
170
 
167
- const effectTrackers = new WeakMap<ScopedCallback, Set<EffectTracking>>()
168
-
169
- export const opaqueEffects = new WeakSet<ScopedCallback>()
171
+ export function raiseEffectTrackers(
172
+ effect: EffectTrigger,
173
+ obj: any,
174
+ evolution: Evolution,
175
+ prop: any
176
+ ) {
177
+ const node = getEffectNode(effect)
178
+ const trackers = node.trackers
179
+ if (trackers) {
180
+ for (const tracker of trackers) tracker(obj, evolution, prop, effect)
181
+ }
182
+ }
183
+ export function onEffectThrow(onThrow: CatchFunction, effect?: EffectTrigger) {
184
+ effect ??= getActiveEffect()
185
+ if (!effect) throw new Error('Tracking an effect throw while not in an effect')
186
+ const node = getEffectNode(effect)
187
+ if (!node.catchers) node.catchers = [onThrow]
188
+ else node.catchers.push(onThrow)
189
+ }
170
190
 
171
191
  // Dependency graph: tracks which effects trigger which other effects
172
192
  // Uses roots (Function) as keys for consistency
173
- const effectTriggers = new WeakMap<Function, IterableWeakSet<Function>>()
174
- const effectTriggeredBy = new WeakMap<Function, IterableWeakSet<Function>>()
193
+ let effectTriggers = new WeakMap<Function, IterableWeakSet<Function>>()
194
+ let effectTriggeredBy = new WeakMap<Function, IterableWeakSet<Function>>()
175
195
 
176
196
  // Transitive closures: track all indirect relationships
177
197
  // causesClosure: for each effect, all effects that trigger it (directly or indirectly)
178
198
  // consequencesClosure: for each effect, all effects that it triggers (directly or indirectly)
179
- const causesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
180
- const consequencesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
199
+ let causesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
200
+ let consequencesClosure = new WeakMap<Function, IterableWeakSet<Function>>()
201
+
202
+ // Batch re-entrance depth and broken state
203
+ let batchDepth = 0
204
+ let broken = false
181
205
 
182
206
  // Debug: Capture where an effect was created
183
- export const effectCreationStacks = new WeakMap<Function, string>()
207
+ export const effectCreationStacks = new WeakMap<Function, StackFrame[]>()
184
208
 
185
209
  /**
186
210
  * Gets or creates an IterableWeakSet for a closure map
@@ -204,14 +228,10 @@ function getOrCreateClosure(
204
228
  * @param targetRoot - Root function of the effect being triggered
205
229
  */
206
230
  function addGraphEdge(callerRoot: Function, targetRoot: Function) {
207
- if (options.cycleHandling === 'none') return
208
- // Skip if edge already exists
231
+ if (options.cycleHandling === 'production') return
232
+ // Add to forward graph: callerRoot → targetRoot
209
233
  const triggers = effectTriggers.get(callerRoot)
210
- if (triggers?.has(targetRoot)) {
211
- return // Edge already exists
212
- }
213
234
 
214
- // Add to forward graph: callerRoot → targetRoot
215
235
  if (!triggers) {
216
236
  const newTriggers = new IterableWeakSet<Function>()
217
237
  newTriggers.add(targetRoot)
@@ -274,7 +294,7 @@ function addGraphEdge(callerRoot: Function, targetRoot: Function) {
274
294
  }
275
295
 
276
296
  // 4. Cross-product: for each X in causesClosure(U) and Y in consequencesClosure(V): X→Y
277
- if (uCausesSet && vConsequencesSet) {
297
+ if (uCausesSet?.size && vConsequencesSet?.size) {
278
298
  for (const x of uCausesSet) {
279
299
  const xConsequences = getOrCreateClosure(consequencesClosure, x)
280
300
  for (const y of vConsequencesSet) {
@@ -295,7 +315,6 @@ function addGraphEdge(callerRoot: Function, targetRoot: Function) {
295
315
  * @param end - Target node
296
316
  * @param exclude - Node to exclude from the path
297
317
  * @returns true if a path exists without going through the excluded node
298
- * @todo Can be REALLY costly - optimise or make optional or ...
299
318
  */
300
319
  function hasPathExcluding(start: Function, end: Function, exclude: Function): boolean {
301
320
  if (start === end) return true
@@ -329,8 +348,8 @@ function hasPathExcluding(start: Function, end: Function, exclude: Function): bo
329
348
  * Called when an effect is stopped/cleaned up
330
349
  * @param effect - The effect being cleaned up
331
350
  */
332
- function cleanupEffectFromGraph(effect: ScopedCallback) {
333
- if (options.cycleHandling === 'none') return
351
+ function cleanupEffectFromGraph(effect: EffectTrigger) {
352
+ if (options.cycleHandling === 'production') return
334
353
  const root = getRoot(effect)
335
354
 
336
355
  // Get closures before removing direct edges (needed for propagation)
@@ -435,7 +454,7 @@ function cleanupEffectFromGraph(effect: ScopedCallback) {
435
454
  // Batch queue structure - optimized with cached in-degrees
436
455
  interface BatchQueue {
437
456
  // All effects in the current batch that still need to be executed (todos)
438
- all: Map<Function, ScopedCallback> // root → effect
457
+ all: Map<Function, EffectTrigger> // root → effect
439
458
  // Cached in-degrees for each effect in the batch (number of causes in batch)
440
459
  inDegrees: Map<Function, number> // root → in-degree count
441
460
  }
@@ -443,17 +462,17 @@ interface BatchQueue {
443
462
  // Track currently executing effects to prevent re-execution
444
463
  // These are all the effects triggered under `activeEffect`
445
464
  let batchQueue: BatchQueue | undefined
446
- export function hasBatched(effect: ScopedCallback) {
465
+ export function hasBatched(effect: EffectTrigger) {
447
466
  return batchQueue?.all.has(getRoot(effect))
448
467
  }
449
- const batchCleanups = new Set<ScopedCallback>()
468
+ const batchCleanups = new Set<EffectCleanup>()
450
469
 
451
470
  /**
452
471
  * Computes and caches in-degrees for all effects in the batch
453
472
  * Called once when batch starts or when new effects are added
454
473
  */
455
474
  function computeAllInDegrees(batch: BatchQueue): void {
456
- if (options.cycleHandling === 'none') return
475
+ if (options.cycleHandling === 'production') return
457
476
  const activeEffect = getActiveEffect()
458
477
  const activeRoot = activeEffect ? getRoot(activeEffect) : null
459
478
 
@@ -495,44 +514,6 @@ function decrementInDegreesForExecuted(batch: BatchQueue, executedRoot: Function
495
514
  }
496
515
  }
497
516
 
498
- /**
499
- * Computes the in-degree (number of dependencies) for an effect in the current batch
500
- * Uses causesClosure to count all effects (directly or indirectly) that trigger this effect
501
- * @param root - Root function of the effect
502
- * @param batchEffects - Map of all effects in current batch (todos - effects that still need execution)
503
- * @returns Number of effects in batch that trigger this effect (directly or indirectly)
504
- *
505
- * TODO: Optimization - For large graphs with small batches, iterating over all causes in the closure
506
- * can be expensive. Consider maintaining a separate "batch causes" set or caching in-degrees.
507
- */
508
- /* function computeInDegreeInBatch(
509
- root: Function,
510
- batchEffects: Map<Function, ScopedCallback>
511
- ): number {
512
- let inDegree = 0
513
- const activeEffect = getActiveEffect()
514
- const activeRoot = activeEffect ? getRoot(activeEffect) : null
515
-
516
- // Count effects in batch that trigger this effect (directly or indirectly)
517
- // Using causesClosure which contains all transitive causes
518
- // Note: batchEffects only contains effects that still need execution (todos),
519
- // so we don't need to check if causes have been executed - they're not in the map if executed
520
- const causes = causesClosure.get(root)
521
- if (causes) {
522
- for (const causeRoot of causes) {
523
- // Only count if it's in the batch (still needs execution)
524
- // BUT: don't count the currently executing effect (active effect)
525
- // This handles the case where an effect is triggered during another effect's execution
526
- // Note: Self-loops are ignored - they should not appear in closures, but we check to be safe
527
- if (batchEffects.has(causeRoot) && causeRoot !== activeRoot && causeRoot !== root) {
528
- inDegree++
529
- }
530
- }
531
- }
532
-
533
- return inDegree
534
- }
535
-
536
517
  /**
537
518
  * Finds a path from startRoot to endRoot in the dependency graph
538
519
  * Uses DFS to find the path through direct edges
@@ -625,84 +606,69 @@ function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
625
606
  * @param caller - The active effect that triggered this one (optional)
626
607
  * @param immediate - If true, don't create edges in the dependency graph
627
608
  */
628
- function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?: boolean) {
629
- (effect as any)[cleanupSymbol]?.()
609
+ function addToBatch(effect: EffectTrigger, caller?: EffectTrigger, immediate?: boolean) {
610
+ const node = getEffectNode(effect)
611
+ node.cleanup?.()
630
612
  // If the effect was stopped during cleanup (e.g. lazy memoization), don't add it to the batch
631
- if ((effect as any)[stopped]) return
613
+ if (node.stopped) {
614
+ // console.log(`[DEBUG] addToBatch: ${effect.name} is stopped`)
615
+ return
616
+ }
632
617
 
633
- if (!batchQueue) return
618
+ if (!batchQueue) {
619
+ // console.log(`[DEBUG] addToBatch: no batchQueue`)
620
+ return
621
+ }
634
622
 
635
623
  const root = getRoot(effect)
636
624
 
637
625
  // 1. Add to batch first (needed for cycle detection)
638
- if (options.cycleHandling === 'none' && batchQueue.all.has(root)) {
639
- // If already present in flat mode, remove it so that the next set puts it at the end
640
- batchQueue.all.delete(root)
626
+ if (options.cycleHandling === 'production') {
627
+ // Production mode: FIFO (delete and re-add to move to end)
628
+ if (batchQueue.all.has(root)) {
629
+ batchQueue.all.delete(root)
630
+ }
631
+ batchQueue.all.set(root, effect)
632
+ } else {
633
+ // Dev/Debug mode: Set if not present (preserve order?)
634
+ // actually, we might want to update it?
635
+ // For now, simple set
636
+ if (batchQueue.all.has(root)) {
637
+ // console.log(`[DEBUG] ${root.name} already in batch`)
638
+ return
639
+ }
640
+ batchQueue.all.set(root, effect)
641
+ // console.log(`[DEBUG] ${root.name} added to batch`)
641
642
  }
642
643
 
643
- batchQueue.all.set(root, effect)
644
-
645
- // 2. Add to global graph (if caller exists and not immediate) - USE ROOTS ONLY
646
- // When immediate is true, don't create edges - the effect is not considered as a consequence
647
- if (caller && !immediate && options.cycleHandling !== 'none') {
644
+ // batchQueue.all.set(root, effect)
645
+ // console.log(`[DEBUG] addToBatch: ${root.name} added to batch`)
646
+ if (caller && !immediate && options.cycleHandling !== 'production') {
648
647
  const callerRoot = getRoot(caller)
648
+ // const root = getRoot(effect) // Already have root
649
649
 
650
650
  // Check for cycle BEFORE adding edge
651
- // We check if adding callerRoot -> root would create a cycle
652
- // This means checking if root already triggers callerRoot (directly or transitively)
653
651
  if (wouldCreateCycle(callerRoot, root)) {
654
- // Cycle detected! Get the full cycle path for debugging
655
652
  const cyclePath = getCyclePathForEdge(callerRoot, root)
656
653
  const cycleMessage =
657
654
  cyclePath.length > 0
658
655
  ? `Cycle detected: ${cyclePath.map((r) => r.name || r.toString()).join(' → ')}`
659
656
  : `Cycle detected: ${callerRoot.name || callerRoot.toString()} → ${root.name || root.toString()} (and back)`
660
657
 
661
- const cycleHandling = options.cycleHandling
662
-
663
- // In strict mode, we throw immediately on detection
664
- if (cycleHandling === 'strict') {
665
- batchQueue.all.delete(root)
666
- const causalChain = getTriggerChain(effect)
667
- const creationStack = effectCreationStacks.get(root)
668
-
669
- throw new ReactiveError(`[reactive] Strict Cycle Prevention: ${cycleMessage}`, {
670
- code: ReactiveErrorCode.CycleDetected,
671
- cycle: cyclePath.map((r) => r.name || r.toString()),
672
- details: cycleMessage,
673
- causalChain,
674
- creationStack,
675
- })
676
- }
658
+ batchQueue.all.delete(root)
659
+ const causalChain = getTriggerChain(effect)
660
+ const creationStack = getEffectNode(effect).creationStack
677
661
 
678
- switch (cycleHandling) {
679
- case 'throw': {
680
- // Remove from batch before throwing
681
- batchQueue.all.delete(root)
682
- const causalChain = getTriggerChain(effect)
683
- const creationStack = effectCreationStacks.get(root)
684
-
685
- throw new ReactiveError(`[reactive] ${cycleMessage}`, {
686
- code: ReactiveErrorCode.CycleDetected,
687
- cycle: cyclePath.map((r) => r.name || r.toString()),
688
- details: cycleMessage,
689
- causalChain,
690
- creationStack,
691
- })
692
- }
693
- case 'warn':
694
- options.warn(`[reactive] ${cycleMessage}`)
695
- // Don't add the edge, break the cycle
696
- batchQueue.all.delete(root)
697
- return
698
- case 'break':
699
- // Silently break cycle, don't add the edge
700
- batchQueue.all.delete(root)
701
- return
702
- }
662
+ throw new ReactiveError(`[reactive] ${cycleMessage}`, {
663
+ code: ReactiveErrorCode.CycleDetected,
664
+ cycle: cyclePath.map((r) => r.name || r.toString()),
665
+ details: cycleMessage,
666
+ causalChain,
667
+ creationStack,
668
+ })
703
669
  }
704
670
 
705
- addGraphEdge(callerRoot, root) // Add to persistent graph using roots
671
+ addGraphEdge(callerRoot, root)
706
672
  }
707
673
  }
708
674
 
@@ -710,7 +676,7 @@ function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?:
710
676
  * Adds a cleanup function to be called when the current batch of effects completes
711
677
  * @param cleanup - The cleanup function to add
712
678
  */
713
- export function addBatchCleanup(cleanup: ScopedCallback) {
679
+ export function addBatchCleanup(cleanup: EffectCleanup) {
714
680
  if (!batchQueue) cleanup()
715
681
  else batchCleanups.add(cleanup)
716
682
  }
@@ -808,10 +774,10 @@ function findCycle(
808
774
  */
809
775
  function executeNext(effectuatedRoots: Function[]): any {
810
776
  // Find an effect with in-degree 0 using cached values
811
- let nextEffect: ScopedCallback | null = null
777
+ let nextEffect: EffectTrigger | null = null
812
778
  let nextRoot: Function | null = null
813
779
 
814
- if (options.cycleHandling === 'none') {
780
+ if (options.cycleHandling === 'production') {
815
781
  // In flat mode, we just take the first effect in the queue (FIFO)
816
782
  const first = batchQueue!.all.entries().next().value
817
783
  if (first) {
@@ -822,6 +788,7 @@ function executeNext(effectuatedRoots: Function[]): any {
822
788
  // Using cached in-degrees for O(n) lookup instead of O(n²)
823
789
  for (const [root, effect] of batchQueue!.all) {
824
790
  const inDegree = batchQueue!.inDegrees.get(root) ?? 0
791
+ // console.log(`[DEBUG] executeNext: checking ${root.name}, inDegree: ${inDegree}`)
825
792
  if (inDegree === 0) {
826
793
  nextEffect = effect
827
794
  nextRoot = root
@@ -865,34 +832,11 @@ function executeNext(effectuatedRoots: Function[]): any {
865
832
  ? `Cycle detected: ${cycle.map((r) => r.name || '<anonymous>').join(' → ')}`
866
833
  : 'Cycle detected in effect batch - all effects have dependencies that prevent execution'
867
834
 
868
- const cycleHandling = options.cycleHandling
869
- switch (cycleHandling) {
870
- case 'throw':
871
- throw new ReactiveError(`[reactive] ${cycleMessage}`)
872
- case 'warn': {
873
- options.warn(`[reactive] ${cycleMessage}`)
874
- // Break the cycle by executing one effect anyway
875
- const firstEffect = batchQueue!.all.values().next().value
876
- if (firstEffect) {
877
- const firstRoot = getRoot(firstEffect)
878
- batchQueue!.all.delete(firstRoot)
879
- batchQueue!.inDegrees.delete(firstRoot)
880
- return firstEffect()
881
- }
882
- break
883
- }
884
- case 'break': {
885
- // Silently break cycle
886
- const firstEffect2 = batchQueue!.all.values().next().value
887
- if (firstEffect2) {
888
- const firstRoot2 = getRoot(firstEffect2)
889
- batchQueue!.all.delete(firstRoot2)
890
- batchQueue!.inDegrees.delete(firstRoot2)
891
- return firstEffect2()
892
- }
893
- break
894
- }
895
- }
835
+ throw new ReactiveError(`[reactive] ${cycleMessage}`, {
836
+ code: ReactiveErrorCode.CycleDetected,
837
+ cycle: cycle.map((r) => r.name || r.toString()),
838
+ details: cycleMessage,
839
+ })
896
840
  }
897
841
  return null // Batch complete
898
842
  }
@@ -911,16 +855,23 @@ function executeNext(effectuatedRoots: Function[]): any {
911
855
 
912
856
  // Track which sub-effects have been executed to prevent infinite loops
913
857
  // These are all the effects triggered under `activeEffect` and all their sub-effects
914
- export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'immediate') {
858
+ export function batch(effect: EffectTrigger | EffectTrigger[], immediate?: 'immediate') {
859
+ if (broken) {
860
+ throw new ReactiveError(
861
+ '[reactive] Reactive system is broken after an unrecoverable error. Call reset() to recover.',
862
+ { code: ReactiveErrorCode.BrokenEffects }
863
+ )
864
+ }
915
865
  if (!Array.isArray(effect)) effect = [effect]
916
866
  const roots = effect.map(getRoot)
917
867
 
868
+ batchDepth++
869
+ try {
918
870
  if (batchQueue) {
919
871
  // Nested batch - add to existing
920
872
  options?.chain(roots, getRoot(getActiveEffect()))
921
873
  const caller = getActiveEffect()
922
- for (let i = 0; i < effect.length; i++)
923
- addToBatch(effect[i], caller, immediate === 'immediate')
874
+ for (let i = 0; i < effect.length; i++) addToBatch(effect[i], caller, immediate === 'immediate')
924
875
  if (immediate) {
925
876
  const firstReturn: { value?: any } = {}
926
877
  // Execute immediately (before batch returns)
@@ -940,15 +891,16 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
940
891
  // New batch - initialize
941
892
  if (!activationRegistry) activationRegistry = new Map()
942
893
  else throw new Error('Batch already in progress')
943
- options.beginChain(roots)
894
+ optionCall('beginChain', roots)
944
895
  batchQueue = {
945
896
  all: new Map(),
946
897
  inDegrees: new Map(),
947
898
  }
948
899
 
949
900
  const caller = getActiveEffect()
950
- const effectuatedRoots: ScopedCallback[] = []
901
+ const effectuatedRoots: Function[] = []
951
902
  const firstReturn: { value?: any } = {}
903
+ let success = false
952
904
 
953
905
  try {
954
906
  if (immediate) {
@@ -963,11 +915,14 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
963
915
  }
964
916
  } else {
965
917
  // Add initial effects to batch and compute dependencies
966
- for (let i = 0; i < effect.length; i++) addToBatch(effect[i], caller, false)
918
+ const len = effect.length
919
+ for (let i = 0; i < len; i++) {
920
+ addToBatch(effect[i], caller, false)
921
+ }
967
922
  computeAllInDegrees(batchQueue)
968
923
  }
969
924
 
970
- // Processing loop for all triggered effects and cleanups
925
+ // Process the batch
971
926
  while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
972
927
  if (batchQueue.all.size > 0) {
973
928
  if (effectuatedRoots.length > options.maxEffectChain) {
@@ -1021,22 +976,53 @@ export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'im
1021
976
  // If we want to keep that behavior: if (immediate) break
1022
977
  }
1023
978
  }
1024
- return firstReturn.value
1025
- } catch (error) {
1026
- throw error instanceof ReactiveError
1027
- ? error
1028
- : new ReactiveError('Effects are broken', { code: ReactiveErrorCode.BrokenEffects, cause: error })
979
+ success = true
1029
980
  } finally {
981
+ if (!success) panicThrow()
1030
982
  activationRegistry = undefined
1031
983
  batchQueue = undefined
1032
- options.endChain()
984
+ batchCleanups.clear()
985
+ optionCall('endChain')
1033
986
  }
987
+ return firstReturn.value
1034
988
  }
989
+ } finally {
990
+ batchDepth--
991
+ }
992
+ }
993
+
994
+ function panicThrow() {
995
+ broken = true
996
+ }
997
+
998
+ /**
999
+ * Resets the reactive system to a consistent state.
1000
+ * Call this after an unrecoverable error has set the system to "broken".
1001
+ * This clears all batch state, effect dependency graphs, and watcher registrations.
1002
+ * All existing effects become orphaned and must be recreated.
1003
+ */
1004
+ export function reset() {
1005
+ broken = false
1006
+ batchDepth = 0
1007
+ activationRegistry = undefined
1008
+ batchQueue = undefined
1009
+ batchCleanups.clear()
1010
+ effectTriggers = new WeakMap()
1011
+ effectTriggeredBy = new WeakMap()
1012
+ causesClosure = new WeakMap()
1013
+ consequencesClosure = new WeakMap()
1014
+ resetRegistry()
1015
+ effectHistory.present.active = undefined
1035
1016
  }
1036
1017
 
1018
+ export { reset as resetBatchQueueForTest }
1019
+
1037
1020
  // Inject batch function to allow atomic game loops in requestAnimationFrame/setTimeout/...
1038
- // TODO: perhaps introduce somewhere a way to wrap async functions - find out if it's necessary
1039
- // wrapAsync(fn=> batch(fn, 'immediate'))
1021
+ // Note: Automatic batching of async callbacks (setTimeout, Promise.then, etc.) is NOT implemented.
1022
+ // Rationale: (1) asyncHooks.addHook API doesn't support knowing when callbacks complete (needed for batching),
1023
+ // (2) hooking all callback-creating functions adds overhead without guaranteed benefit,
1024
+ // (3) incomplete coverage in Node (async_hooks misses user-land patterns).
1025
+ // Solution: Use explicit @atomic decorator or manual batch() calls where optimization is needed.
1040
1026
 
1041
1027
  /**
1042
1028
  * Decorator that makes methods atomic - batches all effects triggered within the method
@@ -1047,7 +1033,7 @@ export const atomic = decorator({
1047
1033
  const atomicEffect = () => original.apply(this, args)
1048
1034
  // Debug: helpful to have a name
1049
1035
  Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1050
- return batch(atomicEffect, 'immediate')
1036
+ return batch(atomicEffect as EffectTrigger, 'immediate')
1051
1037
  }
1052
1038
  },
1053
1039
  default<Args extends any[], Return>(
@@ -1057,7 +1043,7 @@ export const atomic = decorator({
1057
1043
  const atomicEffect = () => original.apply(this, args)
1058
1044
  // Debug: helpful to have a name
1059
1045
  Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1060
- return batch(atomicEffect, 'immediate')
1046
+ return batch(atomicEffect as EffectTrigger, 'immediate')
1061
1047
  }
1062
1048
  },
1063
1049
  })
@@ -1074,226 +1060,294 @@ const fr = new FinalizationRegistry<() => void>((f) => f())
1074
1060
  * @param options - Options for effect execution
1075
1061
  * @returns A cleanup function to stop the effect
1076
1062
  */
1077
- export function effect(
1078
- //biome-ignore lint/suspicious/noConfusingVoidType: We have to
1079
- fn: (access: DependencyAccess) => ScopedCallback | undefined | void | Promise<any>,
1080
- effectOptions?: EffectOptions
1081
- ): ScopedCallback & {
1082
- [stopped]: boolean
1083
- [cleanupSymbol]: () => void
1084
- } {
1085
-
1086
- // Use per-effect asyncMode or fall back to global option
1087
- const asyncMode = effectOptions?.asyncMode ?? options.asyncMode ?? 'cancel'
1088
- if (options.introspection.enableHistory) {
1089
- const stack = new Error().stack
1090
- if (stack) {
1091
- // Clean up the stack trace to remove internal frames
1092
- const cleanStack = stack.split('\n').slice(2).join('\n')
1093
- effectCreationStacks.set(getRoot(fn), cleanStack)
1094
- }
1095
- }
1096
- let cleanup: (() => void) | null = null
1097
- const tracked = effectHistory.present.with(runEffect, ()=> effectAggregator.zoned)
1098
- const ascend = effectHistory.zoned
1099
- //const parent = effectHistory.present.active // TODO: Double-check parenting (untracked -> stop children) - use case or untracked(effect)
1100
- let parent = effectHistory.present.active
1101
- /*if (!parent) {
1102
- for (const h of effectHistory.active.history) parent = h
1103
- }*/
1104
- let effectStopped = false
1105
- let hasReacted = false
1106
- let runningPromise: Promise<any> | null = null
1107
- let cancelPrevious: (() => void) | null = null
1108
-
1109
- function runEffect() {
1110
- // Clear previous dependencies
1111
- if (cleanup) {
1112
- const prevCleanup = cleanup
1113
- cleanup = null
1114
- untracked(() => prevCleanup())
1115
- }
1063
+ export const effect = named(effectMarker.leave, flavored(
1064
+ function effect(
1065
+ fn: (access: EffectAccess) => EffectCloser | undefined | void | Promise<any>,
1066
+ effectOptions?: EffectOptions
1067
+ ): EffectCleanup {
1068
+ if (effectOptions?.name) Object.defineProperty(fn, 'name', { value: effectOptions.name })
1069
+ // Use per-effect asyncMode or fall back to global option
1070
+ const asyncMode = effectOptions?.asyncMode ?? options.asyncMode ?? 'cancel'
1071
+
1072
+ // Create the effect function - naming it for debug
1073
+ const runEffect: EffectTrigger = () => {
1074
+ const node = getEffectNode(runEffect)
1075
+ // Clear previous dependencies
1076
+ if (node.cleanup) {
1077
+ const prevCleanup = node.cleanup
1078
+ node.cleanup = undefined
1079
+ try {
1080
+ untracked(() => prevCleanup())
1081
+ } catch (error) {
1082
+ // If we want to report them, we could use options.warn or similar
1083
+ options.warn('Error during effect cleanup', error)
1084
+ }
1085
+ }
1116
1086
 
1117
- // Handle async modes when effect is retriggered
1118
- if (runningPromise) {
1119
- if (asyncMode === 'cancel' && cancelPrevious) {
1120
- // Cancel previous execution
1121
- cancelPrevious()
1122
- cancelPrevious = null
1123
- runningPromise = null
1124
- } else if (asyncMode === 'ignore') {
1125
- // Ignore new execution while async work is running
1126
- return
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
1127
1099
  }
1128
- // Note: 'queue' mode not yet implemented
1129
- }
1130
1100
 
1131
- // The effect has been stopped after having been planned
1132
- if (effectStopped) return
1101
+ // The effect has been stopped after having been planned
1102
+ if (effectStopped) return
1103
+
1104
+ optionCall('enter', getRoot(fn))
1105
+ let reactionCleanup: EffectCloser | undefined
1106
+ let result: any
1107
+ let caught = 0
1108
+
1109
+ // Default thrower (self)
1110
+ let thrower: CatchFunction = (error: any) => {
1111
+ throw error
1112
+ }
1113
+ node.forwardThrow = thrower
1114
+
1115
+ let errorToThrow: Error | undefined
1116
+ try {
1117
+ result = tracked(named(effectMarker.enter, () => fn.call(null, access)))
1118
+ optionCall('leave', fn)
1119
+ if (
1120
+ result &&
1121
+ typeof result !== 'function' &&
1122
+ (typeof result !== 'object' || !('then' in result))
1123
+ )
1124
+ throw new ReactiveError(`[reactive] Effect returned a non-function value: ${result}`)
1125
+ // Check if result is a Promise (async effect)
1126
+ if (result && typeof result === 'object' && typeof result.then === 'function') {
1127
+ const originalPromise = result as Promise<any>
1128
+
1129
+ // Create a cancellation promise that we can reject
1130
+ let cancelReject: ((reason: any) => void) | null = null
1131
+ const cancelPromise = new Promise<never>((_, reject) => {
1132
+ cancelReject = reject
1133
+ })
1133
1134
 
1134
- options.enter(getRoot(fn))
1135
- let reactionCleanup: ScopedCallback | undefined
1136
- let result: any
1137
- try {
1138
- result = tracked(() => fn({ tracked, ascend, reaction: hasReacted }))
1139
- if (
1140
- result &&
1141
- typeof result !== 'function' &&
1142
- (typeof result !== 'object' || !('then' in result))
1143
- )
1144
- throw new ReactiveError(`[reactive] Effect returned a non-function value: ${result}`)
1145
- // Check if result is a Promise (async effect)
1146
- if (result && typeof result === 'object' && typeof result.then === 'function') {
1147
- const originalPromise = result as Promise<any>
1148
-
1149
- // Create a cancellation promise that we can reject
1150
- let cancelReject: ((reason: any) => void) | null = null
1151
- const cancelPromise = new Promise<never>((_, reject) => {
1152
- cancelReject = reject
1153
- })
1154
-
1155
- const cancelError = new ReactiveError('[reactive] Effect canceled due to dependency change')
1156
-
1157
- // Race between the actual promise and cancellation
1158
- // If canceled, the race rejects, which will propagate through any promise chain
1159
- runningPromise = Promise.race([originalPromise, cancelPromise])
1160
-
1161
- // Store the cancellation function
1162
- cancelPrevious = () => {
1163
- if (cancelReject) {
1164
- cancelReject(cancelError)
1135
+ const cancelError = new ReactiveError(
1136
+ '[reactive] Effect canceled due to dependency change'
1137
+ )
1138
+
1139
+ // Race between the actual promise and cancellation
1140
+ // If canceled, the race rejects, which will propagate through any promise chain
1141
+ runningPromise = Promise.race([originalPromise, cancelPromise])
1142
+
1143
+ // Store the cancellation function
1144
+ cancelPrevious = () => {
1145
+ if (cancelReject) {
1146
+ cancelReject(cancelError)
1147
+ }
1165
1148
  }
1166
- }
1167
1149
 
1168
- // Wrap the original promise chain so cancellation propagates
1169
- // This ensures that when we cancel, the original promise's .catch() handlers are triggered
1170
- // We do this by rejecting the race promise, which makes the original promise chain see the rejection
1171
- // through the zone-wrapped .then()/.catch() handlers
1172
- } else {
1173
- // Synchronous result - treat as cleanup function
1174
- reactionCleanup = result as undefined | ScopedCallback
1150
+ // Wrap the original promise chain so cancellation propagates
1151
+ // This ensures that when we cancel, the original promise's .catch() handlers are triggered
1152
+ // We do this by rejecting the race promise, which makes the original promise chain see the rejection
1153
+ // through the zone-wrapped .then()/.catch() handlers
1154
+ runningPromise = runningPromise.catch((error) => {
1155
+ // Propagate async errors to the effect's error handler
1156
+ // This ensures onEffectThrow handlers are triggered for async errors
1157
+ if (error !== cancelError) {
1158
+ thrower(error)
1159
+ }
1160
+ // If thrower didn't throw (handled), we absorb the error.
1161
+ // If thrower threw (unhandled), it propagates as a new unhandled rejection, which is correct.
1162
+ })
1163
+ } else {
1164
+ // Synchronous result - treat as cleanup function
1165
+ reactionCleanup = result as undefined | EffectCloser
1166
+ }
1167
+ } catch (error) {
1168
+ // catcher:self`
1169
+ errorToThrow = error
1170
+ } finally {
1171
+ access.reaction = true
1175
1172
  }
1176
- } finally {
1177
- hasReacted = true
1178
- options.leave(fn)
1179
- }
1180
1173
 
1181
- // Create cleanup function for next run
1182
- cleanup = () => {
1183
- cleanup = null
1184
- reactionCleanup?.()
1185
- // Remove this effect from all reactive objects it's watching
1186
- const effectObjects = effectToReactiveObjects.get(runEffect)
1187
- if (effectObjects) {
1188
- for (const reactiveObj of effectObjects) {
1189
- const objectWatchers = watchers.get(reactiveObj)
1190
- if (objectWatchers) {
1191
- for (const [prop, deps] of objectWatchers.entries()) {
1192
- deps.delete(runEffect)
1193
- if (deps.size === 0) {
1194
- objectWatchers.delete(prop)
1174
+ // Create cleanup function for next run
1175
+ node.cleanup = () => {
1176
+ node.cleanup = undefined
1177
+ reactionCleanup?.()
1178
+ reactionCleanup = undefined
1179
+ delete node.trackers
1180
+ delete node.catchers
1181
+ // Remove this effect from all reactive objects it's watching
1182
+ const effectObjects = effectToReactiveObjects.get(runEffect)
1183
+ if (effectObjects) {
1184
+ for (const reactiveObj of effectObjects) {
1185
+ const objectWatchers = watchers.get(reactiveObj)
1186
+ if (objectWatchers) {
1187
+ for (const [prop, deps] of objectWatchers.entries()) {
1188
+ deps.delete(runEffect)
1189
+ if (deps.size === 0) {
1190
+ objectWatchers.delete(prop)
1191
+ }
1192
+ }
1193
+ if (objectWatchers.size === 0) {
1194
+ watchers.delete(reactiveObj)
1195
1195
  }
1196
1196
  }
1197
- if (objectWatchers.size === 0) {
1198
- watchers.delete(reactiveObj)
1197
+ }
1198
+ effectToReactiveObjects.delete(runEffect)
1199
+ }
1200
+ // Invoke all child stops (recursive via subEffectCleanup calling its own mainCleanup)
1201
+ const children = node.children
1202
+ if (children) {
1203
+ for (const childCleanup of children) childCleanup()
1204
+ delete node.children
1205
+ }
1206
+ }
1207
+
1208
+ // Define bubbling thrower
1209
+ thrower = (error: any) => {
1210
+ const catches = node.catchers
1211
+ if (catches)
1212
+ while (caught < catches.length) {
1213
+ reactionCleanup?.(error)
1214
+ reactionCleanup = undefined
1215
+ try {
1216
+ reactionCleanup = catches[caught](error) as EffectCloser | undefined
1217
+ return
1218
+ } catch (e) {
1219
+ caught++
1199
1220
  }
1200
1221
  }
1222
+ if (parent) {
1223
+ const parentNode = getEffectNode(parent)
1224
+ if (parentNode.forwardThrow) parentNode.forwardThrow(error)
1225
+ else throw error
1201
1226
  }
1202
- effectToReactiveObjects.delete(runEffect)
1227
+ else throw error
1203
1228
  }
1204
- // Invoke all child stops (recursive via subEffectCleanup calling its own mainCleanup)
1205
- const children = effectChildren.get(runEffect)
1206
- if (children) {
1207
- for (const childCleanup of children) childCleanup()
1208
- effectChildren.delete(runEffect)
1229
+ // Update the node's forwardThrow to the bubbling one
1230
+ node.forwardThrow = thrower
1231
+
1232
+ if (errorToThrow) thrower(errorToThrow)
1233
+ }
1234
+
1235
+ // Initialize metadata node
1236
+ const node = getEffectNode(runEffect)
1237
+
1238
+ if (isDevtoolsEnabled()) {
1239
+ const stack = getStackFrame() // Robustly skips internal mutts frames
1240
+ if (stack.length > 0) {
1241
+ node.creationStack = stack
1209
1242
  }
1210
1243
  }
1211
- }
1212
- // Mark the runEffect callback with the original function as its root
1213
- markWithRoot(runEffect, fn)
1214
- function augmentedRv(rv: ScopedCallback) {
1215
- return Object.defineProperties(rv, {
1216
- [stopped]: {
1217
- get() {
1218
- return effectStopped
1219
- },
1220
- },
1221
- [cleanupSymbol]: {
1222
- value: () => {
1223
- if (cleanup) {
1224
- const prevCleanup = cleanup
1225
- cleanup = null
1226
- untracked(() => prevCleanup())
1227
- }
1244
+
1245
+ let cleanup: (() => void) | null = null
1246
+ const tracked = named(effectMarker.leave, effectHistory.present.with(runEffect, () => named(effectMarker.leave, effectAggregator.zoned)))
1247
+ const ascended = named(effectMarker.leave, effectHistory.zoned)
1248
+ const parent = effectHistory.present.active
1249
+ // Set parent relationship in node
1250
+ node.parent = parent
1251
+
1252
+ // let thrower: CatchFunction | undefined // Moved inside runEffect
1253
+ let effectStopped = false
1254
+ const access: EffectAccess = {
1255
+ tracked,
1256
+ ascend: named(effectMarker.leave, (fn) => ascended(named(effectMarker.enter, ()=> fn.call(null)))),
1257
+ //named(effectMarker.enter, (fn) => ascended(fn)),
1258
+ reaction: false,
1259
+ }
1260
+ let runningPromise: Promise<any> | null = null
1261
+ let cancelPrevious: (() => void) | null = null
1262
+ if (effectOptions?.dependencyHook) {
1263
+ node.dependencyHook = effectOptions.dependencyHook
1264
+ }
1265
+ // Mark the runEffect callback with the original function as its root
1266
+ markWithRoot(runEffect, fn)
1267
+ function augmentedRv(rv: ScopedCallback): EffectCleanup {
1268
+ return Object.defineProperties(rv, {
1269
+ [stopped]: {
1270
+ get: () => effectStopped,
1228
1271
  },
1229
- },
1230
- }) as ScopedCallback & {
1231
- [stopped]: boolean
1232
- [cleanupSymbol]: () => void
1272
+ }) as EffectCleanup
1233
1273
  }
1234
- }
1235
- augmentedRv(runEffect)
1236
1274
 
1237
- // Register strict mode if enabled
1238
- if (effectOptions?.opaque) {
1239
- opaqueEffects.add(runEffect)
1240
- }
1275
+ // Register strict mode if enabled
1276
+ if (effectOptions?.opaque) {
1277
+ node.isOpaque = true
1278
+ }
1241
1279
 
1242
- if (isDevtoolsEnabled()) {
1243
- registerEffectForDebug(runEffect)
1244
- }
1280
+ if (isDevtoolsEnabled()) {
1281
+ registerEffectForDebug(runEffect)
1282
+ }
1283
+
1284
+ // Store parent relationship for hierarchy traversal - ALREADY DONE ABOVE via getEffectNode
1245
1285
 
1246
- batch(runEffect, 'immediate')
1247
-
1248
- // Store parent relationship for hierarchy traversal
1249
- effectParent.set(runEffect, parent)
1250
- // Only ROOT effects are registered for GC cleanup and zone tracking
1251
- const isRootEffect = !parent
1252
-
1253
- const stopEffect = (): void => {
1254
- if (effectStopped) return
1255
- effectStopped = true
1256
- // Cancel any running async work
1257
- if (cancelPrevious) {
1258
- cancelPrevious()
1259
- cancelPrevious = null
1260
- runningPromise = null
1286
+ batch(runEffect, 'immediate')
1287
+ // Only ROOT effects are registered for GC cleanup and zone tracking
1288
+ const isRootEffect = !parent
1289
+
1290
+ const stopEffect = (): void => {
1291
+ if (effectStopped) return
1292
+ effectStopped = true
1293
+ node.stopped = true
1294
+ // Cancel any running async work
1295
+ if (cancelPrevious) {
1296
+ cancelPrevious()
1297
+ cancelPrevious = null
1298
+ runningPromise = null
1299
+ }
1300
+ try {
1301
+ node.cleanup?.()
1302
+ } catch (error) {
1303
+ // Cleanup errors should basically be ignored or at least not stop the world
1304
+ // If we want to report them, we could use options.warn or similar
1305
+ options.warn('Error during effect cleanup', error)
1306
+ }
1307
+ // Clean up dependency graph edges
1308
+ cleanupEffectFromGraph(runEffect)
1309
+ fr.unregister(stopEffect)
1261
1310
  }
1262
- cleanup?.()
1263
- // Clean up dependency graph edges
1264
- cleanupEffectFromGraph(runEffect)
1265
- fr.unregister(stopEffect)
1266
- }
1267
- if (isRootEffect) {
1268
- const callIfCollected = augmentedRv(() => stopEffect())
1269
- fr.register(
1270
- callIfCollected,
1271
- () => {
1311
+ if (isRootEffect) {
1312
+ const callIfCollected = augmentedRv(() => stopEffect())
1313
+ fr.register(
1314
+ callIfCollected,
1315
+ () => {
1316
+ stopEffect()
1317
+ optionCall('garbageCollected', fn)
1318
+ },
1319
+ stopEffect
1320
+ )
1321
+ return callIfCollected
1322
+ }
1323
+ // Register this effect to be stopped when the parent effect is cleaned up
1324
+ if (parent) {
1325
+ const parentNode = getEffectNode(parent)
1326
+ if (!parentNode.children) {
1327
+ parentNode.children = new Set()
1328
+ }
1329
+ const children = parentNode.children
1330
+
1331
+ const subEffectCleanup = augmentedRv(() => {
1332
+ children.delete(subEffectCleanup)
1333
+ // Execute this child effect cleanup (which triggers its own mainCleanup)
1272
1334
  stopEffect()
1273
- options.garbageCollected(fn)
1274
- },
1275
- stopEffect
1276
- )
1277
- return callIfCollected
1278
- }
1279
- // Register this effect to be stopped when the parent effect is cleaned up
1280
- let children = effectChildren.get(parent)
1281
- if (!children) {
1282
- children = new Set()
1283
- effectChildren.set(parent, children)
1284
- }
1285
- const subEffectCleanup = augmentedRv(() => {
1286
- children.delete(subEffectCleanup)
1287
- if (children.size === 0) {
1288
- effectChildren.delete(parent)
1335
+ })
1336
+ children.add(subEffectCleanup)
1337
+ return subEffectCleanup
1289
1338
  }
1290
- // Execute this child effect cleanup (which triggers its own mainCleanup)
1291
- stopEffect()
1292
- })
1293
- children.add(subEffectCleanup)
1294
-
1295
- return subEffectCleanup
1296
- }
1339
+ // Should not be reachable given isRootEffect check, but for type safety
1340
+ return augmentedRv(stopEffect)
1341
+ },
1342
+ {
1343
+ get opaque() {
1344
+ return flavorOptions(this, { opaque: true }, 'opaque')
1345
+ },
1346
+ named(name: string) {
1347
+ return flavorOptions(this, { name }, 'named')
1348
+ },
1349
+ }
1350
+ ))
1297
1351
 
1298
1352
  /**
1299
1353
  * Executes a function without tracking dependencies but maintains parent cleanup relationship
@@ -1313,8 +1367,6 @@ export function root<T>(fn: () => T): T {
1313
1367
  return effectHistory.root(fn)
1314
1368
  }
1315
1369
 
1316
- export { effectTrackers }
1317
-
1318
1370
  /**
1319
1371
  * Creates a bidirectional binding between a reactive value and a non-reactive external value
1320
1372
  * Prevents infinite loops by automatically suppressing circular notifications
@@ -1366,16 +1418,16 @@ export function biDi<T>(
1366
1418
  set = get.set
1367
1419
  get = get.get
1368
1420
  }
1369
- let programatticallySetValue: any = Symbol()
1421
+ let programmaticallySetValue: any = Symbol()
1370
1422
  effect(
1371
1423
  markWithRoot(() => {
1372
1424
  const newValue = get()
1373
- if (unwrap(newValue) !== programatticallySetValue) received(newValue)
1425
+ if (unwrap(newValue) !== programmaticallySetValue) received(newValue)
1374
1426
  }, received)
1375
1427
  )
1376
1428
  return set
1377
1429
  ? atomic((value: T) => {
1378
- programatticallySetValue = unwrap(value)
1430
+ programmaticallySetValue = unwrap(value)
1379
1431
  set(value)
1380
1432
  })
1381
1433
  : () => {}