Skip to content

Commit 89e3e50

Browse files
committed
fix(toast): prevent auto-dismiss cleanup from canceling exit timer; add rAF cleanup; use AnimationDuration token for web transition duration
1 parent c8b4216 commit 89e3e50

2 files changed

Lines changed: 57 additions & 16 deletions

File tree

packages/design-system-react/src/components/Toast/Toast.constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ToastSeverity } from '@metamask/design-system-shared';
2+
import { AnimationDuration } from '@metamask/design-tokens';
23

34
import { IconColor, IconName } from '../../types';
45

56
export const TOAST_VISIBILITY_DURATION = 2750;
67
/** Duration of the enter/exit CSS transition in milliseconds. */
7-
export const TOAST_ANIMATION_DURATION = 200;
8+
export const TOAST_ANIMATION_DURATION = AnimationDuration.Regularly;
89

910
export const TOAST_SEVERITY_ICON_MAP = {
1011
[ToastSeverity.Success]: {

packages/design-system-react/src/components/Toast/Toaster.tsx

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,44 @@ const ToasterComponent = forwardRef<ToasterRef, ToasterProps>(
4848
const replacementTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
4949
null,
5050
);
51-
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
51+
// Separate timers to avoid cleanup collisions.
52+
const autoDismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
53+
null,
54+
);
55+
const exitCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
56+
null,
57+
);
58+
// Track pending rAF callbacks for enter animation.
59+
const enterRafId1Ref = useRef<number | null>(null);
60+
const enterRafId2Ref = useRef<number | null>(null);
5261
const innerRef = useRef<ToasterRef | null>(null);
5362

5463
const closeToast = () => {
64+
// Cancel any pending enter rAFs to prevent stale visibility updates.
65+
if (enterRafId1Ref.current !== null) {
66+
cancelAnimationFrame(enterRafId1Ref.current);
67+
enterRafId1Ref.current = null;
68+
}
69+
if (enterRafId2Ref.current !== null) {
70+
cancelAnimationFrame(enterRafId2Ref.current);
71+
enterRafId2Ref.current = null;
72+
}
5573
if (replacementTimerRef.current !== null) {
5674
clearTimeout(replacementTimerRef.current);
5775
replacementTimerRef.current = null;
5876
}
59-
if (dismissTimerRef.current !== null) {
60-
clearTimeout(dismissTimerRef.current);
61-
dismissTimerRef.current = null;
77+
// Clear any auto-dismiss timer and any prior exit cleanup timer.
78+
if (autoDismissTimerRef.current !== null) {
79+
clearTimeout(autoDismissTimerRef.current);
80+
autoDismissTimerRef.current = null;
81+
}
82+
if (exitCleanupTimerRef.current !== null) {
83+
clearTimeout(exitCleanupTimerRef.current);
84+
exitCleanupTimerRef.current = null;
6285
}
6386
setIsVisible(false);
64-
dismissTimerRef.current = setTimeout(() => {
65-
dismissTimerRef.current = null;
87+
exitCleanupTimerRef.current = setTimeout(() => {
88+
exitCleanupTimerRef.current = null;
6689
setToastOptions(undefined);
6790
}, TOAST_ANIMATION_DURATION);
6891
};
@@ -73,9 +96,14 @@ const ToasterComponent = forwardRef<ToasterRef, ToasterProps>(
7396

7497
if (toastOptions) {
7598
setIsVisible(false);
76-
if (dismissTimerRef.current !== null) {
77-
clearTimeout(dismissTimerRef.current);
78-
dismissTimerRef.current = null;
99+
// Clear any existing timers when replacing an in-flight toast.
100+
if (autoDismissTimerRef.current !== null) {
101+
clearTimeout(autoDismissTimerRef.current);
102+
autoDismissTimerRef.current = null;
103+
}
104+
if (exitCleanupTimerRef.current !== null) {
105+
clearTimeout(exitCleanupTimerRef.current);
106+
exitCleanupTimerRef.current = null;
79107
}
80108
timeoutDuration = TOAST_ANIMATION_DURATION;
81109
setToastOptions(undefined);
@@ -107,24 +135,36 @@ const ToasterComponent = forwardRef<ToasterRef, ToasterProps>(
107135
// Trigger enter animation after toast is mounted in the DOM.
108136
useEffect(() => {
109137
if (toastOptions && !isVisible) {
110-
requestAnimationFrame(() => {
111-
requestAnimationFrame(() => {
138+
enterRafId1Ref.current = requestAnimationFrame(() => {
139+
enterRafId2Ref.current = requestAnimationFrame(() => {
112140
setIsVisible(true);
113141
});
114142
});
143+
// Cleanup to cancel pending rAFs if toast is dismissed quickly.
144+
return () => {
145+
if (enterRafId1Ref.current !== null) {
146+
cancelAnimationFrame(enterRafId1Ref.current);
147+
enterRafId1Ref.current = null;
148+
}
149+
if (enterRafId2Ref.current !== null) {
150+
cancelAnimationFrame(enterRafId2Ref.current);
151+
enterRafId2Ref.current = null;
152+
}
153+
};
115154
}
155+
return undefined;
116156
}, [toastOptions]); // intentionally omit isVisible — only react to new toast options
117157

118158
// Auto-dismiss timer.
119159
useEffect(() => {
120160
if (isVisible && toastOptions && !toastOptions.hasNoTimeout) {
121-
dismissTimerRef.current = setTimeout(() => {
122-
dismissTimerRef.current = null;
161+
autoDismissTimerRef.current = setTimeout(() => {
162+
autoDismissTimerRef.current = null;
123163
innerRef.current?.closeToast();
124164
}, TOAST_VISIBILITY_DURATION);
125165
return () => {
126-
if (dismissTimerRef.current !== null) {
127-
clearTimeout(dismissTimerRef.current);
166+
if (autoDismissTimerRef.current !== null) {
167+
clearTimeout(autoDismissTimerRef.current);
128168
}
129169
};
130170
}

0 commit comments

Comments
 (0)