@zakkster/lite-devtools
v1.8.0
Published
Reactive-graph inspector for @zakkster/lite-signal. Non-perturbing introspection (peek + enumerator walks, never adds an observer), full auto-discovered DAG with diamond/convergence dedupe, live lifecycle feed, leak detector, DOT/tree renderers, and diff(
Maintainers
Readme
@zakkster/lite-devtools
Reactive-graph inspector for
@zakkster/lite-signal. Non-perturbing introspection, full auto-discovered DAG with diamond/convergence dedupe, live lifecycle feed, leak detector, DOT/tree renderers, per-flush burst profiler, steady-state allocation feed. Cold path -- zero hot-path footprint.
npm install @zakkster/lite-devtools
# peers -- lite-signal >= 1.5.0 (baseline helpers need >= 1.1.5; burst tier lights up at >= 1.6):
npm install @zakkster/lite-signal @zakkster/lite-timeimport { signal, computed, effect } from "@zakkster/lite-signal";
import { graph, toDot, inspect, track, leakWatch } from "@zakkster/lite-devtools";
const price = signal(100);
const qty = signal(3);
const total = computed(() => price() * qty());
effect(() => console.log("total:", total()));
// One-shot DAG dump:
console.log(toDot(graph(price)));
// digraph reactive { ... signal#1 -> computed#3; computed#3 -> effect#4; }
// Live lifecycle feed:
track(price, e => console.log(e.type, "id=" + e.id));
// connect id=1 <-- when the effect first subscribed
// disconnect id=1 <-- when the effect disposed
// Single-node snapshot:
inspect(total);
// { id: 3, kind: "computed", observed: true, value: 300, sourceCount: 2, observerCount: 1, ... }
// Leak detector:
const { stop, samples } = leakWatch({ sampleMs: 1000, growth: 32 });Read-side helpers never add a dependency edge. track() registers a lifecycle listener -- also no edges. Nothing here belongs in a 60 fps render loop, but that's the point: it's the cold/debug path, designed to be safe to call any time, including from a dispose-during-flush.
Table of contents
- New in 1.8
- New in 1.7
- New in 1.2
- Why this exists
- What you get
- The non-perturbing guarantee
- Capability tiers
- API reference
- Workflows
- Cost model
- Interactive demo
- Testing strategy
- What this is not
- Ecosystem
- Browser and runtime support
- FAQ
New in 1.8
A non-perturbing settle observer over the lite-signal 1.11-preview onSettled primitive (createRegistry({ settled: true })). Grounded against the real cut first: 1.11-preview's stats() keys are unchanged from 1.9/1.10, so the cold-counter pack still has no substrate and stays deferred, and the settle-aware trace twin (traceAsync) is a 1.12 feature -- so this release adds the settled capability and watchSettled only.
settledcapability -- true when the engine armsonSettled(createRegistry({ settled: true })exposes a workingonSettled, and a default registry'sonSettledthrows). A fail-closed behaviour probe in a throwaway registry -- the 16thcapabilities()key.watchSettled(registry, cb)-- observes a consumer-built settled registry (devtools never arms the capability). On each top-level, non-empty, clean drain -- abatch()or a fan-out is one settle; an empty flush is none -- it bumps a monotonic counter and callscb({ settles, ts })(the engine callback takes no args, so the payload is synthesized). Returns{ count, stop }ornullwhencapabilities().settledis false or the registry is not settled-capable.stop()is idempotent andusing-disposable. Cold-path; attaching leavesstats()byte-identical -- the counter never perturbs the settles it counts. Astop()called from inside a settle callback defers its detach to the end of the fire, so it can never trip the engine's self-unsubscribe sharp edge.
capabilities() gains a settled flag; the peerDependencies union extends by the >=1.11.0-0 <1.12.0 tuple. See Capability tiers and the API reference.
New in 1.7
Engine-native named nodes and pull-side dirty reasons, built against the lite-signal 1.10-preview tracing foundation (describe().name; whyDirty()) and covered by test/16-named-whydirty-explain.test.mjs. Grounded against the real cut before implementation: 1.10-preview's stats() keys are unchanged from 1.9, so the cold-counter pack has no substrate yet and is deferred -- this release ships names + whyDirty + explain.
- Named nodes on snapshots.
inspect()and graph node descriptors carry an optionalnamefromdescribe(handle).namewhencapabilities().names. The key is ABSENT on unnamed nodes and below-floor engines -- never"". - Label precedence: resolver > engine
name>kind#id.toDot/toTreenow default an unnamed, unresolved node tokind#id(the raw-value default of<= 1.6is replaced; theidis kept in the DOT comment / tree tooltip). AlabelResolverstring return still wins over a present engine name. whyDirty(handle)-- the engine's read-only dirty-reason diagnostic (each dep whose version moved past the observer'sevalVersion),nullbelow the floor. Never pulls, adds no observer.explain(from, to, opts?)-- composesfindPathINTERSECTwhyDirty(by node id) to annotate each hop on the path with why it recomputed;nullwhen the capability is absent or no path exists.serialize()schema v1 -> v2 round-tripsname;deserialize()stays backward-compatible (a v1 payload deserializes unchanged).
capabilities() gains names and whyDirty flags; the peerDependencies union extends by the >=1.10.0-0 <1.11.0 tuple. See Capability tiers and the API reference.
New in 1.2
Two cold-path additions for the "the engine allocates nothing" story, both built against the lite-signal 1.6 burst tier and covered by test/13-burst-and-allocations.test.mjs:
burstProfile()-- a per-flush redundant-work profiler.redundant()lists nodes that recomputed more than once inside a single flush;shortCircuited()lists effects that were enqueued but cut before running ({id, queued, ran, wasted}, wherewasted = queued - ran).stop()freezes the tallies and is idempotent. Returnsnullunlesscapabilities().burstis true -- on older engines reach forprofile()(recompute counts only).watchAllocations(cb, opts?)-- a steady-state feed for a live "allocations: 0" panel. Samples lite-signal's cumulative pool-growth / alloc / recompute counters off lite-time'severy()scheduler, out of the reactive graph -- likeleakWatch, it never instruments itself into what it measures.poolGrowthDelta/allocDeltacome from always-on engine counters;recomputeDeltaneeds the mutation hook andflushPassDeltaneeds lite-signal >= 1.6.pendingEffects(registry?)(1.4) -- an attach-time-forward effect-queue-depth gauge.depth = sum(op 7 enqueues) - sum(op 6 effectsToRun), clamped>= 0(op 6's payload is the exact per-pass drain count; op 5 is deliberately NOT the decrement -- it also fires for computed recomputes, so anop7 - op5gauge under-reads). Omit the argument to gauge the default registry (honestly 0 outside abatch()on the eager default); pass acreateRegistry({flushStrategy})handle to gauge that world -- a foreign registry gets its own hub so the default registry'sstats()is never perturbed. Returns{ depth(), peak(), stop() }, ornullunless the burst surface is present.
capabilities() gains a burst flag for these; pendingEffects() also reads flushPasses on the target registry. The leakWatch() / watchAllocations() samples additionally carry nodePoolPopulation / linkPoolPopulation when capabilities().poolPopulation (present on every supported engine, lite-signal >= 1.5.0); the fields are ABSENT when the probe is false (never 0 -- null is not zero). The peerDependencies range admits lite-signal >= 1.5.0 through < 1.12.0 (baseline helpers need >= 1.1.5); the burst surface these read lights up at >= 1.6 -- see Capability tiers. Full signatures live in the API reference.
Why this exists
lite-signal is zero-GC by design -- the engine itself stays out of the GC nursery on the hot path. That property would be undone if its devtools also lived in the same process and allocated freely on every read. So inspection is intentionally kept off the engine module and lives here, as a separate package that:
- Touches the graph only via lite-signal's public introspection surface --
hasObservers,observeObservers,forEachObserver,forEachSource,nodeId,describe. No internal symbol access, no patched objects. - Never adds an observer. Values are read via
peek()(untracked); edge lists are walked via the enumerators. Inspecting a signal does not subscribe to it; you can callinspect()in a tight loop without changing any counter. - Allocates freely. This is the cold/debug path. There is no allocation budget here, just a strict promise that you don't contaminate the hot path by inspecting it.
In other words: lite-signal stays performant whether or not you've installed lite-devtools. If you remove the dependency, the engine's introspection surface stays -- it's already there, behind feature gates that compile to a single branch-predicted count !== 0 check when nothing is observed.
flowchart LR
subgraph App[Your app -- hot path]
S[signals / computeds / effects]
end
subgraph Engine[lite-signal -- engine module]
H1[hasObservers]
H2[observeObservers]
H3[forEachObserver / forEachSource]
H4[nodeId / describe]
end
subgraph Devtools[lite-devtools -- cold/debug path]
I[inspect]
G[graph]
T[track]
L[leakWatch]
D[toDot / toTree]
M[monitor / report]
end
App -.observers/sources.-> Engine
Devtools -- read-only --> Engine
Devtools -. NEVER .-> AppWhat you get
inspect(handle)-- single-node snapshot: id, kind, value, observed, observer/source counts and descriptors. (1.1: now carries astaleflag for handles whose engine slot has been recycled.)subscribers(handle)-- observer descriptors. Subscribe order.dependencies(handle)-- source descriptors. Dependency-read order.track(handle, onEvent)-- liveconnect/disconnectfeed viaobserveObservers. Transition-only. (1.1: also deliversdisposeevents for cascade-disposed nodes when the engine exposes the graph-mutation hook.)monitor()-- current engine stats (signals, computeds, effects, activeLinks, activeNodes...).leakWatch(opts?)-- periodic activeNodes-delta sampler, flags suspicious growth.report(handles)-- one combined snapshot (stats + per-handle inspect).graph(roots, opts?)-- BFS-walks the whole DAG reachable fromroots. Dedupes by stable id. Bidirectional (observers + sources). (1.1:{owners: true}adds owner edges to the walk frontier.)toDot(g, opts?)-- render agraph()result as Graphviz DOT. Owner edges (kind: "owner") render dashed-and-gray. (1.5: optionallabelResolver(id)host hook annotates node labels inline.)toTree(root, opts?)-- indented text tree, with a(seen)marker at convergence. (1.5: optionallabelResolver(id)host hook annotates node labels inline.)diff(before, after)(1.1) -- structural delta between twograph()snapshots: added / removed / changed nodes and edges. Owner-cascade disposals show up asremovedNodes.trace(roots, fn, opts?)(1.1) -- snapshot, runfn, snapshot, diff. "What did this action do to the graph." Attaches partial trace on throw.capabilities()(1.1) -- engine capability snapshot (floor,owners,mutationHook,burst). Lets consumers pick push vs poll without try/catch probing.findPath(from, to, opts?)(1.1) -- shortest dependency path between two handles. Answers "a write to X re-ran Y -- through which computeds?".ownerTree(root, opts?)(1.1, lite-signal >= 1.3) -- nested ownership hierarchy. The dependency DAG says "who updates whom"; this says "who outlives whom".watchGraph(roots, cb, opts?)(1.1) -- push-based observation. Microtask-coalesces mutations into one callback per microtask boundary; polls as a fallback.profile(opts?)(1.1, lite-signal >= 1.2.1) -- per-node recompute counter. Catches the hot-node footgun invisible in value snapshots.serialize(g)/deserialize(json)(1.1) -- JSON-safe round-trip for offline viewing (studio panels, bug reports, CI artifacts).burstProfile()(1.2, lite-signal >= 1.6) -- per-flush redundant-work profiler: nodes that recompute more than once, and effects enqueued but short-circuited before running.watchAllocations(cb, opts?)(1.2) -- steady-state allocation panel feed (poolGrowth / alloc / recompute deltas) sampled off the lite-time scheduler, out of the reactive graph.
Every public symbol has JSDoc; the formal contract lives in Devtools.d.ts.
The non-perturbing guarantee
This is the headline property -- the one that makes lite-devtools safe to use in tight loops, from inside an effect cleanup, or from a long-running observability overlay:
Calling any read-side helper (
inspect,subscribers,dependencies,graph,toDot,toTree,monitor,report) does not subscribe an observer through your call. The engine's stat counters and observer counts stay where they were -- with one corner case: see below.
track() registers a lifecycle hook. It does not add a dependency edge -- hasObservers(handle) stays false for a signal that only has track()'d watchers. The lifecycle hook fires on observer transitions of other subscribers (effects, computeds), not on track itself.
The full property is enforced by test/07-non-perturbing.test.mjs, which exercises every read-side helper hundreds of times against a live graph and asserts engine state is identical before/after.
There are two related nuances worth knowing, both stemming from lite-signal's pull-based semantics:
Stale computed --
inspect()reads value viapeek(), andpeek()on a stale computed pulls. So inspecting a stale computed may cause its body to run. No new edges are created (the existing dep set is reused), no observer is added -- but a side-effecting body would run. Gate it withif (!isStale(c)) inspect(c)if that matters, or usegraph()(which reads throughdescribe()and doesn't force a pull).Never-evaluated computed -- first pull is special. A computed that has never been read has an empty dep set; the first pull (whether yours or
inspect()'speek()) is what establishes its dependencies. So inspecting a never-evaluated computed will materialise its dep edges and bumpactiveLinksaccordingly -- and will firetrack()on the now-newly-observed source signals. Pre-realize the computed (call it once) before inspecting if you need pure non-perturbation, or usegraph()which reads metadata viadescribe()without triggering a pull.
Capability tiers
lite-devtools declares a peerDependencies range that admits lite-signal >= 1.5.0 through < 1.12.0 -- the per-minor prerelease-aware union in package.json, so a consumer pinned to any 1.5-through-1.11 cut (e.g. lite-studio's allocation panel) can depend on this package. The source eagerly imports describe and nodeId, so the baseline helpers themselves need lite-signal >= 1.1.5 at minimum; the burst tier lights up at >= 1.6. Which of the tiers below are live depends on the installed engine cut, so the version on each row marks when that capability first appeared in lite-signal, and the capabilities() flags remain so the package degrades safely if it is ever run against an older engine.
| Tier | What you get | What the engine needs |
|---|---|---|
| Floor (lite-signal >= 1.1.5) | inspect, subscribers, dependencies, track (connect/disconnect), monitor, leakWatch, report, graph, toDot, toTree, diff, trace, findPath, serialize, deserialize, capabilities | describe, nodeId, forEachObserver, forEachSource, hasObservers, observeObservers, stats (all stable since 1.1.5) |
| Graph-mutation hook (lite-signal >= 1.2.1) | watchGraph push mode (microtask-coalesced); profile; track's dispose event; inspect().stale on gen-recycled handles | onGraphMutation |
| Owner-tree (lite-signal >= 1.3) | ownerTree; graph({owners: true}); owner-cascade observable through diff/trace removed/added nodes | forEachOwned, ownerOf |
| Burst payloads (lite-signal >= 1.6) | burstProfile (redundant / shortCircuited); watchAllocations().flushPassDelta | onGraphMutation emitting enqueue / flush-pass payloads |
Without the hook tier, watchGraph falls back to polling at opts.pollMs and profile() returns null -- consumers branch on capabilities().mutationHook. Without the owner tier, ownerTree returns null and graph({owners}) silently behaves as the 1.0 walk -- consumers branch on capabilities().owners.
The internal 1.2 owner tree (effects/computeds cascade-disposing their nested observers on owner re-run) is the headline new behaviour that 1.1 makes observable. diff() / trace() surface cascade-disposed nodes as removedNodes -- the entire owner-cascade saga is just snapshot, act, snapshot, diff.
API reference
inspect(handle)
A non-perturbing snapshot of a handle and its immediate neighbourhood.
function inspect(handle: Handle): InspectSnapshot;
interface InspectSnapshot {
id: number | undefined; // stable for the handle's lifetime
kind: "signal" | "computed" | "effect" | undefined;
observed: boolean; // hasObservers(handle)
value: unknown; // peek (may be stale for unread computeds)
observerCount: number;
sourceCount: number;
observers: Descriptor[]; // subscribe order
sources: Descriptor[]; // dependency-read order
}const total = computed(() => price() * qty());
total(); // force evaluation -> deps recorded
inspect(total);
// {
// id: 4, kind: "computed", observed: false, value: 300,
// observerCount: 0, sourceCount: 2,
// observers: [],
// sources: [{id:1, kind:"signal", value:100}, {id:2, kind:"signal", value:3}]
// }On a non-handle (null, foreign-registry signal, plain object) the shape is still well-formed: id/kind are undefined, all counts zero, both arrays empty.
subscribers(handle) / dependencies(handle)
function subscribers(handle: Handle): Descriptor[]; // what observes this
function dependencies(handle: Handle): Descriptor[]; // what this readsLive snapshots, in the engine's iteration order. Both are no-ops on a non-handle (return []).
const stop = effect(() => { total(); });
subscribers(total); // [{id:5, kind:"effect", value:undefined}]
dependencies(total); // [{id:1, kind:"signal", value:100}, {id:2, kind:"signal", value:3}]track(handle, onEvent)
Live connect / disconnect feed via lite-signal's observeObservers. Fires on the 0->1 and 1->0 observer transitions only (no immediate fire on registration; no churn while the observer count stays positive).
function track(
handle: Handle,
onEvent: (e: { type: "connect" | "disconnect"; id: number; observed: boolean; ts: number }) => void
): () => void;const off = track(price, e => console.log(e.type, "-> observed=", e.observed));
const stop = effect(() => { price(); });
// connect -> observed= true
price.set(101);
price.set(102); // no events -- observer count unchanged
stop();
// disconnect -> observed= false
off(); // idempotent unsubscribetrack() does NOT make the handle observed -- hasObservers(price) is still false for a track-only signal. The hook is a lifecycle listener, not a dependency edge.
Note: One lifecycle hook per node. lite-signal's
observeObservers(whichtrackis a thin adapter over) stores ONE callback per node -- a secondtrack()on the same handle silently overwrites the first. If your code path needs to share lifecycle visibility with another caller, multiplex through your own callback rather than re-registering.
monitor()
Pass-through to lite-signal's stats(). Kept here so consumers don't need to import lite-signal directly for live overlays.
function monitor(): RegistryStats; // see @zakkster/lite-signal for the shapeleakWatch(opts?)
Sample activeNodes over time; flag a sample as suspicious when its delta-vs-previous exceeds a threshold. Cadence is driven by @zakkster/lite-time's drift-corrected scheduler -- not a raw setInterval -- so the leak detector itself does not become part of the reactive graph it measures. (A watch(now)-based detector would.)
function leakWatch(opts?: {
sampleMs?: number; // default 1000
growth?: number; // default 32 -- delta threshold for leakSuspected
onSample?: (s: LeakSample) => void;
}): { stop: () => void; samples: LeakSample[] };
interface LeakSample {
ts: number;
activeNodes: number;
delta: number;
leakSuspected: boolean;
}The samples array is a rolling window (cap 128, oldest evicted FIFO). It's returned by reference -- point a chart at it and let the cadence keep it fresh.
const { stop, samples } = leakWatch({
sampleMs: 500,
growth: 16,
onSample: s => s.leakSuspected && console.warn("leak suspected:", s),
});report(handles)
One combined snapshot. Convenient as the body of a debug overlay redraw or the payload of an error report.
function report(handles: Handle[]): { stats: RegistryStats; nodes: InspectSnapshot[] };graph(roots, opts?) (requires lite-signal >= 1.1.5)
Breadth-first walk of the full DAG reachable from roots, in both directions (observers + sources). Returns deduped nodes (keyed by stable id) and directed edges (from is the source/dependency, to is the observer).
function graph(roots: Handle | Handle[], opts?: { maxNodes?: number }): {
nodes: Descriptor[];
edges: Array<{ from: number; to: number }>;
};const a = signal(1);
const b = computed(() => a() + 1);
const c = computed(() => a() * 2);
const d = computed(() => b() + c());
const stop = effect(() => { d(); });
graph(a);
// nodes: 5 (a, b, c, d, effect -- deduped, each appears once)
// edges: 5 (a->b, a->c, b->d, c->d, d->effect)maxNodes is a between-iteration cap (default 10000): once the result set crosses the threshold, the walk stops expanding new nodes. The returned graph is always consistent -- every edge endpoint is in nodes, even when capped.
toDot(g, opts?) (requires lite-signal >= 1.1.5)
Render a graph() result as Graphviz DOT.
function toDot(g: ReactiveGraph, opts?: {
name?: string;
maxLabel?: number;
labelResolver?: (id: number) => string | undefined; // 1.5: render-time host hook
}): string;Signals are ellipses, computeds are boxes, effects are diamonds. Layout is left-to-right, labels are monospace. Paste the output into edotor.net, dreampuf.github.io/GraphvizOnline, or pipe to dot -Tpng.
labelResolver(id) (1.5) is an optional render-time hook a host (e.g. @zakkster/lite-signal-decorators passing its labelOf) can supply to annotate node labels inline. It never touches graph() -- one snapshot renders under different resolvers, and it works on deserialize()d graphs. A string return replaces the default label (still passing through esc() + maxLabel); undefined/null or any non-string return falls through to the default label; a throw is caught and logged once (the node falls through). A non-function value throws TypeError at the door.
Label precedence (1.7): resolver > engine name > kind#id. A labelResolver string return still wins over a present engine name. With no resolver, the default label is the node's engine-native name (from describe().name, when capabilities().names), falling back to kind#id for an unnamed node -- the id is preserved in the DOT node comment. This replaces the raw-value default of <= 1.6.
toTree(root, opts?) (requires lite-signal >= 1.1.5)
Console-friendly indented tree. direction: "down" follows observers (subscribers); "up" follows sources (dependencies). Already-visited nodes are marked with a (seen) marker rather than expanded -- the graph is a DAG, so convergence is expected and shown explicitly.
function toTree(root: Handle, opts?: {
direction?: "down" | "up";
maxDepth?: number;
labelResolver?: (id: number) => string | undefined; // 1.5: render-time host hook
}): string;labelResolver(id) (1.5) behaves as in toDot: a string return replaces the default label (control chars stripped, then sliced to 24 chars), a non-string return falls through, and a throw is caught + logged once. A non-function value throws TypeError at the door. The same 1.7 precedence applies -- resolver > engine name > kind#id -- with the unnamed-node id preserved in the tree tooltip.
console.log(toTree(a));
// signal#1 = 1
// computed#2 = 2
// computed#4 = 6
// effect#5 = undefined
// computed#3 = 2
// (seen) computed#4 = 6diff(before, after) (1.1)
Diff two graph() snapshots. Nodes are matched by stable id, edges by direction. Pure and non-perturbing (it operates on already-captured snapshots). Under lite-signal 1.2's owner tree, re-running or disposing an owner cascade-disposes its owned observers -- those surface here as removedNodes, which is what makes the otherwise-internal ownership behaviour observable.
function diff(before: ReactiveGraph, after: ReactiveGraph): {
addedNodes: Descriptor[];
removedNodes: Descriptor[]; // includes 1.2 owner-cascade disposals
changedNodes: Array<{ id: number; kind: string; from: unknown; to: unknown }>;
addedEdges: Array<{ from: number; to: number }>;
removedEdges: Array<{ from: number; to: number }>;
};trace(roots, fn, opts?) (1.1)
Snapshot the graph, run fn synchronously, snapshot again, and diff -- the one-liner for "what did this action do to the graph", including owner-cascade removals under lite-signal 1.2.
const { diff } = trace([rootA, rootB], () => store.reset());
console.log(diff.removedNodes); // nodes that disappeared -- e.g. owned effects disposed on an owner re-runIf fn throws, the partial trace ({before, after, diff}) is attached to the thrown error as err.graphTrace and the error is rethrown. Crash post-mortem in one line: catch, read e.graphTrace.diff to see what the action did to the graph BEFORE it blew up.
try {
trace([root], () => doSomethingThatMightThrow());
} catch (e) {
console.error(e.message, "graph delta before crash:", e.graphTrace.diff);
throw e;
}capabilities() (1.1)
Engine capability snapshot. Lets consumers pick push vs poll and show/hide the ownership view without try/catch probing.
const c = capabilities();
// (1.3) full probe-derived vector on lite-signal 1.9.0-preview.6:
// { floor: "1.1.5", owners: true, mutationHook: true, burst: true,
// boxes: true, roots: true, ownerCapture: true, scopes: true,
// flushControl: true, explicitDispose: true, statsKeys: 14,
// poolPopulation: true, cleanupReturn: true }
if (c.mutationHook) {
// can use watchGraph push mode + profile
} else {
// fall back to leakWatch / poll-based monitoring
}Every key is a memoized load-time feature probe (never a version fingerprint) that fails closed to false on an engine that lacks the surface. owners reflects forEachOwned / ownerOf (lite-signal >= 1.3 -- ownerTree, graph({owners: true}), the track() dispose event); mutationHook reflects onGraphMutation (lite-signal >= 1.2.1 -- watchGraph push mode and profile); burst reflects the hook's enqueue / flush-pass payloads (lite-signal >= 1.6 -- burstProfile, watchAllocations().flushPassDelta).
New in 1.3 (detection scaffolding -- reported now, consumed by later sessions):
| Key | True when the engine exposes | Floor |
| --- | --- | --- |
| boxes | signalBox + computedBox | >= 1.5 |
| roots | createRoot | >= 1.5 |
| ownerCapture | getOwner | owner-capture family |
| scopes | createScope | >= 1.6 |
| flushControl | flush | >= 1.7 |
| explicitDispose | dispose and/or destroy | >= 1.5.0 |
| poolPopulation | stats() nodePoolPopulation + linkPoolPopulation | every engine >= 1.5.0 |
| cleanupReturn | effect-body returned cleanup (behaviour probe) | >= 1.8 |
| names | describe(handle) carries a name key (behaviour probe) | >= 1.10-preview |
| whyDirty | engine exports whyDirty() | >= 1.10-preview |
| settled | createRegistry({settled:true}) arms onSettled (behaviour probe) | >= 1.11-preview |
| statsKeys | count of stats() keys (band 13 / 14) | tier HINT only |
cleanupReturn, names and settled are the behaviour probes -- not typeof-detectable, so each runs exactly once at module load inside a throwaway createRegistry() that never touches the default registry (names describes a named scratch node through the registry's own describe, since module-level describe no-ops on a foreign node; settled requires both that createRegistry({settled:true}) exposes onSettled and that a default registry's onSettled throws, so an engine that ignored the option could not false-positive) (the non-perturbing contract holds through detection; proven by the torture suite's load-probe-neutrality law). statsKeys is a coarse tier hint for the cross-engine grid, not a version gate any helper branches on -- branch on the specific capability key instead.
findPath(from, to, opts?) (1.1)
Shortest dependency path between two handles, BFS over observer edges (direction: "down", default -- follows data flow) or source edges ("up"). The classic question it answers: a write to from re-ran effect to -- through which computeds?
const path = findPath(rootSignal, someEffect);
// [{id: 1, kind: "signal"}, {id: 4, kind: "computed"}, {id: 7, kind: "effect"}]
// "up" inverts: walk source edges from the effect back to the signal
const upPath = findPath(someEffect, rootSignal, { direction: "up" });Returns the descriptor path inclusive of both ends, or null (no path, or either endpoint is stale). from === to returns [start].
whyDirty(handle) (1.7, requires lite-signal >= 1.10-preview)
The engine's pull-side dirty-reason payload: for a computed, each dependency whose version moved past the observer's evalVersion -- the exact predicate the engine runs before it recomputes -- tracing to the root signal write. Read-only: it never pulls, so it cannot perturb the graph it describes (the non-perturbing contract holds over it).
const reasons = whyDirty(someComputed); // [] for a clean computed, or the moved depsReturns null when capabilities().whyDirty is false (engine below the 1.10 floor). A non-empty result requires a dirty-but-unpulled computed -- on the eager default registry a write flushes immediately, so capture on a createRegistry({ flushStrategy: "manual" }) and read before flush(). Empty ([]) for a clean computed and for non-computeds; a malformed handle yields null.
explain(from, to, opts?) (1.7, requires lite-signal >= 1.10-preview)
findPath(from, to) INTERSECT whyDirty along the path: the shortest dependency path, each hop annotated with reasons -- the on-path subset of that node's dirty dependencies. "A write to from re-ran to, through which computeds, and why each recomputed rather than short-circuited."
using reg = createRegistry({ flushStrategy: "manual" });
// ... build a > b > c > effect in reg, set a, then (before reg.flush()):
const hops = explain(a, c);
// [{id, kind, value, name?, reasons: [{id, ...rootCause}]}, ...] inclusive of both endsThe intersection is keyed by numeric node id, not descriptor identity (whyDirty yields fresh descriptor objects each call). Returns null when capabilities().whyDirty is false or findPath finds no path (a documented null, never an empty-but-plausible answer). Cold-path; allocates freely; adds no observer.
ownerTree(root, opts?) (1.1, requires lite-signal >= 1.3)
Nested ownership hierarchy from a root: which nodes this one owns (created inside its body) and would cascade-dispose on its next re-run. The dependency DAG answers "who updates whom"; this answers "who outlives whom".
const tree = ownerTree(outerEffect);
// { id: 12, kind: "effect", value: undefined, owned: [
// { id: 13, kind: "computed", value: 100, owned: [] },
// { id: 14, kind: "effect", value: undefined, owned: [] },
// ]}Returns null when the engine has no owner introspection. Companion to graph({owners: true}) -- same node-id space, orthogonal relation.
watchGraph(roots, cb, opts?) (1.1)
Event-driven graph observation. Microtask-coalesces mutations into one callback per microtask boundary, with a fresh snapshot and a structural diff against the previous one. Falls back to polling at opts.pollMs (default 250ms) on engines without the mutation hook -- consumers write one code path.
const w = watchGraph([root], ({ graph: g, diff: d, mutations, mode }) => {
if (d === null) return; // the immediate seed fire
console.log(`${mutations} mutation(s) coalesced; mode=${mode}`);
console.log(`+${d.addedNodes.length} nodes, -${d.removedNodes.length} nodes`);
});
console.log("running in", w.mode, "mode"); // "push" or "poll"
// ... later:
w.stop();Options: pollMs (poll fallback cadence, default 250), immediate (fire once at registration with diff: null to seed consumers, default true), plus everything graph() accepts (maxNodes, owners).
profile(opts?) (1.1, requires lite-signal >= 1.2.1)
Recompute counter. Counts re-runs per node id while active; .top(n) returns the busiest nodes. Catches the "computed re-evaluating 40,000 times behind one slider" footgun that's invisible in value snapshots.
const p = profile();
// ... do something the user might do (drag a slider, type in a search box) ...
const counts = p.stop(); // -> Map<id, recomputes>
console.log(p.top(5)); // top 5 hot nodesReturns null when the engine lacks the graph-mutation hook. Pair with inspect() to translate hot ids back into their kind / value for the report.
serialize(g) / deserialize(json) (1.1)
JSON-safe round-trip for offline viewing -- a studio panel, a bug report, a CI artifact. Non-primitive values are tagged by typeof ("[object]", "[function]"), bigints stringify with an "n" suffix, symbol-keyed walk handles drop out via JSON itself. The deserialized shape is toDot() / diff() compatible but NOT re-walkable (engine references are intentionally gone).
const snap = serialize(graph(root));
fs.writeFileSync("./crash-snapshot.json", snap);
// ... in a CI artifact viewer / bug-report reader / studio panel:
const back = deserialize(fs.readFileSync("./crash-snapshot.json", "utf-8"));
console.log(toDot(back)); // viewable graphviz
console.log(diff(prevSnap, back)); // comparable across timedeserialize() throws TypeError on a non-snapshot shape, and lets JSON.parse SyntaxErrors pass through.
burstProfile() (1.2, requires lite-signal >= 1.6)
Per-flush redundant-work profiler. Built on the richer graph-mutation payloads (which node actually re-ran, which effect was enqueued, the set scheduled per flush pass), it answers "is one update doing the same work twice?". redundant(n) lists nodes that recomputed more than once; shortCircuited(n) lists effects enqueued but short-circuited before running (the engine's glitch-avoidance paying off).
const b = burstProfile();
if (b) { // null unless capabilities().burst
// ... trigger one update (a write, a batch) ...
console.log(b.redundant()); // [{ id, ran }] nodes that ran > once
console.log(b.shortCircuited()); // [{ id, queued, ran, wasted }]
const summary = b.stop(); // { passes, perPass, queued, ran }
}Returns null unless capabilities().burst is true (the hook must emit enqueue / flush-pass payloads -- lite-signal >= 1.6). On older engines, reach for profile() (recompute counts only).
watchAllocations(cb, opts?) (1.2)
Steady-state allocation feed for a "the engine allocates nothing" panel (the lite-studio moat line). Samples lite-signal's cumulative counters off lite-time's every() scheduler -- out of the reactive graph, like leakWatch, so the monitor never instruments itself into what it measures. poolGrowthDelta and allocDelta come from always-on engine counters (accurate regardless of any listener); recomputeDelta needs the mutation hook and flushPassDelta needs lite-signal >= 1.6.
const a = watchAllocations(s => {
panel.poolLine.push(s.poolGrowthDelta); // flat at 0 in steady state
panel.workLine.push(s.recomputeDelta); // climbs under load
}, { sampleMs: 250 });
// ... later ...
a.stop(); // idempotent; detaches hook + timerSet recomputes: false for the pure always-on counter lines with no hook attached.
watchSettled(registry, cb) (1.8, requires lite-signal >= 1.11-preview)
Observe when a registry's graph settles -- the engine's onSettled drain-complete hook, surfaced as a non-perturbing counter. onSettled is a creation-time capability, so watchSettled observes a registry the consumer built with createRegistry({ settled: true }); devtools never arms it.
import { createRegistry } from "@zakkster/lite-signal";
const reg = createRegistry({ settled: true }); // consumer opts in
// ... build signals/computeds/effects in reg ...
const w = watchSettled(reg, s => {
hud.settleBadge.textContent = `settles: ${s.settles}`; // s = { settles, ts }
});
reg.batch(() => { a.set(1); b.set(2); c.set(3); }); // one settle (coalesced)
w.count(); // -> 1
w.stop(); // idempotent; also `using w = ...`Fires cb({ settles, ts }) once per top-level, non-empty, clean drain -- a batch() of many writes, or a single write fanning out to many effects, is one settle; an empty flush() is none. The engine callback receives no arguments, so settles (a monotonic count) and ts (performance.now() or Date.now()) are devtools-synthesized. count() returns settles-since-attach; stop() is an idempotent external detach, Symbol.dispose-stamped for using.
Returns null when capabilities().settled is false or registry is not settled-capable (a default registry's onSettled throws) -- a documented null, never a live-but-dead handle. Attaching the listener leaves stats() byte-identical (the counter stays out of the graph it measures). A throwing cb is swallowed (cold-path) and corrupts neither the count nor the listener, and a stop() called from inside a settle callback defers its detach to the end of the fire -- so it can never trip the engine's mid-fire self-unsubscribe edge.
Workflows
Building a debug overlay
import { monitor, report, graph, toDot } from "@zakkster/lite-devtools";
function redrawOverlay() {
const s = monitor();
overlay.textContent =
`signals: ${s.signals} computeds: ${s.computeds} effects: ${s.effects}\n` +
`activeLinks: ${s.activeLinks} / ${s.linkPoolCapacity}\n` +
`nodes: ${s.activeNodes}`;
}
setInterval(redrawOverlay, 250); // overlay redraw is cold by definitionCatching leaks in dev
import { leakWatch } from "@zakkster/lite-devtools";
if (process.env.NODE_ENV === "development") {
leakWatch({
sampleMs: 2000,
growth: 16,
onSample: s => {
if (s.leakSuspected) console.warn(
`[leak] +${s.delta} nodes since last sample (now ${s.activeNodes})`
);
},
});
}Auto-pausing a clock against track()
import { signal, effect } from "@zakkster/lite-signal";
import { track } from "@zakkster/lite-devtools";
const now = signal(performance.now());
let raf = null;
track(now, e => {
if (e.type === "connect" && !raf) {
const tick = () => { now.set(performance.now()); raf = requestAnimationFrame(tick); };
raf = requestAnimationFrame(tick);
} else if (e.type === "disconnect" && raf) {
cancelAnimationFrame(raf); raf = null;
}
});
// The RAF loop only runs while something has actually subscribed to `now`.(In production, prefer observeObservers directly -- it's the same hook with no devtools wrapper. track() is for the debug case where you also want timestamps and a uniform event shape.)
Scoped teardown with using (1.6)
Every stopper carries Symbol.dispose where the runtime supports explicit resource management, so a using binding tears the watcher down at scope exit -- no manual stop(), no leak if the block throws:
import { leakWatch, watchGraph, pendingEffects } from "@zakkster/lite-devtools";
function auditFrame(roots) {
using watch = leakWatch({ sampleMs: 250, growth: 16 });
using graph = watchGraph(roots, p => report(p.diff));
using queue = pendingEffects(); // null-safe: `using x = null` is a legal no-op
runOneFrame();
// watch, graph, queue are all disposed here -- even on an early return or throw.
}The stamp routes to the same idempotent stop(), so mixing a manual stop() with the automatic dispose (either order) is a no-op, never a double-teardown. track()'s bare-function disposer is self-stamped (off[Symbol.dispose] === off); the object handles stamp h[Symbol.dispose] === h.stop. On a runtime without Symbol.dispose the stamp is simply absent (fail-closed) and you call stop() yourself.
Annotating a render with labelResolver (1.5)
A host can label rendered nodes at render time without touching the graph -- one snapshot renders under different resolvers, and it works on deserialize()d graphs:
import { graph, toDot, nodeId } from "@zakkster/lite-devtools";
const names = { [nodeId(price)]: "price", [nodeId(total)]: "total" };
const dot = toDot(graph(price), { labelResolver: id => names[id] });
// A `string` return replaces the node label; `undefined` falls through to the
// default; omit `labelResolver` entirely and the render is byte-identical.Exporting a DAG snapshot to PNG
node -e 'import("./Devtools.js").then(({graph,toDot}) => process.stdout.write(toDot(graph(rootSignal))))' \
| dot -Tpng -o graph.pngCost model
This is the cold/debug path. Allocations happen -- that's the design.
| Helper | Per call |
|-----------------|-----------------------------------------------------------------------|
| inspect | One result object + two arrays + N descriptors (observers + sources) |
| subscribers / dependencies | One array + N descriptors |
| track | One closure pair at registration; zero per event fire |
| monitor | One stats object (whatever lite-signal allocates for stats) |
| leakWatch | Cadence allocates per tick (lite-time's every thunk) |
| report | One snapshot wrapper + N inspect() results |
| graph | O(N) descriptors, two Maps/Sets, one queue array |
| toDot | One string per node + one per edge, joined |
| toTree | One string per visited node, joined |
Engine state is left untouched. The promise is not "zero alloc" -- it's "zero contamination." Inspect a node 10,000 times and the engine's stat counters won't move by one.
Interactive demo
A high-tier interactive demo lives at demo/index.html. Open it via any static server (python -m http.server, npx serve, etc.) -- pure ES modules, no build step.
What it shows:
- Live DAG visualization of a non-trivial reactive graph (signals, computeds, a diamond, effects), redrawn from
graph()on every change. - Pool occupancy strip -- signals / computeds / effects / activeLinks / activeNodes.
- Lifecycle log --
track()'d connect/disconnect events streaming in real time. - Inspect panel -- click any node to see its full
inspect()snapshot. - Bug-tracking scenarios -- a panel of canned cases (diamond glitch-freeness, lazy-computed deferral, untrack semantics, batch coalescing, dispose cleanup, deep chain, leak simulation, non-perturbation, the 1.5
labelResolverrender hook, and the 1.6Symbol.dispose/usingteardown contract) each with a PASS/FAIL indicator computed from monitor() and inspect() -- so a QA engineer can drive the demo through each scenario and see, mechanically, whether the engine is behaving correctly.
This is the artifact to hand to QA when you're shipping a new lite-signal version, or to a client during an architecture review.
Testing strategy
The suite (Node's built-in --test) covers:
| File | Focus |
|---|---|
| 01-inspect.test.mjs | Single-node read surface -- inspect / subscribers / dependencies, non-handle inputs, dynamic re-tracking |
| 02-track.test.mjs | Lifecycle feed -- 0->1 / 1->0 transitions, idempotent unsubscribe, no churn |
| 03-monitor-report.test.mjs | Engine stat pass-through, per-handle aggregation |
| 04-leak-watch.test.mjs | Cadence + delta + threshold + rolling-window cap |
| 05-graph.test.mjs | BFS walk, dedupe, diamonds, bidirectional reachability, maxNodes |
| 06-render.test.mjs | toDot output shape, toTree direction + convergence markers + maxDepth |
| 07-non-perturbing.test.mjs | The headline contract -- every read-side helper leaves engine state unchanged |
| 08-edge-cases.test.mjs | Cross-registry isolation, disposed handles, descriptor-as-handle, churn baseline |
| 09-extras.test.mjs | Capability tier reality check, track() allocation pressure |
| 10-find-path-and-owners.test.mjs (1.1) | findPath direction + null-on-disconnected, ownerTree, graph({owners}), trace cascade, maxNodes inside-expansion fix |
| 11-watch-and-profile.test.mjs (1.1) | watchGraph push/poll branches, profile counts + equality-cut visibility, capabilities() |
| 12-serialize-and-stale.test.mjs (1.1) | serialize/deserialize round-trip (primitives, bigint, objects, functions), inspect().stale, trace() throw-attachment |
| 13-burst-and-allocations.test.mjs (1.2) | capabilities().burst flag; burstProfile() redundant-node + short-circuited-effect tallies ({id, queued, ran, wasted}), idempotent stop(), clean hub re-install, null when the engine lacks burst payloads; watchAllocations() numeric sample fields on the lite-time cadence, recomputeDelta under load, idempotent stop |
npm test # full suite, ~1.5s
npm run test:gc # adds --expose-gc so the leakWatch heap-budget test engagesThe non-perturbing suite (#07) is the one that matters most. It's the executable form of the headline promise. The owner-tree and graph-mutation-hook tests in 10-12 skip cleanly when the engine doesn't expose those APIs (probed via capabilities()).
What this is not
- Not part of the hot path. Don't call
graph()inside a render loop. The graph walk allocates per node -- that's fine in dev, not fine at 120 fps. - Not a time-travel debugger. No history, no replay. lite-signal's writes are synchronous and don't snapshot; rebuilding that here would be a different package.
- Not an engine fork. Every introspection call goes through lite-signal's public surface. If
lite-devtoolscould see something this package can't, it would mean lite-signal had a hidden API -- which it doesn't. - Not a substitute for tests. This is for observing a live graph. To prove correctness, you still need lite-signal's own conformance and behaviour suites.
Ecosystem
Part of the @zakkster zero-GC stack:
@zakkster/lite-signal-- the reactive engine this package inspects.@zakkster/lite-time-- drift-corrected wall-clock cadence; powersleakWatch.@zakkster/lite-store-@zakkster/lite-resource-@zakkster/lite-form-@zakkster/lite-router-- state & data layers.@zakkster/lite-element-@zakkster/lite-virtual-@zakkster/lite-scene-- rendering.@zakkster/lite-raf-- frame-rate scheduling.
Browser and runtime support
Pure ES2020. Runs anywhere lite-signal does.
| Target | Supported | | --------------------------------- | --------- | | Chrome / Edge (last 2 majors) | yes | | Firefox (last 2 majors) | yes | | Safari 14+ | yes | | Node.js 18+ | yes | | Bun | yes | | Twitch Extensions (1MB / 3s) | yes (but don't ship devtools to production) | | Cloudflare Workers | yes | | Deno | yes |
ESM-only.
FAQ
Will calling inspect() from inside a computed body add a dependency on the inspected signal?
No. inspect reads value via peek(), which is untracked. The enumerator walks use forEachObserver/forEachSource directly -- they don't read through the tracking machinery at all. You can inspect from a computed, an effect, or an onCleanup body without contaminating the dep set.
Does graph() see effects?
Yes -- they appear as nodes with kind: "effect". Effects can't be a graph root (their dispose handle is a plain function with no introspection metadata), but they're reachable from any signal/computed they observe via the BFS walk.
Is track() the same as subscribe()?
No. subscribe adds an observer (hasObservers flips true; the engine starts pulling). track adds a lifecycle listener that fires when other observers connect or disconnect. Two completely different operations.
Can I use this in production?
You can -- it's small, MIT, no surprises. But the whole package is O(N) per call and exists to be called rarely. The right shape is: include it in dev, lazy-import it in production behind a debug flag.
Why doesn't inspect() cache anything?
Because caching invites staleness. The whole point is that you call it now and get an accurate picture of the engine now. If you need a frozen snapshot, that's report(), and you're explicit about freezing.
leakWatch is using every from @zakkster/lite-time -- why not setInterval?
Because every is drift-corrected (doesn't accumulate scheduling jitter), boundary-aligned (1s ticks land on the second), self-unrefs (the Node test runner doesn't hang waiting for it), and -- critically -- does not register itself as a reactive observer of anything. A naive watch(now)-based detector would instrument itself into the very graph it's measuring; every does not.
What about the v1.2 ownership hybrid in lite-signal?
The introspection surface is unchanged in 1.2 -- descriptors, ids, observers/sources all carry through. lite-devtools should keep working unmodified. When the owner tree lands, additional helpers (something like tree(scope)) become buildable on top.
License
MIT (c) Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal-lite-time-lite-store-lite-element-lite-scene
