Files
buzz/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart
Wes b593c7d7fe perf(mobile): reduce cold startup and channel rendering delays (#6996)
Pinky is opening this PR on Wes’s behalf.

## Summary

Reduce two separately measured mobile delays without changing the relay
API or removing rich message rendering:

- Publish the finite channel-list snapshot without waiting for live
subscription setup.
- Batch active channel-list subscriptions into sorted, deterministic
chunks of at most 128 explicit channel IDs, retaining unchanged chunks.
- Install replacement chunks before retiring old coverage. Retain old
chunks across thrown replacement failures; filter callbacks to the
current relay/identity and still-desired channels; clean up
retired/in-flight work across disconnect and disposal.
- Scope the custom-emoji Markdown matcher to known shortcodes actually
referenced in the rendered content, rather than embedding the whole
community palette in every message’s regex. Preserve unknown literals,
shared-colon token boundaries, event-tag URL priority, content edits,
and code literals.
- Honor explicit zero retry hints without inventing a ten-second
session-wide gate, while preserving the ordinary live-subscription retry
backoff and any already-active gate.

## Matched performance results

Medians of three before and three after process-cold launches,
alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator.
Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`;
after is this production patch before two documentation-only comment
fixes.

First channel-list frame: 11.617s → 3.179s · 73% lower latency

Live setup duration: 8.475s → 0.185s · 98% lower latency

Channel-open first message-list frame: 2.754s → 1.230s · 55% lower
latency

Message data ready → first frame: 1.977s → 0.286s · 86% lower latency

Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency

Channel-open data readiness: 0.770s → 0.944s · 23% higher latency

The gain is client-side orchestration/rendering, not a claim that the
relay became faster. First channel-list frame ranges were 10.835–11.788s
before and 2.872–3.395s after; channel-open first-frame ranges were
1.560–2.906s before and 1.149–1.317s after.

### Measurement boundaries

- Debug simulator builds, CPU sampling disabled, bounded timestamp
probes enabled identically. These are not release/physical-device
measurements.
- Startup clock starts at Dart `main`; build/install/native pre-main
time is excluded. Auth/preferences and OS/disk caches are retained
between new processes.
- Same account scale: 113 active channels. Latest-message events varied
slightly with live activity (1543–1546).
- Channel-open uses the same initial 50-row history window, 97 query
events, and 67 provider events. The 2306-entry emoji palette is
explicitly loaded before navigation on both sides; palette preparation
is excluded from the channel-open clock and happens after the startup
frame measurement.
- Both diagnostic builds temporarily disabled unused avatar segmentation
to work around the existing Google ML Kit arm64-simulator slice
limitation. The workaround, dependency/native changes, auto-navigation,
and all probes are excluded from this PR.

## Validation

- Full mobile package suite: `flutter test` — 1890 passed.
- `just mobile-check` — 506 files unchanged; analyzer clean.
- `just file-size-check` — policy tests and all client ratchets passed.
- `git diff --check` — passed.
- New lifecycle regressions cover front-sorting insertion across a chunk
boundary while replacement readiness is paused, failure
retention/departed-channel filtering, retired generation + disconnect
cleanup, disposal, chunk limits, unchanged-set reuse, and scope
switches.
- Emoji unit/widget coverage includes a 2500-unused-emoji palette,
unknown tokens, case matching at the component level, shared-colon
boundaries, rich text, event URL priority, and content edits.
- Fresh-frame source review traced the subscription queue/fences,
callback scopes, duplicate-event paths, matcher/wiring, and retry
scheduling.
- At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`,
all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer,
and the full 1890-test mobile suite), `file-size-check`, `branch-skew`,
and `push-head-scope`. The commit hook formatted 506 files with no
changes. Runtime measurements preceded only the two
documentation-comment fixes; no runtime source changed afterward.

## Limits / follow-ups

- `RelaySession.subscribe` still settles under its existing
EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an
unconditional EOSE or live-delivery guarantee. This PR does not add
status-aware replacement ownership.
- The channel-message provider still awaits subscribe before fetching
history; that separate serialization is not removed here.
- Oversized Huddle queries and the separate history batching path above
128 active channels remain follow-ups, as do pre-existing read-state
initialization/size warnings.
- Palette-only widget refresh and upstream Markdown uppercase-dispatch
behavior are not changed.
- A clean source build still has the existing Google ML Kit
arm64-simulator issue; the profiling workaround is not a proposed
product fix.

Originating Buzz conversation:
buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-28 15:38:03 -06:00

110 lines
4.0 KiB
Dart

import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
const _palette = [
CustomEmoji(shortcode: 'wave', url: 'https://example.com/wave.png'),
CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/long.png'),
CustomEmoji(shortcode: 'party-parrot', url: 'https://example.com/parrot.png'),
];
void main() {
test('pattern is bounded by referenced emoji, not the community palette', () {
final largePalette = [
..._palette,
for (var i = 0; i < 2500; i++)
CustomEmoji(shortcode: 'unused_$i', url: 'https://example.com/$i.png'),
];
final small = CustomEmojiMd(_palette, content: 'hello :wave:');
final large = CustomEmojiMd(largePalette, content: 'hello :wave:');
expect(large.exp.pattern, small.exp.pattern);
expect(large.exp.hasMatch(':wave:'), isTrue);
expect(large.exp.hasMatch(':wave_long:'), isFalse);
expect(large.exp.hasMatch(':unused_2499:'), isFalse);
});
test(
'no references and unknown references produce a nonmatching pattern',
() {
for (final content in [
'hello world',
':unknown:',
'https://example.com',
]) {
final matcher = CustomEmojiMd(_palette, content: content);
expect(matcher.exp.allMatches(content), isEmpty);
expect(matcher.exp.hasMatch(':wave:'), isFalse);
}
expect(
CustomEmojiMd(const [], content: ':wave:').exp.hasMatch(':wave:'),
isFalse,
);
},
);
test('selection preserves the original matcher across token boundaries', () {
final original = RegExp(
':(?:${_palette.map((e) => RegExp.escape(e.shortcode)).join('|')}):',
caseSensitive: false,
);
for (final content in [
':wave:',
':WAVE: :Wave_Long: :PARTY-PARROT:',
':unknown:wave:',
':wave:unknown:wave_long:',
':wave::wave_long:',
':::wave::: :wave_long:! (:party-parrot:)',
':wave_longer: :unknown: no match',
'`code :wave:` **bold :wave_long:**',
]) {
final selected = CustomEmojiMd(_palette, content: content);
expect(
selected.exp.allMatches(content).map((m) => m.group(0)).toList(),
original.allMatches(content).map((m) => m.group(0)).toList(),
reason: content,
);
}
});
testWidgets('known tokens keep their URL and size; unknowns remain text', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
final context = tester.element(find.byType(SizedBox));
final matcher = CustomEmojiMd(
_palette,
content: ':WAVE: :unknown:',
size: 32,
);
final known = matcher.span(context, ':WAVE:', GptMarkdownConfig());
expect(known, isA<WidgetSpan>());
final image = (known as WidgetSpan).child as CustomEmojiImage;
expect(image.shortcode, 'wave');
expect(image.url, 'https://example.com/wave.png');
expect(image.size, 32);
final unknown = matcher.span(context, ':unknown:', GptMarkdownConfig());
expect(unknown, isA<TextSpan>());
expect((unknown as TextSpan).text, ':unknown:');
});
testWidgets('selection uses the current content and current palette URL', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
final context = tester.element(find.byType(SizedBox));
final initial = CustomEmojiMd(_palette, content: ':wave:');
final updated = CustomEmojiMd(const [
CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/new.png'),
], content: ':wave_long:');
expect(initial.exp.hasMatch(':wave_long:'), isFalse);
expect(updated.exp.hasMatch(':wave:'), isFalse);
final span = updated.span(context, ':wave_long:', GptMarkdownConfig());
expect(
((span as WidgetSpan).child as CustomEmojiImage).url,
'https://example.com/new.png',
);
});
}