Fix Confetti end event after dispose, stalled frame without a 2D context and canvas width (#3228)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-28 12:43:13 +02:00
1 parent 0fdf1b71bb
commit ee3843e469
3 files changed
+81 -6

No files matched your search

@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `Confetti` firing `end` after `dispose()`, stalling without a 2D context and sizing its canvas with the scrollbar width.
+31 -6
View File
@@ -59,6 +59,8 @@ type Emitter = {
stopped: boolean stopped: boolean
/** every piece has landed and `end` was fired */ /** every piece has landed and `end` was fired */
done: boolean done: boolean
/** the instance was disposed: the pieces still land, but `end` is not fired */
detached: boolean
} }
/** /**
@@ -134,14 +136,21 @@ const stage = {
frame: 0, frame: 0,
lastTick: 0, lastTick: 0,
add(emitter: Emitter): void { add(emitter: Emitter): boolean {
this.emitters.push(emitter)
this._mount() this._mount()
if (!this.context) {
return false
}
this.emitters.push(emitter)
if (!this.frame) { if (!this.frame) {
this.lastTick = performance.now() this.lastTick = performance.now()
this.frame = requestAnimationFrame((now) => this._tick(now)) this.frame = requestAnimationFrame((now) => this._tick(now))
} }
return true
}, },
_mount(): void { _mount(): void {
@@ -162,9 +171,14 @@ const stage = {
display: 'block', display: 'block',
}) })
const context = canvas.getContext('2d')
if (!context) {
return
}
document.body.append(canvas) document.body.append(canvas)
this.canvas = canvas this.canvas = canvas
this.context = canvas.getContext('2d') this.context = context
this._resize() this._resize()
window.addEventListener('resize', this._onResize) window.addEventListener('resize', this._onResize)
}, },
@@ -192,7 +206,7 @@ const stage = {
// The backing store is scaled for HiDPI screens; the CSS size above keeps // The backing store is scaled for HiDPI screens; the CSS size above keeps
// the element at the viewport size, so the drawing stays sharp and 1:1. // the element at the viewport size, so the drawing stays sharp and 1:1.
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR) const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
this.canvas.width = window.innerWidth * dpr this.canvas.width = document.documentElement.clientWidth * dpr
this.canvas.height = window.innerHeight * dpr this.canvas.height = window.innerHeight * dpr
this.context.setTransform(dpr, 0, 0, dpr, 0, 0) this.context.setTransform(dpr, 0, 0, dpr, 0, 0)
}, },
@@ -313,7 +327,10 @@ const stage = {
this.emitters.splice(i, 1) this.emitters.splice(i, 1)
emitter.done = true emitter.done = true
EventHandler.trigger(emitter.element, EVENT_END)
if (!emitter.detached) {
EventHandler.trigger(emitter.element, EVENT_END)
}
} }
}, },
} }
@@ -373,9 +390,13 @@ class Confetti extends BaseComponent {
emitted: 0, emitted: 0,
stopped: false, stopped: false,
done: false, done: false,
detached: false,
} }
stage.add(this._emitter) if (!stage.add(this._emitter)) {
this._emitter = null
EventHandler.trigger(this._element, EVENT_END)
}
} }
stop(): void { stop(): void {
@@ -387,6 +408,10 @@ class Confetti extends BaseComponent {
} }
dispose(): void { dispose(): void {
if (this._emitter) {
this._emitter.detached = true
}
this.stop() this.stop()
super.dispose() super.dispose()
} }
+45
View File
@@ -100,6 +100,51 @@ describe('Confetti', () => {
expect(canvas()).toBeNull() expect(canvas()).toBeNull()
expect(end).toHaveBeenCalledTimes(1) expect(end).toHaveBeenCalledTimes(1)
}) })
it('should end at once without a canvas when there is no 2D context', () => {
fixtureEl.innerHTML = '<button type="button"></button>'
reduceMotion(false)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
const end = vi.fn()
button().addEventListener('end.bs.confetti', end)
new Confetti(button(), QUICK).burst()
expect(canvas()).toBeNull()
expect(end).toHaveBeenCalledTimes(1)
})
it('should size the backing store to the viewport without the scrollbar', () => {
fixtureEl.innerHTML = '<button type="button"></button>'
reduceMotion(false)
document.documentElement.style.overflowY = 'scroll'
try {
new Confetti(button(), QUICK).burst()
const dpr = Math.min(window.devicePixelRatio || 1, 2)
expect(canvas()!.width).toBe(document.documentElement.clientWidth * dpr)
} finally {
document.documentElement.style.overflowY = ''
}
})
})
describe('dispose', () => {
it('should not fire end once the instance is disposed', async () => {
fixtureEl.innerHTML = '<button type="button"></button>'
reduceMotion(false)
const end = vi.fn()
button().addEventListener('end.bs.confetti', end)
const instance = new Confetti(button(), QUICK)
instance.burst()
instance.dispose()
await vi.waitFor(() => expect(canvas()).toBeNull(), { timeout: 3000 })
expect(end).not.toHaveBeenCalled()
})
}) })
describe('stop', () => { describe('stop', () => {