From 920b1e64bdd05c6605a2562b5b05618bc8bb264a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 4 Nov 2026 20:27:59 -0800 Subject: [PATCH] perf(mobile): keep covered pages still while the keyboard moves (#8941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A keyboard raised by a route above (the Add members sheet, search, a native picker) changes the app's `MediaQuery` on every animation frame. Covered pages that read `MediaQuery` directly or through a `Scaffold` rebuilt and relaid out all their content for keyboard motion they cannot show. On #4things, typing in Add members rebuilt every visible message of the channel page underneath for each keyboard frame. `CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding` at their last values from when its route was current, while the route is covered. Every other `MediaQueryData` field passes through. A covered page still follows the keyboard down, so when a covering route takes focus and the page's own keyboard closes, the page lowers behind the scrim. A size change (rotation) drops the held values so safe-area padding stays right. When the route becomes current again with the keyboard still above the held value, the page keeps the held values rather than jumping up and riding the keyboard back down. It switches to live values once the keyboard reaches the held value, or as soon as focus is inside the route (the page raised its own keyboard). Otherwise the live values return. It always returns the same widget shape, so covering a page never remounts it. Home, search, the channel page and channel details use it. ## Measurements iPhone 12 mini, Profile build, Builderlab #4things (1,361 members). Flow: open the channel, open its details, tap Add members, tap the search field, then type "chris" one character per second. The window is the 10 s of typing, keyboard included. Both builds also contain the fixes from [#8928](https://github.com/block/buzz/pull/8928), [#8929](https://github.com/block/buzz/pull/8929) and [#8931](https://github.com/block/buzz/pull/8931). | | Before | After | | --- | --- | --- | | UI frame time, total | 3,344 ms | 383 ms | | Frames over 16.7 ms | 101 of 232 | 2 of 297 | | Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms | | UI isolate CPU samples (1 ms) | 2,793 | 868 | | Samples in `MessageContent.build` | 621 | 0 | Single runs per side, not repeated. ## Testing - New widget tests: a covered page doesn't rebuild for keyboard frames and gets the live insets back when it's current again. A current page follows the keyboard. Covering a page keeps its state. A page uncovered with the keyboard still up does not jump, and follows the keyboard once it reaches the held value. A page whose own field takes focus after being uncovered follows the keyboard immediately. A covered page follows its own keyboard closing and takes new padding when rotated. The last test fails (3 mounts instead of 1) against a version that returned a different widget tree when covered. - `just mobile-check mobile-test` passes. - On the phone: Add members search, typing and results behave as before. The channel page and search still move above the keyboard when they're current. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 Co-authored-by: studio --- mobile/lib/app.dart | 27 +- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/placement_route.dart | 32 +- .../channels/channel_details_page.dart | 12 +- mobile/lib/features/home/home_page.dart | 3 +- .../covered_route_keyboard_insets.dart | 92 +++++ .../covered_route_keyboard_insets_test.dart | 391 ++++++++++++++++++ 8 files changed, 526 insertions(+), 33 deletions(-) create mode 100644 mobile/lib/shared/widgets/covered_route_keyboard_insets.dart create mode 100644 mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 301c35fd30..7a2e62ce9f 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -48,6 +48,7 @@ import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; import 'shared/widgets/buzz_toast_host.dart'; +import 'shared/widgets/covered_route_keyboard_insets.dart'; const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; @@ -456,19 +457,21 @@ class App extends HookConsumerWidget { data: (state) => switch (state.status) { AuthStatus.authenticated => HostedAppGate( authenticated: true, - child: HomePage( - onAgents: showAgents, - defaultAgentBuilder: (context) => DefaultAgentLauncher( - onChooseAgent: () => showAgents(context, createOnOpen: true), + child: CoveredRouteKeyboardInsets( + child: HomePage( + onAgents: showAgents, + defaultAgentBuilder: (context) => DefaultAgentLauncher( + onChooseAgent: () => showAgents(context, createOnOpen: true), + ), + mePageBuilder: (_, chat, workspace) => MePage( + sliver: true, + newChatAction: chat, + newWorkspaceAction: workspace, + ), + settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => const CommunityInvitePage(), + communityAppearancePageBuilder: (_) => const ThemePickerPage(), ), - mePageBuilder: (_, chat, workspace) => MePage( - sliver: true, - newChatAction: chat, - newWorkspaceAction: workspace, - ), - settingsPageBuilder: _buildSettingsPage, - communityInvitePageBuilder: (_) => const CommunityInvitePage(), - communityAppearancePageBuilder: (_) => const ThemePickerPage(), ), ), _ => const HostedAppGate( diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 3f8cb24548..de5d0c482b 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -36,6 +36,7 @@ import 'members_sheet.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; part 'channel_details_page.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 2ecb2d07f9..a68936e78b 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -43,6 +43,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/bouncing_dots_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/flapping_bee.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/placement_route.dart b/mobile/lib/features/channels/channel_detail_page/placement_route.dart index 84d85f8dfd..3efa58ff9d 100644 --- a/mobile/lib/features/channels/channel_detail_page/placement_route.dart +++ b/mobile/lib/features/channels/channel_detail_page/placement_route.dart @@ -46,21 +46,23 @@ class ChannelDetailPage extends ConsumerWidget { body: const Center(child: BuzzLoadingIndicator()), ); } - return _ResolvedChannelDetailPage( - key: ValueKey((channel.id, placed)), - channel: current, - personalChat: placed, - welcomeAgent: current.isDm - ? ref.watch(bestieWelcomeProvider(channel.id)) - : null, - placementKnown: - placed || - groups != null || - placement != ChannelRoutePlacement.unresolved, - initialAssistant: initialAssistant, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - initialThreadRouteBehavior: initialThreadRouteBehavior, + return CoveredRouteKeyboardInsets( + child: _ResolvedChannelDetailPage( + key: ValueKey((channel.id, placed)), + channel: current, + personalChat: placed, + welcomeAgent: current.isDm + ? ref.watch(bestieWelcomeProvider(channel.id)) + : null, + placementKnown: + placed || + groups != null || + placement != ChannelRoutePlacement.unresolved, + initialAssistant: initialAssistant, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ), ); } } diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 3b87018123..d09cd2cfc4 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -13,11 +13,13 @@ Future showChannelDetailsPage({ String? sectionId, }) => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailsPage( - channel: channel, - currentPubkey: currentPubkey, - onMemberTap: onMemberTap, - sectionId: sectionId, + builder: (_) => CoveredRouteKeyboardInsets( + child: ChannelDetailsPage( + channel: channel, + currentPubkey: currentPubkey, + onMemberTap: onMemberTap, + sectionId: sectionId, + ), ), ), ); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index a9db0198a9..265880632b 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -18,6 +18,7 @@ import '../channels/channels_page.dart'; import '../search/search_page.dart'; import 'messages_filters.dart'; import 'messages_navigation_bar.dart'; +import '../../shared/widgets/covered_route_keyboard_insets.dart'; class HomePage extends HookConsumerWidget { const HomePage({ @@ -67,7 +68,7 @@ class HomePage extends HookConsumerWidget { final reducedMotion = MediaQuery.disableAnimationsOf(context); void openSearch() => Navigator.of(context).push( BottomUpPageRoute( - builder: (_) => const SearchPage(), + builder: (_) => const CoveredRouteKeyboardInsets(child: SearchPage()), reducedMotion: reducedMotion, ), ); diff --git a/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart b/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart new file mode 100644 index 0000000000..c73461ca16 --- /dev/null +++ b/mobile/lib/shared/widgets/covered_route_keyboard_insets.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Holds [child]'s keyboard insets steady while its route is covered. +/// +/// A keyboard raised by a route above (a sheet with a search field, or a +/// native picker presented over the app) still changes the app's +/// [MediaQuery] on every animation frame. Without this, a covered page that +/// reads `MediaQuery.of`, `paddingOf` or `viewInsetsOf` (directly or through +/// a [Scaffold]) rebuilds and relays out its whole content for keyboard +/// motion it cannot show. While the route is not current, [child] keeps the +/// view insets and padding it last had as the current route, except that it +/// still follows the keyboard down: when a covering route takes focus and the +/// page's own keyboard closes, the page lowers behind the scrim instead of +/// jumping when it is uncovered. A size change (rotation) drops the held +/// values, so safe-area padding stays right for the new orientation. Every +/// other [MediaQueryData] field passes through. +/// +/// When the route becomes current again with the keyboard still above the +/// held value (the covering route's keyboard has not finished closing), the +/// page keeps the held values instead of jumping up to the live keyboard and +/// riding it back down. It releases to the live values once the keyboard +/// comes down to the held value, or as soon as focus is inside this route +/// (the page raised its own keyboard and has to follow it). +class CoveredRouteKeyboardInsets extends HookWidget { + const CoveredRouteKeyboardInsets({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final data = MediaQuery.of(context); + final current = ModalRoute.isCurrentOf(context) ?? true; + // Focus moving into the route must release the held values even when the + // keyboard height is not changing at that moment. + useListenable(FocusManager.instance); + final focusInside = _hasFocusInside(FocusScope.of(context)); + // The insets and padding from the last build as the current route, or + // the smallest keyboard seen since it was covered at the current size. + final held = useRef<(EdgeInsets, EdgeInsets, Size)?>(null); + // Whether the last build was as a covered route. + final covered = useRef(false); + // Whether the route is current again but still shows the held values. + final settling = useRef(false); + final heldValue = held.value; + final sizeChanged = heldValue != null && data.size != heldValue.$3; + if (!current) { + covered.value = true; + settling.value = false; + if (heldValue == null || + sizeChanged || + data.viewInsets.bottom <= heldValue.$1.bottom) { + held.value = (data.viewInsets, data.padding, data.size); + } + } else { + if (covered.value && + heldValue != null && + !sizeChanged && + !focusInside && + data.viewInsets.bottom > heldValue.$1.bottom) { + settling.value = true; + } + covered.value = false; + if (settling.value && + (sizeChanged || + focusInside || + data.viewInsets.bottom <= heldValue!.$1.bottom)) { + settling.value = false; + } + if (!settling.value) { + held.value = (data.viewInsets, data.padding, data.size); + } + } + final (viewInsets, padding, _) = held.value!; + // Always the same widget shape, so covering never remounts the page. + return MediaQuery( + data: current && !settling.value + ? data + : data.copyWith(viewInsets: viewInsets, padding: padding), + child: child, + ); + } + + /// Whether primary focus is on a descendant of [scope], not [scope] itself + /// (which holds focus when nothing inside the route has it). + static bool _hasFocusInside(FocusScopeNode scope) { + final primary = FocusManager.instance.primaryFocus; + return primary != null && + primary != scope && + primary.ancestors.contains(scope); + } +} diff --git a/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart b/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart new file mode 100644 index 0000000000..3dbb70dacb --- /dev/null +++ b/mobile/test/shared/widgets/covered_route_keyboard_insets_test.dart @@ -0,0 +1,391 @@ +import 'package:buzz/shared/widgets/covered_route_keyboard_insets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Counts builds and records the insets the page saw last. +class _Probe extends StatelessWidget { + const _Probe(this.builds, this.seen); + + final List builds; + final List seen; + + @override + Widget build(BuildContext context) { + builds[0]++; + seen.add(MediaQuery.viewInsetsOf(context)); + MediaQuery.paddingOf(context); + return const SizedBox.expand(); + } +} + +void main() { + testWidgets('a covered page ignores keyboard motion and gets the live ' + 'insets back when it is current again', (tester) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = EdgeInsets.zero; + late StateSetter setOuter; + // Built once, so only the inherited MediaQuery changes below. + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34), + ), + child: app, + ); + }, + ), + ); + expect(seen.last, EdgeInsets.zero); + + // A transparent route above, as the native member picker uses. + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + final coveredBuilds = builds[0]; + + // Keyboard frames for the covering route's field. + for (final bottom in [80.0, 160.0, 240.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(builds[0], coveredBuilds); + expect(seen.last, EdgeInsets.zero); + + // Uncovered while the covering route's keyboard is still up: the page + // stays where it was instead of jumping up, then takes live values once + // the keyboard is gone. + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, EdgeInsets.zero); + for (final bottom in [200.0, 100.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, EdgeInsets.zero); + } + setOuter(() => insets = EdgeInsets.zero); + await tester.pump(); + expect(seen.last, EdgeInsets.zero); + setOuter(() => insets = const EdgeInsets.only(bottom: 120)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 120)); + }); + + testWidgets('an uncovered page never rises above the live keyboard and ' + 'follows it once it reaches the held value', (tester) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = const EdgeInsets.only(bottom: 300); + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + ), + child: app, + ); + }, + ), + ); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + // The page's keyboard closes to 100 behind the covering route, whose own + // field then raises the keyboard back to 300. + for (final bottom in [200.0, 100.0, 200.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(seen.last, const EdgeInsets.only(bottom: 100)); + + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, const EdgeInsets.only(bottom: 100)); + for (final bottom in [250.0, 150.0, 100.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 100)); + } + setOuter(() => insets = const EdgeInsets.only(bottom: 40)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 40)); + }); + + testWidgets('an uncovered page follows the keyboard its own field raises', ( + tester, + ) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var insets = EdgeInsets.zero; + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets( + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + Expanded(child: _Probe(builds, seen)), + TextField(focusNode: focusNode), + ], + ), + ), + ), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + ), + child: app, + ); + }, + ), + ); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + setOuter(() => insets = const EdgeInsets.only(bottom: 300)); + await tester.pump(); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(seen.last, EdgeInsets.zero); + + // The page's own field takes focus while the keyboard is still up, so the + // page has to follow the keyboard rather than hold below it. + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + setOuter(() => insets = const EdgeInsets.only(bottom: 280)); + await tester.pump(); + expect(seen.last, const EdgeInsets.only(bottom: 280)); + }); + + testWidgets('a covered page follows its own keyboard closing', ( + tester, + ) async { + final builds = [0]; + final seen = []; + final navigator = GlobalKey(); + var insets = const EdgeInsets.only(bottom: 300); + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: const Size(400, 800), + viewInsets: insets, + padding: EdgeInsets.only(bottom: insets.bottom > 0 ? 0 : 34), + ), + child: app, + ); + }, + ), + ); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + + // A sheet takes focus, so the page's keyboard closes behind it. + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + for (final bottom in [200.0, 100.0, 0.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + expect(seen.last, EdgeInsets.only(bottom: bottom)); + } + + // A keyboard the covering route raises is still ignored. + final coveredBuilds = builds[0]; + for (final bottom in [80.0, 160.0, 300.0]) { + setOuter(() => insets = EdgeInsets.only(bottom: bottom)); + await tester.pump(); + } + expect(builds[0], coveredBuilds); + expect(seen.last, EdgeInsets.zero); + }); + + testWidgets('a covered page takes the new padding when rotated', ( + tester, + ) async { + EdgeInsets? padding; + final navigator = GlobalKey(); + var landscape = false; + var keyboard = 0.0; + late StateSetter setOuter; + final app = MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets( + child: Builder( + builder: (context) { + padding = MediaQuery.paddingOf(context); + return const SizedBox.expand(); + }, + ), + ), + ); + + await tester.pumpWidget( + StatefulBuilder( + builder: (context, setState) { + setOuter = setState; + return MediaQuery( + data: MediaQueryData( + size: landscape ? const Size(800, 400) : const Size(400, 800), + padding: landscape + ? const EdgeInsets.symmetric(horizontal: 44) + : const EdgeInsets.only(top: 44, bottom: 34), + viewInsets: EdgeInsets.only(bottom: keyboard), + ), + child: app, + ); + }, + ), + ); + expect(padding, const EdgeInsets.only(top: 44, bottom: 34)); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + + // The covering route raises its own keyboard, so only the size change can + // release the held portrait padding. + setOuter(() => keyboard = 300); + await tester.pump(); + expect(padding, const EdgeInsets.only(top: 44, bottom: 34)); + + setOuter(() => landscape = true); + await tester.pump(); + expect(padding, const EdgeInsets.symmetric(horizontal: 44)); + }); + + testWidgets('a current page follows the keyboard', (tester) async { + final builds = [0]; + final seen = []; + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(size: Size(400, 800)), + child: MaterialApp( + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ), + ), + ); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + size: Size(400, 800), + viewInsets: EdgeInsets.only(bottom: 300), + ), + child: MaterialApp( + home: CoveredRouteKeyboardInsets(child: _Probe(builds, seen)), + ), + ), + ); + expect(seen.last, const EdgeInsets.only(bottom: 300)); + }); + + testWidgets('covering the page keeps its state', (tester) async { + final navigator = GlobalKey(); + final mounts = [0]; + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + home: CoveredRouteKeyboardInsets(child: _MountCounter(mounts)), + ), + ); + expect(mounts.single, 1); + + navigator.currentState!.push( + PageRouteBuilder( + opaque: false, + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const SizedBox(), + ), + ); + await tester.pumpAndSettle(); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(mounts.single, 1); + }); +} + +class _MountCounter extends HookWidget { + const _MountCounter(this.mounts); + + final List mounts; + + @override + Widget build(BuildContext context) { + useEffect(() { + mounts[0]++; + return null; + }, const []); + return const SizedBox(); + } +}