Isotherm

A dead click is about belief, not wiring

You cannot tell from the DOM whether an element has a handler, so Isotherm detects dead clicks by watching what the user believed and whether the page blinked.

There is a rule for dead-click detection that sounds reasonable and is exactly backwards: treat cursor: pointer as evidence that the element has a handler. Developers set a pointer cursor when they attach behavior, the reasoning goes, so a pointer cursor is a proxy for wiring, and an element that looks wired up can be assumed to work and skipped.

Run that rule against the most common broken thing in an admin panel:

<div class="btn btn-primary" style="cursor: pointer">Export CSV</div>

A div styled to look like a button with nothing bound to it. Somebody deleted the Alpine directive in a refactor, or the handler is registered on DOMContentLoaded and the element renders later, or it was always a mockup that shipped. The rule's first act is to declare it healthy. It filters out the exact population it is supposed to find, because it mistook the visual cue for clickability as proof of functional clickability.

The question you can actually answer

You cannot know from outside an element whether it is wired up. getEventListeners exists only in DevTools. Inline onclick is visible; addEventListener is not, delegated handlers on document are not, and framework bindings are not — Vue, Alpine, Livewire and htmx all leave a handler-shaped void in the DOM. Any heuristic built on "is it wired" is guessing at something the browser deliberately does not expose.

So stop asking that. A dead click is not "a click on an element with no listener." It is a click the user believed in that the page ignored. That definition splits into two questions, and both are answerable:

  1. Did the element look clickable? That is the user's belief, and it is entirely a rendering fact.
  2. Did the page do anything at all in response? That is the outcome, and you can watch for it.

The first is looksClickable in sdk/src/selector.ts:

const INTERACTIVE = 'a,button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="tab"],[onclick],[contenteditable="true"]';

export function looksClickable(el: Element | null): boolean {
  if (!el) return false;
  if (el.closest(INTERACTIVE)) return true;
  return getComputedStyle(el).cursor === 'pointer';
}

Same cursor: pointer check the bad rule uses, opposite polarity. It is not an exemption, it is an inclusion — the reason the styled div gets watched instead of skipped. closest() rather than a match on the element itself, because the click target of a <button><span>Save</span></button> is the span, and the span is not the button.

Watching for an effect

The second half lives in sdk/src/collectors/clicks.ts. After a click on something that looked clickable, the SDK watches the page for 800 ms and asks whether anything moved:

const observer = new MutationObserver(settle);
observer.observe(document.documentElement, {
  childList: true,
  subtree: true,
  attributes: true,
  characterData: true,
});
window.addEventListener('scroll', settle, true);

const urlAtClick = location.href;
const timer = window.setTimeout(() => {
  if (done) return;
  done = true;
  observer.disconnect();
  window.removeEventListener('scroll', settle, true);
  if (location.href !== urlAtClick) onEffect();
  // else: nothing moved, nothing navigated — the click was dead.
}, DEAD_TIMEOUT);

Three signals, because a working click can manifest in ways that share no code path. A DOM mutation covers the overwhelming majority: a modal opening, a row expanding, a class flipping to is-active, a spinner appearing. A URL change covers pushState navigation in a router that swaps the view without touching the tree you are observing on the same tick.

Scroll is the weakest of the three and worth being precise about. It does not exist for scroll-to-anchor: <a href="#section"> writes the hash into location.href, so the URL comparison already catches it. What the scroll listener actually buys is programmatic scrolling — a "back to top" button calling window.scrollTo, a validation error calling scrollIntoView on the offending field — where the page moves and nothing in the DOM or URL says so. The cost is that a capture-phase scroll listener cannot tell a scrollIntoView from a user's trackpad. Any incidental scroll inside the 800 ms window counts as an effect, so a click followed by a shrug and a scroll is scored as working. That is a false negative, and it is in the direction I want to be wrong.

The MutationObserver is deliberately maximal — attributes, characterData and subtree all on. A button whose only visible response is toggling aria-expanded did work. Ignoring attribute mutations would report it as dead, and reporting a working button as broken is the failure mode that destroys trust in the whole tool.

800 ms is a judgment call. Long enough that a click firing an XHR whose response renders a table is not called dead, short enough that the observer is not attached to document.documentElement for the life of the page. Anything responding to a click with zero DOM change inside 800 ms is either broken or so slow the user has already given up. The dead-click emit is scheduled at DEAD_TIMEOUT + 20, twenty milliseconds after the watcher's own deadline, so the watcher has definitively settled before the verdict is read.

The bias, on purpose

This design is tuned to miss dead clicks rather than invent them, and it says so in the source, above the check:

// Known bias: a page with constant background DOM churn (polling, a live clock,
// Livewire refreshes) will register an "effect" for every click and report no dead
// clicks at all. That is the safe direction to be wrong in — a missed dead click
// costs nothing, a false one sends someone hunting a bug that is not there.

The failure is real and it is not narrow. A Livewire page polling every 2 s, a Vue app with a setInterval clock in the header, a toast library that re-renders a container, a virtualized table that recycles rows — each fires the MutationObserver inside 800 ms of every click on the page, and the dead-click count for that page is flat zero. Not "low." Zero. If you deploy Isotherm on a Livewire admin panel and see no dead clicks, do not conclude there are none. Conclude the detector is deaf on that page.

I considered fixing this and rejected the two obvious approaches.

Filter the mutation records. Diff the observed mutations against a baseline of what the page does when idle, and count only mutations plausibly caused by the click — inside the subtree of the clicked element, or a new element near it. This is a heuristic on top of a heuristic. It needs a per-page idle profile, it needs a definition of "near," and every rule it adds is a new way to call a working button dead. The cost of that error is severe and asymmetric: someone opens a ticket, spends an afternoon on a button that works, and stops believing the dashboard. A missed dead click costs nothing. The map just stays quiet.

Correlate with rage clicks. Rage detection is independent (three trusted clicks inside RAGE_WINDOW = 1000 ms within RAGE_RADIUS = 40 px, emitted once per cluster and then reset, so a hammering session yields one rage point rather than one per click). A rage cluster is strong evidence of something wrong, and it is tempting to say "rage on a looksClickable element implies dead." But rage also lands on buttons that work and are slow, and on elements the user misread. Rage and dead answer different questions and stay separate signals on the map. On a churning page you still see the rage, which is the practical mitigation and the honest one, because it does not pretend to know why.

The other things that are not clicks

Two guards on the way in, both about intent rather than mechanics:

if (!event.isTrusted) return;

Synthetic clicks dispatched from script are not a user believing anything. A carousel calling .click() on its own next-button on a timer would otherwise fill the map with points nobody made.

And a click on empty background is recorded as a plain click but never enters the dead-click branch, because a user clicking whitespace is putting focus somewhere, not being disappointed. Extending the watcher to every click would multiply the dead count by the ratio of background clicks to interactive ones, a number you can drive almost anywhere by changing the padding on a container.

The listener is registered on the capture phase:

// Capture phase: a site that calls stopPropagation() in its own handler should
// not be able to hide its clicks from us.
document.addEventListener('click', onClick, true);

Note what that is and is not for. stopPropagation() in a site's own handler is what would keep a click from ever reaching a bubble-phase listener on document; preventDefault() does nothing of the sort and leaves bubbling intact. Capture phase is insurance against a site accidentally hiding whole regions of its click traffic from the SDK. It is not itself a dead-click mechanism — a handler that runs, calls preventDefault(), and produces no visible change is caught by the watcher, not by the phase.

What this buys you

The output is not "N dead clicks." It is a point on the heatmap anchored to a selector and an offset inside that element's box, so the dashboard can put a red dot on the specific <div class="btn">Export CSV</div> and tell you 41 people pressed it.

The selector comes from selectorFor, which walks up at most MAX_DEPTH = 6 levels and builds each segment in strict preference order: an explicit data-hm-id from the site owner wins outright, then a stable id, then data-testid or data-test as tag[data-testid="..."], and only if none of those exist does it fall back to tag:nth-of-type(n). The first two are globally unique, so hitting either one ends the walk immediately — anything above it is noise. The data-testid branch matters more than it sounds: test ids are the one thing on a typical page that survives a refactor, and preferring them over positional indexes is the difference between a selector that still resolves next sprint and one that does not.

"Stable" is doing real work in that sentence. An id qualifies only if it is non-empty, under 60 characters, and does not match VOLATILE_ID:

const VOLATILE_ID = /^(:.*:|[a-z]*[-_]?\d{3,}|.*[0-9a-f]{8,}.*)$/i;

ember1043, :r7:, anything carrying an 8-character hex run. The length cap is separate and unconditional: a 200-character id is not something a human wrote, and a selector anchored to a framework render id finds nothing on replay.

The whole thing is a rule you can hold in your head: watch what the user thought was a button, then watch whether the page blinked. Neither half requires knowing anything about the page's code, which is the only reason it works on a page whose code you have never seen.

Related