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/ext/hx-live.js b/src/ext/hx-live.js index 0099b75c1..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); @@ -510,6 +519,8 @@ } }; fns.add(run); + prop.liveRuns = prop.liveRuns || new Set(); + prop.liveRuns.add(run); run(); } } @@ -559,6 +570,9 @@ } }; fns.add(run); + let prop = api.htmxProp(elt); + prop.liveRuns = prop.liveRuns || new Set(); + prop.liveRuns.add(run); run(); } @@ -590,6 +604,12 @@ init: (internalAPI) => { api = internalAPI; }, + htmx_before_cleanup: (elt) => { + 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 5b6819642..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,12 +873,14 @@ var htmx = (() => { return func.call(thisArg, ...values); } - process(elt) { + // 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); + for (let child of elt.children || []) this.process(child, force); return; } + if (force) this.__cleanup(elt, true); if (this.__ignore(elt)) return; if (!this.__trigger(elt, "htmx:before:process")) return let hxOnNodes = [elt]; @@ -936,24 +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); - } - 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; } } @@ -1321,8 +1326,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') { @@ -1622,6 +1625,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) => { @@ -1635,6 +1639,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()" @@ -1662,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 @@ -1671,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; } } } @@ -1685,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 } } @@ -1695,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; } } } @@ -2088,8 +2093,12 @@ var htmx = (() => { __copyAttributes(destination, source) { let attributesToIgnore = this.config.morphIgnore || []; + 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; @@ -2099,9 +2108,12 @@ 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; + if (!this.__triggerExtensions(destination, 'htmx:before:morph:attr', { attrName: attr.name, newValue: null })) continue; destination.removeAttribute(attr.name); } } + if (needsReinit) this.__cleanup(destination, true); } __populateIdMapWithTree(idMap, persistentIds, root, elements) { 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..7c89c07df 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(':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(':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(':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/__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/morph.js b/test/tests/unit/morph.js index 24a2b2d69..25250f892 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('
'); @@ -507,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..287425a85 100644 --- a/test/tests/unit/process.js +++ b/test/tests/unit/process.js @@ -102,4 +102,91 @@ 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('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') + }) + + 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 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