Skip to content

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

Description

@mecampbellsoup

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:

  1. Click an element with hx-get targeting #content, whose response includes <div hx-swap-oob="beforeend:#tabs">…</div>.
  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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions