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