ignal" | "computed" | "effect";
version: number;
};
const metaRegistry = new WeakMap<ReactiveNode, NodeMeta>();
let sequenceCounter = 0;
function resolveMeta(node: ReactiveNode): NodeMeta {
const cached = metaRegistry.get(node);
if (cached) return cached;
const meta: NodeMeta = {
uid: ${node.kind}_${++sequenceCounter},
kind: node.kind,
version: node.version ?? 0,
};
metaRegistry.set(node, meta);
return meta;
}
**Architecture Rationale:** `WeakMap` ensures metadata is garbage-collected when the node is disposed. No properties are added to the core `ReactiveNode` interface. The registry is lazy, meaning overhead only exists when inspection occurs.
### Phase 2: Topology Snapshotting
A single-node snapshot captures inbound (`deps`) and outbound (`subs`) relationships without triggering recomputation.
```typescript
export type NodeSnapshot = {
uid: string;
kind: NodeMeta["kind"];
inbound: number;
outbound: number;
dependencies: Array<{ uid: string; kind: string }>;
subscribers: Array<{ uid: string; kind: string }>;
};
export function captureSnapshot(node: ReactiveNode): NodeSnapshot {
const meta = resolveMeta(node);
const deps = Array.from(node.dependencies ?? []);
const subs = Array.from(node.subscribers ?? []);
return {
uid: meta.uid,
kind: meta.kind,
inbound: deps.length,
outbound: subs.length,
dependencies: deps.map(dep => {
const dMeta = resolveMeta(dep);
return { uid: dMeta.uid, kind: dMeta.kind };
}),
subscribers: subs.map(sub => {
const sMeta = resolveMeta(sub);
return { uid: sMeta.uid, kind: sMeta.kind };
}),
};
}
Architecture Rationale: Returns a plain object to prevent reference leakage. Degree counts (inbound/outbound) immediately reveal graph bottlenecks. High outbound indicates a broadcast signal; high inbound suggests a heavily derived value.
Phase 3: Bounded Graph Expansion
Recursive traversal must prevent infinite loops and stack overflow. A breadth-first search with configurable depth limits ensures predictable memory usage.
export type EdgeRecord = {
source: string;
target: string;
direction: "upstream" | "downstream";
};
export type SubgraphReport = {
root: string;
nodes: Array<{ uid: string; kind: string }>;
edges: EdgeRecord[];
};
export function expandSubgraph(
root: ReactiveNode,
depth: number = 2
): SubgraphReport {
const visited = new Set<ReactiveNode>();
const edges: EdgeRecord[] = [];
const queue: Array<{ node: ReactiveNode; up: number; down: number }> = [
{ node: root, up: depth, down: depth },
];
visited.add(root);
while (queue.length > 0) {
const current = queue.shift()!;
const sourceId = resolveMeta(current.node).uid;
if (current.up > 0 && current.node.dependencies) {
for (const dep of current.node.dependencies) {
edges.push({ source: resolveMeta(dep).uid, target: sourceId, direction: "upstream" });
if (!visited.has(dep)) {
visited.add(dep);
queue.push({ node: dep, up: current.up - 1, down: 0 });
}
}
}
if (current.down > 0 && current.node.subscribers) {
for (const sub of current.node.subscribers) {
edges.push({ source: sourceId, target: resolveMeta(sub).uid, direction: "downstream" });
if (!visited.has(sub)) {
visited.add(sub);
queue.push({ node: sub, up: 0, down: current.down - 1 });
}
}
}
}
return {
root: resolveMeta(root).uid,
nodes: Array.from(visited).map(n => {
const m = resolveMeta(n);
return { uid: m.uid, kind: m.kind };
}),
edges,
};
}
Architecture Rationale: BFS guarantees level-order traversal, making depth limits mathematically predictable. Separating up and down counters allows asymmetric expansion (e.g., trace 3 levels upstream but only 1 downstream). Cycle prevention via Set<ReactiveNode> is O(1) and avoids duplicate processing.
Phase 4: Execution Profiling & Visualization
Performance instrumentation must measure frequency and duration without altering the scheduler. An exponential moving average smooths frequency spikes, while a timing wrapper captures execution cost.
type ProfilerMetrics = {
updates: number;
lastUpdate: number;
frequency: number;
totalDuration: number;
avgDuration: number;
};
const profilerStore = new WeakMap<ReactiveNode, ProfilerMetrics>();
const SMOOTHING_FACTOR = 0.2;
const clock = () => globalThis.performance?.now?.() ?? Date.now();
export function recordExecution(node: ReactiveNode, fn: () => void): void {
const start = clock();
fn();
const duration = clock() - start;
let metrics = profilerStore.get(node);
if (!metrics) {
metrics = { updates: 0, lastUpdate: 0, frequency: 0, totalDuration: 0, avgDuration: 0 };
profilerStore.set(node, metrics);
}
const now = clock();
const elapsed = now - metrics.lastUpdate;
metrics.frequency = metrics.lastUpdate === 0
? 0
: (1 / (elapsed / 60000)) * SMOOTHING_FACTOR + metrics.frequency * (1 - SMOOTHING_FACTOR);
metrics.updates++;
metrics.lastUpdate = now;
metrics.totalDuration += duration;
metrics.avgDuration = metrics.totalDuration / metrics.updates;
}
export function exportToMermaid(report: SubgraphReport): string {
const sanitize = (id: string) => id.replace(/[^a-zA-Z0-9_]/g, "_");
const lines = ["graph TD"];
for (const n of report.nodes) {
lines.push(` ${sanitize(n.uid)}["${n.uid} (${n.kind})"]`);
}
for (const e of report.edges) {
lines.push(` ${sanitize(e.source)} --> ${sanitize(e.target)}`);
}
return lines.join("\n");
}
Architecture Rationale: recordExecution wraps computation boundaries, capturing duration and calculating updates-per-minute using exponential smoothing. This prevents noise from bursty updates. Mermaid export uses sanitized IDs to guarantee compatibility with markdown renderers and diagram tools. The entire profiling layer remains detached from the core scheduler, enabling compile-time stripping in production.
Pitfall Guide
Explanation: Attaching debug IDs, version counters, or profiling data directly to ReactiveNode objects increases memory footprint and breaks framework encapsulation.
Fix: Always use WeakMap or external registries. Never mutate the core node interface.
2. Unbounded Recursive Traversal
Explanation: Depth-first expansion without limits causes stack overflow in cyclic or highly connected graphs.
Fix: Use breadth-first search with explicit depth counters. Track visited nodes in a Set to prevent reprocessing.
3. Synchronous Timing Drift
Explanation: Measuring execution duration without accounting for microtask batching or scheduler deferrals produces inaccurate averages.
Fix: Use performance.now() and wrap execution at stable boundaries. Avoid measuring inside tight loops where GC pauses skew results.
4. Stale State Capture
Explanation: Snapshotting nodes during active propagation returns inconsistent deps/subs states or outdated values.
Fix: Capture snapshots at scheduler idle points or clone node state before inspection. Mark snapshots with a capturedAt timestamp for traceability.
5. Production Instrumentation Bloat
Explanation: Leaving profiling wrappers active in production adds CPU overhead and increases bundle size.
Fix: Gate instrumentation behind environment flags or compile-time tree-shaking. Use process.env.NODE_ENV !== "production" or build-time macros.
6. Directional Blindness
Explanation: Only tracking deps or only tracking subs misses half the propagation path. Over-renders often originate downstream, while bottlenecks originate upstream.
Fix: Always map both inbound and outbound edges. Label direction explicitly in reports and visualizations.
7. GC Pressure from Frequent Snapshots
Explanation: Creating new snapshot objects on every update triggers garbage collection, causing frame drops in UI applications.
Fix: Implement object pooling, lazy evaluation, or delta-based updates. Only generate full snapshots when explicitly requested by DevTools.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single component debugging | captureSnapshot() | Fast, zero traversal overhead, immediate state visibility | <0.1% CPU |
| Cascade root cause analysis | expandSubgraph() with depth=3 | Maps full propagation path without stack overflow | 1β2% CPU (on-demand) |
| Performance regression hunting | recordExecution() + frequency tracking | Identifies hot nodes and expensive recomputations | <2% CPU (continuous) |
| Architecture documentation | exportToMermaid() | Generates renderable DAGs for PRs and wikis | Zero runtime cost |
| Production monitoring | Delta snapshots + sampling | Reduces GC pressure while preserving trend data | <0.5% CPU |
Configuration Template
// devtools-config.ts
import { setupObservability } from "./observability.js";
import { ReactiveScheduler } from "./scheduler.js";
export const devtools = setupObservability({
scheduler: ReactiveScheduler.getInstance(),
options: {
maxTraversalDepth: 3,
enableProfiling: process.env.NODE_ENV !== "production",
samplingInterval: 1000, // ms between frequency recalculations
gcSafeMode: true, // enables object pooling for snapshots
},
hooks: {
onNodeDispose: (node) => {
// Cleanup metadata and profiler metrics
devtools.registry.clear(node);
devtools.profiler.clear(node);
},
onSchedulerIdle: () => {
// Flush pending snapshots and update DevTools UI
devtools.flushPendingReports();
},
},
});
// Attach to global for browser console access
if (typeof window !== "undefined") {
(window as any).__reactiveDevtools = devtools;
}
Quick Start Guide
- Install the observability layer: Import
setupObservability and pass your scheduler instance. Ensure WeakMap registries are initialized before any reactive nodes are created.
- Enable profiling in development: Wrap signal writes, computed recomputations, and effect executions with
recordExecution(). Set enableProfiling: true in your config.
- Inspect a node: Call
captureSnapshot(node) or expandSubgraph(node, 2) from the console. Use the returned uid to trace propagation paths.
- Visualize the graph: Run
exportToMermaid(expandSubgraph(rootNode)) and paste the output into any Markdown viewer or diagram tool. Iterate on dependency architecture based on the rendered DAG.