diff --git a/package.json b/package.json
index 39f3c3330..be598632e 100644
--- a/package.json
+++ b/package.json
@@ -27,8 +27,8 @@
"build": "npm run build:iife && npm run build:esm && npm run build:minify && npm run build:ext && npm run build:htmax && npm run build:editors && npm run build:skills && npm run build:scripts && npm run build:types && npm run build:compress",
"build:htmax": "EXTS='hx-sse hx-ws hx-preload hx-browser-indicator hx-download hx-optimistic hx-targets hx-live hx-history-cache hx-upsert hx-alpine-compat'; { cat dist/htmx.js; printf '\\nhtmx.version += \"-htmax\";\\nhtmx.config.historyCache ??= { disable: true };\\n'; for e in $EXTS; do cat dist/ext/$e.js; done; } > dist/htmax.js && terser --compress --mangle --source-map -o dist/htmax.min.js dist/htmax.js",
"build:skills": "mkdir -p dist/skills && cp src/skills/*.md dist/skills/",
- "build:iife": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.js",
- "build:esm": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.esm.js && printf '\\nif (typeof window !== \"undefined\") window.htmx = htmx;\\nexport default htmx;\\n' >> dist/htmx.esm.js",
+ "build:iife": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' -e 's/this\\.#location/location/g' -e '/#location = window\\.location/d' src/htmx.js > dist/htmx.js",
+ "build:esm": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' -e 's/this\\.#location/location/g' -e '/#location = window\\.location/d' src/htmx.js > dist/htmx.esm.js && printf '\\nif (typeof window !== \"undefined\") window.htmx = htmx;\\nexport default htmx;\\n' >> dist/htmx.esm.js",
"build:minify": "terser --compress --mangle --source-map -o dist/htmx.min.js dist/htmx.js && terser --compress --mangle --source-map -o dist/htmx.esm.min.js dist/htmx.esm.js",
"build:ext": "mkdir -p dist/ext && for file in src/ext/*.js; do name=$(basename \"$file\" .js); cp \"$file\" \"dist/ext/$name.js\" && terser --compress --mangle --source-map -o \"dist/ext/$name.min.js\" \"dist/ext/$name.js\"; done",
"build:editors": "mkdir -p dist/editors && cp -r src/editors/* dist/editors/",
diff --git a/src/htmx.js b/src/htmx.js
index ce6f83bfa..4b8bbe5bc 100644
--- a/src/htmx.js
+++ b/src/htmx.js
@@ -148,6 +148,7 @@ var htmx = (() => {
__extMethods = new Map();
__approvedExt = '';
__registeredExt = new Set();
+ __location = window.location; // mockable for testing
#internalAPI;
#Function = Function;
#AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
@@ -676,11 +677,11 @@ var htmx = (() => {
// Returns true if the response was fully handled by a header.
__handleHeadersAndMaybeReturnEarly(ctx) {
if (ctx.hx.refresh === 'true') { // HX-Refresh
- location.reload();
+ this.__location.reload();
return true
}
if (ctx.hx.redirect) { // HX-Redirect
- location.href = ctx.hx.redirect;
+ this.__location.href = ctx.hx.redirect;
return true
}
if (ctx.hx.location) { // HX-Location
@@ -1654,7 +1655,7 @@ var htmx = (() => {
let historyElt = document.querySelector(this.__prefixSelector('[hx-history-elt]')) || document.body;
if (this.__trigger(document, "htmx:before:history:restore", {path, cacheMiss: true})) {
if (this.config.history === "reload") {
- location.reload();
+ this.__location.reload();
} else {
this.#historyAbort = new AbortController();
return this.ajax('GET', path, {
diff --git a/test/tests/attributes/hx-on.js b/test/tests/attributes/hx-on.js
index d81e55306..352bf68a2 100644
--- a/test/tests/attributes/hx-on.js
+++ b/test/tests/attributes/hx-on.js
@@ -640,4 +640,25 @@ describe('hx-on="eventSpec -> code" syntax', function() {
window.fooCount.should.equal(1);
delete window.fooCount;
});
+
+ // Gap #16: hx-on error trigger
+ it('triggers htmx:error when hx-on handler throws', function(done) {
+ createProcessedHTML('');
+ document.addEventListener('htmx:error', function(evt) {
+ assert.isNotNull(evt.detail.error);
+ assert.equal(evt.detail.error.message, 'test error');
+ done();
+ }, { once: true });
+ find('#btn').click();
+ });
+
+ // Gap: hx-on with invalid separator (hyphen instead of colon)
+ it('skips hx-on attributes with wrong separator', async function() {
+ let div = createProcessedHTML('
test
');
+ let clicked = false;
+ div.addEventListener('click', () => { clicked = true; });
+ div.click();
+ await htmx.timeout(10);
+ assert.isTrue(clicked);
+ });
})
diff --git a/test/tests/unit/__collectFormData.js b/test/tests/unit/__collectFormData.js
index c852ceeec..64fb10c39 100644
--- a/test/tests/unit/__collectFormData.js
+++ b/test/tests/unit/__collectFormData.js
@@ -245,4 +245,45 @@ describe('__collectFormData unit tests', function() {
let formData = htmx.__collectFormData(elt, null, null, false, false);
assert.equal(formData.get('tenantId'), 'acme');
});
+
+ it('includes file input via hx-include', async function () {
+ mockResponse('POST', '/upload', 'OK');
+ createProcessedHTML(
+ '' +
+ ''
+ );
+ let fileInput = find('#file-input');
+ let file = new File(['test content'], 'test.txt', { type: 'text/plain' });
+ let dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ fileInput.files = dataTransfer.files;
+ find('#btn').click();
+ await forRequest();
+ let lastCall = fetchMock.calls[fetchMock.calls.length - 1];
+ let files = lastCall.request.body.getAll('myfile');
+ assert.equal(files.length, 1);
+ assert.equal(files[0].name, 'test.txt');
+ });
+
+ it('includes multiple files via hx-include', async function () {
+ mockResponse('POST', '/upload', 'OK');
+ createProcessedHTML(
+ '' +
+ ''
+ );
+ let fileInput = find('#multi-file');
+ let file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' });
+ let file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' });
+ let dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file1);
+ dataTransfer.items.add(file2);
+ fileInput.files = dataTransfer.files;
+ find('#btn').click();
+ await forRequest();
+ let lastCall = fetchMock.calls[fetchMock.calls.length - 1];
+ let files = lastCall.request.body.getAll('files');
+ assert.equal(files.length, 2);
+ assert.equal(files[0].name, 'file1.txt');
+ assert.equal(files[1].name, 'file2.txt');
+ });
});
\ No newline at end of file
diff --git a/test/tests/unit/__executeJavaScript.js b/test/tests/unit/__executeJavaScript.js
index 2a8545243..41c62d634 100644
--- a/test/tests/unit/__executeJavaScript.js
+++ b/test/tests/unit/__executeJavaScript.js
@@ -13,4 +13,19 @@ describe('__executeJavaScript', function() {
run().should.equal(42);
});
+ it('applies nonce to inline scripts when config.inlineScriptNonce is set', async function () {
+ mockResponse('GET', '/test', '');
+ htmx.config.inlineScriptNonce = 'test-nonce-123';
+ try {
+ createProcessedHTML('');
+ find('#btn').click();
+ await forRequest();
+ await htmx.timeout(10);
+ let script = find('#test-script');
+ assert.equal(script.nonce, 'test-nonce-123');
+ } finally {
+ htmx.config.inlineScriptNonce = null;
+ }
+ });
+
});
diff --git a/test/tests/unit/__findAllExt.js b/test/tests/unit/__findAllExt.js
index 2af636a12..746cc2ae3 100644
--- a/test/tests/unit/__findAllExt.js
+++ b/test/tests/unit/__findAllExt.js
@@ -230,4 +230,39 @@ describe('__findAllExt unit tests', function() {
assert.include(results[0].className, 'outer')
})
+ // Gap #19: 'this' selector without thisAttr
+ it('handles "this" selector returning the element itself', function() {
+ let div = createProcessedHTML('');
+ let results = htmx.__findAllExt(div, 'this');
+ assert.equal(results.length, 1);
+ assert.equal(results[0], div);
+ })
+
+ // Gap #20: getRootNode document fallback for disconnected element
+ it('falls back to document for disconnected element', function() {
+ let detached = document.createElement('div');
+ createProcessedHTML('');
+ let results = htmx.__findAllExt(detached, '.doc-span');
+ assert.equal(results.length, 1);
+ })
+
+ // Gap #18: Shadow DOM host resolution
+ it('handles "host" selector returning shadow host', function() {
+ let host = createProcessedHTML('');
+ let shadow = host.attachShadow({ mode: 'open' });
+ let inner = document.createElement('div');
+ shadow.appendChild(inner);
+
+ let results = htmx.__findAllExt(inner, 'host');
+ assert.equal(results.length, 1);
+ assert.equal(results[0], host);
+ })
+
+ // Public findAll method
+ it('htmx.findAll delegates to __findAllExt', function() {
+ createProcessedHTML('');
+ let results = htmx.findAll(document, '.foo');
+ assert.equal(results.length, 2);
+ })
+
});
\ No newline at end of file
diff --git a/test/tests/unit/__handleHistoryUpdate.js b/test/tests/unit/__handleHistoryUpdate.js
index ddc024b2c..800c9353e 100644
--- a/test/tests/unit/__handleHistoryUpdate.js
+++ b/test/tests/unit/__handleHistoryUpdate.js
@@ -90,4 +90,54 @@ describe('__handleHistoryUpdate unit tests', function() {
assert.include(window.location.href, '/redirected-path?foo=bar')
})
+ it('restores history via popstate event when Navigation API unavailable', async function() {
+ let savedNavigation = window.navigation;
+ delete window.navigation;
+ htmx.__initHistoryHandling();
+ try {
+ playground().innerHTML = 'current
';
+ htmx.process(playground());
+ history.replaceState({ htmx: true }, '', '/popstate-test-a');
+ htmx.__pushUrlIntoHistory('/popstate-test-b');
+ playground().innerHTML = 'page B
';
+ mockResponse('GET', '/popstate-test-a', 'restored via popstate
');
+ history.back();
+ await forRequest();
+ playground().textContent.should.include('restored via popstate');
+ } finally {
+ if (savedNavigation) window.navigation = savedNavigation;
+ }
+ })
+
+ it('returns early when config.history is false', function() {
+ let savedHistory = htmx.config.history;
+ htmx.config.history = false;
+ try {
+ htmx.__initHistoryHandling();
+ assert.equal(htmx.config.history, false);
+ } finally {
+ htmx.config.history = savedHistory;
+ }
+ })
+
+ it('reloads page on history restore when config.history is "reload"', async function() {
+ let originalLocation = htmx.__location;
+ let originalHistoryConfig = htmx.config.history;
+ let reloadCalled = false;
+ htmx.__location = {
+ reload: function() { reloadCalled = true; },
+ pathname: '/test', search: '', href: 'http://localhost/test'
+ };
+ htmx.config.history = 'reload';
+ try {
+ history.replaceState({ htmx: true }, '', '/test');
+ htmx.__restoreHistory({ htmx: true });
+ await htmx.timeout(10);
+ assert.isTrue(reloadCalled);
+ } finally {
+ htmx.__location = originalLocation;
+ htmx.config.history = originalHistoryConfig;
+ }
+ })
+
});
diff --git a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js b/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js
index 2914ae1b1..60323571c 100644
--- a/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js
+++ b/test/tests/unit/__handleHxHeadersAndMaybeReturnEarly.js
@@ -1,10 +1,16 @@
describe('__handleHxHeadersAndMaybeReturnEarly unit tests', function() {
+ let originalLocation;
+
beforeEach(function() {
setupTest();
+ // Save original location reference
+ originalLocation = htmx.__location;
});
afterEach(function() {
+ // Restore original location
+ htmx.__location = originalLocation;
cleanupTest();
});
@@ -34,4 +40,158 @@ describe('__handleHxHeadersAndMaybeReturnEarly unit tests', function() {
assert.isNotOk(result)
})
+ // HX-Refresh header tests
+ it('calls location.reload() on HX-Refresh: true', function () {
+ let reloadCalled = false;
+ htmx.__location = { reload: function() { reloadCalled = true; } };
+
+ let ctx = {
+ hx: { refresh: 'true' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isTrue(result);
+ assert.isTrue(reloadCalled);
+ })
+
+ it('does not reload on HX-Refresh: false', function () {
+ let reloadCalled = false;
+ htmx.__location = { reload: function() { reloadCalled = true; } };
+
+ let ctx = {
+ hx: { refresh: 'false' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isNotOk(result);
+ assert.isFalse(reloadCalled);
+ })
+
+ // HX-Redirect header tests
+ it('sets location.href on HX-Redirect', function () {
+ let redirectUrl = null;
+ htmx.__location = {
+ get href() { return window.location.href; },
+ set href(val) { redirectUrl = val; }
+ };
+
+ let ctx = {
+ hx: { redirect: 'https://example.com/new-page' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isTrue(result);
+ assert.equal(redirectUrl, 'https://example.com/new-page');
+ })
+
+ // HX-Location header tests
+ it('calls ajax on HX-Location with simple path', function () {
+ let ajaxCalled = false;
+ let ajaxArgs = null;
+ const originalAjax = htmx.ajax;
+ htmx.ajax = function(method, path, opts) {
+ ajaxCalled = true;
+ ajaxArgs = { method, path, opts };
+ };
+
+ try {
+ let ctx = {
+ hx: { location: '/new-path' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isTrue(result);
+ assert.isTrue(ajaxCalled);
+ assert.equal(ajaxArgs.method, 'GET');
+ assert.equal(ajaxArgs.path, '/new-path');
+ assert.equal(ajaxArgs.opts.push, 'true');
+ } finally {
+ htmx.ajax = originalAjax;
+ }
+ })
+
+ it('calls ajax on HX-Location with JSON config', function () {
+ let ajaxCalled = false;
+ let ajaxArgs = null;
+ const originalAjax = htmx.ajax;
+ htmx.ajax = function(method, path, opts) {
+ ajaxCalled = true;
+ ajaxArgs = { method, path, opts };
+ };
+
+ try {
+ let ctx = {
+ hx: { location: '{"path":"/test", "target":"#result"}' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isTrue(result);
+ assert.isTrue(ajaxCalled);
+ assert.equal(ajaxArgs.method, 'GET');
+ assert.equal(ajaxArgs.path, '/test');
+ assert.equal(ajaxArgs.opts.target, '#result');
+ assert.equal(ajaxArgs.opts.push, 'true');
+ } finally {
+ htmx.ajax = originalAjax;
+ }
+ })
+
+ it('calls ajax on HX-Location with HCON config', function () {
+ let ajaxCalled = false;
+ let ajaxArgs = null;
+ const originalAjax = htmx.ajax;
+ htmx.ajax = function(method, path, opts) {
+ ajaxCalled = true;
+ ajaxArgs = { method, path, opts };
+ };
+
+ try {
+ let ctx = {
+ hx: { location: 'path:/test target:#result' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ let result = htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.isTrue(result);
+ assert.isTrue(ajaxCalled);
+ assert.equal(ajaxArgs.method, 'GET');
+ assert.equal(ajaxArgs.path, '/test');
+ assert.equal(ajaxArgs.opts.target, '#result');
+ } finally {
+ htmx.ajax = originalAjax;
+ }
+ })
+
+ it('respects push:false in HX-Location', function () {
+ let ajaxArgs = null;
+ const originalAjax = htmx.ajax;
+ htmx.ajax = function(method, path, opts) {
+ ajaxArgs = { method, path, opts };
+ };
+
+ try {
+ let ctx = {
+ hx: { location: '{"path":"/test", "push":"false"}' },
+ sourceElement: createProcessedHTML('')
+ };
+
+ htmx.__handleHeadersAndMaybeReturnEarly(ctx);
+
+ assert.equal(ajaxArgs.opts.push, 'false');
+ } finally {
+ htmx.ajax = originalAjax;
+ }
+ })
+
});
diff --git a/test/tests/unit/__shouldBoost.js b/test/tests/unit/__shouldBoost.js
index 8b522650c..3ba4d8083 100644
--- a/test/tests/unit/__shouldBoost.js
+++ b/test/tests/unit/__shouldBoost.js
@@ -171,5 +171,11 @@ describe('__shouldBoost() unit tests', function() {
const result = htmx.__shouldBoost(input);
assert.isUndefined(result);
});
+
+ it('returns false for malformed URLs that cause URL constructor to throw', function() {
+ createProcessedHTML('');
+ let link = find('#link');
+ assert.isUndefined(link._htmx?.boosted);
+ });
});
});
diff --git a/test/tests/unit/__showHideIndicators.js b/test/tests/unit/__showHideIndicators.js
index 4b0d08487..8e1b95e94 100644
--- a/test/tests/unit/__showHideIndicators.js
+++ b/test/tests/unit/__showHideIndicators.js
@@ -200,4 +200,27 @@ describe('__showIndicators / __hideIndicators unit tests', function() {
assert.equal(indicators.length, 2);
})
+ it('keeps indicators visible when HX-Redirect is received', async function() {
+ let originalLocation = htmx.__location;
+ let redirectUrl = null;
+ htmx.__location = {
+ get href() { return window.location.href; },
+ set href(val) { redirectUrl = val; }
+ };
+ try {
+ mockResponse('GET', '/test', '', { headers: { 'HX-Redirect': '/other-page' } });
+ createProcessedHTML(
+ 'Loading...
' +
+ ''
+ );
+ find('#btn').click();
+ await forRequest();
+ await htmx.timeout(10);
+ assert.equal(redirectUrl, '/other-page');
+ assert.isTrue(find('#indicator').classList.contains('htmx-request'));
+ } finally {
+ htmx.__location = originalLocation;
+ }
+ })
+
});
diff --git a/test/tests/unit/ajax.js b/test/tests/unit/ajax.js
index cb3ab30c7..5b57ef4f3 100644
--- a/test/tests/unit/ajax.js
+++ b/test/tests/unit/ajax.js
@@ -309,4 +309,38 @@ describe('ajax() unit Tests', function() {
assert.include(div.innerHTML, 'old
');
assert.include(div.innerHTML, 'new');
});
+
+ it('sets full URL for cross-origin GET requests with query params', async function() {
+ let capturedAction;
+ document.addEventListener('htmx:before:request', function handler(evt) {
+ capturedAction = evt.detail.ctx.request.action;
+ evt.preventDefault();
+ document.removeEventListener('htmx:before:request', handler);
+ });
+ createProcessedHTML('');
+ find('#btn').click();
+ await htmx.timeout(50);
+ assert.isTrue(capturedAction.startsWith('https://other-domain.com/api'));
+ assert.include(capturedAction, 'foo=bar');
+ });
+
+ it('triggers htmx:error when __createRequestContext throws', async function() {
+ let errorTriggered = false;
+ let caughtError = null;
+ let btn = createProcessedHTML('');
+ btn.addEventListener('htmx:error', (e) => {
+ errorTriggered = true;
+ caughtError = e.detail.error;
+ }, { once: true });
+ let original = htmx.__createRequestContext;
+ htmx.__createRequestContext = function() { throw new Error('context creation failed'); };
+ try {
+ btn.click();
+ await htmx.timeout(50);
+ assert.isTrue(errorTriggered, 'htmx:error should have been triggered');
+ assert.equal(caughtError.message, 'context creation failed');
+ } finally {
+ htmx.__createRequestContext = original;
+ }
+ });
});
diff --git a/test/tests/unit/bootstrap.js b/test/tests/unit/bootstrap.js
index e9ec5e892..956dcc281 100644
--- a/test/tests/unit/bootstrap.js
+++ b/test/tests/unit/bootstrap.js
@@ -226,4 +226,25 @@ describe('bootstrap unit tests', function() {
'Public properties have changed. Expected: ' + JSON.stringify(expectedPublicProperties) +
', Got: ' + JSON.stringify(ownProperties));
})
+
+ it('constructor uses setTimeout when document is already loaded', async function() {
+ assert.notEqual(document.readyState, 'loading', 'document should already be loaded')
+ let HtmxClass = Object.getPrototypeOf(htmx).constructor
+ let setTimeoutCalled = false
+ let originalSetTimeout = window.setTimeout
+ window.setTimeout = function(fn, ...args) {
+ if (fn.name === 'init' || (fn.toString && fn.toString().includes('__initHistoryHandling'))) {
+ setTimeoutCalled = true
+ }
+ return originalSetTimeout.call(this, fn, ...args)
+ }
+ try {
+ let newInstance = new HtmxClass()
+ await new Promise(r => originalSetTimeout(r, 100))
+ assert.isTrue(setTimeoutCalled, 'setTimeout should have been called for init')
+ assert.isDefined(newInstance.config)
+ } finally {
+ window.setTimeout = originalSetTimeout
+ }
+ })
})
diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js
index 98c9a555b..3c9defe79 100644
--- a/test/tests/unit/morph.js
+++ b/test/tests/unit/morph.js
@@ -990,4 +990,65 @@ describe('Morph Swap Styles Tests', function() {
});
});
+ describe('future matches', function() {
+ // Gap #21: __matchesUpcomingSibling returns true when old element matches future sibling
+ it('preserves element that matches upcoming identical sibling during morph reorder', async function() {
+ mockResponse('GET', '/test', 'Y
X
X
');
+ const div = createProcessedHTML('');
+
+ await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
+
+ assert.equal(div.children.length, 3);
+ assert.equal(div.children[0].className, 'b');
+ assert.equal(div.children[1].className, 'a');
+ assert.equal(div.children[2].className, 'a');
+ });
+ });
+
+ describe('pantry move', function() {
+ // Gap #22: ID element moves between DOM branches via pantry
+ it('handles deeply nested ID that moves to different branch', async function() {
+ mockResponse('GET', '/test', '');
+ const div = createProcessedHTML('');
+ const deep = div.querySelector('#deep');
+ const branch1 = div.querySelector('#branch1');
+ const branch2 = div.querySelector('#branch2');
+
+ await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
+
+ assert.equal(div.querySelector('#deep'), deep);
+ assert.equal(deep.textContent, 'Deep moved');
+ assert.equal(deep.parentElement, branch2);
+ assert.equal(branch1.children.length, 0);
+ });
+ });
+
+ describe('moveBefore fallback', function() {
+ // Gap #23: moveBefore throws, falls back to insertBefore
+ it('falls back to insertBefore when moveBefore throws', async function() {
+ let originalMoveBefore = Element.prototype.moveBefore;
+ Element.prototype.moveBefore = function() {
+ throw new Error('moveBefore not supported');
+ };
+
+ try {
+ mockResponse('GET', '/morph-test', '');
+ createProcessedHTML('');
+
+ find('#container').click();
+ await forRequest();
+
+ let container = find('#container');
+ assert.isNotNull(container);
+ assert.equal(container.children.length, 2);
+ } finally {
+ if (originalMoveBefore) {
+ Element.prototype.moveBefore = originalMoveBefore;
+ } else {
+ delete Element.prototype.moveBefore;
+ }
+ }
+ });
+ });
+
});
\ No newline at end of file
diff --git a/test/tests/unit/swap.js b/test/tests/unit/swap.js
index c509e9930..fdb9f7f8e 100644
--- a/test/tests/unit/swap.js
+++ b/test/tests/unit/swap.js
@@ -749,4 +749,107 @@ describe('swap() unit tests', function() {
document.activeElement.selectionEnd.should.equal(11)
})
+ // Gap #11: textContent swap child cleanup
+ it('cleans up htmx-powered descendants on textContent swap', async function() {
+ mockResponse('GET', '/test', 'Plain text content');
+
+ createProcessedHTML(
+ '' +
+ '' +
+ '' +
+ '
' +
+ ''
+ );
+
+ assert.isTrue(find('#child1').hasAttribute('data-htmx-powered'));
+ assert.isTrue(find('#child2').hasAttribute('data-htmx-powered'));
+
+ find('#trigger').click();
+ await forRequest();
+ await htmx.timeout(10);
+
+ assert.isUndefined(find('#child1'));
+ assert.isUndefined(find('#child2'));
+ assert.equal(find('#target').textContent, 'Plain text content');
+ })
+
+ // Gap #9: __setFocus catch block when focus() throws
+ it('handles web component that throws on focus during restoration', async function() {
+ if (!customElements.get('broken-focus')) {
+ customElements.define('broken-focus', class extends HTMLElement {
+ focus() { throw new Error('focus not supported'); }
+ });
+ }
+
+ mockResponse('GET', '/test', 'Updated');
+ createProcessedHTML('');
+
+ find('#field').focus();
+ await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerHTML'});
+
+ let newElt = find('#field');
+ assert.isNotNull(newElt);
+ assert.equal(newElt.tagName, 'BROKEN-FOCUS');
+ })
+
+ // Gap #24: Transition queue task execution when startViewTransition unavailable
+ it('swap works with transition:true when startViewTransition is unavailable', async function() {
+ mockResponse('GET', '/test', 'Swapped!
');
+ const originalStartViewTransition = document.startViewTransition;
+ document.startViewTransition = undefined;
+
+ try {
+ createProcessedHTML('Original
');
+ find('#btn').click();
+ await forRequest();
+ await htmx.timeout(50);
+ assert.include(find('#target').innerHTML, 'Swapped!');
+ } finally {
+ document.startViewTransition = originalStartViewTransition;
+ }
+ })
+
+ // Gap #25: view transition catch block when task throws
+ it('catches error when view transition is aborted', async function() {
+ let savedTransitions = htmx.config.transitions;
+ htmx.config.transitions = true;
+ mockResponse('GET', '/test', 'response
');
+ let btn = createProcessedHTML('');
+
+ document.addEventListener('htmx:before:viewTransition', (e) => {
+ e.detail.task = async () => { throw new Error('transition aborted'); };
+ }, { once: true });
+
+ btn.click();
+ await forRequest();
+ await htmx.timeout(50);
+ htmx.config.transitions = savedTransitions;
+ assert.isTrue(true);
+ })
+
+ // Gap #12: Extension content transformation result
+ it('uses array result from extension handle_swap', async function() {
+ mockResponse('GET', '/test', 'Content
');
+ htmx.registerExtension('test-swap-ext', {
+ handle_swap: function(swapStyle, target, fragment, swapSpec) {
+ if (swapStyle === 'custom-swap') {
+ let newEl = document.createElement('span');
+ newEl.id = 'ext-result';
+ newEl.textContent = 'Extension swapped';
+ target.appendChild(newEl);
+ return [newEl];
+ }
+ }
+ });
+ createProcessedHTML(
+ '' +
+ ''
+ );
+ find('#btn').click();
+ await forRequest();
+ await htmx.timeout(10);
+ assert.isNotNull(find('#ext-result'));
+ assert.equal(find('#ext-result').textContent, 'Extension swapped');
+ })
+
})