#8229·swiper

elementTransitionEnd uses {once: true} with a bubble guard, so a descendant's transitionend silently consumes the listener

Author: lbwexlerCreated Sep 4, 2026Updated Sep 4, 2026
Labelst0ggles

shared/utils.mjs (v14.0.0 through 14.2.0):

javascript
function elementTransitionEnd(el, callback) {
    if (!callback) return;
    el.addEventListener('transitionend', function fireCallBack(e) {
        if (e.target !== el) return;
        callback.call(el, e);
    }, {once: true});
}

transitionend bubbles - which is why the e.target !== el guard exists. But {once: true} removes the listener when it is invoked, before the guard runs. So a transitionend bubbling up from any descendant destroys the listener, and the element's own transition end is never observed. Swiper leaves transition-property at its default all, so the competing descendant is often Swiper's own .swiper-slide-shadow* element, which setTransition gives the same duration.

v12 was immune: it removed the listener only on a matching event, and added a fresh one on every setTransition without removing non-matching ones, so multiple live listeners could each complete the transition.

javascript
// v12
function fireCallBack(e) {
    if (e.target !== el) return;
    callback.call(el, e);
    el.removeEventListener('transitionend', fireCallBack);
}

Impact: transitionEnd is never emitted, animating stays true, and the leftover shadow element is never cleaned up. It is fatal for effect: 'creative', which forces virtualTranslate: true - there the synthetic event from effectVirtualTransitionEnd is the only producer of transitionEnd, so missing it wedges the instance permanently (we saw programmatic slidePrev() silently rejected forever after, because allowSlidePrev is only recomputed on the render driven by that event).

Repro: a creative-effect Swiper (or any Swiper with a transitioning descendant inside a slide) - transition once, then check swiper.animating; it stays true and no transitionEnd fires.

Suggested fix: drop once and remove the listener inside the guard, as in v12; or add {once: false} plus an explicit removeEventListener after the matching call.

Verified present in 14.0.5, 14.0.6, 14.0.7, 14.1.0, 14.2.0. Downstream report: https://github.com/xh/hoist-react/issues/4559