Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 60 additions & 16 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -1621,36 +1654,47 @@ var htmx = (() => {

// hx-on:<event> binds to <event> directly
// hx-on::<event> is shorthand for hx-on:htmx:<event> (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;
}
// hx-on:click="code" or hx-on::before:request="code"
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) {
Expand Down
10 changes: 10 additions & 0 deletions test/tests/unit/htmx.config.prefix.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div x-get="/a"></div>');
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 = "";
});
});
55 changes: 55 additions & 0 deletions test/tests/unit/morph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<button id="btn" hx-on:click="window._calls++">v</button>');
createProcessedHTML('<div id="target"><button id="btn" hx-on:click="window._calls++">v</button></div>');
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', '<button id="btn" hx-on:click="window._calls++">v</button>');
createProcessedHTML('<button id="btn" hx-on:click="window._calls++">v</button>');
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', '<button id="b" hx-on:click="window._log.push(\'c\')" hx-on:focus="window._log.push(\'f\')">b</button>');
createProcessedHTML('<div id="wrap"><button id="b" hx-on:click="window._log.push(\'c\')" hx-on:focus="window._log.push(\'f\')">b</button></div>');
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', '<span/>');
mockResponse('GET', '/test', '<button id="b" hx-get="/x" hx-trigger="keyup">b</button>');
createProcessedHTML('<div id="wrap"><button id="b" hx-get="/x" hx-trigger="click">b</button></div>');

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() {
Expand Down
107 changes: 107 additions & 0 deletions test/tests/unit/process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<button hx-on:click="this.setAttribute(\'fired\', \'v1\')">b</button>')
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('<button hx-on="click -> this.setAttribute(\'fired\', \'v1\')">b</button>')
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('<button>b</button>')
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('<button hx-on:click="this.setAttribute(\'fired\', \'true\')">b</button>')
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', '<span/>')
let btn = createProcessedHTML('<button hx-get="/test" hx-trigger="click" hx-on:click="this.setAttribute(\'side\', \'1\')">b</button>')
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', '<span/>')
let btn = createProcessedHTML('<button hx-get="/test" hx-trigger="click">x</button>')
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', '<span/>')
let div = createProcessedHTML('<div hx-get="/loadtest" hx-trigger="load">x</div>')
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', '<span/>')
let requestCount = 0
let onReq = () => requestCount++
document.addEventListener('htmx:before:request', onReq)
try {
let div = createProcessedHTML('<div hx-get="/dual" hx-trigger="load" hx-on:load="this.setAttribute(\'setup\', \'true\')">x</div>')
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)
}
})

});