fix PopupMenuButton unmounted exception when updating position (#166412)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

This PR fixes an exception thrown when trying to update an unmounted
PopupMenuButton's position, which can happen when a layout change is
triggered during PopupMenuButton's pop animation (see issue's attached
video).
A workaround is to set `popUpAnimationStyle:
AnimationStyle.noAnimation`.
This PR fixes it by returning the last known position if the button is
unmounted.

Exception thrown:` FlutterError (This widget has been unmounted, so the
State no longer has a context (and should be considered defunct).
Consider canceling any active work during "dispose" or using the
"mounted" getter to determine if the State is still active.)`

Code that causes the exception: `final PopupMenuThemeData popupMenuTheme
= PopupMenuTheme.of(context);`

Fixes #163477 

Tested both on stable (3.29.2) and master (a0b1b325341)

## 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].
- [X] 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].

<!-- 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: Navaron Bracke <brackenavaron@gmail.com>
Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
This commit is contained in:
Simone Stasi 2025-06-26 23:04:05 +02:00 committed by GitHub
parent 5285cbb750
commit 777bfea286
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 76 additions and 1 deletions

View File

@ -1568,7 +1568,17 @@ class PopupMenuButton<T> extends StatefulWidget {
/// of your button state.
class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
bool _isMenuExpanded = false;
RelativeRect? _lastPosition;
RelativeRect _positionBuilder(BuildContext _, BoxConstraints constraints) {
if (!mounted) {
// When the route is displayed, the `_positionBuilder` closure is stored.
// Even after the button has been unmounted and the context becomes invalid,
// the route might keep displaying, and `_positionBuilder` must continue to
// work in that case.
return _lastPosition ?? RelativeRect.fromSize(Rect.zero, constraints.biggest);
}
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay =
@ -1598,7 +1608,7 @@ class PopupMenuButtonState<T> extends State<PopupMenuButton<T>> {
Offset.zero & overlay.size,
);
return position;
return _lastPosition = position;
}
/// A method to show a popup menu with the items supplied to

View File

@ -2863,6 +2863,71 @@ void main() {
expect(mediaQueryPadding, EdgeInsets.zero);
});
// Regression test for https://github.com/flutter/flutter/issues/163477
testWidgets("PopupMenu's overlay can be rebuilt even when the button is unmounted", (
WidgetTester tester,
) async {
final GlobalKey buttonKey = GlobalKey();
late StateSetter setState;
bool showButton = true;
Widget widget({required Size viewSize}) {
return Center(
child: SizedBox(
width: viewSize.width,
height: viewSize.height,
child: MaterialApp(
home: Material(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter innerSetState) {
setState = innerSetState;
return showButton
? PopupMenuButton<int>(
key: buttonKey,
popUpAnimationStyle: const AnimationStyle(
reverseDuration: Duration(milliseconds: 400),
),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<int>>[
PopupMenuItem<int>(value: 1, child: const Text('ACTION'), onTap: () {}),
];
},
)
: Container();
},
),
),
),
),
);
}
// Pump a button
await tester.pumpWidget(widget(viewSize: const Size(500, 500)));
// Tap the button to show the menu
await tester.tap(find.byKey(buttonKey));
await tester.pumpAndSettle();
expect(find.text('ACTION'), findsOne);
expect(find.byKey(buttonKey), findsOne);
// Hide the button. The menu still shows since it's placed on a separate route.
setState(() {
showButton = false;
});
await tester.pump();
expect(find.text('ACTION'), findsOne);
expect(find.byKey(buttonKey), findsNothing);
// Resize the view, causing the menu to rebuild. Before the fix, this
// rebuild would lead to a crash, because it relies on context of the button,
// which has been unmounted.
await tester.pumpWidget(widget(viewSize: const Size(300, 300)));
expect(tester.takeException(), isNull);
});
group('feedback', () {
late FeedbackTester feedback;