From bf422ac8e1a34893e15c91bfc86a74d990547feb Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 13 Nov 2026 14:57:26 -0800 Subject: [PATCH] perf(mobile): parse each event's thread reference once (#9460) Every time code asked an event for its parent or thread root (`threadReference`, `parentEventId`), `parseThreadReference` walked its tags again and ran a 64-character hex regex over each `e` tag. The channel store asks that many times per event: - `_trimReplyOverlay` runs on every live event merged into the window and re-parsed every overlay event, twice. - `_reconcilePageSummaries` called `cachedThreadReplyIds(root)` once per row of a fetched page, and each call re-parsed every cached reply (twice per reply) to find that root's replies. A 200-row page did 200 full passes. Now: - `parseThreadReference` keeps its result per tag list in an `Expando`. Event tags are never changed after an event is built (nothing in `lib` mutates a `NostrEvent`'s tags), so the cached value always matches. - `_reconcilePageSummaries` indexes cached replies by root once, then reads each row's count from that map. `cachedThreadReplyIds` keeps its signature and result and shares the same code. ### Validation Full mobile test suite and `flutter analyze` pass. Existing thread-summary, reply-overlay and thread-reference tests cover the unchanged results. On the iPhone 12 mini (profile build, Builderlab), same scripted run on `main` and on this branch: open three threads from Activity, back out after each. | Dart CPU in thread-reference code | main | this branch | | --- | --- | --- | | First thread (not cached yet) | 5 ms | 2 ms | | Second thread (loads the channel window) | 27 ms, plus 18 ms trimming the reply overlay | 3 ms, plus 2 ms | | Third thread | 10 ms | 0 ms | The threads opened and showed the same replies in both runs, and the run covered a channel window load, live events arriving while a thread was open, and backing out to Home. ### Regressions None expected. Results are identical; the cache holds one small record per live tag list and is released with the event. Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../channels/channel_messages_provider.dart | 30 ++++++++++++------- .../thread_summaries.dart | 4 ++- mobile/lib/shared/relay/thread_reference.dart | 10 +++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 2adac63238..3bac9d92bc 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -624,7 +624,11 @@ class ChannelMessagesNotifier extends Notifier>> { _rotateDeletionProofTargets(missingIds); /// Snapshots cached overlay replies covered by a query for this outer root. - Set cachedThreadReplyIds(String rootId) { + Set cachedThreadReplyIds(String rootId) => + _cachedThreadReplyIdsByRoot(only: rootId)[rootId] ?? {}; + + /// Snapshots cached overlay replies for every outer root, or only [only]. + Map> _cachedThreadReplyIdsByRoot({String? only}) { final deletedIds = { for (final event in [ ..._windowStore.liveAux, @@ -636,15 +640,21 @@ class ChannelMessagesNotifier extends Notifier>> { for (final tag in event.tags) if (tag.length > 1 && tag[0] == 'e') tag[1], }; - return { - for (final entry in _localReplyRoots.entries) - if (entry.value == rootId && !deletedIds.contains(entry.key)) entry.key, - for (final event in _cachedThreadEvents) - if (event.threadReference.parentId != null && - event.threadReference.rootId == rootId && - !deletedIds.contains(event.id)) - event.id, - }; + final byRoot = >{}; + void add(String root, String id) { + if ((only == null || root == only) && !deletedIds.contains(id)) { + (byRoot[root] ??= {}).add(id); + } + } + + for (final entry in _localReplyRoots.entries) { + add(entry.value, entry.key); + } + for (final event in _cachedThreadEvents) { + final thread = event.threadReference; + if (thread.parentId != null) add(thread.rootId!, event.id); + } + return byRoot; } /// Applies explicit deletion evidence fetched for cached replies. diff --git a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart index 7d22de342b..8e6cd2a1e8 100644 --- a/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart +++ b/mobile/lib/features/channels/channel_messages_provider/thread_summaries.dart @@ -2,6 +2,8 @@ part of '../channel_messages_provider.dart'; extension _ThreadSummaryState on ChannelMessagesNotifier { void _reconcilePageSummaries(ChannelWindowPage page, int pageVersion) { + // Nothing below changes the cached replies, so index them once per page. + final cachedReplies = _cachedThreadReplyIdsByRoot(); for (final row in page.rows) { final root = row.event.id; if ((_threadEvidenceVersions[root] ?? 0) > pageVersion) continue; @@ -10,7 +12,7 @@ extension _ThreadSummaryState on ChannelMessagesNotifier { _summaryRefreshes.cancel(root); _setThreadQueryVersion(root, pageVersion); _clearDeletionUncertainty(root, pageVersion); - if (cachedThreadReplyIds(root).length > + if ((cachedReplies[root]?.length ?? 0) > (row.thread?.descendantCount ?? 0)) { _queueOverflowSummary(root); } diff --git a/mobile/lib/shared/relay/thread_reference.dart b/mobile/lib/shared/relay/thread_reference.dart index b3ef371dce..7931887f51 100644 --- a/mobile/lib/shared/relay/thread_reference.dart +++ b/mobile/lib/shared/relay/thread_reference.dart @@ -3,8 +3,18 @@ /// The last valid 64-character ASCII-hex marker wins. IDs are lowercased for /// client lookup. Root-only events remain top-level; a reply without a valid /// root uses its parent as root. +/// +/// Timeline and thread-summary code reads this many times per event, so the +/// result is kept per tag list. Event tags are never changed after an event is +/// built. ({String? parentId, String? rootId}) parseThreadReference( List> tags, +) => _threadReferences[tags] ??= _parseThreadReference(tags); + +final _threadReferences = Expando<({String? parentId, String? rootId})>(); + +({String? parentId, String? rootId}) _parseThreadReference( + List> tags, ) { String? parentId; String? rootId;