perf(mobile): build composer channel names once per channel list (#8943)

## 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-03 08:00:19 -08:00
committed by GitHub
parent fc9e652c0e
commit cad7f1d53d
2 changed files with 125 additions and 7 deletions

View File

@@ -32,13 +32,19 @@ void _useComposerChannelNames(
_MarkdownEditingController controller,
AsyncValue<List<Channel>> channelsAsync,
) {
final channelNames = {
for (final channel in channelsAsync.asData?.value ?? const <Channel>[])
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>[])
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;

View File

@@ -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<Channel> channels) {
state = AsyncData(channels);
}
}
/// A channel list that counts how many times it is iterated.
class _IterationCountingChannels extends ListBase<Channel> {
_IterationCountingChannels(this._channels);
final List<Channel> _channels;
int iterations = 0;
@override
Iterator<Channel> 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 <List<String>>[]}) 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 <List<String>>[]}) 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 {