From 77480290dcc524e429be51f5fdcff11e970d6739 Mon Sep 17 00:00:00 2001 From: Bartosz-Do Date: Fri, 25 Sep 2026 12:10:58 +0200 Subject: [PATCH] Fix `complete.bs.countup` firing twice when `update()` runs before the element is in view (#3193) --- .../countup-duplicate-complete-event.md | 5 +++ core/js/src/countup.ts | 22 ++++++---- core/js/tests/unit/countup.spec.ts | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 .changeset/countup-duplicate-complete-event.md diff --git a/.changeset/countup-duplicate-complete-event.md b/.changeset/countup-duplicate-complete-event.md new file mode 100644 index 000000000..55d217494 --- /dev/null +++ b/.changeset/countup-duplicate-complete-event.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed CountUp firing `complete.bs.countup` twice when `update()` ran before it scrolled into view. diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index a63fac804..ed9289518 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -147,6 +147,7 @@ class CountUp extends BaseComponent { _elapsed = 0 _running = false _paused = false + _started = false constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) @@ -175,13 +176,7 @@ class CountUp extends BaseComponent { return } - this._observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - this._observer?.disconnect() - this._observer = null - this.start() - } - }) + this._observer = new IntersectionObserver((entries) => this._observerCallback(entries)) this._observer.observe(this._element) } @@ -253,8 +248,21 @@ class CountUp extends BaseComponent { return super._mergeConfigObj({ ...coerceOptions(dataOptions), ...config }, element) } + _observerCallback(entries: IntersectionObserverEntry[]): void { + if (entries.some((entry) => entry.isIntersecting)) { + this._observer?.disconnect() + this._observer = null + + // update() may have already animated it while it was off screen. + if (!this._started) { + this.start() + } + } + } + _animate(from: number, to: number): void { this._stop() + this._started = true // Users who asked for less motion get the final number at once. if (from === to || this._config.duration <= 0 || reducedMotion()) { diff --git a/core/js/tests/unit/countup.spec.ts b/core/js/tests/unit/countup.spec.ts index c811f30ab..d6f087d8f 100644 --- a/core/js/tests/unit/countup.spec.ts +++ b/core/js/tests/unit/countup.spec.ts @@ -113,6 +113,48 @@ describe('CountUp', () => { expect(element().textContent).toBe('77') }) + + it('should not fire complete.bs.countup a second time when the element scrolls into view after update()', () => { + const spy = vi.fn() + element().addEventListener('complete.bs.countup', spy) + + const instance = new CountUp(element(), { duration: 0 }) + instance.update(77) + expect(spy).toHaveBeenCalledTimes(1) + + instance._observerCallback([{ isIntersecting: true } as unknown as IntersectionObserverEntry]) + + expect(spy).toHaveBeenCalledTimes(1) + expect(element().textContent).toBe('77') + }) + + it('should not fire complete.bs.countup twice when update() ran off screen and it then really scrolls into view', async () => { + const spy = vi.fn() + const off = document.createElement('h1') + off.dataset.countup = '{"duration":0.1}' + off.textContent = '500' + off.style.position = 'absolute' + off.style.top = '-10000px' + off.style.left = '-10000px' + document.body.append(off) + off.addEventListener('complete.bs.countup', spy) + + try { + const instance = new CountUp(off) + instance.update(77) + await wait(300) + expect(spy).toHaveBeenCalledTimes(1) + + off.style.top = '0px' + off.style.left = '0px' + await wait(500) + + expect(spy).toHaveBeenCalledTimes(1) + expect(off.textContent).toBe('77') + } finally { + off.remove() + } + }) }) describe('data-tblr-countup', () => {