From b2c14fffd0bbb518bd3fc3787058bd6bd0d645fb Mon Sep 17 00:00:00 2001 From: Sam Anglin Date: Fri, 21 Aug 2026 18:07:08 +0300 Subject: [PATCH 1/2] Only run callback if element exists --- js/src/base-component.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/js/src/base-component.js b/js/src/base-component.js index b28d8be33813..26b3762890c2 100644 --- a/js/src/base-component.js +++ b/js/src/base-component.js @@ -47,7 +47,16 @@ class BaseComponent extends Config { // Private _queueCallback(callback, element, isAnimated = true) { - executeAfterTransition(callback, element, isAnimated) + executeAfterTransition(() => { + // `dispose()` nulls every property of the instance, but it cannot cancel a + // callback already queued on a transition. Skip it rather than let it run + // against a disposed instance and throw + if (!this._element) { + return + } + + callback() + }, element, isAnimated) } _getConfig(config) { From 69acbb7f25f30443e2c0e5ec62961d758a954579 Mon Sep 17 00:00:00 2001 From: Sam Anglin Date: Fri, 21 Aug 2026 18:07:21 +0300 Subject: [PATCH 2/2] Add tests to catch regression --- js/tests/unit/tooltip.spec.js | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index 37f2c230d037..0d021c2a10a5 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -443,6 +443,65 @@ describe('Tooltip', () => { tooltip2.dispose() expect(tooltipWithoutTitleEl.getAttribute('title')).toBeNull() }) + + it('should not run a callback queued by hide() when disposed mid-transition', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + const hiddenSpy = jasmine.createSpy('hidden') + const errorSpy = jasmine.createSpy('error') + + tooltipEl.addEventListener('hidden.bs.tooltip', hiddenSpy) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + window.addEventListener('error', errorSpy) + + // `hide()` queues its completion callback on the fade transition, + // `dispose()` then nulls every property before that callback runs + tooltip.hide() + tooltip.dispose() + + setTimeout(() => { + window.removeEventListener('error', errorSpy) + + expect(errorSpy).not.toHaveBeenCalled() + expect(hiddenSpy).not.toHaveBeenCalled() + resolve() + }, 50) + }) + + tooltip.show() + }) + }) + + it('should not run a callback queued by show() when disposed mid-transition', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + const shownSpy = jasmine.createSpy('shown') + const errorSpy = jasmine.createSpy('error') + + tooltipEl.addEventListener('shown.bs.tooltip', shownSpy) + window.addEventListener('error', errorSpy) + + tooltip.show() + tooltip.dispose() + + setTimeout(() => { + window.removeEventListener('error', errorSpy) + + expect(errorSpy).not.toHaveBeenCalled() + expect(shownSpy).not.toHaveBeenCalled() + // the queued callback must not write back onto the disposed instance + expect(tooltip._isHovered).toBeNull() + resolve() + }, 50) + }) + }) }) describe('show', () => {