mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user