From 4da58008de9a7a7ba6ed6b26de2a18e334c1315f Mon Sep 17 00:00:00 2001 From: LongTang Date: Thu, 4 Jun 2026 13:27:08 -0400 Subject: [PATCH 1/2] fix(toast): reset remaining time ref when duration changes after pause/resume --- .changeset/violet-bees-shop.md | 5 ++ apps/storybook/stories/toast.stories.tsx | 79 ++++++++++++++++++++++++ packages/react/toast/src/toast.tsx | 14 +++++ 3 files changed, 98 insertions(+) create mode 100644 .changeset/violet-bees-shop.md diff --git a/.changeset/violet-bees-shop.md b/.changeset/violet-bees-shop.md new file mode 100644 index 0000000000..d9cf0ec335 --- /dev/null +++ b/.changeset/violet-bees-shop.md @@ -0,0 +1,5 @@ +--- +"@radix-ui/react-toast": major +--- + +Fix stale closeTimerRemainingTimeRef when duration prop changes after pause / resume diff --git a/apps/storybook/stories/toast.stories.tsx b/apps/storybook/stories/toast.stories.tsx index 4b3ef81cf5..cd96b52904 100644 --- a/apps/storybook/stories/toast.stories.tsx +++ b/apps/storybook/stories/toast.stories.tsx @@ -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 ( + +
+

+ Steps to reproduce the bug: +
1. Click "Start" to open the toast (duration = Infinity) +
2. Hover over the toast to pause the timer +
3. Move the mouse away to resume +
4. Wait 3 seconds, toast transitions to "Done!" (duration = 2000ms) +
5. Hover and un-hover again — toast should auto-close in ~2s +
BUG: it never closes because the ref still holds Infinity +

+ +
+ + { setOpen(o); if (!o) { setPhase('idle'); setPaused(false); } }} + duration={duration} + onPause={() => setPaused(true)} + onResume={() => setPaused(false)} + > +
+ + {phase === 'loading' ? 'Loading…' : 'Done! Will close in 2s'} + + {paused && ( + + ⏸ PAUSED + + )} +
+ + {phase === 'loading' + ? 'Waiting for async work…' + : paused + ? 'Hovering — timer paused' + : 'Move mouse away to resume countdown'} + + {phase === 'done' && ( +
+
+
+ )} + + + + + ); +}; + export const KeyChange = () => { const [toastOneCount, setToastOneCount] = React.useState(0); const [toastTwoCount, setToastTwoCount] = React.useState(0); diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 0b71d0d712..1f985b4bb0 100644 --- a/packages/react/toast/src/toast.tsx +++ b/packages/react/toast/src/toast.tsx @@ -497,6 +497,20 @@ const ToastImpl = React.forwardRef( 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 From 8e2ce0a06e1ad6a27ea9fc753814ebb64b131420 Mon Sep 17 00:00:00 2001 From: LongTang Date: Thu, 4 Jun 2026 13:30:07 -0400 Subject: [PATCH 2/2] chore: fix changeset bump type to patch --- .changeset/violet-bees-shop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/violet-bees-shop.md b/.changeset/violet-bees-shop.md index d9cf0ec335..4d4e63dc44 100644 --- a/.changeset/violet-bees-shop.md +++ b/.changeset/violet-bees-shop.md @@ -1,5 +1,5 @@ --- -"@radix-ui/react-toast": major +"@radix-ui/react-toast": patch --- Fix stale closeTimerRemainingTimeRef when duration prop changes after pause / resume