@zakkster/lite-devtools
v1.1.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. Cold path -- zero hot-path footprint.
npm install @zakkster/lite-devtools
# peers (you almost certainly already have lite-signal):
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
- 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
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.toTree(root, opts?)-- indented text tree, with a(seen)marker at convergence.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). 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).
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 1.1 targets lite-signal >= 1.2.0 as the package's peerDependencies floor. The source eagerly imports describe and nodeId (so any older lite-signal throws a SyntaxError at module load anyway). Above that floor, the 1.1 line surfaces three layered tiers; use capabilities() to probe.
| Tier | What you get | What the engine needs |
|---|---|---|
| Floor (lite-signal >= 1.2.0) | 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 |
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 }): 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.
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 }): string;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();
// { floor: "1.1.5", owners: true, mutationHook: true }
if (c.mutationHook) {
// can use watchGraph push mode + profile
} else {
// fall back to leakWatch / poll-based monitoring
}owners reflects whether the engine exposes forEachOwned / ownerOf (lite-signal >= 1.3 -- needed for ownerTree, graph({owners: true}), and the track() dispose event). mutationHook reflects the onGraphMutation hook from lite-signal >= 1.2.1 -- needed for watchGraph push mode and profile.
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].
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.
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.)
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) 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 |
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
