perf(mobile): format a channel timeline only when its events change (#8929)

## Summary

The channel page ran `formatTimeline` on every build. The page also
rebuilds for changes unrelated to messages: read state, presence, the
roster, keyboard and layout. `formatTimeline` walks every loaded event
and JSON-decodes each system row.

In #4things (1,235 members, mostly join rows), on a 12 mini Profile
build:
- 58 ms of main thread over 21 idle seconds.
- About 11 ms per frame while the keyboard animated.

The messages notifiers publish a new list for every event change, so
this memoizes the result on that list, the current pubkey, and the forum
flag.

`buildMainTimelineEntries` and the relay thread summaries it reads stay
outside the memo. A summary-only update that republishes the same list
still shows up.

### Related issue
None found.

### Testing
- Updated `loading_review_tests.dart`: unrelated page rebuilds now
format the timeline 0 times, where main formats once per rebuild (the
test fails on main). A new live event formats exactly once, and its
message appears.
- `just mobile-install mobile-check mobile-test` passes.
- Device (12 mini, Profile build with this change, #4things idle). Runs
are not matched, so this shows the mechanism, not a controlled speedup.
  - Before: 58 ms over 21 s.
  - After: 7 ms over 18 s, all from real event changes.
- An independent agent review found no defects. It checked that
`formatTimeline` reads only its arguments, that every publish path
creates a new list or republishes unchanged events, and that nothing
downstream mutates the shared result.

Not covered: a human test (waived under the mobile testing exception;
this was an agent-driven device run), and Android.

🤖 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:11 -08:00
committed by GitHub
parent 478cf0a97e
commit fc9e652c0e
2 changed files with 36 additions and 13 deletions

View File

@@ -675,12 +675,16 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
? dmHeader?.presenceLabel
: headerMemberLabel;
final timelineMessages = resolvedChannel.isForum
? <TimelineMessage>[]
: formatTimeline(
messagesState.value ?? const [],
currentPubkey: currentPubkey,
);
// The page also rebuilds for read state, presence, roster and layout
// changes. Format only when the events themselves change: the notifier
// publishes a new list for every event change.
final events = messagesState.value ?? const <NostrEvent>[];
final timelineMessages = useMemoized(
() => resolvedChannel.isForum
? <TimelineMessage>[]
: formatTimeline(events, currentPubkey: currentPubkey),
[events, currentPubkey, resolvedChannel.isForum],
);
final timelineEntries = buildMainTimelineEntries(
// End events still update the original card through allMessages.
timelineMessages

View File

@@ -446,24 +446,43 @@ void _loadingReviewTests() {
);
}
testWidgets('loaded page rebuild formats the timeline once', (tester) async {
testWidgets('loaded page formats the timeline only when its events change', (
tester,
) async {
var transforms = 0;
debugOnFormatTimeline = () => transforms++;
addTearDown(() => debugOnFormatTimeline = null);
final first = _textMsg(
id: 'one',
pubkey: 'alice',
content: 'Loaded message',
);
final messages = _FakeMessagesNotifier([first]);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(id: 'one', pubkey: 'alice', content: 'Loaded message'),
],
),
_buildTestable(messages: [first], messagesNotifier: messages),
);
await tester.pumpAndSettle();
// Read state, presence and layout changes rebuild the page; formatting
// every event again for each of them stalls large channels.
for (var i = 0; i < 3; i++) {
transforms = 0;
tester.element(find.byType(ChannelDetailPage)).markNeedsBuild();
await tester.pump();
expect(transforms, 1);
expect(transforms, 0);
}
transforms = 0;
messages.setMessages([
first,
_textMsg(
id: 'two',
pubkey: 'alice',
createdAt: first.createdAt + 1,
content: 'Live message',
),
]);
await tester.pump();
expect(transforms, 1);
expect(find.text('Live message', findRichText: true), findsOneWidget);
});
testWidgets('reconnect keeps cached media in place', (tester) async {