Skip to content

fix(Toast): a toast plays its entrance once - #10086

Merged
cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c18-toast-entrance-once
Nov 5, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c18-toast-entrance-once

Conversation

@vjeux

@vjeux vjeux commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

User impact

When a Dialog, a modal Drawer or a bottom sheet with a scrim opens or closes while a toast shows, the toast fades out and slides in a second time. On a phone it reads as a flicker: the toast settles, jumps back to its entry offset, and enters again. Main does this on every open or close of such a modal over a toast. AlertDialog's touch exit (#8084) would also do it on its main path: the alert stays open while it fades, so a toast its action raises enters inside the alert and moves out when the fade ends.

Problem and solution fit

  • Why does it happen? A top-layer ToastViewport moves its host element into the latest open native modal and back out. Moving a node renders it again. The toast row's and the card's entrance are CSS @starting-style rules, which apply to every render of an element that had no style before, so each move plays the entrance again.
  • Why does it harm the task? The toast seems to arrive twice, and its text and its action jump while the user reads them or reaches for them.
  • Why does that matter? The record promises one entrance for a new card and a viewport that keeps each toast across a move. A replayed entrance breaks both, and a moving target is harder to tap.

The fix keeps the starting style only for a toast's first render, so a move renders the toast at rest.

Expected behavior and authority

  • component:Toast FR5: "A new card slides in from its edge and fades in".
  • component:Toast FR12: a top-layer viewport moves into the latest open modal and back, "keeping each toast, its timer, and focus inside the viewport".

The record already says this, so it does not change.

Smallest restoration

  • A toast row keeps its starting style only until its entry transition starts (the row's own transitionrun for grid-template-rows, or its settle). Then the row drops its closed-track start, and it sets two custom properties that the card's starting style reads, so a later render starts the card at rest.
  • A standalone Toast card, outside a viewport row, keeps today's starting style through the custom properties' fallbacks.
  • A move during a toast's own entrance finishes the entrance at once instead of playing it again. The move cancels the row's entry transition, so no transition ends; the row settles on that cancel, releasing its clip so the card's shadow paints.
  • No API change, and a toast's first entrance looks the same.

The layer runtime's preserveAnimations carries animations across a display reset, but it cannot serve this move for two reasons. It cannot tell a toast whose entrance has not started from one that settled long ago: neither has an animation to save, so it would either drop a first entrance or play a settled one's again. Only the row knows that its entry has started. And it resumes a cancelled transition as a script animation, which fires no transitionend, while the row's settle and its exit both wait for that event.

Evidence

  • Reproduction before the change: per-frame samples of the card (time ms: opacity / slide px, D inside the dialog, R at the root) on main's "App toast over dialog" story in Chromium. Closing the dialog over a toast that is still entering: 31:0/8D … 131:1/0D 148:0/8R … 248:1/0R. Over a toast that has settled: 14:1/0D 31:0/8R … 147:1/0R. WebKit shows the same.
  • Result after the change: on a Storybook built from main, the three move cases below fail three runs out of three in Chromium and in WebKit (two entrances, or the opacity back to 0); on this head all four pass. On a build of this change without the settle on cancel, the move-during-entrance case fails three out of three in each engine: the row keeps its clip.
  • Representative unchanged path: a new toast still fades in from nearly clear (the first case), and a dismissed toast still collapses (the existing viewport tests).
  • Regression test: Toast/__tests__/ToastViewport.hostMove.interaction.browser.spec.ts records the card on each frame: its opacity, where it is (in the dialog or the page), and its opacity transitions. Its cases are "a new toast plays its entrance once", "a toast moved during its entrance finishes it once and settles" (with a slowed entrance, so the move lands inside it), "a toast already shown stays still when the Dialog that hosts it closes" and "… when a Dialog opens over it". Each move case first checks that the card was recorded on both sides of the move, and that the row is shown with a height after it.
  • The AlertDialog path: the not-for-landing commit a4deb85 on the fork's evidence/c18-toast-alert-path branch holds the same recorder for AlertDialog's "Alert over a toast" story (a finger taps Delete, and the alert fades out over the toast it raised). On a build of feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 at 62c9b4a it fails three runs out of three in Chromium and in WebKit (the toast fades back to 0 and enters again). On feat(AlertDialog): under a finger the alert appears and leaves the way iOS's does #8084 merged with this head it passes three out of three in each. The toast keeps its own pixels while the alert fades: the alert's opacity does not reach it in either engine, so no second entrance and no dimming remain.

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

Local gates at 8bbf387, on main 8fdf6be:

  • The new spec: Chromium twice and pinned to 2 cores three times, 4 of 4 each; WebKit in CI's container five times, 20 of 20. The bottom sheet's and the drawer's toast outlet accessibility contracts (6) and the Toast, Layer, Dialog, Drawer and BottomSheet units (1,314) pass. The four cases also pass on a build merged with fix(Toast): a quick flick dismisses a toast of any height #8047's head.
  • CI=true pnpm lint (no errors), the build, pnpm typecheck, Storybook, pnpm test:ui (21,134), pnpm test:node (16,655), check:knowledge, check:repo and specs:for --diff pass. api:report --check reports drift already on main (none of it in Toast).

@vercel

vercel Bot commented Nov 5, 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 Nov 5, 2026 6:50pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 5, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 5, 2026
@astracat-bot

astracat-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 6:43 PM UTC

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · d1e2ffd

This branch's preview address cannot be known in advance; open it from Vercel's comment.

Modified components

Component Size LoC Visual · A11y · RTL
Toast 64.5 kB (+1 B) · 9.1 kB CSS (+14 B) 1,574 (+31) — · — · —

Package size: @astryxdesign/core 6.61 MB packed (+978 B); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 19 rows, core 23 rows; run pnpm api:report.

Wide change (shared styles: packages/core/src/theme/derivedVarRegistry.test.ts): add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

The fix restores component:Toast FR5 and FR12: the new browser spec's three move cases fail on main and pass on this branch, and the Drawer and bottom-sheet stories show the same before and after.

Two optional comment suggestions are inline on ToastViewport.tsx (the hasEntered name and the transitionrun listener note).

[Automated review]

Comment on lines +274 to +276
// The row's entry has started. The viewport moves between modal outlets, and
// each move renders the row again, so its starting style must not apply.
const [hasEntered, setHasEntered] = useState(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional (comment style): the first sentence restates the flag, and transitionrun sets it when the entry starts, so hasEntered says more than it holds. Consider hasEntryStarted and keeping only the reason:

// Each move between modal outlets renders the row again. Its starting style
// must not apply then.
const [hasEntryStarted, setHasEntryStarted] = useState(false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at dbfdaa1: the state is hasEntryStarted, and its comment keeps only the reason.

Comment on lines +277 to +278
// React passes `transitionrun` without its property name, so the row
// listens for it on the element.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional: React 19 does deliver this event as onTransitionRun; only its synthetic event lacks propertyName, and event.nativeEvent.propertyName carries it. A handler next to the existing onTransitionEnd could replace the native listener and this comment. If the listener stays, the quirk reads more exactly as:

// React's onTransitionRun event has no propertyName field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at dbfdaa1: the comment now reads as you suggest. The native listener stays: onTransitionRun would need a disable for @eslint-react/dom-no-unknown-property, which flags it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The fix stops the replayed entrance for a toast already on screen when a Dialog, a modal Drawer or a scrim BottomSheet opens or closes, but two issues need fixing first:

  • Miss: a toast whose viewport moves during its own 175 ms entrance never settles, because the move cancels the row's grid-template-rows transition and isSettled is set only on transitionend, so its wrapper keeps overflow: hidden and the card's shadow stays cut off for as long as the toast shows (ToastViewport.tsx:299, :321).
  • Miss: the row's new transitionrun listener, hasEntryStarted flag and two private start properties duplicate the animation carry that Layer/preserveAnimations.ts already owns, so extend preserveAnimations (it currently loses a first entrance whose transition was cancelled before it started) and call it once around the host move in place(), or say in the pull request why it cannot serve this move.

Either fix should come with a check that a row moved during its entrance settles, which the two move-during-entrance cases in the new spec do not assert today. CI has not run on this head yet: once the pull request is ready, a maintainer adds the ci:run label so CI runs.

[Automated review]

data-toast-id={entry.id}
{...stylex.props(
styles.toastWrapper,
!hasEntryStarted && styles.toastWrapperEntering,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss: once this drops the starting style, a host move during the entrance cancels the row's grid-template-rows transition (transitioncancel) and starts none, so onTransitionEnd never sets isSettled and the inner wrapper keeps overflow: hidden: the card's shadow stays cut off until the toast leaves. If the local approach stays, settling the row on that cancel would cover it, for example inside watchEntry:

const onCancel = (event: TransitionEvent) => {
  if (event.target === row && event.propertyName === 'grid-template-rows') {
    // A move cancels the entry; the row then renders open with no transition.
    setIsSettled(true);
  }
};
row.addEventListener('transitioncancel', onCancel);

The "enters once" and "…when a Dialog opens over it" cases both move the row during its entrance, so a check that the row settles there (for example the inner wrapper's computed overflow) would catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at 38b1799: the row settles on its own transitioncancel for grid-template-rows. The move-during-entrance case now slows the entrance and checks that the clip turns visible; it fails without the fix in both engines.

setHasEntryStarted(true);
}
};
row.addEventListener('transitionrun', onRun);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Miss (one shared path): Layer/preserveAnimations.ts already carries a layer's CSS transitions across a reset and finishes a recreated @starting-style transition. Wrapping the host move in place() with preserveAnimations(host, () => …) keeps a shown toast still in the Dialog close and open cases and releases the clip, but it loses the first entrance of a toast raised in the same task as the close, so the shared owner needs that one extension. Extending it and calling it once from place() would replace this listener, hasEntryStarted and the two private start properties; if it cannot serve the host move, please say why in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept local, and the description now says why. preserveAnimations cannot tell a toast whose entrance has not started from one that settled long ago, since neither has an animation to save. And its resumed script animation fires no transitionend, which the row's settle and exit wait for.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is ready for review, a maintainer adds the ci:run label so CI runs.

The fix restores component:Toast FR5 and FR12: the new browser spec's three move cases fail on main and pass on this branch, a toast moved during its entrance now settles with its shadow, and the description explains why the shared animation carry cannot serve this move.

One optional comment-wording suggestion is inline on ToastViewport.tsx.

[Automated review]

Comment on lines +190 to +191
// The card's starting style reads these. A card that has entered then
// starts where it is when a move renders it again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional (comment wording): these properties are set when the entrance starts, and a card moved during its entrance starts at rest rather than where it is. Consider:

// The card's starting style reads these. Once the entrance starts, a move
// renders the card at rest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 797b0b3: the comment now reads as you suggest, and the commit message and description say "at rest" too.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is ready for review, a maintainer adds the ci:run label so CI runs.

The fix restores component:Toast FR5 and FR12: the new browser spec's three move cases fail on main and pass on this branch, also when merged with current main, and a Dialog, modal Drawer or scrim BottomSheet opening or closing over a toast no longer replays its entrance.

One optional wording suggestion for the two private doc entries is inline on Toast.doc.mjs.

[Automated review]

Comment thread packages/core/src/Toast/Toast.doc.mjs Outdated
Comment on lines +132 to +139
'Private starting opacity of the entry; ToastViewport sets it to 1 once the toast has entered',
default: '0',
private: true,
},
{
name: '--_toast-start-slide',
description:
'Private starting offset of the entry; ToastViewport sets it to 0px once the toast has entered',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional (wording): ToastViewport sets both properties when the toast's entrance starts (its transitionrun), not once it has entered, so these two descriptions could match the source comment you already corrected:

'Private starting opacity of the entry; ToastViewport sets it to 1 once the entrance starts'
'Private starting offset of the entry; ToastViewport sets it to 0px once the entrance starts'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done at 8bbf387: both descriptions now say "once the entrance starts".

A top-layer ToastViewport moves its host into the latest open modal and
back out. Each move renders the toast's row again, and the row's and the
card's `@starting-style` played the entrance again. So a toast already
on screen faded out and slid in a second time whenever a Dialog, a modal
Drawer or a scrim sheet opened or closed over it.

A row now keeps its starting style only until its entry transition
starts. Then the row drops its closed-track start, and the card's start
reads the row's custom properties, so a later move renders the card
at rest. A move during a toast's own entrance finishes it at once
instead of playing it again. That move cancels the row's entry
transition, so no transition ends, and the row settles on the cancel:
it releases its clip and the card's shadow paints.

AlertDialog's touch exit (meta#8084) keeps the alert open while it fades,
so a toast its action raises enters inside the alert and moves out when
the fade ends. This fix keeps that toast from entering twice.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet. The required checks are waiting for a maintainer to add the ci:run label, so the next step is that label.

The fix restores component:Toast FR5 and FR12: the new browser spec's three move cases fail on main and pass on this branch in Chromium and WebKit, also when merged with current main, and a Dialog, modal Drawer or scrim BottomSheet opening or closing over a toast no longer replays its entrance.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 5, 2026 17:26
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 5, 2026
@cixzhang
cixzhang marked this pull request as draft November 5, 2026 17:40
@cixzhang
cixzhang marked this pull request as ready for review November 5, 2026 17:40

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved.

The fix restores a single entrance for a toast whose viewport moves into or out of a Dialog, a modal Drawer or a scrim BottomSheet (component:Toast FR5 and FR12, spec:AST-003 FR19). The new browser spec's three move cases fail on main and pass on this branch in Chromium and WebKit, the merge of main leaves the fix unchanged, and CI passes on this head.

[Automated review]

@cixzhang
cixzhang merged commit 813c58c into meta:main Nov 5, 2026
66 checks passed
@vjeux
vjeux deleted the uikit/c18-toast-entrance-once branch November 5, 2026 19:53

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants