# Your retired button is still mounted, still handling clicks

> A refactor left an old injected control alive in the page. It called the retired handler, threw no error, and looked exactly like the button that replaced it.

- Author: Chad Priest
- Published: 2026-08-26
- Canonical URL: https://blog.vodou.ai/your-retired-button-is-still-mounted-still-handling-clicks/
- Tags: javascript, chrome, debugging, webdev

---

The button did nothing. No console error, no failed request, no red toast. I clicked it, and the page went on being a page.

I had spent the afternoon inside `runInject()`: the function that pulls a context block from the local gateway and drops it into whatever composer the host site is using. Edit, reload the extension, reload the tab, click. Nothing. On the third round I put a `console.log` on the first line of `runInject`. It never fired.

The button I was clicking was not wired to `runInject`. It was `cbtn`, the retired "🧠 My context" control from the previous design, and it called `requestContext`: the old in-page picker overlay. It had been superseded weeks earlier. It was still mounting on every page load, still painted with the same mark as its replacement, and still perfectly happy to handle a click.

## Two entry points, one scope, no error

The shape that made it invisible:

```js
// content.js — one IIFE, two mount paths
function mountContextButton() {
  if (window.__vodouContextButtonMounted === MOUNT_TOKEN) return;
  window.__vodouContextButtonMounted = MOUNT_TOKEN;

  function runInject(site, forceComposer, composer, onDone, manual, ctl) { /* current */ }

  function mountFab() {                       // the NEW control
    if (document.getElementById('vodou-fab-wrap')) return;
    // ...
    run(report) { runInject(site, true, findComposer(), undefined, true); }
  }
  mountFab();
}

// ...elsewhere in the same IIFE, outside that closure:
mountCbtn();     // the OLD control — cannot see runInject, calls requestContext
```

`runInject` lives inside `mountContextButton()`'s scope. Nothing outside that closure can call it. So the retired control *could not* have been calling the current path even if I'd rewired it, and that's the part that took me embarrassingly long: the scope was the proof, sitting right there, and I read it three times as a detail instead of as an answer.

The mount guards made it worse by working. Ours are versioned on purpose: `MOUNT_TOKEN` is the manifest version, so reloading the extension re-arms the guard in already-open tabs instead of hitting a `=== true` and returning early. The new control's guard is keyed on its own `id`. The old control had its own guard, keyed on its own `id`. Both cleared. Both mounted. Neither knew the other existed.

## The standard advice is about orphaning. This wasn't orphaning.

Every result you get searching for this is about *context invalidation*. The classic [Stack Overflow thread on orphaned content scripts](https://stackoverflow.com/questions/57468219/how-to-remove-orphaned-script-after-chrome-extension-update) and [samber/cc-skills' content-scripts reference](https://github.com/samber/cc-skills/blob/main/skills/chrome-extension/references/content-scripts.md) both cover it well: after an update the old script keeps running, loses `chrome.runtime`, every message throws, so you guard with `isExtensionAlive()` and show a refresh banner. [AuditBuffet's pattern](https://auditbuffet.com/patterns/ab-001334) names the visible symptom exactly right: "ghost UI that does nothing when clicked."

None of it covers my case. My extension context was fine. `chrome.runtime.id` was there. `isExtensionAlive()` returned true, messaging worked, and the ghost button was shipping in the *current* bundle. There is no invalidation event to hang cleanup off when the thing that retired your code path was a refactor.

## The class: a retired entry point that still boots

This is not a Chrome bug. It's what happens whenever a new implementation is added beside an old one in overlapping scopes and the old mount is left in the boot sequence: React components whose replacement ships but whose old `addEventListener` still runs from a stale `useEffect`, embedded JS SDKs (Stripe/Intercom-shaped) where v1's mount call survives in the host's snippet next to v2's, any server-rendered page that emits two `<script>` tags for the same widget.

**Retiring a code path means deleting its mount call, not deleting its callers: an entry point that still executes at boot is live code, and if its handler is in a different scope from your current implementation, it is by construction calling something else.**

Here's the check, in your own console, on your own page. Chrome DevTools only, since `getEventListeners` is a console builtin:

```js
for (const el of document.querySelectorAll('[id*="myprefix"], [class*="myprefix"]')) {
  console.log(el.id || el.className, getEventListeners(el));
}
```

Passing looks like one node per control, one `click` entry each. Failing looks like two nodes you only rendered once, or one node with two `click` listeners. Then expand the listener, click the source link, and read *which file and line* it lands on. If that line is inside code you believed you deleted, you just found it. Sixty seconds.

## The backtick that eats the stylesheet

Second half of the same day, same file. The injected `<style>` is a 103-line template literal, `content.js:1325-1428`, with ten prose comments inside it explaining why each rule exists. 327 lines earlier there's a JS comment that reads ``// `manual` = a user-initiated trigger``. Same writing habit, different container. A backtick inside that CSS comment terminates the template early, everything after it parses as JavaScript, and the file fails to parse, so nothing mounts at all, on all 22 hosts. You get `Uncaught SyntaxError: Unexpected identifier` pointing at a line nowhere near the backtick you typed.

The check is a shell loop, and it works on any repo that emits markup from template strings:

```sh
for f in $(git ls-files '*.js' '*.mjs'); do node --check "$f" || echo "BROKEN: $f"; done
```

Passing output is silence. Failing output is a filename and a line number that will not be where the mistake is: that's the tell, and it's why this takes an hour by eye and four seconds by parser.

Two rules out of one day. Grep your boot sequence for mount calls before you grep for handlers. And whatever delimiter your template literal uses, it is banned inside that literal, including in the comments, especially in the comments, because comments are where prose goes and prose is where backticks live.

---

Source: [Your retired button is still mounted, still handling clicks](https://blog.vodou.ai/your-retired-button-is-still-mounted-still-handling-clicks/) by Chad Priest, from Building Vodou in Public.
