[widgets/raw_menu_anchor.dart] Always call onClose and onCloseRequested on descendants before parent.#182357
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the menu closing logic in RawMenuAnchor to ensure that descendant menus always receive close requests before their parents. The _RawMenuAnchorBaseMixin.closeChildren method has been split into closeChildren (which now always calls close() on children) and a new closeChildrenWithRequest() method (which calls handleCloseRequest() on children). Several call sites have been updated to use closeChildrenWithRequest(). The core change is in _RawMenuAnchorState.handleCloseRequest, which now calls closeChildrenWithRequest() to propagate the close request down the tree before handling the close for the current menu. This change fixes a potential race condition in nested menus. Correspondingly, redundant calls to close children have been removed from example and Material library code. New tests have been added to verify the correct closing order, and existing tests have been updated to reflect the new behavior.
dkwingsmt
left a comment
There was a problem hiding this comment.
Overall LGTM.
Honestly I think the PR description might be over complicated and I don't really understand what it has to do with "if onCloseRequested was defined by a submenu and that submenu didn't immediately call hideMenu()". But I think the change is pretty easy to understand. Basically a close is immediate and should close children immediately before closing itself, while a closeRequest is async and should closeRequest children, which makes perfect sense.
| void handleCloseRequest(); | ||
|
|
||
| /// Request that the submenus of this menu be closed. | ||
| /// Close the open submenus of this menu.. |
There was a problem hiding this comment.
| /// Close the open submenus of this menu.. | |
| /// Close the open submenus of this menu. |
| /// This method will call [handleCloseRequest] on each child of this | ||
| /// menu, which will trigger the closing sequence of each child. | ||
| @protected | ||
| void closeChildrenWithRequest() { |
There was a problem hiding this comment.
nit: Will a name like requestChildrenClose be clearer and more recognizable from closeChildren? I'm fine with the current name as well, just providing another option.
There was a problem hiding this comment.
I went with closeChildrenWithRequest to indicate to the future editors that they should pay attention to the differentiator between the two functions (i.e. they both closeChildren, so users should pay attention to what makes one a request). That said, I was between those two. Your suggestion is more accurate since calling requestChildrenClose doesn't guarantee the children will close. Let me know what you prefer and I'll add it.
There was a problem hiding this comment.
+1 to @dkwingsmt , I think we should prioritize readaibiity here
| @@ -470,6 +470,92 @@ void main() { | |||
| expect(find.text(Tag.b.a.text), findsNothing); | |||
| }); | |||
|
|
|||
There was a problem hiding this comment.
Just to make sure, do we have a test that verifies that the root's onClose is called after the entire subtree is closed in an async way?
There was a problem hiding this comment.
Like onCloseRequest() -> wait 1 second -> hideMenu()? I'll add it in if we don't have one. The dismiss test I refactored did test for async but was so convoluted that I decided to simplify it down to checking that the dismiss occurred.
There was a problem hiding this comment.
It was just that, while reading the code, I wondered how onClose was invoked, which slightly concerned me if not tested since the flow is full of async (and I didn't want to read everything to verify that :) )
There was a problem hiding this comment.
I added an additional test to validate that async closures occur as expected. There's actually no async code (where async is code involving futures) in RawMenuAnchor, but it can be used in an async way.
That said, done!
You're right... it was a long night 😴. The only caveat is a close request is sync by default since it just calls hideMenu immediately, but it's easier to think about it in sync/async terms. I'll update the description later tonight |
|
@chunhtai I'm requesting a review from you because I'm curious whether we should be calling closeChildrenWithRequest() on children that are not opened when MenuController.open() is called. This can lead to some weird behavior (e.g. a user forgets to cancel their timers, so every open() request creates a new timer for each submenu). Edit: @dkwingsmt this is also a question for you. |
chunhtai
left a comment
There was a problem hiding this comment.
I am onboard with this change, just left some suggestion.
| /// This method will call [handleCloseRequest] on each child of this | ||
| /// menu, which will trigger the closing sequence of each child. | ||
| @protected | ||
| void closeChildrenWithRequest() { |
There was a problem hiding this comment.
+1 to @dkwingsmt , I think we should prioritize readaibiity here
|
|
||
| // Close all siblings. | ||
| _parent?.closeChildren(); | ||
| _parent?.closeChildrenWithRequest(); |
There was a problem hiding this comment.
I know this is not introduced in this pr, why would we want to do async close here? it feels weird if the new menu opens before old one close completely
There was a problem hiding this comment.
Now that you mentioned, I tested on macOS's system menu bar, and noticed that, while top menus have a tiny fade out animation, there is no fade out when switching between menus. I'm not sure if this applies to all other menus in other design languages (i.e. whether we should leave this as a flag or callback for design languages to implement), but the async closure here should indeed be wrong.
Screen.Recording.2026-02-18.at.3.35.53.PM.mov
There was a problem hiding this comment.
Nvm, I just noticed that this place is for repositioning menus, and I think should probably have no animations at all.
There was a problem hiding this comment.
Some menus do an async close. For example, Google Drive:
Screen.Recording.2026-02-26.at.1.08.04.AM.mov
There was a problem hiding this comment.
I see. I guess we should somehow allow themed widget to customize this.
Edit: Actually, I think this is possible with my suggestion in #182357 (comment), which will allow submenus to know the cause of the closure, and decide the animation for themselves.
(Although I wonder a clearer way to explicitly tell this is possible, or needed at all)
There was a problem hiding this comment.
😿 It does make things a bit more complicated, but it is probably for the best.
There was a problem hiding this comment.
Also to be clear I mean that we should use closeChildrenWithRequest here since this allows children to use animation. They can choose to make the animation none.
| /// immediately close the child. | ||
| /// | ||
| /// If `inDispose` is true, this method was triggered by the widget being | ||
| /// unmounted. |
There was a problem hiding this comment.
add a see also to reference closeChildrenWithRequest and vice versa
|
also migration guide should land in flutter/website first before this can land |
|
|
||
| @override | ||
| void handleCloseRequest() { | ||
| closeChildrenWithRequest(); |
There was a problem hiding this comment.
I think we should call this after widget.onCloseRequested (i.e. the if clause below). The onCloseRequested callback is where themed menus change the animation status, and calling closeChildrenWithRequest later allows submenus to read the animation status and know what triggers the closure, and hence decide whether to display closing animation, which helps implementing the behavior here https://github.com/flutter/flutter/pull/182357/changes#r2824951515 .
There was a problem hiding this comment.
I think this is reasonable. My only concern is that closeChildren begins at the topmost menu and travels to underlying menus. This would begin closing from the bottom menu and travel to the topmost menu. I'm not sure what implications this may have.
There was a problem hiding this comment.
The full closing sequence is:
- Request propagates in (parent.requestClose, child.requestClose, grandchild.requestClose, ...)
- Close propagates out (..., grandchild.close, child.close, parent.close).
It makes perfect sense to me. What do you think?
There was a problem hiding this comment.
I think it makes sense... it doesn't seem to break anything so that's a good sign. I pushed the update implementing this.
|
I pushed the change mentioned above that short-circuits handleCloseRequest on children that are not opened. If that seems okay, then I can land the migration guide. |
| /// prevent them from closing the menu at an unintended time. | ||
| /// | ||
| /// If the menu is not closed, this callback will also be called when the root | ||
| /// menu anchor is scrolled and when the screen is resized. |
There was a problem hiding this comment.
Given the current content, I think we should group the trigger timing of the callback together. Something like this: (you might want to rephrase it to reduce repetitiveness)
/// This callback can be used to add a delay or a closing animation before the menu
/// is hidden.
///
/// This callback is triggered every time [MenuController.close] is called,
/// regardless of whether the overlay is already hidden.
/// This callback is also triggered a sibling [RawMenuAnchor] is opened while this menu is open
/// This callback will also be called when the root
/// menu anchor is scrolled and when the screen is resized while this menu anchor is open.And I suggest the following sentence be moved after the next paragraph, since the next paragraph mentions asynchronous operation (i.e. timer).
/// Pending timers or animations previously started in a previous call to
/// [onCloseRequested] should be canceled when this callback is triggered to
/// prevent them from closing the menu at an unintended time.
There was a problem hiding this comment.
/// Called when a request is made to close the menu.
///
/// This callback can be used to add a delay or a closing animation before the
/// menu is hidden.
///
/// This callback is triggered every time [MenuController.close] is called
/// while this menu is open.
///
/// This callback is also triggered when a sibling [RawMenuAnchor] is opened
/// while this menu is open. In this case, the callback can be used to add a
/// delay or a closing animation while the sibling menu opens. When
/// implementing this behavior, the closing menu should immediately stop being
/// interactive, so that it doesn't interfere with the opening sibling menu.
/// Semantics, focus, and hit testing for the closing menu should be disabled
/// for the duration of the closing animation.
///
/// Pending timers or animations started in a previous call to
/// [onCloseRequested] should be canceled when this callback is triggered to
/// prevent them from closing the menu at an unintended time.
///
/// If the menu is not closed, this callback will also be called when the root
/// menu anchor is scrolled and when the screen is resized.
///
/// After a close request is intercepted and closing behaviors have completed,
/// the hideOverlay callback should be called. This callback sets
/// [MenuController.isOpen] to false and hides the menu overlay widget. If the
/// [RawMenuAnchor] is used in a themed menu that plays a closing animation,
/// hideOverlay should be called after the closing animation has ended,
/// since the animation plays on the overlay itself. This means that
/// [MenuController.isOpen] will stay true while closing animations are
/// running.
///
/// Calling hideOverlay after disposal is a no-op, meaning it will not
/// trigger [onClose] or hide the menu overlay.
///
/// Typically, [onCloseRequested] consists of the following steps:
///
/// 1. Optionally start the closing animation and wait for it to complete.
/// 2. Call hideOverlay (whose call chain eventually invokes [onClose]).
///
/// Throughout the closing sequence, menus should typically not be focusable
/// or interactive.
///
/// Defaults to a callback that immediately hides the menu.
|
Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change). If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
…oseRequested on descendants before parent. (flutter/flutter#182357)
flutter/flutter@9cd60b5...a0924c7 2026-04-07 [email protected] Reland "[data_assets] Cleanup tests" (flutter/flutter#184714) 2026-04-07 [email protected] Use the WindowRegistry in the multiple_windows example app (flutter/flutter#184579) 2026-04-07 [email protected] Introduce command to build a swift package for SwiftPM add to app integration (flutter/flutter#184660) 2026-04-07 [email protected] Have `flutter create` create a pubspec.lock to ensure pinned versions are being used. (flutter/flutter#175352) 2026-04-07 [email protected] [widgets/raw_menu_anchor.dart] Always call onClose and onCloseRequested on descendants before parent. (flutter/flutter#182357) 2026-04-07 [email protected] `WindowsPlugin` should not crash when ffiPlugin enabled (flutter/flutter#184695) 2026-04-06 [email protected] Use full goto.google.com hostname for go/ links (flutter/flutter#184679) 2026-04-06 [email protected] Apply rect clipping to surface views (flutter/flutter#184471) 2026-04-06 [email protected] [A11y] Allow percentage strings like "50%" as `SemanticsValue` for `ProgressIndicator` (flutter/flutter#183670) 2026-04-06 [email protected] Fix invisible accessibility element before scroll view (flutter/flutter#184155) 2026-04-06 [email protected] Roll Skia from 163dfdf500c7 to e264d870a380 (2 revisions) (flutter/flutter#184674) 2026-04-06 [email protected] Keep last character obscured when toggling obscureText (flutter/flutter#183488) 2026-04-06 [email protected] Parse scheme file with XML parser for SwiftPM migrator (flutter/flutter#184525) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC [email protected],[email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
…ed on descendants before parent. (flutter#182357) This PR corrects the order in which onClose() and onCloseRequested() is called by a RawMenuAnchor tree. Per @dkwingsmt: > A close is immediate and should close children immediately before closing itself, while a closeRequest [can be] async and should closeRequest children. Before, calling close() on a parent would call handleCloseRequest() on its children, leading to children (potentially) finishing their closure after their parents. Additionally, if a RawMenuAnchor is already closed, it will not trigger onCloseRequested. **Details:** The handleCloseRequest portion of the _RawMenuAnchorBaseMixin.closeChildren function has been moved into a requestChildrenClose function to be more explicit. Additionally, this PR makes handleCloseRequest call requestChildrenClose() after calling widget.onCloseRequested on _RawMenuAnchorState. This makes submenus begin closing after their parent menu begins closing. I also modified the menu_anchor.dart and raw_menu_anchor.3.dart code to remove a closeChildren call (now redundant). Last, this PR makes handleCloseRequest short-circuit if a menu isn't open. This prevents unncessary widget.onCloseRequest calls when a submenu is opened, since all sibling menus call handleCloseRequest before the submenu begins opening. This is a breaking change. I had to modify one of the DismissMenuAnchor tests since onCloseRequested is now called on all descendants when a dismissal occurs. I think it's best to introduce this change because it can lead to hard-to-track behavior when creating menus (that's how I discovered it). Migration guide: flutter/website#13145 Additional tests were added to check the opening and closing behavior. Fixes flutter#182355 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Tong Mu <[email protected]>
) _Description of what this PR is changing or adding, and why:_ Adds a timeline for breaking changes in raw-menu-anchor-close-order. I didn't fill in the stable version yet -- should I? _PRs or commits this PR depends on (if any):_ flutter/flutter#182357
This PR corrects the order in which onClose() and onCloseRequested() is called by a RawMenuAnchor tree.
Per @dkwingsmt:
Before, calling close() on a parent would call handleCloseRequest() on its children, leading to children (potentially) finishing their closure after their parents.
Additionally, if a RawMenuAnchor is already closed, it will not trigger onCloseRequested.
Details:
The handleCloseRequest portion of the _RawMenuAnchorBaseMixin.closeChildren function has been moved into a requestChildrenClose function to be more explicit.
Additionally, this PR makes handleCloseRequest call requestChildrenClose() after calling widget.onCloseRequested on _RawMenuAnchorState. This makes submenus begin closing after their parent menu begins closing. I also modified the menu_anchor.dart and raw_menu_anchor.3.dart code to remove a closeChildren call (now redundant).
Last, this PR makes handleCloseRequest short-circuit if a menu isn't open. This prevents unncessary widget.onCloseRequest calls when a submenu is opened, since all sibling menus call handleCloseRequest before the submenu begins opening.
This is a breaking change. I had to modify one of the DismissMenuAnchor tests since onCloseRequested is now called on all descendants when a dismissal occurs. I think it's best to introduce this change because it can lead to hard-to-track behavior when creating menus (that's how I discovered it).
Migration guide: flutter/website#13145
Additional tests were added to check the opening and closing behavior.
Fixes #182355
Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.