bigskysoftware / bigskysoftware/htmx
hx-swap-oob fragments are silently dropped when the triggering element is detached before the response arrives
- Dominant language
- JavaScript
- Stars
- 49.4k
- Forks
- 1.7k
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 30
Description
**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()`:
```js
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:
1. Click an element with `hx-get` targeting `#content`, whose response includes `
2. Before the response arrives, replace the clicked element's parent region (`list.innerHTML = list.innerHTML` reproduces it deterministically with a delayed response).
3. 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:
```js
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.
Contributor guide
Research direction
Start in src/htmx.js at swap(), where handleAjaxResponse supplies contextElement, and trace oobSwap() through querySelectorAllExt(). Reproduce the delayed-response detached-trigger scenario described; done means the document-level #tabs OOB fragment is applied while connected-trigger shadow-root behavior remains unchanged, with a regression test for detachment.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 82/100