perf(mobile): rank search channels once per query (#9136)

Search re-ranked every channel on each live channel event, even while it
sat covered in the back stack, and could move a result while you were
reaching for it. On an iPhone 12 mini in a large community, each re-rank
cost 4 to 20 ms at about 2.4 channel events per second: 454 ms of 2,575
ms UI CPU in a 25 s scroll of a big channel with search behind it, and
343 ms in the 15 s after a resume.

Channel results are now ranked when the query runs and when the set of
channels changes: the first load and a directory install (including one
another caller started). Live channel events no longer reorder shown
results; they only update the shown rows' content (membership badge,
member count, name) in place. The next query ranks the current list.

### Accepted gap

A channel joined, left or renamed while results are shown updates its
row but keeps its position until the next query.

### Validation

- Added test: `live channel events never reorder shown results` adds a
channel and checks it is ranked in, moves it to the top of the list
through activity and checks the shown rows don't move, lets a live event
land between another caller's directory status turning loaded and its
install and checks the open channel shows, then joins that open channel
and checks its Open badge goes without the row moving. It fails when
activity re-ranks again, when a change to the channel set is not ranked,
and when shown rows keep stale content.
- Search suite and full mobile suite pass except the known
notification/push failures that also fail on main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", channel and people results listed, opened
a channel result, went back with the results still shown.
- Device numbers are from a wired Profile build of main 120d75d976
(pre-change); the change removes the call path, so there's no
post-change device capture.

🤖 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>
This commit is contained in:
Tom Brow
2026-11-05 17:04:31 -08:00
committed by GitHub
parent 1a30e51689
commit fd0ec10505
2 changed files with 138 additions and 4 deletions

View File

@@ -190,10 +190,21 @@ class SearchNotifier extends Notifier<SearchState> {
_usersPending = false;
_directoryLoad = null;
_directoryAttempted = false;
// Channel rows come from the loaded channel list, which gains open
// channels when the directory arrives. Re-rank whenever either changes;
// the directory status for this community also drives the spinner.
ref.listen(channelsProvider, (_, _) => _refreshChannels());
// Channel rows are ranked once per query, so live channel events never
// reorder rows under a finger: they only update the shown rows' content.
// Rows are ranked again when the set of channels changes (the first
// load, a directory install); the directory status for this community
// also drives the spinner.
ref.listen(channelsProvider, (previous, next) {
final channels = next.asData?.value;
if (channels == null) return;
final before = previous?.asData?.value;
if (before == null || !_sameChannels(before, channels)) {
_refreshChannels();
} else {
_updateShownChannels(channels);
}
});
ref.listen(
channelDirectoryLoadStatusProvider,
(_, _) => _refreshChannels(),
@@ -383,6 +394,25 @@ class SearchNotifier extends Notifier<SearchState> {
state = state.copyWith(userResults: users, isLoading: _isLoading);
}
static bool _sameChannels(List<Channel> a, List<Channel> b) =>
a.length == b.length &&
a.map((c) => c.id).toSet().containsAll(b.map((c) => c.id));
/// Shows each shown channel's latest content (membership, member count,
/// name) in the rows' existing order.
void _updateShownChannels(List<Channel> channels) {
final shown = state.channelResults;
if (shown.isEmpty) return;
final byId = {for (final channel in channels) channel.id: channel};
final updated = [for (final row in shown) byId[row.id] ?? row];
for (var i = 0; i < shown.length; i++) {
if (!identical(updated[i], shown[i])) {
state = state.copyWith(channelResults: updated);
return;
}
}
}
/// Publishes the channel rows and spinner for the current query.
void _refreshChannels() {
if (state.query.isEmpty) return;

View File

@@ -142,6 +142,110 @@ void main() {
expect(find.text("No results for 'ada'"), findsOneWidget);
});
testWidgets('live channel events never reorder shown results', (
tester,
) async {
final session = _HeldSession();
final actions = _HeldUserSearch();
final channels = _DirectoryChannels();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_Community.new),
relaySessionProvider.overrideWith(() => session),
channelActionsProvider.overrideWith((ref) => actions),
channelsProvider.overrideWith(() => channels),
recentSearchesProvider.overrideWith(_NoRecentSearches.new),
profileProvider.overrideWith(_Profile.new),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'team');
await tester.pump(const Duration(milliseconds: 300));
final shown = find.byKey(
const ValueKey('search-channel-row-team-4things-id'),
);
final joined = find.byKey(
const ValueKey('search-channel-row-team-chat-id'),
);
expect(shown, findsOneWidget);
// A new channel changes the set, so it is ranked in: joined ties
// keep the list's order.
channels.state = AsyncData([
...channels.state.requireValue,
_channel('team-chat', isMember: true),
]);
await tester.pump();
expect(joined, findsOneWidget);
expect(tester.getTopLeft(shown).dy, lessThan(tester.getTopLeft(joined).dy));
// Activity moves the joined channel to the top of the list. Rows stay put
// under the finger.
void bump(String name) {
final list = channels.state.requireValue;
channels.state = AsyncData([
...list.where((c) => c.name == name),
...list.where((c) => c.name != name),
]);
}
bump('team-chat');
await tester.pump();
expect(tester.getTopLeft(shown).dy, lessThan(tester.getTopLeft(joined).dy));
// Another caller's directory load: its status turns loaded, a live event
// lands, then it installs the open channels, which this search shows.
final container = ProviderScope.containerOf(
tester.element(find.byType(SearchPage)),
);
final status = container.read(channelDirectoryLoadStatusProvider.notifier);
final scope = channelDirectoryScope(
'http://community.test',
container.read(myPubkeyProvider),
);
status.markLoaded(scope);
await tester.pump();
bump('general');
await tester.pump();
channels.state = AsyncData([
...channels.state.requireValue,
_channel('team-open', isMember: false),
]);
await tester.pump();
final open = find.byKey(const ValueKey('search-channel-row-team-open-id'));
expect(open, findsOneWidget);
expect(
find.descendant(of: open, matching: find.text('Open')),
findsOneWidget,
);
// Joining a shown open channel updates its row in place: the badge goes,
// and no row moves.
final openTop = tester.getTopLeft(open).dy;
channels.state = AsyncData([
for (final channel in channels.state.requireValue)
channel.name == 'team-open'
? channel.copyWith(isMember: true)
: channel,
]);
await tester.pump();
expect(
find.descendant(of: open, matching: find.text('Open')),
findsNothing,
);
expect(tester.getTopLeft(open).dy, openTop);
// The next query ranks the current list.
await tester.enterText(find.byType(TextField), 'team ');
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getTopLeft(joined).dy, lessThan(tester.getTopLeft(shown).dy));
});
testWidgets('a failed directory offers a retry instead of no results', (
tester,
) async {