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