From 777bfea28624f54df9effd69650b7e6284dbd9cd Mon Sep 17 00:00:00 2001 From: Simone Stasi <62812903+sstasi95@users.noreply.github.com> Date: Thu, 26 Jun 2025 23:04:05 +0200 Subject: [PATCH] fix PopupMenuButton unmounted exception when updating position (#166412) 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]. [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 Co-authored-by: Tong Mu --- .../flutter/lib/src/material/popup_menu.dart | 12 +++- .../test/material/popup_menu_test.dart | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index d99c7e106fa..0f7732ca048 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -1568,7 +1568,17 @@ class PopupMenuButton extends StatefulWidget { /// of your button state. class PopupMenuButtonState extends State> { 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 extends State> { Offset.zero & overlay.size, ); - return position; + return _lastPosition = position; } /// A method to show a popup menu with the items supplied to diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index f51ab5ad78d..85f6c7987f5 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -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( + key: buttonKey, + popUpAnimationStyle: const AnimationStyle( + reverseDuration: Duration(milliseconds: 400), + ), + itemBuilder: (BuildContext context) { + return >[ + PopupMenuItem(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;