Skip to content

Commit

Permalink
fix for hx-swab-oob within web components (#2846)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
workjonathan authored Oct 3, 2024
1 parent 8c65826 commit 99285cd
Show file tree
Hide file tree
Showing 2 changed files with 98 additions and 7 deletions.
18 changes: 11 additions & 7 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
87 changes: 87 additions & 0 deletions test/attributes/hx-swap-oob.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<div hx-swap-oob="innerHTML:#oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="next div">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should get swapped</div>
`
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(`<div><div id="oob-swap-target">this should not get swapped</div><${elementName}/></div>`)
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', '<div hx-swap-oob="innerHTML:#oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="global #main-target">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should get swapped</div>
`
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(`<div><div id="main-target"></div><div id="oob-swap-target">this should not get swapped</div><${elementName}/></div>`)
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', '<div hx-swap-oob="innerHTML:global #oob-swap-target">new contents</div>Clicked')
class TestElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
root.innerHTML = `
<button hx-get="/test" hx-target="next div">Click me!</button>
<div id="main-target"></div>
<div id="oob-swap-target">this should not get swapped</div>
`
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(`<div><div id="main-target"></div><div id="oob-swap-target">this should get swapped</div><${elementName}/></div>`)
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')
})
}
})

0 comments on commit 99285cd

Please sign in to comment.