From 7ee14aaf335648a6990243d5c4741cdba7e21240 Mon Sep 17 00:00:00 2001 From: t31a Date: Thu, 27 Aug 2026 13:06:42 -0300 Subject: [PATCH] fix: escape IDs in restorePreservedElements, selectOOB, and anchor scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites build a CSS selector from a raw element id without CSS.escape(), causing failures when the id contains CSS-special characters (dots, slashes, colons, etc.). - restorePreservedElements: find('#' + preservedElt.id) breaks on the moveBefore path (Chrome) with dotted ids — the selector parses as id + class, matches nothing, throws TypeError on .parentNode. - selectOOB: fragment.querySelector('#' + id) — same pattern. - anchor scroll: resolveTarget('#' + swapOptions.anchor) — same pattern. The sibling call sites in oobSwap (fixed by #3304) and handleAttributes (fixed by #3752) already use CSS.escape() correctly. --- src/htmx.js | 6 +++--- test/attributes/hx-preserve.js | 10 ++++++++++ test/attributes/hx-select-oob.js | 9 +++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index e574f31f4..0f8ed07eb 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1518,7 +1518,7 @@ var htmx = (function() { const pantry = find('#--htmx-preserve-pantry--') if (pantry) { for (const preservedElt of [...pantry.children]) { - const existingElement = find('#' + preservedElt.id) + const existingElement = find('#' + CSS.escape(preservedElt.id)) // @ts-ignore - use proposed moveBefore feature existingElement.parentNode.moveBefore(preservedElt, existingElement) existingElement.remove() @@ -1926,7 +1926,7 @@ var htmx = (function() { id = id.substring(1) } const oobValue = oobSelectValue[1] || 'true' - const oobElement = fragment.querySelector('#' + id) + const oobElement = fragment.querySelector('#' + CSS.escape(id)) if (oobElement) { oobSwap(oobValue, oobElement, settleInfo, rootNode) } @@ -2001,7 +2001,7 @@ var htmx = (function() { }) if (swapOptions.anchor) { - const anchorTarget = asElement(resolveTarget('#' + swapOptions.anchor)) + const anchorTarget = asElement(resolveTarget('#' + CSS.escape(swapOptions.anchor))) if (anchorTarget) { anchorTarget.scrollIntoView({ block: 'start', behavior: 'auto' }) } diff --git a/test/attributes/hx-preserve.js b/test/attributes/hx-preserve.js index 7a7db2722..49c74d9b0 100644 --- a/test/attributes/hx-preserve.js +++ b/test/attributes/hx-preserve.js @@ -76,4 +76,14 @@ describe('hx-preserve attribute', function() { htmx._('handlePreservedElements')(fragment) fragment.firstChild.innerHTML.should.equal('Old Content') }) + + it('handles hx-preserve on elements with dotted IDs', function() { + this.server.respondWith('GET', '/test', "
New Content
New Content
") + var div = make("
Old Content
Old Content
") + div.click() + this.server.respond() + var preserved = document.querySelector('[id="d1.sub"]') + preserved.innerHTML.should.equal('Old Content') + byId('d2').innerHTML.should.equal('New Content') + }) }) diff --git a/test/attributes/hx-select-oob.js b/test/attributes/hx-select-oob.js index 9751b081a..f99e32a79 100644 --- a/test/attributes/hx-select-oob.js +++ b/test/attributes/hx-select-oob.js @@ -45,4 +45,13 @@ describe('hx-select-oob attribute', function() { var div2 = byId('d2') div2.innerHTML.should.equal('') }) + + it('handles elements with IDs containing dots in hx-select-oob', function() { + this.server.respondWith('GET', '/test', "Normal Content
New oob Content
") + var div1 = make("
Click Me!
") + make("
Old Content
") + div1.click() + this.server.respond() + byId('d3').innerHTML.should.equal('New oob Content') + }) })