From 46755a5ed686122d49e6142a4049f7ab589482b0 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 23 Oct 2026 20:38:49 -0700 Subject: [PATCH] fix(Toast): a toast dismissed with no row on screen leaves the stack A dismissed toast left the list only when its row's exit transition ended. A toast with no mounted row (hidden past maxVisible, or dismissed before it first rendered) has no transition: when it came back into view its row mounted already collapsed and never left, holding a visible slot and the Notifications landmark open with nothing in it. Such a toast now leaves the list at once, and a dismiss() before the first render still finds the toast and reports onHide once, even when it is dismissed twice before the viewport renders again. --- .changeset/toast-dismiss-without-row.md | 7 + .../core/src/Toast/ToastViewport.test.tsx | 137 ++++++++++++++++++ packages/core/src/Toast/ToastViewport.tsx | 40 ++++- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 .changeset/toast-dismiss-without-row.md diff --git a/.changeset/toast-dismiss-without-row.md b/.changeset/toast-dismiss-without-row.md new file mode 100644 index 0000000000000..abd1ab11247c3 --- /dev/null +++ b/.changeset/toast-dismiss-without-row.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A toast dismissed while it had no row on screen — hidden past `maxVisible`, or dismissed before it first rendered — now leaves the stack. Before, it stayed as an empty, invisible row that took a visible slot when it came back into view and kept the "Notifications" landmark (and its F6 stop) present after every real toast was gone. + +@vjeux diff --git a/packages/core/src/Toast/ToastViewport.test.tsx b/packages/core/src/Toast/ToastViewport.test.tsx index 4f6ea0be722c8..98305eac4db5d 100644 --- a/packages/core/src/Toast/ToastViewport.test.tsx +++ b/packages/core/src/Toast/ToastViewport.test.tsx @@ -442,6 +442,143 @@ describe('ToastViewport visible limit', () => { }); }); +describe('dismissing a toast that has no mounted row', () => { + function ShowAndKeepDismiss({ + options, + label, + onShown, + }: { + options: ToastOptions; + label: string; + onShown: (dismiss: () => void) => void; + }) { + const toast = useToast(); + return ( + + ); + } + + it('reports a hidden toast once when it is dismissed twice before the viewport renders again', () => { + const onHideA = vi.fn(); + let dismissA: (() => void) | null = null; + render( + + { + dismissA = dismiss; + }} + /> + + , + ); + act(() => { + fireEvent.click(screen.getByText('Show A')); + fireEvent.click(screen.getByText('Show B')); + }); + expect(screen.queryByText('Toast A')).toBeNull(); + + // Both calls land in one batch, before the list renders without A. + act(() => { + dismissA?.(); + dismissA?.(); + }); + expect(onHideA).toHaveBeenCalledTimes(1); + }); + + it('frees the slot of a toast dismissed while it was hidden past maxVisible', () => { + const onHideA = vi.fn(); + let dismissA: (() => void) | null = null; + render( + + { + dismissA = dismiss; + }} + /> + + + , + ); + act(() => { + fireEvent.click(screen.getByText('Show A')); + fireEvent.click(screen.getByText('Show B')); + fireEvent.click(screen.getByText('Show C')); + }); + // Only the newest two are shown: A is hidden past the limit. + expect(screen.queryByText('Toast A')).toBeNull(); + + act(() => { + dismissA?.(); + }); + expect(onHideA).toHaveBeenCalledTimes(1); + expect(onHideA).toHaveBeenCalledWith('manual'); + + // C leaves. A was dismissed, so it must not come back to hold the slot + // as an empty row: B is the only toast left. + const toastC = getToastWrapperByText('Toast C'); + act(() => { + fireEvent.click( + within(toastC).getByRole('button', {name: 'Dismiss notification'}), + ); + }); + act(() => { + completeExit(toastC.dataset.toastId ?? ''); + }); + expect(document.querySelectorAll('[data-toast-id]')).toHaveLength(1); + expect(screen.getByText('Toast B')).toBeInTheDocument(); + + // Once B leaves too, nothing is left: no row, no empty landmark. + const toastB = getToastWrapperByText('Toast B'); + act(() => { + fireEvent.click( + within(toastB).getByRole('button', {name: 'Dismiss notification'}), + ); + }); + act(() => { + completeExit(toastB.dataset.toastId ?? ''); + }); + expect(document.querySelectorAll('[data-toast-id]')).toHaveLength(0); + expect(screen.queryByRole('region', {name: 'Notifications'})).toBeNull(); + expect(onHideA).toHaveBeenCalledTimes(1); + }); + + it('never shows a toast dismissed before it rendered, and reports it once', () => { + const onHide = vi.fn(); + render( + + dismiss()} + /> + , + ); + act(() => { + fireEvent.click(screen.getByText('Show and dismiss')); + }); + + expect(document.querySelectorAll('[data-toast-id]')).toHaveLength(0); + expect(screen.queryByRole('region', {name: 'Notifications'})).toBeNull(); + expect(onHide).toHaveBeenCalledTimes(1); + expect(onHide).toHaveBeenCalledWith('manual'); + }); +}); + describe('ToastViewport keyboard reach + focus', () => { it('F6 moves focus into the newest toast', () => { renderViewport( diff --git a/packages/core/src/Toast/ToastViewport.tsx b/packages/core/src/Toast/ToastViewport.tsx index 7202d4082725a..f91e443541fad 100644 --- a/packages/core/src/Toast/ToastViewport.tsx +++ b/packages/core/src/Toast/ToastViewport.tsx @@ -348,6 +348,9 @@ export function ToastViewport({ // Toast ids whose exit has begun — guards onHide from double-firing (see // removeToast). Mirrors exitingIds state, readable synchronously. const exitingIdsRef = useRef>(new Set()); + // Toasts dispatched since the last commit, by id: a dismiss() called + // before the toast first renders still finds it (and its onHide). + const pendingEntriesRef = useRef>(new Map()); // The element that was focused before the user jumped into the viewport // (via F6). Used to restore focus once all toasts are dismissed so focus // never falls to . @@ -423,6 +426,16 @@ export function ToastViewport({ // else to the polite region (role="status") — mirrors Toast.tsx. announce(text, entry.options.type === 'error' ? 'assertive' : 'polite'); } + const pending = pendingEntriesRef.current; + if (uniqueID) { + // An overwrite replaces a toast that has not rendered yet too. + for (const [pendingId, pendingEntry] of pending) { + if (pendingEntry.options.uniqueID === uniqueID) { + pending.delete(pendingId); + } + } + } + pending.set(entry.id, entry); // An overwrite swaps a new entry, with a new id, into the replaced // toast's place, so the old row leaves the DOM without ever being // dismissed. Its settled id is dropped by the prune below, which covers @@ -449,11 +462,15 @@ export function ToastViewport({ if (exitingIdsRef.current.has(id)) { return; } - exitingIdsRef.current.add(id); - const entry = toastsRef.current.find(t => t.id === id); - if (entry) { - entry.options.onHide?.(reason); + const entry = + toastsRef.current.find(t => t.id === id) ?? + pendingEntriesRef.current.get(id); + if (entry == null) { + // Already gone: nothing to hide or report. + return; } + exitingIdsRef.current.add(id); + entry.options.onHide?.(reason); // If focus currently lives inside the toast being dismissed, remember // that its removal must hand focus off to a neighbor (or the element // focused before the user entered the viewport) rather than . @@ -480,6 +497,20 @@ export function ToastViewport({ pendingFocusRef.current = 'restore'; } } + if (dismissedNode == null) { + // No row is mounted for it: it is hidden past `maxVisible`, or it has + // not rendered yet. Nothing animates out, so it leaves the list now. + // Waiting for its row to finish exiting would keep it forever: when it + // surfaced, its row would mount already collapsed, with no transition + // left to end, and hold a visible slot as an empty row. + // It leaves the committed list now too, so a second dismissal before + // the next render finds nothing to report. + exitingIdsRef.current.delete(id); + pendingEntriesRef.current.delete(id); + toastsRef.current = toastsRef.current.filter(t => t.id !== id); + setToasts(prev => prev.filter(t => t.id !== id)); + return; + } setExitingIds(prev => { if (prev.has(id)) { return prev; @@ -490,6 +521,7 @@ export function ToastViewport({ const handleExited = useCallback((id: string) => { exitingIdsRef.current.delete(id); + pendingEntriesRef.current.delete(id); setExitingIds(withoutId(id)); setToasts(prev => prev.filter(t => t.id !== id)); }, []);