diff --git a/.changeset/dead-click-anchor-descendants.md b/.changeset/dead-click-anchor-descendants.md new file mode 100644 index 0000000000..1539b4055b --- /dev/null +++ b/.changeset/dead-click-anchor-descendants.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +fix: don't report clicks on elements nested inside anchors, buttons, or other interactive elements as `$dead_click`. The detector now walks up the DOM to find an interactive ancestor, matching how autocapture already attributes clicks. This was causing false positives on pages where images or other non-interactive elements are wrapped in `` tags (for example, sites built with Framer). diff --git a/packages/browser/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts b/packages/browser/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts index 30ca914878..4f83ebcd5c 100644 --- a/packages/browser/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts +++ b/packages/browser/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts @@ -158,6 +158,49 @@ describe('LazyLoadedDeadClicksAutocapture', () => { expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0) expect(fakeInstance.capture).not.toHaveBeenCalled() }) + + it.each(autocaptureCompatibleElements)( + 'click on an element nested inside a %s ancestor is never a deadclick', + (element) => { + const ancestor = document.createElement(element) + const child = document.createElement('img') + ancestor.append(child) + document.body.append(ancestor) + + triggerMouseEvent(child, 'click') + + expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0) + expect(fakeInstance.capture).not.toHaveBeenCalled() + } + ) + + it('click on an element nested inside a non-interactive ancestor is still queued', () => { + const ancestor = document.createElement('div') + const child = document.createElement('img') + ancestor.append(child) + document.body.append(ancestor) + + triggerMouseEvent(child, 'click') + + expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(1) + expect(lazyLoadedDeadClicksAutocapture['_clicks'][0].node).toBe(child) + }) + + it('click on a deeply nested element inside an anchor is never a deadclick', () => { + const anchor = document.createElement('a') + const wrapper = document.createElement('div') + const inner = document.createElement('span') + const child = document.createElement('img') + inner.append(child) + wrapper.append(inner) + anchor.append(wrapper) + document.body.append(anchor) + + triggerMouseEvent(child, 'click') + + expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0) + expect(fakeInstance.capture).not.toHaveBeenCalled() + }) }) describe('dead click detection', () => { diff --git a/packages/browser/src/autocapture-utils.ts b/packages/browser/src/autocapture-utils.ts index b66bb37af5..3cf4af4abc 100644 --- a/packages/browser/src/autocapture-utils.ts +++ b/packages/browser/src/autocapture-utils.ts @@ -97,6 +97,7 @@ export function getEventTarget(e: Event): Element | null { } export const autocaptureCompatibleElements = ['a', 'button', 'form', 'input', 'select', 'textarea', 'label'] +export const autocaptureCompatibleElementsSelector = autocaptureCompatibleElements.join(',') /* if there is no config, then all elements are allowed diff --git a/packages/browser/src/entrypoints/dead-clicks-autocapture.ts b/packages/browser/src/entrypoints/dead-clicks-autocapture.ts index 27864d1aaf..ba7b17a330 100644 --- a/packages/browser/src/entrypoints/dead-clicks-autocapture.ts +++ b/packages/browser/src/entrypoints/dead-clicks-autocapture.ts @@ -1,7 +1,7 @@ import { assignableWindow, document, LazyLoadedDeadClicksAutocaptureInterface } from '../utils/globals' import { PostHog } from '../posthog-core' import { isNull, isNumber, isUndefined } from '@posthog/core' -import { autocaptureCompatibleElements, getEventTarget } from '../autocapture-utils' +import { autocaptureCompatibleElementsSelector, getEventTarget } from '../autocapture-utils' import { DeadClickCandidate, DeadClicksAutoCaptureConfig, Properties } from '../types' import { autocapturePropertiesForElement } from '../autocapture' import { isElementInToolbar, isElementNode, isTag } from '../utils/element-utils' @@ -190,10 +190,12 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture return true } + // closest() does not pierce shadow roots: a click whose target lives in a shadow tree + // hosted by an interactive ancestor will not be suppressed here. if ( isTag(click.node, 'html') || !isElementNode(click.node) || - autocaptureCompatibleElements.includes(click.node.tagName.toLowerCase()) + click.node.closest(autocaptureCompatibleElementsSelector) ) { return true }