bigskysoftware / bigskysoftware/htmx

hx-swap-oob fragments are silently dropped when the triggering element is detached before the response arrives

Open Beginner friendly
#3,959 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.