Summary
hx-swap="… show:top showTarget:'closest li'" throws TypeError: document.closest is not a function (minified: o.closest is not a function) in htmx 4.0.0 — the swap lands, htmx:error fires, and nothing scrolls. The same holds for scrollTarget:'closest …'. The four branch docs advertise exactly this form (www/src/content/reference/01-attributes/08-hx-swap.md lines 257 and 280):
<div hx-swap="innerHTML scroll:top scrollTarget:'closest .container'"></div>
<div hx-swap="innerHTML show:top showTarget:'closest .container'"></div>
An id selector (showTarget:#row) works. Still present on four-dev @ 372c6e3.
Cause
__handleScroll resolves both targets with a single argument:
let scrollTarget = swapSpec.scrollTarget ? this.__findExt(swapSpec.scrollTarget) : target;
…
let showTarget = swapSpec.showTarget ? this.__findExt(swapSpec.showTarget) : target;
and __findAllExt(eltOrSelector, maybeSelector, …) treats a one-argument call as "no context":
let selector = maybeSelector ?? eltOrSelector;
let elt = maybeSelector ? (this.__normalizeElement(eltOrSelector) || document.body) : document;
…
if (selector.startsWith('closest ')) {
item = elt.closest(selector.slice(8)) // elt is document here → TypeError
So every relative form (closest, next, previous, find, this) resolves against document rather than an element. A fix would be to pass the swap target (or the source element — the docs don't say which the selector is relative to) as the context: this.__findExt(target, swapSpec.showTarget). test/tests/attributes/hx-swap.js has no showTarget/scrollTarget case, which is presumably how this slipped through; #3880 guarded the scrollIntoView call but not the lookup.
Reproduction
Headless Chromium (Playwright) against https://unpkg.com/htmx.org@4.0.0/dist/htmx.min.js, /fragment answered with <p>swapped</p>:
<div style="height:1500px"></div>
<ul><li id="row">
<div id="a" hx-get="/fragment" hx-trigger="go" hx-swap="innerHTML show:top showTarget:'closest li'">relative showTarget</div>
</li></ul>
<ul><li id="row2">
<div id="b" hx-get="/fragment" hx-trigger="go" hx-swap="innerHTML show:top showTarget:#row2">id showTarget</div>
</li></ul>
htmx.trigger(a, "go") → content swapped, scrollY stays 0, and:
htmx: htmx:error: o.closest is not a function TypeError: o.closest is not a function
at #be (htmx.min.js:1:29775)
at #Be (htmx.min.js:1:31075)
…
at Object.swap (htmx.min.js:1:20350)
htmx.trigger(b, "go") → content swapped, scrollY 872, no error. (htmx.version → 4.0.0.)
Summary
hx-swap="… show:top showTarget:'closest li'"throwsTypeError: document.closest is not a function(minified:o.closest is not a function) in htmx 4.0.0 — the swap lands,htmx:errorfires, and nothing scrolls. The same holds forscrollTarget:'closest …'. Thefourbranch docs advertise exactly this form (www/src/content/reference/01-attributes/08-hx-swap.mdlines 257 and 280):An id selector (
showTarget:#row) works. Still present onfour-dev@ 372c6e3.Cause
__handleScrollresolves both targets with a single argument:and
__findAllExt(eltOrSelector, maybeSelector, …)treats a one-argument call as "no context":So every relative form (
closest,next,previous,find,this) resolves againstdocumentrather than an element. A fix would be to pass the swap target (or the source element — the docs don't say which the selector is relative to) as the context:this.__findExt(target, swapSpec.showTarget).test/tests/attributes/hx-swap.jshas noshowTarget/scrollTargetcase, which is presumably how this slipped through; #3880 guarded thescrollIntoViewcall but not the lookup.Reproduction
Headless Chromium (Playwright) against
https://unpkg.com/htmx.org@4.0.0/dist/htmx.min.js,/fragmentanswered with<p>swapped</p>:htmx.trigger(a, "go")→ content swapped,scrollYstays 0, and:htmx.trigger(b, "go")→ content swapped,scrollY872, no error. (htmx.version→4.0.0.)