Files
buzz/mobile/lib/shared/relay/thread_reference.dart
Tom Brow bf422ac8e1 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-13 14:57:26 -08:00

33 lines
1.2 KiB
Dart

/// Resolve NIP-10 markers using relay ingest validity and ordering rules.
///
/// 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<List<String>> tags,
) => _threadReferences[tags] ??= _parseThreadReference(tags);
final _threadReferences = Expando<({String? parentId, String? rootId})>();
({String? parentId, String? rootId}) _parseThreadReference(
List<List<String>> tags,
) {
String? parentId;
String? rootId;
for (final tag in tags) {
if (tag.length < 4 || tag[0] != 'e') continue;
final id = tag[1];
if (id.length != 64 || !_threadEventId.hasMatch(id)) continue;
if (tag[3] == 'reply') parentId = id.toLowerCase();
if (tag[3] == 'root') rootId = id.toLowerCase();
}
if (parentId == null) return (parentId: null, rootId: null);
return (parentId: parentId, rootId: rootId ?? parentId);
}
final _threadEventId = RegExp(r'^[0-9a-fA-F]{64}$');