From 5f1cd679853b6623582f97154dd0352ccdd1caa3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 13 Nov 2026 14:40:22 -0800 Subject: [PATCH] perf(mobile): stop rebuilding Home's channel list when a route covers it (#9459) Opening any route or sheet above Home (search, a message's action menu, a profile sheet, Activity, Settings) rebuilt Home's whole channel list twice: once when the route started covering Home and once when it finished. On a phone the open channel sits inside Home's conversation pane, so a sheet over a channel (long-press menu, profile) did the same. `HomePage` and `ChannelsPage` read `ModalRoute.of(context)` in their own builds, which depends on every part of the route's state (whether it's current, whether it's offstage, and so on). `ChannelsPage` also depended on the whole `DuoToolbarScope`, whose `active` flag flips whenever a route covers the pane, through `frostedAppBarHeight`. Every channel row and the Solo section depended on `ConversationPaneScope` for the selected channel, and that scope notified all of them when its route-dependent `active` flag flipped. Each of those rebuilt a fresh channel inventory, including Solo rows that had scrolled away, which resubscribed their typing feeds for 30 seconds. Now: - Home's native controls (Activity bell, tab bar, Duo rail) read the route in a small `_NativeControlsGate` leaf, so only they rebuild when Home is covered. - `ChannelsPage` mirrors route currency into its header suppression notifier from a `_RouteCurrencySync` leaf above the header, so the header still switches in the same frame. The two "wait until this route is current again" loops read the same notifier. - `DuoToolbarScope` is an `InheritedModel` with a strip-only lookup, `maybeStripOf`. The frosted header's height and `ChannelsPage` use it, so `active` changes no longer reach them. Every other reader keeps `maybeOf` and its current behavior. - `ConversationPaneScope`'s `active` flag lives in its own private inherited widget, read only by `isActiveOf`. Rows that only need the controller no longer rebuild when it flips. - Opening a channel looks up the conversation pane instead of depending on it. ### Validation New test: opening and closing a sheet above Home rebuilds neither `HomePage`, `ChannelsPage` nor any channel row. It fails without this change. Full mobile test suite, `flutter analyze` and the file-size check pass. On the iPhone 12 mini (profile build, Builderlab): Same scripted run on `main` and on this branch (search opened and closed 3 times, then a message's long-press menu and a profile sheet over an open channel): | | main | this branch | | --- | --- | --- | | Widget builds per search open | 126 to 350 | 56 to 60 | | Build time per search open | 79 to 90 ms | 29 to 41 ms | | Build time, long-press menu open | 64 ms | 17 ms | | Typing subscriptions reopened by the sheets | 9 (about 80 KB of messages downloaded) | 0 | The menu, profile sheet, channel header and Home's tab bar and Activity button behaved the same in both runs. ### Regressions None expected. The native controls and header switch from the same route state as before, in the same frame. A `DuoToolbarScope` strip change still reaches the header and the channel list. --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: studio --- .../lib/features/channels/channels_page.dart | 51 +++- .../channels/channels_page/sections.dart | 3 +- mobile/lib/features/home/home_page.dart | 272 ++++++++++-------- .../widgets/adaptive_conversation_pane.dart | 31 +- mobile/lib/shared/widgets/duo_toolbar.dart | 22 +- .../lib/shared/widgets/frosted_app_bar.dart | 7 +- mobile/test/features/home/home_page_test.dart | 44 +++ 7 files changed, 283 insertions(+), 147 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 0eea0f29a2..3e530c36e8 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -188,7 +188,7 @@ class ChannelsPage extends HookConsumerWidget { final nativeDuoTitle = messagesFilters != null && defaultTargetPlatform == TargetPlatform.iOS && - (DuoToolbarScope.maybeOf(context)?.strip ?? + (DuoToolbarScope.maybeStripOf(context) ?? FoldGeometry.of(context).strip) != null; final filterContentHeight = messagesFilters == null @@ -206,10 +206,8 @@ class ChannelsPage extends HookConsumerWidget { ); // A sheet's dim barrier can't composite over Home's native soft edge, so // use the Flutter backdrop while another route covers this page. - final route = ModalRoute.of(context); final headerNativeSuppressed = useValueNotifier(false); - headerNativeSuppressed.value = - messagesFilters != null && !(route?.isCurrent ?? true); + final routeCurrent = useValueNotifier(true); final communityAvatarKey = useMemoized(GlobalKey.new); final nativeCommunityAvatarBounds = useRef(null); final headerKey = useMemoized(GlobalKey.new); @@ -310,7 +308,10 @@ class ChannelsPage extends HookConsumerWidget { .toList(); Future openChannel(Channel channel) async { if (!context.mounted) return; - final pane = ConversationPaneScope.maybeOf(context); + // A lookup, not a dependency: the page doesn't rebuild with the pane. + final pane = context + .getInheritedWidgetOfExactType() + ?.notifier; if (pane != null) { pane.open( channel.id, @@ -431,7 +432,7 @@ class ChannelsPage extends HookConsumerWidget { do { await WidgetsBinding.instance.endOfFrame; if (cancelled || !context.mounted) return; - } while (ModalRoute.of(context)?.isCurrent == false); + } while (!routeCurrent.value); if (ref.read(pairedCommunityLandingProvider)?.id != arrivingCommunity.id) { return; @@ -500,7 +501,7 @@ class ChannelsPage extends HookConsumerWidget { do { await WidgetsBinding.instance.endOfFrame; if (cancelled || !context.mounted) return; - } while (ModalRoute.of(context)?.isCurrent == false); + } while (!routeCurrent.value); if (!ref.read(communityGridLandingProvider)) return; ref.read(communityGridLandingProvider.notifier).clear(); openCommunityGrid(); @@ -648,7 +649,7 @@ class ChannelsPage extends HookConsumerWidget { return () => active = false; }, [newChannelAction, newDmAction, currentPubkey]); - return ValueListenableBuilder( + final page = ValueListenableBuilder( valueListenable: headerTreatment, // Retain the inventory during header animation. Provider and parent // updates still construct a fresh body with current channel data. @@ -840,6 +841,40 @@ class ChannelsPage extends HookConsumerWidget { body: child!, ), ); + return _RouteCurrencySync( + current: routeCurrent, + nativeSuppressed: headerNativeSuppressed, + suppressesNative: messagesFilters != null, + child: page, + ); + } +} + +/// Mirrors whether this page's route is current into [current], and into +/// [nativeSuppressed] when [suppressesNative] is set. +/// +/// Only this leaf depends on the route, so a route or sheet opening above Home +/// rebuilds it and the header listening to [nativeSuppressed], not the channel +/// inventory. The header is a descendant, so it updates in the same frame. +class _RouteCurrencySync extends StatelessWidget { + const _RouteCurrencySync({ + required this.current, + required this.nativeSuppressed, + required this.suppressesNative, + required this.child, + }); + + final ValueNotifier current; + final ValueNotifier nativeSuppressed; + final bool suppressesNative; + final Widget child; + + @override + Widget build(BuildContext context) { + final isCurrent = ModalRoute.isCurrentOf(context) ?? true; + current.value = isCurrent; + nativeSuppressed.value = suppressesNative && !isCurrent; + return child; } } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 62e2736d4a..d9dd003f3a 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -640,8 +640,7 @@ class _SectionChevron extends StatelessWidget { bool _usesNativeSectionMenu(BuildContext context) => defaultTargetPlatform == TargetPlatform.iOS && - (DuoToolbarScope.maybeOf(context)?.strip ?? - FoldGeometry.of(context).strip) != + (DuoToolbarScope.maybeStripOf(context) ?? FoldGeometry.of(context).strip) != null; class _SectionOptionsButton extends StatelessWidget { diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 3613d70eae..3d81109ddd 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -78,12 +78,6 @@ class HomePage extends HookConsumerWidget { ? 375.0 : null; final showWorkspaceNavigation = wide || conversations.selectedId == null; - final route = ModalRoute.of(context); - final coveringProgress = useAnimation( - route?.secondaryAnimation ?? kAlwaysDismissedAnimation, - ); - final nativeControlsVisible = - (route?.isCurrent ?? true) && coveringProgress == 0; final communitySurfaceProgress = useState(0.0); final filter = useState('All'); @@ -166,83 +160,85 @@ class HomePage extends HookConsumerWidget { (label: 'Settings', symbol: 'gearshape', onPressed: openSettings), ]; return [ - if (defaultTargetPlatform == TargetPlatform.iOS && - !nativeControlsVisible) - const SizedBox(width: 44, height: 44) - else - Stack( - alignment: Alignment.center, - children: [ - defaultTargetPlatform == TargetPlatform.iOS - ? IosGlassNavigationButton( - icon: IosGlassNavigationIcon.bell, - semanticLabel: inboxLabel, - width: 44, - height: 44, - controlSize: 44, - foregroundColor: context.colors.onSurface, - onPressed: openInbox, - ) - : IconButton.filledTonal( - tooltip: inboxLabel, - style: IconButton.styleFrom( - backgroundColor: - context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, - minimumSize: const Size.square(48), - ), - icon: const Icon(BuzzIcons.bell), - onPressed: openInbox, - ), - Positioned.fill( - child: IgnorePointer( - child: ExcludeSemantics( - child: Center( - child: AnimatedOpacity( - opacity: hasUnreadInbox ? 1 : 0, - duration: reducedMotion - ? Duration.zero - : const Duration(milliseconds: 150), - curve: Curves.easeOut, - child: SizedBox.square( - key: const Key('home-inbox-badge'), - dimension: 24, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - top: 0, - right: 2, - child: Container( - key: const Key('home-inbox-dot'), - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.colors.primary, - border: Border.all( - color: - defaultTargetPlatform == - TargetPlatform.android - ? context - .colors - .surfaceContainerHighest - : context.controlSurface, - width: 2, + _NativeControlsGate( + builder: (_, nativeControlsVisible) => + defaultTargetPlatform == TargetPlatform.iOS && + !nativeControlsVisible + ? const SizedBox(width: 44, height: 44) + : Stack( + alignment: Alignment.center, + children: [ + defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.bell, + semanticLabel: inboxLabel, + width: 44, + height: 44, + controlSize: 44, + foregroundColor: context.colors.onSurface, + onPressed: openInbox, + ) + : IconButton.filledTonal( + tooltip: inboxLabel, + style: IconButton.styleFrom( + backgroundColor: + context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + minimumSize: const Size.square(48), + ), + icon: const Icon(BuzzIcons.bell), + onPressed: openInbox, + ), + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: Center( + child: AnimatedOpacity( + opacity: hasUnreadInbox ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: SizedBox.square( + key: const Key('home-inbox-badge'), + dimension: 24, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + right: 2, + child: Container( + key: const Key('home-inbox-dot'), + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + border: Border.all( + color: + defaultTargetPlatform == + TargetPlatform.android + ? context + .colors + .surfaceContainerHighest + : context.controlSurface, + width: 2, + ), + ), + ), ), - ), + ], ), ), - ], + ), ), ), ), ), - ), + ], ), - ), - ], - ), + ), const SizedBox(width: Grid.xs), SizedBox( key: avatarKey, @@ -330,41 +326,43 @@ class HomePage extends HookConsumerWidget { ], ); return DuoWorkspaceScaffold( - navigationBuilder: (context, placement) => - wide || - !showWorkspaceNavigation || - !nativeControlsVisible || - communitySurfaceProgress.value != 0 - ? const SizedBox.shrink() - : Transform.translate( - offset: Offset( - IosNavigationMetrics.of(context).railCenter( - MediaQuery.sizeOf(context), - strip!.rect.center.dx, - ) - - strip.rect.center.dx, - 0, - ), - child: SafeArea( - bottom: false, - child: Padding( - // Match the compact composer's safe-area gutter and its - // downward resting offset in _ComposerDockFrame. - padding: EdgeInsets.only( - bottom: - (MediaQuery.viewPaddingOf(context).bottom + - Grid.xxs - - Grid.twelve - - Grid.quarter) - .clamp(0.0, double.infinity), - ), - child: Align( - alignment: Alignment.bottomCenter, - child: navigationBar(Axis.vertical), + navigationBuilder: (context, placement) => _NativeControlsGate( + builder: (_, nativeControlsVisible) => + wide || + !showWorkspaceNavigation || + !nativeControlsVisible || + communitySurfaceProgress.value != 0 + ? const SizedBox.shrink() + : Transform.translate( + offset: Offset( + IosNavigationMetrics.of(context).railCenter( + MediaQuery.sizeOf(context), + strip!.rect.center.dx, + ) - + strip.rect.center.dx, + 0, + ), + child: SafeArea( + bottom: false, + child: Padding( + // Match the compact composer's safe-area gutter and its + // downward resting offset in _ComposerDockFrame. + padding: EdgeInsets.only( + bottom: + (MediaQuery.viewPaddingOf(context).bottom + + Grid.xxs - + Grid.twelve - + Grid.quarter) + .clamp(0.0, double.infinity), + ), + child: Align( + alignment: Alignment.bottomCenter, + child: navigationBar(Axis.vertical), + ), ), ), ), - ), + ), body: Scaffold( resizeToAvoidBottomInset: false, extendBody: true, @@ -373,11 +371,13 @@ class HomePage extends HookConsumerWidget { padding: EdgeInsets.only(bottom: bottom), child: SizedBox( height: mobileTabBarHeight, - child: - (defaultTargetPlatform != TargetPlatform.iOS || - nativeControlsVisible) - ? Center(child: navigationBar(Axis.horizontal)) - : null, + child: _NativeControlsGate( + builder: (_, nativeControlsVisible) => + (defaultTargetPlatform != TargetPlatform.iOS || + nativeControlsVisible) + ? Center(child: navigationBar(Axis.horizontal)) + : const SizedBox.shrink(), + ), ), ) : null, @@ -470,16 +470,22 @@ class HomePage extends HookConsumerWidget { ), ), ), - if (nativeControlsVisible) - Positioned( - left: insets.left, - right: insets.right, - bottom: mobileTabBarBottom(paneContext), - height: mobileTabBarHeight, - child: Center( - child: navigationBar(Axis.horizontal), - ), + Positioned( + left: insets.left, + right: insets.right, + bottom: mobileTabBarBottom(paneContext), + height: mobileTabBarHeight, + child: _NativeControlsGate( + builder: (_, nativeControlsVisible) => + nativeControlsVisible + ? Center( + child: navigationBar( + Axis.horizontal, + ), + ) + : const SizedBox.shrink(), ), + ), ], ); }, @@ -506,3 +512,27 @@ class HomePage extends HookConsumerWidget { ); } } + +/// Builds [builder] with whether Home's native controls may show: Home is the +/// current route and nothing is covering it. +/// +/// Only this leaf depends on Home's route, so a route or sheet opening above +/// Home rebuilds these controls and not the channel inventory. +class _NativeControlsGate extends HookWidget { + const _NativeControlsGate({required this.builder}); + + final Widget Function(BuildContext context, bool nativeControlsVisible) + builder; + + @override + Widget build(BuildContext context) { + final route = ModalRoute.of(context); + final coveringProgress = useAnimation( + route?.secondaryAnimation ?? kAlwaysDismissedAnimation, + ); + return builder( + context, + (route?.isCurrent ?? true) && coveringProgress == 0, + ); + } +} diff --git a/mobile/lib/shared/widgets/adaptive_conversation_pane.dart b/mobile/lib/shared/widgets/adaptive_conversation_pane.dart index b16c0e0dcd..25ca737a78 100644 --- a/mobile/lib/shared/widgets/adaptive_conversation_pane.dart +++ b/mobile/lib/shared/widgets/adaptive_conversation_pane.dart @@ -102,14 +102,15 @@ class ConversationPaneController extends ChangeNotifier { class ConversationPaneScope extends InheritedNotifier { - const ConversationPaneScope({ + ConversationPaneScope({ required ConversationPaneController controller, - required super.child, - this.active = true, + required Widget child, + bool active = true, super.key, - }) : super(notifier: controller); - - final bool active; + }) : super( + notifier: controller, + child: _ConversationPaneActive(active: active, child: child), + ); /// Only the selected page owns the pane's implicit root navigation. /// Imperatively pushed pages retain ordinary Back navigation. @@ -121,21 +122,29 @@ class ConversationPaneScope settings.key == controller!.selectionKey; } + /// Depends only on [active], which flips whenever a route covers the pane, + /// so readers of [maybeOf] (rows, the list) don't rebuild with it. static bool isActiveOf(BuildContext context) => context - .dependOnInheritedWidgetOfExactType() + .dependOnInheritedWidgetOfExactType<_ConversationPaneActive>() ?.active ?? true; - @override - bool updateShouldNotify(ConversationPaneScope oldWidget) => - active != oldWidget.active || super.updateShouldNotify(oldWidget); - static ConversationPaneController? maybeOf(BuildContext context) => context .dependOnInheritedWidgetOfExactType() ?.notifier; } +class _ConversationPaneActive extends InheritedWidget { + const _ConversationPaneActive({required this.active, required super.child}); + + final bool active; + + @override + bool updateShouldNotify(_ConversationPaneActive oldWidget) => + active != oldWidget.active; +} + /// Keeps both the list and its detail navigator mounted across compact/regular /// layouts, so opening or closing Duo does not recreate the composer or thread. class AdaptiveConversationPane extends HookConsumerWidget { diff --git a/mobile/lib/shared/widgets/duo_toolbar.dart b/mobile/lib/shared/widgets/duo_toolbar.dart index e930d74e4b..f946aa0b28 100644 --- a/mobile/lib/shared/widgets/duo_toolbar.dart +++ b/mobile/lib/shared/widgets/duo_toolbar.dart @@ -18,7 +18,7 @@ bool isCompactDuoWindow(BuildContext context) { } /// Carries window geometry through the independently sized conversation panes. -class DuoToolbarScope extends InheritedWidget { +class DuoToolbarScope extends InheritedModel { const DuoToolbarScope({ required this.strip, required this.active, @@ -35,8 +35,20 @@ class DuoToolbarScope extends InheritedWidget { /// Space above detail actions for the workspace's persistent rail controls. final int leadingSlots; + static const Object _stripAspect = #strip; + static DuoToolbarScope? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType(); + InheritedModel.inheritFrom(context); + + /// The enclosing pane's [strip], depending on nothing else. + /// + /// [active] flips whenever a route covers the pane, so geometry readers + /// such as headers and page lists use this to avoid rebuilding with it. + static FoldStrip? maybeStripOf(BuildContext context) => + InheritedModel.inheritFrom( + context, + aspect: _stripAspect, + )?.strip; @override bool updateShouldNotify(DuoToolbarScope oldWidget) => @@ -44,6 +56,12 @@ class DuoToolbarScope extends InheritedWidget { active != oldWidget.active || contentInsets != oldWidget.contentInsets || leadingSlots != oldWidget.leadingSlots; + + @override + bool updateShouldNotifyDependent( + DuoToolbarScope oldWidget, + Set dependencies, + ) => strip != oldWidget.strip; } /// Content clearance for a pane, or a full-window route outside a pane. diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 12e133f5aa..6a4b0557a8 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -43,7 +43,7 @@ double _barContentHeight( : scaledTitleHeight; final accessibleHeight = Grid.xxs + effectiveTitleHeight + Grid.xxs; final strip = - DuoToolbarScope.maybeOf(context)?.strip ?? FoldGeometry.of(context).strip; + DuoToolbarScope.maybeStripOf(context) ?? FoldGeometry.of(context).strip; final minimumHeight = defaultTargetPlatform == TargetPlatform.iOS && strip != null && @@ -72,8 +72,9 @@ double frostedAppBarLowerTitleHeight( // Duo has no top status inset in the content panes. Reserve breathing room // around the native 44-point controls instead of collapsing the header to them. double _nativeCompactHeight(BuildContext context, [double titleHeight = 0]) { - final scope = DuoToolbarScope.maybeOf(context); - final strip = scope == null ? FoldGeometry.of(context).strip : scope.strip; + final strip = context.getInheritedWidgetOfExactType() == null + ? FoldGeometry.of(context).strip + : DuoToolbarScope.maybeStripOf(context); final height = IosNavigationMetrics.of(context).compactHeight; return strip == null ? max(height, titleHeight) diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 115348f524..f2bd5c0f4f 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:buzz/shared/widgets/navigation_backdrop.dart'; import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/shared/widgets/adaptive_conversation_pane.dart'; + import 'dart:async'; import 'package:buzz/features/channels/default_agent_launcher.dart'; @@ -12,6 +13,7 @@ import 'package:buzz/features/activity/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/me/default_agent.dart'; import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; @@ -159,6 +161,48 @@ void main() { expect(homeBuilds, 0); }); + for (final (name, size, rightInset, platform) in [ + ('phone', const Size(402, 874), 0.0, null), + ('iOS Duo', const Size(951, 669), 76.0, TargetPlatform.iOS), + ]) { + testWidgets('a sheet opening above Home does not rebuild its inventory ' + '($name)', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = size; + tester.view.viewPadding = FakeViewPadding(right: rightInset); + tester.view.padding = tester.view.viewPadding; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(await buildHome()); + await tester.pumpAndSettle(); + final rebuilds = []; + debugOnRebuildDirtyWidget = (element, _) { + final type = '${element.widget.runtimeType}'; + if (const {'HomePage', 'ChannelsPage', '_ChannelTile'}.contains(type)) { + rebuilds.add(type); + } + }; + addTearDown(() => debugOnRebuildDirtyWidget = null); + + unawaited( + showModalBottomSheet( + context: tester.element(find.byType(ChannelsPage)), + useRootNavigator: true, + builder: (_) => const Text('Sheet'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Sheet'), findsOneWidget); + Navigator.of(tester.element(find.text('Sheet'))).pop(); + await tester.pumpAndSettle(); + debugOnRebuildDirtyWidget = null; + debugDefaultTargetPlatformOverride = null; + + expect(rebuilds, isEmpty); + }); + } + testWidgets('feedback clears floating navigation and wraps large text', ( tester, ) async {