@pyreon/devtools is a Chrome DevTools extension for inspecting Pyreon applications — a component tree, a live reactive graph (signals / computeds / effects), fire timeline, and a page-world console. It is a private workspace package (packages/tools/devtools), built and loaded unpacked.
Installation
It lives in the Pyreon monorepo — build it from there:
bun install
bun run --filter='@pyreon/devtools' buildThen open chrome://extensions, enable Developer mode, click Load unpacked, and select packages/tools/devtools/dist/. A Pyreon panel appears in Chrome DevTools.
How it connects
The framework installs a hook automatically on the first mount() in the browser (no-op on the server, tree-shaken in production):
window.__PYREON_DEVTOOLS__
// component tree: getComponentTree() · getAllComponents() · highlight(id)
// onComponentMount(cb) · onComponentUnmount(cb)
// enableOverlay() · disableOverlay() (also Ctrl+Shift+P)
// reactive bridge: reactive.activate() · reactive.deactivate()
// reactive.getGraph() · reactive.getFires()
// reactive.showOverlay() · reactive.hideOverlay() (also Ctrl+Shift+R)
// reactive.nodesForElement(el) (DOM→signal — overlay Inspect / 🎯)$p is a console helper for the same data ($p.tree(), $p.components(), $p.help()).
Four isolated execution contexts cooperate: a page hook (reads __PYREON_DEVTOOLS__), a content script (bridges the isolated-world boundary), a background service worker (routes panel ↔ page), and the DevTools panel UI.
Panel tabs
The panel is skinned on the Pyreon brand identity (ink/ember/cyan, JetBrains Mono + Space Grotesk).
Components — the mounted component tree (rebuilt from
parentId— the framework registers post-order, so a parent's ownchildIdsis empty when its children register first). A freshly-mounted component pulses with the signature ember signal-propagation animation (reduced-motion gated). Click to highlight the DOM node; the inspector shows id / parent / children. The toolbar Inspect toggle drives the element-picker overlay.Signals — every tracked signal / computed / effect: name, kind, value preview, subscriber count, fire count, sorted by activity; hot rows ember-tinted.
Graph — a layered SVG dependency diagram (signals → derived → effects), ember edges on the recently-fired path.
Effects — per-node fire lanes across the observed time window.
Profiler — fires bucketed into 100 ms frames with a peak/frame summary.
Console — evaluates expressions in the inspected page's own world (e.g.
__PYREON_DEVTOOLS__.reactive.getGraph()), result streamed back.
Programmatic API
The extension reads everything through a global hook the framework attaches in the browser — you can use the same surface directly (custom devtools, tests, an in-app debug panel) without the Chrome extension.
The window.__PYREON_DEVTOOLS__ hook
@pyreon/runtime-dom auto-installs the hook on the first browser mount() (idempotent, SSR-safe — a no-op when there is no window). It exposes the component-tree surface:
| Member | Description |
|---|---|
version | Hook protocol version. |
getComponentTree() | Root components as a collapsible hierarchy. |
getAllComponents() | Flat list of every mounted component entry. |
highlight(id) | Briefly outlines a component's DOM element. |
onComponentMount(cb) / onComponentUnmount(cb) | Subscribe to mount/unmount. |
enableOverlay() / disableOverlay() | Click-to-inspect element picker. |
reactive | The opt-in reactive bridge (see below). |
Reactive bridge (opt-in)
The reactive bridge is a leak-free, opt-in introspection layer over the live signal / computed / effect graph, exported from @pyreon/reactivity and proxied onto __PYREON_DEVTOOLS__.reactive:
import {
activateReactiveDevtools,
deactivateReactiveDevtools,
isReactiveDevtoolsActive,
getReactiveGraph,
getReactiveFires,
} from '@pyreon/reactivity'
activateReactiveDevtools() // attach — start recording the graph
const graph = getReactiveGraph() // { nodes, edges } snapshot, derived fresh
const fires = getReactiveFires() // bounded ring buffer of recent fires
deactivateReactiveDevtools() // detach — drops all retained stateSnapshot shapes:
type ReactiveNodeKind = 'signal' | 'derived' | 'effect'
interface ReactiveNode {
id: number
kind: ReactiveNodeKind
name: string // signal `.label`, else synthetic (`derived#12` / `effect#7`)
value: string // bounded preview (signals/derived only)
subscribers: number // live downstream subscriber count
fires: number // total fires/recomputes since activation
lastFire: number | null // performance.now() of the last fire
}
interface ReactiveEdge {
from: number // the reactive value being read
to: number // the computed/effect that read it
}
interface ReactiveGraph {
nodes: ReactiveNode[]
edges: ReactiveEdge[]
}
interface ReactiveFire {
id: number
ts: number // performance.now() at fire time
}Zero cost until attached. Every instrumentation point early-returns until activateReactiveDevtools() is called, and the single call site per node creation / fire sits inside the standard process.env.NODE_ENV !== 'production' gate, so it is fully tree-shaken from production builds. No retention. Nodes are held via WeakRef and pruned by a FinalizationRegistry — the registry never keeps a signal/computed/effect alive; edges and the fire buffer hold only numeric ids and timestamps, never node references or values. getReactiveGraph() recomputes edges fresh from the live subscriber sets on each call, so it never drifts out of sync.
This is the Foundation the extension's Signals, Graph, Effects, Profiler, and Console tabs consume. The Profiler tab buckets fire timestamps into 100 ms frames; a true per-frame duration flamegraph additionally needs run-duration instrumentation in the Foundation (deferred).
Only user signal() / computed() / effect() are tracked — compiler-emitted DOM-binding plumbing (renderEffect / _bind) is intentionally excluded so the graph stays meaningful and the hottest path untouched. The extension consumes reactive defensively: a page running an older @pyreon/runtime-dom (no Foundation) shows an explicit "needs the Foundation" notice rather than a fake/empty surface — Components works regardless, and polling runs only while a reactive tab is open.
Reactive dev overlay — zero-install, Ctrl+Shift+R
The reactive bridge also drives a zero-install in-app panel: press Ctrl+Shift+R in any dev build (or call __PYREON_DEVTOOLS__.reactive.showOverlay()) and a floating panel appears over the live graph — no Chrome extension, no wiring.
__PYREON_DEVTOOLS__.reactive.showOverlay() // mount the panel (also: Ctrl+Shift+R)
__PYREON_DEVTOOLS__.reactive.hideOverlay() // remove it
// or, from the console shorthand:
$p.reactivity() // toggleThe panel has three tabs:
Health
The graph-wiring readout: a summary header (N signals · M derived · K effects · E edges) followed by the insights that only the graph can surface — the same ones describeReactiveGraph computes:
orphan-signal— nothing depends on this signal: dead reactivity or an unused signal.high-fanout— changing this signal re-runs many effects; a hot hub worth auditing.deep-chain— this node sits at the end of a long dependency chain.
Activity — "why did X update?"
The runtime causal view, powered by getReactiveFires + getUpdateCause / formatUpdateCause. Interact with the app, switch to Activity, and the panel shows the recent reactive fires (newest first) plus the causal chain that explains the most recent one:
Recent updates (newest first):
• total (derived)
• price (signal)
• qty (signal)
Why did total (derived) update?
qty (signal) changed
→ total (derived) recomputed ← explainedThis is the inverse of React DevTools' "why did this render?" — instead of a whole-component re-render reason, it reconstructs the exact signal→computed→effect chain from the dependency graph. (React DevTools can't do this; nobody else's in-browser panel does either.)
Inspect — point at the pixel, get the signal
The DOM→signal picker. Press the 🎯 Pick button, then click any element in your app, and the panel shows the signals whose values that element's text displays — plus each one's causal chain:
<span> displays 1 reactive value:
• count (signal)
Why did count (signal) update?
count was updated directly (no upstream dependency fired before it).The correlation is exact, not a heuristic: it's captured at bind time in _bindText, where both the text node and its source signal are in scope — so clicking a {count()} element resolves to exactly count, never a guess. Available programmatically as __PYREON_DEVTOOLS__.reactive.nodesForElement(el) (also exported from @pyreon/runtime-dom) and via the console shorthand $p.pick(). Escape cancels picking.
Scope: text bindings only — the dominant "I see a wrong value" case. Attribute / class / style bindings and multi-signal text expressions aren't correlated (their owner element isn't in scope at bind time). Returns [] in production (nothing is tagged).
Opening the panel auto-activates graph/fire tracking, so it works even if the app never called reactive.activate(). It's build-only in effect: the whole devtools module (overlay included) is tree-shaken from production by the process.env.NODE_ENV !== 'production' gate, so there is nothing to ship or disable. Use the ⟳ button to re-read after interacting with the app. This is the runtime complement to the component-inspect overlay (Ctrl+Shift+P, hover-to-inspect DOM) and the Reactivity Lens editor hints (compiler-side static analysis).