From 9aa70124bae4f6763babd9daaacbc5157ce1fc56 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 11:31:38 -0800 Subject: [PATCH] perf(mobile): validate each verified profile once, not on every publish (#9101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening or resuming a big channel spent about 210 ms of UI-thread CPU validating verified profiles, measured in Dart CPU samples on an iPhone 12 mini (Profile build) across two separate channel-resume captures (213 ms and 211 ms in `VerifiedProfilesNotifier._snapshot`, nearly all of it in `parseVerifiedProfile`). Every publish rebuilt the profile map by validating every known head again, and a channel with many members publishes once per subscription batch as each lands, so the work grew with members times batches. Each subject's parse is now kept while its head and the authority stand. Only `checked` changes between publishes (rebatching unchecks and rechecks subjects), so it is applied to the kept parse without validating again. ### Validation - Added `a later batch reuses profiles already parsed`: after a second subject rebatches the first, each head is validated exactly once. It fails when the kept parse is ignored. - `flutter analyze` is clean and the full mobile suite passes. - Same phone and scenario after this change: `parseVerifiedProfile` no longer appears in the resume CPU samples, and main-isolate message handling over the 15 s after resume dropped from 604 ms to 491 ms (one run each). Frame build and layout time on resume is unchanged; that is separate work. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../verified_profile_directory.dart | 71 ++++++++++++++++--- .../verified_profile_directory_test.dart | 37 ++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index 3bda442249..6aa83ec060 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -452,17 +452,46 @@ class VerifiedProfilesNotifier extends Notifier> { } } + /// Validates one head. Tests override this to count parses. + @visibleForTesting + VerifiedProfile? parseHead( + NostrEvent event, + VerifiedProfileAuthority authority, { + required bool checked, + }) => parseVerifiedProfile(event, authority, checked: checked); + + /// The last parse of each subject's head. Every publish rebuilds the whole + /// map, so without it each batch re-validated every head already known. + Map _parsed = const {}; + Map _snapshot() { final authority = _authority; - if (!_enabled || authority == null) return const {}; - return { - for (final MapEntry(:key, :value) in _heads.entries) - key: ?parseVerifiedProfile( - value, - authority, - checked: _discoveryFresh && _checked.contains(key), - ), - }; + if (!_enabled || authority == null) { + _parsed = const {}; + return const {}; + } + final parsed = {}; + final profiles = {}; + for (final MapEntry(:key, :value) in _heads.entries) { + final checked = _discoveryFresh && _checked.contains(key); + final previous = _parsed[key]; + final head = + previous != null && + identical(previous.event, value) && + previous.authority == authority + ? previous + : _ParsedHead( + value, + authority, + parseHead(value, authority, checked: checked), + ); + parsed[key] = head; + if (head.withChecked(checked) case final profile?) { + profiles[key] = profile; + } + } + _parsed = parsed; + return profiles; } void _publish() { @@ -472,6 +501,30 @@ class VerifiedProfilesNotifier extends Notifier> { } } +/// A head's parse, kept while the head and authority stand. Only `checked` +/// varies between publishes, so it is applied without validating again. +class _ParsedHead { + _ParsedHead(this.event, this.authority, this._profile); + + final NostrEvent event; + final VerifiedProfileAuthority authority; + VerifiedProfile? _profile; + + VerifiedProfile? withChecked(bool checked) { + final profile = _profile; + if (profile == null || profile.checked == checked) return profile; + return _profile = VerifiedProfile( + displayName: profile.displayName, + username: profile.username, + fullName: profile.fullName, + issuerLabel: profile.issuerLabel, + verifiedAt: profile.verifiedAt, + checked: checked, + multipleIssuers: profile.multipleIssuers, + ); + } +} + final verifiedProfilesProvider = NotifierProvider>( VerifiedProfilesNotifier.new, diff --git a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart index ac19b756ea..ce93027eba 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart @@ -55,6 +55,22 @@ class _CountingNotifier extends VerifiedProfilesNotifier { Map newInterestMap() => _CountingMap(iterations); } +class _ParseCountingNotifier extends VerifiedProfilesNotifier { + _ParseCountingNotifier(this.parses); + + final List parses; + + @override + VerifiedProfile? parseHead( + NostrEvent event, + VerifiedProfileAuthority authority, { + required bool checked, + }) { + parses.add(verifiedProfileSubject(event, authority)!); + return super.parseHead(event, authority, checked: checked); + } +} + class Harness { Harness(this.async, {VerifiedProfilesNotifier Function()? notifier}) { container = ProviderContainer( @@ -129,6 +145,27 @@ void main() { }); }); + test('a later batch reuses profiles already parsed', () { + fakeAsync((async) { + final parses = []; + final h = Harness(async, notifier: () => _ParseCountingNotifier(parses)); + addTearDown(h.container.dispose); + h.relay.seed([ + signedProjection(profileTags(subjectA)), + signedProjection(profileTags(subjectB)), + ]); + h.start([subjectA]); + expect(h.profiles[subjectA]?.checked, isTrue); + h.container.read(verifiedProfilesProvider.notifier).track([subjectB]); + h.settle(); + // Rebatching unchecks and rechecks A; every publish rebuilds the map, + // but each head is validated once. + expect(h.profiles[subjectA]?.checked, isTrue); + expect(h.profiles[subjectB]?.checked, isTrue); + expect(parses..sort(), [subjectA, subjectB]); + }); + }); + journey('no marks until discovery succeeds and advertises public_profile', ( h, ) {