From 58eb36dd5791c1bc93c7f373db3d58cc2468b39a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 08:00:41 -0800 Subject: [PATCH] perf(mobile): cheaper identity-name rebuilds for large rosters (#8936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Every identity-name source change (a profile batch, a verified name) rebuilds a channel's naming scope over its whole member list. In a 4,000-member channel, each rebuild derived every key's naming fact more than once: owner loading and resolution each derived it, and so did each label for a key outside the scope. It also checked every key against a regular expression several times, and iOS AOT builds run regular expressions in an interpreter. - `IdentityNames` derives each key's naming fact once per scope. - `isHexPubkey`: a character-loop hex check, used by the identity-name code instead of the regular expressions. Labels are unchanged. ## Measurements These were taken with an earlier version of this PR that also cached npub encoding. That cache was removed to keep the change small, so the remaining gain is smaller than shown. I haven't re-measured it. - **Synthetic** (Mac, `flutter test`, 4,391 members, 60% with profiles; scope, `missingOwnerProfiles` and every label for each new sources object): a warm rebuild took 63 ms before and 12 ms with the cache. The first rebuild took 120 ms before and 91 ms with the cache. - **Device** (iPhone 12 mini, Profile build, first 5 s after opening #announcements with 4,391 members; runs not matched): `channelIdentityNamesProvider` plus `watchChannelIdentityLabel` inclusive CPU was 148 ms before and 81 ms with the cache. Of that, `fullNpub` (npub encoding, the cached part) went from 62 ms to 22 ms. The identity path no longer calls `RegExp.hasMatch`. ## Testing - A new test counts profile lookups. Owner loading, every label and an outside key read each member's profile once. Without the memo, each is read 3 times. - New `isHexPubkey` tests cover both cases, wrong lengths, characters just outside each range, and a bad character at every position. - `just mobile-check mobile-test` passes. 🤖 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 --- .../identity_names/identity_name_policy.dart | 4 +- .../shared/identity_names/identity_names.dart | 24 +++++---- mobile/lib/shared/utils/string_utils.dart | 23 +++++++-- .../identity_names/identity_names_test.dart | 51 +++++++++++++++++++ .../test/shared/utils/string_utils_test.dart | 37 ++++++++++++++ 5 files changed, 123 insertions(+), 16 deletions(-) diff --git a/mobile/lib/shared/identity_names/identity_name_policy.dart b/mobile/lib/shared/identity_names/identity_name_policy.dart index 62d09438b1..d04c0fb0e6 100644 --- a/mobile/lib/shared/identity_names/identity_name_policy.dart +++ b/mobile/lib/shared/identity_names/identity_name_policy.dart @@ -80,10 +80,8 @@ String trimIdentityName(String value) { return value.substring(start, end); } -final RegExp _hexKey = RegExp(r'^[0-9a-fA-F]{64}$'); - void _requireKey(String key, String name) { - if (!_hexKey.hasMatch(key)) { + if (!isHexPubkey(key)) { throw ArgumentError.value(key, name, 'not a 64-character hex public key'); } } diff --git a/mobile/lib/shared/identity_names/identity_names.dart b/mobile/lib/shared/identity_names/identity_names.dart index 3b4a687bda..57d4af0a6e 100644 --- a/mobile/lib/shared/identity_names/identity_names.dart +++ b/mobile/lib/shared/identity_names/identity_names.dart @@ -4,8 +4,6 @@ import '../profile/user_profile.dart'; import '../utils/string_utils.dart'; import 'identity_name_policy.dart'; -final RegExp _hexPubkey = RegExp(r'^[0-9a-f]{64}$'); - /// Immutable naming facts known to this client, independent of any view. /// /// Mobile's owner hint is the verified NIP-OA owner from the profile `auth` @@ -44,7 +42,7 @@ class IdentityNameSources { bool isAgent = false, }) { final key = pubkey.toLowerCase(); - if (!_hexPubkey.hasMatch(key)) return null; + if (!isHexPubkey(key)) return null; final profile = profiles[key]; final owner = (profile?.ownerPubkey ?? agentOwners[key] ?? fallbackOwner) ?.toLowerCase(); @@ -57,7 +55,7 @@ class IdentityNameSources { _nonBlank(fallbackName) ?? shortPubkey(key), isAgent: isAgent || owner != null || agentPubkeys.contains(key), - ownerPubkey: owner != null && _hexPubkey.hasMatch(owner) ? owner : null, + ownerPubkey: owner != null && isHexPubkey(owner) ? owner : null, ); } @@ -85,7 +83,7 @@ class IdentityNameSources { this, { for (final key in candidates) - if (_hexPubkey.hasMatch(key.toLowerCase())) key.toLowerCase(), + if (isHexPubkey(key.toLowerCase())) key.toLowerCase(), }, {for (final key in agentPubkeys) key.toLowerCase()}, { @@ -112,6 +110,7 @@ class IdentityNames { final Map _fallbackNames; final Map _ownerPubkeys; final Map _outside = {}; + final Map _facts = {}; late final Map _members = _resolve(_candidates); IdentityNames._( @@ -151,11 +150,16 @@ class IdentityNames { .where((owner) => !_sources.profiles.containsKey(owner)) .toSet(); - NamingIdentity? _factFor(String key) => _sources.factFor( + /// Facts are fixed for this scope's sources, so each key is derived once + /// even though owner loading and resolution both read the whole context. + NamingIdentity? _factFor(String key) => _facts.putIfAbsent( key, - fallbackName: _fallbackNames[key], - fallbackOwner: _ownerPubkeys[key], - isAgent: _agentPubkeys.contains(key), + () => _sources.factFor( + key, + fallbackName: _fallbackNames[key], + fallbackOwner: _ownerPubkeys[key], + isAgent: _agentPubkeys.contains(key), + ), ); /// The resolved label and qualifier for [pubkey], or null for an invalid key. @@ -163,7 +167,7 @@ class IdentityNames { final key = pubkey.toLowerCase(); if (_candidates.contains(key)) return _members[key]; return _outside.putIfAbsent(key, () { - if (!_hexPubkey.hasMatch(key)) return null; + if (!isHexPubkey(key)) return null; return _resolve({..._candidates, key})[key]; }); } diff --git a/mobile/lib/shared/utils/string_utils.dart b/mobile/lib/shared/utils/string_utils.dart index b9fb720b51..df9fa2c238 100644 --- a/mobile/lib/shared/utils/string_utils.dart +++ b/mobile/lib/shared/utils/string_utils.dart @@ -4,7 +4,24 @@ import 'package:nostr/nostr.dart' as nostr; /// public key — malformed identities never leak truncated raw hex into the UI. const String unknownIdentityLabel = 'Unknown identity'; -final RegExp _hexPubkeyPattern = RegExp(r'^[0-9a-fA-F]{64}$'); +/// Whether [value] is a 64-character hex public key (any case). +/// +/// A character loop rather than a regular expression: AOT builds on iOS run +/// regular expressions in an interpreter, and identity views check every key +/// of a large roster on each rebuild. +bool isHexPubkey(String value) { + if (value.length != 64) return false; + for (var i = 0; i < 64; i++) { + final c = value.codeUnitAt(i); + if (!((c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x61 && c <= 0x66) || // a-f + (c >= 0x41 && c <= 0x46))) { + // A-F + return false; + } + } + return true; +} /// The canonical full NIP-19 npub for [identity], or null when [identity] is /// not a valid public key. @@ -18,12 +35,12 @@ final RegExp _hexPubkeyPattern = RegExp(r'^[0-9a-fA-F]{64}$'); String? fullNpub(String identity) { final trimmed = identity.trim(); try { - if (_hexPubkeyPattern.hasMatch(trimmed)) { + if (isHexPubkey(trimmed)) { return _encodeNpub(trimmed.toLowerCase()); } final decoded = nostr.Nip19.decode(payload: trimmed); if (decoded.prefix != nostr.Nip19Prefix.npub || - !_hexPubkeyPattern.hasMatch(decoded.data)) { + !isHexPubkey(decoded.data)) { return null; } return _encodeNpub(decoded.data.toLowerCase()); diff --git a/mobile/test/shared/identity_names/identity_names_test.dart b/mobile/test/shared/identity_names/identity_names_test.dart index 8d12c39dbf..c2bcb30729 100644 --- a/mobile/test/shared/identity_names/identity_names_test.dart +++ b/mobile/test/shared/identity_names/identity_names_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; @@ -223,4 +225,53 @@ void main() { // The owner is the viewer, whose profile is cached: nothing to load. expect(names.missingOwnerProfiles(), isEmpty); }); + + test('a scope derives the naming facts of each key once', () { + final keys = [for (final d in '1234'.split('')) _key(d)]; + final profiles = _CountingProfiles({ + for (final key in keys) key: UserProfile(pubkey: key, displayName: key), + }); + final names = IdentityNameSources(profiles: profiles).scope(keys); + + // Owner loading, every label, and an outside key all read the whole + // context; each member's profile is still read once. + names.missingOwnerProfiles(); + for (final key in keys) { + names.labelFor(key); + } + names.labelFor(_key('9')); + + for (final key in keys) { + expect(profiles.reads[key], 1, reason: key); + } + }); +} + +/// A profile map that counts lookups per key. +class _CountingProfiles extends MapBase { + _CountingProfiles(this._inner); + + final Map _inner; + final Map reads = {}; + + @override + UserProfile? operator [](Object? key) { + if (key is String) reads[key] = (reads[key] ?? 0) + 1; + return _inner[key]; + } + + @override + void operator []=(String key, UserProfile value) => _inner[key] = value; + + @override + void clear() => _inner.clear(); + + @override + Iterable get keys => _inner.keys; + + @override + UserProfile? remove(Object? key) => _inner.remove(key); + + @override + bool containsKey(Object? key) => _inner.containsKey(key); } diff --git a/mobile/test/shared/utils/string_utils_test.dart b/mobile/test/shared/utils/string_utils_test.dart index f5188c2a68..d11754d52d 100644 --- a/mobile/test/shared/utils/string_utils_test.dart +++ b/mobile/test/shared/utils/string_utils_test.dart @@ -77,6 +77,43 @@ void main() { }); }); + group('isHexPubkey', () { + test('accepts exactly 64 hex characters in either case', () { + expect(isHexPubkey(canonicalHex), isTrue); + expect(isHexPubkey(canonicalHex.toUpperCase()), isTrue); + expect(isHexPubkey('0' * 64), isTrue); + expect(isHexPubkey('F' * 64), isTrue); + }); + + test('rejects other lengths and characters', () { + expect(isHexPubkey(''), isFalse); + expect(isHexPubkey(canonicalHex.substring(1)), isFalse); + expect(isHexPubkey('${canonicalHex}0'), isFalse); + expect(isHexPubkey(' ${canonicalHex.substring(1)}'), isFalse); + // Characters just outside each accepted range. + for (final c in ['/', ':', '@', 'G', '`', 'g', 'n']) { + expect(isHexPubkey('$c${'0' * 63}'), isFalse, reason: c); + } + expect(isHexPubkey(canonicalNpub), isFalse); + }); + + test('rejects an invalid character at any position', () { + // Every index, so a check that only inspects some positions fails. + for (final c in ['g', 'G', ':', '/', ' ']) { + for (var i = 0; i < 64; i++) { + final value = '${'a' * i}$c${'b' * (63 - i)}'; + expect(isHexPubkey(value), isFalse, reason: '"$c" at index $i'); + } + } + // Middle and final positions of an otherwise valid key. + final middle = canonicalHex.replaceRange(32, 33, 'z'); + final last = canonicalHex.replaceRange(63, 64, 'z'); + expect(isHexPubkey(middle), isFalse); + expect(isHexPubkey(last), isFalse); + expect(isHexPubkey(canonicalHex), isTrue); + }); + }); + group('copy to paste roundtrip', () { test('a copied npub parses back to the original hex key', () { final copied = fullNpub(canonicalHex);