From 728728375dc81246d0f7ecd65ea849740abcf640 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 10 Jun 2026 09:26:23 +1200 Subject: [PATCH 1/7] cleanup htmx attributes for reinit during morph --- src/htmx.js | 10 ++++++-- test/tests/unit/morph.js | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 5b6819642..5fefb6da5 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1321,8 +1321,6 @@ var htmx = (() => { } } else if (swapStyle === 'outerSync') { this.__copyAttributes(target, fragment.firstElementChild); - this.__cleanup(target); - delete target._htmx; target.replaceChildren(...fragment.firstElementChild.childNodes); newContent = [target]; } else if (swapStyle === 'innerMorph') { @@ -2088,8 +2086,11 @@ var htmx = (() => { __copyAttributes(destination, source) { let attributesToIgnore = this.config.morphIgnore || []; + let isHxAttr = name => this.__prefixes('hx-').some(p => name.startsWith(p)); + let needsReinit = false; for (const attr of source.attributes) { if (!attributesToIgnore.some(p => attr.name.startsWith(p)) && destination.getAttribute(attr.name) !== attr.value) { + if (isHxAttr(attr.name)) needsReinit = true; destination.setAttribute(attr.name, attr.value); if (attr.name === "value" && destination instanceof HTMLInputElement && destination.type !== "file") { destination.value = attr.value; @@ -2099,9 +2100,14 @@ var htmx = (() => { for (let i = destination.attributes.length - 1; i >= 0; i--) { let attr = destination.attributes[i]; if (attr && !source.hasAttribute(attr.name) && !attributesToIgnore.some(p => attr.name.startsWith(p))) { + if (isHxAttr(attr.name)) needsReinit = true; destination.removeAttribute(attr.name); } } + if (needsReinit) { + this.__cleanup(destination); + delete destination._htmx; + } } __populateIdMapWithTree(idMap, persistentIds, root, elements) { diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 24a2b2d69..c97c3da63 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -478,6 +478,59 @@ describe('Morph Swap Styles Tests', function() { assert.equal(newBtn.getAttribute('data-htmx-powered'), 'true', 'New inserted element should be processed'); }); + it('reinitializes element when hx-get is added during innerMorph', async function() { + mockResponse('GET', '/dynamic', 'fetched'); + const div = createProcessedHTML('

static

'); + + await htmx.swap({target: '#target', text: '

now interactive

', swap: 'innerMorph', sourceElement: div}); + + const child = div.querySelector('#child'); + assert.isNotNull(child._htmx?.initialized, 'child should be initialized'); + child.click(); + await forRequest(); + assert.equal(child.textContent, 'fetched'); + }); + + it('reinitializes element when hx-get is removed during innerMorph', async function() { + mockResponse('GET', '/should-not-fire', 'bad'); + const div = createProcessedHTML('
interactive
'); + const child = div.querySelector('#child'); + assert.isNotNull(child._htmx?.initialized, 'child should start initialized'); + + await htmx.swap({target: '#target', text: '
no longer interactive
', swap: 'innerMorph', sourceElement: div}); + + assert.isNull(child.getAttribute('hx-get'), 'hx-get should be removed'); + let requestFired = false; + let handler = () => { requestFired = true; }; + document.addEventListener('htmx:before:request', handler); + child.click(); + await htmx.timeout(50); + document.removeEventListener('htmx:before:request', handler); + assert.isFalse(requestFired, 'no request should fire after hx-get removed'); + }); + + it('reinitializes element when hx-trigger changes during innerMorph', async function() { + mockResponse('GET', '/endpoint', 'response'); + const div = createProcessedHTML('
original
'); + + await htmx.swap({target: '#target', text: '
updated
', swap: 'innerMorph', sourceElement: div}); + + const child = div.querySelector('#child'); + assert.equal(child.getAttribute('hx-trigger'), 'mousedown'); + + let requestFired = false; + let handler = () => { requestFired = true; }; + document.addEventListener('htmx:before:request', handler); + child.click(); + await htmx.timeout(50); + document.removeEventListener('htmx:before:request', handler); + assert.isFalse(requestFired, 'click should not trigger after hx-trigger changed to mousedown'); + + child.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); + await forRequest(); + assert.equal(child.textContent, 'response'); + }); + it('processes new htmx attributes on inserted elements during outerMorph', async function() { mockResponse('GET', '/test', '
'); const container = createProcessedHTML('
'); From f99a0850379a0bb0ce21af660bc46fa74809d803 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 12 Jun 2026 01:00:37 +1200 Subject: [PATCH 2/7] Also handle hx-live and hx-on --- src/ext/hx-live.js | 13 +++ src/htmx.js | 2 + test/tests/attributes/hx-on.js | 22 +++++ test/tests/ext/hx-live.js | 143 +++++++++++++++++++++++++++++++++ test/tests/unit/morph.js | 55 +++++++++++++ test/tests/unit/process.js | 25 ++++++ 6 files changed, 260 insertions(+) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index 0099b75c1..e9b4aade2 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -510,6 +510,8 @@ } }; fns.add(run); + prop.liveRuns = prop.liveRuns || new Set(); + prop.liveRuns.add(run); run(); } } @@ -559,6 +561,9 @@ } }; fns.add(run); + let prop = api.htmxProp(elt); + prop.liveRuns = prop.liveRuns || new Set(); + prop.liveRuns.add(run); run(); } @@ -590,6 +595,14 @@ init: (internalAPI) => { api = internalAPI; }, + htmx_before_cleanup: (elt) => { + let prop = elt._htmx; + if (!prop?.liveRuns) return; + for (let run of prop.liveRuns) fns.delete(run); + delete prop.liveRuns; + delete prop.liveRegistered; + delete prop.liveAttrs; + }, htmx_after_process: (elt) => { processLive(elt); }, diff --git a/src/htmx.js b/src/htmx.js index 5fefb6da5..32356a212 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1620,6 +1620,7 @@ var htmx = (() => { // hx-on: binds to directly // hx-on:: is shorthand for hx-on:htmx: (htmx events) __handleHxOnAttributes(node) { + if (node._htmx?.onInitialized) return; let hxOnNames = this.__prefixes("hx-on"); let mc = this.config.metaCharacter || ':'; let handler = (code) => async (evt) => { @@ -1633,6 +1634,7 @@ var htmx = (() => { for (let attr of node.getAttributeNames()) { let prefix = hxOnNames.find(p => attr.startsWith(p)); if (!prefix) continue; + this.__htmxProp(node).onInitialized = true; let rest = attr.substring(prefix.length); let value = node.getAttribute(attr); // hx-on="click once -> doA(); blur -> doB()" diff --git a/test/tests/attributes/hx-on.js b/test/tests/attributes/hx-on.js index b2674d139..d81e55306 100644 --- a/test/tests/attributes/hx-on.js +++ b/test/tests/attributes/hx-on.js @@ -618,4 +618,26 @@ describe('hx-on="eventSpec -> code" syntax', function() { window.foo.should.equal(true); delete window.foo; }); + + it('calling process() multiple times does not duplicate hx-on listeners', function() { + window.fooCount = 0; + let btn = createProcessedHTML(''); + htmx.process(btn); + htmx.process(btn); + htmx.process(btn); + btn.click(); + window.fooCount.should.equal(1); + delete window.fooCount; + }); + + it('calling process() multiple times does not duplicate hx-on= listeners', function() { + window.fooCount = 0; + let btn = createProcessedHTML(''); + htmx.process(btn); + htmx.process(btn); + htmx.process(btn); + btn.click(); + window.fooCount.should.equal(1); + delete window.fooCount; + }); }) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index a1274634d..d22c68112 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1835,4 +1835,147 @@ describe('hx-live extension', function () { window.__liveCallCountSimple.should.equal(countAfterFirst); delete window.__liveCallCountSimple; }); + + // ------------------------------------------------------------------------- + // morph integration: cleanup + re-registration on attribute change + // ------------------------------------------------------------------------- + + describe('morph integration', function() { + + it('hx-live body: morph changing expression adopts new code, does not duplicate', async function() { + window.__morphLiveCount = 0; + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + let before = window.__morphLiveCount; + + // outerMorph the element with a changed hx-live expression — morph will + // detect the attribute change, cleanup the old registration, and re-process. + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + await htmx.timeout(5); + + // Should have incremented by 10 (new code), not 1 (old code). + let delta = window.__morphLiveCount - before; + assert.isAtLeast(delta, 10, 'new expression should run'); + assert.equal(delta % 10, 0, 'old expression should not still be running'); + delete window.__morphLiveCount; + }); + + it('hx-live body: morph removing hx-live stops the fn from running', async function() { + window.__morphRemovedCount = 0; + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + + // outerMorph to a version with hx-live removed — morph cleans up the old fn. + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + + let countAfterMorph = window.__morphRemovedCount; + // Trigger a recompute cycle — the old fn should no longer be in fns. + document.body.setAttribute('data-morph-test-trigger', '1'); + await htmx.timeout(5); + document.body.removeAttribute('data-morph-test-trigger'); + await htmx.timeout(5); + + window.__morphRemovedCount.should.equal(countAfterMorph); + delete window.__morphRemovedCount; + }); + + it('hx-live:attr binding: morph changing expression adopts new code, does not duplicate', async function() { + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + playground().querySelector('#o').textContent.should.equal('original'); + + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + await htmx.timeout(5); + + playground().querySelector('#o').textContent.should.equal('updated'); + }); + + it('hx-live:attr binding: morph adding a new binding registers it', async function() { + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + await htmx.timeout(5); + + playground().querySelector('#o').dataset.extra.should.equal('added'); + }); + + it('hx-live:attr binding: morph removing a binding stops it running', async function() { + window.__morphAttrCount = 0; + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + + let countAfterMorph = window.__morphAttrCount; + document.body.setAttribute('data-morph-attr-trigger', '1'); + await htmx.timeout(5); + document.body.removeAttribute('data-morph-attr-trigger'); + await htmx.timeout(5); + + window.__morphAttrCount.should.equal(countAfterMorph); + delete window.__morphAttrCount; + }); + + it('morph cycle does not accumulate duplicate fns across multiple morphs', async function() { + window.__morphMultiCount = 0; + playground().innerHTML = '
'; + htmx.process(playground()); + await htmx.timeout(5); + + // 3 morph cycles with identical content — each should cleanup and re-register once. + for (let i = 0; i < 3; i++) { + await htmx.swap({ + target: '#wrap', + text: '
', + swap: 'outerMorph', + sourceElement: playground() + }); + await htmx.timeout(5); + } + + let baseline = window.__morphMultiCount; + document.body.setAttribute('data-morph-multi-trigger', '1'); + await htmx.timeout(5); + document.body.removeAttribute('data-morph-multi-trigger'); + await htmx.timeout(5); + + // Should only fire once per binding, not once per morph cycle. + let delta = window.__morphMultiCount - baseline; + assert.isAtMost(delta, 2, 'should not accumulate duplicate fns across morph cycles'); + delete window.__morphMultiCount; + }); + + }); + }); diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index c97c3da63..25250f892 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -560,6 +560,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..fe1430ac4 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -102,4 +102,29 @@ describe('process() unit tests', function() { assert.isFalse(div.hasAttribute('data-htmx-powered')) }) + 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 From 28d4537a7dcf7b6938d927c20464ef63cf5c9b6d Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 12 Jun 2026 01:09:56 +1200 Subject: [PATCH 3/7] handle : attr prefix as well --- src/htmx.js | 2 +- test/tests/ext/hx-live.js | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 32356a212..bc0aa6e1e 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -2088,7 +2088,7 @@ var htmx = (() => { __copyAttributes(destination, source) { let attributesToIgnore = this.config.morphIgnore || []; - let isHxAttr = name => this.__prefixes('hx-').some(p => name.startsWith(p)); + let isHxAttr = name => this.__prefixes('hx-').some(p => name.startsWith(p)) || name.startsWith(':'); let needsReinit = false; for (const attr of source.attributes) { if (!attributesToIgnore.some(p => attr.name.startsWith(p)) && destination.getAttribute(attr.name) !== attr.value) { diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index d22c68112..7c89c07df 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -1891,15 +1891,15 @@ describe('hx-live extension', function () { delete window.__morphRemovedCount; }); - it('hx-live:attr binding: morph changing expression adopts new code, does not duplicate', async function() { - playground().innerHTML = '
'; + it(':attr binding: morph changing expression adopts new code, does not duplicate', async function() { + playground().innerHTML = '
'; htmx.process(playground()); await htmx.timeout(5); playground().querySelector('#o').textContent.should.equal('original'); await htmx.swap({ target: '#wrap', - text: '
', + text: '
', swap: 'outerMorph', sourceElement: playground() }); @@ -1908,14 +1908,14 @@ describe('hx-live extension', function () { playground().querySelector('#o').textContent.should.equal('updated'); }); - it('hx-live:attr binding: morph adding a new binding registers it', async function() { - playground().innerHTML = '
'; + it(':attr binding: morph adding a new binding registers it', async function() { + playground().innerHTML = '
'; htmx.process(playground()); await htmx.timeout(5); await htmx.swap({ target: '#wrap', - text: '
', + text: '
', swap: 'outerMorph', sourceElement: playground() }); @@ -1924,9 +1924,9 @@ describe('hx-live extension', function () { playground().querySelector('#o').dataset.extra.should.equal('added'); }); - it('hx-live:attr binding: morph removing a binding stops it running', async function() { + it(':attr binding: morph removing a binding stops it running', async function() { window.__morphAttrCount = 0; - playground().innerHTML = '
'; + playground().innerHTML = '
'; htmx.process(playground()); await htmx.timeout(5); @@ -1949,7 +1949,7 @@ describe('hx-live extension', function () { it('morph cycle does not accumulate duplicate fns across multiple morphs', async function() { window.__morphMultiCount = 0; - playground().innerHTML = '
'; + playground().innerHTML = '
'; htmx.process(playground()); await htmx.timeout(5); @@ -1957,7 +1957,7 @@ describe('hx-live extension', function () { for (let i = 0; i < 3; i++) { await htmx.swap({ target: '#wrap', - text: '
', + text: '
', swap: 'outerMorph', sourceElement: playground() }); From b689b383666f44900ab07d27ead1b438c5a4ea57 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 17 Jun 2026 01:00:26 +1200 Subject: [PATCH 4/7] Move bind cleanup all into hx-live --- src/ext/hx-live.js | 19 +++++++++++++------ src/htmx.js | 5 ++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index e9b4aade2..25599b434 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -488,6 +488,15 @@ } } + function cleanupLive(elt) { + let prop = elt._htmx; + if (!prop?.liveRuns) return; + for (let run of prop.liveRuns) fns.delete(run); + delete prop.liveRuns; + delete prop.liveRegistered; + delete prop.liveAttrs; + } + function processElement(elt) { if (elt.closest('[hx-ignore]')) return; let prop = api.htmxProp(elt); @@ -596,12 +605,10 @@ api = internalAPI; }, htmx_before_cleanup: (elt) => { - let prop = elt._htmx; - if (!prop?.liveRuns) return; - for (let run of prop.liveRuns) fns.delete(run); - delete prop.liveRuns; - delete prop.liveRegistered; - delete prop.liveAttrs; + cleanupLive(elt); + }, + htmx_before_morph_attr: (elt, detail) => { + if (bindPrefixes.some(p => detail.attrName.startsWith(p))) cleanupLive(elt); }, htmx_after_process: (elt) => { processLive(elt); diff --git a/src/htmx.js b/src/htmx.js index bc0aa6e1e..1238cc293 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -948,6 +948,7 @@ var htmx = (() => { for (let listenerInfo of elt._htmx.listeners || []) { listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler, listenerInfo); } + elt.removeAttribute('data-htmx-powered'); this.__trigger(elt, "htmx:after:cleanup") } if (elt.firstChild) { @@ -2088,11 +2089,12 @@ var htmx = (() => { __copyAttributes(destination, source) { let attributesToIgnore = this.config.morphIgnore || []; - let isHxAttr = name => this.__prefixes('hx-').some(p => name.startsWith(p)) || name.startsWith(':'); let needsReinit = false; + let isHxAttr = name => this.__prefixes('hx-').some(p => name.startsWith(p)); for (const attr of source.attributes) { if (!attributesToIgnore.some(p => attr.name.startsWith(p)) && destination.getAttribute(attr.name) !== attr.value) { if (isHxAttr(attr.name)) needsReinit = true; + if (!this.__triggerExtensions(destination, 'htmx:before:morph:attr', { attrName: attr.name, newValue: attr.value })) continue; destination.setAttribute(attr.name, attr.value); if (attr.name === "value" && destination instanceof HTMLInputElement && destination.type !== "file") { destination.value = attr.value; @@ -2103,6 +2105,7 @@ var htmx = (() => { let attr = destination.attributes[i]; if (attr && !source.hasAttribute(attr.name) && !attributesToIgnore.some(p => attr.name.startsWith(p))) { if (isHxAttr(attr.name)) needsReinit = true; + if (!this.__triggerExtensions(destination, 'htmx:before:morph:attr', { attrName: attr.name, newValue: null })) continue; destination.removeAttribute(attr.name); } } From a196c95a80a8f2923438c405dd0041fe935dadd6 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 17 Jun 2026 10:55:29 +1200 Subject: [PATCH 5/7] allow force re-process if hx attributes manually mutated --- dist/htmx.d.ts | 2 +- src/htmx.js | 9 +++++++-- .../content/reference/05-methods/08-htmx-process.md | 11 +++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dist/htmx.d.ts b/dist/htmx.d.ts index adcac3260..72de4b8bc 100644 --- a/dist/htmx.d.ts +++ b/dist/htmx.d.ts @@ -76,7 +76,7 @@ export interface Htmx { on(event: string, handler: (evt: Event) => void): void; on(target: string | Element, event: string, handler: (evt: Event) => void): void; onLoad(callback: (elt: Element) => void): void; - process(elt: Element): void; + process(elt: Element, force?: boolean): void; registerExtension(name: string, ext: any): void; trigger(elt: Element | string, event: string, detail?: any, bubbles?: boolean): boolean; timeout(ms: number): Promise; diff --git a/src/htmx.js b/src/htmx.js index 1238cc293..a9152ad3b 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -869,12 +869,17 @@ var htmx = (() => { return func.call(thisArg, ...values); } - process(elt) { + // when force is true: cleans up and resets _htmx before processing, use after manually mutating hx attributes + process(elt, force) { if (!elt) return; if (!(elt instanceof Element)) { - for (let child of elt.children || []) this.process(child); + for (let child of elt.children || []) this.process(child, force); return; } + if (force) { + this.__cleanup(elt); + delete elt._htmx; + } if (this.__ignore(elt)) return; if (!this.__trigger(elt, "htmx:before:process")) return let hxOnNodes = [elt]; diff --git a/www/src/content/reference/05-methods/08-htmx-process.md b/www/src/content/reference/05-methods/08-htmx-process.md index 455f2eb19..02c8364a2 100644 --- a/www/src/content/reference/05-methods/08-htmx-process.md +++ b/www/src/content/reference/05-methods/08-htmx-process.md @@ -9,11 +9,13 @@ Processes htmx attributes on the specified element and its descendants, initiali ```javascript htmx.process(element) +htmx.process(element, force) ``` ## Parameters - `element` - DOM element to process +- `force` - (optional) when `true`, cleans up and resets existing htmx state before processing. Use after manually mutating hx attributes on an already-initialized element ## Usage @@ -29,6 +31,14 @@ document.body.appendChild(newContent); htmx.process(newContent); ``` +Use `force` after manually mutating hx attributes on an already-initialized element: + +```javascript +const btn = document.querySelector('#my-btn'); +btn.setAttribute('hx-trigger', 'keyup'); +htmx.process(btn, true); +``` + ## Notes * Automatically called by htmx after swaps @@ -38,3 +48,4 @@ htmx.process(newContent); * Initializes event listeners for [`hx-get`](/reference/attributes/hx-get), [`hx-post`](/reference/attributes/hx-post), etc. * Sets up boosted links and forms * Processes [`hx-on`](/reference/attributes/hx-on) attributes +* When `force` is `true`, triggers [`htmx:before:cleanup`](/reference/events/htmx-before-cleanup) before reinitializing From fc21215284e8c0e50fd543eb7ba9a513d356d00a Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 17 Jun 2026 10:58:59 +1200 Subject: [PATCH 6/7] add process true test --- test/tests/unit/process.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/test/tests/unit/process.js b/test/tests/unit/process.js index fe1430ac4..6a91d91cc 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -112,19 +112,26 @@ describe('process() unit tests', function() { 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) - } + it('process(elt, true) picks up a changed hx-trigger value', 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 fires') + + btn.setAttribute('hx-trigger', 'keyup') + htmx.process(btn, true) + + btn.click() + await new Promise(r => setTimeout(r, 20)) + assert.equal(fired, 1, 'click no longer fires after trigger change') + + btn.dispatchEvent(new KeyboardEvent('keyup')) + await waitForEvent('htmx:after:request', 100) + assert.equal(fired, 2, 'keyup fires after force reprocess') }) }); \ No newline at end of file From 7a84d2b9a6b9f2c6ab88dfc7f8b9a92fd7280624 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Thu, 18 Jun 2026 12:40:33 +1200 Subject: [PATCH 7/7] seperate request queue state for simpler cleanup --- src/htmx.js | 62 ++++++++++------------ test/tests/unit/__disableEnableElements.js | 12 ++--- test/tests/unit/__getRequestQueue.js | 6 +-- test/tests/unit/__showHideIndicators.js | 12 ++--- test/tests/unit/process.js | 55 +++++++++++++++++++ 5 files changed, 99 insertions(+), 48 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index a9152ad3b..f47ca8354 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -323,6 +323,10 @@ var htmx = (() => { return elt._htmx; } + __htmxState(elt) { + return elt._htmx_state ||= {}; + } + __initializeElement(elt) { if (this.__shouldInitialize(elt) && this.__trigger(elt, "htmx:before:init", {}, true)) { let htmxProp = this.__htmxProp(elt); @@ -652,7 +656,7 @@ var htmx = (() => { : (/^(drop|abort|replace|queue)/.test(syncValue) ? null : syncValue); if (selector) syncElt = this.__findOrWarn(elt, selector, "hx-sync") || elt; } - return this.__htmxProp(syncElt).rq ||= new ReqQ() + return this.__htmxState(syncElt).rq ||= new ReqQ() } __isModifierKeyClick(evt) { @@ -869,17 +873,14 @@ var htmx = (() => { return func.call(thisArg, ...values); } - // when force is true: cleans up and resets _htmx before processing, use after manually mutating hx attributes + // when force is true: re-wires elt and all powered descendants from current attributes process(elt, force) { if (!elt) return; if (!(elt instanceof Element)) { for (let child of elt.children || []) this.process(child, force); return; } - if (force) { - this.__cleanup(elt); - delete elt._htmx; - } + if (force) this.__cleanup(elt, true); if (this.__ignore(elt)) return; if (!this.__trigger(elt, "htmx:before:process")) return let hxOnNodes = [elt]; @@ -941,25 +942,23 @@ var htmx = (() => { return !elt._htmx?.initialized && !this.__ignore(elt); } - __cleanup(elt) { - if (elt._htmx) { - this.__trigger(elt, "htmx:before:cleanup") - for (let spec of elt._htmx.triggerSpecs || []) { + __cleanup(elt, force) { + let elts = [elt, ...elt.querySelectorAll?.('[data-htmx-powered]') ?? []]; + for (let e of elts) { + if (!e._htmx) continue; + this.__trigger(e, "htmx:before:cleanup") + for (let spec of e._htmx.triggerSpecs || []) { 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); - } - elt.removeAttribute('data-htmx-powered'); - this.__trigger(elt, "htmx:after:cleanup") - } - if (elt.firstChild) { - for (let child of elt.querySelectorAll('[data-htmx-powered]')) { - this.__cleanup(child); + for (let info of e._htmx.listeners || []) { + info.fromElt.removeEventListener(info.eventName, info.handler, info); } + e.removeAttribute('data-htmx-powered'); + this.__trigger(e, "htmx:after:cleanup") + if (force) delete e._htmx; } } @@ -1668,8 +1667,8 @@ var htmx = (() => { indicatorElements = this.__findAllExt(elt, indicatorsSelector, "hx-indicator"); } for (const indicator of indicatorElements) { - let p = this.__htmxProp(indicator); - p.rc = (p.rc || 0) + 1; + let s = this.__htmxState(indicator); + s.rc = (s.rc || 0) + 1; this.__addClass(indicator, this.config.requestClass) } return indicatorElements @@ -1677,10 +1676,10 @@ var htmx = (() => { __hideIndicators(indicatorElements) { for (let indicator of indicatorElements) { - let p = this.__htmxProp(indicator); - if (p.rc && --p.rc <= 0) { + let s = this.__htmxState(indicator); + if (s.rc && --s.rc <= 0) { this.__removeClass(indicator, this.config.requestClass); - delete p.rc; + delete s.rc; } } } @@ -1691,8 +1690,8 @@ var htmx = (() => { if (disabledSelector) { disabledElements = this.__findAllExt(elt, disabledSelector, "hx-disable"); for (let indicator of disabledElements) { - let p = this.__htmxProp(indicator); - p.dc = (p.dc || 0) + 1; + let s = this.__htmxState(indicator); + s.dc = (s.dc || 0) + 1; indicator.disabled = true } } @@ -1701,10 +1700,10 @@ var htmx = (() => { __enableElements(disabledElements) { for (const indicator of disabledElements) { - let p = this.__htmxProp(indicator); - if (p.dc && --p.dc <= 0) { + let s = this.__htmxState(indicator); + if (s.dc && --s.dc <= 0) { indicator.disabled = false - delete p.dc; + delete s.dc; } } } @@ -2114,10 +2113,7 @@ var htmx = (() => { destination.removeAttribute(attr.name); } } - if (needsReinit) { - this.__cleanup(destination); - delete destination._htmx; - } + if (needsReinit) this.__cleanup(destination, true); } __populateIdMapWithTree(idMap, persistentIds, root, elements) { diff --git a/test/tests/unit/__disableEnableElements.js b/test/tests/unit/__disableEnableElements.js index 8c0934922..8fb6e7ff5 100644 --- a/test/tests/unit/__disableEnableElements.js +++ b/test/tests/unit/__disableEnableElements.js @@ -34,7 +34,7 @@ describe('__disableElements / __enableElements unit tests', function() { htmx.__disableElements(container) htmx.__disableElements(container) - assert.equal(htmx.__htmxProp(button).dc, 2) + assert.equal(htmx.__htmxState(button).dc, 2) assert.isTrue(button.disabled) }) @@ -46,7 +46,7 @@ describe('__disableElements / __enableElements unit tests', function() { let elements2 = htmx.__disableElements(container) htmx.__enableElements(elements1) - assert.equal(htmx.__htmxProp(button).dc, 1) + assert.equal(htmx.__htmxState(button).dc, 1) assert.isTrue(button.disabled) }) @@ -60,7 +60,7 @@ describe('__disableElements / __enableElements unit tests', function() { htmx.__enableElements(elements2) assert.isFalse(button.disabled) - assert.isUndefined(htmx.__htmxProp(button).dc) + assert.isUndefined(htmx.__htmxState(button).dc) }) it('handles multiple elements', function () { @@ -98,7 +98,7 @@ describe('__disableElements / __enableElements unit tests', function() { htmx.__enableElements([button]) assert.isFalse(button.disabled) - assert.isUndefined(htmx.__htmxProp(button).dc) + assert.isUndefined(htmx.__htmxState(button).dc) }) it('works with nested elements', function () { @@ -126,8 +126,8 @@ describe('__disableElements / __enableElements unit tests', function() { button.parentElement.setAttribute('hx-disable', 'button.disable-me') htmx.__disableElements(button.parentElement) - assert.equal(htmx.__htmxProp(button).dc, 2) - assert.equal(htmx.__htmxProp(input).dc, 1) + assert.equal(htmx.__htmxState(button).dc, 2) + assert.equal(htmx.__htmxState(input).dc, 1) }) it('resolves this selector for disable', function () { diff --git a/test/tests/unit/__getRequestQueue.js b/test/tests/unit/__getRequestQueue.js index b7282e8d5..ab0c4828f 100644 --- a/test/tests/unit/__getRequestQueue.js +++ b/test/tests/unit/__getRequestQueue.js @@ -250,7 +250,7 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { // Both children should share the parent's queue assert.equal(queueA, queueB) - assert.equal(htmx.__htmxProp(parent).rq, queueA) + assert.equal(htmx.__htmxState(parent).rq, queueA) assert.equal(htmx.__determineSyncStrategy(a), 'replace') }) @@ -438,7 +438,7 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { assert.equal(htmx.__determineSyncStrategy(btn), 'queue first') // queue should be on the form, not the button let queue = htmx.__getRequestQueue(btn) - assert.equal(htmx.__htmxProp(form).rq, queue) + assert.equal(htmx.__htmxState(form).rq, queue) }) it('hx-sync="closest form:replace" uses closest form with replace strategy', function () { @@ -446,7 +446,7 @@ describe('__getRequestQueue / RequestQueue unit tests', function() { let btn = form.querySelector('#btn') assert.equal(htmx.__determineSyncStrategy(btn), 'replace') let queue = htmx.__getRequestQueue(btn) - assert.equal(htmx.__htmxProp(form).rq, queue) + assert.equal(htmx.__htmxState(form).rq, queue) }) it('hx-sync with "this" shares queue between sibling elements synced to parent', function () { diff --git a/test/tests/unit/__showHideIndicators.js b/test/tests/unit/__showHideIndicators.js index 8c431d02d..9b9d01ebe 100644 --- a/test/tests/unit/__showHideIndicators.js +++ b/test/tests/unit/__showHideIndicators.js @@ -34,7 +34,7 @@ describe('__showIndicators / __hideIndicators unit tests', function() { htmx.__showIndicators(container) htmx.__showIndicators(container) - assert.equal(htmx.__htmxProp(span).rc, 2) + assert.equal(htmx.__htmxState(span).rc, 2) assert.isTrue(span.classList.contains('htmx-request')) }) @@ -46,7 +46,7 @@ describe('__showIndicators / __hideIndicators unit tests', function() { let indicators2 = htmx.__showIndicators(container) htmx.__hideIndicators(indicators1) - assert.equal(htmx.__htmxProp(span).rc, 1) + assert.equal(htmx.__htmxState(span).rc, 1) assert.isTrue(span.classList.contains('htmx-request')) }) @@ -60,7 +60,7 @@ describe('__showIndicators / __hideIndicators unit tests', function() { htmx.__hideIndicators(indicators2) assert.isFalse(span.classList.contains('htmx-request')) - assert.isUndefined(htmx.__htmxProp(span).rc) + assert.isUndefined(htmx.__htmxState(span).rc) }) it('handles multiple indicators', function () { @@ -98,7 +98,7 @@ describe('__showIndicators / __hideIndicators unit tests', function() { htmx.__hideIndicators([span]) assert.isFalse(span.classList.contains('htmx-request')) - assert.isUndefined(htmx.__htmxProp(span).rc) + assert.isUndefined(htmx.__htmxState(span).rc) }) it('works with nested indicators', function () { @@ -126,8 +126,8 @@ describe('__showIndicators / __hideIndicators unit tests', function() { container.setAttribute('hx-indicator', 'span.indicator') htmx.__showIndicators(container) - assert.equal(htmx.__htmxProp(span).rc, 2) - assert.equal(htmx.__htmxProp(div).rc, 1) + assert.equal(htmx.__htmxState(span).rc, 2) + assert.equal(htmx.__htmxState(div).rc, 1) }) it('resolves this selector for indicators', function () { diff --git a/test/tests/unit/process.js b/test/tests/unit/process.js index 6a91d91cc..287425a85 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -134,4 +134,59 @@ describe('process() unit tests', function() { assert.equal(fired, 2, 'keyup fires after force reprocess') }) + it('process(parent, true) reprocesses descendants with mutated hx-on', function () { + window._n = 0 + let div = createProcessedHTML('
') + let btn = div.querySelector('#b') + btn.click(); assert.equal(window._n, 1) + btn.setAttribute('hx-on:click', 'window._n += 10') + htmx.process(div, true) + btn.click() + assert.equal(window._n, 11, 'descendant rebound from current attribute') + delete window._n + }) + + it('process(elt, true) unwires a removed hx-on attribute', function () { + window._n = 0 + let btn = createProcessedHTML('') + btn.click(); assert.equal(window._n, 1) + btn.removeAttribute('hx-on:click') + htmx.process(btn, true) + btn.click() + assert.equal(window._n, 1, 'removed handler is gone') + delete window._n + }) + + it('process(elt, true) mid-request keeps the indicator wired to the running request', async function () { + mockResponse('GET', '/slow', '') + let btn = createProcessedHTML('') + btn.click() + assert.isTrue(btn.classList.contains('htmx-request'), 'indicator on during request') + htmx.process(btn, true) // force mid-flight must not orphan the indicator + await forRequest() + assert.isFalse(btn.classList.contains('htmx-request'), 'indicator cleared when request completes') + }) + + it('cleanup fires before:cleanup and after:cleanup exactly once per element', async function () { + let div = createProcessedHTML('
') + let events = [] + div.addEventListener('htmx:before:cleanup', (e) => events.push('before:' + e.target.id)) + div.addEventListener('htmx:after:cleanup', (e) => events.push('after:' + e.target.id)) + await htmx.swap({target: '#target', text: '

replaced

', swap: 'innerHTML', sourceElement: div}) + assert.deepEqual(events, ['before:parent', 'after:parent', 'before:child', 'after:child']) + }) + + it('cleanup fires exactly once per element in nested tree (no double-fire)', async function () { + let html = '
' + let div = createProcessedHTML(html) + let counts = {} + div.addEventListener('htmx:before:cleanup', (e) => { + counts[e.target.id] = (counts[e.target.id] || 0) + 1 + }) + await htmx.swap({target: '#target', text: '

replaced

', swap: 'innerHTML', sourceElement: div}) + assert.equal(counts['gp'], 1, 'grandparent cleaned once') + assert.equal(counts['p'], 1, 'parent cleaned once') + assert.equal(counts['c'], 1, 'child cleaned once') + }) + }); \ No newline at end of file