diff --git a/packages/react/src/toast/root/ToastRoot.test.tsx b/packages/react/src/toast/root/ToastRoot.test.tsx
index 926aad95a8f..7c1f5fc39d2 100644
--- a/packages/react/src/toast/root/ToastRoot.test.tsx
+++ b/packages/react/src/toast/root/ToastRoot.test.tsx
@@ -229,6 +229,466 @@ describe('', () => {
});
});
+ it.skipIf(isJSDOM)(
+ 'clears the starting state and restores the height when re-adding an ending toast',
+ async () => {
+ const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+
+ function App() {
+ const { add, close, toasts } = Toast.useToastManager();
+
+ return (
+
+
+
+
+
+ {toasts.map((toastItem) => (
+
+
+
+ ))}
+
+
+ );
+ }
+
+ try {
+ const { user } = await render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ const toastRoot = screen.getByTestId('toast-root');
+ expect(toastRoot).not.toHaveAttribute('data-starting-style');
+ const initialHeight = toastRoot.style.getPropertyValue('--toast-height');
+ expect(initialHeight).not.toBe('');
+
+ await user.click(screen.getByRole('button', { name: 'close' }));
+ expect(toastRoot).toHaveAttribute('data-ending-style');
+ expect(toastRoot.style.getPropertyValue('--toast-height')).toBe('');
+
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ expect(screen.getByTestId('toast-root')).toBe(toastRoot);
+
+ await waitFor(() => {
+ expect(toastRoot).not.toHaveAttribute('data-starting-style');
+ });
+ await waitFor(() => {
+ expect(toastRoot.style.getPropertyValue('--toast-height')).toBe(initialHeight);
+ });
+ } finally {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
+ }
+ },
+ );
+
+ it.skipIf(isJSDOM)('registers an active toast after its root remounts', async () => {
+ function App() {
+ const { add, toasts } = Toast.useToastManager();
+ const [showToasts, setShowToasts] = React.useState(true);
+ const [longTitle, setLongTitle] = React.useState(false);
+
+ return (
+
+
+
+
+
+ {showToasts
+ ? toasts.map((toastItem) => (
+
+
+ {longTitle ? 'This title is much longer than before' : undefined}
+
+
+ ))
+ : null}
+
+
+ );
+ }
+
+ const { user } = await render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ const initialRoot = screen.getByTestId('toast-root');
+ expect(initialRoot).not.toHaveAttribute('data-starting-style');
+ const initialHeight = parseInt(initialRoot.style.getPropertyValue('--toast-height'), 10);
+
+ await user.click(screen.getByRole('button', { name: 'hide' }));
+ expect(screen.queryByTestId('toast-root')).toBe(null);
+
+ await user.click(screen.getByRole('button', { name: 'show' }));
+ const remountedRoot = screen.getByTestId('toast-root');
+ expect(remountedRoot).not.toBe(initialRoot);
+
+ await waitFor(() => {
+ const remountedHeight = parseInt(remountedRoot.style.getPropertyValue('--toast-height'), 10);
+ expect(remountedHeight).toBeGreaterThan(initialHeight);
+ });
+
+ await user.keyboard('{F6}');
+ await user.keyboard('{Tab}');
+
+ expect(remountedRoot).toHaveFocus();
+ });
+
+ it.skipIf(isJSDOM)(
+ 'keeps stacking intact when re-adding an ending toast among other toasts',
+ async () => {
+ const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+
+ function App() {
+ const { add, close, toasts } = Toast.useToastManager();
+
+ return (
+
+
+
+
+
+
+ {toasts.map((toastItem) => (
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ try {
+ const { user } = await render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'add-1' }));
+ await user.click(screen.getByRole('button', { name: 'add-2' }));
+ await user.click(screen.getByRole('button', { name: 'close-1' }));
+
+ const toast1 = screen.getByTestId('toast-t1');
+ expect(toast1).toHaveAttribute('data-ending-style');
+
+ await user.click(screen.getByRole('button', { name: 'add-1' }));
+
+ await waitFor(() => {
+ expect(toast1).not.toHaveAttribute('data-starting-style');
+ });
+ await waitFor(() => {
+ expect(toast1.style.getPropertyValue('--toast-height')).not.toBe('');
+ });
+
+ // The revived toast is frontmost and its content is not hidden as "behind".
+ expect(toast1.style.getPropertyValue('--toast-index')).toBe('0');
+ expect(screen.getByTestId('content-t1')).not.toHaveAttribute('data-behind');
+
+ // The unrelated toast is unaffected and stacks behind the revived one.
+ const toast2 = screen.getByTestId('toast-t2');
+ expect(toast2).not.toHaveAttribute('data-ending-style');
+ expect(toast2.style.getPropertyValue('--toast-index')).toBe('1');
+ expect(screen.getByTestId('content-t2')).toHaveAttribute('data-behind');
+ } finally {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
+ }
+ },
+ );
+
+ it.skipIf(isJSDOM)('clears swipe state when re-adding a swipe-dismissed toast', async () => {
+ const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+
+ function App() {
+ const { add, toasts } = Toast.useToastManager();
+
+ return (
+
+
+
+
+ {toasts.map((toastItem) => (
+
+
+
+ ))}
+
+
+ );
+ }
+
+ try {
+ const { user } = await render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ const toastRoot = screen.getByTestId('toast-root');
+ Object.defineProperty(toastRoot, 'setPointerCapture', {
+ configurable: true,
+ value: () => {},
+ });
+ Object.defineProperty(toastRoot, 'releasePointerCapture', {
+ configurable: true,
+ value: () => {},
+ });
+
+ simulateSwipe(toastRoot, 100, 100, 160, 100);
+
+ expect(toastRoot).toHaveAttribute('data-ending-style');
+ expect(toastRoot).toHaveAttribute('data-swipe-direction', 'right');
+
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ expect(screen.getByTestId('toast-root')).toBe(toastRoot);
+
+ await waitFor(() => {
+ expect(toastRoot).not.toHaveAttribute('data-starting-style');
+ });
+ await waitFor(() => {
+ expect(toastRoot).not.toHaveAttribute('data-swipe-direction');
+ });
+ expect(toastRoot.style.getPropertyValue('--toast-swipe-movement-x')).toBe('0px');
+ expect(toastRoot.style.getPropertyValue('--toast-swipe-movement-y')).toBe('0px');
+ } finally {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
+ }
+ });
+
+ it.skipIf(isJSDOM)(
+ 'clears swipe state when a retained root is reused for another toast',
+ async () => {
+ const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+
+ function App() {
+ const { add, toasts } = Toast.useToastManager();
+
+ return (
+
+
+
+
+ {toasts.map((toastItem, index) => (
+
+
+
+ ))}
+
+
+ );
+ }
+
+ try {
+ const { user } = await render(
+
+
+ ,
+ );
+
+ const addButton = screen.getByRole('button', { name: 'add' });
+ await user.click(addButton);
+ await user.click(addButton);
+ await user.click(addButton);
+
+ const swipedRoot = screen.getByTestId('root-1');
+ Object.defineProperty(swipedRoot, 'setPointerCapture', {
+ configurable: true,
+ value: () => {},
+ });
+ Object.defineProperty(swipedRoot, 'releasePointerCapture', {
+ configurable: true,
+ value: () => {},
+ });
+
+ simulateSwipe(swipedRoot, 100, 100, 160, 100);
+ expect(swipedRoot).toHaveAttribute('data-swipe-direction', 'right');
+
+ // Index keys shift every retained root onto a different toast, so this root now
+ // renders a toast that was never swiped.
+ await user.click(addButton);
+ expect(screen.getByTestId('root-1')).toBe(swipedRoot);
+
+ await waitFor(() => {
+ expect(swipedRoot).not.toHaveAttribute('data-swipe-direction');
+ });
+ expect(swipedRoot).not.toHaveAttribute('data-ending-style');
+ expect(swipedRoot.style.getPropertyValue('--toast-swipe-movement-x')).toBe('0px');
+ expect(swipedRoot.style.getPropertyValue('--toast-swipe-movement-y')).toBe('0px');
+ } finally {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
+ }
+ },
+ );
+
+ it.skipIf(isJSDOM)(
+ 'moves focus to the next toast when closing in an index-keyed list',
+ async () => {
+ const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
+
+ function App() {
+ const { add, toasts } = Toast.useToastManager();
+
+ return (
+
+
+
+
+ {toasts.map((toastItem, index) => (
+
+
+
+ ))}
+
+
+ );
+ }
+
+ try {
+ const { user } = await render(
+
+
+ ,
+ );
+
+ // With index keys, each add shifts every retained root to a different toast,
+ // so the store refs must be re-registered per toast id.
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ await user.click(screen.getByRole('button', { name: 'add' }));
+ await user.click(screen.getByRole('button', { name: 'add' }));
+
+ await user.keyboard('{F6}');
+ await user.keyboard('{Tab}');
+ expect(screen.getByTestId('root-0')).toHaveFocus();
+
+ await user.keyboard('{Escape}');
+
+ // Focus hands off to the next active toast, not the one animating out.
+ await waitFor(() => {
+ expect(screen.getByTestId('root-1')).toHaveFocus();
+ });
+ } finally {
+ globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
+ }
+ },
+ );
+
// requires :focus-visible check
it.skipIf(isJSDOM)('closes when pressing escape', async () => {
const { user } = await render(
diff --git a/packages/react/src/toast/root/ToastRoot.tsx b/packages/react/src/toast/root/ToastRoot.tsx
index 73806b35387..de45e7255a4 100644
--- a/packages/react/src/toast/root/ToastRoot.tsx
+++ b/packages/react/src/toast/root/ToastRoot.tsx
@@ -79,6 +79,7 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
);
const rootRef = React.useRef(null);
+ const lastToastIdRef = React.useRef(undefined);
const dragStartPosRef = React.useRef({ x: 0, y: 0 });
const initialTransformRef = React.useRef({ x: 0, y: 0, scale: 1 });
const intendedSwipeDirectionRef = React.useRef<'up' | 'down' | 'left' | 'right' | undefined>(
@@ -110,6 +111,7 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
// Recalculates the natural height of the toast and updates it in the toast manager.
// `flushSync` avoids visual flickers when called from observer callbacks.
+ // The store ignores this write while the toast is transitioning out.
const recalculateHeight = useStableCallback((flushSync: boolean = false) => {
const element = rootRef.current;
if (!element) {
@@ -138,7 +140,28 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
}
});
- useIsoLayoutEffect(recalculateHeight, [recalculateHeight]);
+ // Initialize the toast on mount, and reinitialize when it begins a new lifecycle:
+ // re-adding an ending toast retains the same root instance (`key={toast.id}`), and
+ // index-keyed lists can hand an existing instance a different toast.
+ useIsoLayoutEffect(() => {
+ const previousToastId = lastToastIdRef.current;
+ // `recalculateHeight` clears the `starting` status itself, so bail out on the
+ // resulting re-run and on the later `ending` one, which the store discards anyway.
+ if (toast.transitionStatus !== 'starting' && previousToastId === toast.id) {
+ return;
+ }
+
+ if (previousToastId !== undefined) {
+ // A retained root keeps component-local swipe state from its previous lifecycle;
+ // clear it so the toast doesn't stay offset or exit in the swiped direction.
+ setCurrentSwipeDirection(undefined);
+ setInitialTransform({ x: 0, y: 0, scale: 1 });
+ setResolvedDragOffset({ x: 0, y: 0 });
+ }
+
+ lastToastIdRef.current = toast.id;
+ recalculateHeight();
+ }, [recalculateHeight, toast.id, toast.transitionStatus]);
function setResolvedDragOffset(nextDragOffset: { x: number; y: number }) {
dragOffsetRef.current = nextDragOffset;