Repository navigation
fix(Toast): a toast dismissed with no row on screen leaves the stack #8050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Set<string>>(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<Map<string, ToastEntry>>(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 <body>. | ||
|
|
@@ -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 <body>. | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Releasing the guard here while |
||
| 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)); | ||
| }, []); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional, non-blocking: with the commit-time effect removed, nothing prunes this map at commit. An entry now leaves only through the
uniqueIDloop inaddToast, the no-row branch inremoveToast, orhandleExited, so the map holds every live toast, committed or not. Something like// Every toast dispatched and not yet removed, by id: a dismiss() called before the toast first renders still finds it (and its onHide).would match the code.