mutts 1.0.2 → 1.0.4
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 +14 -6
- package/dist/chunks/{_tslib-C-cuVLvZ.js → _tslib-BgjropY9.js} +9 -1
- package/dist/chunks/_tslib-BgjropY9.js.map +1 -0
- package/dist/chunks/{_tslib-CMEnd0VE.esm.js → _tslib-Mzh1rNsX.esm.js} +9 -2
- package/dist/chunks/_tslib-Mzh1rNsX.esm.js.map +1 -0
- package/dist/chunks/{decorator-D4DU97Zg.js → decorator-DLvrD0UF.js} +42 -19
- package/dist/chunks/decorator-DLvrD0UF.js.map +1 -0
- package/dist/chunks/{decorator-GnHw1Az7.esm.js → decorator-DqiszP7i.esm.js} +42 -19
- package/dist/chunks/decorator-DqiszP7i.esm.js.map +1 -0
- package/dist/chunks/index-79Kk8D6e.esm.js +4857 -0
- package/dist/chunks/index-79Kk8D6e.esm.js.map +1 -0
- package/dist/chunks/index-GRBSx0mB.js +4908 -0
- package/dist/chunks/index-GRBSx0mB.js.map +1 -0
- package/dist/decorator.esm.js +1 -1
- package/dist/decorator.js +1 -1
- package/dist/destroyable.d.ts +1 -1
- package/dist/destroyable.esm.js +1 -1
- package/dist/destroyable.esm.js.map +1 -1
- package/dist/destroyable.js +1 -1
- package/dist/destroyable.js.map +1 -1
- package/dist/devtools/devtools.html +9 -0
- package/dist/devtools/devtools.js +5 -0
- package/dist/devtools/devtools.js.map +1 -0
- package/dist/devtools/manifest.json +8 -0
- package/dist/devtools/panel.css +72 -0
- package/dist/devtools/panel.html +31 -0
- package/dist/devtools/panel.js +13048 -0
- package/dist/devtools/panel.js.map +1 -0
- package/dist/eventful.esm.js +1 -1
- package/dist/eventful.js +1 -1
- package/dist/index.d.ts +18 -63
- package/dist/index.esm.js +4 -4
- package/dist/index.js +37 -11
- package/dist/index.js.map +1 -1
- package/dist/indexable.d.ts +187 -1
- package/dist/indexable.esm.js +197 -3
- package/dist/indexable.esm.js.map +1 -1
- package/dist/indexable.js +198 -2
- package/dist/indexable.js.map +1 -1
- package/dist/mutts.umd.js +1 -1
- package/dist/mutts.umd.js.map +1 -1
- package/dist/mutts.umd.min.js +1 -1
- package/dist/mutts.umd.min.js.map +1 -1
- package/dist/promiseChain.esm.js.map +1 -1
- package/dist/promiseChain.js.map +1 -1
- package/dist/reactive.d.ts +602 -97
- package/dist/reactive.esm.js +3 -3
- package/dist/reactive.js +32 -10
- package/dist/reactive.js.map +1 -1
- package/dist/std-decorators.esm.js +1 -1
- package/dist/std-decorators.js +1 -1
- package/docs/ai/api-reference.md +133 -0
- package/docs/ai/manual.md +105 -0
- package/docs/iterableWeak.md +646 -0
- package/docs/reactive/advanced.md +1280 -0
- package/docs/reactive/collections.md +767 -0
- package/docs/reactive/core.md +973 -0
- package/docs/reactive.md +21 -9545
- package/package.json +18 -5
- package/src/decorator.ts +266 -0
- package/src/destroyable.ts +199 -0
- package/src/eventful.ts +77 -0
- package/src/index.d.ts +9 -0
- package/src/index.ts +9 -0
- package/src/indexable.ts +484 -0
- package/src/introspection.ts +59 -0
- package/src/iterableWeak.ts +233 -0
- package/src/mixins.ts +123 -0
- package/src/promiseChain.ts +110 -0
- package/src/reactive/array.ts +414 -0
- package/src/reactive/change.ts +134 -0
- package/src/reactive/debug.ts +517 -0
- package/src/reactive/deep-touch.ts +268 -0
- package/src/reactive/deep-watch-state.ts +82 -0
- package/src/reactive/deep-watch.ts +168 -0
- package/src/reactive/effect-context.ts +94 -0
- package/src/reactive/effects.ts +1345 -0
- package/src/reactive/index.ts +76 -0
- package/src/reactive/interface.ts +223 -0
- package/src/reactive/map.ts +171 -0
- package/src/reactive/mapped.ts +130 -0
- package/src/reactive/memoize.ts +107 -0
- package/src/reactive/non-reactive-state.ts +49 -0
- package/src/reactive/non-reactive.ts +43 -0
- package/src/reactive/project.project.md +93 -0
- package/src/reactive/project.ts +335 -0
- package/src/reactive/proxy-state.ts +27 -0
- package/src/reactive/proxy.ts +289 -0
- package/src/reactive/record.ts +196 -0
- package/src/reactive/register.ts +421 -0
- package/src/reactive/set.ts +144 -0
- package/src/reactive/tracking.ts +101 -0
- package/src/reactive/types.ts +358 -0
- package/src/reactive/zone.ts +208 -0
- package/src/std-decorators.ts +217 -0
- package/src/utils.ts +117 -0
- package/dist/chunks/_tslib-C-cuVLvZ.js.map +0 -1
- package/dist/chunks/_tslib-CMEnd0VE.esm.js.map +0 -1
- package/dist/chunks/decorator-D4DU97Zg.js.map +0 -1
- package/dist/chunks/decorator-GnHw1Az7.esm.js.map +0 -1
- package/dist/chunks/index-DBScoeCX.esm.js +0 -1960
- package/dist/chunks/index-DBScoeCX.esm.js.map +0 -1
- package/dist/chunks/index-DOTmXL89.js +0 -1983
- package/dist/chunks/index-DOTmXL89.js.map +0 -1
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug utilities for the reactivity system
|
|
3
|
+
* - Captures effect metadata (names, parent relationships)
|
|
4
|
+
* - Records cause → consequence edges with object/prop labels
|
|
5
|
+
* - Provides graph data for tooling (DevTools panel, etc.)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { effectParent, effectToReactiveObjects, getRoot } from './tracking'
|
|
9
|
+
import { allProps, type Evolution, options, type ScopedCallback } from './types'
|
|
10
|
+
|
|
11
|
+
const EXTERNAL_SOURCE = Symbol('external-source')
|
|
12
|
+
type SourceEffect = ScopedCallback | typeof EXTERNAL_SOURCE
|
|
13
|
+
|
|
14
|
+
let devtoolsEnabled = false
|
|
15
|
+
|
|
16
|
+
// Registry for debugging (populated lazily when DevTools are enabled)
|
|
17
|
+
const debugEffectRegistry = new Set<ScopedCallback>()
|
|
18
|
+
const debugObjectRegistry = new Set<object>()
|
|
19
|
+
|
|
20
|
+
// Human-friendly names
|
|
21
|
+
const effectNames = new WeakMap<ScopedCallback, string>()
|
|
22
|
+
const objectNames = new WeakMap<object, string>()
|
|
23
|
+
let effectCounter = 0
|
|
24
|
+
let objectCounter = 0
|
|
25
|
+
|
|
26
|
+
// Cause/consequence edges aggregated by (source, target, descriptor)
|
|
27
|
+
interface TriggerRecord {
|
|
28
|
+
label: string
|
|
29
|
+
object: object
|
|
30
|
+
prop: any
|
|
31
|
+
evolution: Evolution
|
|
32
|
+
count: number
|
|
33
|
+
lastTriggered: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const triggerGraph = new Map<SourceEffect, Map<ScopedCallback, Map<string, TriggerRecord>>>()
|
|
37
|
+
|
|
38
|
+
export type NodeKind = 'effect' | 'external' | 'state'
|
|
39
|
+
export type EdgeKind = 'cause' | 'dependency' | 'trigger'
|
|
40
|
+
|
|
41
|
+
export interface EffectNode {
|
|
42
|
+
id: string
|
|
43
|
+
label: string
|
|
44
|
+
type: NodeKind
|
|
45
|
+
depth: number
|
|
46
|
+
parentId?: string
|
|
47
|
+
debugName?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ObjectNode {
|
|
51
|
+
id: string
|
|
52
|
+
label: string
|
|
53
|
+
type: NodeKind
|
|
54
|
+
debugName?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GraphEdge {
|
|
58
|
+
id: string
|
|
59
|
+
source: string
|
|
60
|
+
target: string
|
|
61
|
+
type: EdgeKind
|
|
62
|
+
label: string
|
|
63
|
+
count?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ReactivityGraph {
|
|
67
|
+
nodes: Array<EffectNode | ObjectNode>
|
|
68
|
+
edges: GraphEdge[]
|
|
69
|
+
meta: {
|
|
70
|
+
generatedAt: number
|
|
71
|
+
devtoolsEnabled: boolean
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureEffectName(effect: ScopedCallback): string {
|
|
76
|
+
let name = effectNames.get(effect)
|
|
77
|
+
if (!name) {
|
|
78
|
+
const root = getRoot(effect)
|
|
79
|
+
name = root?.name?.trim() || `effect_${++effectCounter}`
|
|
80
|
+
effectNames.set(effect, name)
|
|
81
|
+
}
|
|
82
|
+
return name
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureObjectName(obj: object): string {
|
|
86
|
+
let name = objectNames.get(obj)
|
|
87
|
+
if (!name) {
|
|
88
|
+
const ctorName = (obj as any)?.constructor?.name
|
|
89
|
+
const base = ctorName && ctorName !== 'Object' ? ctorName : 'object'
|
|
90
|
+
name = `${base}_${++objectCounter}`
|
|
91
|
+
objectNames.set(obj, name)
|
|
92
|
+
}
|
|
93
|
+
return name
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function describeProp(obj: object, prop: any): string {
|
|
97
|
+
const objectName = ensureObjectName(obj)
|
|
98
|
+
if (prop === allProps) return `${objectName}.*`
|
|
99
|
+
if (typeof prop === 'symbol') return `${objectName}.${prop.description ?? prop.toString()}`
|
|
100
|
+
return `${objectName}.${String(prop)}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function addEffectToRegistry(effect: ScopedCallback) {
|
|
104
|
+
if (!effect || debugEffectRegistry.has(effect)) return
|
|
105
|
+
debugEffectRegistry.add(effect)
|
|
106
|
+
const deps = effectToReactiveObjects.get(effect)
|
|
107
|
+
if (deps) {
|
|
108
|
+
for (const obj of deps) {
|
|
109
|
+
documentObject(obj)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function documentObject(obj: object) {
|
|
115
|
+
if (!debugObjectRegistry.has(obj)) {
|
|
116
|
+
dbRegisterObject(obj)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dbRegisterObject(obj: object) {
|
|
121
|
+
debugObjectRegistry.add(obj)
|
|
122
|
+
ensureObjectName(obj)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureParentChains(effects: Set<ScopedCallback>) {
|
|
126
|
+
const queue = Array.from(effects)
|
|
127
|
+
for (let i = 0; i < queue.length; i++) {
|
|
128
|
+
const effect = queue[i]
|
|
129
|
+
const parent = effectParent.get(effect)
|
|
130
|
+
if (parent && !effects.has(parent)) {
|
|
131
|
+
effects.add(parent)
|
|
132
|
+
queue.push(parent)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ensureTriggerContainers(source: SourceEffect) {
|
|
138
|
+
let targetMap = triggerGraph.get(source)
|
|
139
|
+
if (!targetMap) {
|
|
140
|
+
targetMap = new Map()
|
|
141
|
+
triggerGraph.set(source, targetMap)
|
|
142
|
+
}
|
|
143
|
+
return targetMap
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ensureTriggerRecord(
|
|
147
|
+
source: SourceEffect,
|
|
148
|
+
target: ScopedCallback,
|
|
149
|
+
label: string,
|
|
150
|
+
obj: object,
|
|
151
|
+
prop: any,
|
|
152
|
+
evolution: Evolution
|
|
153
|
+
): TriggerRecord {
|
|
154
|
+
const targetMap = ensureTriggerContainers(source)
|
|
155
|
+
let labelMap = targetMap.get(target)
|
|
156
|
+
if (!labelMap) {
|
|
157
|
+
labelMap = new Map()
|
|
158
|
+
targetMap.set(target, labelMap)
|
|
159
|
+
}
|
|
160
|
+
let record = labelMap.get(label)
|
|
161
|
+
if (!record) {
|
|
162
|
+
record = { label, object: obj, prop, evolution, count: 0, lastTriggered: Date.now() }
|
|
163
|
+
labelMap.set(label, record)
|
|
164
|
+
}
|
|
165
|
+
return record
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Assign a debug-friendly name to an effect (shown in DevTools)
|
|
170
|
+
*/
|
|
171
|
+
export function setEffectName(effect: ScopedCallback, name: string) {
|
|
172
|
+
effectNames.set(effect, name)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Assign a debug-friendly name to a reactive object
|
|
177
|
+
*/
|
|
178
|
+
export function setObjectName(obj: object, name: string) {
|
|
179
|
+
objectNames.set(obj, name)
|
|
180
|
+
debugObjectRegistry.add(obj)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Register an effect so it appears in the DevTools graph
|
|
185
|
+
*/
|
|
186
|
+
export function registerEffectForDebug(effect: ScopedCallback) {
|
|
187
|
+
if (!effect || !devtoolsEnabled) return
|
|
188
|
+
addEffectToRegistry(effect)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register a reactive object so it appears in the DevTools graph
|
|
193
|
+
*/
|
|
194
|
+
export function registerObjectForDebug(obj: object) {
|
|
195
|
+
if (!devtoolsEnabled) return
|
|
196
|
+
documentObject(obj)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Records a cause → consequence relationship between effects.
|
|
201
|
+
* @param source - The effect performing the write (undefined if external/user input)
|
|
202
|
+
* @param target - The effect that re-ran because of the write
|
|
203
|
+
* @param obj - The reactive object that changed
|
|
204
|
+
* @param prop - The property that changed
|
|
205
|
+
* @param evolution - The type of change (set/add/del/bunch)
|
|
206
|
+
*/
|
|
207
|
+
export function recordTriggerLink(
|
|
208
|
+
source: ScopedCallback | undefined,
|
|
209
|
+
target: ScopedCallback,
|
|
210
|
+
obj: object,
|
|
211
|
+
prop: any,
|
|
212
|
+
evolution: Evolution
|
|
213
|
+
) {
|
|
214
|
+
if (options.introspection.enableHistory) {
|
|
215
|
+
addToMutationHistory(source, target, obj, prop, evolution)
|
|
216
|
+
}
|
|
217
|
+
if (!devtoolsEnabled) return
|
|
218
|
+
addEffectToRegistry(target)
|
|
219
|
+
if (source) addEffectToRegistry(source)
|
|
220
|
+
const descriptor = describeProp(obj, prop)
|
|
221
|
+
const record = ensureTriggerRecord(
|
|
222
|
+
source ?? EXTERNAL_SOURCE,
|
|
223
|
+
target,
|
|
224
|
+
descriptor,
|
|
225
|
+
obj,
|
|
226
|
+
prop,
|
|
227
|
+
evolution
|
|
228
|
+
)
|
|
229
|
+
record.count += 1
|
|
230
|
+
record.lastTriggered = Date.now()
|
|
231
|
+
documentObject(obj)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Traces back the chain of triggers that led to a specific effect
|
|
236
|
+
* @param effect The effect to trace back
|
|
237
|
+
* @param limit Max depth
|
|
238
|
+
*/
|
|
239
|
+
export function getTriggerChain(effect: ScopedCallback, limit = 5): string[] {
|
|
240
|
+
const chain: string[] = []
|
|
241
|
+
let current = effect
|
|
242
|
+
for (let i = 0; i < limit; i++) {
|
|
243
|
+
// Find who triggered 'current'
|
|
244
|
+
// We need to reverse search the triggerGraph (source -> target)
|
|
245
|
+
// This is expensive O(Edges) but okay for error reporting
|
|
246
|
+
let foundSource: ScopedCallback | undefined
|
|
247
|
+
let foundReason = ''
|
|
248
|
+
|
|
249
|
+
search: for (const [source, targetMap] of triggerGraph) {
|
|
250
|
+
for (const [target, labelMap] of targetMap) {
|
|
251
|
+
if (target === current) {
|
|
252
|
+
// Found a source! Use the most recent trigger record
|
|
253
|
+
let lastTime = 0
|
|
254
|
+
for (const record of labelMap.values()) {
|
|
255
|
+
if (record.lastTriggered > lastTime) {
|
|
256
|
+
lastTime = record.lastTriggered
|
|
257
|
+
foundReason = record.label
|
|
258
|
+
foundSource = source === EXTERNAL_SOURCE ? undefined : (source as ScopedCallback)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (foundSource || foundReason) break search
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (foundSource) {
|
|
267
|
+
chain.push(
|
|
268
|
+
`${ensureEffectName(foundSource)} -> (${foundReason}) -> ${ensureEffectName(current)}`
|
|
269
|
+
)
|
|
270
|
+
current = foundSource
|
|
271
|
+
} else if (foundReason) {
|
|
272
|
+
chain.push(`External -> (${foundReason}) -> ${ensureEffectName(current)}`)
|
|
273
|
+
break
|
|
274
|
+
} else {
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return chain.reverse()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildEffectNodes(allEffects: Set<ScopedCallback>) {
|
|
282
|
+
const nodes: EffectNode[] = []
|
|
283
|
+
const nodeByEffect = new Map<ScopedCallback, EffectNode>()
|
|
284
|
+
|
|
285
|
+
const ordered = Array.from(allEffects)
|
|
286
|
+
for (const effect of ordered) {
|
|
287
|
+
const label = ensureEffectName(effect)
|
|
288
|
+
const node: EffectNode = {
|
|
289
|
+
id: `effect_${nodes.length}`,
|
|
290
|
+
label,
|
|
291
|
+
type: 'effect',
|
|
292
|
+
depth: 0,
|
|
293
|
+
debugName: label,
|
|
294
|
+
}
|
|
295
|
+
nodes.push(node)
|
|
296
|
+
nodeByEffect.set(effect, node)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const depthCache = new Map<ScopedCallback, number>()
|
|
300
|
+
const computeDepth = (effect: ScopedCallback | undefined): number => {
|
|
301
|
+
if (!effect) return 0
|
|
302
|
+
const cached = depthCache.get(effect)
|
|
303
|
+
if (cached !== undefined) return cached
|
|
304
|
+
const parent = effectParent.get(effect)
|
|
305
|
+
const depth = computeDepth(parent) + (parent ? 1 : 0)
|
|
306
|
+
depthCache.set(effect, depth)
|
|
307
|
+
return depth
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const [effect, node] of nodeByEffect) {
|
|
311
|
+
node.depth = computeDepth(effect)
|
|
312
|
+
const parent = effectParent.get(effect)
|
|
313
|
+
if (parent) {
|
|
314
|
+
const parentNode = nodeByEffect.get(parent)
|
|
315
|
+
if (parentNode) {
|
|
316
|
+
node.parentId = parentNode.id
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { nodes, nodeByEffect }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Builds a graph representing current reactive state (effects, objects, and trigger edges)
|
|
326
|
+
*/
|
|
327
|
+
export function buildReactivityGraph(): ReactivityGraph {
|
|
328
|
+
const nodes: Array<EffectNode | ObjectNode> = []
|
|
329
|
+
const edges: GraphEdge[] = []
|
|
330
|
+
const nodeIds = new Map<ScopedCallback | object | SourceEffect, string>()
|
|
331
|
+
|
|
332
|
+
const allEffects = new Set<ScopedCallback>(debugEffectRegistry)
|
|
333
|
+
ensureParentChains(allEffects)
|
|
334
|
+
const { nodes: effectNodes, nodeByEffect } = buildEffectNodes(allEffects)
|
|
335
|
+
for (const node of effectNodes) nodes.push(node)
|
|
336
|
+
for (const [effect, node] of nodeByEffect) {
|
|
337
|
+
nodeIds.set(effect, node.id)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Object nodes (optional, used for dependency inspection)
|
|
341
|
+
for (const obj of debugObjectRegistry) {
|
|
342
|
+
const id = `object_${nodes.length}`
|
|
343
|
+
nodes.push({ id, label: ensureObjectName(obj), type: 'state', debugName: objectNames.get(obj) })
|
|
344
|
+
nodeIds.set(obj, id)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// External source node (user/system outside of effects)
|
|
348
|
+
if (triggerGraph.has(EXTERNAL_SOURCE)) {
|
|
349
|
+
const externalId = `effect_external`
|
|
350
|
+
nodes.push({ id: externalId, label: 'External', type: 'external', depth: 0 })
|
|
351
|
+
nodeIds.set(EXTERNAL_SOURCE, externalId)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Dependency edges (effect → object)
|
|
355
|
+
for (const effect of allEffects) {
|
|
356
|
+
const effectId = nodeIds.get(effect)
|
|
357
|
+
if (!effectId) continue
|
|
358
|
+
const deps = effectToReactiveObjects.get(effect)
|
|
359
|
+
if (!deps) continue
|
|
360
|
+
for (const obj of deps) {
|
|
361
|
+
const objId = nodeIds.get(obj)
|
|
362
|
+
if (!objId) continue
|
|
363
|
+
edges.push({
|
|
364
|
+
id: `${effectId}->${objId}`,
|
|
365
|
+
source: effectId,
|
|
366
|
+
target: objId,
|
|
367
|
+
type: 'dependency',
|
|
368
|
+
label: 'depends',
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Cause edges (effect/object/prop → effect)
|
|
374
|
+
for (const [source, targetMap] of triggerGraph) {
|
|
375
|
+
for (const [targetEffect, labelMap] of targetMap) {
|
|
376
|
+
const targetId = nodeIds.get(targetEffect)
|
|
377
|
+
if (!targetId) continue
|
|
378
|
+
const sourceId = nodeIds.get(source)
|
|
379
|
+
if (!sourceId) continue
|
|
380
|
+
for (const record of labelMap.values()) {
|
|
381
|
+
edges.push({
|
|
382
|
+
id: `${sourceId}->${targetId}:${record.label}`,
|
|
383
|
+
source: sourceId,
|
|
384
|
+
target: targetId,
|
|
385
|
+
type: 'cause',
|
|
386
|
+
label: record.count > 1 ? `${record.label} (${record.count})` : record.label,
|
|
387
|
+
count: record.count,
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
nodes,
|
|
395
|
+
edges,
|
|
396
|
+
meta: {
|
|
397
|
+
generatedAt: Date.now(),
|
|
398
|
+
devtoolsEnabled,
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Enables the DevTools bridge and exposes the debug API on window.
|
|
405
|
+
* Call as early as possible in development builds.
|
|
406
|
+
*/
|
|
407
|
+
export function enableDevTools() {
|
|
408
|
+
if (typeof window === 'undefined') return
|
|
409
|
+
if (devtoolsEnabled) return
|
|
410
|
+
devtoolsEnabled = true
|
|
411
|
+
|
|
412
|
+
// @ts-expect-error - global window extension
|
|
413
|
+
window.__MUTTS_DEVTOOLS__ = {
|
|
414
|
+
getGraph: buildReactivityGraph,
|
|
415
|
+
setEffectName,
|
|
416
|
+
setObjectName,
|
|
417
|
+
registerEffect: registerEffectForDebug,
|
|
418
|
+
registerObject: registerObjectForDebug,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function forceEnableGraphTracking() {
|
|
423
|
+
devtoolsEnabled = true
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function isDevtoolsEnabled() {
|
|
427
|
+
return devtoolsEnabled
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- Introspection API ---
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Returns the raw dependency graph data structure.
|
|
434
|
+
* This is useful for programmatic analysis of the reactive system.
|
|
435
|
+
*/
|
|
436
|
+
export function getDependencyGraph() {
|
|
437
|
+
return {
|
|
438
|
+
nodes: buildReactivityGraph().nodes,
|
|
439
|
+
edges: buildReactivityGraph().edges,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Returns a list of effects that depend on the given object.
|
|
445
|
+
*/
|
|
446
|
+
export function getDependents(obj: object): ScopedCallback[] {
|
|
447
|
+
const dependents: ScopedCallback[] = []
|
|
448
|
+
// Scan the trigger graph for effects triggered by this object
|
|
449
|
+
// This is O(E) where E is the number of edges, might need optimization for large graphs
|
|
450
|
+
// but acceptable for introspection
|
|
451
|
+
for (const [_source, targetMap] of triggerGraph) {
|
|
452
|
+
for (const [targetEffect, labelMap] of targetMap) {
|
|
453
|
+
for (const record of labelMap.values()) {
|
|
454
|
+
if (record.object === obj) {
|
|
455
|
+
dependents.push(targetEffect)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Also check direct dependencies (dependency graph)
|
|
461
|
+
// We don't have a direct obj -> effect map without walking all effects
|
|
462
|
+
// unless we use `watchers` from tracking.ts but that's internal
|
|
463
|
+
return [...new Set(dependents)]
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Returns a list of objects that the given effect depends on.
|
|
468
|
+
*/
|
|
469
|
+
export function getDependencies(effect: ScopedCallback): object[] {
|
|
470
|
+
const deps = effectToReactiveObjects.get(effect)
|
|
471
|
+
return deps ? Array.from(deps) : []
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Mutation History ---
|
|
475
|
+
|
|
476
|
+
export interface MutationRecord {
|
|
477
|
+
id: number
|
|
478
|
+
timestamp: number
|
|
479
|
+
source: string
|
|
480
|
+
target: string
|
|
481
|
+
objectName: string
|
|
482
|
+
prop: string
|
|
483
|
+
type: string
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const mutationHistory: MutationRecord[] = []
|
|
487
|
+
let mutationCounter = 0
|
|
488
|
+
|
|
489
|
+
function addToMutationHistory(
|
|
490
|
+
source: ScopedCallback | undefined,
|
|
491
|
+
target: ScopedCallback,
|
|
492
|
+
obj: object,
|
|
493
|
+
prop: any,
|
|
494
|
+
evolution: Evolution
|
|
495
|
+
) {
|
|
496
|
+
const record: MutationRecord = {
|
|
497
|
+
id: ++mutationCounter,
|
|
498
|
+
timestamp: Date.now(),
|
|
499
|
+
source: source ? ensureEffectName(source) : 'External',
|
|
500
|
+
target: ensureEffectName(target),
|
|
501
|
+
objectName: ensureObjectName(obj),
|
|
502
|
+
prop: String(prop),
|
|
503
|
+
type: evolution.type,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
mutationHistory.push(record)
|
|
507
|
+
if (mutationHistory.length > options.introspection.historySize) {
|
|
508
|
+
mutationHistory.shift()
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Get the recent mutation history
|
|
514
|
+
*/
|
|
515
|
+
export function getMutationHistory(): MutationRecord[] {
|
|
516
|
+
return [...mutationHistory]
|
|
517
|
+
}
|