From 35044f5f0ac65313e9649e58fa1b7f024d68f5e7 Mon Sep 17 00:00:00 2001 From: Christian Tanul Date: Tue, 9 Jun 2026 15:12:58 +0300 Subject: [PATCH] re-init elements when their hx-* attributes change Mutating hx-trigger, hx-on, or hx-boost on an initialized element now takes effect. --- src/htmx.js | 76 ++++++++++++++---- test/tests/unit/htmx.config.prefix.js | 10 +++ test/tests/unit/morph.js | 55 +++++++++++++ test/tests/unit/process.js | 107 ++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 16 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 5b6819642..b5a587a35 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -795,9 +795,12 @@ var htmx = (() => { }, this.parseInterval(interval)); } - // Load: fire immediately, no listener needed + // Load: fire once per element, never on re-init if (eventName === 'load') { - spec.handler(new CustomEvent('load')); + if (!elt._htmx.loadFired) { + elt._htmx.loadFired = true; + spec.handler(new CustomEvent('load')); + } continue; } @@ -881,17 +884,28 @@ var htmx = (() => { let iter = this.#hxOnQuery.evaluate(elt) let node = null while (node = iter.iterateNext()) hxOnNodes.push(node) + let actionNodes = this.__queryEltAndDescendants(elt, this.#actionSelector); + let boostNodes = this.__queryEltAndDescendants(elt, this.#boostSelector); + let candidates = new Set([...hxOnNodes, ...actionNodes, ...boostNodes]); + for (let candidate of candidates) { + if (candidate._htmx?.initHash != null && candidate._htmx.initHash !== this.__attributeHash(candidate)) { + this.__cleanup(candidate); + } + } for (let hxOnNode of hxOnNodes) { if (!this.__ignore(hxOnNode) && this.__trigger(hxOnNode, "htmx:before:on:init", {}, true)) { this.__handleHxOnAttributes(hxOnNode); } } - for (let child of this.__queryEltAndDescendants(elt, this.#actionSelector)) { + for (let child of actionNodes) { this.__initializeElement(child); } - for (let child of this.__queryEltAndDescendants(elt, this.#boostSelector)) { + for (let child of boostNodes) { this.__maybeBoost(child); } + for (let candidate of candidates) { + if (candidate._htmx) candidate._htmx.initHash = this.__attributeHash(candidate); + } this.__trigger(elt, "htmx:after:process"); } @@ -943,11 +957,14 @@ var htmx = (() => { if (spec.interval) clearInterval(spec.interval); if (spec.timeout) clearTimeout(spec.timeout); if (spec.throttleTimeout) clearTimeout(spec.throttleTimeout); - spec.observer?.disconnect() - } - for (let listenerInfo of elt._htmx.listeners || []) { - listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler, listenerInfo); - } + spec.observer?.disconnect(); + } + for (let info of elt._htmx.listeners || []) info.fromElt.removeEventListener(info.eventName, info.handler, info); + elt._htmx.listeners.length = 0; + elt._htmx.triggerSpecs.length = 0; + delete elt._htmx.initialized; + delete elt._htmx.initHash; + delete elt._htmx.hxOnInitialized; this.__trigger(elt, "htmx:after:cleanup") } if (elt.firstChild) { @@ -957,6 +974,22 @@ var htmx = (() => { } } + __attributeHash(elt) { + let hash = 0; + let prefix = this.config.prefix; + for (let attr of elt.attributes) { + if (!attr.value) continue; + let n = attr.name; + if (!/^(data-)?hx-/.test(n) && !(prefix && n.startsWith(prefix))) continue; + for (let str of [n, attr.value]) { + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i) | 0; + } + } + } + return hash; + } + __handlePreservedElements(fragment) { let pantry = document.createElement('div'); pantry.hidden = true; @@ -1621,27 +1654,30 @@ var htmx = (() => { // hx-on: binds to directly // hx-on:: is shorthand for hx-on:htmx: (htmx events) - __handleHxOnAttributes(node) { + __handleHxOnAttributes(elt) { + if (elt._htmx?.hxOnInitialized) return; let hxOnNames = this.__prefixes("hx-on"); let mc = this.config.metaCharacter || ':'; let handler = (code) => async (evt) => { try { - await this.__executeJavaScript(node, { event: evt }, + await this.__executeJavaScript(elt, { event: evt }, `with(event?.detail||{}){${code}}`, false); } catch (e) { - if (typeof e !== 'symbol') this.__trigger(node, 'htmx:error', { error: e }); + if (typeof e !== 'symbol') this.__trigger(elt, 'htmx:error', { error: e }); } }; - for (let attr of node.getAttributeNames()) { + let matched = false; + for (let attr of elt.getAttributeNames()) { let prefix = hxOnNames.find(p => attr.startsWith(p)); if (!prefix) continue; + matched = true; let rest = attr.substring(prefix.length); - let value = node.getAttribute(attr); + let value = elt.getAttribute(attr); // hx-on="click once -> doA(); blur -> doB()" if (!rest) { for (let part of value.split(/;(?=[^;]*->)/)) { let idx = part.indexOf('->'); - if (idx !== -1) this.__onTrigger(node, part.substring(0, idx).trim(), handler(part.substring(idx + 2).trim())); + if (idx !== -1) this.__onTrigger(elt, part.substring(0, idx).trim(), handler(part.substring(idx + 2).trim())); } continue; } @@ -1649,8 +1685,16 @@ var htmx = (() => { if (rest[0] !== mc) continue; let eventName = rest.substring(1); if (eventName.startsWith(mc)) eventName = 'htmx' + mc + eventName.substring(1); - this.__onTrigger(node, eventName, handler(value)); + if (eventName === 'load') { + if (!elt._htmx?.hxOnLoadFired) { + this.__htmxProp(elt).hxOnLoadFired = true; + handler(value)(new CustomEvent('load')); + } + continue; + } + this.__onTrigger(elt, eventName, handler(value)); } + if (matched) this.__htmxProp(elt).hxOnInitialized = true; } __showIndicators(elt) { diff --git a/test/tests/unit/htmx.config.prefix.js b/test/tests/unit/htmx.config.prefix.js index 667870f6f..c2c8f45b8 100644 --- a/test/tests/unit/htmx.config.prefix.js +++ b/test/tests/unit/htmx.config.prefix.js @@ -121,4 +121,14 @@ describe('htmx.config.prefix functionality', function() { htmx.config.prefix = ""; }); + + it('__attributeHash detects changes in custom-prefix attributes', function() { + htmx.config.prefix = "x-"; + let div = createDisconnectedHTML('
'); + let h1 = htmx.__attributeHash(div); + div.setAttribute('x-get', '/b'); + let h2 = htmx.__attributeHash(div); + assert.notEqual(h1, h2, 'hash should change when custom-prefix attribute changes'); + htmx.config.prefix = ""; + }); }); diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 24a2b2d69..d4010134e 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -507,6 +507,61 @@ describe('Morph Swap Styles Tests', function() { assert.equal(container.querySelector('#result').textContent, 'Clicked!', 'htmx actions on new div should work'); }); + it('does not stack hx-on:click handlers across innerMorph', async function() { + window._calls = 0; + mockResponse('GET', '/test', ''); + createProcessedHTML('
'); + for (let i = 0; i < 3; i++) { + await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'}); + } + document.getElementById('btn').click(); + assert.equal(window._calls, 1, 'click should fire handler exactly once'); + delete window._calls; + }); + + it('does not stack hx-on:click handlers across outerMorph', async function() { + window._calls = 0; + mockResponse('GET', '/test', ''); + createProcessedHTML(''); + for (let i = 0; i < 3; i++) { + await htmx.ajax('GET', '/test', {target: '#btn', swap: 'outerMorph'}); + } + document.getElementById('btn').click(); + assert.equal(window._calls, 1, 'click should fire handler exactly once'); + delete window._calls; + }); + + it('handles multiple hx-on attributes after morph', async function() { + window._log = []; + mockResponse('GET', '/test', ''); + createProcessedHTML('
'); + await htmx.ajax('GET', '/test', {target: '#wrap', swap: 'innerMorph'}); + const b = document.getElementById('b'); + b.dispatchEvent(new Event('focus')); + b.click(); + assert.deepEqual(window._log, ['f', 'c'], 'both handlers should fire exactly once'); + delete window._log; + }); + + it('picks up hx-trigger value change across innerMorph', async function() { + mockResponse('GET', '/x', ''); + mockResponse('GET', '/test', ''); + createProcessedHTML('
'); + + await htmx.ajax('GET', '/test', {target: '#wrap', swap: 'innerMorph'}); + + const b = document.getElementById('b'); + let fired = 0; + b.addEventListener('htmx:before:request', () => fired++); + + b.click(); + await new Promise(r => setTimeout(r, 20)); + assert.equal(fired, 0, 'click should no longer fire after morph'); + b.dispatchEvent(new KeyboardEvent('keyup')); + await waitForEvent('htmx:after:request', 100); + assert.equal(fired, 1, 'keyup should fire after morph'); + }); + }); describe('htmx integration', function() { diff --git a/test/tests/unit/process.js b/test/tests/unit/process.js index 45607f771..9d6ea4f47 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -102,4 +102,111 @@ describe('process() unit tests', function() { assert.isFalse(div.hasAttribute('data-htmx-powered')) }) + it('picks up hx-on:click value changes on re-process', function () { + let btn = createProcessedHTML('') + btn.click() + assert.equal(btn.getAttribute('fired'), 'v1') + + btn.setAttribute('hx-on:click', 'this.setAttribute(\'fired\', \'v2\')') + htmx.process(btn) + btn.click() + assert.equal(btn.getAttribute('fired'), 'v2', 'new handler should run after re-process') + }) + + it('picks up shorthand hx-on value changes on re-process', function () { + let btn = createProcessedHTML('') + btn.click() + assert.equal(btn.getAttribute('fired'), 'v1') + + btn.setAttribute('hx-on', 'click -> this.setAttribute(\'fired\', \'v2\')') + htmx.process(btn) + btn.click() + assert.equal(btn.getAttribute('fired'), 'v2', 'new shorthand should run after re-process') + }) + + it('wires a newly added hx-on attribute on re-process', function () { + let btn = createProcessedHTML('') + btn.setAttribute('hx-on:click', 'this.setAttribute(\'fired\', \'true\')') + htmx.process(btn) + btn.click() + assert.equal(btn.getAttribute('fired'), 'true') + }) + + it('unwires a removed hx-on attribute on re-process', function () { + let btn = createProcessedHTML('') + btn.click() + assert.equal(btn.getAttribute('fired'), 'true') + + btn.removeAttribute('fired') + btn.removeAttribute('hx-on:click') + htmx.process(btn) + btn.click() + assert.isNull(btn.getAttribute('fired')) + }) + + it('preserves hx-trigger when re-processing hx-on', async function () { + mockResponse('GET', '/test', '') + let btn = createProcessedHTML('') + let fetches = 0 + btn.addEventListener('htmx:before:request', () => fetches++) + + btn.click() + await waitForEvent('htmx:after:request', 100) + assert.equal(fetches, 1) + assert.equal(btn.getAttribute('side'), '1') + + htmx.process(btn) + btn.removeAttribute('side') + btn.click() + await waitForEvent('htmx:after:request', 100) + assert.equal(fetches, 2, 'hx-trigger should not duplicate') + assert.equal(btn.getAttribute('side'), '1', 'hx-on should fire exactly once') + }) + + it('picks up hx-trigger value change on re-process', async function () { + mockResponse('GET', '/test', '') + let btn = createProcessedHTML('') + let fired = 0 + btn.addEventListener('htmx:before:request', () => fired++) + + btn.click() + await waitForEvent('htmx:after:request', 100) + assert.equal(fired, 1, 'baseline click should fire') + + btn.setAttribute('hx-trigger', 'keyup') + htmx.process(btn) + + btn.click() + await new Promise(r => setTimeout(r, 20)) + assert.equal(fired, 1, 'click should no longer fire') + btn.dispatchEvent(new KeyboardEvent('keyup')) + await waitForEvent('htmx:after:request', 100) + assert.equal(fired, 2, 'keyup should fire') + }) + + it('hx-trigger="load" does not re-fire on re-process', function () { + mockResponse('GET', '/loadtest', '') + let div = createProcessedHTML('
x
') + let count = 0 + div.addEventListener('htmx:before:request', () => count++) + div.setAttribute('hx-trigger', 'load delay:0') + htmx.process(div) + assert.equal(count, 0, 'load should not re-fire on re-process') + }) + + it('hx-on:load and hx-trigger="load" both fire on first init', async function () { + mockResponse('GET', '/dual', '') + let requestCount = 0 + let onReq = () => requestCount++ + document.addEventListener('htmx:before:request', onReq) + try { + let div = createProcessedHTML('
x
') + await waitForEvent('htmx:after:request', 100) + assert.equal(div.getAttribute('setup'), 'true', 'hx-on:load should fire') + assert.equal(requestCount, 1, 'hx-trigger="load" should fire') + } finally { + document.removeEventListener('htmx:before:request', onReq) + } + }) + }); \ No newline at end of file