mutts 1.0.8 → 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 (195) hide show
  1. package/README.md +77 -35
  2. package/dist/browser.cjs +2144 -5355
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.ts +2 -1655
  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 +20 -107
  11. package/dist/browser.esm.js.map +1 -1
  12. package/dist/chunks/{async-browser-CA0jPWIi.cjs → async-browser-BU_IfxYD.cjs} +19 -107
  13. package/dist/chunks/async-browser-BU_IfxYD.cjs.map +1 -0
  14. package/dist/chunks/{async-core-UqHzvJ-S.cjs → async-core-CRLKP3l-.cjs} +8 -4
  15. package/dist/chunks/async-core-CRLKP3l-.cjs.map +1 -0
  16. package/dist/chunks/{async-node-BYHuGTni.cjs → async-node-3PrbVAbB.cjs} +7 -12
  17. package/dist/chunks/async-node-3PrbVAbB.cjs.map +1 -0
  18. package/dist/chunks/index-CaaQQlPJ.esm.js +3174 -0
  19. package/dist/chunks/index-CaaQQlPJ.esm.js.map +1 -0
  20. package/dist/chunks/node-nKJBk8iJ.esm.js +96 -0
  21. package/dist/chunks/node-nKJBk8iJ.esm.js.map +1 -0
  22. package/dist/chunks/proxy-Dtg-bJ3T.cjs +3763 -0
  23. package/dist/chunks/proxy-Dtg-bJ3T.cjs.map +1 -0
  24. package/dist/chunks/proxy-r7lARftl.esm.js +3701 -0
  25. package/dist/chunks/proxy-r7lARftl.esm.js.map +1 -0
  26. package/dist/debug.cjs +980 -0
  27. package/dist/debug.cjs.map +1 -0
  28. package/dist/debug.d.ts +208 -0
  29. package/dist/debug.esm.js +950 -0
  30. package/dist/debug.esm.js.map +1 -0
  31. package/dist/devtools/manifest.json +1 -1
  32. package/dist/devtools/panel.html +1 -1
  33. package/dist/devtools/panel.js +107 -94
  34. package/dist/devtools/panel.js.map +1 -1
  35. package/dist/index.d.ts +1447 -13
  36. package/dist/mutts.umd.js +7167 -1
  37. package/dist/mutts.umd.js.map +1 -1
  38. package/dist/mutts.umd.min.js +1 -1
  39. package/dist/mutts.umd.min.js.map +1 -1
  40. package/dist/node.cjs +70 -66
  41. package/dist/node.cjs.map +1 -1
  42. package/dist/node.d.ts +2 -1
  43. package/dist/node.dev.cjs +110 -0
  44. package/dist/node.dev.cjs.map +1 -0
  45. package/dist/node.dev.d.ts +2 -0
  46. package/dist/node.dev.esm.js +6 -0
  47. package/dist/node.dev.esm.js.map +1 -0
  48. package/dist/node.esm.js +4 -103
  49. package/dist/node.esm.js.map +1 -1
  50. package/dist/{reactive/types.d.ts → types-W5vD6m2n.d.ts} +186 -93
  51. package/docs/ai/api-reference.md +12 -19
  52. package/docs/ai/manual.md +521 -17
  53. package/docs/events.md +57 -2
  54. package/docs/flavored.md +210 -0
  55. package/docs/reactive/advanced.md +314 -5
  56. package/docs/reactive/attend.md +142 -0
  57. package/docs/reactive/collections.md +76 -39
  58. package/docs/reactive/core.md +100 -14
  59. package/docs/reactive/debugging.md +123 -18
  60. package/docs/reactive/error-handling.md +121 -0
  61. package/docs/reactive/resource.md +125 -0
  62. package/docs/reactive/scan.md +247 -1
  63. package/docs/reactive.md +4 -1
  64. package/docs/std-decorators.md +102 -51
  65. package/docs/utils.md +70 -0
  66. package/docs/zone.md +3 -7
  67. package/package.json +64 -30
  68. package/dist/async/browser.d.ts +0 -2
  69. package/dist/async/browser.d.ts.map +0 -1
  70. package/dist/async/index.d.ts +0 -18
  71. package/dist/async/index.d.ts.map +0 -1
  72. package/dist/async/node.d.ts +0 -2
  73. package/dist/async/node.d.ts.map +0 -1
  74. package/dist/chunks/async-browser-CA0jPWIi.cjs.map +0 -1
  75. package/dist/chunks/async-core-UqHzvJ-S.cjs.map +0 -1
  76. package/dist/chunks/async-node-BYHuGTni.cjs.map +0 -1
  77. package/dist/chunks/index-DhaOVusv.esm.js +0 -6411
  78. package/dist/chunks/index-DhaOVusv.esm.js.map +0 -1
  79. package/dist/decorator.d.ts +0 -106
  80. package/dist/decorator.d.ts.map +0 -1
  81. package/dist/destroyable.d.ts +0 -87
  82. package/dist/destroyable.d.ts.map +0 -1
  83. package/dist/entry-browser.d.ts +0 -3
  84. package/dist/entry-browser.d.ts.map +0 -1
  85. package/dist/entry-node.d.ts +0 -3
  86. package/dist/entry-node.d.ts.map +0 -1
  87. package/dist/eventful.d.ts +0 -18
  88. package/dist/eventful.d.ts.map +0 -1
  89. package/dist/index.d.ts.map +0 -1
  90. package/dist/indexable.d.ts +0 -243
  91. package/dist/indexable.d.ts.map +0 -1
  92. package/dist/introspection.d.ts +0 -27
  93. package/dist/introspection.d.ts.map +0 -1
  94. package/dist/iterableWeak.d.ts +0 -53
  95. package/dist/iterableWeak.d.ts.map +0 -1
  96. package/dist/mixins.d.ts +0 -25
  97. package/dist/mixins.d.ts.map +0 -1
  98. package/dist/promiseChain.d.ts +0 -20
  99. package/dist/promiseChain.d.ts.map +0 -1
  100. package/dist/reactive/array.d.ts +0 -49
  101. package/dist/reactive/array.d.ts.map +0 -1
  102. package/dist/reactive/buffer.d.ts +0 -44
  103. package/dist/reactive/buffer.d.ts.map +0 -1
  104. package/dist/reactive/change.d.ts +0 -29
  105. package/dist/reactive/change.d.ts.map +0 -1
  106. package/dist/reactive/debug.d.ts +0 -111
  107. package/dist/reactive/debug.d.ts.map +0 -1
  108. package/dist/reactive/deep-touch.d.ts +0 -28
  109. package/dist/reactive/deep-touch.d.ts.map +0 -1
  110. package/dist/reactive/deep-watch-state.d.ts +0 -25
  111. package/dist/reactive/deep-watch-state.d.ts.map +0 -1
  112. package/dist/reactive/deep-watch.d.ts +0 -19
  113. package/dist/reactive/deep-watch.d.ts.map +0 -1
  114. package/dist/reactive/effect-context.d.ts +0 -7
  115. package/dist/reactive/effect-context.d.ts.map +0 -1
  116. package/dist/reactive/effects.d.ts +0 -151
  117. package/dist/reactive/effects.d.ts.map +0 -1
  118. package/dist/reactive/index.d.ts +0 -20
  119. package/dist/reactive/index.d.ts.map +0 -1
  120. package/dist/reactive/interface.d.ts +0 -64
  121. package/dist/reactive/interface.d.ts.map +0 -1
  122. package/dist/reactive/map.d.ts +0 -30
  123. package/dist/reactive/map.d.ts.map +0 -1
  124. package/dist/reactive/memoize.d.ts +0 -5
  125. package/dist/reactive/memoize.d.ts.map +0 -1
  126. package/dist/reactive/non-reactive-state.d.ts +0 -9
  127. package/dist/reactive/non-reactive-state.d.ts.map +0 -1
  128. package/dist/reactive/non-reactive.d.ts +0 -11
  129. package/dist/reactive/non-reactive.d.ts.map +0 -1
  130. package/dist/reactive/project.d.ts +0 -41
  131. package/dist/reactive/project.d.ts.map +0 -1
  132. package/dist/reactive/proxy-state.d.ts +0 -8
  133. package/dist/reactive/proxy-state.d.ts.map +0 -1
  134. package/dist/reactive/proxy.d.ts +0 -23
  135. package/dist/reactive/proxy.d.ts.map +0 -1
  136. package/dist/reactive/record.d.ts +0 -116
  137. package/dist/reactive/record.d.ts.map +0 -1
  138. package/dist/reactive/register.d.ts +0 -64
  139. package/dist/reactive/register.d.ts.map +0 -1
  140. package/dist/reactive/registry.d.ts +0 -20
  141. package/dist/reactive/registry.d.ts.map +0 -1
  142. package/dist/reactive/set.d.ts +0 -28
  143. package/dist/reactive/set.d.ts.map +0 -1
  144. package/dist/reactive/tracking.d.ts +0 -7
  145. package/dist/reactive/tracking.d.ts.map +0 -1
  146. package/dist/reactive/types.d.ts.map +0 -1
  147. package/dist/std-decorators.d.ts +0 -50
  148. package/dist/std-decorators.d.ts.map +0 -1
  149. package/dist/utils.d.ts +0 -49
  150. package/dist/utils.d.ts.map +0 -1
  151. package/dist/zone.d.ts +0 -40
  152. package/dist/zone.d.ts.map +0 -1
  153. package/docs/reactive/project.md +0 -93
  154. package/src/async/browser.ts +0 -319
  155. package/src/async/index.ts +0 -23
  156. package/src/async/node.ts +0 -104
  157. package/src/decorator.ts +0 -272
  158. package/src/destroyable.ts +0 -199
  159. package/src/entry-browser.ts +0 -5
  160. package/src/entry-node.ts +0 -5
  161. package/src/eventful.ts +0 -77
  162. package/src/index.d.ts +0 -12
  163. package/src/index.ts +0 -64
  164. package/src/indexable.ts +0 -526
  165. package/src/introspection.ts +0 -59
  166. package/src/iterableWeak.ts +0 -233
  167. package/src/mixins.ts +0 -123
  168. package/src/promiseChain.ts +0 -110
  169. package/src/reactive/array.ts +0 -507
  170. package/src/reactive/buffer.ts +0 -168
  171. package/src/reactive/change.ts +0 -138
  172. package/src/reactive/debug.ts +0 -517
  173. package/src/reactive/deep-touch.ts +0 -268
  174. package/src/reactive/deep-watch-state.ts +0 -82
  175. package/src/reactive/deep-watch.ts +0 -168
  176. package/src/reactive/effect-context.ts +0 -18
  177. package/src/reactive/effects.ts +0 -1382
  178. package/src/reactive/index.ts +0 -75
  179. package/src/reactive/interface.ts +0 -210
  180. package/src/reactive/map.ts +0 -158
  181. package/src/reactive/memoize.ts +0 -163
  182. package/src/reactive/non-reactive-state.ts +0 -49
  183. package/src/reactive/non-reactive.ts +0 -43
  184. package/src/reactive/project.ts +0 -453
  185. package/src/reactive/proxy-state.ts +0 -27
  186. package/src/reactive/proxy.ts +0 -264
  187. package/src/reactive/record.ts +0 -196
  188. package/src/reactive/register.ts +0 -419
  189. package/src/reactive/registry.ts +0 -59
  190. package/src/reactive/set.ts +0 -130
  191. package/src/reactive/tracking.ts +0 -44
  192. package/src/reactive/types.ts +0 -440
  193. package/src/std-decorators.ts +0 -217
  194. package/src/utils.ts +0 -301
  195. package/src/zone.ts +0 -138
@@ -1,1382 +0,0 @@
1
- import { decorator } from '../decorator'
2
- import { IterableWeakSet } from '../iterableWeak'
3
- import { getTriggerChain, isDevtoolsEnabled, registerEffectForDebug } from './debug'
4
- import {
5
- effectAggregator,
6
- effectHistory,
7
- getActiveEffect,
8
- } from './effect-context'
9
- import {
10
- effectChildren,
11
- effectParent,
12
- effectToReactiveObjects,
13
- getRoot,
14
- markWithRoot,
15
- watchers,
16
- } from './registry'
17
- import {
18
- cleanup as cleanupSymbol,
19
- type DependencyAccess,
20
- type EffectOptions,
21
- type Evolution,
22
- options,
23
- ReactiveError,
24
- ReactiveErrorCode,
25
- // type AsyncExecutionMode,
26
- type ScopedCallback,
27
- stopped,
28
- } from './types'
29
-
30
- import { unwrap } from './proxy-state'
31
-
32
- /**
33
- * Finds a cycle in a sequence of functions by looking for the first repetition
34
- */
35
- function findCycleInChain(roots: Function[]): Function[] | null {
36
- const seen = new Map<Function, number>()
37
- for (let i = 0; i < roots.length; i++) {
38
- const root = roots[i]
39
- if (seen.has(root)) {
40
- return roots.slice(seen.get(root)!)
41
- }
42
- seen.set(root, i)
43
- }
44
- return null
45
- }
46
-
47
- /**
48
- * Formats a list of function roots into a readable trace
49
- */
50
- function formatRoots(roots: Function[], limit = 20): string {
51
- const names = roots.map((r) => r.name || '<anonymous>')
52
- if (names.length <= limit) return names.join(' → ')
53
- const start = names.slice(0, 5)
54
- const end = names.slice(-10)
55
- return `${start.join(' → ')} ... (${names.length - 15} more) ... ${end.join(' → ')}`
56
- }
57
-
58
- type EffectTracking = (obj: any, evolution: Evolution, prop: any) => void
59
-
60
- export interface ActivationRecord {
61
- effect: ScopedCallback
62
- obj: any
63
- evolution: Evolution
64
- prop: any
65
- batchId: number
66
- }
67
-
68
- // Nested map structure for efficient counting and batch cleanup
69
- // batchId -> effect root -> obj -> prop -> count
70
- let activationRegistry: Map<Function, Map<any, Map<any, number>>> | undefined
71
-
72
- export const activationLog: Omit<ActivationRecord, 'batchId'>[] = new Array(100)
73
-
74
- export function getActivationLog() {
75
- return activationLog
76
- }
77
-
78
- export function recordActivation(
79
- effect: ScopedCallback,
80
- obj: any,
81
- evolution: Evolution,
82
- prop: any
83
- ) {
84
- const root = getRoot(effect)
85
-
86
- if (!activationRegistry) return
87
- let effectData = activationRegistry.get(root)
88
- if (!effectData) {
89
- effectData = new Map()
90
- activationRegistry.set(root, effectData)
91
- }
92
- let objData = effectData.get(obj)
93
- if (!objData) {
94
- objData = new Map()
95
- effectData.set(obj, objData)
96
- }
97
- const count = (objData.get(prop) ?? 0) + 1
98
- objData.set(prop, count)
99
-
100
- // Keep a limited history for diagnostics
101
- activationLog.unshift({
102
- effect,
103
- obj,
104
- evolution,
105
- prop,
106
- })
107
- activationLog.pop()
108
-
109
- if (count >= options.maxTriggerPerBatch) {
110
- const effectName = (root as any)?.name || 'anonymous'
111
- const message = `Aggressive trigger detected: effect "${effectName}" triggered ${count} times in the batch by the same cause.`
112
- if (options.maxEffectReaction === 'throw') {
113
- throw new ReactiveError(message, {
114
- code: ReactiveErrorCode.MaxReactionExceeded,
115
- count,
116
- effect: effectName,
117
- })
118
- }
119
- options.warn(`[reactive] ${message}`)
120
- }
121
- }
122
-
123
- /**
124
- * Registers a debug callback that is called when the current effect is triggered by a dependency change
125
- *
126
- * This function is useful for debugging purposes as it pin-points exactly which reactive property
127
- * change triggered the effect. The callback receives information about:
128
- * - The object that changed
129
- * - The type of change (evolution)
130
- * - The specific property that changed
131
- *
132
- * **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.
134
- *
135
- * @param onTouch - Callback function that receives (obj, evolution, prop) when the effect is triggered
136
- * @throws {Error} If called outside of an effect context
137
- *
138
- * @example
139
- * ```typescript
140
- * const state = reactive({ count: 0, name: 'John' })
141
- *
142
- * effect(() => {
143
- * // Register a tracker to see what triggers this effect
144
- * trackEffect((obj, evolution, prop) => {
145
- * console.log(`Effect triggered by:`, {
146
- * object: obj,
147
- * change: evolution.type,
148
- * property: prop
149
- * })
150
- * })
151
- *
152
- * // Access reactive properties
153
- * console.log(state.count, state.name)
154
- * })
155
- *
156
- * state.count = 5
157
- * // Logs: Effect triggered by: { object: state, change: 'set', property: 'count' }
158
- * ```
159
- */
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)
165
- }
166
-
167
- const effectTrackers = new WeakMap<ScopedCallback, Set<EffectTracking>>()
168
-
169
- export const opaqueEffects = new WeakSet<ScopedCallback>()
170
-
171
- // Dependency graph: tracks which effects trigger which other effects
172
- // Uses roots (Function) as keys for consistency
173
- const effectTriggers = new WeakMap<Function, IterableWeakSet<Function>>()
174
- const effectTriggeredBy = new WeakMap<Function, IterableWeakSet<Function>>()
175
-
176
- // Transitive closures: track all indirect relationships
177
- // causesClosure: for each effect, all effects that trigger it (directly or indirectly)
178
- // 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>>()
181
-
182
- // Debug: Capture where an effect was created
183
- export const effectCreationStacks = new WeakMap<Function, string>()
184
-
185
- /**
186
- * Gets or creates an IterableWeakSet for a closure map
187
- */
188
- function getOrCreateClosure(
189
- closure: WeakMap<Function, IterableWeakSet<Function>>,
190
- root: Function
191
- ): IterableWeakSet<Function> {
192
- let set = closure.get(root)
193
- if (!set) {
194
- set = new IterableWeakSet()
195
- closure.set(root, set)
196
- }
197
- return set
198
- }
199
-
200
- /**
201
- * Adds an edge to the dependency graph: callerRoot → targetRoot
202
- * Also maintains transitive closures
203
- * @param callerRoot - Root function of the effect that triggers
204
- * @param targetRoot - Root function of the effect being triggered
205
- */
206
- function addGraphEdge(callerRoot: Function, targetRoot: Function) {
207
- if (options.cycleHandling === 'none') return
208
- // Skip if edge already exists
209
- const triggers = effectTriggers.get(callerRoot)
210
- if (triggers?.has(targetRoot)) {
211
- return // Edge already exists
212
- }
213
-
214
- // Add to forward graph: callerRoot → targetRoot
215
- if (!triggers) {
216
- const newTriggers = new IterableWeakSet<Function>()
217
- newTriggers.add(targetRoot)
218
- effectTriggers.set(callerRoot, newTriggers)
219
- } else {
220
- triggers.add(targetRoot)
221
- }
222
-
223
- // Add to reverse graph: targetRoot ← callerRoot
224
- let triggeredBy = effectTriggeredBy.get(targetRoot)
225
- if (!triggeredBy) {
226
- triggeredBy = new IterableWeakSet()
227
- effectTriggeredBy.set(targetRoot, triggeredBy)
228
- }
229
- triggeredBy.add(callerRoot)
230
-
231
- // Update transitive closures
232
- // When U→V is added, we need to propagate the relationship:
233
- // 1. Add U to causesClosure(V) and V to consequencesClosure(U) (direct relationship)
234
- // 2. For each X in causesClosure(U): add V to consequencesClosure(X) and X to causesClosure(V)
235
- // 3. For each Y in consequencesClosure(V): add U to causesClosure(Y) and Y to consequencesClosure(U)
236
- // Note: Self-loops (U→U) are not added to closures - if an effect appears in its own closure,
237
- // it means there's an indirect cycle that should be detected
238
-
239
- // Self-loops are explicitly ignored - an effect reading and writing the same property
240
- // (e.g., obj.prop++) should not create a dependency relationship or appear in closures
241
- if (callerRoot === targetRoot) {
242
- return
243
- }
244
-
245
- const uConsequences = getOrCreateClosure(consequencesClosure, callerRoot)
246
- const vCauses = getOrCreateClosure(causesClosure, targetRoot)
247
-
248
- // 1. Add direct relationship
249
- uConsequences.add(targetRoot)
250
- vCauses.add(callerRoot)
251
-
252
- // 2. For each X in causesClosure(U): X→U→V means X→V
253
- const uCausesSet = causesClosure.get(callerRoot)
254
- if (uCausesSet) {
255
- for (const x of uCausesSet) {
256
- // Skip if this would create a self-loop
257
- if (x === targetRoot) continue
258
- const xConsequences = getOrCreateClosure(consequencesClosure, x)
259
- xConsequences.add(targetRoot)
260
- vCauses.add(x)
261
- }
262
- }
263
-
264
- // 3. For each Y in consequencesClosure(V): U→V→Y means U→Y
265
- const vConsequencesSet = consequencesClosure.get(targetRoot)
266
- if (vConsequencesSet) {
267
- for (const y of vConsequencesSet) {
268
- // Skip if this would create a self-loop
269
- if (y === callerRoot) continue
270
- const yCauses = getOrCreateClosure(causesClosure, y)
271
- yCauses.add(callerRoot)
272
- uConsequences.add(y)
273
- }
274
- }
275
-
276
- // 4. Cross-product: for each X in causesClosure(U) and Y in consequencesClosure(V): X→Y
277
- if (uCausesSet && vConsequencesSet) {
278
- for (const x of uCausesSet) {
279
- const xConsequences = getOrCreateClosure(consequencesClosure, x)
280
- for (const y of vConsequencesSet) {
281
- // Skip if this would create a self-loop
282
- if (x === y) continue
283
- xConsequences.add(y)
284
- const yCauses = getOrCreateClosure(causesClosure, y)
285
- yCauses.add(x)
286
- }
287
- }
288
- }
289
- }
290
-
291
- /**
292
- * Checks if there's a path from start to end in the dependency graph, excluding a specific node
293
- * Uses BFS to find any path that doesn't go through the excluded node
294
- * @param start - Starting node
295
- * @param end - Target node
296
- * @param exclude - Node to exclude from the path
297
- * @returns true if a path exists without going through the excluded node
298
- * @todo Can be REALLY costly - optimise or make optional or ...
299
- */
300
- function hasPathExcluding(start: Function, end: Function, exclude: Function): boolean {
301
- if (start === end) return true
302
- if (start === exclude) return false
303
-
304
- const visited = new Set<Function>()
305
- const queue: Function[] = [start]
306
- visited.add(start)
307
- visited.add(exclude) // Pre-mark excluded node as visited to skip it
308
-
309
- while (queue.length > 0) {
310
- const current = queue.shift()!
311
- const triggers = effectTriggers.get(current)
312
- if (!triggers) continue
313
-
314
- for (const next of triggers) {
315
- if (next === end) return true
316
- if (!visited.has(next)) {
317
- visited.add(next)
318
- queue.push(next)
319
- }
320
- }
321
- }
322
-
323
- return false
324
- }
325
-
326
- /**
327
- * Removes all edges involving the given effect from the dependency graph
328
- * Also cleans up transitive closures by propagating cleanup to all affected effects
329
- * Called when an effect is stopped/cleaned up
330
- * @param effect - The effect being cleaned up
331
- */
332
- function cleanupEffectFromGraph(effect: ScopedCallback) {
333
- if (options.cycleHandling === 'none') return
334
- const root = getRoot(effect)
335
-
336
- // Get closures before removing direct edges (needed for propagation)
337
- const rootCauses = causesClosure.get(root)
338
- const rootConsequences = consequencesClosure.get(root)
339
-
340
- // Remove from effectTriggers (outgoing edges)
341
- const triggers = effectTriggers.get(root)
342
- if (triggers) {
343
- // Remove this root from all targets' effectTriggeredBy sets
344
- for (const targetRoot of triggers) {
345
- const triggeredBy = effectTriggeredBy.get(targetRoot)
346
- triggeredBy?.delete(root)
347
- }
348
- effectTriggers.delete(root)
349
- }
350
-
351
- // Remove from effectTriggeredBy (incoming edges)
352
- const triggeredBy = effectTriggeredBy.get(root)
353
- if (triggeredBy) {
354
- // Remove this root from all sources' effectTriggers sets
355
- for (const sourceRoot of triggeredBy) {
356
- const triggers = effectTriggers.get(sourceRoot)
357
- triggers?.delete(root)
358
- }
359
- effectTriggeredBy.delete(root)
360
- }
361
-
362
- // Propagate closure cleanup to all affected effects
363
- // When removing B from A → B → C:
364
- // - Remove B from causesClosure(C) and consequencesClosure(A)
365
- // - For each X in causesClosure(B): remove C from consequencesClosure(X) if B was the only path
366
- // - For each Y in consequencesClosure(B): remove A from causesClosure(Y) if B was the only path
367
- // - Remove transitive relationships that depended on B
368
-
369
- if (rootCauses) {
370
- // For each X that triggers root: remove root from X's consequences
371
- // Only remove root's consequences if no alternate path exists
372
- for (const causeRoot of rootCauses) {
373
- const causeConsequences = consequencesClosure.get(causeRoot)
374
- if (causeConsequences) {
375
- // Remove root itself (it's being cleaned up)
376
- causeConsequences.delete(root)
377
- // Only remove consequences of root if there's no alternate path from causeRoot to them
378
- if (rootConsequences) {
379
- for (const consequence of rootConsequences) {
380
- // Check if causeRoot can still reach consequence without going through root
381
- if (!hasPathExcluding(causeRoot, consequence, root)) {
382
- causeConsequences.delete(consequence)
383
- }
384
- }
385
- }
386
- }
387
- }
388
- }
389
-
390
- if (rootConsequences) {
391
- // For each Y that root triggers: remove root from Y's causes
392
- // Only remove root's causes if no alternate path exists
393
- for (const consequenceRoot of rootConsequences) {
394
- const consequenceCauses = causesClosure.get(consequenceRoot)
395
- if (consequenceCauses) {
396
- // Remove root itself (it's being cleaned up)
397
- consequenceCauses.delete(root)
398
- // Only remove causes of root if there's no alternate path from them to consequenceRoot
399
- if (rootCauses) {
400
- for (const cause of rootCauses) {
401
- // Check if cause can still reach consequenceRoot without going through root
402
- if (!hasPathExcluding(cause, consequenceRoot, root)) {
403
- consequenceCauses.delete(cause)
404
- }
405
- }
406
- }
407
- }
408
- }
409
- }
410
-
411
- // Cross-product cleanup: for each X in causesClosure(B) and Y in consequencesClosure(B),
412
- // remove X→Y if B was the only path connecting them
413
- if (rootCauses && rootConsequences) {
414
- for (const x of rootCauses) {
415
- const xConsequences = consequencesClosure.get(x)
416
- if (xConsequences) {
417
- for (const y of rootConsequences) {
418
- // Check if there's still a path from X to Y without going through root
419
- // Use BFS to find any path that doesn't include root
420
- if (!hasPathExcluding(x, y, root)) {
421
- xConsequences.delete(y)
422
- const yCauses = causesClosure.get(y)
423
- yCauses?.delete(x)
424
- }
425
- }
426
- }
427
- }
428
- }
429
-
430
- // Finally, delete the closures for this effect
431
- causesClosure.delete(root)
432
- consequencesClosure.delete(root)
433
- }
434
-
435
- // Batch queue structure - optimized with cached in-degrees
436
- interface BatchQueue {
437
- // All effects in the current batch that still need to be executed (todos)
438
- all: Map<Function, ScopedCallback> // root → effect
439
- // Cached in-degrees for each effect in the batch (number of causes in batch)
440
- inDegrees: Map<Function, number> // root → in-degree count
441
- }
442
-
443
- // Track currently executing effects to prevent re-execution
444
- // These are all the effects triggered under `activeEffect`
445
- let batchQueue: BatchQueue | undefined
446
- export function hasBatched(effect: ScopedCallback) {
447
- return batchQueue?.all.has(getRoot(effect))
448
- }
449
- const batchCleanups = new Set<ScopedCallback>()
450
-
451
- /**
452
- * Computes and caches in-degrees for all effects in the batch
453
- * Called once when batch starts or when new effects are added
454
- */
455
- function computeAllInDegrees(batch: BatchQueue): void {
456
- if (options.cycleHandling === 'none') return
457
- const activeEffect = getActiveEffect()
458
- const activeRoot = activeEffect ? getRoot(activeEffect) : null
459
-
460
- // Reset all in-degrees
461
- batch.inDegrees.clear()
462
-
463
- for (const [root] of batch.all) {
464
- let inDegree = 0
465
- const causes = causesClosure.get(root)
466
- if (causes) {
467
- for (const causeRoot of causes) {
468
- // Only count if it's in the batch and not the active/self effect
469
- if (batch.all.has(causeRoot) && causeRoot !== activeRoot && causeRoot !== root) {
470
- inDegree++
471
- }
472
- }
473
- }
474
- batch.inDegrees.set(root, inDegree)
475
- }
476
- }
477
-
478
- /**
479
- * Decrements in-degrees of all effects that depend on the executed effect
480
- * Called after an effect is executed to update the cached in-degrees
481
- */
482
- function decrementInDegreesForExecuted(batch: BatchQueue, executedRoot: Function): void {
483
- // Get all effects that this executed effect triggers
484
- const consequences = consequencesClosure.get(executedRoot)
485
- if (!consequences) return
486
-
487
- for (const consequenceRoot of consequences) {
488
- // Only update if it's still in the batch
489
- if (batch.all.has(consequenceRoot)) {
490
- const currentDegree = batch.inDegrees.get(consequenceRoot) ?? 0
491
- if (currentDegree > 0) {
492
- batch.inDegrees.set(consequenceRoot, currentDegree - 1)
493
- }
494
- }
495
- }
496
- }
497
-
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
- /**
537
- * Finds a path from startRoot to endRoot in the dependency graph
538
- * Uses DFS to find the path through direct edges
539
- * @param startRoot - Starting effect root
540
- * @param endRoot - Target effect root
541
- * @param visited - Set of visited nodes (for recursion)
542
- * @param path - Current path being explored
543
- * @returns Path from startRoot to endRoot, or empty array if no path exists
544
- */
545
- function findPath(
546
- startRoot: Function,
547
- endRoot: Function,
548
- visited: Set<Function> = new Set(),
549
- path: Function[] = []
550
- ): Function[] {
551
- if (startRoot === endRoot) {
552
- return [...path, endRoot]
553
- }
554
-
555
- if (visited.has(startRoot)) {
556
- return []
557
- }
558
-
559
- visited.add(startRoot)
560
- const newPath = [...path, startRoot]
561
-
562
- const triggers = effectTriggers.get(startRoot)
563
- if (triggers) {
564
- for (const targetRoot of triggers) {
565
- const result = findPath(targetRoot, endRoot, visited, newPath)
566
- if (result.length > 0) {
567
- return result
568
- }
569
- }
570
- }
571
-
572
- return []
573
- }
574
-
575
- /**
576
- * Gets the cycle path when adding an edge would create a cycle
577
- * @param callerRoot - Root of the effect that triggers
578
- * @param targetRoot - Root of the effect being triggered
579
- * @returns Array of effect roots forming the cycle, or empty array if no cycle
580
- */
581
- function getCyclePathForEdge(callerRoot: Function, targetRoot: Function): Function[] {
582
- // Find path from targetRoot back to callerRoot (this is the existing path)
583
- // Then adding callerRoot -> targetRoot completes the cycle
584
- const path = findPath(targetRoot, callerRoot)
585
- if (path.length > 0) {
586
- // The cycle is: callerRoot -> targetRoot -> ... -> callerRoot
587
- return [callerRoot, ...path]
588
- }
589
- return []
590
- }
591
-
592
- /**
593
- * Checks if adding an edge would create a cycle
594
- * Uses causesClosure to check if callerRoot is already a cause of targetRoot
595
- * Self-loops (callerRoot === targetRoot) are explicitly ignored and return false
596
- *
597
- * **Note**: This is the primary optimization benefit of the transitive closure system.
598
- * It allows detecting cycles in O(1) time before they are executed.
599
- *
600
- * @param callerRoot - Root of the effect that triggers
601
- * @param targetRoot - Root of the effect being triggered
602
- * @returns true if adding this edge would create a cycle
603
- */
604
- function wouldCreateCycle(callerRoot: Function, targetRoot: Function): boolean {
605
- // Self-loops are explicitly ignored - an effect reading and writing the same property
606
- // (e.g., obj.prop++) should not create a dependency relationship
607
- if (callerRoot === targetRoot) {
608
- return false
609
- }
610
-
611
- // Check if targetRoot already triggers callerRoot (directly or indirectly)
612
- // This would create a cycle: callerRoot -> targetRoot -> ... -> callerRoot
613
- // Using consequencesClosure: if targetRoot triggers callerRoot, then callerRoot is in consequencesClosure(targetRoot)
614
- const targetConsequences = consequencesClosure.get(targetRoot)
615
- if (targetConsequences?.has(callerRoot)) {
616
- return true // Cycle detected: targetRoot -> ... -> callerRoot, and we're adding callerRoot -> targetRoot
617
- }
618
-
619
- return false
620
- }
621
-
622
- /**
623
- * Adds an effect to the batch queue
624
- * @param effect - The effect to add
625
- * @param caller - The active effect that triggered this one (optional)
626
- * @param immediate - If true, don't create edges in the dependency graph
627
- */
628
- function addToBatch(effect: ScopedCallback, caller?: ScopedCallback, immediate?: boolean) {
629
- (effect as any)[cleanupSymbol]?.()
630
- // 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
632
-
633
- if (!batchQueue) return
634
-
635
- const root = getRoot(effect)
636
-
637
- // 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)
641
- }
642
-
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') {
648
- const callerRoot = getRoot(caller)
649
-
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
- if (wouldCreateCycle(callerRoot, root)) {
654
- // Cycle detected! Get the full cycle path for debugging
655
- const cyclePath = getCyclePathForEdge(callerRoot, root)
656
- const cycleMessage =
657
- cyclePath.length > 0
658
- ? `Cycle detected: ${cyclePath.map((r) => r.name || r.toString()).join(' → ')}`
659
- : `Cycle detected: ${callerRoot.name || callerRoot.toString()} → ${root.name || root.toString()} (and back)`
660
-
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
- }
677
-
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
- }
703
- }
704
-
705
- addGraphEdge(callerRoot, root) // Add to persistent graph using roots
706
- }
707
- }
708
-
709
- /**
710
- * Adds a cleanup function to be called when the current batch of effects completes
711
- * @param cleanup - The cleanup function to add
712
- */
713
- export function addBatchCleanup(cleanup: ScopedCallback) {
714
- if (!batchQueue) cleanup()
715
- else batchCleanups.add(cleanup)
716
- }
717
-
718
- /**
719
- * Semantic alias for `addBatchCleanup` - defers work to the end of the current reactive batch.
720
- *
721
- * Use this when an effect needs to perform an action that would modify state the effect depends on,
722
- * which would create a reactive cycle. The deferred callback runs after all effects complete.
723
- *
724
- * @param callback - The callback to defer until after the current batch completes
725
- *
726
- * @example
727
- * ```typescript
728
- * effect(() => {
729
- * processData()
730
- *
731
- * // Defer to avoid cycle (createMovement modifies state this effect reads)
732
- * defer(() => {
733
- * createMovement(data)
734
- * })
735
- * })
736
- * ```
737
- */
738
- export const defer = addBatchCleanup
739
-
740
- /**
741
- * Gets a cycle path for debugging
742
- * Uses DFS to find cycles in the batch
743
- * @param batchQueue - The batch queue
744
- * @returns Array of effect roots forming a cycle
745
- */
746
- function getCyclePath(batchQueue: BatchQueue): Function[] {
747
- // If all effects have in-degree > 0, there must be a cycle
748
- // Use DFS to find it
749
- const visited = new Set<Function>()
750
- const recursionStack = new Set<Function>()
751
- const path: Function[] = []
752
-
753
- for (const [root] of batchQueue.all) {
754
- if (visited.has(root)) continue
755
- const cycle = findCycle(root, visited, recursionStack, path, batchQueue)
756
- if (cycle.length > 0) {
757
- return cycle
758
- }
759
- }
760
-
761
- return []
762
- }
763
-
764
- function findCycle(
765
- root: Function,
766
- visited: Set<Function>,
767
- recursionStack: Set<Function>,
768
- path: Function[],
769
- batchQueue: BatchQueue
770
- ): Function[] {
771
- if (recursionStack.has(root)) {
772
- // Found a cycle! Return the path from the cycle start to root
773
- const cycleStart = path.indexOf(root)
774
- return path.slice(cycleStart).concat([root])
775
- }
776
-
777
- if (visited.has(root)) {
778
- return []
779
- }
780
-
781
- visited.add(root)
782
- recursionStack.add(root)
783
- path.push(root)
784
-
785
- // Follow edges to effects in the batch
786
- // Use direct edges (effectTriggers) for cycle detection
787
- const triggers = effectTriggers.get(root)
788
- if (triggers) {
789
- for (const targetRoot of triggers) {
790
- if (batchQueue.all.has(targetRoot)) {
791
- const cycle = findCycle(targetRoot, visited, recursionStack, path, batchQueue)
792
- if (cycle.length > 0) {
793
- return cycle
794
- }
795
- }
796
- }
797
- }
798
-
799
- path.pop()
800
- recursionStack.delete(root)
801
- return []
802
- }
803
-
804
- /**
805
- * Executes the next effect in dependency order (using cached in-degrees)
806
- * Finds an effect with in-degree 0 and executes it
807
- * @returns The return value of the executed effect, or null if batch is complete
808
- */
809
- function executeNext(effectuatedRoots: Function[]): any {
810
- // Find an effect with in-degree 0 using cached values
811
- let nextEffect: ScopedCallback | null = null
812
- let nextRoot: Function | null = null
813
-
814
- if (options.cycleHandling === 'none') {
815
- // In flat mode, we just take the first effect in the queue (FIFO)
816
- const first = batchQueue!.all.entries().next().value
817
- if (first) {
818
- ;[nextRoot, nextEffect] = first
819
- }
820
- } else {
821
- // Find an effect with in-degree 0 (no dependencies in batch that still need execution)
822
- // Using cached in-degrees for O(n) lookup instead of O(n²)
823
- for (const [root, effect] of batchQueue!.all) {
824
- const inDegree = batchQueue!.inDegrees.get(root) ?? 0
825
- if (inDegree === 0) {
826
- nextEffect = effect
827
- nextRoot = root
828
- break
829
- }
830
- }
831
- }
832
-
833
- if (!nextEffect) {
834
- // No effect with in-degree 0 - there must be a cycle
835
- // If all effects have dependencies, it means there's a circular dependency
836
- if (batchQueue!.all.size > 0) {
837
- let cycle = getCyclePath(batchQueue!)
838
- // If we couldn't find a cycle path using direct edges, try using closures
839
- // (transitive relationships) - if all effects have in-degree > 0, there must be a cycle
840
- if (cycle.length === 0) {
841
- // Try to find a cycle using consequencesClosure (transitive relationships)
842
- // Note: Self-loops are ignored - we only look for cycles between different effects
843
- for (const [root] of batchQueue!.all) {
844
- const consequences = consequencesClosure.get(root)
845
- if (consequences) {
846
- // Check if any consequence in the batch also has root as a consequence
847
- for (const consequence of consequences) {
848
- // Skip self-loops - they are ignored
849
- if (consequence === root) continue
850
- if (batchQueue!.all.has(consequence)) {
851
- const consequenceConsequences = consequencesClosure.get(consequence)
852
- if (consequenceConsequences?.has(root)) {
853
- // Found cycle: root -> consequence -> root
854
- cycle = [root, consequence, root]
855
- break
856
- }
857
- }
858
- }
859
- if (cycle.length > 0) break
860
- }
861
- }
862
- }
863
- const cycleMessage =
864
- cycle.length > 0
865
- ? `Cycle detected: ${cycle.map((r) => r.name || '<anonymous>').join(' → ')}`
866
- : 'Cycle detected in effect batch - all effects have dependencies that prevent execution'
867
-
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
- }
896
- }
897
- return null // Batch complete
898
- }
899
-
900
- effectuatedRoots.push(getRoot(nextEffect))
901
- // Execute the effect
902
- const result = nextEffect()
903
-
904
- // Remove from batch and update in-degrees of dependents
905
- batchQueue!.all.delete(nextRoot!)
906
- batchQueue!.inDegrees.delete(nextRoot!)
907
- decrementInDegreesForExecuted(batchQueue!, nextRoot!)
908
-
909
- return result
910
- }
911
-
912
- // Track which sub-effects have been executed to prevent infinite loops
913
- // These are all the effects triggered under `activeEffect` and all their sub-effects
914
- export function batch(effect: ScopedCallback | ScopedCallback[], immediate?: 'immediate') {
915
- if (!Array.isArray(effect)) effect = [effect]
916
- const roots = effect.map(getRoot)
917
-
918
- if (batchQueue) {
919
- // Nested batch - add to existing
920
- options?.chain(roots, getRoot(getActiveEffect()))
921
- const caller = getActiveEffect()
922
- for (let i = 0; i < effect.length; i++)
923
- addToBatch(effect[i], caller, immediate === 'immediate')
924
- if (immediate) {
925
- const firstReturn: { value?: any } = {}
926
- // Execute immediately (before batch returns)
927
- for (let i = 0; i < effect.length; i++) {
928
- try {
929
- const rv = effect[i]()
930
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
931
- } finally {
932
- const root = getRoot(effect[i])
933
- batchQueue.all.delete(root)
934
- }
935
- }
936
- return firstReturn.value
937
- }
938
- // Otherwise, effects will be picked up in next executeNext() call
939
- } else {
940
- // New batch - initialize
941
- if (!activationRegistry) activationRegistry = new Map()
942
- else throw new Error('Batch already in progress')
943
- options.beginChain(roots)
944
- batchQueue = {
945
- all: new Map(),
946
- inDegrees: new Map(),
947
- }
948
-
949
- const caller = getActiveEffect()
950
- const effectuatedRoots: ScopedCallback[] = []
951
- const firstReturn: { value?: any } = {}
952
-
953
- try {
954
- if (immediate) {
955
- // Execute initial effects in providing order
956
- for (let i = 0; i < effect.length; i++) {
957
- try {
958
- const rv = effect[i]()
959
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
960
- } finally {
961
- batchQueue.all.delete(getRoot(effect[i]))
962
- }
963
- }
964
- } else {
965
- // Add initial effects to batch and compute dependencies
966
- for (let i = 0; i < effect.length; i++) addToBatch(effect[i], caller, false)
967
- computeAllInDegrees(batchQueue)
968
- }
969
-
970
- // Processing loop for all triggered effects and cleanups
971
- while (batchQueue.all.size > 0 || batchCleanups.size > 0) {
972
- if (batchQueue.all.size > 0) {
973
- if (effectuatedRoots.length > options.maxEffectChain) {
974
- const cycle = findCycleInChain(effectuatedRoots as any)
975
- const trace = formatRoots(effectuatedRoots as any)
976
- const message = cycle
977
- ? `Max effect chain reached (cycle detected: ${formatRoots(cycle)})`
978
- : `Max effect chain reached (trace: ${trace})`
979
-
980
- const queuedRoots = batchQueue ? Array.from(batchQueue.all.keys()) : []
981
- const queued = queuedRoots.map((r) => r.name || '<anonymous>')
982
- const debugInfo = {
983
- code: ReactiveErrorCode.MaxDepthExceeded,
984
- effectuatedRoots,
985
- cycle,
986
- trace,
987
- maxEffectChain: options.maxEffectChain,
988
- queued: queued.slice(0, 50),
989
- queuedCount: queued.length,
990
- // Try to get causation for the last effect
991
- causalChain:
992
- effectuatedRoots.length > 0
993
- ? getTriggerChain(
994
- batchQueue.all.get(effectuatedRoots[effectuatedRoots.length - 1])!
995
- )
996
- : [],
997
- }
998
- switch (options.maxEffectReaction) {
999
- case 'throw':
1000
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
1001
- case 'debug':
1002
- // biome-ignore lint/suspicious/noDebugger: This is the whole point here
1003
- debugger
1004
- throw new ReactiveError(`[reactive] ${message}`, debugInfo)
1005
- case 'warn':
1006
- options.warn(
1007
- `[reactive] ${message} (queued: ${queued.slice(0, 10).join(', ')}${queued.length > 10 ? ', …' : ''})`
1008
- )
1009
- break
1010
- }
1011
- }
1012
- const rv = executeNext(effectuatedRoots)
1013
- if (rv !== undefined && !('value' in firstReturn)) firstReturn.value = rv
1014
- } else {
1015
- // Process cleanups. If they trigger more effects, they will be caught in the next iteration.
1016
- const cleanups = Array.from(batchCleanups)
1017
- batchCleanups.clear()
1018
- for (const cleanup of cleanups) cleanup()
1019
-
1020
- // In immediate mode, we traditionally don't process recursive effects from cleanups.
1021
- // If we want to keep that behavior: if (immediate) break
1022
- }
1023
- }
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 })
1029
- } finally {
1030
- activationRegistry = undefined
1031
- batchQueue = undefined
1032
- options.endChain()
1033
- }
1034
- }
1035
- }
1036
-
1037
- // 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'))
1040
-
1041
- /**
1042
- * Decorator that makes methods atomic - batches all effects triggered within the method
1043
- */
1044
- export const atomic = decorator({
1045
- method(original) {
1046
- return function (this: any, ...args: any[]) {
1047
- const atomicEffect = () => original.apply(this, args)
1048
- // Debug: helpful to have a name
1049
- Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1050
- return batch(atomicEffect, 'immediate')
1051
- }
1052
- },
1053
- default<Args extends any[], Return>(
1054
- original: (...args: Args) => Return
1055
- ): (...args: Args) => Return {
1056
- return function (this: any, ...args: Args) {
1057
- const atomicEffect = () => original.apply(this, args)
1058
- // Debug: helpful to have a name
1059
- Object.defineProperty(atomicEffect, 'name', { value: `atomic(${original.name})` })
1060
- return batch(atomicEffect, 'immediate')
1061
- }
1062
- },
1063
- })
1064
-
1065
- const fr = new FinalizationRegistry<() => void>((f) => f())
1066
-
1067
- /**
1068
- * @param fn - The effect function to run - provides the cleaner
1069
- * @returns The cleanup function
1070
- */
1071
- /**
1072
- * Creates a reactive effect that automatically re-runs when dependencies change
1073
- * @param fn - The effect function that provides dependencies and may return a cleanup function or Promise
1074
- * @param options - Options for effect execution
1075
- * @returns A cleanup function to stop the effect
1076
- */
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
- }
1116
-
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
1127
- }
1128
- // Note: 'queue' mode not yet implemented
1129
- }
1130
-
1131
- // The effect has been stopped after having been planned
1132
- if (effectStopped) return
1133
-
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)
1165
- }
1166
- }
1167
-
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
1175
- }
1176
- } finally {
1177
- hasReacted = true
1178
- options.leave(fn)
1179
- }
1180
-
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)
1195
- }
1196
- }
1197
- if (objectWatchers.size === 0) {
1198
- watchers.delete(reactiveObj)
1199
- }
1200
- }
1201
- }
1202
- effectToReactiveObjects.delete(runEffect)
1203
- }
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)
1209
- }
1210
- }
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
- }
1228
- },
1229
- },
1230
- }) as ScopedCallback & {
1231
- [stopped]: boolean
1232
- [cleanupSymbol]: () => void
1233
- }
1234
- }
1235
- augmentedRv(runEffect)
1236
-
1237
- // Register strict mode if enabled
1238
- if (effectOptions?.opaque) {
1239
- opaqueEffects.add(runEffect)
1240
- }
1241
-
1242
- if (isDevtoolsEnabled()) {
1243
- registerEffectForDebug(runEffect)
1244
- }
1245
-
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
1261
- }
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
- () => {
1272
- 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)
1289
- }
1290
- // Execute this child effect cleanup (which triggers its own mainCleanup)
1291
- stopEffect()
1292
- })
1293
- children.add(subEffectCleanup)
1294
-
1295
- return subEffectCleanup
1296
- }
1297
-
1298
- /**
1299
- * Executes a function without tracking dependencies but maintains parent cleanup relationship
1300
- * Effects created inside will still be cleaned up when the parent effect is destroyed
1301
- * @param fn - The function to execute
1302
- */
1303
- export function untracked<T>(fn: () => T): T {
1304
- return effectHistory.present.root(fn)
1305
- }
1306
-
1307
- /**
1308
- * Executes a function from a virgin/root context - no parent effect, no tracking
1309
- * Creates completely independent effects that won't be cleaned up by any parent
1310
- * @param fn - The function to execute
1311
- */
1312
- export function root<T>(fn: () => T): T {
1313
- return effectHistory.root(fn)
1314
- }
1315
-
1316
- export { effectTrackers }
1317
-
1318
- /**
1319
- * Creates a bidirectional binding between a reactive value and a non-reactive external value
1320
- * Prevents infinite loops by automatically suppressing circular notifications
1321
- *
1322
- * @param received - Function called when the reactive value changes (external setter)
1323
- * @param get - Getter for the reactive value OR an object with `{ get, set }` properties
1324
- * @param set - Setter for the reactive value (required if `get` is a function)
1325
- * @returns A function to manually provide updates from the external side
1326
- *
1327
- * @example
1328
- * ```typescript
1329
- * const model = reactive({ value: '' })
1330
- * const input = { value: '' }
1331
- *
1332
- * // Bidirectional binding
1333
- * const provide = biDi(
1334
- * (v) => input.value = v, // external setter
1335
- * () => model.value, // reactive getter
1336
- * (v) => model.value = v // reactive setter
1337
- * )
1338
- *
1339
- * // External notification (e.g., from input event)
1340
- * provide('new value') // Updates model.value, doesn't trigger circular loop
1341
- * ```
1342
- *
1343
- * @example Using object syntax
1344
- * ```typescript
1345
- * const provide = biDi(
1346
- * (v) => setHTMLValue(v),
1347
- * { get: () => reactiveObj.value, set: (v) => reactiveObj.value = v }
1348
- * )
1349
- * ```
1350
- */
1351
- export function biDi<T>(
1352
- received: (value: T) => void,
1353
- value: { get: () => T; set: (value: T) => void }
1354
- ): (value: T) => void
1355
- export function biDi<T>(
1356
- received: (value: T) => void,
1357
- get: () => T,
1358
- set: (value: T) => void
1359
- ): (value: T) => void
1360
- export function biDi<T>(
1361
- received: (value: T) => void,
1362
- get: (() => T) | { get: () => T; set: (value: T) => void },
1363
- set?: (value: T) => void
1364
- ): (value: T) => void {
1365
- if (typeof get !== 'function') {
1366
- set = get.set
1367
- get = get.get
1368
- }
1369
- let programatticallySetValue: any = Symbol()
1370
- effect(
1371
- markWithRoot(() => {
1372
- const newValue = get()
1373
- if (unwrap(newValue) !== programatticallySetValue) received(newValue)
1374
- }, received)
1375
- )
1376
- return set
1377
- ? atomic((value: T) => {
1378
- programatticallySetValue = unwrap(value)
1379
- set(value)
1380
- })
1381
- : () => {}
1382
- }