npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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(

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 version sponsor Non-perturbing npm bundle size npm downloads npm total downloads lite-signal peer lite-time peer types dependencies license

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-time
import { 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

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.

  • settled capability -- true when the engine arms onSettled (createRegistry({ settled: true }) exposes a working onSettled, and a default registry's onSettled throws). A fail-closed behaviour probe in a throwaway registry -- the 16th capabilities() key.
  • watchSettled(registry, cb) -- observes a consumer-built settled registry (devtools never arms the capability). On each top-level, non-empty, clean drain -- a batch() or a fan-out is one settle; an empty flush is none -- it bumps a monotonic counter and calls cb({ settles, ts }) (the engine callback takes no args, so the payload is synthesized). Returns { count, stop } or null when capabilities().settled is false or the registry is not settled-capable. stop() is idempotent and using-disposable. Cold-path; attaching leaves stats() byte-identical -- the counter never perturbs the settles it counts. 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 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 optional name from describe(handle).name when capabilities().names. The key is ABSENT on unnamed nodes and below-floor engines -- never "".
  • Label precedence: resolver > engine name > kind#id. toDot / toTree now default an unnamed, unresolved node to kind#id (the raw-value default of <= 1.6 is replaced; the id is kept in the DOT comment / tree tooltip). A labelResolver string 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's evalVersion), null below the floor. Never pulls, adds no observer.
  • explain(from, to, opts?) -- composes findPath INTERSECT whyDirty (by node id) to annotate each hop on the path with why it recomputed; null when the capability is absent or no path exists.
  • serialize() schema v1 -> v2 round-trips name; 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}, where wasted = queued - ran). stop() freezes the tallies and is idempotent. Returns null unless capabilities().burst is true -- on older engines reach for profile() (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's every() scheduler, out of the reactive graph -- like leakWatch, it never instruments itself into what it measures. poolGrowthDelta / allocDelta come from always-on engine counters; recomputeDelta needs the mutation hook and flushPassDelta needs 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 an op7 - op5 gauge under-reads). Omit the argument to gauge the default registry (honestly 0 outside a batch() on the eager default); pass a createRegistry({flushStrategy}) handle to gauge that world -- a foreign registry gets its own hub so the default registry's stats() is never perturbed. Returns { depth(), peak(), stop() }, or null unless 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:

  1. Touches the graph only via lite-signal's public introspection surface -- hasObservers, observeObservers, forEachObserver, forEachSource, nodeId, describe. No internal symbol access, no patched objects.
  2. 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 call inspect() in a tight loop without changing any counter.
  3. 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 .-> App

What you get

  • inspect(handle) -- single-node snapshot: id, kind, value, observed, observer/source counts and descriptors. (1.1: now carries a stale flag for handles whose engine slot has been recycled.)
  • subscribers(handle) -- observer descriptors. Subscribe order.
  • dependencies(handle) -- source descriptors. Dependency-read order.
  • track(handle, onEvent) -- live connect / disconnect feed via observeObservers. Transition-only. (1.1: also delivers dispose events 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 from roots. Dedupes by stable id. Bidirectional (observers + sources). (1.1: {owners: true} adds owner edges to the walk frontier.)
  • toDot(g, opts?) -- render a graph() result as Graphviz DOT. Owner edges (kind: "owner") render dashed-and-gray. (1.5: optional labelResolver(id) host hook annotates node labels inline.)
  • toTree(root, opts?) -- indented text tree, with a (seen) marker at convergence. (1.5: optional labelResolver(id) host hook annotates node labels inline.)
  • diff(before, after) (1.1) -- structural delta between two graph() snapshots: added / removed / changed nodes and edges. Owner-cascade disposals show up as removedNodes.
  • trace(roots, fn, opts?) (1.1) -- snapshot, run fn, 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:

  1. Stale computed -- inspect() reads value via peek(), and peek() 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 with if (!isStale(c)) inspect(c) if that matters, or use graph() (which reads through describe() and doesn't force a pull).

  2. 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()'s peek()) is what establishes its dependencies. So inspecting a never-evaluated computed will materialise its dep edges and bump activeLinks accordingly -- and will fire track() on the now-newly-observed source signals. Pre-realize the computed (call it once) before inspecting if you need pure non-perturbation, or use graph() which reads metadata via describe() 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 reads

Live 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 unsubscribe

track() 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 (which track is a thin adapter over) stores ONE callback per node -- a second track() 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 shape

leakWatch(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 = 6

diff(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-run

If 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 deps

Returns 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 ends

The 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 nodes

Returns 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 time

deserialize() 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 + timer

Set 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 definition

Catching 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.png

Cost 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 labelResolver render hook, and the 1.6 Symbol.dispose/using teardown 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 engages

The 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-devtools could 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:


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