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