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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
7 changes: 4 additions & 3 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down
21 changes: 21 additions & 0 deletions test/tests/attributes/hx-on.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<button id="btn" hx-on:click="throw new Error(\'test error\')">Click</button>');
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('<div hx-on-click="console.log(1)">test</div>');
let clicked = false;
div.addEventListener('click', () => { clicked = true; });
div.click();
await htmx.timeout(10);
assert.isTrue(clicked);
});
})
41 changes: 41 additions & 0 deletions test/tests/unit/__collectFormData.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<input type="file" name="myfile" id="file-input">' +
'<button id="btn" hx-post="/upload" hx-include="#file-input" hx-encoding="multipart/form-data">Upload</button>'
);
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(
'<input type="file" name="files" id="multi-file" multiple>' +
'<button id="btn" hx-post="/upload" hx-include="#multi-file" hx-encoding="multipart/form-data">Upload</button>'
);
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');
});
});
15 changes: 15 additions & 0 deletions test/tests/unit/__executeJavaScript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<div id="result"><script id="test-script">window.nonceTestRan = true;</script></div>');
htmx.config.inlineScriptNonce = 'test-nonce-123';
try {
createProcessedHTML('<button id="btn" hx-get="/test" hx-target="#target">Click</button><div id="target"></div>');
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;
}
});

});
35 changes: 35 additions & 0 deletions test/tests/unit/__findAllExt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div id="target"></div>');
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('<span class="doc-span"></span>');
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('<div id="shadow-host"></div>');
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('<div class="foo"></div><div class="foo"></div>');
let results = htmx.findAll(document, '.foo');
assert.equal(results.length, 2);
})

});
50 changes: 50 additions & 0 deletions test/tests/unit/__handleHistoryUpdate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<main hx-history-elt><p>current</p></main>';
htmx.process(playground());
history.replaceState({ htmx: true }, '', '/popstate-test-a');
htmx.__pushUrlIntoHistory('/popstate-test-b');
playground().innerHTML = '<main hx-history-elt><p>page B</p></main>';
mockResponse('GET', '/popstate-test-a', '<html><body><main hx-history-elt><p>restored via popstate</p></main></body></html>');
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;
}
})

});
Loading
Loading