diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..c3ab23c0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Description +*Please describe what changes you made, and why you feel they are necessary. Make sure to include +code examples, where applicable.* + +Corresponding issue: + +## Testing +*Please explain how you tested this change manually, and, if applicable, what new tests you added. If +you're making a change to just the website, you can omit this section.* + +## Checklist + +* [ ] I have read the contribution guidelines +* [ ] I have targeted this PR against the correct branch (`master` for website changes, `dev` for + source changes) +* [ ] This is either a bugfix, a documentation update, or a new feature that has been explicitly + approved via an issue +* [ ] I ran the test suite locally (`npm run test`) and verified that it succeeded diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d67d568..32baa50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.9.10] - 2023-12-?? + +## [1.9.9] - 2023-11-21 + +* Allow CSS selectors with whitespace in attributes like `hx-target` by using parens or curly-braces +* Properly allow users to override the `Content-Type` request header +* Added the `select` option to `htmx.ajax()` +* Fixed a race condition in readystate detection that lead to htmx not being initialized in some scenarios with 3rd + party script loaders +* Fixed a bug that caused relative resources to resolve against the wrong base URL when a new URL is pushed +* Fixed a UI issue that could cause indicators to briefly flash + ## [1.9.8] - 2023-11-06 * Fixed a few npm & build related issues diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c10ad1e..8c52947a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,31 @@ # Contributing -Thank you for your interest in contributing! Because we're a small team, we have a couple -contribution guidelines that make it easier for us to triage all the incoming suggestions. - -tl;dr: if proposing a new feature, start with an issue; if you think your change is a bugfix or otherwise uncontroversial, feel free to PR, but know that we might close it and kick you back to an issue if more discussion is required. - -Want to contribute but don't know where to start? Look for issues with the "help wanted" tag. +Thank you for your interest in contributing! Because we're a small team, we have a couple contribution guidelines that make it easier for us to triage all the incoming suggestions. ## Issues 1. Issues are the best place to propose a new feature. Keep in mind that htmx is a small library, so there are lots of great ideas that don't fit in the core; it's always best to check in about an idea before doing a bunch of work on it. -1. If you are adding a feature, consider doing it as an [extension](https://htmx.org/extensions). Even if we don't end up supporting it officially, you can publish it yourself and we can link to it. +1. When proposing a new features, we will often suggest that you implement it as an [extension](https://htmx.org/extensions), so try that first. Even if we don't end up supporting it officially, you can publish it yourself and we can link to it. 1. Search the issues before proposing a feature to see if it is already under discussion. Referencing existing issues is a good way to increase the priority of your own. -1. We don't have an issue template yet, but the more detailed your explanation, the more quickly we'll be able to evaluate it. +1. We don't have an issue template yet, but the more detailed your description of the issue, the more quickly we'll be able to evaluate it. 1. See an issue that you also have? Give it a reaction (and comment, if you have something to add). We note that! +1. If you haven't gotten any traction on an issue, feel free to bump it in the #issues-and-pull-requests channel on our Discord. +1. Want to contribute but don't know where to start? Look for issues with the "help wanted" tag. ## Pull Requests -1. Open PRs represent issues that we're actively thinking working on merging (at a pace we can manage). If we think a proposal needs more discussion, or that the existing code would require a lot of back-and-forth to merge, we might close it and suggest you make an issue. -1. All PRs should be made against the `dev` branch, except documentation PRs (`www/` directory) which can be made against `master`. -1. Please avoid sending the `dist` files along your PR, only include the `src` ones +### Technical Requirements 1. Code, including tests, must be written in ES5 for [IE 11 compatibility](https://stackoverflow.com/questions/39902809/support-for-es6-in-internet-explorer-11). +1. All PRs must be made against the `dev` branch, except documentation PRs (that only modify the `www/` directory) which can be made against `master`. +1. Please avoid sending the `dist` files along your PR, only include the `src` ones. 1. Please include test cases in [`/test`](https://github.com/bigskysoftware/htmx/tree/dev/test) and docs in [`/www`](https://github.com/bigskysoftware/htmx/tree/dev/www). +1. We squash all PRs, so you're welcome to submit with as many commits are you like; they will be evaluated as a single, standalone change. + +### Review Guidelines +1. Open PRs represent issues that we're actively thinking working on merging (at a pace we can manage). If we think a proposal needs more discussion, or that the existing code would require a lot of back-and-forth to merge, we might close it and suggest you make an issue. +1. Smaller PRs are easier and quicker to review. If we feel that the scope of your changes is too large, we will close the PR and try to suggest ways that the change could be broken down. +1. Please do not PR new features unless you have already made an issue proposing the feature, and had it accepted by a core maintainer. This helps us triage the features we can support before you put a lot of work into them. +1. Correspondingly, it is fine to directly PR bugfixes for behavior that htmx already guarantees, but please check if there's an issue first, and if you're not sure whether this *is* a bug, make an issue where we can hash it out.. 1. Refactors that do not make functional changes will be automatically closed, unless explicitly solicited. Imagine someone came into your house unannounced, rearranged a bunch of furniture, and left. -1. Typo fixes in documentation are welcome, but if it's at all debatable we might just close it. +1. Typo fixes in the documentation (not the code comments) are welcome, but formatting or debatable grammar changes will be automatically closed. ## Misc 1. If you think we closed something incorrectly, feel free to (politely) tell us why! We're human and make mistakes. +1. There are lots of ways to improve htmx besides code changes. Sometimes a problem can be solved with better docs, usage patterns, extensions, or community support. Talk to us and we can almost always help you get to a solution. diff --git a/README.md b/README.md index 556a2efe..a9287d12 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ By removing these arbitrary constraints htmx completes HTML as a ## quick start ```html - + "); + btn.click(); + window.foo.should.equal(true); + delete window.foo; + }); + + it("can modify a parameter via htmx:configRequest", function () { this.server.respondWith("POST", "/test", function (xhr) { var params = parseParams(xhr.requestBody); @@ -26,6 +34,17 @@ describe("hx-on:* attribute", function() { btn.innerText.should.equal("bar"); }); + it("can modify a parameter via htmx:configRequest with dashes", function () { + this.server.respondWith("POST", "/test", function (xhr) { + var params = parseParams(xhr.requestBody); + xhr.respond(200, {}, params.foo); + }); + var btn = make(""); + btn.click(); + this.server.respond(); + btn.innerText.should.equal("bar"); + }); + it("expands :: shorthand into htmx:", function () { this.server.respondWith("POST", "/test", function (xhr) { var params = parseParams(xhr.requestBody); @@ -37,6 +56,17 @@ describe("hx-on:* attribute", function() { btn.innerText.should.equal("bar"); }); + it("expands -- shorthand into htmx:", function () { + this.server.respondWith("POST", "/test", function (xhr) { + var params = parseParams(xhr.requestBody); + xhr.respond(200, {}, params.foo); + }); + var btn = make(""); + btn.click(); + this.server.respond(); + btn.innerText.should.equal("bar"); + }); + it("can cancel an event via preventDefault for htmx:config-request", function () { this.server.respondWith("POST", "/test", function (xhr) { xhr.respond(200, {}, ""); @@ -185,7 +215,7 @@ describe("hx-on:* attribute", function() { // check there is just one handler against each event htmx.trigger(div, "increment-foo"); - htmx.trigger(div, "increment-bar"); + htmx.trigger(div, "increment-bar"); window.foo.should.equal(1); window.bar.should.equal(1); diff --git a/test/attributes/hx-trigger.js b/test/attributes/hx-trigger.js index 5e2f138b..0cbb4adf 100644 --- a/test/attributes/hx-trigger.js +++ b/test/attributes/hx-trigger.js @@ -895,5 +895,64 @@ describe("hx-trigger attribute", function(){ form.innerHTML.should.equal("Called!"); }) + it("correctly handles CSS descendant combinators", function(){ + this.server.respondWith("GET", "/test", "Clicked!"); + + var outer = make(` +
+
+
+
+
+
Unclicked.
+
+
Unclicked.
+
+ `); + + var inner = byId("inner"); + var second = byId("second"); + var other = byId("other"); + + second.innerHTML.should.equal("Unclicked."); + other.innerHTML.should.equal("Unclicked."); + + inner.click(); + this.server.respond(); + + second.innerHTML.should.equal("Clicked!"); + other.innerHTML.should.equal("Clicked!"); + }) + + + it('correctly handles CSS descendant combinators in modifier target', function() { + this.server.respondWith('GET', '/test', 'Called'); + + document.addEventListener('htmx:syntax:error', function(evt) { + chai.assert.fail('htmx:syntax:error'); + }); + + make('
Click meClick me
'); + var div = make('
Not Called
'); + + byId('a1').click(); + this.server.respond(); + div.innerHTML.should.equal("Not Called"); + + byId('a2').click(); + this.server.respond(); + div.innerHTML.should.equal("Called"); + }); + + it('correctly handles CSS descendant combinators in modifier root', function() { + this.server.respondWith('GET', '/test', 'Called'); + + document.addEventListener('htmx:syntax:error', function(evt) { + chai.assert.fail('htmx:syntax:error'); + }); + + make('
Not Called
'); + }); + }) diff --git a/test/core/ajax.js b/test/core/ajax.js index 7d18be73..42d733a6 100644 --- a/test/core/ajax.js +++ b/test/core/ajax.js @@ -868,9 +868,7 @@ describe("Core htmx AJAX Tests", function(){ var btn = make('') btn.click(); this.server.respond(); - if (supportsSvgTitles()) { // IE 11 - btn.innerText.should.equal("Clicked!"); - } + btn.innerText.should.equal("Clicked!"); window.document.title.should.equal(originalTitle); }); @@ -884,9 +882,7 @@ describe("Core htmx AJAX Tests", function(){ var btn = make('') btn.click(); this.server.respond(); - if (supportsSvgTitles()) { // IE 11 - btn.innerText.should.equal("Clicked!"); - } + btn.innerText.should.equal("Clicked!"); window.document.title.should.equal(newTitle); }); @@ -1089,11 +1085,6 @@ describe("Core htmx AJAX Tests", function(){ }) it('properly handles clicked submit button with a value outside a htmx form', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } var values; this.server.respondWith("Post", "/test", function (xhr) { values = getParameters(xhr); @@ -1111,11 +1102,6 @@ describe("Core htmx AJAX Tests", function(){ }) it('properly handles clicked submit input with a value outside a htmx form', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } var values; this.server.respondWith("Post", "/test", function (xhr) { values = getParameters(xhr); @@ -1187,11 +1173,6 @@ describe("Core htmx AJAX Tests", function(){ }) it('properly handles clicked submit button with a value inside a form, referencing another form', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } var values; this.server.respondWith("Post", "/test", function (xhr) { values = getParameters(xhr); @@ -1212,11 +1193,6 @@ describe("Core htmx AJAX Tests", function(){ }) it('properly handles clicked submit input with a value inside a form, referencing another form', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } var values; this.server.respondWith("Post", "/test", function (xhr) { values = getParameters(xhr); @@ -1284,6 +1260,8 @@ describe("Core htmx AJAX Tests", function(){ byId("submit").click(); this.server.respond(); responded.should.equal(true); + }) + it("can associate submit buttons from outside a form with the current version of the form after swap", function(){ const template = '
foo!

'); }); + it('ajax api works with select', function() + { + this.server.respondWith("GET", "/test", "
foo
bar
"); + var div = make("
"); + htmx.ajax("GET", "/test", {target: "#target", select: "#d2"}); + this.server.respond(); + div.innerHTML.should.equal('
bar
'); + }); + + it('ajax api works with Hx-Select overrides select', function() + { + this.server.respondWith("GET", "/test", [200, {"HX-Reselect": "#d2"}, "
foo
bar
"]); + var div = make("
"); + htmx.ajax("GET", "/test", {target: "#target", select: "#d1"}); + this.server.respond(); + div.innerHTML.should.equal('
bar
'); + }); + it('ajax returns a promise', function(done) { // in IE we do not return a promise @@ -255,6 +273,44 @@ describe("Core htmx API test", function(){ div.innerHTML.should.equal("Clicked!"); }); + it('ajax api Content-Type header is application/x-www-form-urlencoded', function(){ + + this.server.respondWith("POST", "/test", function (xhr) { + var params = getParameters(xhr); + xhr.requestHeaders['Content-Type'].should.equal('application/x-www-form-urlencoded;charset=utf-8'); + params['i1'].should.equal("test"); + xhr.respond(200, {}, "Clicked!") + }); + var div = make("
"); + htmx.ajax("POST", "/test", {target:"#d1", values:{i1: 'test'}}) + this.server.respond(); + div.innerHTML.should.equal("Clicked!"); + }); + + it('ajax api Content-Type header override to application/json', function(){ + + this.server.respondWith("POST", "/test", function (xhr) { + var params = getParameters(xhr); + xhr.requestHeaders['Content-Type'].should.equal('application/json;charset=utf-8'); + params['i1'].should.equal("test"); + xhr.respond(200, {}, "Clicked!"); + }); + + var div = make("
"); + htmx.ajax('POST',"/test", { + target:'#d1', + swap:'innerHTML', + headers: { + 'Content-Type': 'application/json' + }, + values:{i1: 'test'} + }) + + this.server.respond(); + div.innerHTML.should.equal("Clicked!"); + }); + + it('can re-init with new attributes', function () { this.server.respondWith("PATCH", "/test", "patch"); this.server.respondWith("DELETE", "/test", "delete"); diff --git a/test/core/events.js b/test/core/events.js index 7c1c1596..309c3e07 100644 --- a/test/core/events.js +++ b/test/core/events.js @@ -389,12 +389,7 @@ describe("Core htmx Events", function() { }); it("htmx:sendError is called after a failed request", function (done) { - if (IsIE11()) { - // IE will throw an exception on xhr.open with the URL below, xhr.send won't even be called - this._runnable.title += " - Skipped on IE11 as xhr.send won't even be called with a file URL" - this.skip() - return - } + htmx.config.selfRequestsOnly = false; // turn off self requests only var called = false; var handler = htmx.on("htmx:sendError", function (evt) { called = true; @@ -405,6 +400,7 @@ describe("Core htmx Events", function() { setTimeout(function () { htmx.off("htmx:sendError", handler); should.equal(called, true); + htmx.config.selfRequestsOnly = true; // restore self requests only done(); }, 30); }); diff --git a/test/core/extensions.js b/test/core/extensions.js index f2d3ed2a..2bcfe208 100644 --- a/test/core/extensions.js +++ b/test/core/extensions.js @@ -29,11 +29,6 @@ describe('Core htmx extension tests', function() { onEvent: function(name, evt) { if (name === 'htmx:beforeRequest') { evt.preventDefault(); - if (IsIE11()) { - // IE11 doesn't set defaultPrevented to true on custom events it seems, so use a - // return false instead to cancel the event - return false - } } } }); diff --git a/test/core/headers.js b/test/core/headers.js index af357d37..7e127ece 100644 --- a/test/core/headers.js +++ b/test/core/headers.js @@ -348,4 +348,31 @@ describe("Core htmx AJAX headers", function () { this.server.respond(); div.innerHTML.should.equal('
Yay! Welcome
'); }) + + it('request to restore history should include the HX-Request header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + xhr.requestHeaders['HX-Request'].should.be.equal('true'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); + + it('request to restore history should include the HX-History-Restore-Request header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + xhr.requestHeaders['HX-History-Restore-Request'].should.be.equal('true'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); + + it('request to restore history should include the HX-Current-URL header', function () { + this.server.respondWith('GET', '/test', function (xhr) { + chai.assert(typeof xhr.requestHeaders['HX-Current-URL'] !== 'undefined', 'HX-Current-URL should not be undefined'); + xhr.respond(200, {}, ''); + }); + htmx._('loadHistoryFromServer')('/test'); + this.server.respond(); + }); }); diff --git a/test/core/internals.js b/test/core/internals.js index 1de1fb98..f5c96356 100644 --- a/test/core/internals.js +++ b/test/core/internals.js @@ -22,11 +22,6 @@ describe("Core htmx internals Tests", function() { }) it("makeFragment works with template wrapping", function(){ - if (!supportsTemplates()) { - this._runnable.title += " - Skipped as IE11 doesn't support templates" - this.skip() - return - } try { htmx._("makeFragment")("").children.length.should.equal(0); htmx._("makeFragment")("").children.length.should.equal(0); @@ -50,11 +45,6 @@ describe("Core htmx internals Tests", function() { it("makeFragment works with template wrapping and funky combos", function(){ - if (!supportsTemplates()) { - this._runnable.title += " - Skipped as IE11 doesn't support templates" - this.skip() - return - } htmx.config.useTemplateFragments = true; try { var fragment = htmx._("makeFragment")("
"); diff --git a/test/core/perf.js b/test/core/perf.js index eacaa5b0..87f2c95d 100644 --- a/test/core/perf.js +++ b/test/core/perf.js @@ -50,10 +50,6 @@ describe("Core htmx perf Tests", function() { it("history snapshot cleaning should be fast", function(){ var size = 5 * 1024 // ~350K in size, about the size of CNN's body tag :p - if (IsIE11()) { - // So slow in IE11 it freezes the browser and blocks other tests, pretty annoying - size = 5 * 100 // Seriously this already takes ~1.5 SECOND to run, more simply makes it crash - } var workArea = getWorkArea(); var html = "
Yay, really large HTML documents are fun!
\n"; html = stringRepeat(html, size); diff --git a/test/core/regressions.js b/test/core/regressions.js index a0f6724e..585ab3dc 100644 --- a/test/core/regressions.js +++ b/test/core/regressions.js @@ -129,12 +129,6 @@ describe("Core htmx Regression Tests", function(){ }) it('a form can reset based on the htmx:afterRequest event', function() { - if (IsIE11()) { - this._runnable.title += " - Skipped as hyperscript isn't IE11 compatible" - this.skip() - return - } - this.server.respondWith("POST", "/test", "posted"); var form = make('
' + diff --git a/test/core/security.js b/test/core/security.js index 95f17350..733a4ef9 100644 --- a/test/core/security.js +++ b/test/core/security.js @@ -106,10 +106,12 @@ describe("security options", function() { btn.innerHTML.should.equal("Clicked a second time"); }) - it("can make egress cross site requests when htmx.config.selfRequestsOnly is enabled", function(done){ + it("can make egress cross site requests when htmx.config.selfRequestsOnly is disabled", function(done){ this.timeout(4000) + htmx.config.selfRequestsOnly = false; // should trigger send error, rather than reject var listener = htmx.on("htmx:sendError", function (){ + htmx.config.selfRequestsOnly = true; htmx.off("htmx:sendError", listener); done(); }); @@ -122,9 +124,7 @@ describe("security options", function() { it("can't make egress cross site requests when htmx.config.selfRequestsOnly is enabled", function(done){ this.timeout(4000) // should trigger send error, rather than reject - htmx.config.selfRequestsOnly = true; var listener = htmx.on("htmx:invalidPath", function (){ - htmx.config.selfRequestsOnly = false; htmx.off("htmx:invalidPath", listener); done(); }) diff --git a/test/core/validation.js b/test/core/validation.js index 075e4639..428a4acc 100644 --- a/test/core/validation.js +++ b/test/core/validation.js @@ -110,12 +110,6 @@ describe("Core htmx client side validation tests", function(){ it('hyperscript validation error prevents request', function() { - if (IsIE11()) { - this._runnable.title += " - Skipped as hyperscript isn't IE11 compatible" - this.skip() - return - } - this.server.respondWith("POST", "/test", "Clicked!"); var form = make('' + diff --git a/test/ext/hyperscript.js b/test/ext/hyperscript.js index 67889d05..6d49a2da 100644 --- a/test/ext/hyperscript.js +++ b/test/ext/hyperscript.js @@ -2,11 +2,6 @@ describe("hyperscript integration", function() { beforeEach(function () { this.server = makeServer(); clearWorkArea(); - - if (IsIE11()) { - this.title += " - Skipped as hyperscript isn't IE11 compatible" - this.skip() - } }); afterEach(function () { this.server.restore(); diff --git a/test/ext/ws.js b/test/ext/ws.js index 7e6f140d..2b90eb00 100644 --- a/test/ext/ws.js +++ b/test/ext/ws.js @@ -550,12 +550,6 @@ describe("web-sockets extension", function () { }) it('sends data to the server with external non-htmx form + submit button & value', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } - make('
' + '' + '' + @@ -583,12 +577,6 @@ describe("web-sockets extension", function () { }) it('sends data to the server with external non-htmx form + submit input & value', function () { - if (!supportsFormAttribute()) { - this._runnable.title += " - Skipped as IE11 doesn't support form attribute" - this.skip() - return - } - make('
' + '' + '' + @@ -614,4 +602,205 @@ describe("web-sockets extension", function () { this.messages[1].should.contains('"foo":"bar"') this.messages[1].should.contains('"action":"B"') }) + + describe("Send immediately", function() { + function checkCallForWsBeforeSend(spy, wrapper, message, target) { + // Utility function to always check the same for htmx:wsBeforeSend caught by a spy + spy.calledOnce.should.be.true; + var call = spy.getCall(0); + call.args.length.should.equal(1); + var arg = call.args[0]; + arg.target.should.equal(target); + arg.detail.socketWrapper.should.equal(wrapper); + arg.detail.message.should.equal(message); + } + it('triggers wsBeforeSend on body if provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"foo":"bar"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + window.document.body.addEventListener("htmx:wsBeforeSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, window.document.body) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body) + } finally { + window.document.body.removeEventListener("htmx:wsBeforeSend", myEventCalled) + } + done() + } + try { + window.document.addEventListener("htmx:wsOpen", handler) + + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsBeforeSend on any send element provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, d1) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", myEventCalled) + } + done() + } + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsAfterSend on body if provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"foo":"bar"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + window.document.body.addEventListener("htmx:wsAfterSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, window.document.body) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body) + } finally { + window.document.body.removeEventListener("htmx:wsAfterSend", myEventCalled) + } + done() + } + try { + window.document.addEventListener("htmx:wsOpen", handler) + + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsAfterSend on any send element provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsAfterSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, d1) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1) + } finally { + id1.removeEventListener("htmx:wsAfterSend", myEventCalled) + } + done() + } + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('sends message if event is not prevented', function (done) { + var message = '{"a":"b"}'; + var noop = function() {} + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", noop) + try { + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([message]) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", noop) + } + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('sends message if no sending element is provided', function (done) { + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + socketWrapper.sendImmediately(message) + this.tickMock(); + this.messages.should.eql([message]) + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('sends message if sending element has no event listener for beforeSend', function (done) { + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var d1 = byId("d1"); + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([message]) + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('does not send message if beforeSend is prevented', function (done) { + var message = '{"a":"b"}'; + var eventPrevented = function(e) {e.preventDefault()} + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", eventPrevented) + try { + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([]) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", eventPrevented) + } + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
div1
'); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + }) + + }); diff --git a/test/index.html b/test/index.html index 45ec50aa..98d9981a 100644 --- a/test/index.html +++ b/test/index.html @@ -81,7 +81,6 @@ - @@ -169,7 +168,6 @@ diff --git a/test/manual/hxboost_relative_resources/index.html b/test/manual/hxboost_relative_resources/index.html new file mode 100644 index 00000000..9bf741eb --- /dev/null +++ b/test/manual/hxboost_relative_resources/index.html @@ -0,0 +1,10 @@ + + + + + hx-boost - Relative Resources Page 1 + + +To Page 2 + + diff --git a/test/manual/hxboost_relative_resources/nested/img.png b/test/manual/hxboost_relative_resources/nested/img.png new file mode 100644 index 00000000..533a968a Binary files /dev/null and b/test/manual/hxboost_relative_resources/nested/img.png differ diff --git a/test/manual/hxboost_relative_resources/nested/page2.html b/test/manual/hxboost_relative_resources/nested/page2.html new file mode 100644 index 00000000..12cdd25d --- /dev/null +++ b/test/manual/hxboost_relative_resources/nested/page2.html @@ -0,0 +1,12 @@ + + + + + hx-boost - Relative Resources Page 2 + + +Back To Page 1 +

Image should be displayed below

+ + + diff --git a/test/manual/index.html b/test/manual/index.html index 1097e0fd..99b25d65 100644 --- a/test/manual/index.html +++ b/test/manual/index.html @@ -37,6 +37,12 @@
  • History Style
  • +
  • Boost Tests + +
  • Perf

  • Manual Perf Test
  • diff --git a/test/scratch/demo.html b/test/scratch/demo.html index 19102815..379e718a 100644 --- a/test/scratch/demo.html +++ b/test/scratch/demo.html @@ -1 +1,5 @@ -foo \ No newline at end of file + + +
    asdfasdf
    + + diff --git a/test/util/util.js b/test/util/util.js index 2986487d..699c4d22 100644 --- a/test/util/util.js +++ b/test/util/util.js @@ -112,33 +112,3 @@ function log(val) { console.log(val); return val; } - -// region IE11 -function supportsTemplates() { - return typeof document.createElement("template").content !== "undefined" -} - -function supportsSvgTitles() { - // Need to append the element to the body, otherwise innerText will add the svg title to the returned value... - var tempButton = document.createElement("button") - tempButton.innerHTML = 'Svg titleText'; - document.body.appendChild(tempButton) - var titleOk = tempButton.innerText === "Text" - document.body.removeChild(tempButton) - return titleOk -} - -function supportsFormAttribute() { - var parser = new DOMParser() - return !!parser.parseFromString('', "text/html").body.firstChild.form -} - -function supportsXPath() { - return typeof document.evaluate !== "undefined" -} - -function IsIE11() { - return !supportsTemplates() && !supportsSvgTitles() && !supportsFormAttribute() && !supportsXPath() -} - -// endregion \ No newline at end of file diff --git a/www/content/_index.md b/www/content/_index.md index c062fdcf..e504e2ef 100644 --- a/www/content/_index.md +++ b/www/content/_index.md @@ -35,7 +35,7 @@ By removing these arbitrary constraints, htmx completes HTML as a [hypertext](ht

    quick start

    ```html - + + + + +``` ### hx-on (deprecated) The value is an event name, followed by a colon `:`, followed by the script: diff --git a/www/content/attributes/hx-trigger.md b/www/content/attributes/hx-trigger.md index 14b6e367..ec5f1762 100644 --- a/www/content/attributes/hx-trigger.md +++ b/www/content/attributes/hx-trigger.md @@ -153,3 +153,4 @@ The AJAX request can be triggered via JavaScript [`htmx.trigger()`](@/api.md#tri * `hx-trigger` is not inherited * `hx-trigger` can be used without an AJAX request, in which case it will only fire the `htmx:trigger` event +* In order to pass a CSS selector that contains whitespace (e.g. `form input`) to the `from`- or `target`-modifier, surround the selector in parentheses or curly brackets (e.g. `from:(form input)` or `from:nearest (form input)`) diff --git a/www/content/docs.md b/www/content/docs.md index 9ab4f795..1ec6486d 100644 --- a/www/content/docs.md +++ b/www/content/docs.md @@ -114,7 +114,7 @@ The fastest way to get going with htmx is to load it via a CDN. You can simply a and get going: ```html - + ``` While the CDN approach is extremely simple, you may want to consider [not using CDNs in production](https://blog.wesleyac.com/posts/why-not-javascript-cdn). @@ -171,6 +171,43 @@ window.htmx = require('htmx.org'); * Finally, rebuild your bundle +### htmx 1.x to 2.x Upgrade Guide + +To upgrade to htmx 2.0 from htmx 1.0, you will need to do the following: + +* If you are still using the legacy `hx-ws` and `hx-sse` attributes, please upgrade to the extension versions (available in 1.x) + of those features +* Default Changes + * If you want to retain the 1.0 behavior of "smooth scrolling" by default, revert `htmx.config.scrollBehavior` to `'smooth'` + * If you want `DELETE` requests to use a form-encoded body rather than parameters, revert + `htmx.config.methodsThatUseUrlParams` to `["get"]` (it's a little crazy, but `DELETE`, according to the spec, should + use request parameters.) + * If you want to make cross-domain requests with htmx, revert `htmx.config.selfRequestsOnly` to `false` +* Convert any `hx-on` attributes to their `hx-on:` equivalent: + ```html + + ``` + becomes: + ```html + + Note that you must use the kebab-case of the event name due to the fact that attributes are case-insensitive in HTML. + ``` + +here is a meta tag to revert to htmx 1.x defaults: + +```html + +``` + +IE is no longer supported in htmx 2.0, but htmx 1.x continues to support IE and will be supported for the foreseeable +future. + ## AJAX The core of htmx is a set of attributes that allow you to issue AJAX requests directly from HTML: @@ -1258,7 +1295,7 @@ Scripting solutions that pair well with htmx include: team that created htmx. It is designed to embed well in HTML and both respond to and create events, and pairs very well with htmx. -### [The `hx-on` Attribute](#hyperscript) +### [The `hx-on*` Attributes](#hx-on) HTML allows the embedding of inline scripts via the [`onevent` properties](https://developer.mozilla.org/en-US/docs/Web/Events/Event_handlers#using_onevent_properties), such as `onClick`: @@ -1271,35 +1308,45 @@ such as `onClick`: This feature allows scripting logic to be co-located with the HTML elements the logic applies to, giving good [Locality of Behaviour (LoB)](/essays/locality-of-behaviour). Unfortunately, HTML only allows `on*` attributes for a fixed -number of specific DOM events (e.g. `onclick`) and doesn't offer a way to respond generally to events in this embedded -manner. +number of [specific DOM events](https://www.w3schools.com/tags/ref_eventattributes.asp) (e.g. `onclick`) and +doesn't provide a generalized mechanism for responding to arbitrary events on elements. -In order to address this shortcoming, htmx offers the [`hx-on`](/attributes/hx-on) attribute. This attribute allows -you to respond to any event in a manner that preserves the LoB of the `on*` properties: +In order to address this shortcoming, htmx offers [`hx-on*`](/attributes/hx-on) attributes. These attributes allow +you to respond to any event in a manner that preserves the LoB of the standard `on*` properties. + +If we wanted to respond to the `click` event using an `hx-on` attribute, we would write this: ```html - ``` -For a `click` event, we would recommend sticking with the standard `onclick` attribute. However, consider an htmx-powered -button that wishes to add an attribute to a request using the `htmx:configRequest` event. This would not be possible -with an `on*` property, but can be done using the `hx-on` attribute: +So, the string `hx-on`, followed by a colon (or a dahs), then by the name of the event. + +For a `click` event, of course, we would recommend sticking with the standard `onclick` attribute. However, consider an +htmx-powered button that wishes to add a parameter to a request using the `htmx:config-request` event. This would not +be possible using a standard `on*` property, but it can be done using the `hx-on:htmx:config-request` attribute: ```html ``` Here the `example` parameter is added to the `POST` request before it is issued, with the value 'Hello Scripting!'. -The `hx-on` attribute is a very simple mechanism for generalized embedded scripting. It is _not_ a replacement for more +The `hx-on*` attributes are a very simple mechanism for generalized embedded scripting. It is _not_ a replacement for more fully developed front-end scripting solutions such as AlpineJS or hyperscript. It can, however, augment a VanillaJS-based approach to scripting in your htmx-powered application. +Note that HTML attributes are *case insensitive*. This means that, unfortunately, events that rely on capitalization/ +camel casing, cannot be responded to. If you need to support camel case events we recommend using a more fully +functional scripting solution such as AlpineJS or hyperscript. htmx dispatches all its events in both camelCase and in +kebab-case for this very reason. + + ### hyperscript Hyperscript is an experimental front end scripting language designed to be expressive and easily embeddable directly in HTML diff --git a/www/content/essays/_index.md b/www/content/essays/_index.md index 7fa7b556..aeab75ab 100644 --- a/www/content/essays/_index.md +++ b/www/content/essays/_index.md @@ -22,6 +22,7 @@ page_template = "essay.html" * [A Response To "Have SPAs Ruined The Web"](@/essays/a-response-to-rich-harris.md) * [When To Use Hypermedia?](@/essays/when-to-use-hypermedia.md) * [The API Churn/Security Trade-off](https://intercoolerjs.org/2016/02/17/api-churn-vs-security.html) +* [Does Hypermedia Scale?](@/essays/does-hypermedia-scale.md) * [SPA Alternative](@/essays/spa-alternative.md) ### Building Hypermedia Applications @@ -30,6 +31,7 @@ page_template = "essay.html" * [Hypermedia-Driven Applications (HDAs)](@/essays/hypermedia-driven-applications.md) * [Hypermedia Friendly Scripting](@/essays/hypermedia-friendly-scripting.md) * [10 Tips For Building SSR/HDA applications](@/essays/10-tips-for-SSR-HDA-apps.md) +* [Why I Tend Not To Use Content Negotiation](@/essays/why-tend-not-to-use-content-negotiation.md) * [Template Fragments](@/essays/template-fragments.md) * [View Transitions](@/essays/view-transitions.md) diff --git a/www/content/essays/does-hypermedia-scale.md b/www/content/essays/does-hypermedia-scale.md index de737db2..304db83c 100644 --- a/www/content/essays/does-hypermedia-scale.md +++ b/www/content/essays/does-hypermedia-scale.md @@ -12,7 +12,7 @@ One objection that we sometimes hear to htmx and hypermedia is some variation of > Well, it might work well for something small, but it won't scale. It is always dangerous to provoke us with essay-fodder and so lets dig into this claim a bit and see if we can -shed some light on whether [Hypermedia-Driven Applications]((@/essays/hypermedia-driven-applications.md)) (HDAs) can scale. +shed some light on whether [Hypermedia-Driven Applications](@/essays/hypermedia-driven-applications.md) (HDAs) can scale. ## Scaling diff --git a/www/content/essays/hypermedia-apis-vs-data-apis.md b/www/content/essays/hypermedia-apis-vs-data-apis.md index b066d8dd..6d31233e 100644 --- a/www/content/essays/hypermedia-apis-vs-data-apis.md +++ b/www/content/essays/hypermedia-apis-vs-data-apis.md @@ -24,7 +24,7 @@ Hypermedia APIs: Data APIs, on the other hand: * Will not benefit dramatically from REST-fulness, beyond perhaps [Level 2 of the Richardson Maturity Model](https://en.wikipedia.org/wiki/Richardson_Maturity_Model) -* Should strive for both regularity and expressivity due to the arbitrary data needs of consumers +* Should strive for both regularity and expressiveness due to the arbitrary data needs of consumers * Should be versioned and should be very stable within a particular version of the API * Should be consumed by code, processed and then potentially presented to a human diff --git a/www/content/essays/template-fragments.md b/www/content/essays/template-fragments.md index bdb23f8e..9699edb3 100644 --- a/www/content/essays/template-fragments.md +++ b/www/content/essays/template-fragments.md @@ -143,6 +143,7 @@ Here are some known implementations of the fragment concept: * PHP * [Latte](https://latte.nette.org/en/template-inheritance#toc-blocks) - Use the 3rd parameter to only render 1 block from the template - `$Latte_Engine->render('path/to/template.latte', [ 'foo' => 'bar' ], 'content');` * [Laravel Blade](https://laravel.com/docs/10.x/blade#rendering-blade-fragments) - includes built-in support for template fragments as of v9.x + * [Twig](https://twig.symfony.com/doc/3.x/api.html#rendering-templates) - `$template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']);` * Python * [Django Render Block Extension](https://pypi.org/project/django-render-block/) - see [example code for htmx](https://github.com/spookylukey/django-htmx-patterns/blob/master/inline_partials.rst) * [jinja2-fragments package](https://github.com/sponsfreixes/jinja2-fragments) diff --git a/www/content/essays/why-tend-not-to-use-content-negotiation.md b/www/content/essays/why-tend-not-to-use-content-negotiation.md new file mode 100644 index 00000000..952ec6cb --- /dev/null +++ b/www/content/essays/why-tend-not-to-use-content-negotiation.md @@ -0,0 +1,177 @@ ++++ +title = "Why I Tend Not To Use Content Negotiation" +date = 2023-11-18 +updated = 2023-11-18 +[taxonomies] +author = ["Carson Gross"] +tag = ["posts"] ++++ + +I have written a lot about Hypermedia APIs vs. Data (JSON) APIs, including [the differences between the two](@/essays/hypermedia-apis-vs-data-apis.md), +what [REST "really" means](@/essays/how-did-rest-come-to-mean-the-opposite-of-rest.md) and why [HATEOAS](@/essays/hateoas.md) +isn't so bad as long as your API is interacting with a [Hypermedia Client](@/essays/hypermedia-clients.md). + +Often when I am engaged in discussions with people coming from the "REST is JSON over HTTP" world (that is, the normal +world) I have to navigate a lot of language and conceptual issues: + +* No, I am not advocating you return HTML as a general purpose API, hypermedia makes for a bad general purpose API +* Yes, I am advocating [tightly coupling](@/essays/two-approaches-to-decoupling.md) your web application to your hypermedia API +* No, I do not think that we will ever fix how the industry [uses the term REST](@/essays/how-did-rest-come-to-mean-the-opposite-of-rest.md) +* Yes, I am advocating you [split your data API and your hypermedia API up](@/essays/splitting-your-apis.md) + +The last point often strikes people who are used to a single, general purpose JSON API as dumb: why have two APIs when you +can have a single API that can satisfy any number of types of clients? I tried to answer that question as best I can in the essay +above, but it is certainly a reasonable one to ask. + +It seems like (and it is) extra work in some ways when compared to having one general API. + +At this point in a conversation, someone who agrees broadly with my take on REST, [Hypermedia-Driven Applications](@/essays/hypermedia-driven-applications.md), +etc. will often jump in and say something like + +> "Oh, it's easy, you just use _content negotiation_, it's baked into HTTP!" + +Not being content with alienating only the general purpose JSON API enthusiasts, let me now proceed to also alienate +my erstwhile hypermedia enthusiast allies by saying: + +*I don't think content negotiation is typically the right approach to +returning both JSON and HTML for most applications.* + +## What Is Content Negotiation? + +First things first, what is "content negotiation"? + +[Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) is a feature of HTTP that +allows a client to negotiate the content type of the response from a server. A full treatment of the implementation +in HTTP is beyond the scope of this essay, but let us consider the most well known mechanism for content negotiation +in HTTP, the [`Accept` Request Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#the_accept_header). + +The `Accept` request header allows a client, such as a browser, to indicate the `MIME` types that it is willing to accept +from the server in a response. + +An example value of this header is: + +```http request +Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8 +``` + +This `Accept` header tells the server what formats the client is willing to accept. Preferences are expressed via the +`q` weighting factor. Wildcards are expressed with asterisks `*`. + +In this case, the client is saying: + +> I would most like to receive text/html, application/xhtml+xml or image/webp. Next I would prefer application/xml. Finally, I will accept whatever you give me. + +The server then can take this information and determine the best content type to provide to the client. + +This is the act of "content negotiation" and it is certainly an interesting feature of HTTP. + +## Using Content Negotiation In APIs + +As far as I am aware, it was the [Ruby On Rails](https://rubyonrails.org/) community that first went in in a big way +using content negotiation to provide both HTML and JSON (and other) formats from the same URL. + +In Rails, this is accomplished via the [`respond_to`](https://apidock.com/rails/ActionController/MimeResponds/respond_to) helper method available in +controllers. + +Leaving the gory details of Rails aside, you might have a request like an HTTP `GET` to `/contacts` that ends up invoking +a function in a `ContactsController` class that looks like this: + +```ruby +def index + @contacts = Contacts.all + + respond_to do |format| + format.html # default rendering logic + format.json { render json: @contacts } + end +end +``` + +By making use of the `respond_to` helper method, if a client makes a request with the `Accept` header above, the controller +will render an HTML response using the Rails templating systems. + +However, if the `Accept` header from the client has the value `application/json` instead, Rails will render the contacts +as a JSON array for the client. + +A pretty neat trick: you can keep all your controller logic, like looking up the contacts, the same and just use a +bit of ruby/Rails magic to render two different response types using content negotiation. Barely any additional work on +top of the normal Model/View/Controller logic. + +You can see why people like the idea! + +## So What's The Problem? + +So why don't I think this is a good approach to splitting your JSON and HTML APIs up? + +It boils down to the [differences between JSON APIs and Hypermedia (HTML) APIs](hypermedia-apis-vs-data-apis.md) I hinted +at earlier. In particular: + +* Data APIs should be versioned and should be very stable within a particular version of the API +* Data APIs should strive for both regularity and expressiveness due to the arbitrary data needs of consumers +* Data APIs typically use some sort of token-based authentication +* Data APIs should be rate limited +* Hypermedia APIs typically use some sort of session-cookie based authentication +* Hypermedia APIs should be driven by the needs of the underlying hypermedia application + +While all of these differences matter and have an effect on your controller code, pulling it in two different directions, +it is really the first and last items that make me often choose not to use content negotiation in my applications. + +Your JSON API needs to be a stable set of endpoint that client code can rely on. + +Your hypermedia API, on the other hand, can change dramatically based on the user interface needs of your applications. + +These two things don't mix well. + +To give you a concrete example, consider an end point that renders a detail view of a contact, at, say `/contacts/:id` +(where `:id` is a parameter containing the id of the contact to render). Let's say that this page has a "related contacts" +section of the UI and, further, computing these related contacts is expensive for some reason. + +In this situation you might choose to use the [Lazy Loading](https://htmx.org/examples/lazy-load/) pattern to defer +loading the related contacts until after the initial contact detail screen has been rendered. This improves perceived +performance of the page for your users. + +If you did this, you might put the lazy loaded content at the end-point `/contacts/:id/related`. + +Now, later on, maybe you are able to optimize the computation of related contacts. At this point you might choose to +rip the `/contacts/:id/related` end-point out and just render the related contacts information in the initial page render. + +All of this is fine for your hypermedia API: hypermedia, through [the uniform interface & HATEOAS](@/essays/hateoas.md) +is _designed_ to handle these sorts of changes. + +However, your JSON API... not so much. + +Your JSON API should remain stable. You can't be adding and removing end-points +willy-nilly. Yes, you can have _some_ end-points respond with either JSON or HTML and others only respond with HTML, but +it gets messy. What if you accidentally copy-and-paste in the wrong code somewhere, for example. + +Taking all of this into account, as well as things like rate-limiting and so on, I think you can make a strong argument +that there should be a [Separation Of Concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) between the JSON +API and the hypermedia API. + +(Yes, I am aware of the irony that the person who coined the term [Locality of Behaviour](@/essays/locality-of-behaviour.md) +is making a SoC argument.) + +## So What's The Alternative? + +The alternative is to, as I advocate in [Splitting Your APIs](@/essays/splitting-your-apis.md), erm, splitting your +APIs. This means providing different paths (or sub-domains, or whatever) for your JSON API and your hypermedia (HTML) +API. + +Going back to our contacts API, we might have the following: + +* The JSON API to get all contacts is found at `/api/v1/contacts` +* The Hypermedia API to get all contacts is found at `/contacts` + +This layout implies two different controllers and, I say, that's a good thing: the JSON API controller can implement the +requirements of a JSON API: rate limiting, stability, maybe an expressive query mechanism like GraphQL. + +Meanwhile, your +hypermedia API (really, just your Hypermedia Driven Application endpoints) can change dramatically as your user interface +needs change, with highly tuned database queries, end-points to support special UI needs, etc. + +By separating these two concerns, your JSON API can be stable, regular and low-maintenance, and your hypermedia API can +be chaotic, specialized and flexible. Each gets its own controller environment to thrive in, without conflicting with +one another. + +And this is why I prefer to split my JSON and hypermedia APIs up into separate controllers, rather than use HTTP content +negotiation to attempt to reuse controllers for both. diff --git a/www/content/examples/_index.md b/www/content/examples/_index.md index a9311317..394f1e9c 100644 --- a/www/content/examples/_index.md +++ b/www/content/examples/_index.md @@ -31,7 +31,7 @@ You can copy and paste them and then adjust them for your needs. | [File Upload](@/examples/file-upload.md) | Demonstrates how to upload a file via ajax with a progress bar | [Preserving File Inputs after Form Errors](@/examples/file-upload-input.md) | Demonstrates how to preserve file inputs after form errors | [Dialogs - Browser](@/examples/dialogs.md) | Demonstrates the prompt and confirm dialogs -| [Dialogs - UIKIt](@/examples/modal-uikit.md) | Demonstrates modal dialogs using UIKit +| [Dialogs - UIKit](@/examples/modal-uikit.md) | Demonstrates modal dialogs using UIKit | [Dialogs - Bootstrap](@/examples/modal-bootstrap.md) | Demonstrates modal dialogs using Bootstrap | [Dialogs - Custom](@/examples/modal-custom.md) | Demonstrates modal dialogs from scratch | [Tabs (Using HATEOAS)](@/examples/tabs-hateoas.md) | Demonstrates how to display and select tabs using HATEOAS principles diff --git a/www/content/examples/active-search.md b/www/content/examples/active-search.md index 1f48acd1..242a4c21 100644 --- a/www/content/examples/active-search.md +++ b/www/content/examples/active-search.md @@ -17,7 +17,7 @@ We start with a search input and an empty table: @@ -34,16 +34,16 @@ We start with a search input and an empty table: ``` -The input issues a `POST` to `/search` on the `keyup` event and sets the body of the table to be the resulting content. +The input issues a `POST` to `/search` on the [`input`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) event and sets the body of the table to be the resulting content. Note that the `keyup` event could be used as well, but would not fire if the user pasted text with their mouse (or any other non-keyboard method). We add the `delay:500ms` modifier to the trigger to delay sending the query until the user stops typing. Additionally, we add the `changed` modifier to the trigger to ensure we don't send new queries when the user doesn't change the -value of the input (e.g. they hit an arrow key). +value of the input (e.g. they hit an arrow key, or pasted the same value). Since we use a `search` type input we will get an `x` in the input field to clear the input. To make this trigger a new `POST` we have to specify another trigger. We specify another trigger by using a comma to separate them. The `search` trigger will be run when the field is cleared but it also makes it possible to override -the 500 ms delay on `keyup` by just pressing enter. +the 500 ms `input` event delay by just pressing enter. Finally, we show an indicator when the search is in flight with the `hx-indicator` attribute. @@ -78,7 +78,7 @@ Search Contacts diff --git a/www/content/reference.md b/www/content/reference.md index 2e3b05c3..98b31bce 100644 --- a/www/content/reference.md +++ b/www/content/reference.md @@ -12,6 +12,7 @@ title = "Reference" * [htmx Events](#events) * [htmx Extensions](/extensions#included) * [JavaScript API](#api) +* [Configuration Options](#config) ## Core Attribute Reference {#attributes} @@ -24,7 +25,7 @@ The following are the most common attributes when using htmx. | [`hx-boost`](@/attributes/hx-boost.md) | add or remove [progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement) for links and forms | | [`hx-get`](@/attributes/hx-get.md) | issues a `GET` to the specified URL | | [`hx-post`](@/attributes/hx-post.md) | issues a `POST` to the specified URL | -| [`hx-on`](@/attributes/hx-on.md) | handle any event with a script inline | +| [`hx-on*`](@/attributes/hx-on.md) | handle events with a inline scripts on elements | | [`hx-push-url`](@/attributes/hx-push-url.md) | pushes the URL into the browser location bar, creating a new history entry | | [`hx-select`](@/attributes/hx-select.md) | select content to swap in from a response | | [`hx-select-oob`](@/attributes/hx-select-oob.md) | select content to swap in from a response, out of band (somewhere other than the target) | @@ -207,3 +208,51 @@ The table below lists all other attributes available in htmx.
    + +## Configuration Reference {#config} + +Htmx has some configuration options that can be accessed either programmatically or declaratively. They are +listed below: + +
    + +| Config Variable | Info | +|---------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `htmx.config.historyEnabled` | defaults to `true`, really only useful for testing | +| `htmx.config.historyCacheSize` | defaults to 10 | +| `htmx.config.refreshOnHistoryMiss` | defaults to `false`, if set to `true` htmx will issue a full page refresh on history misses rather than use an AJAX request | +| `htmx.config.defaultSwapStyle` | defaults to `innerHTML` | +| `htmx.config.defaultSwapDelay` | defaults to 0 | +| `htmx.config.defaultSettleDelay` | defaults to 20 | +| `htmx.config.includeIndicatorStyles` | defaults to `true` (determines if the indicator styles are loaded) | +| `htmx.config.indicatorClass` | defaults to `htmx-indicator` | +| `htmx.config.requestClass` | defaults to `htmx-request` | +| `htmx.config.addedClass` | defaults to `htmx-added` | +| `htmx.config.settlingClass` | defaults to `htmx-settling` | +| `htmx.config.swappingClass` | defaults to `htmx-swapping` | +| `htmx.config.allowEval` | defaults to `true`, can be used to disable htmx's use of eval for certain features (e.g. trigger filters) | +| `htmx.config.allowScriptTags` | defaults to `true`, determines if htmx will process script tags found in new content | +| `htmx.config.inlineScriptNonce` | defaults to `''`, meaning that no nonce will be added to inline scripts | +| `htmx.config.attributesToSettle` | defaults to `["class", "style", "width", "height"]`, the attributes to settle during the settling phase | +| `htmx.config.useTemplateFragments` | defaults to `false`, HTML template tags for parsing content from the server (not IE11 compatible!) | +| `htmx.config.wsReconnectDelay` | defaults to `full-jitter` | +| `htmx.config.wsBinaryType` | defaults to `blob`, the [the type of binary data](https://developer.mozilla.org/docs/Web/API/WebSocket/binaryType) being received over the WebSocket connection | +| `htmx.config.disableSelector` | defaults to `[hx-disable], [data-hx-disable]`, htmx will not process elements with this attribute on it or a parent | +| `htmx.config.withCredentials` | defaults to `false`, allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates | +| `htmx.config.timeout` | defaults to 0, the number of milliseconds a request can take before automatically being terminated | +| `htmx.config.scrollBehavior` | defaults to 'smooth', the behavior for a boosted link on page transitions. The allowed values are `auto` and `smooth`. Smooth will smoothscroll to the top of the page while auto will behave like a vanilla link. | +| `htmx.config.defaultFocusScroll` | if the focused element should be scrolled into view, defaults to false and can be overridden using the [focus-scroll](@/attributes/hx-swap.md#focus-scroll) swap modifier. | +| `htmx.config.getCacheBusterParam` | defaults to false, if set to true htmx will include a cache-busting parameter in `GET` requests to avoid caching partial responses by the browser | +| `htmx.config.globalViewTransitions` | if set to `true`, htmx will use the [View Transition](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) API when swapping in new content. | +| `htmx.config.methodsThatUseUrlParams` | defaults to `["get"]`, htmx will format requests with these methods by encoding their parameters in the URL, not the request body | +| `htmx.config.selfRequestsOnly` | defaults to `false`, if set to `true` will only allow AJAX requests to the same domain as the current document | +| `htmx.config.ignoreTitle` | defaults to `false`, if set to `true` htmx will not update the title of the document when a `title` tag is found in new content | +| `htmx.config.scrollIntoViewOnBoost` | defaults to `true`, whether or not the target of a boosted element is scrolled into the viewport. If `hx-target` is omitted on a boosted element, the target defaults to `body`, causing the page to scroll to the top. | + +
    + +You can set them directly in javascript, or you can use a `meta` tag: + +```html + +``` diff --git a/www/content/server-examples.md b/www/content/server-examples.md index 21da2c3e..5f6b3f54 100644 --- a/www/content/server-examples.md +++ b/www/content/server-examples.md @@ -138,6 +138,15 @@ These examples may make it a bit easier to get started using htmx with your plat - + +### Laravel + +- + +### Symfony + +- + ## Elixir ### Phoenix diff --git a/www/static/img/blackhost-logo.svg b/www/static/img/blackhost-logo.svg new file mode 100644 index 00000000..9b96afc4 --- /dev/null +++ b/www/static/img/blackhost-logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/www/static/js/demo.js b/www/static/js/demo.js index 037006de..1228b4e3 100644 --- a/www/static/js/demo.js +++ b/www/static/js/demo.js @@ -28,8 +28,8 @@ function parseParams(str) { str = str.substr(1); } while (e = re.exec(str)) { - var k = decode(e[1]); - var v = decode(e[2]); + var k = encodeHTML(decode(e[1])); + var v = encodeHTML(decode(e[2])); if (params[k] !== undefined) { if (!Array.isArray(params[k])) { params[k] = [params[k]]; @@ -52,6 +52,10 @@ function getQuery(url) { url.substring(question + 1, hash); } +function encodeHTML(s) { + return s.replace(/&/g, '&').replace(/ + context: Partial<{ source: any; event: any; handler: any; target: any; swap: any; values: any; headers: any; select: any }> ): Promise; /** @@ -395,6 +395,11 @@ export interface HtmxConfig { * @default false */ selfRequestsOnly?: boolean; + /** + * Whether or not the target of a boosted element is scrolled into the viewport. + * @default true + */ + scrollIntoViewOnBoost?: boolean; } /** diff --git a/www/static/src/htmx.js b/www/static/src/htmx.js index 79a4702e..1f0709d7 100644 --- a/www/static/src/htmx.js +++ b/www/static/src/htmx.js @@ -75,6 +75,7 @@ return (function () { globalViewTransitions: false, methodsThatUseUrlParams: ["get"], selfRequestsOnly: false, + ignoreTitle: false, scrollIntoViewOnBoost: true }, parseInterval:parseInterval, @@ -87,7 +88,7 @@ return (function () { sock.binaryType = htmx.config.wsBinaryType; return sock; }, - version: "1.9.8" + version: "1.9.9" }; /** @type {import("./htmx").HtmxInternalApi} */ @@ -1145,6 +1146,8 @@ return (function () { var SYMBOL_CONT = /[_$a-zA-Z0-9]/; var STRINGISH_START = ['"', "'", "/"]; var NOT_WHITESPACE = /[^\s]/; + var COMBINED_SELECTOR_START = /[{(]/; + var COMBINED_SELECTOR_END = /[})]/; function tokenizeString(str) { var tokens = []; var position = 0; @@ -1233,6 +1236,18 @@ return (function () { return result; } + function consumeCSSSelector(tokens) { + var result; + if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) { + tokens.shift(); + result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim(); + tokens.shift(); + } else { + result = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } + return result; + } + var INPUT_SELECTOR = 'input, textarea, select'; /** @@ -1281,29 +1296,33 @@ return (function () { triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); } else if (token === "from" && tokens[0] === ":") { tokens.shift(); - var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); - if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { - tokens.shift(); - var selector = consumeUntil( - tokens, - WHITESPACE_OR_COMMA - ) - // `next` and `previous` allow a selector-less syntax - if (selector.length > 0) { - from_arg += " " + selector; + if (COMBINED_SELECTOR_START.test(tokens[0])) { + var from_arg = consumeCSSSelector(tokens); + } else { + var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); + if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { + tokens.shift(); + var selector = consumeCSSSelector(tokens); + // `next` and `previous` allow a selector-less syntax + if (selector.length > 0) { + from_arg += " " + selector; + } } } triggerSpec.from = from_arg; } else if (token === "target" && tokens[0] === ":") { tokens.shift(); - triggerSpec.target = consumeUntil(tokens, WHITESPACE_OR_COMMA); + triggerSpec.target = consumeCSSSelector(tokens); } else if (token === "throttle" && tokens[0] === ":") { tokens.shift(); triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); } else if (token === "queue" && tokens[0] === ":") { tokens.shift(); triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else if ((token === "root" || token === "threshold") && tokens[0] === ":") { + } else if (token === "root" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeCSSSelector(tokens); + } else if (token === "threshold" && tokens[0] === ":") { tokens.shift(); triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); } else { @@ -2906,6 +2925,7 @@ return (function () { values : context.values, targetOverride: resolveTarget(context.target), swapOverride: context.swap, + select: context.select, returnPromise: true }); } @@ -2960,6 +2980,7 @@ return (function () { elt = getDocument().body; } var responseHandler = etc.handler || handleAjaxResponse; + var select = etc.select || null; if (!bodyContains(elt)) { // do not issue requests for elements removed from the DOM @@ -3108,6 +3129,11 @@ return (function () { var headers = getHeaders(elt, target, promptResponse); + + if (verb !== 'get' && !usesFormData(elt)) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + if (etc.headers) { headers = mergeObjects(headers, etc.headers); } @@ -3121,10 +3147,6 @@ return (function () { var allParameters = mergeObjects(rawParameters, expressionVars); var filteredParameters = filterValues(allParameters, elt); - if (verb !== 'get' && !usesFormData(elt)) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - if (htmx.config.getCacheBusterParam && verb === 'get') { filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true"; } @@ -3222,7 +3244,7 @@ return (function () { } var responseInfo = { - xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, + xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select, pathInfo: { requestPath: path, finalRequestPath: finalPath, @@ -3393,6 +3415,7 @@ return (function () { var target = responseInfo.target; var etc = responseInfo.etc; var requestConfig = responseInfo.requestConfig; + var select = responseInfo.select; if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return; @@ -3502,10 +3525,26 @@ return (function () { } var selectOverride; + if (select) { + selectOverride = select; + } + if (hasHeader(xhr, /HX-Reselect:/i)) { selectOverride = xhr.getResponseHeader("HX-Reselect"); } + // if we need to save history, do so, before swapping so that relative resources have the correct base URL + if (historyUpdate.type) { + triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)); + if (historyUpdate.type === "push") { + pushUrlIntoHistory(historyUpdate.path); + triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path}); + } else { + replaceUrlInHistory(historyUpdate.path); + triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path}); + } + } + var settleInfo = makeSettleInfo(target); selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride); @@ -3555,17 +3594,6 @@ return (function () { triggerEvent(elt, 'htmx:afterSettle', responseInfo); }); - // if we need to save history, do so - if (historyUpdate.type) { - triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)); - if (historyUpdate.type === "push") { - pushUrlIntoHistory(historyUpdate.path); - triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path}); - } else { - replaceUrlInHistory(historyUpdate.path); - triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path}); - } - } if (responseInfo.pathInfo.anchor) { var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor); if(anchorTarget) { @@ -3724,25 +3752,34 @@ return (function () { //==================================================================== // Initialization //==================================================================== - var isReady = false - getDocument().addEventListener('DOMContentLoaded', function() { - isReady = true - }) - /** - * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. - * - * This function uses isReady because there is no realiable way to ask the browswer whether - * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded - * firing and readystate=complete. + * We want to initialize the page elements after DOMContentLoaded + * fires, but there isn't always a good way to tell whether + * it has already fired when we get here or not. */ - function ready(fn) { - // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by - // some means other than the initial page load. - if (isReady || getDocument().readyState === 'complete') { - fn(); - } else { - getDocument().addEventListener('DOMContentLoaded', fn); + function ready(functionToCall) { + // call the function exactly once no matter how many times this is called + var callReadyFunction = function() { + if (!functionToCall) return; + functionToCall(); + functionToCall = null; + }; + + if (getDocument().readyState === "complete") { + // DOMContentLoaded definitely fired, we can initialize the page + callReadyFunction(); + } + else { + /* DOMContentLoaded *maybe* already fired, wait for + * the next DOMContentLoaded or readystatechange event + */ + getDocument().addEventListener("DOMContentLoaded", function() { + callReadyFunction(); + }); + getDocument().addEventListener("readystatechange", function() { + if (getDocument().readyState !== "complete") return; + callReadyFunction(); + }); } } @@ -3750,9 +3787,9 @@ return (function () { if (htmx.config.includeIndicatorStyles !== false) { getDocument().head.insertAdjacentHTML("beforeend", ""); } } diff --git a/www/static/test/attributes/hx-boost.js b/www/static/test/attributes/hx-boost.js index 7866abec..f8698a33 100644 --- a/www/static/test/attributes/hx-boost.js +++ b/www/static/test/attributes/hx-boost.js @@ -116,4 +116,3 @@ describe("hx-boost attribute", function() { }); }); - diff --git a/www/static/test/attributes/hx-trigger.js b/www/static/test/attributes/hx-trigger.js index 5e2f138b..0cbb4adf 100644 --- a/www/static/test/attributes/hx-trigger.js +++ b/www/static/test/attributes/hx-trigger.js @@ -895,5 +895,64 @@ describe("hx-trigger attribute", function(){ form.innerHTML.should.equal("Called!"); }) + it("correctly handles CSS descendant combinators", function(){ + this.server.respondWith("GET", "/test", "Clicked!"); + + var outer = make(` +
    +
    +
    +
    +
    +
    Unclicked.
    +
    +
    Unclicked.
    +
    + `); + + var inner = byId("inner"); + var second = byId("second"); + var other = byId("other"); + + second.innerHTML.should.equal("Unclicked."); + other.innerHTML.should.equal("Unclicked."); + + inner.click(); + this.server.respond(); + + second.innerHTML.should.equal("Clicked!"); + other.innerHTML.should.equal("Clicked!"); + }) + + + it('correctly handles CSS descendant combinators in modifier target', function() { + this.server.respondWith('GET', '/test', 'Called'); + + document.addEventListener('htmx:syntax:error', function(evt) { + chai.assert.fail('htmx:syntax:error'); + }); + + make(''); + var div = make('
    Not Called
    '); + + byId('a1').click(); + this.server.respond(); + div.innerHTML.should.equal("Not Called"); + + byId('a2').click(); + this.server.respond(); + div.innerHTML.should.equal("Called"); + }); + + it('correctly handles CSS descendant combinators in modifier root', function() { + this.server.respondWith('GET', '/test', 'Called'); + + document.addEventListener('htmx:syntax:error', function(evt) { + chai.assert.fail('htmx:syntax:error'); + }); + + make('
    Not Called
    '); + }); + }) diff --git a/www/static/test/core/ajax.js b/www/static/test/core/ajax.js index 7d18be73..00e677df 100644 --- a/www/static/test/core/ajax.js +++ b/www/static/test/core/ajax.js @@ -1284,6 +1284,8 @@ describe("Core htmx AJAX Tests", function(){ byId("submit").click(); this.server.respond(); responded.should.equal(true); + }) + it("can associate submit buttons from outside a form with the current version of the form after swap", function(){ const template = '
    foo!

    '); }); + it('ajax api works with select', function() + { + this.server.respondWith("GET", "/test", "
    foo
    bar
    "); + var div = make("
    "); + htmx.ajax("GET", "/test", {target: "#target", select: "#d2"}); + this.server.respond(); + div.innerHTML.should.equal('
    bar
    '); + }); + + it('ajax api works with Hx-Select overrides select', function() + { + this.server.respondWith("GET", "/test", [200, {"HX-Reselect": "#d2"}, "
    foo
    bar
    "]); + var div = make("
    "); + htmx.ajax("GET", "/test", {target: "#target", select: "#d1"}); + this.server.respond(); + div.innerHTML.should.equal('
    bar
    '); + }); + it('ajax returns a promise', function(done) { // in IE we do not return a promise @@ -255,6 +273,44 @@ describe("Core htmx API test", function(){ div.innerHTML.should.equal("Clicked!"); }); + it('ajax api Content-Type header is application/x-www-form-urlencoded', function(){ + + this.server.respondWith("POST", "/test", function (xhr) { + var params = getParameters(xhr); + xhr.requestHeaders['Content-Type'].should.equal('application/x-www-form-urlencoded;charset=utf-8'); + params['i1'].should.equal("test"); + xhr.respond(200, {}, "Clicked!") + }); + var div = make("
    "); + htmx.ajax("POST", "/test", {target:"#d1", values:{i1: 'test'}}) + this.server.respond(); + div.innerHTML.should.equal("Clicked!"); + }); + + it('ajax api Content-Type header override to application/json', function(){ + + this.server.respondWith("POST", "/test", function (xhr) { + var params = getParameters(xhr); + xhr.requestHeaders['Content-Type'].should.equal('application/json;charset=utf-8'); + params['i1'].should.equal("test"); + xhr.respond(200, {}, "Clicked!"); + }); + + var div = make("
    "); + htmx.ajax('POST',"/test", { + target:'#d1', + swap:'innerHTML', + headers: { + 'Content-Type': 'application/json' + }, + values:{i1: 'test'} + }) + + this.server.respond(); + div.innerHTML.should.equal("Clicked!"); + }); + + it('can re-init with new attributes', function () { this.server.respondWith("PATCH", "/test", "patch"); this.server.respondWith("DELETE", "/test", "delete"); diff --git a/www/static/test/ext/ws.js b/www/static/test/ext/ws.js index 7e6f140d..ce17782d 100644 --- a/www/static/test/ext/ws.js +++ b/www/static/test/ext/ws.js @@ -614,4 +614,205 @@ describe("web-sockets extension", function () { this.messages[1].should.contains('"foo":"bar"') this.messages[1].should.contains('"action":"B"') }) + + describe("Send immediately", function() { + function checkCallForWsBeforeSend(spy, wrapper, message, target) { + // Utility function to always check the same for htmx:wsBeforeSend caught by a spy + spy.calledOnce.should.be.true; + var call = spy.getCall(0); + call.args.length.should.equal(1); + var arg = call.args[0]; + arg.target.should.equal(target); + arg.detail.socketWrapper.should.equal(wrapper); + arg.detail.message.should.equal(message); + } + it('triggers wsBeforeSend on body if provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"foo":"bar"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + window.document.body.addEventListener("htmx:wsBeforeSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, window.document.body) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body) + } finally { + window.document.body.removeEventListener("htmx:wsBeforeSend", myEventCalled) + } + done() + } + try { + window.document.addEventListener("htmx:wsOpen", handler) + + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsBeforeSend on any send element provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, d1) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", myEventCalled) + } + done() + } + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsAfterSend on body if provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"foo":"bar"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + window.document.body.addEventListener("htmx:wsAfterSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, window.document.body) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, window.document.body) + } finally { + window.document.body.removeEventListener("htmx:wsAfterSend", myEventCalled) + } + done() + } + try { + window.document.addEventListener("htmx:wsOpen", handler) + + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('triggers wsAfterSend on any send element provided to sendImmediately', function (done) { + var myEventCalled = sinon.spy(); + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsAfterSend", myEventCalled) + try { + socketWrapper.sendImmediately(message, d1) + checkCallForWsBeforeSend(myEventCalled, socketWrapper, message, d1) + } finally { + id1.removeEventListener("htmx:wsAfterSend", myEventCalled) + } + done() + } + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + it('sends message if event is not prevented', function (done) { + var message = '{"a":"b"}'; + var noop = function() {} + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", noop) + try { + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([message]) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", noop) + } + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('sends message if no sending element is provided', function (done) { + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + socketWrapper.sendImmediately(message) + this.tickMock(); + this.messages.should.eql([message]) + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('sends message if sending element has no event listener for beforeSend', function (done) { + var message = '{"a":"b"}'; + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var d1 = byId("d1"); + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([message]) + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + }) + it('does not send message if beforeSend is prevented', function (done) { + var message = '{"a":"b"}'; + var eventPrevented = function(e) {e.preventDefault()} + var handler = function(e){ + var socketWrapper = e.detail.socketWrapper; + var id1 = byId("d1"); + id1.addEventListener("htmx:wsBeforeSend", eventPrevented) + try { + socketWrapper.sendImmediately(message, d1) + this.tickMock(); + this.messages.should.eql([]) + } finally { + id1.removeEventListener("htmx:wsBeforeSend", eventPrevented) + } + done() + }.bind(this) + + window.document.addEventListener("htmx:wsOpen", handler) + try { + var div = make('
    div1
    '); + this.tickMock(); + } finally { + window.document.removeEventListener("htmx:wsOpen", handler) + } + + }) + }) + + }); diff --git a/www/static/test/manual/hxboost_relative_resources/index.html b/www/static/test/manual/hxboost_relative_resources/index.html new file mode 100644 index 00000000..9bf741eb --- /dev/null +++ b/www/static/test/manual/hxboost_relative_resources/index.html @@ -0,0 +1,10 @@ + + + + + hx-boost - Relative Resources Page 1 + + +To Page 2 + + diff --git a/www/static/test/manual/hxboost_relative_resources/nested/img.png b/www/static/test/manual/hxboost_relative_resources/nested/img.png new file mode 100644 index 00000000..533a968a Binary files /dev/null and b/www/static/test/manual/hxboost_relative_resources/nested/img.png differ diff --git a/www/static/test/manual/hxboost_relative_resources/nested/page2.html b/www/static/test/manual/hxboost_relative_resources/nested/page2.html new file mode 100644 index 00000000..12cdd25d --- /dev/null +++ b/www/static/test/manual/hxboost_relative_resources/nested/page2.html @@ -0,0 +1,12 @@ + + + + + hx-boost - Relative Resources Page 2 + + +Back To Page 1 +

    Image should be displayed below

    + + + diff --git a/www/static/test/manual/index.html b/www/static/test/manual/index.html index 1097e0fd..99b25d65 100644 --- a/www/static/test/manual/index.html +++ b/www/static/test/manual/index.html @@ -37,6 +37,12 @@
  • History Style
  • +
  • Boost Tests + +
  • Perf

  • Manual Perf Test
  • diff --git a/www/static/test/ws-sse/server.mjs b/www/static/test/ws-sse/server.mjs new file mode 100644 index 00000000..0cbe53c3 --- /dev/null +++ b/www/static/test/ws-sse/server.mjs @@ -0,0 +1,244 @@ +import * as http from 'node:http' +import * as path from 'node:path' +import * as fs from 'node:fs/promises' + +import { WebSocketServer } from 'ws' + +// Define some string and number constants +const HOSTNAME = '127.0.0.1'; +const PORT = 8080; +const DATA = JSON.parse(await fs.readFile('./static/data.json')) +const SITE_BASE = (await fs.readFile('./static/site-base.html')).toString() + +// Define the websockets +const ECHO_WS = createWebSocket((ws) => { + ws.on('message', (message) => { + const data = JSON.parse(message.toString()) + ws.send(`
    ${data.message}
    `) + }) +}) +const HEARTBEAT_WS = createWebSocket((ws) => { + ws.interval = setInterval(() => { + const num = Math.trunc(Math.random() * 10**10) + ws.send(`
    ${num}
    `) + }, 1000) +}, (ws) => clearInterval(ws.interval)) + + +// Define the server +const server = http.createServer(async (req, res) => { + try { + await handleRequest(req, res) + } catch (error) { + console.error(`Error serving ${req.url}`) + console.error(req.body) + console.error(error) + } +}) + +// This handles all the non-websocket requests +async function handleRequest (req, res) { + // If the URL starts with htmx, serve the src/ root version of htmx + if (req.url.startsWith('/htmx')) { + const resource = req.url.substring(6) + res.setHeader('Content-Type', 'text/javascript') + const fp = path.join('../../src', resource) + return serveFile(res, fp) + } + + // If the URL matches one of these, it's an event stream + if (req.url.startsWith("/posts.html")) return servePosts(req, res) + if (req.url === "/comments.html") return makeStream(req, res, DATA.comments, formatComment) + if (req.url === "/albums.html") return makeStream(req, res, DATA.albums, formatAlbum) + if (req.url === "/todos.html") return makeStream(req, res, DATA.todos, formatTodo) + if (req.url === "/users.html") return makeStream(req, res, DATA.users, formatUser) + + // Randomly-generated HTML + if (req.url === "/page/random") return serveRandomHtml(req, res) + + // Otherwise, attempt to serve the file from ./static and return a 404 on failure + try { + await serveFileFromStatic(req, res) + } catch (error) { + sendNotFound(res) + } +} + +// Attach the websockets +server.on('upgrade', (request, socket, head) => { + if (request.url === '/echo') ECHO_WS.handle(request, socket, head) + if (request.url === '/heartbeat') HEARTBEAT_WS.handle(request, socket, head) +}) + +// Start listening +server.listen(PORT, HOSTNAME, () => { + console.log('Loading the WebSocket / Server-Side Event Tests...'); + console.log(`You can run them at http://${HOSTNAME}:${PORT}/`); +}) + +function createWebSocket (connectionFunc, closeFunc) { + const server = new WebSocketServer({ noServer: true }) + server.on('connection', connectionFunc) + if (closeFunc) server.on('close', closeFunc) + + const handle = (request, socket, head) => { + server.handleUpgrade(request, socket, head, (ws) => { + server.emit('connection', ws, request) + }) + } + return { handle } +} + +async function serveFileFromStatic (req, res) { + // For the root, serve the static index.html file + const resource = req.url === '/' ? '/index.html' : req.url + + let fp = path.join('./static/', resource) + let lstat = await fs.lstat(fp) + + // If it's a directory, re-set the fp to be the index.html of that directory + if (lstat.isDirectory()) { + fp = path.join(fp, 'index.html') + lstat = await fs.lstat(fp) + } + + if (!lstat.isFile) return sendNotFound(res) + + const withBase = fp.endsWith('.html') + return serveFile(res, fp, withBase) +} + +async function serveFile (res, fp, withBase) { + try { + const file = await fs.readFile(fp) + let text = file.toString() + if (withBase) text = SITE_BASE + text + res.end(text) + } catch (error) { + console.error(error) + sendNotFound(res) + } +} + +function servePosts (req, res) { + // Why do we have to specify a fake protocol here? Because WHATWG doesn't support relative URLs + // Maddening discussion here: https://github.com/whatwg/url/issues/531 + const url = new URL(req.url, "thismessage:/") + const types = url.searchParams?.get('types') + + const numEvents = types ? types.split(',').length : 0 + makeStream(req, res, DATA.posts, formatPost, numEvents) +} + +function sendNotFound(res) { + res.statusCode = 404 + res.setHeader('Content-Type', 'text/plain') + res.end('404 NOT FOUND') +} + +function makeStream(req, res, arr, formatFunc, numEvents = 0) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache' + }) + + // Make the intervals somewhat random, between 200 and 400ms + // We have some tests that create multiple streams at once, so this ensures they're all visibile + const intervalLength = Math.floor(Math.random() * 200) + 200 + + let i = 0 + const interval = setInterval(() => { + if (i == arr.length) i = 0 + + const item = arr[i] + try { + const evenNum = Math.floor(Math.random() * numEvents) + 1 + const eventName = numEvents > 0 ? `Event${evenNum}` : '(none)' + item.event = eventName + + const formattedData = formatFunc(item).replace(/\n/g, ' ') + const event = `${numEvents > 0 ? `event: ${eventName}\n` : ''}data: ${formattedData}\n\n` + res.write(event) + i++ + } catch (error) { + // Stop the interval if it errors for any reason + clearInterval(interval) + } + }, intervalLength) + + req.on('close', () => { + res.end('OK') + clearInterval(interval) + }) +} + +function serveRandomHtml(_req, res) { + const page_num = Math.trunc(Math.random() * 10**10) + const html_num = Math.trunc(Math.random() * 10**10) + const html = ` +
    + This is page ${page_num} +

    + Randomly generated HTML ${html_num} +

    + I wish I were a haiku. +
    + ` + res.end(html) +} + +function formatPost (post) { + return ` +
    +
    Post: ${post.title}
    +
    ${post.body}
    +
    id: ${post.id}
    +
    user: ${post.userId}
    +
    event: ${post.event}
    +
    + ` +} + +function formatComment (comment) { + return ` +
    +
    Comment: ${comment.name}
    +
    ${comment.email}
    +
    id: ${comment.body}
    +
    event: ${comment.event}
    +
    + ` +} + +function formatAlbum (album) { + return ` +
    +
    Album: ${album.title}
    +
    id: ${album.id}
    +
    event: ${album.event}
    +
    + ` +} + +function formatTodo (todo) { + return ` +
    +
    To-Do: ${todo.title}
    +
    complete? ${todo.completed}
    +
    event: ${todo.event}
    +
    + ` +} + +function formatUser (user) { + return ` +
    +
    User: ${user.name}
    +
    ${user.email}
    +
    ${user.address.street} ${user.address.suite}
    ${user.address.city}, ${user.address.zipcode}
    +
    event: ${user.event}
    +
    + ` +} + diff --git a/www/themes/htmx-theme/static/js/htmx.js b/www/themes/htmx-theme/static/js/htmx.js index 79a4702e..1f0709d7 100644 --- a/www/themes/htmx-theme/static/js/htmx.js +++ b/www/themes/htmx-theme/static/js/htmx.js @@ -75,6 +75,7 @@ return (function () { globalViewTransitions: false, methodsThatUseUrlParams: ["get"], selfRequestsOnly: false, + ignoreTitle: false, scrollIntoViewOnBoost: true }, parseInterval:parseInterval, @@ -87,7 +88,7 @@ return (function () { sock.binaryType = htmx.config.wsBinaryType; return sock; }, - version: "1.9.8" + version: "1.9.9" }; /** @type {import("./htmx").HtmxInternalApi} */ @@ -1145,6 +1146,8 @@ return (function () { var SYMBOL_CONT = /[_$a-zA-Z0-9]/; var STRINGISH_START = ['"', "'", "/"]; var NOT_WHITESPACE = /[^\s]/; + var COMBINED_SELECTOR_START = /[{(]/; + var COMBINED_SELECTOR_END = /[})]/; function tokenizeString(str) { var tokens = []; var position = 0; @@ -1233,6 +1236,18 @@ return (function () { return result; } + function consumeCSSSelector(tokens) { + var result; + if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) { + tokens.shift(); + result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim(); + tokens.shift(); + } else { + result = consumeUntil(tokens, WHITESPACE_OR_COMMA); + } + return result; + } + var INPUT_SELECTOR = 'input, textarea, select'; /** @@ -1281,29 +1296,33 @@ return (function () { triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); } else if (token === "from" && tokens[0] === ":") { tokens.shift(); - var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); - if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { - tokens.shift(); - var selector = consumeUntil( - tokens, - WHITESPACE_OR_COMMA - ) - // `next` and `previous` allow a selector-less syntax - if (selector.length > 0) { - from_arg += " " + selector; + if (COMBINED_SELECTOR_START.test(tokens[0])) { + var from_arg = consumeCSSSelector(tokens); + } else { + var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); + if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { + tokens.shift(); + var selector = consumeCSSSelector(tokens); + // `next` and `previous` allow a selector-less syntax + if (selector.length > 0) { + from_arg += " " + selector; + } } } triggerSpec.from = from_arg; } else if (token === "target" && tokens[0] === ":") { tokens.shift(); - triggerSpec.target = consumeUntil(tokens, WHITESPACE_OR_COMMA); + triggerSpec.target = consumeCSSSelector(tokens); } else if (token === "throttle" && tokens[0] === ":") { tokens.shift(); triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); } else if (token === "queue" && tokens[0] === ":") { tokens.shift(); triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); - } else if ((token === "root" || token === "threshold") && tokens[0] === ":") { + } else if (token === "root" && tokens[0] === ":") { + tokens.shift(); + triggerSpec[token] = consumeCSSSelector(tokens); + } else if (token === "threshold" && tokens[0] === ":") { tokens.shift(); triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); } else { @@ -2906,6 +2925,7 @@ return (function () { values : context.values, targetOverride: resolveTarget(context.target), swapOverride: context.swap, + select: context.select, returnPromise: true }); } @@ -2960,6 +2980,7 @@ return (function () { elt = getDocument().body; } var responseHandler = etc.handler || handleAjaxResponse; + var select = etc.select || null; if (!bodyContains(elt)) { // do not issue requests for elements removed from the DOM @@ -3108,6 +3129,11 @@ return (function () { var headers = getHeaders(elt, target, promptResponse); + + if (verb !== 'get' && !usesFormData(elt)) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + if (etc.headers) { headers = mergeObjects(headers, etc.headers); } @@ -3121,10 +3147,6 @@ return (function () { var allParameters = mergeObjects(rawParameters, expressionVars); var filteredParameters = filterValues(allParameters, elt); - if (verb !== 'get' && !usesFormData(elt)) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } - if (htmx.config.getCacheBusterParam && verb === 'get') { filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true"; } @@ -3222,7 +3244,7 @@ return (function () { } var responseInfo = { - xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, + xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select, pathInfo: { requestPath: path, finalRequestPath: finalPath, @@ -3393,6 +3415,7 @@ return (function () { var target = responseInfo.target; var etc = responseInfo.etc; var requestConfig = responseInfo.requestConfig; + var select = responseInfo.select; if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return; @@ -3502,10 +3525,26 @@ return (function () { } var selectOverride; + if (select) { + selectOverride = select; + } + if (hasHeader(xhr, /HX-Reselect:/i)) { selectOverride = xhr.getResponseHeader("HX-Reselect"); } + // if we need to save history, do so, before swapping so that relative resources have the correct base URL + if (historyUpdate.type) { + triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)); + if (historyUpdate.type === "push") { + pushUrlIntoHistory(historyUpdate.path); + triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path}); + } else { + replaceUrlInHistory(historyUpdate.path); + triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path}); + } + } + var settleInfo = makeSettleInfo(target); selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride); @@ -3555,17 +3594,6 @@ return (function () { triggerEvent(elt, 'htmx:afterSettle', responseInfo); }); - // if we need to save history, do so - if (historyUpdate.type) { - triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)); - if (historyUpdate.type === "push") { - pushUrlIntoHistory(historyUpdate.path); - triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path}); - } else { - replaceUrlInHistory(historyUpdate.path); - triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path}); - } - } if (responseInfo.pathInfo.anchor) { var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor); if(anchorTarget) { @@ -3724,25 +3752,34 @@ return (function () { //==================================================================== // Initialization //==================================================================== - var isReady = false - getDocument().addEventListener('DOMContentLoaded', function() { - isReady = true - }) - /** - * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. - * - * This function uses isReady because there is no realiable way to ask the browswer whether - * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded - * firing and readystate=complete. + * We want to initialize the page elements after DOMContentLoaded + * fires, but there isn't always a good way to tell whether + * it has already fired when we get here or not. */ - function ready(fn) { - // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by - // some means other than the initial page load. - if (isReady || getDocument().readyState === 'complete') { - fn(); - } else { - getDocument().addEventListener('DOMContentLoaded', fn); + function ready(functionToCall) { + // call the function exactly once no matter how many times this is called + var callReadyFunction = function() { + if (!functionToCall) return; + functionToCall(); + functionToCall = null; + }; + + if (getDocument().readyState === "complete") { + // DOMContentLoaded definitely fired, we can initialize the page + callReadyFunction(); + } + else { + /* DOMContentLoaded *maybe* already fired, wait for + * the next DOMContentLoaded or readystatechange event + */ + getDocument().addEventListener("DOMContentLoaded", function() { + callReadyFunction(); + }); + getDocument().addEventListener("readystatechange", function() { + if (getDocument().readyState !== "complete") return; + callReadyFunction(); + }); } } @@ -3750,9 +3787,9 @@ return (function () { if (htmx.config.includeIndicatorStyles !== false) { getDocument().head.insertAdjacentHTML("beforeend", ""); } }