From 1d1a75ad4a28d542c865e159c9bc1bbfd9771c01 Mon Sep 17 00:00:00 2001 From: Paul Scandariato Date: Mon, 2 Nov 2026 10:35:48 -0500 Subject: [PATCH 1/2] Only cancel the native action for the element's request trigger hx-on handlers no longer prevent a plain form's submit or a link's navigation; a modifier-key click on an hx-get link opens the new tab. Make the modifier-click tests dispatch cancelable events. --- src/htmx.js | 9 ++++++--- test/tests/attributes/hx-on.js | 10 ++++++++++ test/tests/end2end/cancel-behavior.js | 10 +++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index aadea7839..8f7d11190 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -736,12 +736,15 @@ var htmx = (() => { let trigger = hxTrigger || (elt.matches("form") ? "submit" : elt.matches("input:not([type=button]):not([type=submit]),select,textarea") ? "change" : "click"); - this.__onTrigger(elt, trigger, initialHandler) + this.__onTrigger(elt, trigger, initialHandler, true) } // Wire up event listeners with full modifier support (once, prevent, stop, // delay, throttle, changed, capture, passive, from, filter, etc.) - __onTrigger(elt, specString, handler) { + // request: the handler issues the element's htmx request, so htmx prevents the native + // click/submit (except a modifier-key click on a link). hx-on and extension handlers + // prevent only through prevent/halt or their own preventDefault. + __onTrigger(elt, specString, handler, request) { let specs = this.__parseTriggerSpecs(specString) this.__htmxProp(elt).triggerSpecs.push(...specs) @@ -802,7 +805,7 @@ var htmx = (() => { // Guarded: pre-timing checks that determine if event should proceed spec.handler = (evt) => { - if (this.__shouldCancel(evt)) evt.preventDefault(); + if (request && !this.__isModifierKeyClick(evt) && this.__shouldCancel(evt)) evt.preventDefault(); if (spec.from === 'self' && evt.target !== elt) return; if (spec.from === 'outside' && elt.contains(evt.target)) return; if (spec.target && !evt.target?.matches?.(spec.target)) return; diff --git a/test/tests/attributes/hx-on.js b/test/tests/attributes/hx-on.js index 30c66ccac..060dfeae3 100644 --- a/test/tests/attributes/hx-on.js +++ b/test/tests/attributes/hx-on.js @@ -104,6 +104,16 @@ describe('hx-on attribute', function() { // --- synthetic events in simple form --- + it('hx-on:submit on a plain form does not prevent the native submit', function() { + let defaultPrevented = null; + let form = createProcessedHTML('
'); + form.addEventListener('submit', (evt) => defaultPrevented = evt.defaultPrevented); + form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true })); + window.foo.should.equal(1); + defaultPrevented.should.equal(false); + delete window.foo; + }); + it('hx-on:load fires immediately when element is processed', function() { window.foo = false; createProcessedHTML('
x
'); diff --git a/test/tests/end2end/cancel-behavior.js b/test/tests/end2end/cancel-behavior.js index 351cc340c..a6a314f8b 100644 --- a/test/tests/end2end/cancel-behavior.js +++ b/test/tests/end2end/cancel-behavior.js @@ -266,12 +266,13 @@ describe('Cancel behavior integration tests', function() { defaultPrevented = evt.defaultPrevented; }); - const evt = new MouseEvent('click', { ctrlKey: true, bubbles: true }); + const evt = new MouseEvent('click', { ctrlKey: true, bubbles: true, cancelable: true }); link.dispatchEvent(evt); await htmx.timeout(1); // Should not prevent default to allow browser's native behavior defaultPrevented.should.equal(false); + fetchMock.calls.length.should.equal(0); }); it('meta+click on link does not prevent default (allows open in new tab on Mac)', async function() { @@ -284,12 +285,13 @@ describe('Cancel behavior integration tests', function() { defaultPrevented = evt.defaultPrevented; }); - const evt = new MouseEvent('click', { metaKey: true, bubbles: true }); + const evt = new MouseEvent('click', { metaKey: true, bubbles: true, cancelable: true }); link.dispatchEvent(evt); await htmx.timeout(1); // Should not prevent default to allow browser's native behavior defaultPrevented.should.equal(false); + fetchMock.calls.length.should.equal(0); }); it('shift+click on link does not prevent default (allows open in new window)', async function() { @@ -302,11 +304,13 @@ describe('Cancel behavior integration tests', function() { defaultPrevented = evt.defaultPrevented; }); - const evt = new MouseEvent('click', { shiftKey: true, bubbles: true }); + const evt = new MouseEvent('click', { shiftKey: true, bubbles: true, cancelable: true }); link.dispatchEvent(evt); await htmx.timeout(1); // Should not prevent default to allow browser's native behavior defaultPrevented.should.equal(false); + fetchMock.calls.length.should.equal(0); }); + }); From 7cc27895206b3a8eb6ee7501963ebfb608f0c6a0 Mon Sep 17 00:00:00 2001 From: Paul Scandariato Date: Mon, 2 Nov 2026 13:39:51 -0500 Subject: [PATCH 2/2] shorten comment --- src/htmx.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 8f7d11190..1e924bb85 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -741,9 +741,7 @@ var htmx = (() => { // Wire up event listeners with full modifier support (once, prevent, stop, // delay, throttle, changed, capture, passive, from, filter, etc.) - // request: the handler issues the element's htmx request, so htmx prevents the native - // click/submit (except a modifier-key click on a link). hx-on and extension handlers - // prevent only through prevent/halt or their own preventDefault. + // request: true for the element's own hx-* request trigger, which cancels the native action __onTrigger(elt, specString, handler, request) { let specs = this.__parseTriggerSpecs(specString) this.__htmxProp(elt).triggerSpecs.push(...specs)