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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
This commit is contained in:
Tom Brow
2026-11-13 14:40:22 -08:00
committed by GitHub
parent 3764a2e4d5
commit 5f1cd67985
7 changed files with 283 additions and 147 deletions

View File

@@ -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<Rect?>(null);
final headerKey = useMemoized(GlobalKey.new);
@@ -310,7 +308,10 @@ class ChannelsPage extends HookConsumerWidget {
.toList();
Future<void> 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<ConversationPaneScope>()
?.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<bool> current;
final ValueNotifier<bool> 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;
}
}

View File

@@ -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 {

View File

@@ -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,
);
}
}

View File

@@ -102,14 +102,15 @@ class ConversationPaneController extends ChangeNotifier {
class ConversationPaneScope
extends InheritedNotifier<ConversationPaneController> {
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<ConversationPaneScope>()
.dependOnInheritedWidgetOfExactType<_ConversationPaneActive>()
?.active ??
true;
@override
bool updateShouldNotify(ConversationPaneScope oldWidget) =>
active != oldWidget.active || super.updateShouldNotify(oldWidget);
static ConversationPaneController? maybeOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<ConversationPaneScope>()
?.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 {

View File

@@ -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<Object> {
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<DuoToolbarScope>();
InheritedModel.inheritFrom<DuoToolbarScope>(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<DuoToolbarScope>(
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<Object> dependencies,
) => strip != oldWidget.strip;
}
/// Content clearance for a pane, or a full-window route outside a pane.

View File

@@ -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<DuoToolbarScope>() == null
? FoldGeometry.of(context).strip
: DuoToolbarScope.maybeStripOf(context);
final height = IosNavigationMetrics.of(context).compactHeight;
return strip == null
? max(height, titleHeight)

View File

@@ -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 = <String>[];
debugOnRebuildDirtyWidget = (element, _) {
final type = '${element.widget.runtimeType}';
if (const {'HomePage', 'ChannelsPage', '_ChannelTile'}.contains(type)) {
rebuilds.add(type);
}
};
addTearDown(() => debugOnRebuildDirtyWidget = null);
unawaited(
showModalBottomSheet<void>(
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 {