From 99285cd5c3785fcdf760b0b9983997ce621bad23 Mon Sep 17 00:00:00 2001 From: Jonathan <61032310+workjonathan@users.noreply.github.com> Date: Wed, 2 Oct 2024 20:44:41 -0500 Subject: [PATCH] fix for hx-swab-oob within web components (#2846) * Failing test for oob-swap within web components * hx-swap-oob respects shadow roots * Lint and type fixes * fix jsdoc types for rootNode parameter * Fix for linter issue I was confused about before * oob swaps handle global correctly * swap uses contextElement if available, document if not Previous a commit made swapOptions.contextElement a required field. This could have harmful ramifications for extensions and users, so instead, the old behavior of assuming document as a root will be used if the contextElement is not provided. * rootNode parameter is optional in oobSwap If not provided, it will fall back to using document as rootNode. jsdocs have been updated for oobSwap and findAndSwapElements accordingly. --- src/htmx.js | 18 ++++--- test/attributes/hx-swap-oob.js | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 0bdac100..04faaea0 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -1405,9 +1405,11 @@ var htmx = (function() { * @param {string} oobValue * @param {Element} oobElement * @param {HtmxSettleInfo} settleInfo + * @param {Node|Document} [rootNode] * @returns */ - function oobSwap(oobValue, oobElement, settleInfo) { + function oobSwap(oobValue, oobElement, settleInfo, rootNode) { + rootNode = rootNode || getDocument() let selector = '#' + getRawAttribute(oobElement, 'id') /** @type HtmxSwapStyle */ let swapStyle = 'outerHTML' @@ -1422,7 +1424,7 @@ var htmx = (function() { oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') - const targets = getDocument().querySelectorAll(selector) + const targets = querySelectorAllExt(rootNode, selector, false) if (targets) { forEach( targets, @@ -1807,14 +1809,15 @@ var htmx = (function() { /** * @param {DocumentFragment} fragment * @param {HtmxSettleInfo} settleInfo + * @param {Node|Document} [rootNode] */ - function findAndSwapOobElements(fragment, settleInfo) { + function findAndSwapOobElements(fragment, settleInfo, rootNode) { var oobElts = findAll(fragment, '[hx-swap-oob], [data-hx-swap-oob]') forEach(oobElts, function(oobElement) { if (htmx.config.allowNestedOobSwaps || oobElement.parentElement === null) { const oobValue = getAttributeValue(oobElement, 'hx-swap-oob') if (oobValue != null) { - oobSwap(oobValue, oobElement, settleInfo) + oobSwap(oobValue, oobElement, settleInfo, rootNode) } } else { oobElement.removeAttribute('hx-swap-oob') @@ -1838,6 +1841,7 @@ var htmx = (function() { } target = resolveTarget(target) + const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument() // preserve focus and selection const activeElt = document.activeElement @@ -1876,14 +1880,14 @@ var htmx = (function() { const oobValue = oobSelectValue[1] || 'true' const oobElement = fragment.querySelector('#' + id) if (oobElement) { - oobSwap(oobValue, oobElement, settleInfo) + oobSwap(oobValue, oobElement, settleInfo, rootNode) } } } // oob swaps - findAndSwapOobElements(fragment, settleInfo) + findAndSwapOobElements(fragment, settleInfo, rootNode) forEach(findAll(fragment, 'template'), /** @param {HTMLTemplateElement} template */function(template) { - if (findAndSwapOobElements(template.content, settleInfo)) { + if (findAndSwapOobElements(template.content, settleInfo, rootNode)) { // Avoid polluting the DOM with empty templates that were only used to encapsulate oob swap template.remove() } diff --git a/test/attributes/hx-swap-oob.js b/test/attributes/hx-swap-oob.js index 327d583d..e7f54a45 100644 --- a/test/attributes/hx-swap-oob.js +++ b/test/attributes/hx-swap-oob.js @@ -260,4 +260,91 @@ describe('hx-swap-oob attribute', function() { byId('td1').innerHTML.should.equal('hey') }) } + for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) { + it('handles oob target in web components with both inside shadow root and config ' + JSON.stringify(config), function() { + this.server.respondWith('GET', '/test', '
new contents
Clicked') + class TestElement extends HTMLElement { + connectedCallback() { + const root = this.attachShadow({ mode: 'open' }) + root.innerHTML = ` + +
+
this should get swapped
+ ` + htmx.process(root) // Tell HTMX about this component's shadow DOM + } + } + var elementName = 'test-oobswap-inside-' + config.allowNestedOobSwaps + customElements.define(elementName, TestElement) + var div = make(`
this should not get swapped
<${elementName}/>
`) + var badTarget = div.querySelector('#oob-swap-target') + var webComponent = div.querySelector(elementName) + var btn = webComponent.shadowRoot.querySelector('button') + var goodTarget = webComponent.shadowRoot.querySelector('#oob-swap-target') + var mainTarget = webComponent.shadowRoot.querySelector('#main-target') + btn.click() + this.server.respond() + should.equal(mainTarget.textContent, 'Clicked') + should.equal(goodTarget.textContent, 'new contents') + should.equal(badTarget.textContent, 'this should not get swapped') + }) + } + for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) { + it('handles oob target in web components with main target outside web component config ' + JSON.stringify(config), function() { + this.server.respondWith('GET', '/test', '
new contents
Clicked') + class TestElement extends HTMLElement { + connectedCallback() { + const root = this.attachShadow({ mode: 'open' }) + root.innerHTML = ` + +
+
this should get swapped
+ ` + htmx.process(root) // Tell HTMX about this component's shadow DOM + } + } + var elementName = 'test-oobswap-global-main-' + config.allowNestedOobSwaps + customElements.define(elementName, TestElement) + var div = make(`
this should not get swapped
<${elementName}/>
`) + var badTarget = div.querySelector('#oob-swap-target') + var webComponent = div.querySelector(elementName) + var btn = webComponent.shadowRoot.querySelector('button') + var goodTarget = webComponent.shadowRoot.querySelector('#oob-swap-target') + var mainTarget = div.querySelector('#main-target') + btn.click() + this.server.respond() + should.equal(mainTarget.textContent, 'Clicked') + should.equal(goodTarget.textContent, 'new contents') + should.equal(badTarget.textContent, 'this should not get swapped') + }) + } + for (const config of [{ allowNestedOobSwaps: true }, { allowNestedOobSwaps: false }]) { + it('handles global oob target in web components with main target inside web component config ' + JSON.stringify(config), function() { + this.server.respondWith('GET', '/test', '
new contents
Clicked') + class TestElement extends HTMLElement { + connectedCallback() { + const root = this.attachShadow({ mode: 'open' }) + root.innerHTML = ` + +
+
this should not get swapped
+ ` + htmx.process(root) // Tell HTMX about this component's shadow DOM + } + } + var elementName = 'test-oobswap-global-oob-' + config.allowNestedOobSwaps + customElements.define(elementName, TestElement) + var div = make(`
this should get swapped
<${elementName}/>
`) + var webComponent = div.querySelector(elementName) + var badTarget = webComponent.shadowRoot.querySelector('#oob-swap-target') + var btn = webComponent.shadowRoot.querySelector('button') + var goodTarget = div.querySelector('#oob-swap-target') + var mainTarget = webComponent.shadowRoot.querySelector('#main-target') + btn.click() + this.server.respond() + should.equal(mainTarget.textContent, 'Clicked') + should.equal(goodTarget.textContent, 'new contents') + should.equal(badTarget.textContent, 'this should not get swapped') + }) + } })