From ee3843e469c0bc4742332031b0d31bb5a2c75bdb Mon Sep 17 00:00:00 2001 From: Bartosz-Do Date: Mon, 28 Sep 2026 12:43:13 +0200 Subject: [PATCH] Fix `Confetti` end event after dispose, stalled frame without a 2D context and canvas width (#3228) --- .../fix-confetti-dispose-context-width.md | 5 +++ core/js/src/confetti.ts | 37 ++++++++++++--- core/js/tests/unit/confetti.spec.ts | 45 +++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-confetti-dispose-context-width.md diff --git a/.changeset/fix-confetti-dispose-context-width.md b/.changeset/fix-confetti-dispose-context-width.md new file mode 100644 index 000000000..c6424acf4 --- /dev/null +++ b/.changeset/fix-confetti-dispose-context-width.md @@ -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. diff --git a/core/js/src/confetti.ts b/core/js/src/confetti.ts index 8ae25b1e3..ef43b27e5 100644 --- a/core/js/src/confetti.ts +++ b/core/js/src/confetti.ts @@ -59,6 +59,8 @@ type Emitter = { stopped: boolean /** every piece has landed and `end` was fired */ 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, lastTick: 0, - add(emitter: Emitter): void { - this.emitters.push(emitter) + add(emitter: Emitter): boolean { this._mount() + if (!this.context) { + return false + } + + this.emitters.push(emitter) + if (!this.frame) { this.lastTick = performance.now() this.frame = requestAnimationFrame((now) => this._tick(now)) } + + return true }, _mount(): void { @@ -162,9 +171,14 @@ const stage = { display: 'block', }) + const context = canvas.getContext('2d') + if (!context) { + return + } + document.body.append(canvas) this.canvas = canvas - this.context = canvas.getContext('2d') + this.context = context this._resize() 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 element at the viewport size, so the drawing stays sharp and 1:1. 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.context.setTransform(dpr, 0, 0, dpr, 0, 0) }, @@ -313,7 +327,10 @@ const stage = { this.emitters.splice(i, 1) 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, stopped: 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 { @@ -387,6 +408,10 @@ class Confetti extends BaseComponent { } dispose(): void { + if (this._emitter) { + this._emitter.detached = true + } + this.stop() super.dispose() } diff --git a/core/js/tests/unit/confetti.spec.ts b/core/js/tests/unit/confetti.spec.ts index 9cb73615b..f422e9508 100644 --- a/core/js/tests/unit/confetti.spec.ts +++ b/core/js/tests/unit/confetti.spec.ts @@ -100,6 +100,51 @@ describe('Confetti', () => { expect(canvas()).toBeNull() expect(end).toHaveBeenCalledTimes(1) }) + + it('should end at once without a canvas when there is no 2D context', () => { + fixtureEl.innerHTML = '' + 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 = '' + 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 = '' + 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', () => {