From cad7f1d53d2c81a86835fcc26ac06cfb5e29e697 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 08:00:19 -0800 Subject: [PATCH] perf(mobile): build composer channel names once per channel list (#8943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `_useComposerChannelNames` ran on every `ComposeBar` build. Each time it built a lowercase-name to id map over every channel and joined every entry into a string key, only so a `useEffect` could skip calling `setChannelNames`. The composer rebuilds on every keystroke and keyboard animation frame. This memoizes the map and key on the channel list instance. The effect is still gated on the content key, so an equal new list still doesn't call `setChannelNames` again. ## Measurements iPhone 12 mini, Profile build, Builderlab, raising the composer keyboard in #4things: `_useComposerChannelNames` was 26 of 31 `ComposeBar.build` CPU samples (about 26 ms of UI work across the 600 ms raise). Limitation: I haven't re-measured on the device after the change. The removal is shown by the test below. ## Testing - New test: rebuilding the composer 5 times (keyboard insets and typing) with the same channel list doesn't iterate the list again. It fails with the change reverted (8 iterations instead of 3). - New test: changing the channel list still updates the composer's channel chips. - `just mobile-check mobile-test` passes (pre-push hook). 🤖 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 --- .../channels/compose_bar/helpers.dart | 20 ++-- .../features/channels/compose_bar_test.dart | 112 ++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 43811dcb87..2898bbb95b 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -32,13 +32,19 @@ void _useComposerChannelNames( _MarkdownEditingController controller, AsyncValue> channelsAsync, ) { - final channelNames = { - for (final channel in channelsAsync.asData?.value ?? const []) - channel.name.toLowerCase(): channel.id, - }; - final channelNamesKey = channelNames.entries - .map((entry) => '${entry.key}\u0000${entry.value}') - .join('\u0001'); + // The composer rebuilds on every keystroke and keyboard frame, so build the + // name map and its key only when the channel list itself changes. + final channels = channelsAsync.asData?.value; + final (:channelNames, :channelNamesKey) = useMemoized(() { + final names = { + for (final channel in channels ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final key = names.entries + .map((entry) => '${entry.key}\u0000${entry.value}') + .join('\u0001'); + return (channelNames: names, channelNamesKey: key); + }, [channels]); useEffect(() { controller.setChannelNames(channelNames); return null; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 9afad8a85c..00d073a735 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/scheduler.dart'; import 'dart:async'; import 'package:buzz/features/channels/composer_send_origin.dart'; +import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -695,6 +696,37 @@ class _FakeChannelsNotifier extends ChannelsNotifier { void notifyWithCopy() { state = AsyncData([..._channels]); } + + void replaceChannels(List channels) { + state = AsyncData(channels); + } +} + +/// A channel list that counts how many times it is iterated. +class _IterationCountingChannels extends ListBase { + _IterationCountingChannels(this._channels); + + final List _channels; + int iterations = 0; + + @override + Iterator get iterator { + iterations++; + return _channels.iterator; + } + + @override + int get length => _channels.length; + + @override + set length(int value) => throw UnsupportedError('read-only'); + + @override + Channel operator [](int index) => _channels[index]; + + @override + void operator []=(int index, Channel value) => + throw UnsupportedError('read-only'); } void main() { @@ -3806,6 +3838,86 @@ void main() { ); }); + testWidgets('builds channel names once per channel list, not per build', ( + tester, + ) async { + final channels = _IterationCountingChannels([ + _makeChannel(name: 'engineering', channelType: 'stream'), + ]); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: channels, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _expandComposer(tester); + await tester.pump(); + expect(channels.iterations, greaterThan(0)); + final settled = channels.iterations; + + // Typing and keyboard frames rebuild the composer without changing the + // channel list. + addTearDown(tester.view.resetViewInsets); + for (var i = 1; i <= 5; i++) { + tester.view.viewInsets = FakeViewPadding(bottom: i * 40.0); + await tester.enterText(find.byType(TextField), 'draft $i'); + await tester.pump(); + } + + expect(channels.iterations, settled); + }); + + testWidgets('composer channel chips follow channel list changes', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + Channel named(String name) => Channel( + id: channelId, + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: [named('engineering')], + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _expandComposer(tester); + await tester.enterText( + find.byType(TextField), + 'buzz://channel/$channelId', + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:engineering')), + findsOneWidget, + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + (container.read(channelsProvider.notifier) as _FakeChannelsNotifier) + .replaceChannels([named('platform')]); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:platform')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:engineering')), + findsNothing, + ); + }); + testWidgets('renders all five permalink types as composer chips', ( tester, ) async {