Two related defects in the Toast timer/dismiss machinery (both verified by execution)
1. onHide double-fires. ToastViewport.removeToast fires entry.options.onHide?.(reason) whenever the entry is still in toastsRef.current — but an exiting toast stays in that list until its exit transition ends (handleExited). The dedupe guard protects only exitingIds state, after onHide already fired. Any second dismissal inside the ~150ms exit window double-fires:
- double-click on "Dismiss notification" →
onHide × 2
- auto-hide timer fires, then the
dismiss() function returned by toast(...) is called (e.g. effect cleanup) → onHide('auto') + onHide(...)
onHide is the documented state-sync hook (mark-as-read, re-enable submit, telemetry), and in a backgrounded tab the exit transition can stall, widening the window to seconds.
2. A paused timer is silently cancelled by any viewport re-render. Toast's timer effect depends on startTimer, which depends on onDismiss — an inline arrow re-created on every ToastViewport render (onDismiss={reason => removeToast(entry.id, reason)}). So when any other toast is added/removed/exits, every mounted toast's effect re-runs: remainingRef resets to full and startTimer() fires unconditionally — ignoring isPausedRef. Verified: hover toast A (paused) or blur the window (paused), let toast B arrive, advance time → A auto-dismisses while still hovered/blurred. The pause features exist precisely for busy multi-toast UIs, so this defeats them in the common case (and it is what widens defect 1's window in background tabs). WCAG 2.2.1-relevant: the toast expires mid-read.
Suggested fix
removeToast: bail out before firing onHide when the toast is already exiting (mirror exitingIds in a ref).
Toast: hold onDismiss in a ref so the timer effect depends only on [autoHideDuration, isAutoHide], and have startTimer respect isPausedRef.
Happy to send the PR.
Astryx Version
main (d9932ea)
Two related defects in the Toast timer/dismiss machinery (both verified by execution)
1.
onHidedouble-fires.ToastViewport.removeToastfiresentry.options.onHide?.(reason)whenever the entry is still intoastsRef.current— but an exiting toast stays in that list until its exit transition ends (handleExited). The dedupe guard protects onlyexitingIdsstate, afteronHidealready fired. Any second dismissal inside the ~150ms exit window double-fires:onHide× 2dismiss()function returned bytoast(...)is called (e.g. effect cleanup) →onHide('auto')+onHide(...)onHideis the documented state-sync hook (mark-as-read, re-enable submit, telemetry), and in a backgrounded tab the exit transition can stall, widening the window to seconds.2. A paused timer is silently cancelled by any viewport re-render.
Toast's timer effect depends onstartTimer, which depends ononDismiss— an inline arrow re-created on everyToastViewportrender (onDismiss={reason => removeToast(entry.id, reason)}). So when any other toast is added/removed/exits, every mounted toast's effect re-runs:remainingRefresets to full andstartTimer()fires unconditionally — ignoringisPausedRef. Verified: hover toast A (paused) or blur the window (paused), let toast B arrive, advance time → A auto-dismisses while still hovered/blurred. The pause features exist precisely for busy multi-toast UIs, so this defeats them in the common case (and it is what widens defect 1's window in background tabs). WCAG 2.2.1-relevant: the toast expires mid-read.Suggested fix
removeToast: bail out before firingonHidewhen the toast is already exiting (mirrorexitingIdsin a ref).Toast: holdonDismissin a ref so the timer effect depends only on[autoHideDuration, isAutoHide], and havestartTimerrespectisPausedRef.Happy to send the PR.
Astryx Version
main (d9932ea)