From 94b4f0ca14a06c69887d7d93a3f9675f126c6398 Mon Sep 17 00:00:00 2001 From: MohamedElfeky1 Date: Sun, 16 Aug 2026 19:50:15 +0300 Subject: [PATCH] Fix(collapse): prevent race condition on rapid accordion clicks Fix an issue where rapidly clicking different accordion toggle buttons can cause multiple accordion items to remain open simultaneously. When multiple active children exist within an accordion parent (such as one resting and another transitioning), checking only activeChildren[0]._isTransitioning failed if the transitioning element appeared later in DOM order. Tracking expanding state and inspecting all active children prevents subsequent show() invocations while transitions are in flight. Fixes #41883 --- js/src/collapse.js | 8 +++- js/tests/unit/collapse.spec.js | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/js/src/collapse.js b/js/src/collapse.js index b308863f468f..983ea67318b0 100644 --- a/js/src/collapse.js +++ b/js/src/collapse.js @@ -39,7 +39,7 @@ const CLASS_NAME_HORIZONTAL = 'collapse-horizontal' const WIDTH = 'width' const HEIGHT = 'height' -const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing' +const SELECTOR_ACTIVES = '.collapse.show, .collapsing' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]' const Default = { @@ -61,6 +61,7 @@ class Collapse extends BaseComponent { super(element, config) this._isTransitioning = false + this._isExpanding = false this._triggerArray = [] const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE) @@ -120,9 +121,10 @@ class Collapse extends BaseComponent { activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES) .filter(element => element !== this._element) .map(element => Collapse.getOrCreateInstance(element, { toggle: false })) + .filter(activeInstance => !this._triggerArray.some(trigger => activeInstance._triggerArray.includes(trigger))) } - if (activeChildren.length && activeChildren[0]._isTransitioning) { + if (activeChildren.some(activeChild => activeChild._isTransitioning && activeChild._isExpanding)) { return } @@ -144,9 +146,11 @@ class Collapse extends BaseComponent { this._addAriaAndCollapsedClass(this._triggerArray, true) this._isTransitioning = true + this._isExpanding = true const complete = () => { this._isTransitioning = false + this._isExpanding = false this._element.classList.remove(CLASS_NAME_COLLAPSING) this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW) diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js index 58c5367526b9..73ea9b35eede 100644 --- a/js/tests/unit/collapse.spec.js +++ b/js/tests/unit/collapse.spec.js @@ -202,6 +202,36 @@ describe('Collapse', () => { expect(spy).not.toHaveBeenCalled() }) + it('should do nothing if any active sibling is transitioning', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
', + '
' + ].join('') + + const collapseEl1 = fixtureEl.querySelector('#collapse1') + const collapseEl2 = fixtureEl.querySelector('#collapse2') + const collapseEl3 = fixtureEl.querySelector('#collapse3') + + const collapse1 = new Collapse(collapseEl1, { toggle: false }) + const collapse2 = new Collapse(collapseEl2, { toggle: false }) + const collapse3 = new Collapse(collapseEl3, { toggle: false }) + + collapse1._isTransitioning = false + collapse2._isTransitioning = true + collapse2._isExpanding = true + + const spy = spyOn(EventHandler, 'trigger').and.callThrough() + + collapse3.show() + + expect(spy).not.toHaveBeenCalled() + expect(collapse3._isTransitioning).toBeFalse() + expect(collapseEl3).not.toHaveClass('show') + }) + it('should show a collapsed element', () => { return new Promise(resolve => { fixtureEl.innerHTML = '
' @@ -941,6 +971,49 @@ describe('Collapse', () => { trigger3.click() }) }) + + it('should not allow multiple items to open when triggers are clicked rapidly in succession', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + ' ', + '
', + '
', + '
', + ' ', + '
', + '
', + '
', + ' ', + '
', + '
', + '
' + ].join('') + + const btn1 = fixtureEl.querySelector('#btn1') + const btn2 = fixtureEl.querySelector('#btn2') + const btn3 = fixtureEl.querySelector('#btn3') + const collapse1 = fixtureEl.querySelector('#collapse1') + const collapse2 = fixtureEl.querySelector('#collapse2') + const collapse3 = fixtureEl.querySelector('#collapse3') + + collapse3.addEventListener('shown.bs.collapse', () => { + setTimeout(() => { + const shownCollapses = fixtureEl.querySelectorAll('.collapse.show') + expect(shownCollapses.length).toEqual(1) + expect(collapse3).toHaveClass('show') + expect(collapse1).not.toHaveClass('show') + expect(collapse2).not.toHaveClass('show') + resolve() + }, 10) + }) + + btn3.click() + btn2.click() + btn1.click() + }) + }) }) describe('jQueryInterface', () => {