Double-clicking a card in a solitaire game should send it to its foundation pile. In mine, it didn't. There was no error in the console, and the dblclick handler itself was correct. The browser just never called it.
The cause is a pattern that is easy to end up with in a hand-written renderer: the click handler re-renders, and the re-render replaces the element that was clicked. Every engine I tested then drops the dblclick. This post has the minimal repro, a table of what Chromium, Firefox and WebKit actually send in five variations, and a fix that works in all three.
The repro
<div id="pile"><div class="card">A♠</div></div>
<script>
const pile = document.getElementById('pile');
const render = () => { pile.innerHTML = '<div class="card">A♠</div>'; };
pile.addEventListener('click', (e) => { console.log('click', e.detail); render(); });
pile.addEventListener('dblclick', () => console.log('dblclick'));
</script>
Double-click the card. The console shows click 1, click 2, and nothing else. The listener sits on the pile, which is never replaced, and it still hears nothing.
Now change render so it updates the card instead of replacing it:
const render = () => { pile.firstElementChild.textContent = 'A♠'; };
Double-click again: click 1, click 2, dblclick.
The only difference is whether the second click lands on the same DOM node as the first. In the game, the first click selects the card, and selecting redraws the board to highlight it, which rebuilds every card node.
What each engine sends
I ran five variations in Chromium 148, Firefox 150 and WebKit 26.4, double-clicking with Playwright's mouse and logging every mouse event at the document. The variations differ only in what happens to the card between the two clicks.
| Between the two clicks, the page… | Chromium | Firefox | WebKit |
detail of the second click |
|---|---|---|---|---|
| changes an attribute on the card | dblclick fires | fires | fires | 2 |
| removes the card and appends the same node again | fires | fires | fires | 2 |
| replaces the card with an identical new node | never | never | never | 2 |
| replaces the card and its parent | never | never | never | 2 |
| replaces the card on mousedown instead of click | no click, no dblclick | same | same | none |
Three things fall out of it.
It's node identity, not DOM churn. Taking the card out of the document and putting the same object back is fine. Swapping in a new node with the same markup, the same class and the same position is not. The two clicks are paired by the node they hit.
The parent doesn't get it either. My first guess was that the dblclick would go to the nearest ancestor the two clicks share, which is the pile. It doesn't. In the repro the listener is on that pile and hears nothing, and in the table a listener on the document saw no dblclick anywhere.
Re-rendering on mousedown loses the click too. If the re-render runs on mousedown, a natural place for it when a press should show a selected state straight away, the mousedown lands on the old node and the mouseup on the new one. None of the three engines fired a click at all, let alone a dblclick. Firefox also reported detail 0 on that mouseup.
One thing does survive: in every engine, the second click reports detail: 2, even when it lands on a brand-new node. The click counter evidently tracks time and position, not the target.
The fix
Since detail survives, detect the double-click inside the click handler:
pile.addEventListener('click', (e) => {
if (e.detail === 2) { console.log('double-click'); return; }
console.log('click');
render();
});
That logs click, then double-click, in all three engines, with render still replacing the node on every click.
What I actually shipped, before measuring any of this, was a timer:
const quick = Date.now() - selAt < 450;
selAt is set when the first click selects a card, and a second click on the same card within 450 ms counts as a double-click. It works, but it is a second definition of "double-click" competing with the operating system's. The Windows default is 500 ms, so a double-click that Windows accepts can miss this timer, and anyone who has slowed their double-click speed down misses it more often. event.detail uses the platform's own setting.
Where you can, the better fix is not replacing the node: update it in place, or give your renderer stable keys so it reuses elements. Frameworks that diff the DOM normally keep the node, so the risk is concentrated in code that rebuilds with innerHTML, and in keyed lists whose key changes when an item is selected.
Two caveats. These are Playwright's mouse events, sent through each browser's automation input rather than a physical mouse; a real mouse, a trackpad and especially a touchscreen can differ, so test the devices you ship to. And detail keeps counting: a triple-click gives 3. Check === 2 if a third click should do nothing, >= 2 if it should repeat the action.
Double-click now sends a card home in this FreeCell: double-click any card that can go to a foundation.
Takeaways
- A dblclick belongs to the node that took both clicks. Replace that node between the clicks and no engine fires it, not even at a parent.
- Re-render on mousedown and you lose the click as well.
-
event.detail === 2in a click handler survives the replacement in Chromium, Firefox and WebKit, and it follows the user's own double-click speed, so prefer it to a home-made timer.
Top comments (0)