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;