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