mutts 1.0.9 → 1.0.10

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 (204) hide show
  1. package/README.md +58 -33
  2. package/dist/browser.cjs +1371 -1206
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.ts +2 -1392
  5. package/dist/browser.dev.cjs +110 -0
  6. package/dist/browser.dev.cjs.map +1 -0
  7. package/dist/browser.dev.d.ts +2 -0
  8. package/dist/browser.dev.esm.js +5 -0
  9. package/dist/browser.dev.esm.js.map +1 -0
  10. package/dist/browser.esm.js +6 -87
  11. package/dist/browser.esm.js.map +1 -1
  12. package/dist/chunks/{async-browser-lvzLOCgk.cjs → async-browser-BU_IfxYD.cjs} +4 -85
  13. package/dist/chunks/async-browser-BU_IfxYD.cjs.map +1 -0
  14. package/dist/chunks/{async-node-C3DeIb0y.cjs → async-node-3PrbVAbB.cjs} +3 -1
  15. package/dist/chunks/async-node-3PrbVAbB.cjs.map +1 -0
  16. package/dist/chunks/{index-2vea86wD.esm.js → index-CaaQQlPJ.esm.js} +1116 -953
  17. package/dist/chunks/index-CaaQQlPJ.esm.js.map +1 -0
  18. package/dist/chunks/node-nKJBk8iJ.esm.js +96 -0
  19. package/dist/chunks/node-nKJBk8iJ.esm.js.map +1 -0
  20. package/dist/chunks/{index-VTO-b2vR.cjs → proxy-Dtg-bJ3T.cjs} +2285 -2983
  21. package/dist/chunks/proxy-Dtg-bJ3T.cjs.map +1 -0
  22. package/dist/chunks/{index-CtA2AWl3.esm.js → proxy-r7lARftl.esm.js} +2273 -2948
  23. package/dist/chunks/proxy-r7lARftl.esm.js.map +1 -0
  24. package/dist/debug.cjs +973 -28
  25. package/dist/debug.cjs.map +1 -1
  26. package/dist/debug.d.ts +36 -37
  27. package/dist/debug.esm.js +949 -1
  28. package/dist/debug.esm.js.map +1 -1
  29. package/dist/devtools/manifest.json +1 -1
  30. package/dist/devtools/panel.html +1 -1
  31. package/dist/devtools/panel.js +107 -94
  32. package/dist/devtools/panel.js.map +1 -1
  33. package/dist/index.d.ts +1447 -1
  34. package/dist/mutts.umd.js +7167 -1
  35. package/dist/mutts.umd.js.map +1 -1
  36. package/dist/mutts.umd.min.js +1 -1
  37. package/dist/mutts.umd.min.js.map +1 -1
  38. package/dist/node.cjs +62 -60
  39. package/dist/node.cjs.map +1 -1
  40. package/dist/node.d.ts +2 -2
  41. package/dist/node.dev.cjs +110 -0
  42. package/dist/node.dev.cjs.map +1 -0
  43. package/dist/node.dev.d.ts +2 -0
  44. package/dist/node.dev.esm.js +6 -0
  45. package/dist/node.dev.esm.js.map +1 -0
  46. package/dist/node.esm.js +4 -97
  47. package/dist/node.esm.js.map +1 -1
  48. package/dist/{types-DaHFfhlN.d.ts → types-W5vD6m2n.d.ts} +99 -44
  49. package/docs/ai/api-reference.md +11 -18
  50. package/docs/ai/manual.md +308 -230
  51. package/docs/reactive/advanced.md +312 -2
  52. package/docs/reactive/attend.md +1 -2
  53. package/docs/reactive/collections.md +21 -39
  54. package/docs/reactive/core.md +7 -37
  55. package/docs/reactive/debugging.md +117 -10
  56. package/docs/reactive/error-handling.md +10 -10
  57. package/docs/reactive/resource.md +125 -0
  58. package/docs/reactive/scan.md +32 -1
  59. package/docs/reactive.md +3 -2
  60. package/docs/utils.md +70 -0
  61. package/package.json +59 -30
  62. package/dist/chunks/async-browser-lvzLOCgk.cjs.map +0 -1
  63. package/dist/chunks/async-node-C3DeIb0y.cjs.map +0 -1
  64. package/dist/chunks/index-2vea86wD.esm.js.map +0 -1
  65. package/dist/chunks/index-CtA2AWl3.esm.js.map +0 -1
  66. package/dist/chunks/index-VTO-b2vR.cjs.map +0 -1
  67. package/dist/debug/debug.d.ts +0 -122
  68. package/dist/debug/debug.d.ts.map +0 -1
  69. package/dist/debug/index.d.ts +0 -4
  70. package/dist/debug/index.d.ts.map +0 -1
  71. package/dist/debug/lineage-panel.d.ts +0 -5
  72. package/dist/debug/lineage-panel.d.ts.map +0 -1
  73. package/dist/debug/lineage.d.ts +0 -79
  74. package/dist/debug/lineage.d.ts.map +0 -1
  75. package/dist/src/async/browser.d.ts +0 -2
  76. package/dist/src/async/browser.d.ts.map +0 -1
  77. package/dist/src/async/index.d.ts +0 -19
  78. package/dist/src/async/index.d.ts.map +0 -1
  79. package/dist/src/async/node.d.ts +0 -2
  80. package/dist/src/async/node.d.ts.map +0 -1
  81. package/dist/src/decorator.d.ts +0 -106
  82. package/dist/src/decorator.d.ts.map +0 -1
  83. package/dist/src/destroyable.d.ts +0 -87
  84. package/dist/src/destroyable.d.ts.map +0 -1
  85. package/dist/src/entry-browser.d.ts +0 -3
  86. package/dist/src/entry-browser.d.ts.map +0 -1
  87. package/dist/src/entry-node.d.ts +0 -3
  88. package/dist/src/entry-node.d.ts.map +0 -1
  89. package/dist/src/eventful.d.ts +0 -20
  90. package/dist/src/eventful.d.ts.map +0 -1
  91. package/dist/src/flavored.d.ts +0 -33
  92. package/dist/src/flavored.d.ts.map +0 -1
  93. package/dist/src/index.d.ts +0 -14
  94. package/dist/src/index.d.ts.map +0 -1
  95. package/dist/src/indexable.d.ts +0 -243
  96. package/dist/src/indexable.d.ts.map +0 -1
  97. package/dist/src/introspection.d.ts +0 -27
  98. package/dist/src/introspection.d.ts.map +0 -1
  99. package/dist/src/iterableWeak.d.ts +0 -53
  100. package/dist/src/iterableWeak.d.ts.map +0 -1
  101. package/dist/src/mixins.d.ts +0 -25
  102. package/dist/src/mixins.d.ts.map +0 -1
  103. package/dist/src/promiseChain.d.ts +0 -20
  104. package/dist/src/promiseChain.d.ts.map +0 -1
  105. package/dist/src/reactive/array.d.ts +0 -48
  106. package/dist/src/reactive/array.d.ts.map +0 -1
  107. package/dist/src/reactive/buffer.d.ts +0 -120
  108. package/dist/src/reactive/buffer.d.ts.map +0 -1
  109. package/dist/src/reactive/change.d.ts +0 -29
  110. package/dist/src/reactive/change.d.ts.map +0 -1
  111. package/dist/src/reactive/deep-touch.d.ts +0 -28
  112. package/dist/src/reactive/deep-touch.d.ts.map +0 -1
  113. package/dist/src/reactive/deep-watch-state.d.ts +0 -25
  114. package/dist/src/reactive/deep-watch-state.d.ts.map +0 -1
  115. package/dist/src/reactive/deep-watch.d.ts +0 -20
  116. package/dist/src/reactive/deep-watch.d.ts.map +0 -1
  117. package/dist/src/reactive/describe.d.ts +0 -12
  118. package/dist/src/reactive/describe.d.ts.map +0 -1
  119. package/dist/src/reactive/effect-context.d.ts +0 -34
  120. package/dist/src/reactive/effect-context.d.ts.map +0 -1
  121. package/dist/src/reactive/effects.d.ts +0 -164
  122. package/dist/src/reactive/effects.d.ts.map +0 -1
  123. package/dist/src/reactive/index.d.ts +0 -19
  124. package/dist/src/reactive/index.d.ts.map +0 -1
  125. package/dist/src/reactive/map.d.ts +0 -28
  126. package/dist/src/reactive/map.d.ts.map +0 -1
  127. package/dist/src/reactive/memoize.d.ts +0 -28
  128. package/dist/src/reactive/memoize.d.ts.map +0 -1
  129. package/dist/src/reactive/non-reactive-state.d.ts +0 -9
  130. package/dist/src/reactive/non-reactive-state.d.ts.map +0 -1
  131. package/dist/src/reactive/non-reactive.d.ts +0 -11
  132. package/dist/src/reactive/non-reactive.d.ts.map +0 -1
  133. package/dist/src/reactive/project.d.ts +0 -40
  134. package/dist/src/reactive/project.d.ts.map +0 -1
  135. package/dist/src/reactive/proxy-state.d.ts +0 -8
  136. package/dist/src/reactive/proxy-state.d.ts.map +0 -1
  137. package/dist/src/reactive/proxy.d.ts +0 -23
  138. package/dist/src/reactive/proxy.d.ts.map +0 -1
  139. package/dist/src/reactive/record.d.ts +0 -115
  140. package/dist/src/reactive/record.d.ts.map +0 -1
  141. package/dist/src/reactive/register.d.ts +0 -125
  142. package/dist/src/reactive/register.d.ts.map +0 -1
  143. package/dist/src/reactive/registry.d.ts +0 -21
  144. package/dist/src/reactive/registry.d.ts.map +0 -1
  145. package/dist/src/reactive/set.d.ts +0 -26
  146. package/dist/src/reactive/set.d.ts.map +0 -1
  147. package/dist/src/reactive/tracking.d.ts +0 -7
  148. package/dist/src/reactive/tracking.d.ts.map +0 -1
  149. package/dist/src/reactive/types.d.ts +0 -424
  150. package/dist/src/reactive/types.d.ts.map +0 -1
  151. package/dist/src/reactive/watch.d.ts +0 -48
  152. package/dist/src/reactive/watch.d.ts.map +0 -1
  153. package/dist/src/std-decorators.d.ts +0 -45
  154. package/dist/src/std-decorators.d.ts.map +0 -1
  155. package/dist/src/utils.d.ts +0 -49
  156. package/dist/src/utils.d.ts.map +0 -1
  157. package/dist/src/zone.d.ts +0 -40
  158. package/dist/src/zone.d.ts.map +0 -1
  159. package/docs/reactive/describe.md +0 -85
  160. package/docs/reactive/project.md +0 -93
  161. package/src/async/browser.ts +0 -323
  162. package/src/async/index.ts +0 -27
  163. package/src/async/node.ts +0 -92
  164. package/src/decorator.ts +0 -272
  165. package/src/destroyable.ts +0 -199
  166. package/src/entry-browser.ts +0 -5
  167. package/src/entry-node.ts +0 -5
  168. package/src/eventful.ts +0 -110
  169. package/src/flavored.ts +0 -106
  170. package/src/index.d.ts +0 -12
  171. package/src/index.ts +0 -64
  172. package/src/indexable.ts +0 -526
  173. package/src/introspection.ts +0 -59
  174. package/src/iterableWeak.ts +0 -233
  175. package/src/mixins.ts +0 -123
  176. package/src/promiseChain.ts +0 -110
  177. package/src/reactive/array.ts +0 -500
  178. package/src/reactive/buffer.ts +0 -328
  179. package/src/reactive/change.ts +0 -131
  180. package/src/reactive/deep-touch.ts +0 -273
  181. package/src/reactive/deep-watch-state.ts +0 -82
  182. package/src/reactive/deep-watch.ts +0 -171
  183. package/src/reactive/describe.ts +0 -39
  184. package/src/reactive/effect-context.ts +0 -83
  185. package/src/reactive/effects.ts +0 -1434
  186. package/src/reactive/index.ts +0 -72
  187. package/src/reactive/map.ts +0 -142
  188. package/src/reactive/memoize.ts +0 -186
  189. package/src/reactive/non-reactive-state.ts +0 -49
  190. package/src/reactive/non-reactive.ts +0 -43
  191. package/src/reactive/project.md +0 -107
  192. package/src/reactive/project.ts +0 -430
  193. package/src/reactive/proxy-state.ts +0 -27
  194. package/src/reactive/proxy.ts +0 -282
  195. package/src/reactive/record.ts +0 -181
  196. package/src/reactive/register.ts +0 -538
  197. package/src/reactive/registry.ts +0 -72
  198. package/src/reactive/set.ts +0 -117
  199. package/src/reactive/tracking.ts +0 -41
  200. package/src/reactive/types.ts +0 -520
  201. package/src/reactive/watch.ts +0 -180
  202. package/src/std-decorators.ts +0 -256
  203. package/src/utils.ts +0 -300
  204. package/src/zone.ts +0 -142
@@ -1,1434 +0,0 @@
1
- import { named } from '../utils'
2
- import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from '../../debug/debug'
3
- import { effectMarker, getStackFrame, type StackFrame } from '../../debug/lineage'
4
- import { decorator } from '../decorator'
5
- import { flavored, flavorOptions } from '../flavored'
6
- import { IterableWeakSet } from '../iterableWeak'
7
- import { effectAggregator, effectHistory, getActiveEffect } from './effect-context'
8
- import { unwrap } from './proxy-state'
9
- import {
10
- effectToReactiveObjects,
11
- getEffectNode,
12
- getRoot,
13
- markWithRoot,
14
- resetRegistry,
15
- watchers,
16
- } from './registry'
17
- import {
18
- type CatchFunction,
19
- cleanup as cleanupSymbol,
20
- type EffectAccess,
21
- type EffectCleanup,
22
- type EffectCloser,
23
- type EffectOptions,
24
- type EffectTrigger,
25
- type Evolution,
26
- optionCall,
27
- options,
28
- ReactiveError,
29
- ReactiveErrorCode,
30
- // type AsyncExecutionMode,
31
- type ScopedCallback,
32
- stopped,
33
- } from './types'
34
-
35
- /**
36
- * Finds a cycle in a sequence of functions by looking for the first repetition
37
- */
38
- function findCycleInChain(roots: Function[]): Function[] | null {
39
- const seen = new Map<Function, number>()
40
- for (let i = 0; i < roots.length; i++) {
41
- const root = roots[i]
42
- if (seen.has(root)) {
43
- return roots.slice(seen.get(root)!)
44
- }
45
- seen.set(root, i)
46
- }
47
- return null
48
- }
49
-
50
- /**
51
- * Formats a list of function roots into a readable trace
52
- */
53
- function formatRoots(roots: Function[], limit = 20): string {
54
- const names = roots.map((r) => r.name || '<anonymous>')
55
- if (names.length <= limit) return names.join(' → ')
56
- const start = names.slice(0, 5)
57
- const end = names.slice(-10)
58
- return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`
59
- }
60
-
61
- type EffectTracking = (obj: any, evolution: Evolution, prop: any, effect: EffectTrigger) => void
62
-
63
- export interface ActivationRecord {
64
- effect: EffectTrigger
65
- obj: any
66
- evolution: Evolution
67
- prop: any
68
- batchId: number
69
- }
70
-
71
- // Nested map structure for efficient counting and batch cleanup
72
- // batchId -> effect root -> obj -> prop -> count
73
- let activationRegistry: Map<Function, Map<any, Map<any, number>>> | undefined
74
-
75
- export const activationLog: Omit<ActivationRecord, 'batchId'>[] = new Array(100)
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
- */
83
- export function getActivationLog() {
84
- return activationLog
85
- }
86
-
87
- export function recordActivation(effect: EffectTrigger, obj: any, evolution: Evolution, prop: any) {
88
- const root = getRoot(effect)
89
-
90
- if (!activationRegistry) return
91
- let effectData = activationRegistry.get(root)
92
- if (!effectData) {
93
- effectData = new Map()
94
- activationRegistry.set(root, effectData)
95
- }
96
- let objData = effectData.get(obj)
97
- if (!objData) {
98
- objData = new Map()
99
- effectData.set(obj, objData)
100
- }
101
- const count = (objData.get(prop) ?? 0) + 1
102
- objData.set(prop, count)
103
-
104
- // Keep a limited history for diagnostics
105
- activationLog.unshift({
106
- effect,
107
- obj,
108
- evolution,
109
- prop,
110
- })
111
- activationLog.pop()
112
-
113
- if (count >= options.maxTriggerPerBatch) {
114
- const effectName = (root as any)?.name || 'anonymous'
115
- const message = `Aggressive trigger detected: effect "${effectName}" triggered ${count} times in the batch by the same cause.`
116
- if (options.maxEffectReaction === 'throw') {
117
- throw new ReactiveError(message, {
118
- code: ReactiveErrorCode.MaxReactionExceeded,
119
- count,
120
- effect: effectName,
121
- })
122
- }
123
- options.warn(`[reactive] ${message}`)
124
- }
125
- }
126
-
127
- /**
128
- * Registers a debug callback that is called when the current effect is triggered by a dependency change
129
- *
130
- * This function is useful for debugging purposes as it pin-points exactly which reactive property
131
- * change triggered the effect. The callback receives information about:
132
- * - The object that changed
133
- * - The type of change (evolution)
134
- * - The specific property that changed
135
- *
136
- * **Note:** The tracker callback is automatically removed after being called once. If you need
137
- * to track multiple triggers, call `onEffectTrigger` again within the effect.
138
- *
139
- * @param onTouch - Callback function that receives (obj, evolution, prop) when the effect is triggered
140
- * @throws {Error} If called outside of an effect context
141
- *
142
- * @example
143
- * ```typescript
144
- * const state = reactive({ count: 0, name: 'John' })
145
- *
146
- * effect(() => {
147
- * // Register a tracker to see what triggers this effect
148
- * onEffectTrigger((obj, evolution, prop) => {
149
- * console.log(`Effect triggered by:`, {
150
- * object: obj,
151
- * change: evolution.type,
152
- * property: prop
153
- * })
154
- * })
155
- *
156
- * // Access reactive properties
157
- * console.log(state.count, state.name)
158
- * })
159
- *
160
- * state.count = 5
161
- * ```
162
- */
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)
169
- }
170
-
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
- }
190
-
191
- // Dependency graph: tracks which effects trigger which other effects
192
- // Uses roots (Function) as keys for consistency
193
- let effectTriggers = new WeakMap<Function, IterableWeakSet<Function>>()
194
- let effectTriggeredBy = new WeakMap<Function, IterableWeakSet<Function>>()
195
-
196
- // Transitive closures: track all indirect relationships
197
- // causesClosure: for each effect, all effects that trigger it (directly or indirectly)
198
- // consequencesClosure: for each effect, all effects that it triggers (directly or indirectly)
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
205
-
206
- // Debug: Capture where an effect was created
207
- export const effectCreationStacks = new WeakMap<Function, StackFrame[]>()
208
-
209
- /**
210
- * Gets or creates an IterableWeakSet for a closure map
211
- */
212
- function getOrCreateClosure(
213
- closure: WeakMap<Function, IterableWeakSet<Function>>,
214
- root: Function
215
- ): IterableWeakSet<Function> {
216
- let set = closure.get(root)
217
- if (!set) {
218
- set = new IterableWeakSet()
219
- closure.set(root, set)
220
- }
221
- return set
222
- }
223
-
224
- /**
225
- * Adds an edge to the dependency graph: callerRoot → targetRoot
226
- * Also maintains transitive closures
227
- * @param callerRoot - Root function of the effect that triggers
228
- * @param targetRoot - Root function of the effect being triggered
229
- */
230
- function addGraphEdge(callerRoot: Function, targetRoot: Function) {
231
- if (options.cycleHandling === 'production') return
232
- // Add to forward graph: callerRoot → targetRoot
233
- const triggers = effectTriggers.get(callerRoot)
234
-
235
- if (!triggers) {
236
- const newTriggers = new IterableWeakSet<Function>()
237
- newTriggers.add(targetRoot)
238
- effectTriggers.set(callerRoot, newTriggers)
239
- } else {
240
- triggers.add(targetRoot)
241
- }
242
-
243
- // Add to reverse graph: targetRoot ← callerRoot
244
- let triggeredBy = effectTriggeredBy.get(targetRoot)
245
- if (!triggeredBy) {
246
- triggeredBy = new IterableWeakSet()
247
- effectTriggeredBy.set(targetRoot, triggeredBy)
248
- }
249
- triggeredBy.add(callerRoot)
250
-
251
- // Update transitive closures
252
- // When U→V is added, we need to propagate the relationship:
253
- // 1. Add U to causesClosure(V) and V to consequencesClosure(U) (direct relationship)
254
- // 2. For each X in causesClosure(U): add V to consequencesClosure(X) and X to causesClosure(V)
255
- // 3. For each Y in consequencesClosure(V): add U to causesClosure(Y) and Y to consequencesClosure(U)
256
- // Note: Self-loops (U→U) are not added to closures - if an effect appears in its own closure,
257
- // it means there's an indirect cycle that should be detected
258
-
259
- // Self-loops are explicitly ignored - an effect reading and writing the same property
260
- // (e.g., obj.prop++) should not create a dependency relationship or appear in closures
261
- if (callerRoot === targetRoot) {
262
- return
263
- }
264
-
265
- const uConsequences = getOrCreateClosure(consequencesClosure, callerRoot)
266
- const vCauses = getOrCreateClosure(causesClosure, targetRoot)
267
-
268
- // 1. Add direct relationship
269
- uConsequences.add(targetRoot)
270
- vCauses.add(callerRoot)
271
-
272
- // 2. For each X in causesClosure(U): X→U→V means X→V
273
- const uCausesSet = causesClosure.get(callerRoot)
274
- if (uCausesSet) {
275
- for (const x of uCausesSet) {
276
- // Skip if this would create a self-loop
277
- if (x === targetRoot) continue
278
- const xConsequences = getOrCreateClosure(consequencesClosure, x)
279
- xConsequences.add(targetRoot)
280
- vCauses.add(x)
281
- }
282
- }
283
-
284
- // 3. For each Y in consequencesClosure(V): U→V→Y means U→Y
285
- const vConsequencesSet = consequencesClosure.get(targetRoot)
286
- if (vConsequencesSet) {
287
- for (const y of vConsequencesSet) {
288
- // Skip if this would create a self-loop
289
- if (y === callerRoot) continue
290
- const yCauses = getOrCreateClosure(causesClosure, y)
291
- yCauses.add(callerRoot)
292
- uConsequences.add(y)
293
- }
294
- }
295
-
296
- // 4. Cross-product: for each X in causesClosure(U) and Y in consequencesClosure(V): X→Y
297
- if (uCausesSet?.size && vConsequencesSet?.size) {
298
- for (const x of uCausesSet) {
299
- const xConsequences = getOrCreateClosure(consequencesClosure, x)
300
- for (const y of vConsequencesSet) {
301
- // Skip if this would create a self-loop
302
- if (x === y) continue
303
- xConsequences.add(y)
304
- const yCauses = getOrCreateClosure(causesClosure, y)
305
- yCauses.add(x)
306
- }
307
- }
308
- }
309
- }
310
-
311
- /**
312
- * Checks if there's a path from start to end in the dependency graph, excluding a specific node
313
- * Uses BFS to find any path that doesn't go through the excluded node
314
- * @param start - Starting node
315
- * @param end - Target node
316
- * @param exclude - Node to exclude from the path
317
- * @returns true if a path exists without going through the excluded node
318
- */
319
- function hasPathExcluding(start: Function, end: Function, exclude: Function): boolean {
320
- if (start === end) return true
321
- if (start === exclude) return false
322
-
323
- const visited = new Set<Function>()
324
- const queue: Function[] = [start]
325
- visited.add(start)
326
- visited.add(exclude) // Pre-mark excluded node as visited to skip it
327
-
328
- while (queue.length > 0) {
329
- const current = queue.shift()!
330
- const triggers = effectTriggers.get(current)
331
- if (!triggers) continue
332
-
333
- for (const next of triggers) {
334
- if (next === end) return true
335
- if (!visited.has(next)) {
336
- visited.add(next)
337
- queue.push(next)
338
- }
339
- }
340
- }
341
-
342
- return false
343
- }
344
-
345
- /**
346
- * Removes all edges involving the given effect from the dependency graph
347
- * Also cleans up transitive closures by propagating cleanup to all affected effects
348
- * Called when an effect is stopped/cleaned up
349
- * @param effect - The effect being cleaned up
350
- */
351
- function cleanupEffectFromGraph(effect: EffectTrigger) {
352
- if (options.cycleHandling === 'production') return
353
- const root = getRoot(effect)
354
-
355
- // Get closures before removing direct edges (needed for propagation)
356
- const rootCauses = causesClosure.get(root)
357
- const rootConsequences = consequencesClosure.get(root)
358
-
359
- // Remove from effectTriggers (outgoing edges)
360
- const triggers = effectTriggers.get(root)
361
- if (triggers) {
362
- // Remove this root from all targets' effectTriggeredBy sets
363
- for (const targetRoot of triggers) {
364
- const triggeredBy = effectTriggeredBy.get(targetRoot)
365
- triggeredBy?.delete(root)
366
- }
367
- effectTriggers.delete(root)
368
- }
369
-
370
- // Remove from effectTriggeredBy (incoming edges)
371
- const triggeredBy = effectTriggeredBy.get(root)
372
- if (triggeredBy) {
373
- // Remove this root from all sources' effectTriggers sets
374
- for (const sourceRoot of triggeredBy) {
375
- const triggers = effectTriggers.get(sourceRoot)
376
- triggers?.delete(root)
377
- }
378
- effectTriggeredBy.delete(root)
379
- }
380
-
381
- // Propagate closure cleanup to all affected effects
382
- // When removing B from A → B → C:
383
- // - Remove B from causesClosure(C) and consequencesClosure(A)
384
- // - For each X in causesClosure(B): remove C from consequencesClosure(X) if B was the only path
385
- // - For each Y in consequencesClosure(B): remove A from causesClosure(Y) if B was the only path
386
- // - Remove transitive relationships that depended on B
387
-
388
- if (rootCauses) {
389
- // For each X that triggers root: remove root from X's consequences
390
- // Only remove root's consequences if no alternate path exists
391
- for (const causeRoot of rootCauses) {
392
- const causeConsequences = consequencesClosure.get(causeRoot)
393
- if (causeConsequences) {
394
- // Remove root itself (it's being cleaned up)
395
- causeConsequences.delete(root)
396
- // Only remove consequences of root if there's no alternate path from causeRoot to them
397
- if (rootConsequences) {
398
- for (const consequence of rootConsequences) {
399
- // Check if causeRoot can still reach consequence without going through root
400
- if (!hasPathExcluding(causeRoot, consequence, root)) {
401
- causeConsequences.delete(consequence)
402
- }
403
- }
404
- }
405
- }
406
- }
407
- }
408
-
409
- if (rootConsequences) {
410
- // For each Y that root triggers: remove root from Y's causes
411
- // Only remove root's causes if no alternate path exists
412
- for (const consequenceRoot of rootConsequences) {
413
- const consequenceCauses = causesClosure.get(consequenceRoot)
414
- if (consequenceCauses) {
415
- // Remove root itself (it's being cleaned up)
416
- consequenceCauses.delete(root)
417
- // Only remove causes of root if there's no alternate path from them to consequenceRoot
418
- if (rootCauses) {
419
- for (const cause of rootCauses) {
420
- // Check if cause can still reach consequenceRoot without going through root
421
- if (!hasPathExcluding(cause, consequenceRoot, root)) {
422
- consequenceCauses.delete(cause)
423
- }
424
- }
425
- }
426
- }
427
- }
428
- }
429
-
430
- // Cross-product cleanup: for each X in causesClosure(B) and Y in consequencesClosure(B),
431
- // remove X→Y if B was the only path connecting them
432
- if (rootCauses && rootConsequences) {
433
- for (const x of rootCauses) {
434
- const xConsequences = consequencesClosure.get(x)
435
- if (xConsequences) {
436
- for (const y of rootConsequences) {
437
- // Check if there's still a path from X to Y without going through root
438
- // Use BFS to find any path that doesn't include root
439
- if (!hasPathExcluding(x, y, root)) {
440
- xConsequences.delete(y)
441
- const yCauses = causesClosure.get(y)
442
- yCauses?.delete(x)
443
- }
444
- }
445
- }
446
- }
447
- }
448
-
449
- // Finally, delete the closures for this effect
450
- causesClosure.delete(root)
451
- consequencesClosure.delete(root)
452
- }
453
-
454
- // Batch queue structure - optimized with cached in-degrees
455
- interface BatchQueue {
456
- // All effects in the current batch that still need to be executed (todos)
457
- all: Map<Function, EffectTrigger> // root → effect
458
- // Cached in-degrees for each effect in the batch (number of causes in batch)
459
- inDegrees: Map<Function, number> // root → in-degree count
460
- }
461
-
462
- // Track currently executing effects to prevent re-execution
463
- // These are all the effects triggered under `activeEffect`
464
- let batchQueue: BatchQueue | undefined
465
- export function hasBatched(effect: EffectTrigger) {
466
- return batchQueue?.all.has(getRoot(effect))
467
- }
468
- const batchCleanups = new Set<EffectCleanup>()
469
-
470
- /**
471
- * Computes and caches in-degrees for all effects in the batch
472
- * Called once when batch starts or when new effects are added
473
- */
474
- function computeAllInDegrees(batch: BatchQueue): void {
475
- if (options.cycleHandling === 'production') return
476
- const activeEffect = getActiveEffect()
477
- const activeRoot = activeEffect ? getRoot(activeEffect) : null
478
-
479
- // Reset all in-degrees
480
- batch.inDegrees.clear()
481
-
482
- for (const [root] of batch.all) {
483
- let inDegree = 0
484
- const causes = causesClosure.get(root)
485
- if (causes) {
486
- for (const causeRoot of causes) {
487
- // Only count if it's in the batch and not the active/self effect
488
- if (batch.all.has(causeRoot) && causeRoot !== activeRoot && causeRoot !== root) {
489
- inDegree++
490
- }
491
- }
492
- }
493
- batch.inDegrees.set(root, inDegree)
494
- }
495
- }
496
-
497
- /**
498
- * Decrements in-degrees of all effects that depend on the executed effect
499
- * Called after an effect is executed to update the cached in-degrees
500
- */
501
- function decrementInDegreesForExecuted(batch: BatchQueue, executedRoot: Function): void {
502
- // Get all effects that this executed effect triggers
503
- const consequences = consequencesClosure.get(executedRoot)
504
- if (!consequences) return
505
-
506
- for (const consequenceRoot of consequences) {
507
- // Only update if it's still in the batch
508
- if (batch.all.has(consequenceRoot)) {
509
- const currentDegree = batch.inDegrees.get(consequenceRoot) ?? 0
510
- if (currentDegree > 0) {
511
- batch.inDegrees.set(consequenceRoot, currentDegree - 1)
512
- }
513
- }
514
- }
515
- }
516
-
517
- /**
518
- * Finds a path from startRoot to endRoot in the dependency graph
519
- * Uses DFS to find the path through direct edges
520
- * @param startRoot - Starting effect root
521
- * @param endRoot - Target effect root
522
- * @param visited - Set of visited nodes (for recursion)
523
- * @param path - Current path being explored
524
- * @returns Path from startRoot to endRoot, or empty array if no path exists
525
- */
526
- function findPath(
527
- startRoot: Function,
528
- endRoot: Function,
529
- visited: Set<Function> = new Set(),
530
- path: Function[] = []
531
- ): Function[] {
532
- if (startRoot === endRoot) {
533
- return [...path, endRoot]
534
- }
535
-
536
- if (visited.has(startRoot)) {
537
- return []
538
- }
539
-
540
- visited.add(startRoot)
541
- const newPath = [...path, startRoot]
542
-
543
- const triggers = effectTriggers.get(startRoot)
544
- if (triggers) {
545
- for (const targetRoot of triggers) {
546
- const result = findPath(targetRoot, endRoot, visited, newPath)
547
- if (result.length > 0) {
548
- return result
549
- }
550
- }
551
- }
552
-
553
- return []
554
- }
555
-
556
- /**
557
- * Gets the cycle path when adding an edge would create a cycle
558
- * @param callerRoot - Root of the effect that triggers
559
- * @param targetRoot - Root of the effect being triggered
560
- * @returns Array of effect roots forming the cycle, or empty array if no cycle
561
- */
562
- function getCyclePathForEdge(callerRoot: Function, targetRoot: Function): Function[] {
563
- // Find path from targetRoot back to callerRoot (this is the existing path)
564
- // Then adding callerRoot -> targetRoot completes the cycle
565
- const path = findPath(targetRoot, callerRoot)
566
- if (path.length > 0) {
567
- // The cycle is: callerRoot -> targetRoot -> ... -> callerRoot
568
- return [callerRoot, ...path]
569
- }
570
- return []
571
- }
572
-
573
- /**
574
- * Checks if adding an edge would create a cycle
575
- * Uses causesClosure to check if callerRoot is already a cause of targetRoot
576
- * Self-loops (callerRoot === targetRoot) are explicitly ignored and return false
577
- *
578
- * **Note**: This is the primary optimization benefit of the transitive closure system.
579
- * It allows detecting cycles in O(1) time before they are executed.
580
- *
581
- * @param callerRoot - Root of the effect that triggers
582
- * @param targetRoot - Root of the effect being triggered
583
- * @returns true if adding this edge would create a cycle
584
- */
585
- function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
586
- // Self-loops are explicitly ignored - an effect reading and writing the same property
587
- // (e.g., obj.prop++) should not create a dependency relationship
588
- if (callerRoot === targetRoot) {
589
- return false
590
- }
591
-
592
- // Check if targetRoot already triggers callerRoot (directly or indirectly)
593
- // This would create a cycle: callerRoot -> targetRoot -> ... -> callerRoot
594
- // Using consequencesClosure: if targetRoot triggers callerRoot, then callerRoot is in consequencesClosure(targetRoot)
595
- const targetConsequences = consequencesClosure.get(targetRoot)
596
- if (targetConsequences?.has(callerRoot)) {
597
- return true // Cycle detected: targetRoot -> ... -> callerRoot, and we're adding callerRoot -> targetRoot
598
- }
599
-
600
- return false
601
- }
602
-
603
- /**
604
- * Adds an effect to the batch queue
605
- * @param effect - The effect to add
606
- * @param caller - The active effect that triggered this one (optional)
607
- * @param immediate - If true, don't create edges in the dependency graph
608
- */
609
- function addToBatch(effect: EffectTrigger, caller?: EffectTrigger, immediate?: boolean) {
610
- const node = getEffectNode(effect)
611
- node.cleanup?.()
612
- // If the effect was stopped during cleanup (e.g. lazy memoization), don't add it to the batch
613
- if (node.stopped) {
614
- // console.log(`[DEBUG] addToBatch: ${effect.name} is stopped`)
615
- return
616
- }
617
-
618
- if (!batchQueue) {
619
- // console.log(`[DEBUG] addToBatch: no batchQueue`)
620
- return
621
- }
622
-
623
- const root = getRoot(effect)
624
-
625
- // 1. Add to batch first (needed for cycle detection)
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`)
642
- }
643
-
644
- // batchQueue.all.set(root, effect)
645
- // console.log(`[DEBUG] addToBatch: ${root.name} added to batch`)
646
- if (caller && !immediate && options.cycleHandling !== 'production') {
647
- const callerRoot = getRoot(caller)
648
- // const root = getRoot(effect) // Already have root
649
-
650
- // Check for cycle BEFORE adding edge
651
- if (wouldCreateCycle(callerRoot, root)) {
652
- const cyclePath = getCyclePathForEdge(callerRoot, root)
653
- const cycleMessage =
654
- cyclePath.length > 0
655
- ? `Cycle detected: ${cyclePath.map((r) => r.name || r.toString()).join(' → ')}`
656
- : `Cycle detected: ${callerRoot.name || callerRoot.toString()} → ${root.name || root.toString()} (and back)`
657
-
658
- batchQueue.all.delete(root)
659
- const causalChain = getTriggerChain(effect)
660
- const creationStack = getEffectNode(effect).creationStack
661
-
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
- })
669
- }
670
-
671
- addGraphEdge(callerRoot, root)
672
- }
673
- }
674
-
675
- /**
676
- * Adds a cleanup function to be called when the current batch of effects completes
677
- * @param cleanup - The cleanup function to add
678
- */
679
- export function addBatchCleanup(cleanup: EffectCleanup) {
680
- if (!batchQueue) cleanup()
681
- else batchCleanups.add(cleanup)
682
- }
683
-
684
- /**
685
- * Semantic alias for `addBatchCleanup` - defers work to the end of the current reactive batch.
686
- *
687
- * Use this when an effect needs to perform an action that would modify state the effect depends on,
688
- * which would create a reactive cycle. The deferred callback runs after all effects complete.
689
- *
690
- * @param callback - The callback to defer until after the current batch completes
691
- *
692
- * @example
693
- * ```typescript
694
- * effect(() => {
695
- * processData()
696
- *
697
- * // Defer to avoid cycle (createMovement modifies state this effect reads)
698
- * defer(() => {
699
- * createMovement(data)
700
- * })
701
- * })
702
- * ```
703
- */
704
- export const defer = addBatchCleanup
705
-
706
- /**
707
- * Gets a cycle path for debugging
708
- * Uses DFS to find cycles in the batch
709
- * @param batchQueue - The batch queue
710
- * @returns Array of effect roots forming a cycle
711
- */
712
- function getCyclePath(batchQueue: BatchQueue): Function[] {
713
- // If all effects have in-degree > 0, there must be a cycle
714
- // Use DFS to find it
715
- const visited = new Set<Function>()
716
- const recursionStack = new Set<Function>()
717
- const path: Function[] = []
718
-
719
- for (const [root] of batchQueue.all) {
720
- if (visited.has(root)) continue
721
- const cycle = findCycle(root, visited, recursionStack, path, batchQueue)
722
- if (cycle.length > 0) {
723
- return cycle
724
- }
725
- }
726
-
727
- return []
728
- }
729
-
730
- function findCycle(
731
- root: Function,
732
- visited: Set<Function>,
733
- recursionStack: Set<Function>,
734
- path: Function[],
735
- batchQueue: BatchQueue
736
- ): Function[] {
737
- if (recursionStack.has(root)) {
738
- // Found a cycle! Return the path from the cycle start to root
739
- const cycleStart = path.indexOf(root)
740
- return path.slice(cycleStart).concat([root])
741
- }
742
-
743
- if (visited.has(root)) {
744
- return []
745
- }
746
-
747
- visited.add(root)
748
- recursionStack.add(root)
749
- path.push(root)
750
-
751
- // Follow edges to effects in the batch
752
- // Use direct edges (effectTriggers) for cycle detection
753
- const triggers = effectTriggers.get(root)
754
- if (triggers) {
755
- for (const targetRoot of triggers) {
756
- if (batchQueue.all.has(targetRoot)) {
757
- const cycle = findCycle(targetRoot, visited, recursionStack, path, batchQueue)
758
- if (cycle.length > 0) {
759
- return cycle
760
- }
761
- }
762
- }
763
- }
764
-
765
- path.pop()
766
- recursionStack.delete(root)
767
- return []
768
- }
769
-
770
- /**
771
- * Executes the next effect in dependency order (using cached in-degrees)
772
- * Finds an effect with in-degree 0 and executes it
773
- * @returns The return value of the executed effect, or null if batch is complete
774
- */
775
- function executeNext(effectuatedRoots: Function[]): any {
776
- // Find an effect with in-degree 0 using cached values
777
- let nextEffect: EffectTrigger | null = null
778
- let nextRoot: Function | null = null
779
-
780
- if (options.cycleHandling === 'production') {
781
- // In flat mode, we just take the first effect in the queue (FIFO)
782
- const first = batchQueue!.all.entries().next().value
783
- if (first) {
784
- ;[nextRoot, nextEffect] = first
785
- }
786
- } else {
787
- // Find an effect with in-degree 0 (no dependencies in batch that still need execution)
788
- // Using cached in-degrees for O(n) lookup instead of O(n²)
789
- for (const [root, effect] of batchQueue!.all) {
790
- const inDegree = batchQueue!.inDegrees.get(root) ?? 0
791
- // console.log(`[DEBUG] executeNext: checking ${root.name}, inDegree: ${inDegree}`)
792
- if (inDegree === 0) {
793
- nextEffect = effect
794
- nextRoot = root
795
- break
796
- }
797
- }
798
- }
799
-
800
- if (!nextEffect) {
801
- // No effect with in-degree 0 - there must be a cycle
802
- // If all effects have dependencies, it means there's a circular dependency
803
- if (batchQueue!.all.size > 0) {
804
- let cycle = getCyclePath(batchQueue!)
805
- // If we couldn't find a cycle path using direct edges, try using closures
806
- // (transitive relationships) - if all effects have in-degree > 0, there must be a cycle
807
- if (cycle.length === 0) {
808
- // Try to find a cycle using consequencesClosure (transitive relationships)
809
- // Note: Self-loops are ignored - we only look for cycles between different effects
810
- for (const [root] of batchQueue!.all) {
811
- const consequences = consequencesClosure.get(root)
812
- if (consequences) {
813
- // Check if any consequence in the batch also has root as a consequence
814
- for (const consequence of consequences) {
815
- // Skip self-loops - they are ignored
816
- if (consequence === root) continue
817
- if (batchQueue!.all.has(consequence)) {
818
- const consequenceConsequences = consequencesClosure.get(consequence)
819
- if (consequenceConsequences?.has(root)) {
820
- // Found cycle: root -> consequence -> root
821
- cycle = [root, consequence, root]
822
- break
823
- }
824
- }
825
- }
826
- if (cycle.length > 0) break
827
- }
828
- }
829
- }
830
- const cycleMessage =
831
- cycle.length > 0
832
- ? `Cycle detected: ${cycle.map((r) => r.name || '<anonymous>').join(' → ')}`
833
- : 'Cycle detected in effect batch - all effects have dependencies that prevent execution'
834
-
835
- throw new ReactiveError(`[reactive] ${cycleMessage}`, {
836
- code: ReactiveErrorCode.CycleDetected,
837
- cycle: cycle.map((r) => r.name || r.toString()),
838
- details: cycleMessage,
839
- })
840
- }
841
- return null // Batch complete
842
- }
843
-
844
- effectuatedRoots.push(getRoot(nextEffect))
845
- // Execute the effect
846
- const result = nextEffect()
847
-
848
- // Remove from batch and update in-degrees of dependents
849
- batchQueue!.all.delete(nextRoot!)
850
- batchQueue!.inDegrees.delete(nextRoot!)
851
- decrementInDegreesForExecuted(batchQueue!, nextRoot!)
852
-
853
- return result
854
- }
855
-
856
- // Track which sub-effects have been executed to prevent infinite loops
857
- // These are all the effects triggered under `activeEffect` and all their sub-effects
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
- }
865
- if (!Array.isArray(effect)) effect = [effect]
866
- const roots = effect.map(getRoot)
867
-
868
- batchDepth++
869
- try {
870
- if (batchQueue) {
871
- // Nested batch - add to existing
872
- options?.chain(roots, getRoot(getActiveEffect()))
873
- const caller = getActiveEffect()
874
- for (let i = 0; i < effect.length; i++) addToBatch(effect[i], caller, immediate === 'immediate')
875
- if (immediate) {
876
- const firstReturn: { value?: any } = {}
877
- // Execute immediately (before batch returns)
878
- for (let i = 0; i < effect.length; i++) {
879
- try {
880
- const rv = effect[i]()
881
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
882
- } finally {
883
- const root = getRoot(effect[i])
884
- batchQueue.all.delete(root)
885
- }
886
- }
887
- return firstReturn.value
888
- }
889
- // Otherwise, effects will be picked up in next executeNext() call
890
- } else {
891
- // New batch - initialize
892
- if (!activationRegistry) activationRegistry = new Map()
893
- else throw new Error('Batch already in progress')
894
- optionCall('beginChain', roots)
895
- batchQueue = {
896
- all: new Map(),
897
- inDegrees: new Map(),
898
- }
899
-
900
- const caller = getActiveEffect()
901
- const effectuatedRoots: Function[] = []
902
- const firstReturn: { value?: any } = {}
903
- let success = false
904
-
905
- try {
906
- if (immediate) {
907
- // Execute initial effects in providing order
908
- for (let i = 0; i < effect.length; i++) {
909
- try {
910
- const rv = effect[i]()
911
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
912
- } finally {
913
- batchQueue.all.delete(getRoot(effect[i]))
914
- }
915
- }
916
- } else {
917
- // Add initial effects to batch and compute dependencies
918
- const len = effect.length
919
- for (let i = 0; i < len; i++) {
920
- addToBatch(effect[i], caller, false)
921
- }
922
- computeAllInDegrees(batchQueue)
923
- }
924
-
925
- // Process the batch
926
- while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
927
- if (batchQueue.all.size > 0) {
928
- if (effectuatedRoots.length > options.maxEffectChain) {
929
- const cycle = findCycleInChain(effectuatedRoots as any)
930
- const trace = formatRoots(effectuatedRoots as any)
931
- const message = cycle
932
- ? `Max effect chain reached (cycle detected: ${formatRoots(cycle)})`
933
- : `Max effect chain reached (trace: ${trace})`
934
-
935
- const queuedRoots = batchQueue ? Array.from(batchQueue.all.keys()) : []
936
- const queued = queuedRoots.map((r) => r.name || '<anonymous>')
937
- const debugInfo = {
938
- code: ReactiveErrorCode.MaxDepthExceeded,
939
- effectuatedRoots,
940
- cycle,
941
- trace,
942
- maxEffectChain: options.maxEffectChain,
943
- queued: queued.slice(0, 50),
944
- queuedCount: queued.length,
945
- // Try to get causation for the last effect
946
- causalChain:
947
- effectuatedRoots.length > 0
948
- ? getTriggerChain(
949
- batchQueue.all.get(effectuatedRoots[effectuatedRoots.length - 1])!
950
- )
951
- : [],
952
- }
953
- switch (options.maxEffectReaction) {
954
- case 'throw':
955
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
956
- case 'debug':
957
- // biome-ignore lint/suspicious/noDebugger: This is the whole point here
958
- debugger
959
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
960
- case 'warn':
961
- options.warn(
962
- `[reactive] ${message} (queued: ${queued.slice(0, 10).join(', ')}${queued.length > 10 ? ', …' : ''})`
963
- )
964
- break
965
- }
966
- }
967
- const rv = executeNext(effectuatedRoots)
968
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
969
- } else {
970
- // Process cleanups. If they trigger more effects, they will be caught in the next iteration.
971
- const cleanups = Array.from(batchCleanups)
972
- batchCleanups.clear()
973
- for (const cleanup of cleanups) cleanup()
974
-
975
- // In immediate mode, we traditionally don't process recursive effects from cleanups.
976
- // If we want to keep that behavior: if (immediate) break
977
- }
978
- }
979
- success = true
980
- } finally {
981
- if (!success) panicThrow()
982
- activationRegistry = undefined
983
- batchQueue = undefined
984
- batchCleanups.clear()
985
- optionCall('endChain')
986
- }
987
- return firstReturn.value
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
1016
- }
1017
-
1018
- export { reset as resetBatchQueueForTest }
1019
-
1020
- // Inject batch function to allow atomic game loops in requestAnimationFrame/setTimeout/...
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.
1026
-
1027
- /**
1028
- * Decorator that makes methods atomic - batches all effects triggered within the method
1029
- */
1030
- export const atomic = decorator({
1031
- method(original) {
1032
- return function (this: any, ...args: any[]) {
1033
- const atomicEffect = () => original.apply(this, args)
1034
- // Debug: helpful to have a name
1035
- Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1036
- return batch(atomicEffect as EffectTrigger, 'immediate')
1037
- }
1038
- },
1039
- default<Args extends any[], Return>(
1040
- original: (...args: Args) => Return
1041
- ): (...args: Args) => Return {
1042
- return function (this: any, ...args: Args) {
1043
- const atomicEffect = () => original.apply(this, args)
1044
- // Debug: helpful to have a name
1045
- Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1046
- return batch(atomicEffect as EffectTrigger, 'immediate')
1047
- }
1048
- },
1049
- })
1050
-
1051
- const fr = new FinalizationRegistry<() => void>((f) => f())
1052
-
1053
- /**
1054
- * @param fn - The effect function to run - provides the cleaner
1055
- * @returns The cleanup function
1056
- */
1057
- /**
1058
- * Creates a reactive effect that automatically re-runs when dependencies change
1059
- * @param fn - The effect function that provides dependencies and may return a cleanup function or Promise
1060
- * @param options - Options for effect execution
1061
- * @returns A cleanup function to stop the effect
1062
- */
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
- }
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
- 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
- })
1134
-
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
- }
1148
- }
1149
-
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
1172
- }
1173
-
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
- }
1196
- }
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++
1220
- }
1221
- }
1222
- if (parent) {
1223
- const parentNode = getEffectNode(parent)
1224
- if (parentNode.forwardThrow) parentNode.forwardThrow(error)
1225
- else throw error
1226
- }
1227
- else throw error
1228
- }
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
1242
- }
1243
- }
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,
1271
- },
1272
- }) as EffectCleanup
1273
- }
1274
-
1275
- // Register strict mode if enabled
1276
- if (effectOptions?.opaque) {
1277
- node.isOpaque = true
1278
- }
1279
-
1280
- if (isDevtoolsEnabled()) {
1281
- registerEffectForDebug(runEffect)
1282
- }
1283
-
1284
- // Store parent relationship for hierarchy traversal - ALREADY DONE ABOVE via getEffectNode
1285
-
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)
1310
- }
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)
1334
- stopEffect()
1335
- })
1336
- children.add(subEffectCleanup)
1337
- return subEffectCleanup
1338
- }
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
- ))
1351
-
1352
- /**
1353
- * Executes a function without tracking dependencies but maintains parent cleanup relationship
1354
- * Effects created inside will still be cleaned up when the parent effect is destroyed
1355
- * @param fn - The function to execute
1356
- */
1357
- export function untracked<T>(fn: () => T): T {
1358
- return effectHistory.present.root(fn)
1359
- }
1360
-
1361
- /**
1362
- * Executes a function from a virgin/root context - no parent effect, no tracking
1363
- * Creates completely independent effects that won't be cleaned up by any parent
1364
- * @param fn - The function to execute
1365
- */
1366
- export function root<T>(fn: () => T): T {
1367
- return effectHistory.root(fn)
1368
- }
1369
-
1370
- /**
1371
- * Creates a bidirectional binding between a reactive value and a non-reactive external value
1372
- * Prevents infinite loops by automatically suppressing circular notifications
1373
- *
1374
- * @param received - Function called when the reactive value changes (external setter)
1375
- * @param get - Getter for the reactive value OR an object with `{ get, set }` properties
1376
- * @param set - Setter for the reactive value (required if `get` is a function)
1377
- * @returns A function to manually provide updates from the external side
1378
- *
1379
- * @example
1380
- * ```typescript
1381
- * const model = reactive({ value: '' })
1382
- * const input = { value: '' }
1383
- *
1384
- * // Bidirectional binding
1385
- * const provide = biDi(
1386
- * (v) => input.value = v, // external setter
1387
- * () => model.value, // reactive getter
1388
- * (v) => model.value = v // reactive setter
1389
- * )
1390
- *
1391
- * // External notification (e.g., from input event)
1392
- * provide('new value') // Updates model.value, doesn't trigger circular loop
1393
- * ```
1394
- *
1395
- * @example Using object syntax
1396
- * ```typescript
1397
- * const provide = biDi(
1398
- * (v) => setHTMLValue(v),
1399
- * { get: () => reactiveObj.value, set: (v) => reactiveObj.value = v }
1400
- * )
1401
- * ```
1402
- */
1403
- export function biDi<T>(
1404
- received: (value: T) => void,
1405
- value: { get: () => T; set: (value: T) => void }
1406
- ): (value: T) => void
1407
- export function biDi<T>(
1408
- received: (value: T) => void,
1409
- get: () => T,
1410
- set: (value: T) => void
1411
- ): (value: T) => void
1412
- export function biDi<T>(
1413
- received: (value: T) => void,
1414
- get: (() => T) | { get: () => T; set: (value: T) => void },
1415
- set?: (value: T) => void
1416
- ): (value: T) => void {
1417
- if (typeof get !== 'function') {
1418
- set = get.set
1419
- get = get.get
1420
- }
1421
- let programmaticallySetValue: any = Symbol()
1422
- effect(
1423
- markWithRoot(() => {
1424
- const newValue = get()
1425
- if (unwrap(newValue) !== programmaticallySetValue) received(newValue)
1426
- }, received)
1427
- )
1428
- return set
1429
- ? atomic((value: T) => {
1430
- programmaticallySetValue = unwrap(value)
1431
- set(value)
1432
- })
1433
- : () => {}
1434
- }