Skip to content
Development

The bug that only exists under a configuration nobody uses yet

By Victor Da Luz
astroprivacyjavascriptdev-logastro-tools

Neither of the two sites running my analytics consent package uses Astro’s view transitions. Not one page. So when a backlog item said “this breaks under ClientRouter,” my first reaction was: does it matter?

I decided it does, and fixing it taught me something about the difference between a bug and a bug you can’t see yet.

The setup that was never tested

ConsentGate and ConsentPrompt boot themselves with a small inline script tag: import a function, call it once. That works because Astro runs an inline module script exactly once per full page load. Every production page load, the boot function runs, finds the DOM elements, wires up listeners. Simple, and correct in production for months.

<ClientRouter /> changes what “once per page load” means. Turn it on and Astro starts doing soft navigations: clicking a link swaps in new page content without a full reload. Inline module scripts don’t re-run on a soft nav; they already ran, as far as the module system is concerned. So the consent prompt’s accept and decline buttons, bound to whatever DOM element existed on the first page, sit there attached to nothing once a soft nav swaps in a fresh copy.

Two bugs, not one

I assumed this was a single “listeners don’t rebind” problem. It’s two, and the second is the interesting one.

The obvious bug: the prompt’s own buttons are bound to a specific element instance. Gone after a swap.

The subtle one: there’s a footer link, “Analytics preferences,” that reopens the prompt on request. Its handler was written once at boot time and captured a reference to that page’s prompt element in a closure:

document.addEventListener(OPEN_PROMPT_EVENT, () => {
  prompt.hidden = false; // `prompt` is whatever it was when this ran
});

document itself survives a soft nav - same document object the whole time. So this listener never gets removed, never needs rebinding, and keeps firing forever. It’s just firing against a prompt variable that stopped pointing at anything visible the moment the first navigation happened. The listener isn’t broken. Its data is stale.

That distinction changed the fix. My first instinct was “re-run the whole boot function on every navigation,” which would re-register this same handler once per nav, forever, each closing over whatever the prompt happened to be at that moment. Harmless in the sense that only the most recent handler would find a real element to touch, but it’s an unbounded pile of dead closures accumulating for the length of a session. A leak dressed up as a fix.

What actually fixed it

Two different rules for two different kinds of listener. Anything bound to a specific DOM element - the prompt’s own buttons - needs rebinding on every navigation, because the element genuinely is new each time. Anything bound to document needs binding once, ever, and its handler should look up whatever it touches at the moment it fires rather than close over a reference captured at registration:

document.addEventListener(OPEN_PROMPT_EVENT, () => {
  const current = document.getElementById('oia-prompt'); // fresh, every time
  if (current) current.hidden = false;
});

Re-running the boot function on Astro’s astro:page-load event, which fires on the first load and every soft nav after it, handles the element-rebinding side. A one-line module-level flag stops the document-level listeners from registering twice.

Verifying a bug that doesn’t exist in production

Here’s the part I couldn’t shortcut. Neither real site uses <ClientRouter />, so no live page anywhere actually exhibited this. Unit tests wouldn’t help either, since this is DOM event wiring and the package is deliberately dependency-free with no jsdom.

So I temporarily added <ClientRouter /> to one site’s layout, uncommitted, purely to manufacture the failure condition long enough to prove the fix. Landed on the blog listing, clicked into a post (a real soft navigation, not a fresh load), and checked that the prompt showed up, the accept button worked, and - the specific path the closure bug broke - that the footer reopen link targeted the current page’s prompt rather than a ghost of the first one. Then reverted the layout change entirely.

It’s an odd feeling, turning on a feature in your own site just to create a bug on purpose so you can watch yourself fix it. But there wasn’t another way to see it happen, and a fix you can’t watch fail first isn’t really verified.

What I’d do differently

I’d be more suspicious of “neither site uses X” as a reason to deprioritize something. It’s true, and it’s also exactly the condition under which a bug sits in a codebase for months without anyone noticing, because nothing is exercising the code path that’s wrong. Low priority isn’t the same as low risk.

Related reading