Debugging Purity apps

Status: the Vite plugin offers an opt-in development panel (see ADR-0002). The inspection hook below remains available in every build.

Visual panel

Enable the panel in vite.config.ts:

export default defineConfig({
  plugins: [purity({ devtools: true })],
});

Start vite dev and click the floating Purity button. Search the current state, computed and effect nodes by kind, status or value, then select a row to see its version and source/observer connections. Node numbers belong to the current snapshot, not permanent identities. Values are text previews; objects are not expanded or evaluated.

The panel refreshes once a second while open. Refreshing pauses while a node row has keyboard focus to preserve its focus and details; use Refresh for an explicit new snapshot. Close or Escape returns focus to the trigger, clears the rows and stops polling. HMR disposal and host removal also clean up the panel. Snapshots are converted to text immediately so the panel does not retain inspected object values.

The panel requires the version 1 inspection hook to be installed when the page finishes loading. It supports Vite's base path, adds no application runtime dependency, and is excluded from production builds and vite preview. It shows the reactive graph, without source locations, a component hierarchy or time travel. Use the console hook below for deeper inspection.

Console inspection

When the UI doesn't behave the way the data says it should, you need to see the reactive graph. Purity ships a small global hook on globalThis.__purity_inspect__ that exposes every live signal, computed, and effect with their current values, statuses, and relationships. The hook is always installed (no dev/prod build flag needed). Its current bundle contribution has not been freshly measured.

Quick tour

Open the browser devtools console on a page that runs Purity and try:

__purity_inspect__.version;
// → 1

const all = __purity_inspect__.nodes();
all.length;
// → number of live state/computed/effect nodes

all.filter((n) => n.kind === 'state').map((s) => s.value);
// → all current state values

all.filter((n) => n.kind === 'computed' && n.status !== 'clean');
// → which computeds are dirty / waiting to recompute

The hook is on globalThis, so it works in any browsing context the framework runs in — main pages, iframes, web workers (if you use Purity there), jsdom in tests.

The InspectorNode shape

Each call to nodes() returns a fresh tree of InspectorNode objects:

interface InspectorNode {
  kind: 'state' | 'computed' | 'effect';
  version: number;
  status?: 'clean' | 'check' | 'dirty'; // present on computed and effect
  value: unknown;
  sources: InspectorNode[]; // empty for state
  observers: InspectorNode[];
}
Field What it tells you
kind Node type: state (writable signal), computed (derived), effect (watcher)
version Bumps every time the value actually changed. Useful for "did this update?"
status clean = up to date, check = ancestor moved, may need re-eval, dirty = known stale
value The current cached value (for state, the live value; for computed, the last computed value)
sources Nodes this node reads from. Empty for state.
observers Nodes that read this one. Forms a cycle with sources.

Cycles are preserved by reference identity within a single nodes() call: if A is a source of B and B is an observer of A, walking A.observers[0].sources[0] returns the same object as A.

Common debugging patterns

"Why hasn't my UI updated?"

// Find computeds that should have run but are still 'check' or 'dirty':
__purity_inspect__.nodes().filter((n) => n.kind === 'computed' && n.status !== 'clean');

If a computed is stuck at check it means an ancestor was marked dirty but the comparison hasn't re-run. Forcing a read (call the accessor) triggers the lazy update.

If it's stuck at dirty, the next read or the next flush will rebuild it. If you're not seeing that, check whether something is stalling the microtask queue (a long synchronous operation, or a debugger pause).

"Why did this fire?"

Check the version field. Every state write that changes the value bumps version. If a computed has run but the upstream state's version hasn't moved, the framework's CHECK→CLEAN fast path skipped the re-run — that's correct behavior, the value is still the previous one.

"How many things observe this state?"

const myState = __purity_inspect__.nodes().find((n) => n.value === 42);
myState.observers.length; // → number of computeds/effects watching this state
myState.observers.map((o) => o.kind); // → what kinds are watching

Useful when you suspect a leaked observer (a watch that should have been disposed but wasn't).

"Is anything leaking?"

const before = __purity_inspect__.nodes().length;
// ... mount and unmount a component a few times ...
const after = __purity_inspect__.nodes().length;
console.log(after - before, 'extra nodes');

A small positive number is normal (WeakRefs of GC'd nodes aren't immediately purged). A large or growing number across mount/unmount cycles indicates a leak — usually a watch() whose disposer wasn't called.

Snapshot-then-act pattern

The inspector returns a snapshot — values reflect the moment of the call. To diff before/after a user action:

const snap = (label) =>
  console.log(
    label,
    __purity_inspect__
      .nodes()
      .filter((n) => n.kind === 'state')
      .map((n) => ({ v: n.value, ver: n.version })),
  );

snap('before');
document.querySelector('#save-button').click();
queueMicrotask(() => snap('after'));

Things the hook doesn't expose

By design (see ADR-0002):

  • Variable names. Each node is identified by its kind/value/version, not by the JS variable that holds the accessor. state(0) and state(0) look identical from the hook's view.
  • Source location. No file:line of where the node was created.
  • Time travel. Every snapshot is current. There is no replay log.
  • The component tree. mount()/component() lifecycle is separate from the reactive graph; the hook only sees state/compute/watch nodes.

If your debugging case needs any of those, today the answer is console.log in the relevant render functions or watchers. A real devtools panel that adds these is post-1.0 (ADR-0002 trigger conditions).

What about console.log in framework code?

Purity logs with the prefix [Purity] for errors only — never on the happy path. Searching [Purity] in the console surfaces:

  • [Purity] cleanup error: — a watch cleanup function threw
  • [Purity] Error during disposal: — an onDispose callback threw
  • [Purity] Error in onDestroy: — an onDestroy callback threw
  • [Purity] Error in onError handler: — an onError itself threw (rare; usually means the error boundary is broken)
  • [Purity] Maximum effect depth exceeded. — a watch is feeding back into its own dep, infinite loop tripped at depth 100

If you see any of these and the cause isn't obvious, run __purity_inspect__.nodes() and look for the node whose value or status matches the failing operation.

Bundle impact

The inspector adds ~0.4 kB gzipped to the shared chunk. Always present, no NODE_ENV switch — Vite's library build substitutes process.env.NODE_ENV at our build time, which would strip the hook before downstream users could ever see it. Trade-off accepted.

If you must strip it from a production bundle:

// In your app's entrypoint, after importing from '@purityjs/core':
delete (globalThis as { __purity_inspect__?: unknown }).__purity_inspect__;

That removes the global; the conversion code in the framework still exists in the bundle (about 200–300 bytes), but no one calls it and modern bundlers may DCE the dead reachable code on a subsequent build pass.

Inspector memory regression check

Run npm run test:inspector:memory to build the core and exercise its ESM and CJS packages in separate Node processes. Each creates 600,000 transient signals over 12 cycles and measures retained heap after forced GC and event-loop turns. The inspector is never read: calling nodes() would prune dead entries and could conceal a broken automatic finalizer.

After two warm-up cycles, the check compares later samples with the median of three early samples and rejects growth above 8 MiB. The same check must reject a test-only control with automatic cleanup disabled. An unexpected crash or timeout is an error, not successful leak detection. CI runs both controls and records the samples in its job summary.

This is a repeatable regression workload, not proof that all applications are leak-free. Finalization timing and heap size vary by runtime. Without FinalizationRegistry, the supported fallback prunes dead entries when nodes() is read; automatic cleanup while the inspector is unopened requires FinalizationRegistry. Deterministic unit tests cover both paths.

Compatibility note

The hook's top-level version is 1. Future framework releases that change the InspectorNode shape will bump this number; any external panel reading the hook should check version === 1 first and fall back to "unsupported" rather than crashing.