From 7ac16b2917bfb64b8142f06036274e83f3aa5d9f Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 13:49:09 -0800 Subject: [PATCH] perf(mobile): memoize npub labels (#9137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a big channel on mobile stalls while member labels resolve. On an iPhone 12 mini (wired Profile build of main 120d75d976), opening a 5,892-member channel had frames of 217, 113 and 101 ms. The channel identity scope recomputed 13 times during the open as profiles arrived, 25 to 35 ms each: 235 ms of 1,755 ms UI CPU, 190 ms of it bech32 encoding. Every recompute labels each member without a profile name by npub (`missingOwnerProfiles` → `IdentityNameSources.factFor` → `shortPubkey` → `fullNpub`), and encoded the same keys again. The same chain cost 152 ms in a 15 s resume with the channel open. `fullNpub` now memoizes encodings by hex key, bounded at 8,192 entries. Once full it keeps what it has, so a roster larger than the limit still reuses most of its encodings. Labels are unchanged. ### Validation - Added tests: `fullNpub reuses the encoding of a key it has already encoded` fails without the memo; `a roster larger than the cache still reuses its encodings` fails if the cache clears when full. - Host benchmark, `shortPubkey` over 5,900 random keys: 53.5 ms on the first pass, about 3.1 ms on later passes. - Full mobile suite passes except the 10 known notification/push failures and `push_lease_revocation_outbox_test`, which also fail on main. Analyze clean. - Automated simulator run of this branch (debug build, Builderlab community): searched "studio", opened a channel result and came back; people results show the same short npub labels as main (for example `npub194z...aq3k`). - No post-change device capture. The scope still recomputes as profiles arrive; this removes the encoding cost from each recompute. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- mobile/lib/shared/utils/string_utils.dart | 21 +++++++++++++++++-- .../test/shared/utils/string_utils_test.dart | 17 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/mobile/lib/shared/utils/string_utils.dart b/mobile/lib/shared/utils/string_utils.dart index df9fa2c238..177a4ec6aa 100644 --- a/mobile/lib/shared/utils/string_utils.dart +++ b/mobile/lib/shared/utils/string_utils.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:nostr/nostr.dart' as nostr; /// Neutral label rendered when an identity string cannot be read as a Nostr @@ -49,8 +50,24 @@ String? fullNpub(String identity) { } } -String _encodeNpub(String hexPubkey) => - nostr.Nip19.encode(prefix: nostr.Nip19Prefix.npub, data: hexPubkey); +/// Encoded npubs by lowercase hex key. Identity views label every roster +/// member without a profile name by npub on each rebuild, and bech32 encoding +/// dominated those rebuilds on large channels. Bounded: once full it keeps +/// what it has, so a roster larger than the limit still reuses most keys. +final _npubs = {}; +@visibleForTesting +const npubCacheLimit = 8192; + +String _encodeNpub(String hexPubkey) { + final cached = _npubs[hexPubkey]; + if (cached != null) return cached; + final npub = nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.npub, + data: hexPubkey, + ); + if (_npubs.length < npubCacheLimit) _npubs[hexPubkey] = npub; + return npub; +} /// The canonical compact public-key label: the first 8 and last 4 characters /// of the full npub joined by an ellipsis — the same truncation desktop uses diff --git a/mobile/test/shared/utils/string_utils_test.dart b/mobile/test/shared/utils/string_utils_test.dart index d11754d52d..fc5e7f4430 100644 --- a/mobile/test/shared/utils/string_utils_test.dart +++ b/mobile/test/shared/utils/string_utils_test.dart @@ -16,6 +16,23 @@ void main() { const canonicalCompact = 'npub180c\u2026h6w6'; group('fullNpub', () { + test('reuses the encoding of a key it has already encoded', () { + // Identity views relabel a large roster on every rebuild; each key's + // bech32 encoding must happen once, not once per rebuild. + final first = fullNpub(canonicalHex); + expect(first, canonicalNpub); + expect(identical(fullNpub(canonicalHex.toUpperCase()), first), isTrue); + }); + + test('a roster larger than the cache still reuses its encodings', () { + String key(int i) => i.toRadixString(16).padLeft(64, '0'); + final firstPass = [ + for (var i = 0; i <= npubCacheLimit; i++) fullNpub(key(i)), + ]; + // A second scan in the same order finds the early keys still cached. + expect(identical(fullNpub(key(1)), firstPass[1]), isTrue); + }); + test('canonicalizes every accepted input wrapper', () { // Every accepted wrapper — lowercase hex, uppercase hex, whitespace- // padded, and an already-npub key — converges on the canonical npub.