Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/violet-bees-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@radix-ui/react-toast": patch
---

Fix stale closeTimerRemainingTimeRef when duration prop changes after pause / resume
79 changes: 79 additions & 0 deletions apps/storybook/stories/toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,85 @@ export const Promise = () => {
);
};

export const DurationChangeAfterPause = () => {
const [phase, setPhase] = React.useState<'idle' | 'loading' | 'done'>('idle');
const [open, setOpen] = React.useState(false);
const [paused, setPaused] = React.useState(false);

const handleStart = () => {
setPhase('loading');
setOpen(true);
setPaused(false);
// Simulate async work completing after 3 seconds
window.setTimeout(() => setPhase('done'), 3000);
};

// Duration: Infinity while loading, 2000ms once done
const duration = phase === 'loading' ? Infinity : 2000;

return (
<Toast.Provider>
<div style={{ padding: 20 }}>
<p style={{ marginBottom: 12 }}>
Steps to reproduce the bug:
<br />1. Click "Start" to open the toast (duration = Infinity)
<br />2. Hover over the toast to pause the timer
<br />3. Move the mouse away to resume
<br />4. Wait 3 seconds, toast transitions to "Done!" (duration = 2000ms)
<br />5. Hover and un-hover again — toast should auto-close in ~2s
<br /> BUG: it never closes because the ref still holds Infinity
</p>
<button onClick={handleStart} disabled={phase !== 'idle'}>
Start
</button>
</div>

<Toast.Root
className={styles.root}
open={open}
onOpenChange={(o) => { setOpen(o); if (!o) { setPhase('idle'); setPaused(false); } }}
duration={duration}
onPause={() => setPaused(true)}
onResume={() => setPaused(false)}
>
<div className={styles.header}>
<Toast.Title className={styles.title}>
{phase === 'loading' ? 'Loading…' : 'Done! Will close in 2s'}
</Toast.Title>
{paused && (
<span style={{ marginLeft: 'auto', fontSize: 10, color: '#ffcc00', fontWeight: 'bold', letterSpacing: 1 }}>
⏸ PAUSED
</span>
)}
</div>
<Toast.Description className={styles.description}>
{phase === 'loading'
? 'Waiting for async work…'
: paused
? 'Hovering — timer paused'
: 'Move mouse away to resume countdown'}
</Toast.Description>
{phase === 'done' && (
<div className={styles.progressBar}>
<div
key="done"
className={styles.progressBarInner}
style={{
animationDuration: `${duration - 100}ms`,
animationFillMode: 'forwards',
animationPlayState: paused ? 'paused' : 'running',
backgroundColor: paused ? 'orange' : undefined,
}}
/>
</div>
)}
</Toast.Root>

<Toast.Viewport className={styles.viewport} />
</Toast.Provider>
);
};

export const KeyChange = () => {
const [toastOneCount, setToastOneCount] = React.useState(0);
const [toastTwoCount, setToastTwoCount] = React.useState(0);
Expand Down
14 changes: 14 additions & 0 deletions packages/react/toast/src/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,20 @@ const ToastImpl = React.forwardRef<ToastImplElement, ToastImplProps>(
const closeTimerStartTimeRef = React.useRef(0);
const closeTimerRemainingTimeRef = React.useRef(duration);
const closeTimerRef = React.useRef(0);

// Sync remaining time when duration prop changes.
// When not paused: reset to new duration (timer restart is handled by the open/duration effect).
// When paused: clamp remaining to new duration so resume uses the correct value.
// Without this, a toast paused while duration=Infinity will hold Infinity in the ref
// forever and never close after duration changes to a finite value.
React.useEffect(() => {
if (!context.isClosePausedRef.current) {
closeTimerRemainingTimeRef.current = duration;
} else if (duration < closeTimerRemainingTimeRef.current) {
closeTimerRemainingTimeRef.current = duration;
}
}, [duration, context.isClosePausedRef])

const { onToastAdd, onToastRemove } = context;
const handleClose = useCallbackRef(() => {
// focus viewport if focus is within toast to read the remaining toast
Expand Down