Version: 2.0.7 (mechanism unchanged in 2.0.9 — src/htmx.js is identical in the relevant functions)
Summary
When the element that issued a request is removed from the DOM before the response lands, every hx-swap-oob fragment in that response is discarded — even though its target exists in the live document. The main swap still applies (its target resolved at request time), so the page ends up half-updated with only a console htmx:oobErrorNoTarget marking the loss.
Mechanism
The rootNode threading was introduced in #2846 to make OOB swaps work inside shadow roots — this report is about an unintended consequence for a case that PR didn't consider. In swap():
const rootNode = swapOptions.contextElement ? getRootNode(swapOptions.contextElement, false) : getDocument()
handleAjaxResponse passes the triggering element as contextElement. For a detached element, getRootNode() returns the orphaned subtree's own root — not the document — so oobSwap()'s querySelectorAllExt(rootNode, selector, false) finds nothing and the fragment is dropped. composed: true (the global selector modifier) doesn't help: a detached element's composed root is still itself, since composed only crosses shadow boundaries.
Reproduction
Any UI where a region containing the trigger can re-render while a request is in flight — e.g. a row list that's replaced by one interaction while a row's own hx-get is pending:
- Click an element with
hx-get targeting #content, whose response includes <div hx-swap-oob="beforeend:#tabs">…</div>.
- Before the response arrives, replace the clicked element's parent region (
list.innerHTML = list.innerHTML reproduces it deterministically with a delayed response).
- The
#content swap lands; the #tabs fragment is dropped with htmx:oobErrorNoTarget, though #tabs is present in the document throughout.
We hit this in production as a ~15%-under-load failure (a resource panel rendering its document with no tab-strip entry) and confirmed the mechanism with a forced-detach harness: detachment alone flips the outcome, 5/5 each way.
Expected
An OOB fragment addresses the document the user is looking at. The trigger element's identity shouldn't decide whether an unrelated, present target is findable — especially since the drop is silent apart from a console error, and the same response's main swap still applies.
Suggested fix
Falling back to the document when the context element is disconnected preserves the shadow-DOM behavior #2846 added for live triggers:
const rootNode = (swapOptions.contextElement && swapOptions.contextElement.isConnected)
? getRootNode(swapOptions.contextElement, false)
: getDocument()
We ran this patch against the forced-detach harness and a 20× contention battery: the drop disappears; connected-trigger behavior (including shadow-root resolution) is untouched. Happy to open a PR against dev with this and a regression test if the approach looks right.
Version: 2.0.7 (mechanism unchanged in 2.0.9 —
src/htmx.jsis identical in the relevant functions)Summary
When the element that issued a request is removed from the DOM before the response lands, every
hx-swap-oobfragment in that response is discarded — even though its target exists in the live document. The main swap still applies (its target resolved at request time), so the page ends up half-updated with only a consolehtmx:oobErrorNoTargetmarking the loss.Mechanism
The
rootNodethreading was introduced in #2846 to make OOB swaps work inside shadow roots — this report is about an unintended consequence for a case that PR didn't consider. Inswap():handleAjaxResponsepasses the triggering element ascontextElement. For a detached element,getRootNode()returns the orphaned subtree's own root — not the document — sooobSwap()'squerySelectorAllExt(rootNode, selector, false)finds nothing and the fragment is dropped.composed: true(theglobalselector modifier) doesn't help: a detached element's composed root is still itself, sincecomposedonly crosses shadow boundaries.Reproduction
Any UI where a region containing the trigger can re-render while a request is in flight — e.g. a row list that's replaced by one interaction while a row's own
hx-getis pending:hx-gettargeting#content, whose response includes<div hx-swap-oob="beforeend:#tabs">…</div>.list.innerHTML = list.innerHTMLreproduces it deterministically with a delayed response).#contentswap lands; the#tabsfragment is dropped withhtmx:oobErrorNoTarget, though#tabsis present in the document throughout.We hit this in production as a ~15%-under-load failure (a resource panel rendering its document with no tab-strip entry) and confirmed the mechanism with a forced-detach harness: detachment alone flips the outcome, 5/5 each way.
Expected
An OOB fragment addresses the document the user is looking at. The trigger element's identity shouldn't decide whether an unrelated, present target is findable — especially since the drop is silent apart from a console error, and the same response's main swap still applies.
Suggested fix
Falling back to the document when the context element is disconnected preserves the shadow-DOM behavior #2846 added for live triggers:
We ran this patch against the forced-detach harness and a 20× contention battery: the drop disappears; connected-trigger behavior (including shadow-root resolution) is untouched. Happy to open a PR against
devwith this and a regression test if the approach looks right.