From 414611ad83b320283d0d062b8c3275f843f8fd72 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 6 Aug 2026 13:21:51 +0300 Subject: [PATCH 1/2] fix(ToastProvider): dismiss stuck toasts in a background tab (DS-4709) --- .../ToastProvider/KbqToastQueue.test.ts | 202 +++++++++++++++++- .../components/ToastProvider/KbqToastQueue.ts | 138 +++++++++--- 2 files changed, 306 insertions(+), 34 deletions(-) diff --git a/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts b/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts index b10360919..12a88d8ea 100644 --- a/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts +++ b/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts @@ -1,20 +1,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ToastQueue, DELAY } from './KbqToastQueue'; +import { ToastQueue, DELAY, CHECK_INTERVAL } from './KbqToastQueue'; describe('ToastQueue', () => { + let queues: ToastQueue[]; + + const createQueue = () => { + const queue = new ToastQueue(); + + queues.push(queue); + + return queue; + }; + beforeEach(() => { + queues = []; vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); }); afterEach(() => { + queues.forEach((queue) => queue.clear()); vi.useRealTimers(); vi.restoreAllMocks(); }); it('should close toasts FIFO, while TTLs tick in parallel and delay the next close', () => { - const q = new ToastQueue(); + const q = createQueue(); const onClose1 = vi.fn(); const onClose2 = vi.fn(); @@ -65,7 +77,7 @@ describe('ToastQueue', () => { }); it('should start the delay after manual close before the next auto-close', () => { - const q = new ToastQueue(); + const q = createQueue(); const onClose1 = vi.fn(); const onClose2 = vi.fn(); @@ -90,7 +102,7 @@ describe('ToastQueue', () => { }); it('should freeze auto-close on pauseAll and continue correctly on resumeAll (no huge delta)', () => { - const q = new ToastQueue(); + const q = createQueue(); const onClose = vi.fn(); q.add('t', { timeout: 5000, onClose }); @@ -111,8 +123,188 @@ describe('ToastQueue', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + /** Throttled background tab: the clock moves on, the ticker gets one tick. */ + const starveTicker = (ms: number) => { + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + ms); + vi.advanceTimersByTime(CHECK_INTERVAL); + }; + + it('should catch up on a delayed tick (throttled background tab)', () => { + const q = createQueue(); + const onClose = Array.from({ length: 5 }, () => vi.fn()); + + onClose.forEach((fn, i) => q.add(`t${i}`, { timeout: 5000, onClose: fn })); + + // 30s covers the 5s ttl and all four 2s gaps + starveTicker(30000); + + onClose.forEach((fn) => expect(fn).toHaveBeenCalledTimes(1)); + expect(q.visibleToasts).toHaveLength(0); + }); + + it('should close no more than the delayed tick is owed', () => { + const q = createQueue(); + const onClose = Array.from({ length: 5 }, () => vi.fn()); + + onClose.forEach((fn, i) => q.add(`t${i}`, { timeout: 5000, onClose: fn })); + + // due 3s ago: two slots have passed, the third is still 1s away + starveTicker(8000); + + expect(q.visibleToasts).toHaveLength(3); + }); + + it('should count a delayed tick down from when each toast was added', () => { + const q = createQueue(); + + for (let i = 0; i < 5; i += 1) q.add(`first ${i}`, { timeout: 5000 }); + + // a second burst arrives while the ticker is still starved + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 20000); + for (let i = 0; i < 5; i += 1) q.add(`second ${i}`, { timeout: 5000 }); + + // at 30s: the first burst is due since 5s (5 slots), the second since 25s (3) + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 10000); + vi.advanceTimersByTime(CHECK_INTERVAL); + + expect(q.visibleToasts).toHaveLength(2); + }); + + it('should not count a toast down for time before it was added', () => { + const q = createQueue(); + + // queued 2s before a tick that covers 30s, so it keeps 3s of its ttl + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 28000); + q.add('late', { timeout: 5000 }); + + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 2000); + vi.advanceTimersByTime(CHECK_INTERVAL); + + expect(q.visibleToasts).toHaveLength(1); + expect(q.visibleToasts[0].ttl).toBe(3000); + }); + + it('should catch up as soon as the tab becomes visible', () => { + const q = createQueue(); + const onClose = Array.from({ length: 5 }, () => vi.fn()); + + onClose.forEach((fn, i) => q.add(`t${i}`, { timeout: 5000, onClose: fn })); + + // hidden long enough for every toast to expire, no tick yet + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 30000); + document.dispatchEvent(new Event('visibilitychange')); + + expect(q.visibleToasts).toHaveLength(0); + }); + + it('should keep the gap between closes across a pause', () => { + const q = createQueue(); + const onClose = Array.from({ length: 3 }, () => vi.fn()); + + onClose.forEach((fn, i) => q.add(`t${i}`, { timeout: 5000, onClose: fn })); + + // the first one closes, the rest are waiting for their slots + vi.advanceTimersByTime(5000); + expect(onClose[0]).toHaveBeenCalledTimes(1); + + // hovering the region for a minute must not dump the rest at once + q.pauseAll(); + vi.advanceTimersByTime(60000); + q.resumeAll(); + + expect(onClose[1]).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(DELAY - 1); + expect(onClose[1]).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + expect(onClose[1]).toHaveBeenCalledTimes(1); + expect(onClose[2]).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(DELAY); + expect(onClose[2]).toHaveBeenCalledTimes(1); + }); + + it('should not grow the ttl when the system clock moves backwards', () => { + const q = createQueue(); + + q.add('t', { timeout: 5000 }); + + // NTP correction, manual clock change, waking from sleep + vi.spyOn(Date, 'now').mockReturnValue(Date.now() - 10000); + vi.advanceTimersByTime(CHECK_INTERVAL); + + expect(q.visibleToasts[0].ttl).toBe(5000); + }); + + it('should give a toast added while paused its full ttl after the resume', () => { + const q = createQueue(); + const onClose = vi.fn(); + + vi.advanceTimersByTime(1000); + q.pauseAll(); + + vi.advanceTimersByTime(10000); + q.add('t', { timeout: 5000, onClose }); + + vi.advanceTimersByTime(10000); + q.resumeAll(); + + vi.advanceTimersByTime(4999); + expect(onClose).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should start the delay from the resume for a close made while paused', () => { + const q = createQueue(); + const onClose1 = vi.fn(); + const onClose2 = vi.fn(); + + const k1 = q.add('t1', { timeout: 5000, onClose: onClose1 }); + + q.add('t2', { timeout: 5000, onClose: onClose2 }); + + // hovered, then closed by hand halfway through the pause + q.pauseAll(); + vi.advanceTimersByTime(10000); + q.close(k1); + vi.advanceTimersByTime(10000); + q.resumeAll(); + + // t2 still needs its 5s, and the manual close must not add the pause on top + vi.advanceTimersByTime(4999); + expect(onClose2).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + expect(onClose2).toHaveBeenCalledTimes(1); + }); + + it('should survive a toast queued from an onClose handler', () => { + const q = createQueue(); + + // the handler runs while the queue is being drained + const onClose = vi.fn(() => { + q.add('from onClose'); + }); + + q.add('t', { timeout: 5000, onClose }); + + vi.advanceTimersByTime(5000); + + expect(onClose).toHaveBeenCalledTimes(1); + + expect(q.visibleToasts.map(({ content }) => content)).toEqual([ + 'from onClose', + ]); + + expect((q as any).timedCount).toBe(0); + expect((q as any).tickId).toBeNull(); + }); + it('should stop ticker when no timed toasts remain', () => { - const q = new ToastQueue(); + const q = createQueue(); const onClose = vi.fn(); q.add('t', { timeout: 5000, onClose }); diff --git a/packages/components/src/components/ToastProvider/KbqToastQueue.ts b/packages/components/src/components/ToastProvider/KbqToastQueue.ts index e67a1236b..97c0a3c39 100644 --- a/packages/components/src/components/ToastProvider/KbqToastQueue.ts +++ b/packages/components/src/components/ToastProvider/KbqToastQueue.ts @@ -27,6 +27,16 @@ export interface QueuedToast extends ToastOptions { key: string; /** Remaining ms until the toast becomes eligible for auto-close. */ ttl?: number; + /** + * When the ttl ran out. Tells a late tick how long the toast has been due. + * @internal + */ + expiredAt?: number; + /** + * When the toast was queued. Keeps a tick from counting down time before that. + * @internal + */ + addedAt?: number; } export interface ToastState { @@ -54,6 +64,9 @@ export class ToastQueue { private isPaused = false; + /** When the current pause started. */ + private pausedAt = 0; + private tickId: ReturnType | null = null; private lastTickAt = 0; @@ -81,6 +94,11 @@ export class ToastQueue { return () => this.subscriptions.delete(fn); } + /** Catches up right after the tab is back, before stale toasts are painted. */ + private onVisibilityChange = () => { + if (document.visibilityState === 'visible') this.onTick(); + }; + /** Starts the ticker. */ private startTicker(): void { if (this.tickId != null) return; @@ -88,6 +106,7 @@ export class ToastQueue { this.lastTickAt = Date.now(); this.tickId = setInterval(this.onTick, CHECK_INTERVAL); + document.addEventListener('visibilitychange', this.onVisibilityChange); } /** Stops the ticker. */ @@ -96,6 +115,7 @@ export class ToastQueue { clearInterval(this.tickId); this.tickId = null; + document.removeEventListener('visibilitychange', this.onVisibilityChange); this.lastTickAt = 0; this.nextCloseAllowedAt = 0; @@ -111,6 +131,7 @@ export class ToastQueue { content, key: toastKey, ttl: timeout > 0 ? timeout : undefined, + addedAt: Date.now(), }; this.queue.unshift(toast); @@ -134,22 +155,39 @@ export class ToastQueue { */ close(key: string): void { this.removeToast(key); - this.nextCloseAllowedAt = Date.now() + DELAY; + + // while paused the gap starts counting from the resume, not from now + this.nextCloseAllowedAt = + (this.isPaused ? this.pausedAt : Date.now()) + DELAY; } /** Pauses all auto-close logic (e.g. hover/focus). */ pauseAll(): void { + if (this.isPaused) return; + this.isPaused = true; + this.pausedAt = Date.now(); } - /** Resumes auto-close logic. */ + /** Resumes auto-close logic, moving pending slots past the pause. */ resumeAll(): void { if (!this.isPaused) return; + const now = Date.now(); + const pausedFor = Math.max(0, now - this.pausedAt); + this.isPaused = false; + this.pausedAt = 0; + + // addedAt stays put: the pause is already excluded by lastTickAt below + for (const toast of this.queue) { + if (toast.expiredAt != null) toast.expiredAt += pausedFor; + } + + if (this.nextCloseAllowedAt > 0) this.nextCloseAllowedAt += pausedFor; if (this.tickId != null) { - this.lastTickAt = Date.now(); + this.lastTickAt = now; } } @@ -176,20 +214,24 @@ export class ToastQueue { } } - private removeToast(key: string): void { + /** Drops a toast from the queue without notifying subscribers. */ + private deleteToast(key: string): void { const index = this.queue.findIndex((t) => t.key === key); - if (index >= 0) { - const toast = this.queue[index]; + if (index < 0) return; - if (toast.ttl != null) { - this.timedCount -= 1; - } + const [toast] = this.queue.splice(index, 1); - toast.onClose?.(); - this.queue.splice(index, 1); + if (toast.ttl != null) { + this.timedCount -= 1; } + // called last: the handler may queue another toast + toast.onClose?.(); + } + + private removeToast(key: string): void { + this.deleteToast(key); this.updateVisibleToasts('remove'); } @@ -206,32 +248,70 @@ export class ToastQueue { return undefined; } - private onTick = () => { - if (this.isPaused || this.queue.length === 0) return; - - const now = Date.now(); - - const delta = this.lastTickAt + /** Real time passed since the previous tick. */ + private takeElapsed(now: number): number { + const elapsed = this.lastTickAt ? Math.max(0, now - this.lastTickAt) : CHECK_INTERVAL; this.lastTickAt = now; - // all timed toasts tick simultaneously - for (const t of this.queue) { - if (t.ttl != null) { - t.ttl = Math.max(0, t.ttl - delta); - } - } + return elapsed; + } + + /** Counts timed toasts down and marks the ones that ran out. */ + private countDown(now: number, elapsed: number): void { + for (const toast of this.queue) { + if (toast.ttl == null || toast.ttl === 0) continue; + + // a toast queued mid-tick has only lived through part of it, + // and a system clock moved backwards must not add time back + const step = Math.max(0, Math.min(elapsed, now - (toast.addedAt ?? now))); + const remaining = Math.max(0, toast.ttl - step); - // enforce delay between closes - if (now < this.nextCloseAllowedAt) return; + // the ttl can run out inside a long tick + if (remaining === 0) toast.expiredAt = now - (step - toast.ttl); + + toast.ttl = remaining; + } + } - // close only the head timed toast, if it has expired + /** + * Closes the oldest expired toast if its slot has come, then books the next + * slot DELAY later. Slots follow the expiry, not the tick, so a late tick + * still closes what it slept through. + */ + private closeHeadIfDue(now: number): boolean { const head = this.getHeadTimedToast(); - if (!head || (head.ttl ?? 0) > 0) return; - this.removeToast(head.key); - this.nextCloseAllowedAt = this.timedCount > 0 ? now + DELAY : 0; + if (!head || head.ttl !== 0) return false; + + const dueAt = Math.max(head.expiredAt ?? now, this.nextCloseAllowedAt); + + if (now < dueAt) return false; + + this.deleteToast(head.key); + this.nextCloseAllowedAt = this.timedCount > 0 ? dueAt + DELAY : 0; + + return true; + } + + /** + * Hidden tabs get throttled ticks (~1/s, ~1/min after a while). One close per + * tick would leave expired toasts on screen for minutes, so a tick closes + * every slot it covers. + */ + private onTick = () => { + if (this.isPaused || this.queue.length === 0) return; + + const now = Date.now(); + + this.countDown(now, this.takeElapsed(now)); + + let closed = 0; + + while (this.closeHeadIfDue(now)) closed += 1; + + if (closed > 0) this.updateVisibleToasts('remove'); }; } From 046bb1ed45c33db71bc6c34755150ae03f40a71e Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 6 Aug 2026 15:09:19 +0300 Subject: [PATCH 2/2] fix(ToastProvider): stop clear() from looping when onClose adds a toast --- .../ToastProvider/KbqToastQueue.test.ts | 20 +++++++++++++++++++ .../components/ToastProvider/KbqToastQueue.ts | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts b/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts index 12a88d8ea..401fbffac 100644 --- a/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts +++ b/packages/components/src/components/ToastProvider/KbqToastQueue.test.ts @@ -281,6 +281,26 @@ describe('ToastQueue', () => { expect(onClose2).toHaveBeenCalledTimes(1); }); + it('should survive a toast queued from an onClose handler on clear', () => { + const q = createQueue(); + + const onClose = vi.fn(() => { + q.add('from onClose'); + }); + + q.add('t', { timeout: 5000, onClose }); + q.clear(); + + expect(onClose).toHaveBeenCalledTimes(1); + + expect(q.visibleToasts.map(({ content }) => content)).toEqual([ + 'from onClose', + ]); + + expect((q as any).timedCount).toBe(0); + expect((q as any).tickId).toBeNull(); + }); + it('should survive a toast queued from an onClose handler', () => { const q = createQueue(); diff --git a/packages/components/src/components/ToastProvider/KbqToastQueue.ts b/packages/components/src/components/ToastProvider/KbqToastQueue.ts index 97c0a3c39..b04699de7 100644 --- a/packages/components/src/components/ToastProvider/KbqToastQueue.ts +++ b/packages/components/src/components/ToastProvider/KbqToastQueue.ts @@ -192,13 +192,16 @@ export class ToastQueue { } clear(): void { - for (const toast of this.queue) toast.onClose?.(); + const cleared = this.queue; this.queue = []; this.timedCount = 0; + // detached first: a handler may queue another toast, which stays + for (const toast of cleared) toast.onClose?.(); + + // stops the ticker unless a handler queued a timed toast this.updateVisibleToasts('clear'); - this.stopTicker(); } private updateVisibleToasts(action: ToastAction) {