Skip to content

DevTools: enumerate live Readables/Signals via an instrumented SignalRegistry #86

Description

@jonlaing

Motivation

There's no way today to enumerate the Readables/Signals live in a running app. That's fine for production but blocks any future DevTools work — a browser panel that shows current values, subscriber counts, or a reactivity graph needs a way to reach into the running app and ask "what exists?"

Current state

SignalRegistry exists as a Context.Tag (packages/core/src/Signal.ts:294) with make and scoped methods, and a Live layer — but the top-level Signal.make function doesn't consult it. Users calling Signal.make(initial) bypass the registry entirely, so today the registry is a defined-but-inert extension point.

Proposed sequence

Small opt-in hooks — no runtime cost when no registry is provided.

  1. Wire Signal.make through the registry, with a no-op fallback:

    export const make = <A>(initial: A) => Effect.gen(function*() {
      const opt = yield* Effect.serviceOption(SignalRegistry);
      const sig = /* existing make impl */;
      if (Option.isSome(opt)) opt.value.track(sig);
      return sig;
    });
  2. Add a Signal.label(name) combinator so DevTools UIs can show "cart.total" instead of an anonymous Signal<object>.

  3. Ship a SignalRegistry.Instrumented layer that keeps a WeakSet<Signal<unknown>> (or Set in debug mode) and exposes an accessor via window.__EFFEX_DEVTOOLS__.signals(). Users provide Instrumented instead of Live when they want tracking; production layer stack stays untouched.

Steps 1–3 alone give you Object.keys(window.__EFFEX_DEVTOOLS__.signals()) in the browser console — a rough MVP a real DevTools panel could build on top of.

Considerations before shipping

  • Naming. Signals are anonymous closures today. A Signal.make(initial, { name }) overload or a Signal.label combinator is needed for a legible UI; otherwise it's a wall of Signal<object> entries.
  • Creation site. Capturing new Error().stack at make time gives a "created at" reference for each entry. Costs a few microseconds; fine for dev, opt out for prod.
  • Subscribers. SubscriptionRef's PubSub doesn't expose subscribers. Sizes are introspectable, but to know who is subscribed you'd need to wrap .changes calls. Path to live subscriber graphs / time-travel later.
  • Derived Readables. Readable.map(sig, fn) creates a derived value; tracking the parent-child relationship at map time would let DevTools show a proper reactivity graph. Similar registry accessor pattern.
  • GC pressure. WeakSet won't retain signals past their scope (correct behavior, but DevTools snapshot changes constantly). Set retains them (leak in prod, useful in debug). Registry variant chooses.

Open questions

  • What does a v0 DevTools UI actually look like? Chrome extension, a Portal(...) in-page panel, or just a scriptable inspector? Depends on what value we want first — inspection vs. time-travel vs. reactivity graph.
  • Should the registry track derived Readables as first-class citizens or only "source" Signals?
  • Do we want per-signal history (last N values), or only current state?

Related

None yet — this is a fresh design chat between me and Claude.

Metadata

Metadata

Assignees

No one assigned

    Labels

    devtoolsDeveloper tooling: inspection, debugging, DevTools panels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions