Fix complete.bs.countup firing twice when update() runs before the element is in view (#3193)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-25 12:10:58 +02:00
1 parent e2da442007
commit 77480290dc
3 files changed
+62 -7

No files matched your search

@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed CountUp firing `complete.bs.countup` twice when `update()` ran before it scrolled into view.
+15 -7
View File
@@ -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()) {
+42
View File
@@ -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', () => {