From fc9e652c0ee83537b844922747074394bd210d7a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 08:00:11 -0800 Subject: [PATCH] perf(mobile): format a channel timeline only when its events change (#8929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../channels/channel_detail_page.dart | 16 +++++---- .../loading_review_tests.dart | 33 +++++++++++++++---- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 745b0f3950..162f1fd804 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -675,12 +675,16 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { ? dmHeader?.presenceLabel : headerMemberLabel; - final timelineMessages = resolvedChannel.isForum - ? [] - : 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 []; + final timelineMessages = useMemoized( + () => resolvedChannel.isForum + ? [] + : formatTimeline(events, currentPubkey: currentPubkey), + [events, currentPubkey, resolvedChannel.isForum], + ); final timelineEntries = buildMainTimelineEntries( // End events still update the original card through allMessages. timelineMessages diff --git a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart index 1c71be9ee6..e69e4005e7 100644 --- a/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/loading_review_tests.dart @@ -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 {