Skip to content

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.

A reactive UI’s perf bugs are almost always one of three things:

  1. 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.
  2. 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.
  3. 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.

react-scaninfrared
update unit visualizedcomponent re-renderindividual DOM binding
instrumentation surfacefiber traversal + reconciler patchruntime hooks (options._*) — same pattern preact uses
”wasted work” detectionre-renders that produced identical outputeffects whose body wrote no DOM mutation
visualize subscription graphnoyes (force-directed, edges weighted by update frequency)
heatmap of hot bindingsnoyes
size (minified)~13 kBtarget ≤ 4 kB brotli
coupling to runtimedeep (private React internals)one stable hooks API
script-tag drop-inyesyes
programmatic APIyesyes

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.”
metricactionability
effect runs / secreactive churn rate — “is your app overworking?“
binding updates / secactual DOM mutation rate — finer than the above
signal sets / secstate-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 changeeffect ran, wrote, but produced an identical string. Likely a missing equality guard upstream.
dispose / create churnmount/unmount thrash — components being created and torn down rapidly
signal count / effect countmemory pressure proxy
subscription graphwhich effects subscribe to which signals; surface accidental wide-graph subscriptions
scheduler queue depthhow backed up is the microtask queue at any given tick

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?”

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.

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
})

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.

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.

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 from setText / 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.

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.

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.

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.

phazescopesticks
MVP (0.0.1)runtime hooks + panel + DOM pulses + slowest-effects listwill not change in 0.0.x
0.1.0heatmap mode + ignore patterns + name-based filteringminor refinement
0.2.0subscription graph view (force-directed)new feature, lazy-loaded
0.3.0”record a session” — capture a few seconds of activity, replay/scrub through it offlinebigger lift, pre-1.0 stretch
1.0API freezetied to phaze 1.0

Tracking issue and feedback channels will land alongside the runtime hooks PR.