From 1ce831645c5f210f8fc7883bb9fc63dae6e52a12 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 13:48:23 -0800 Subject: [PATCH] mobile: stop re-parsing messages on every resume (#9140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming the app with a busy channel open stalled for several frames (149, 136, 109 and 102 ms on an iPhone 12 mini), because every visible message re-parsed its markdown, repeatedly. The app widget rebuilds on every lifecycle change, and each rebuild created new light and dark `ThemeData`. A new `ThemeData` never equals the previous one, so `MaterialApp` animated between two identical themes for its full theme transition. Each frame of that animation produced a new markdown theme, and every mounted message re-parsed its markdown in response (`_MdWidgetState.didChangeDependencies` and `didUpdateWidget`). In a 15 s resume on a large channel that re-parse took 373 ms of main-isolate time. The themes are now built only when the community accent changes, so a lifecycle rebuild keeps the same theme objects and starts no transition. ### Validation - Added test: `an app rebuild keeps its themes and markdown theme` rebuilds the app and checks that the theme and the markdown theme stay identical. It fails without the change. - Full mobile suite: only the known failures that also fail on main (notification toggle, push bootstrap, push bridge, push lease revocation outbox). - Automated simulator run (iPhone 17 Pro, debug build): opened a channel with markdown messages, backgrounded and resumed the app several times, and switched between light and dark appearance while the app was open. Messages render, resume works, and the app follows appearance changes live. A channel's long-press action sheet keeps its light background after a switch to dark mode on both this build and main, so that is unrelated to this change. - Profile evidence (iPhone 12 mini, Profile build, before the change): the stalls and re-parse samples above came from a resume with a large channel open. 🤖 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/app.dart | 17 ++++- .../shared/theme/app_theme_rebuild_test.dart | 72 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 mobile/test/shared/theme/app_theme_rebuild_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 7a2e62ce9f..4a3cd4320e 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -354,6 +354,19 @@ class App extends HookConsumerWidget { ? ref.watch(communityThemeProvider) : defaultCommunityTheme; final themeMode = communityTheme.mode; + // The app rebuilds on every lifecycle change. A new ThemeData never + // equals the last one, so MaterialApp would animate between identical + // themes, and each frame of that makes every message re-parse its + // markdown. Build the themes only when the accent changes. + final accent = communityTheme.accent; + final lightTheme = useMemoized( + () => mobileAppearanceTheme(Brightness.light, accent), + [accent], + ); + final darkTheme = useMemoized( + () => mobileAppearanceTheme(Brightness.dark, accent), + [accent], + ); final authState = ref.watch(authProvider); final pairedCommunity = ref.watch(pairedCommunityLandingProvider); @@ -413,8 +426,8 @@ class App extends HookConsumerWidget { navigatorObservers: [appRouteObserver], title: 'Buzz', debugShowCheckedModeBanner: false, - theme: mobileAppearanceTheme(Brightness.light, communityTheme.accent), - darkTheme: mobileAppearanceTheme(Brightness.dark, communityTheme.accent), + theme: lightTheme, + darkTheme: darkTheme, themeMode: themeMode, // Above the navigator, so an age restriction cannot be bypassed by a // route that was pushed while the store signal request was in flight. diff --git a/mobile/test/shared/theme/app_theme_rebuild_test.dart b/mobile/test/shared/theme/app_theme_rebuild_test.dart new file mode 100644 index 0000000000..f8e5ffa9ea --- /dev/null +++ b/mobile/test/shared/theme/app_theme_rebuild_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/age_gate/age_signal_provider.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// The app rebuilds on every lifecycle change, so a resume rebuilds it several +// times. ThemeData has no value equality the app's themes satisfy, so new +// themes made MaterialApp animate between identical ones, and every frame of +// that animation made each mounted message re-parse its markdown. +void main() { + testWidgets('an app rebuild keeps its themes and markdown theme', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final auth = _SignedOutAuth(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(() => auth), + ageSignalProvider.overrideWith(_AllowedAgeSignal.new), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const App()), + ); + await tester.pump(const Duration(seconds: 1)); + + MaterialApp app() => tester.widget(find.byType(MaterialApp)); + GptMarkdownThemeData markdown() => tester + .widget(find.byType(GptMarkdownTheme).first) + .gptThemeData; + final theme = app().theme; + final darkTheme = app().darkTheme; + final markdownTheme = markdown(); + + // A new auth state rebuilds the app, as a lifecycle change does. + auth.republish(); + await tester.pump(); + // Midway through what would be a theme animation. + await tester.pump(const Duration(milliseconds: 100)); + + expect(identical(app().theme, theme), isTrue); + expect(identical(app().darkTheme, darkTheme), isTrue); + expect(identical(markdown(), markdownTheme), isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} + +class _SignedOutAuth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); + + void republish() => + state = AsyncData(AuthState(status: state.requireValue.status)); +} + +class _AllowedAgeSignal extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.allowed; + + @override + Future request() async {} +}