firsthand

ADR-0020: Devtools that production does not pay for

Status: accepted · 2026-09-20 · the package is experimental: the mechanism below is settled, the surface it exposes is not

Problem

Fine-grained reactivity is pleasant in the small and opaque in the large. The question that comes up in a big application is always some version of:

order.status
   ↓
computed(isEditable)
   ↓
Button.disabled

Which signal updates this DOM node? What depends on this computed? Why did that effect just run? Which query did that invalidation hit? When does this component’s scope end? In a framework that re-renders, the answer is “the component ran again” and a profiler is enough. Here the answer is a path through a graph, and nothing shows it.

The constraint is absolute: production must pay nothing. Not a branch in the read path, not a field on a cell, not a byte in the bundle. .value is the hottest path in the framework and bench:ic exists to keep it monomorphic.

What is already there

The reactive graph is not something devtools need to record. It is the mechanism:

That is the finding this ADR rests on. Devtools do not instrument the graph. They read it.

What is genuinely missing is three things: names, a way in, and the causes of an effect run, which is the one fact the graph does not retain because nothing needs it after the flush.

Constraints

Options

  1. A field on Cell. One name per node. Costs memory per cell in production and changes the shape every graph access sees. Rejected on ADR-0002 grounds alone.
  2. An exported devtools API on core. Bytes in every application that never opens devtools.
  3. A global hook, spoken to only from each package’s dev module. Each package already has dev.ts, which the production build aliases to dev.prod.ts — empty bodies, and because those functions are not exported from the package’s entry, the minifier removes the calls entirely. Verified for ADR-0019: no check and no message string in the shipped bundle.

Chosen design

Option 3.

The hook. globalThis.__FIRSTHAND_DEVTOOLS__ is an object that @firsthandjs/devtools installs. Every package talks to it through its own dev.ts and never through an import of another package, so there is no new cross-package surface and nothing to export. In production the modules that mention it do not exist.

Off until asked. Recording names costs a WeakMap write per cell, which a hundred thousand rows would notice even in development. So nothing is recorded until @firsthandjs/devtools is imported and attaches. The cost of not attaching is one if in a module that production does not ship.

Names. Two sources today, and a third that is designed for:

Causes. The one piece of bookkeeping: what changed. Reported from write, once per write, and paired with whatever runs before the next one — which is what turns “this effect ran” into “this effect ran because order.status changed”. It was first placed in the propagation loops, where the dependency is known exactly; that was a hook in the inner loop of the graph for a slightly better answer, and the trade was the wrong way round.

Performance implications

Measured rather than asserted, because the constraint was the point.

Memory implications

Everything devtools keeps is in WeakMaps keyed by the cell or the node, so a disposed subtree takes its labels with it. The root registry holds weak references and is swept when it is read.

DX implications

Rejected alternatives