Skip to content

[menu] Play the enter transition for an initially open submenu - #4383

Merged
michaldudak merged 9 commits into
mui:masterfrom
michaldudak:defer-submenu-open
Aug 17, 2026
Merged

[menu] Play the enter transition for an initially open submenu#4383
michaldudak merged 9 commits into
mui:masterfrom
michaldudak:defer-submenu-open

Conversation

@michaldudak

@michaldudak michaldudak commented Mar 19, 2026

Copy link
Copy Markdown
Member

Summary

A submenu with defaultOpen appears fully formed while its parent menu is still animating in, so it reads as a detached panel floating next to an invisible menu.

Fixes #4224.

Note

This PR was repurposed. It previously deferred the submenu's reveal until the parent's open animation finished, to fix the submenu "teleporting" into place. That symptom no longer reproduces on master — positioning now tracks a moving anchor to within ~1px (measured in Chromium: median 0.7px, max 1.3px deviation from the submenu trigger across a parent scale animation). What remains of #4224 is the missing enter transition, which is what this PR now fixes. The previous approach has been dropped entirely.

Root cause

useTransitionStatus initialises mounted to open:

const [mounted, setMounted] = React.useState(open);

if (open && !mounted) {
  setMounted(true);
  setTransitionStatus('starting');
}

A popup that mounts already open has mounted === true on its first render, so the branch that produces the 'starting' phase never runs. [data-starting-style] is never applied and no enter transition plays.

That is deliberate for page-load content — a defaultOpen popup or SSR'd markup shouldn't animate in on every load. But a submenu's subtree only mounts once its parent popup opens, so its appearance is something the user watches happen, and it should animate like everything else in that popup.

Measured on master (frames carrying [data-starting-style]):

popup frames
root, opened by click 1 / 32
root, defaultOpen 0 / 36
submenu, defaultOpen 0 / 32

Solution

useTransitionStatus gains an opt-in animateInitialOpen that starts mounted at false, so the existing open && !mounted branch runs on the first render. React re-renders before committing, so this costs no extra frame and needs no synthesized transition status — the popup goes through the normal 'starting' phase.

It is threaded through useOpenStateTransitions, and only MenuRoot passes it — only when the parent menu popup is itself mid enter transition at the submenu's first render (parent.type === 'menu' and the parent store's transitionStatus is 'starting'). Keying off the parent's live 'starting' phase rather than off being a submenu means a submenu animates exactly when its mount is part of a reveal the user is watching: a parent popup that was already open on the first page render (defaultOpen at page load, keepMounted) never passes through 'starting', so its submenu remains page-load content and doesn't animate. Every other popup, and a defaultOpen root menu, keeps today's behaviour.

An initially open submenu also seeds its store's instantType from the parent, so when the parent opened instantly (e.g. a keyboard click), [data-instant] is present on both popups and consumer styling suppresses both enter transitions or neither.

Not covered

With a keepMounted parent the submenu's subtree already exists at page load, so it mounts open during the initial render and appears without a transition when the parent later opens. Closing that gap needs a visibility-based signal rather than a mount-based one, which is a larger change to shared transition machinery. A test pins the current behaviour to document the gap.

Tests

Six Chromium tests in MenuPopupEnterTransition.test.tsx, verified to discriminate — on master the first fails, and with the parent-transition gating loosened back to plain parent.type === 'menu' the two page-load tests and the instant test fail:

  • a submenu open when its parent opens plays the enter transition
  • a defaultOpen root menu still does not play it
  • a defaultOpen submenu inside a defaultOpen root menu does not play it at page load
  • a defaultOpen submenu under a keepMounted parent does not play it (pins the gap above)
  • a user-opened submenu still plays it
  • an initially open submenu inherits data-instant from an instantly-opened parent

The per-frame [data-starting-style] sampler uses the managed AnimationFrame scheduler, exposes a non-destructive seen() accessor for waitFor retries, and is stopped in afterEach, so a failing test can't leak a polling loop into later tests.

Full suites: Menu 1048 passed (chromium) / 877 passed (jsdom), Popover 473 passed, popupStoreUtils 31 passed.

docs/src/app/(private)/experiments/menu/default-open-submenu.tsx is included as a repro harness, with the transition slowed to 2s so the enter animation is easy to see. Happy to drop it if experiments aren't wanted here.

@michaldudak michaldudak added the component: menu Changes related to the menu component. label Mar 19, 2026
@pkg-pr-new

pkg-pr-new Bot commented Mar 19, 2026

Copy link
Copy Markdown

commit: 97eedac

@netlify

netlify Bot commented Mar 19, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 97eedac
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a82c80cca1a9b0008d00e52
😎 Deploy Preview https://deploy-preview-4383--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Apr 1, 2026
A submenu with `defaultOpen` appeared fully formed while its parent menu was
still animating in, so it looked like a detached panel floating next to an
invisible menu.

`useTransitionStatus` initialises `mounted` to `open`, which means the
`open && !mounted` branch that produces the `'starting'` phase never runs for a
popup that mounts already open. No `[data-starting-style]`, no enter
transition. That is deliberate for page-load content, but a submenu's subtree
only mounts once its parent popup opens, so its appearance is something the
user watches happen.

`useTransitionStatus` gains an opt-in `animateInitialOpen`, threaded through
`useOpenStateTransitions`, that starts `mounted` at `false` so the existing
branch runs on the first render. React re-renders before committing, so this
costs no extra frame and no synthesized transition status. Only `MenuRoot`
passes it, and only for submenus; every other popup keeps today's behaviour.

Known gap: with a `keepMounted` parent the submenu subtree already exists at
page load, so it still skips the transition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 14, 2026
@michaldudak michaldudak changed the title [menu] Defer showing submenu until parent animation is finished [menu] Play the enter transition for an initially open submenu Aug 14, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+471B(+0.10%) 🔺+152B(+0.10%)

Details of bundle changes

Performance

Total duration: 1,453.74 ms -132.22 ms(-8.3%) | Renders: 76 (+0) | Paint: 2,236.13 ms -200.10 ms(-8.2%)

Test Duration Renders
Menu open (500 items) 115.13 ms 🔺+31.23 ms(+37.2%) 11 (+0)
Tabs mount (200 instances) 215.76 ms ▼-75.94 ms(-26.0%) 3 (+0)
Checkbox mount (500 instances) 75.26 ms ▼-30.95 ms(-29.1%) 1 (+0)
Tooltip mount (300 contained roots) 53.05 ms ▼-26.34 ms(-33.2%) 1 (+0)
Dialog mount (300 instances) 49.72 ms ▼-23.57 ms(-32.2%) 1 (+0)

…and 1 more (+9 within noise) — details


Check out the code infra dashboard for more information about this PR.

@michaldudak
michaldudak marked this pull request as ready for review August 14, 2026 07:31

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e52263ecb4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/menu/root/MenuRoot.tsx Outdated
Comment thread packages/react/src/menu/popup/MenuPopupEnterTransition.test.tsx Outdated
michaldudak and others added 2 commits August 14, 2026 10:05
Review follow-up:

- Gate `animateInitialOpen` on the parent popup being mid enter transition
  (`transitionStatus === 'starting'`) instead of on being a submenu, so a
  `defaultOpen` submenu inside a menu that is open on the first page render
  no longer animates at page load.
- Seed the submenu store's `instantType` from the parent when it mounts
  already open, so `[data-instant]` styling suppresses the enter transition
  on both popups or neither (e.g. keyboard opens).
- Harden the test frame sampler: use the managed AnimationFrame scheduler,
  expose a non-destructive `seen()` accessor so `waitFor` retries observe a
  live value, and stop trackers in `afterEach` so a failing test cannot leak
  a per-frame DOM polling loop into later tests.
- Add tests for the page-load, keepMounted (pins the known limitation), and
  instant-parent scenarios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Gate `animateInitialOpen` on the submenu being open at mount so a
  closed submenu doesn't seed an `instantType` that a later controlled
  open would never clear
- Write `instantType` in the same store update that mounts the popup
  subtree so a React 17 legacy-mode sync flush can't mount the submenu
  in between and read the previous cycle's value
- Wrap the negative tests' waits in `act` so pending transition-status
  updates don't trip `vitest-fail-on-console`
- Correct the gating comment: a `keepMounted` parent does pass through
  `'starting'`; its submenu doesn't animate because these initializers
  already ran at page load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1411888bfc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/menu/root/MenuRoot.tsx Outdated
…ransition

A controlled open={false} must win over defaultOpen when deciding whether an
initially open submenu animates in, so a closed submenu cannot seed an
instantType it would never clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 667f0c61ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/menu/popup/MenuPopupEnterTransition.test.tsx Outdated
Comment thread packages/react/src/menu/root/MenuRoot.tsx Outdated
The seeded value must not outlive the initial reveal: controlled open flips
bypass setOpen, so a lingering value would render [data-instant] on every
subsequent open and suppress transitions that should play. Cleared once the
enter phase settles, following the MenuPositioner trigger-change pattern.

Also replaces the raw setTimeout waits in the enter-transition tests with the
managed Timeout scheduler, cleared in afterEach, so an interrupted test cannot
leak a pending callback into a later one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 171eb1afe9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/menu/root/MenuRoot.tsx Outdated
With the popup subtree omitted at mount (suspended or waiting on data),
useAnimationsFinished returns without invoking its callback and a later ref
assignment never reruns the clearing effect, so the seeded instantType would
stick and mark a late-rendered popup as instant. Clear immediately in that
case: a popup appearing after the reveal settles is page-load-like content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d643428d09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react/src/menu/root/MenuRoot.tsx Outdated
michaldudak and others added 2 commits August 17, 2026 10:16
A controlled close during the initial enter aborts the pending
animations-finished cleanup, and a closed popup schedules no replacement, so
the seeded instantType survived and suppressed the next controlled reopen's
transition. Clear the seed as soon as closing starts: the exit's suppression
is already decided at its trigger commit, so nothing is left to protect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mui mui deleted a comment from mui-bot Aug 17, 2026
@mui mui deleted a comment from netlify Bot Aug 17, 2026
@michaldudak
michaldudak merged commit 25cb5ba into mui:master Aug 17, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: menu Changes related to the menu component.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[menu] Fix submenu position jumping during parent animation

1 participant