From e9bce8db65c37f40935ca44bcb3b13581ea0dcd8 Mon Sep 17 00:00:00 2001 From: Fernando Comunello <66500627+fercomunello@users.noreply.github.com> Date: Wed, 20 Dec 2023 19:37:42 -0300 Subject: [PATCH 1/5] Improve head tag parsing on template fragments (#2024) Fix https://github.com/bigskysoftware/htmx/issues/2018 --- src/htmx.js | 74 +++++++++++-------- .../index-partial.html | 8 ++ .../index.html | 41 ++++++++++ .../other-content.html | 16 ++++ test/manual/index.html | 1 + 5 files changed, 111 insertions(+), 29 deletions(-) create mode 100644 test/manual/hxboost_partial_template_parsing/index-partial.html create mode 100644 test/manual/hxboost_partial_template_parsing/index.html create mode 100644 test/manual/hxboost_partial_template_parsing/other-content.html diff --git a/src/htmx.js b/src/htmx.js index 668148ec..e81d96a9 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -128,10 +128,24 @@ return (function () { return "[hx-" + verb + "], [data-hx-" + verb + "]" }).join(", "); + var HEAD_TAG_REGEX = newTagRegex('head'), + TITLE_TAG_REGEX = newTagRegex('title'), + SVG_TAGS_REGEX = newTagRegex('svg', true); + //==================================================================== // Utilities //==================================================================== + /** + * @param {string} tag + * @param {boolean} global + * @returns {RegExp} + */ + function newTagRegex(tag, global = false) { + return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`, + global ? 'gim' : 'im'); + } + function parseInterval(str) { if (str == undefined) { return undefined @@ -282,38 +296,41 @@ return (function () { /** * - * @param {string} resp + * @param {string} response * @returns {Element} */ - function makeFragment(resp) { - var partialResponse = !aFullPageResponse(resp); + function makeFragment(response) { + var partialResponse = !aFullPageResponse(response); + var startTag = getStartTag(response); + var content = response; + if (startTag === 'head') { + content = content.replace(HEAD_TAG_REGEX, ''); + } if (htmx.config.useTemplateFragments && partialResponse) { - var documentFragment = parseHTML("", 0); + var documentFragment = parseHTML("", 0); // @ts-ignore type mismatch between DocumentFragment and Element. // TODO: Are these close enough for htmx to use interchangeably? return documentFragment.querySelector('template').content; - } else { - var startTag = getStartTag(resp); - switch (startTag) { - case "thead": - case "tbody": - case "tfoot": - case "colgroup": - case "caption": - return parseHTML("" + resp + "
", 1); - case "col": - return parseHTML("" + resp + "
", 2); - case "tr": - return parseHTML("" + resp + "
", 2); - case "td": - case "th": - return parseHTML("" + resp + "
", 3); - case "script": - case "style": - return parseHTML("
" + resp + "
", 1); - default: - return parseHTML(resp, 0); - } + } + switch (startTag) { + case "thead": + case "tbody": + case "tfoot": + case "colgroup": + case "caption": + return parseHTML("" + content + "
", 1); + case "col": + return parseHTML("" + content + "
", 2); + case "tr": + return parseHTML("" + content + "
", 2); + case "td": + case "th": + return parseHTML("" + content + "
", 3); + case "script": + case "style": + return parseHTML("
" + content + "
", 1); + default: + return parseHTML(content, 0); } } @@ -1097,9 +1114,8 @@ return (function () { function findTitle(content) { if (content.indexOf(' -1) { - var contentWithSvgsRemoved = content.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - var result = contentWithSvgsRemoved.match(/]*>|>)([\s\S]*?)<\/title>/im); - + var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, ''); + var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX); if (result) { return result[2]; } diff --git a/test/manual/hxboost_partial_template_parsing/index-partial.html b/test/manual/hxboost_partial_template_parsing/index-partial.html new file mode 100644 index 00000000..4ec55342 --- /dev/null +++ b/test/manual/hxboost_partial_template_parsing/index-partial.html @@ -0,0 +1,8 @@ + + Index content + +

# Index

+
    +
  • <title> should not spawn inside <main>
  • +
  • <title> should be index content
  • +
\ No newline at end of file diff --git a/test/manual/hxboost_partial_template_parsing/index.html b/test/manual/hxboost_partial_template_parsing/index.html new file mode 100644 index 00000000..dd8a93ff --- /dev/null +++ b/test/manual/hxboost_partial_template_parsing/index.html @@ -0,0 +1,41 @@ + + + + + + + Index content + + + + + + + +
+
+
+
+

# Index

+
    +
  • <title> should not spawn inside <main>
  • +
  • <title> should be index content
  • +
+
+ + \ No newline at end of file diff --git a/test/manual/hxboost_partial_template_parsing/other-content.html b/test/manual/hxboost_partial_template_parsing/other-content.html new file mode 100644 index 00000000..2c749abe --- /dev/null +++ b/test/manual/hxboost_partial_template_parsing/other-content.html @@ -0,0 +1,16 @@ + + Other content + + +

# Swapped content

+
    +
  • <title> and <style> + should not spawn inside <main>
  • +
  • <title> should be other content
  • +
  • Background should be green
  • +
+ diff --git a/test/manual/index.html b/test/manual/index.html index 99b25d65..19f41b5f 100644 --- a/test/manual/index.html +++ b/test/manual/index.html @@ -41,6 +41,7 @@ From 1f4903c213cc37f4d995c0585c2c01c850b0a5e9 Mon Sep 17 00:00:00 2001 From: Noa Aarts Date: Wed, 20 Dec 2023 23:38:25 +0100 Subject: [PATCH 2/5] rewrite the HX-Retarget header to use extended query selectors (#2017) --- src/htmx.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index e81d96a9..2bb58239 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -3504,7 +3504,11 @@ return (function () { } if (hasHeader(xhr,/HX-Retarget:/i)) { - responseInfo.target = getDocument().querySelector(xhr.getResponseHeader("HX-Retarget")); + if (xhr.getResponseHeader("HX-Retarget") === "this") { + responseInfo.target = elt; + } else { + responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget")); + } } var historyUpdate = determineHistoryUpdates(elt, responseInfo); From 078d5da5b4425bb779aedaad079b0643a24754cd Mon Sep 17 00:00:00 2001 From: Thomas Cowart Date: Wed, 20 Dec 2023 17:46:04 -0500 Subject: [PATCH 3/5] Update parseInterval to handle "0" correctly (#1835) * Update parseInterval to handle "0" correctly When a parameter like "0ms" is passed in to parseInterval it gets parsed to 0. Previously this would result in a return value of "undefined" because 0 is falsy and thus the `return 0 || undefined` statements return undefined. The purpose of the form `parseFloat(str) || undefined` was to return "undefined" if parseFloat failed (parseFloat returns NaN, a falsy value, if it can't parse its argument). Unfortunately, as mentioned, parseFloat can also succeed and return a falsy value -- when the argument is "0" (or "0.0", etc.). So the new code, rather than depending on the falsiness of the result of parseFloat, explicitly checks for a NaN. * Adds some semicolons Adds some semicolons to parseInterval (and tests) for consistency. * Add one more parseInterval test for "0" Adds test test to make sure parseInterval works on "0". * Adds functional tests for every, swap, settle, throttle, and delay * Explcitly check that setTimeout values are > 0 These values come from user settings that are read from parseInterval, so they could be a number or undefined. If the value being checked is > 0 setTimeout will be called with some associated function. If the value is 0 or 'undefined' the associated function will be called immediately ('undefined' is not greater than 0). * Change '!== undefined' to '> 0' `pollInterval !== undefined` is a subtly different conditional than just `pollInterval` or `pollInterval > 0` (which are equivalent). Changes the conditional to `pollInterval > 0` so as to not change the behavior but also be more explicit in the test. --- src/htmx.js | 28 ++++++------ test/attributes/hx-swap.js | 49 +++++++++++++++++++- test/attributes/hx-trigger.js | 84 ++++++++++++++++++++++++++++++++++- test/core/internals.js | 11 +++-- 4 files changed, 152 insertions(+), 20 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 2bb58239..7861ae84 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -148,18 +148,20 @@ return (function () { function parseInterval(str) { if (str == undefined) { - return undefined + return undefined; } + + let interval = NaN; if (str.slice(-2) == "ms") { - return parseFloat(str.slice(0,-2)) || undefined + interval = parseFloat(str.slice(0, -2)); + } else if (str.slice(-1) == "s") { + interval = parseFloat(str.slice(0, -1)) * 1000; + } else if (str.slice(-1) == "m") { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60; + } else { + interval = parseFloat(str); } - if (str.slice(-1) == "s") { - return (parseFloat(str.slice(0,-1)) * 1000) || undefined - } - if (str.slice(-1) == "m") { - return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined - } - return parseFloat(str) || undefined + return isNaN(interval) ? undefined : interval; } /** @@ -1538,14 +1540,14 @@ return (function () { return; } - if (triggerSpec.throttle) { + if (triggerSpec.throttle > 0) { if (!elementData.throttle) { handler(elt, evt); elementData.throttle = setTimeout(function () { elementData.throttle = null; }, triggerSpec.throttle); } - } else if (triggerSpec.delay) { + } else if (triggerSpec.delay > 0) { elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay); } else { triggerEvent(elt, 'htmx:trigger') @@ -1822,7 +1824,7 @@ return (function () { handler(elt); } } - if (delay) { + if (delay > 0) { setTimeout(load, delay); } else { load(); @@ -1881,7 +1883,7 @@ return (function () { if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) { loadImmediately(elt, handler, nodeData, triggerSpec.delay); } - } else if (triggerSpec.pollInterval) { + } else if (triggerSpec.pollInterval > 0) { nodeData.polling = true; processPolling(elt, handler, triggerSpec); } else { diff --git a/test/attributes/hx-swap.js b/test/attributes/hx-swap.js index dff1b040..4ff63621 100644 --- a/test/attributes/hx-swap.js +++ b/test/attributes/hx-swap.js @@ -197,27 +197,43 @@ describe("hx-swap attribute", function(){ swapSpec(make("
")).swapDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(0) // set to 0 in tests swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).swapDelay.should.equal(0) + swapSpec(make("
")).swapDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(10) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).settleDelay.should.equal(11) swapSpec(make("
")).settleDelay.should.equal(11) - + swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).swapDelay.should.equal(0); + swapSpec(make("
")).swapDelay.should.equal(0); swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).settleDelay.should.equal(10) - + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) + swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).swapDelay.should.equal(0) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapStyle.should.equal("innerHTML") swapSpec(make("
")).swapDelay.should.equal(10) swapSpec(make("
")).settleDelay.should.equal(11) + swapSpec(make("
")).swapDelay.should.equal(10) + swapSpec(make("
")).settleDelay.should.equal(0) swapSpec(make("
")).swapStyle.should.equal("customstyle") }) @@ -234,6 +250,17 @@ describe("hx-swap attribute", function(){ }, 30); }); + it("works immediately with no swap delay", function (done) { + this.server.respondWith("GET", "/test", "Clicked!"); + var div = make( + "
" + ); + div.click(); + this.server.respond(); + div.innerText.should.equal("Clicked!"); + done(); + }); + it('works with a settle delay', function(done) { this.server.respondWith("GET", "/test", "
"); var div = make("
"); @@ -246,6 +273,24 @@ describe("hx-swap attribute", function(){ }, 30); }); + it("works with no settle delay", function (done) { + this.server.respondWith( + "GET", + "/test", + "
" + ); + var div = make( + "
" + ); + div.click(); + this.server.respond(); + div.classList.contains("foo").should.equal(false); + setTimeout(function () { + byId("d1").classList.contains("foo").should.equal(true); + done(); + }, 30); + }); + it('swap outerHTML properly w/ data-* prefix', function() { this.server.respondWith("GET", "/test", 'Click Me'); diff --git a/test/attributes/hx-trigger.js b/test/attributes/hx-trigger.js index 9e88b59d..96d64eaa 100644 --- a/test/attributes/hx-trigger.js +++ b/test/attributes/hx-trigger.js @@ -211,14 +211,20 @@ describe("hx-trigger attribute", function(){ var specExamples = { "": [{trigger: 'click'}], "every 1s": [{trigger: 'every', pollInterval: 1000}], + "every 0s": [{trigger: 'every', pollInterval: 0}], + "every 0ms": [{trigger: 'every', pollInterval: 0}], "click": [{trigger: 'click'}], "customEvent": [{trigger: 'customEvent'}], "event changed": [{trigger: 'event', changed: true}], "event once": [{trigger: 'event', once: true}], - "event delay:1s": [{trigger: 'event', delay: 1000}], "event throttle:1s": [{trigger: 'event', throttle: 1000}], - "event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}], + "event throttle:0s": [{trigger: 'event', throttle: 0}], + "event throttle:0ms": [{trigger: 'event', throttle: 0}], "event throttle:1s, foo": [{trigger: 'event', throttle: 1000}, {trigger: 'foo'}], + "event delay:1s": [{trigger: 'event', delay: 1000}], + "event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}], + "event delay:0s, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}], + "event delay:0ms, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}], "event changed once delay:1s": [{trigger: 'event', changed: true, once: true, delay: 1000}], "event1,event2": [{trigger: 'event1'}, {trigger: 'event2'}], "event1, event2": [{trigger: 'event1'}, {trigger: 'event2'}], @@ -398,6 +404,18 @@ describe("hx-trigger attribute", function(){ }, 100); }) + // Don't actually do this! + it("polling works every 0ms", function (done) { + this.server.respondWith("GET", "/test", "Called!"); + var div = make('
Not Called
'); + this.server.autoRespond = true; + this.server.autoRespondAfter = 0; + setTimeout(function () { + div.innerHTML.should.equal("Called!"); + done(); + }, 100); + }); + it('bad condition issues error', function(){ this.server.respondWith("GET", "/test", "Called!"); var div = make('
Not Called
'); @@ -678,6 +696,37 @@ describe("hx-trigger attribute", function(){ }, 50); }); + it("A throttle of 0 does not multiple requests from happening", function (done) { + var requests = 0; + var server = this.server; + server.respondWith("GET", "/test", function (xhr) { + requests++; + xhr.respond(200, {}, "Requests: " + requests); + }); + server.respondWith("GET", "/bar", "bar"); + var div = make( + "
" + ); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 1"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 2"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 3"); + + div.click(); + server.respond(); + div.innerText.should.equal("Requests: 4"); + + done() + }); + it('delay delays the request', function(done) { var requests = 0; @@ -714,6 +763,37 @@ describe("hx-trigger attribute", function(){ }, 50); }); + it("A 0 delay does not delay the request", function (done) { + var requests = 0; + this.server.respondWith("GET", "/test", function (xhr) { + requests++; + xhr.respond(200, {}, "Requests: " + requests); + }); + this.server.respondWith("GET", "/bar", "bar"); + var div = make( + "
" + ); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 1"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 2"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 3"); + + div.click(); + this.server.respond(); + div.innerText.should.equal("Requests: 4"); + + done(); + }); + + it('requests are queued with last one winning by default', function() { var requests = 0; diff --git a/test/core/internals.js b/test/core/internals.js index 1de1fb98..3d689293 100644 --- a/test/core/internals.js +++ b/test/core/internals.js @@ -76,9 +76,14 @@ describe("Core htmx internals Tests", function() { it("handles parseInterval correctly", function() { chai.expect(htmx.parseInterval("1ms")).to.be.equal(1); chai.expect(htmx.parseInterval("300ms")).to.be.equal(300); - chai.expect(htmx.parseInterval("1s")).to.be.equal(1000) - chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500) - chai.expect(htmx.parseInterval("2s")).to.be.equal(2000) + chai.expect(htmx.parseInterval("1s")).to.be.equal(1000); + chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500); + chai.expect(htmx.parseInterval("2s")).to.be.equal(2000); + chai.expect(htmx.parseInterval("0ms")).to.be.equal(0); + chai.expect(htmx.parseInterval("0s")).to.be.equal(0); + chai.expect(htmx.parseInterval("0m")).to.be.equal(0); + chai.expect(htmx.parseInterval("0")).to.be.equal(0); + chai.expect(htmx.parseInterval("5")).to.be.equal(5); chai.expect(htmx.parseInterval(null)).to.be.undefined chai.expect(htmx.parseInterval("")).to.be.undefined From 126187fe8e643ed66be735682f6a63afed5eb4b3 Mon Sep 17 00:00:00 2001 From: Carson Gross Date: Wed, 20 Dec 2023 15:47:05 -0700 Subject: [PATCH 4/5] slight rename --- src/htmx.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 7861ae84..36dfd1ed 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -128,9 +128,9 @@ return (function () { return "[hx-" + verb + "], [data-hx-" + verb + "]" }).join(", "); - var HEAD_TAG_REGEX = newTagRegex('head'), - TITLE_TAG_REGEX = newTagRegex('title'), - SVG_TAGS_REGEX = newTagRegex('svg', true); + var HEAD_TAG_REGEX = makeTagRegEx('head'), + TITLE_TAG_REGEX = makeTagRegEx('title'), + SVG_TAGS_REGEX = makeTagRegEx('svg', true); //==================================================================== // Utilities @@ -141,7 +141,7 @@ return (function () { * @param {boolean} global * @returns {RegExp} */ - function newTagRegex(tag, global = false) { + function makeTagRegEx(tag, global = false) { return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`, global ? 'gim' : 'im'); } From b4080e71da1500ae986b02010d8238154954378b Mon Sep 17 00:00:00 2001 From: Carson Gross Date: Wed, 20 Dec 2023 15:48:31 -0700 Subject: [PATCH 5/5] remove psychotic test --- test/attributes/hx-trigger.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/attributes/hx-trigger.js b/test/attributes/hx-trigger.js index 96d64eaa..f65f974b 100644 --- a/test/attributes/hx-trigger.js +++ b/test/attributes/hx-trigger.js @@ -404,18 +404,6 @@ describe("hx-trigger attribute", function(){ }, 100); }) - // Don't actually do this! - it("polling works every 0ms", function (done) { - this.server.respondWith("GET", "/test", "Called!"); - var div = make('
Not Called
'); - this.server.autoRespond = true; - this.server.autoRespondAfter = 0; - setTimeout(function () { - div.innerHTML.should.equal("Called!"); - done(); - }, 100); - }); - it('bad condition issues error', function(){ this.server.respondWith("GET", "/test", "Called!"); var div = make('
Not Called
');