From a96ace4e55baa9c943cfbbfc323502ff2eff05f3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 17 Oct 2026 16:53:48 -0700 Subject: [PATCH] fix(mobile perf): avoid eager emoji palette scans for prose (#8416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinary mobile messages eagerly built a set of every community emoji shortcode and segmented their entire body just to decide whether to use enlarged emoji rendering. Stop at the first non-emoji grapheme and build the shortcode set only when a complete shortcode is encountered. Emoji-only sizing and shortcode matching stay unchanged. ### Validation - Full `just ci` passed. - 125 focused detector and message-rendering tests pass; targeted analysis is clean. The palette-access regression fails on the original implementation (10,320 unnecessary reads) and passes with the fix. It also checks that consecutive shortcodes share one palette scan. - **Full Buzz app, real community data, debug/JIT iOS simulator, not a rendering fixture:** two paired actual-input captures measured 125–127 µs per original detector call versus under 1 µs with the fix. All 5,200 executions per version agreed across 260 observed prose calls and a 2,580-entry palette. Instrumentation repeated each detector call, so these captures are excluded from frame comparisons. - Separate clean-code scrolling runs do **not** establish a reliable smoothness improvement: quiet original UI-callback p95 was 10.1–12.6 ms versus 10.1–12.9 ms fixed. A busy fixed run still reached 90.8 ms. Normal app services stayed enabled; only scrolling was exercised. Live background refresh, debug mode, host scheduling, and profiler overhead limit interpretation. No physical-device/release-AOT or end-to-end interaction-latency claim. Signed-off-by: Tom Brow --- mobile/lib/shared/emoji/emoji_only.dart | 39 +++++++------- mobile/test/shared/emoji/emoji_only_test.dart | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/mobile/lib/shared/emoji/emoji_only.dart b/mobile/lib/shared/emoji/emoji_only.dart index e6994f0b9b..97a945b78f 100644 --- a/mobile/lib/shared/emoji/emoji_only.dart +++ b/mobile/lib/shared/emoji/emoji_only.dart @@ -44,35 +44,35 @@ bool isEmojiOnlyMessage( final trimmed = content.trim(); if (trimmed.isEmpty) return false; - final shortcodes = { - for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), - }; + Set? shortcodes; var sawEmoji = false; // Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop // hand-rolls this scan; Dart's `characters` already segments correctly. final iterator = trimmed.characters.iterator; - final clusters = []; while (iterator.moveNext()) { - clusters.add(iterator.current); - } + final cluster = iterator.current; - var index = 0; - while (index < clusters.length) { - final cluster = clusters[index]; - - if (cluster.trim().isEmpty) { - index += 1; - continue; - } + if (cluster.trim().isEmpty) continue; if (cluster == ':') { - final end = clusters.indexOf(':', index + 1); - if (end <= index + 1) return false; - final shortcode = clusters.sublist(index + 1, end).join().toLowerCase(); - if (!shortcodes.contains(shortcode)) return false; + final shortcode = StringBuffer(); + var closed = false; + while (iterator.moveNext()) { + if (iterator.current == ':') { + closed = true; + break; + } + shortcode.write(iterator.current); + } + if (!closed || shortcode.isEmpty) return false; + shortcodes ??= { + for (final emoji in customEmoji) emoji.shortcode.toLowerCase(), + }; + if (!shortcodes.contains(shortcode.toString().toLowerCase())) { + return false; + } sawEmoji = true; - index = end + 1; continue; } @@ -80,7 +80,6 @@ bool isEmojiOnlyMessage( return false; } sawEmoji = true; - index += 1; } return sawEmoji; diff --git a/mobile/test/shared/emoji/emoji_only_test.dart b/mobile/test/shared/emoji/emoji_only_test.dart index 261b7d7cf1..10b7703db1 100644 --- a/mobile/test/shared/emoji/emoji_only_test.dart +++ b/mobile/test/shared/emoji/emoji_only_test.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -55,6 +57,34 @@ void main() { expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse); }); + test('scans consecutive shortcodes without skipping following text', () { + expect(emojiOnly(':BUZZ::buzz:', customEmoji: _customEmoji), isTrue); + expect(emojiOnly('😀:buzz:😀', customEmoji: _customEmoji), isTrue); + expect( + emojiOnly(':buzz:ordinary text', customEmoji: _customEmoji), + isFalse, + ); + expect( + emojiOnly(':buzz::unfinished', customEmoji: _customEmoji), + isFalse, + ); + expect(emojiOnly(':buzz:::', customEmoji: _customEmoji), isFalse); + }); + + test('does not inspect the community palette without a shortcode', () { + final palette = _CountingEmojiList(); + expect( + emojiOnly('Ordinary message text. ' * 500, customEmoji: palette), + isFalse, + ); + expect(emojiOnly('😀 👋', customEmoji: palette), isTrue); + expect(emojiOnly('text :buzz:', customEmoji: palette), isFalse); + expect(emojiOnly(':unfinished', customEmoji: palette), isFalse); + expect(palette.reads, 0); + expect(emojiOnly(':buzz::BUZZ:', customEmoji: palette), isTrue); + expect(palette.reads, palette.length); + }); + test('an unknown shortcode is just text', () { // Desktop behaves the same: without the palette it renders literally, so // it must not be scaled up as if it were a glyph. @@ -79,3 +109,26 @@ void main() { }); }); } + +class _CountingEmojiList extends ListBase { + int reads = 0; + + @override + int get length => 2580; + + @override + set length(int value) => throw UnsupportedError('read-only fixture'); + + @override + CustomEmoji operator [](int index) { + reads += 1; + return CustomEmoji( + shortcode: index == 0 ? 'buzz' : 'unused_$index', + url: 'https://example.test/emoji.png', + ); + } + + @override + void operator []=(int index, CustomEmoji value) => + throw UnsupportedError('read-only fixture'); +}