infrared
@madenowhere/infrared is the phaze-native devtool. It surfaces every effect run, every signal write, and every DOM binding update in real time — directly on the page you’re debugging, no DevTools panel required.
It does what react-scan does, then keeps going. Where react-scan flashes a red outline on each re-rendered component, infrared flashes the individual binding that changed — style.transform on this <div>, the text node inside that <span>, an attribute toggle on the <button>. Phaze’s update unit is finer than a component, and infrared exposes that.
Why this exists
Section titled “Why this exists”A reactive UI’s perf bugs are almost always one of three things:
- A signal updates more often than you think. Some scroll handler is re-firing every event instead of being RAF-throttled, or a setter is being called inside an effect with no equality guard. The downstream graph re-runs hundreds of times per second.
- An effect is doing more work than it needs to. It re-runs when one of its tracked signals changes, but the body’s actual output (a DOM write, an API call) produces the same result. The framework can’t tell; the developer has to.
- A subscription you didn’t intend. You use
signal()inside a hot path and accidentally subscribe an outer effect, causing the whole effect to re-run on every change of the inner signal.
These are subtle. Profilers don’t surface them, because the work each individual effect does is tiny — it’s the frequency and the graph shape that matter. Infrared makes both visible at a glance.
Compared to react-scan
Section titled “Compared to react-scan”| react-scan | infrared | |
|---|---|---|
| update unit visualized | component re-render | individual DOM binding |
| instrumentation surface | fiber traversal + reconciler patch | runtime hooks (options._*) — same pattern preact uses |
| ”wasted work” detection | re-renders that produced identical output | effects whose body wrote no DOM mutation |
| visualize subscription graph | no | yes (force-directed, edges weighted by update frequency) |
| heatmap of hot bindings | no | yes |
| size (minified) | ~13 kB | target ≤ 4 kB brotli |
| coupling to runtime | deep (private React internals) | one stable hooks API |
| script-tag drop-in | yes | yes |
| programmatic API | yes | yes |
The two big architectural shifts:
- No fiber traversal. Phaze has no reconciler, no fiber tree, no alternate-fiber comparison. The runtime exposes lifecycle callbacks (
options._signalSet,options._effectRun,options._bindingWrite, etc.) and infrared subscribes to them. That’s the entire surface area. - Update-level granularity. Components in phaze run once. Re-renders aren’t a thing. The unit you’d want to track is the binding update — one DOM mutation per signal change. That’s a strictly finer signal than React’s “this component rendered N times.”
What it tracks
Section titled “What it tracks”| metric | actionability |
|---|---|
| effect runs / sec | reactive churn rate — “is your app overworking?“ |
| binding updates / sec | actual DOM mutation rate — finer than the above |
| signal sets / sec | state-write rate — “is something firing too often?“ |
| long effects (>16ms) | violations of the frame budget; named + timed; click → console.log the owner + source location |
| bindings updating with no value change | effect ran, wrote, but produced an identical string. Likely a missing equality guard upstream. |
| dispose / create churn | mount/unmount thrash — components being created and torn down rapidly |
| signal count / effect count | memory pressure proxy |
| subscription graph | which effects subscribe to which signals; surface accidental wide-graph subscriptions |
| scheduler queue depth | how backed up is the microtask queue at any given tick |
Visual surface
Section titled “Visual surface”DOM pulse. A 1-frame red outline on every element that just received a binding update. The pulse shows you not just that the element changed but which property — text, class, style, attribute — via a small label that fades with the outline.
Heatmap mode. Long-running pulses; bindings that update frequently glow brighter. Hold this on for ~5 seconds while interacting with your app and the hot bindings light up like a neon sign.
Floating panel. Top-right, draggable, collapsible. Lives counts (effects/sec, bindings/sec, signals/sec) plus a “slowest 10 effects” list. Click any row → console.log the effect’s owner + source location, using the name you passed to signal({ name }) / effect({ name }).
Subscription graph. Toggleable side panel with a force-directed layout — nodes are signals (round) and effects (square), edges are subscriptions, edge thickness scales with update frequency over the last second. The layout is the most useful tool we have for spotting “wait, why does that effect subscribe to this signal?”
Quick start
Section titled “Quick start”import { startInfrared } from '@madenowhere/infrared'
if (import.meta.env.DEV) { startInfrared()}That’s it. The defaults give you the panel and the DOM pulses. The import.meta.env.DEV guard tree-shakes infrared out of production bundles entirely — vite/rollup constant-fold the gate.
With options
Section titled “With options”startInfrared({ panel: true, // floating panel pulses: true, // DOM update pulses heatmap: false, // long-running heatmap graph: 'lazy', // 'on' | 'off' | 'lazy' (load when opened) threshold: 16, // ms — flag effects taking longer pulseDuration: 600, // ms ignore: [/^_internal\./], // skip signals/effects matching these names})Script-tag form
Section titled “Script-tag form”For pages where you can’t easily edit the entry, drop:
<script src="https://unpkg.com/@madenowhere/infrared/dist/script.js" defer></script>Auto-starts with default options. Same script can be loaded from a bookmarklet for ad-hoc inspection.
Naming your signals and effects
Section titled “Naming your signals and effects”Infrared’s labels come from the optional name field on signal() / effect():
const count = signal(0, { name: 'count' })effect(() => render(count()), { name: 'render-counter' })Without a name, infrared falls back to the source location (file:line:col) when source maps are available, otherwise to a generated id. Naming hot signals and effects is a small investment that pays off the first time you stare at a 200-node subscription graph.
Architecture
Section titled “Architecture”Two pieces, by design separable:
Runtime hooks live in @madenowhere/phaze itself. The runtime fires lifecycle callbacks at well-defined points:
_signalCreate(signal, name?)_signalSet(signal, prev, next)_effectCreate(effect, name?)_effectRun(effect, durationMs)_effectDispose(effect)_bindingWrite(node, kind, name, value)— fires fromsetText/setAttribute/setStyle/setClass_subscribe(signal, effect)_unsubscribe(signal, effect)
All of these are gated behind import.meta.env.DEV. In production builds, the calls are dead code and tree-shake to nothing — zero runtime cost, zero bundle cost.
Infrared itself is @madenowhere/infrared, a separate package. It wires into the hooks at startup, maintains a live update graph, throttles redraws, and renders the panel + pulses + (optionally) the graph view. Infrared has zero coupling to phaze’s internals beyond the hooks API.
Size budget
Section titled “Size budget”Target: ≤ 4 kB brotli for @madenowhere/infrared.
Breakdown:
- Hook subscription + counter maintenance: ~1 kB
- Panel UI (DOM-only, CSS via a single
<style>tag): ~1 kB - DOM pulses (CSS keyframes, throttled outline application): ~0.5 kB
- Slowest-effects list + click-to-console: ~0.5 kB
- Subtotal MVP: ~3 kB
- Optional graph view (force-directed layout, SVG render): ~1.5 kB → loaded lazily so it doesn’t pay for users who don’t open it
For comparison, react-scan ships at 13 kB minified / 5 kB gzip. Infrared aims for a third of that without losing capability — the savings come from not having to instrument a reconciler.
Why “infrared”
Section titled “Why “infrared””Phaze, photon, graviton — the package brand is the physical-science register. Ray was an early working name and clashed too directly with react-scan’s family. Infrared reads as “the wavelength you can’t see but that reveals the heat.” That’s exactly what the tool does for a reactive graph: surfaces the parts you can’t see directly but that are doing the work.
Status
Section titled “Status”Not yet shipped. The runtime hooks are scoped for phaze 0.0.5; the @madenowhere/infrared package follows shortly after. This page exists as the canonical spec — what’s documented here is what will ship in the MVP, modulo small adjustments based on real-world use.
Roadmap
Section titled “Roadmap”| phaze | scope | sticks |
|---|---|---|
| MVP (0.0.1) | runtime hooks + panel + DOM pulses + slowest-effects list | will not change in 0.0.x |
| 0.1.0 | heatmap mode + ignore patterns + name-based filtering | minor refinement |
| 0.2.0 | subscription graph view (force-directed) | new feature, lazy-loaded |
| 0.3.0 | ”record a session” — capture a few seconds of activity, replay/scrub through it offline | bigger lift, pre-1.0 stretch |
| 1.0 | API freeze | tied to phaze 1.0 |
Tracking issue and feedback channels will land alongside the runtime hooks PR.