Skip to content

fix(Toast): a toast dismissed with no row on screen leaves the stack - #8050

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c25-evicted-dismiss
Oct 28, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c25-evicted-dismiss

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

User impact

A toast dismissed while it has no row on screen never leaves the stack, when an app calls the dismiss() from useToast():

  • for a toast hidden past maxVisible, or
  • right after showing it, before its first render.

It stays as an invisible row that holds a visible slot; after every real toast is gone, the "Notifications" landmark stays and F6 focuses an empty viewport.

Problem and solution fit

  1. Why it stays. A dismissed toast leaves the list when its row's collapse transition ends; a toast with no mounted row has none. When it surfaces, its row mounts collapsed, so nothing ends it, and a second dismiss() returns early (already leaving).
  2. Before the first render, dismiss() did not find the toast, so onHide never fired.
  3. Why it matters. Screen-reader (landmarks) and keyboard (F6) users land on an empty region all session.

Expected behavior and authority

  • architecture:layer-runtime (current): ToastViewport owns the entries, timers and focus handoff.
  • The existing test "does not expose an empty notifications landmark" (ToastViewport region ARIA).
  • ToastOptions.onHide: "Callback fired when the toast is removed."

Smallest restoration

  • A dismissed toast with no mounted row leaves the list at once; nothing on screen animates out.
  • A toast not yet rendered stays findable until it commits, so onHide fires once.
  • Unchanged: an on-screen toast's exit, focus handoff, the double-dismiss guard, overwrite and ignore.

Evidence

Mobile Safari, iOS 26.2 simulator, "Programmatic dismiss" story: a persistent toast, then five shown and dismissed (labels drawn by the test page). Left, main: the five stay as invisible rows (the label counts them) and push the toast out. Right: it stays.

before after

  • Reproduction on main (maxVisible 2, toasts A, B, C; dismiss() hidden A; close C): A returns as an empty row beside B; after B closes, the row and landmark remain. toast() then dismiss() in one handler leaves the same row, with no onHide.
  • After: only B remains, then nothing and no landmark; onHide fires once in both cases, even for two dismiss() calls in one batch.
  • Unchanged: the visible-limit, exit, focus and announcement suites.
  • Regression tests: three new tests fail on main and pass here.

Related: touches adjacent lines with #8047 and #8051; whichever lands second rebases.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/Toast/: 3 files, 80 tests passed. The three new tests fail on main; the double-dismiss one also fails on the previous head.
  • pnpm -F @astryxdesign/core typecheck: pass.
  • Size (architecture:component-style-authoring INV6): Toast's import, bundled with React and StyleX external and minified, is 51,488 bytes gzip on main and +73 bytes here; no CSS change.
  • ESLint and Prettier on the touched files: clean. The pre-commit checks pass (check:changesets, check:sync, check:knowledge, …).
  • State and list defect with no layout dependency: covered by the jsdom tests above.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 24, 2026 10:31pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 24, 2026, 11:12 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

Toast (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) N/A N/A N/A
Lines of Code N/A 1382 -
Complexity N/A Very High (145) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

46 of 494 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
RichTextEditor Markdown parity: side by side probe dark 94,866
RichTextEditor Markdown parity: side by side probe light 89,762
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
Markdown Default neutral dark 7,573
Markdown Default neutral light 7,566

and 26 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix works: a toast dismissed while hidden past maxVisible, or before its first render, now leaves the stack, the next toast surfaces, and the empty Notifications region is gone. Two changes are needed before approval:

  • A hidden toast dismissed twice before the viewport re-renders now fires onHide twice, because the no-row branch removes the id from exitingIdsRef while toastsRef still lists the toast; also dropping it from toastsRef (or keeping the guard until the list commits) fixes it, and a regression test for that case would pin it.
  • The new commit-time useLayoutEffect does not name an external system as architecture:react-component-runtime INV3 requires; every exit path already deletes the pending copy, so it can be removed, or its comment can name what it synchronizes.

[Automated review]

// 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.
exitingIdsRef.current.delete(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Releasing the guard here while toastsRef.current still lists the toast (it is only reassigned on the next render) lets a second dismiss() in the same batch pass the check at line 462 and call onHide again. With maxVisible={1} and two dismissA() calls in one act(), onHide runs twice on this branch and once on main. Dropping the entry from toastsRef.current here, or keeping the id in exitingIdsRef until the list commits, fixes it; a regression test for the double call would pin it.

Comment on lines +526 to +532
// Once committed, a toast is found in the list; forget its pending copy.
useLayoutEffect(() => {
const pending = pendingEntriesRef.current;
for (const entry of toasts) {
pending.delete(entry.id);
}
}, [toasts]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

architecture:react-component-runtime INV3 asks every new Effect to name the external system it synchronizes; this one reconciles a private map with React's own commit. It is also not load-bearing: handleExited, the no-row branch and the uniqueID loop already delete a toast's pending copy when it leaves the list, and the Toast suite and both new tests pass without it, so it can be removed (or kept with a comment naming what it synchronizes).

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.
@vjeux
vjeux force-pushed the uikit/c25-evicted-dismiss branch from 13228e6 to 46755a5 Compare October 24, 2026 22:23
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Both findings are fixed at 46755a5.

  • Double onHide. The no-row branch now also removes the toast from the committed list (toastsRef.current), so a second dismiss() in the same batch finds nothing to report. New test, "reports a hidden toast once when it is dismissed twice before the viewport renders again" (maxVisible={1}, two dismiss() calls in one act()): onHide runs twice on the previous head and once here.
  • The commit-time layout effect is removed. Every exit path already deletes the pending copy, and the Toast suite (80 tests) passes without it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending approval: a toast dismissed while it is hidden past maxVisible, or before its first render, now leaves the stack, the slot it held is freed, and the empty Notifications landmark is gone, with onHide firing once on each path. Both findings from the earlier review are fixed in this revision. One optional cleanup: the comment on pendingEntriesRef still says it holds toasts dispatched since the last commit, but with the layout effect removed it holds every toast until that toast leaves the list.

[Automated review]

Comment on lines +351 to +352
// Toasts dispatched since the last commit, by id: a dismiss() called
// before the toast first renders still finds it (and its onHide).

Copy link
Copy Markdown

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 uniqueID loop in addToast, the no-row branch in removeToast, or handleExited, 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.

@cixzhang
cixzhang merged commit 0af8ee1 into meta:main Oct 28, 2026
65 checks passed
@vjeux
vjeux deleted the uikit/c25-evicted-dismiss branch October 28, 2026 17:52
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
meta#8050, now on main, tracks toasts dispatched before they render
(pendingEntriesRef) so a dismissal still finds them, and lets a toast with
no mounted row leave the list at once. This pull request moves toastsRef
ahead of the render and keeps the toasts a repeat replaced in the batch.
Both are kept: a dismissal finds a toast in toastsRef, then among the
replaced ones, then among the pending ones.

Two paths had to leave every list for the union to report onHide once
(red first): a leaving toast that a new one pushes out of view leaves
the pending map too, and a toast with no mounted row leaves the replaced
ones too, so a toast a repeat replaced before it rendered is reported
once when one handler dismisses it twice.

This branch was successfully deployed

1 active deployment
Preview — 46755a5e Deployed Oct 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants