From bc2534a22b504c7ef4c9315b576db224d8f6632e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 23:32:31 +0100 Subject: [PATCH] fix: reduce false positives in rage click and dead click detection - Skip rage clicks when text is selected (triple-click to select) - Exclude tabindex="-1" elements from dead click interactive selector - Observe document.body for DOM changes (modals, drawers, overlays) - Listen for popstate/hashchange to detect SPA navigations --- CHANGELOG.md | 8 ++++++++ public/script.js | 25 +++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c711b6e..2525a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Improved + +- **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now. +- **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable: + - Buttons that trigger changes elsewhere on the page (closing a drawer, opening a modal) are no longer flagged as dead. + - Page content areas that aren't actually clickable (like `
` containers) are no longer treated as interactive elements. + - Single-page app navigations are now properly detected, so links that use client-side routing aren't mistakenly reported as broken. + ### Removed - **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before. diff --git a/public/script.js b/public/script.js index a2a596b..340590a 100644 --- a/public/script.js +++ b/public/script.js @@ -579,6 +579,15 @@ // * Check if rage click threshold is met if (entry.times.length >= RAGE_CLICK_THRESHOLD) { + // * Skip if user is selecting text (triple-click to select paragraph) + try { + var sel = window.getSelection(); + if (sel && sel.toString().trim().length > 0) { + entry.times = []; + return; + } + } catch (ex) {} + // * Debounce: max one rage_click per element per 5 seconds if (now - entry.lastFired >= RAGE_CLICK_DEBOUNCE) { var clickCount = entry.times.length; @@ -602,7 +611,7 @@ // * or network request occurs within 1 second // * Opt-out: add data-no-dead to the script tag if (!script.hasAttribute('data-no-dead')) { - var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]'; + var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[onclick],[tabindex]:not([tabindex="-1"])'; var DEAD_CLICK_DEBOUNCE = 10000; var DEAD_CLEANUP_INTERVAL = 30000; var deadClickDebounce = {}; // * selector -> lastFiredTimestamp @@ -665,11 +674,15 @@ var mutationObs = null; var perfObs = null; var cleanupTimer = null; + var popstateHandler = null; + var hashchangeHandler = null; function cleanup() { if (mutationObs) { try { mutationObs.disconnect(); } catch (ex) {} mutationObs = null; } if (perfObs) { try { perfObs.disconnect(); } catch (ex) {} perfObs = null; } if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; } + if (popstateHandler) { window.removeEventListener('popstate', popstateHandler); popstateHandler = null; } + if (hashchangeHandler) { window.removeEventListener('hashchange', hashchangeHandler); hashchangeHandler = null; } } function onEffect() { @@ -677,7 +690,7 @@ cleanup(); } - // * Set up MutationObserver to detect DOM changes on the element and its parent + // * Set up MutationObserver to detect DOM changes on the element, its parent, and body if (typeof MutationObserver !== 'undefined') { try { mutationObs = new MutationObserver(function() { @@ -689,6 +702,8 @@ if (parent && parent.tagName !== 'HTML' && parent.tagName !== 'BODY') { mutationObs.observe(parent, { childList: true }); } + // * Also observe body for top-level DOM changes (modals, drawers, overlays, toasts) + mutationObs.observe(document.body, { childList: true, attributes: true }); } catch (ex) { mutationObs = null; } @@ -706,6 +721,12 @@ } } + // * Listen for SPA navigation events (popstate, hashchange) + popstateHandler = function() { onEffect(); }; + hashchangeHandler = function() { onEffect(); }; + window.addEventListener('popstate', popstateHandler); + window.addEventListener('hashchange', hashchangeHandler); + // * After 1 second, check if any effect was detected cleanupTimer = setTimeout(function() { cleanup();