From f331107e3f318aa5dc1663cdb0d579cf3b83e2a8 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 13:48:40 +0000 Subject: [PATCH 1/3] Count label clicks once in inline events A click on a label makes the browser dispatch a second click on the labelled control. Both bubbled to the inline event listener, so one click on a label inside a data-simple-event element sent the event twice. After a label click sends an inline event, remember the label's control and skip the next click whose composed path contains it. The label is the first label in event.composedPath(), so labels in open shadow roots, slotted label text and shadow-DOM components inside labels are handled too. The control is not remembered when the click was on or inside it, because browsers don't forward those clicks. A zero-delay timeout clears it for label clicks the browser doesn't forward, such as cancelled ones. Submit events are never skipped, and independent clicks are counted as before. When the label and its control resolve to different tagged elements, the tag of the clicked label wins, matching the nearest-tag rule for other clicks. Untagged labels still send the control's event. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 19 +++++ test/unit/inline-events.test.js | 134 ++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/src/default.js b/src/default.js index 41ecf1c..b3b8355 100644 --- a/src/default.js +++ b/src/default.js @@ -1143,8 +1143,15 @@ } } + var labelControl; var inlineEvent = function (event) { var element = event.target; + // The clicked element and its ancestors, also in open shadow roots + var path = event.composedPath ? event.composedPath() : [element]; + // Browsers repeat a label click on its control, count only the first + if (event.type === "click" && path.indexOf(labelControl) > -1) + return (labelControl = undefinedVar); + while ( element && element !== doc && @@ -1173,6 +1180,18 @@ .trim() .slice(0, 200) : ""; + } else { + var label = path[0]; + for (var j = 1; label && label.tagName !== "LABEL"; j++) + label = path[j]; + // Remember the control, unless the click was on or inside it + if (label && label.control && path.indexOf(label.control) < 0) { + labelControl = label.control; + // Forget it when the browser doesn't click it (cancelled click) + setTimeout(function () { + labelControl = undefinedVar; + }); + } } sendEvent(name, metadata, undefinedVar, trueVar); }; diff --git a/test/unit/inline-events.test.js b/test/unit/inline-events.test.js index dbf5923..31b1854 100644 --- a/test/unit/inline-events.test.js +++ b/test/unit/inline-events.test.js @@ -57,6 +57,140 @@ describe("inline events", function () { ]); }); + it("counts a label click once when the browser also clicks its control", function () { + const dom = setup(); + dom.window.document.body.innerHTML = + '
'; + const [forLabel, wrapLabel] = dom.window.document.querySelectorAll("label"); + click(dom, forLabel); + click(dom, wrapLabel.querySelector("span")); + expect(forLabel.control.checked).to.equal(true); + expect(wrapLabel.control.checked).to.equal(true); + expect(events(dom).map((event) => event.name)).to.deep.equal([ + "plan_toggle", + "plan_toggle", + ]); + }); + + it("uses the tag of the clicked label, or of its control when the label is untagged", function () { + const dom = setup(); + dom.window.document.body.innerHTML = + '
'; + const [outerLabel, otherLabel] = + dom.window.document.querySelectorAll("label"); + click(dom, outerLabel); + click(dom, outerLabel.control); + click(dom, otherLabel); + expect(events(dom).map((event) => event.name)).to.deep.equal([ + "outer", + "inner", + "control", + ]); + }); + + it("counts quick independent clicks, keyboard activation and cancelled label clicks", async function () { + const dom = setup(); + dom.window.document.body.innerHTML = + '
'; + const [label, wrapLabel] = dom.window.document.querySelectorAll("label"); + const buttonText = dom.window.document.querySelector("span"); + const input = label.control; + click(dom, label); + click(dom, label); + input.click(); + label.click(); + click(dom, wrapLabel.control); + click(dom, wrapLabel.control); + click(dom, buttonText); + click(dom, buttonText); + dom.window.document.querySelector("[for=b]").click(); + dom.window.document.querySelector("b").click(); + expect(events(dom)).to.have.length(10); + label.addEventListener("click", (event) => event.preventDefault()); + click(dom, label); + await new Promise((resolve) => setTimeout(resolve, 10)); + input.click(); + expect(events(dom)).to.have.length(12); + }); + + it("counts labels across shadow roots and slots once", function () { + const dom = setup(); + const document = dom.window.document; + document.body.innerHTML = + '
Remember
'; + const [shadow, slotted, icon] = document.querySelectorAll("div"); + const root = shadow.attachShadow({ mode: "open" }); + root.innerHTML = + ''; + slotted.attachShadow({ mode: "open" }).innerHTML = + ''; + const iconRoot = icon.querySelector("span").attachShadow({ mode: "open" }); + iconRoot.innerHTML = "Icon"; + click(dom, root.querySelector("label"), { composed: true }); + // Browsers forward a composed click, jsdom's doesn't leave the shadow root + click(dom, root.querySelector("input"), { composed: true }); + click(dom, root.querySelector("input"), { composed: true }); + click(dom, slotted.firstChild, { composed: true }); + click(dom, slotted.shadowRoot.querySelector("input"), { composed: true }); + click(dom, iconRoot.firstChild, { composed: true }); + expect(events(dom).map((event) => event.name)).to.deep.equal([ + "shadow", + "shadow", + "slotted", + "icon", + ]); + }); + + it("skips the forwarded click when a custom control clicks its inner input", function () { + const dom = setup(); + runInContext( + 'customElements.define("x-input", class extends HTMLElement { static formAssociated = true; });', + dom.getInternalVMContext() + ); + dom.window.document.body.innerHTML = + '
'; + const host = dom.window.document.querySelector("x-input"); + const root = host.attachShadow({ mode: "open" }); + root.innerHTML = ''; + const form = host.querySelector("form"); + form.addEventListener("submit", (event) => event.preventDefault()); + host.addEventListener("click", (event) => { + if (event.composedPath()[0] !== host) return; + event.stopPropagation(); + form.requestSubmit(); + click(dom, root.firstChild, { composed: true }); + }); + click(dom, dom.window.document.querySelector("label")); + click(dom, host); + expect(events(dom).map((event) => event.name)).to.deep.equal([ + "outer", + "signup", + "signup", + "inner", + ]); + }); + + it("skips the forwarded click when its handlers send other events", function () { + const dom = setup(); + dom.window.document.body.innerHTML = + '
'; + const document = dom.window.document; + const form = document.querySelector("form"); + form.addEventListener("submit", (event) => event.preventDefault()); + document.getElementById("c").addEventListener("click", () => { + form.requestSubmit(); + document.querySelector("button").click(); + document.querySelector("[data-simple-event=note]").click(); + }); + click(dom, document.querySelector("label")); + expect(events(dom).map((event) => event.name)).to.deep.equal([ + "toggle", + "signup", + "other", + "note", + ]); + }); + it("leaves link behavior and modified clicks untouched", function () { const dom = setup(); dom.window.document.body.innerHTML = From 96475030b06150fda3dd1d1c386c5558b63e1af7 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 14:41:51 +0000 Subject: [PATCH 2/3] Simplify the label click fix to one check Drop the remembered control, timer and composed path search. Ignore a label click when its control is inside the same tagged element: the browser clicks the control right after, and that click is counted. The closest() call matches interactive elements first, so clicks on the control itself or on a link or button inside the label still count. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 25 ++---- test/unit/inline-events.test.js | 137 +++----------------------------- 2 files changed, 18 insertions(+), 144 deletions(-) diff --git a/src/default.js b/src/default.js index b3b8355..fa9e30c 100644 --- a/src/default.js +++ b/src/default.js @@ -1143,15 +1143,8 @@ } } - var labelControl; var inlineEvent = function (event) { var element = event.target; - // The clicked element and its ancestors, also in open shadow roots - var path = event.composedPath ? event.composedPath() : [element]; - // Browsers repeat a label click on its control, count only the first - if (event.type === "click" && path.indexOf(labelControl) > -1) - return (labelControl = undefinedVar); - while ( element && element !== doc && @@ -1164,6 +1157,12 @@ var name = attr(element, "simple-event"); if (!name || isForm !== (event.type === "submit")) return; + // Browsers repeat a label click on its control, so only count that one + var label = + event.target.closest && + event.target.closest("a,button,input,label,select,textarea"); + if (label && label.control && element.contains(label.control)) return; + var metadata = { tag: element.tagName.toLowerCase() }; var attributes = element.attributes; for (var i = 0; i < attributes.length; i++) { @@ -1180,18 +1179,6 @@ .trim() .slice(0, 200) : ""; - } else { - var label = path[0]; - for (var j = 1; label && label.tagName !== "LABEL"; j++) - label = path[j]; - // Remember the control, unless the click was on or inside it - if (label && label.control && path.indexOf(label.control) < 0) { - labelControl = label.control; - // Forget it when the browser doesn't click it (cancelled click) - setTimeout(function () { - labelControl = undefinedVar; - }); - } } sendEvent(name, metadata, undefinedVar, trueVar); }; diff --git a/test/unit/inline-events.test.js b/test/unit/inline-events.test.js index 31b1854..cd3d164 100644 --- a/test/unit/inline-events.test.js +++ b/test/unit/inline-events.test.js @@ -57,137 +57,24 @@ describe("inline events", function () { ]); }); - it("counts a label click once when the browser also clicks its control", function () { + it("counts a label click once, as a click on its control", function () { const dom = setup(); dom.window.document.body.innerHTML = - '
'; - const [forLabel, wrapLabel] = dom.window.document.querySelectorAll("label"); + '
'; + const [forLabel, wrapLabel, termsLabel] = + dom.window.document.querySelectorAll("label"); click(dom, forLabel); click(dom, wrapLabel.querySelector("span")); - expect(forLabel.control.checked).to.equal(true); - expect(wrapLabel.control.checked).to.equal(true); - expect(events(dom).map((event) => event.name)).to.deep.equal([ - "plan_toggle", - "plan_toggle", - ]); - }); - - it("uses the tag of the clicked label, or of its control when the label is untagged", function () { - const dom = setup(); - dom.window.document.body.innerHTML = - '
'; - const [outerLabel, otherLabel] = - dom.window.document.querySelectorAll("label"); - click(dom, outerLabel); - click(dom, outerLabel.control); - click(dom, otherLabel); - expect(events(dom).map((event) => event.name)).to.deep.equal([ - "outer", - "inner", - "control", - ]); - }); - - it("counts quick independent clicks, keyboard activation and cancelled label clicks", async function () { - const dom = setup(); - dom.window.document.body.innerHTML = - '
'; - const [label, wrapLabel] = dom.window.document.querySelectorAll("label"); - const buttonText = dom.window.document.querySelector("span"); - const input = label.control; - click(dom, label); - click(dom, label); - input.click(); - label.click(); click(dom, wrapLabel.control); - click(dom, wrapLabel.control); - click(dom, buttonText); - click(dom, buttonText); - dom.window.document.querySelector("[for=b]").click(); - dom.window.document.querySelector("b").click(); - expect(events(dom)).to.have.length(10); - label.addEventListener("click", (event) => event.preventDefault()); - click(dom, label); - await new Promise((resolve) => setTimeout(resolve, 10)); - input.click(); - expect(events(dom)).to.have.length(12); - }); - - it("counts labels across shadow roots and slots once", function () { - const dom = setup(); - const document = dom.window.document; - document.body.innerHTML = - '
Remember
'; - const [shadow, slotted, icon] = document.querySelectorAll("div"); - const root = shadow.attachShadow({ mode: "open" }); - root.innerHTML = - ''; - slotted.attachShadow({ mode: "open" }).innerHTML = - ''; - const iconRoot = icon.querySelector("span").attachShadow({ mode: "open" }); - iconRoot.innerHTML = "Icon"; - click(dom, root.querySelector("label"), { composed: true }); - // Browsers forward a composed click, jsdom's doesn't leave the shadow root - click(dom, root.querySelector("input"), { composed: true }); - click(dom, root.querySelector("input"), { composed: true }); - click(dom, slotted.firstChild, { composed: true }); - click(dom, slotted.shadowRoot.querySelector("input"), { composed: true }); - click(dom, iconRoot.firstChild, { composed: true }); - expect(events(dom).map((event) => event.name)).to.deep.equal([ - "shadow", - "shadow", - "slotted", - "icon", - ]); - }); - - it("skips the forwarded click when a custom control clicks its inner input", function () { - const dom = setup(); - runInContext( - 'customElements.define("x-input", class extends HTMLElement { static formAssociated = true; });', - dom.getInternalVMContext() - ); - dom.window.document.body.innerHTML = - '
'; - const host = dom.window.document.querySelector("x-input"); - const root = host.attachShadow({ mode: "open" }); - root.innerHTML = ''; - const form = host.querySelector("form"); - form.addEventListener("submit", (event) => event.preventDefault()); - host.addEventListener("click", (event) => { - if (event.composedPath()[0] !== host) return; - event.stopPropagation(); - form.requestSubmit(); - click(dom, root.firstChild, { composed: true }); - }); - click(dom, dom.window.document.querySelector("label")); - click(dom, host); - expect(events(dom).map((event) => event.name)).to.deep.equal([ - "outer", - "signup", - "signup", - "inner", - ]); - }); - - it("skips the forwarded click when its handlers send other events", function () { - const dom = setup(); - dom.window.document.body.innerHTML = - '
'; - const document = dom.window.document; - const form = document.querySelector("form"); - form.addEventListener("submit", (event) => event.preventDefault()); - document.getElementById("c").addEventListener("click", () => { - form.requestSubmit(); - document.querySelector("button").click(); - document.querySelector("[data-simple-event=note]").click(); - }); - click(dom, document.querySelector("label")); + click(dom, wrapLabel.querySelector("a")); + click(dom, termsLabel); + expect(forLabel.control.checked).to.equal(true); expect(events(dom).map((event) => event.name)).to.deep.equal([ - "toggle", - "signup", - "other", - "note", + "yearly", + "plan", + "plan", + "plan", + "terms", ]); }); From c15f335c1450e8642ac5a2284ed189040ef84b24 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 17:52:04 +0000 Subject: [PATCH 3/3] Count one inline click event per element per task Replace the label and element-type check with a simpler rule: a tagged element sends at most one click event per task. The browser dispatches a label's forwarded control click in the same task as the label click, so it is skipped, while separate visitor clicks run in separate tasks and still count. This also covers labels in shadow DOM and custom controls, and cancelled or disabled labels still send their event. Form submissions are not affected. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 14 ++++++++------ test/unit/inline-events.test.js | 28 +++++++++++++--------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/default.js b/src/default.js index fa9e30c..4706104 100644 --- a/src/default.js +++ b/src/default.js @@ -1143,6 +1143,7 @@ } } + var lastInlineElement; var inlineEvent = function (event) { var element = event.target; while ( @@ -1157,12 +1158,6 @@ var name = attr(element, "simple-event"); if (!name || isForm !== (event.type === "submit")) return; - // Browsers repeat a label click on its control, so only count that one - var label = - event.target.closest && - event.target.closest("a,button,input,label,select,textarea"); - if (label && label.control && element.contains(label.control)) return; - var metadata = { tag: element.tagName.toLowerCase() }; var attributes = element.attributes; for (var i = 0; i < attributes.length; i++) { @@ -1179,6 +1174,13 @@ .trim() .slice(0, 200) : ""; + } else { + // A label click also clicks its control, count one click per task + if (element === lastInlineElement) return; + lastInlineElement = element; + setTimeout(function () { + lastInlineElement = undefinedVar; + }); } sendEvent(name, metadata, undefinedVar, trueVar); }; diff --git a/test/unit/inline-events.test.js b/test/unit/inline-events.test.js index cd3d164..fd4b1ab 100644 --- a/test/unit/inline-events.test.js +++ b/test/unit/inline-events.test.js @@ -42,12 +42,13 @@ describe("inline events", function () { afterEach(() => windows.splice(0).forEach((window) => window.close())); - it("tracks nested clicks and new elements with current metadata", function () { + it("tracks nested clicks and new elements with current metadata", async function () { const dom = setup(); dom.window.document.body.innerHTML = ''; const button = dom.window.document.querySelector("button"); click(dom, button.firstChild); + await new Promise((resolve) => setTimeout(resolve, 10)); button.setAttribute("data-simple-event", "upgrade"); button.setAttribute("data-simple-event-plan-name", "enterprise"); click(dom, button); @@ -57,25 +58,22 @@ describe("inline events", function () { ]); }); - it("counts a label click once, as a click on its control", function () { + it("counts a label click once, and independent clicks separately", async function () { const dom = setup(); dom.window.document.body.innerHTML = - '
'; - const [forLabel, wrapLabel, termsLabel] = - dom.window.document.querySelectorAll("label"); + '
'; + const [forLabel, wrapLabel] = dom.window.document.querySelectorAll("label"); + const nextTask = () => new Promise((resolve) => setTimeout(resolve, 10)); click(dom, forLabel); + await nextTask(); click(dom, wrapLabel.querySelector("span")); - click(dom, wrapLabel.control); - click(dom, wrapLabel.querySelector("a")); - click(dom, termsLabel); + await nextTask(); + wrapLabel.control.click(); + await nextTask(); + forLabel.addEventListener("click", (event) => event.preventDefault()); + click(dom, forLabel); expect(forLabel.control.checked).to.equal(true); - expect(events(dom).map((event) => event.name)).to.deep.equal([ - "yearly", - "plan", - "plan", - "plan", - "terms", - ]); + expect(events(dom)).to.have.length(4); }); it("leaves link behavior and modified clicks untouched", function () {