From 766c219f5fc505f5ef7ef14cafcff8acf55a71af Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 26 Oct 2026 18:48:40 -0300 Subject: [PATCH 1/5] feat(perf): record worksheet render cost and structure --- lib/features/perf/perf_logger.dart | 22 +++++++ lib/widgets/html_view.dart | 19 ++++++ test/html_view_perf_test.dart | 93 ++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 test/html_view_perf_test.dart diff --git a/lib/features/perf/perf_logger.dart b/lib/features/perf/perf_logger.dart index 67a7cc9..39ca111 100644 --- a/lib/features/perf/perf_logger.dart +++ b/lib/features/perf/perf_logger.dart @@ -254,6 +254,28 @@ class PerfLogger { } } + /// Measure from now until the end of the current frame, i.e. including + /// the build, layout and paint work that whatever triggered it causes. + /// + /// Wrapping a widget's construction in [spanSync] measures next to nothing: + /// the expensive part happens later in the frame pipeline. Call this from + /// build() instead to attribute that cost. + static void spanToEndOfFrame( + String name, { + Map Function()? data, + }) { + if (!enabled) return; + try { + if (!_clock.isRunning) _clock.start(); + final int startUs = _clock.elapsedMicroseconds; + SchedulerBinding.instance.addPostFrameCallback( + (_) => _endSpan(name, startUs, true, data), + ); + } catch (e) { + debugPrint('PerfLogger: starting span $name failed: $e'); + } + } + static void _endSpan( String name, int startUs, diff --git a/lib/widgets/html_view.dart b/lib/widgets/html_view.dart index d97eb65..994b13b 100644 --- a/lib/widgets/html_view.dart +++ b/lib/widgets/html_view.dart @@ -50,6 +50,25 @@ class _HtmlViewState extends State { data: () => {'htmlBytes': widget.content.length}, ); _documentIsDarkMode = isDarkMode; + // Building and laying out the flutter_html tree for a new document is + // the expensive part (hundreds of ms on slow devices). Record the page + // structure (no identity) to tell which kind of content is to blame. + final htmldom.Document document = _document!; + // Routes covered by another screen get their tickers disabled by the + // Overlay. Reading the notifier (unlike TickerMode.valuesOf) adds no + // dependency, so this doesn't cause rebuilds of its own. + final bool hidden = !TickerMode.getValuesNotifier(context).value.enabled; + PerfLogger.spanToEndOfFrame( + 'page.render', + data: + () => { + 'hidden': hidden, + 'tables': document.querySelectorAll('table').length, + 'rows': document.querySelectorAll('tr').length, + 'cells': document.querySelectorAll('td, th').length, + 'images': document.querySelectorAll('img').length, + }, + ); } return _document!; } diff --git a/test/html_view_perf_test.dart b/test/html_view_perf_test.dart new file mode 100644 index 0000000..dcb7636 --- /dev/null +++ b/test/html_view_perf_test.dart @@ -0,0 +1,93 @@ +import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:app4training/widgets/html_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'perf_logger_test.dart'; + +void main() { + tearDown(PerfLogger.reset); + + Widget htmlView(String body) => MaterialApp( + home: Scaffold( + body: HtmlView('$body', TextDirection.ltr), + ), + ); + + testWidgets('rendering a page records its structure in page.render', ( + tester, + ) async { + final fileSystem = startLogger(); + + await tester.pumpWidget( + htmlView(''' +

Intro

+ + + + +
AB
12
3
+'''), + ); + await tester.pumpAndSettle(); + await PerfLogger.flush(); + + final renders = + (await readRecords( + fileSystem, + )).where((r) => r['name'] == 'page.render').toList(); + expect(renders, hasLength(1)); + expect(renders.single['t'], 'span'); + expect(renders.single['durUs'], isA()); + expect(renders.single['tables'], 1); + expect(renders.single['rows'], 3); + expect(renders.single['cells'], 6); + expect(renders.single['images'], 1); + }); + + testWidgets('rebuilds of the same page record no further page.render', ( + tester, + ) async { + final fileSystem = startLogger(); + + await tester.pumpWidget(htmlView('

Same

')); + await tester.pumpAndSettle(); + // An unrelated rebuild above HtmlView (e.g. a snackbar or the drawer) + await tester.pumpWidget(htmlView('

Same

')); + await tester.pumpAndSettle(); + await PerfLogger.flush(); + + final records = await readRecords(fileSystem); + expect(records.where((r) => r['name'] == 'page.render'), hasLength(1)); + }); + + testWidgets('re-renders of a page hidden under another screen are flagged', ( + tester, + ) async { + final fileSystem = startLogger(); + final navigatorKey = GlobalKey(); + Widget app(Brightness brightness) => MaterialApp( + navigatorKey: navigatorKey, + theme: ThemeData(brightness: brightness), + home: const Scaffold( + body: HtmlView('

Worksheet

', TextDirection.ltr), + ), + ); + + await tester.pumpWidget(app(Brightness.light)); + await tester.pumpAndSettle(); + navigatorKey.currentState!.push( + MaterialPageRoute(builder: (_) => const Text('Settings')), + ); + await tester.pumpAndSettle(); + // The system switches to dark mode while the worksheet is covered + await tester.pumpWidget(app(Brightness.dark)); + await tester.pumpAndSettle(); + await PerfLogger.flush(); + + final renders = (await readRecords( + fileSystem, + )).where((r) => r['name'] == 'page.render'); + expect(renders.map((r) => r['hidden']), [false, true]); + }); +} From 6110452200099a60ebbc8df596fb0281e19c03c5 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 26 Oct 2026 18:49:46 -0300 Subject: [PATCH 2/5] feat(perf): record which screen is shown and the stack depth --- .../perf/perf_navigator_observer.dart | 45 ++++++++ test/perf_navigator_observer_test.dart | 103 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 lib/features/perf/perf_navigator_observer.dart create mode 100644 test/perf_navigator_observer_test.dart diff --git a/lib/features/perf/perf_navigator_observer.dart b/lib/features/perf/perf_navigator_observer.dart new file mode 100644 index 0000000..3b09263 --- /dev/null +++ b/lib/features/perf/perf_navigator_observer.dart @@ -0,0 +1,45 @@ +import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:flutter/widgets.dart'; + +/// Records which kind of screen is shown, so jank frames in the perf logs +/// can be attributed to a screen (e.g. a worksheet vs. the settings). +/// +/// Only the first route segment is recorded: `/view/Healing/de` becomes +/// `view` - page names and language codes stay out of the logs. +/// `depth` is the number of screens on the stack (hidden ones included). +class PerfNavigatorObserver extends NavigatorObserver { + int _depth = 0; + + @override + void didPush(Route route, Route? previousRoute) { + if (route is PageRoute) _depth++; + _recordScreen(route); + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) _depth--; + _recordScreen(previousRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PageRoute) _depth--; + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _recordScreen(newRoute); + } + + void _recordScreen(Route? route) { + final String? name = route?.settings.name; + if (name == null) return; + final List segments = name.split('/'); + final String screen = segments.length > 1 ? segments[1] : name; + PerfLogger.event( + 'screen', + data: {'screen': screen.isEmpty ? '/' : screen, 'depth': _depth}, + ); + } +} diff --git a/test/perf_navigator_observer_test.dart b/test/perf_navigator_observer_test.dart new file mode 100644 index 0000000..8ab3c70 --- /dev/null +++ b/test/perf_navigator_observer_test.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:app4training/features/perf/perf_navigator_observer.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'perf_logger_test.dart'; + +void main() { + tearDown(PerfLogger.reset); + + final navigatorKey = GlobalKey(); + + Widget app() => MaterialApp( + navigatorKey: navigatorKey, + navigatorObservers: [PerfNavigatorObserver()], + initialRoute: '/home', + onGenerateRoute: + (settings) => MaterialPageRoute( + settings: settings, + builder: (_) => Text(settings.name!), + ), + ); + + Future>> screenEvents(fileSystem) async { + await PerfLogger.flush(); + return (await readRecords( + fileSystem, + )).where((r) => r['name'] == 'screen').toList(); + } + + testWidgets('opening a worksheet records the screen kind only', ( + tester, + ) async { + final fileSystem = startLogger(); + await tester.pumpWidget(app()); + + navigatorKey.currentState!.pushNamed('/view/Healing/de'); + await tester.pumpAndSettle(); + + final events = await screenEvents(fileSystem); + // initialRoute '/home' makes the Navigator push '/' first + expect(events.map((e) => e['screen']), ['/', 'home', 'view']); + // No page name or language code (no PII in the perf logs) + expect( + events.last.keys, + unorderedEquals(['t', 'name', 'atUs', 'screen', 'depth']), + ); + }); + + testWidgets('replacing a screen (startup -> worksheet) records it', ( + tester, + ) async { + final fileSystem = startLogger(); + await tester.pumpWidget(app()); + + navigatorKey.currentState!.pushReplacementNamed('/view/Healing/de'); + await tester.pumpAndSettle(); + + final events = await screenEvents(fileSystem); + expect(events.last['screen'], 'view'); + }); + + testWidgets('going back records the screen that is visible again', ( + tester, + ) async { + final fileSystem = startLogger(); + await tester.pumpWidget(app()); + navigatorKey.currentState!.pushNamed('/view/Healing/de'); + await tester.pumpAndSettle(); + navigatorKey.currentState!.pushNamed('/settings'); + await tester.pumpAndSettle(); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + final events = await screenEvents(fileSystem); + expect(events.last['screen'], 'view'); + }); + + testWidgets('records how many screens are on the stack', (tester) async { + final fileSystem = startLogger(); + await tester.pumpWidget(app()); // '/' and '/home' + navigatorKey.currentState!.pushNamed('/view/Healing/de'); + await tester.pumpAndSettle(); + // A dialog is no screen of its own + unawaited( + showDialog( + context: navigatorKey.currentContext!, + builder: (_) => const Text('dialog'), + ), + ); + await tester.pumpAndSettle(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + final events = await screenEvents(fileSystem); + expect(events.map((e) => e['depth']), [1, 2, 3, 3, 2]); + }); +} From f4292992cb5b57f8a99fc2f124006b325e2fb01e Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 26 Oct 2026 18:50:57 -0300 Subject: [PATCH 3/5] feat(navigation): keep at most 5 screens on the navigation stack --- lib/main.dart | 12 ++++ lib/routes/route_stack_limiter.dart | 59 +++++++++++++++ test/route_stack_limiter_test.dart | 108 ++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 lib/routes/route_stack_limiter.dart create mode 100644 test/route_stack_limiter_test.dart diff --git a/lib/main.dart b/lib/main.dart index d54a203..aba1582 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:app4training/data/language_downloader.dart'; import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:app4training/features/perf/perf_navigator_observer.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; import 'package:dio/dio.dart'; import 'package:file/local.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:app4training/routes/route_stack_limiter.dart'; import 'package:app4training/routes/routes.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -191,6 +193,15 @@ void main() async { class App4Training extends ConsumerWidget { const App4Training({super.key}); + /// One instance each for the app's lifetime: new ones on every rebuild + /// would make the Navigator detach and re-attach its observers each time + /// (and the limiter would lose track of the stack) + static final RouteStackLimiter _routeStackLimiter = RouteStackLimiter( + maxDepth: 5, + ); + static final PerfNavigatorObserver _perfNavigatorObserver = + PerfNavigatorObserver(); + @override Widget build(BuildContext context, WidgetRef ref) { final AppLanguage appLanguage = ref.watch(appLanguageProvider); @@ -201,6 +212,7 @@ class App4Training extends ConsumerWidget { themeMode: ThemeMode.system, initialRoute: '/', onGenerateRoute: (settings) => generateRoutes(settings), + navigatorObservers: [_routeStackLimiter, _perfNavigatorObserver], locale: appLanguage.locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/lib/routes/route_stack_limiter.dart b/lib/routes/route_stack_limiter.dart new file mode 100644 index 0000000..aa27843 --- /dev/null +++ b/lib/routes/route_stack_limiter.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:flutter/widgets.dart'; + +/// Keeps the navigation stack from growing without bounds. +/// +/// Every worksheet the user opens (drawer, language menu, links inside a +/// worksheet) is pushed on top of the previous one, and each hidden route +/// keeps its whole flutter_html tree alive - and rebuilds it whenever +/// something it depends on changes (dark mode, app language, content +/// updates). So once the stack is deeper than [maxDepth], the oldest route +/// is dropped: the back button still works for the most recent screens. +class RouteStackLimiter extends NavigatorObserver { + final int maxDepth; + + /// The page routes currently on the stack, oldest first + final List> _routes = []; + + RouteStackLimiter({required this.maxDepth}); + + @override + void didPush(Route route, Route? previousRoute) { + // Dialogs and popup menus are short-lived and keep no page alive + if (route is! PageRoute) return; + _routes.add(route); + if (_routes.length > maxDepth) { + final Route oldest = _routes.first; + // The navigator is locked while it notifies its observers + scheduleMicrotask(() { + if (!oldest.isActive) return; + navigator?.removeRoute(oldest); + // How often does that happen, i.e. users lose back history? + PerfLogger.event('stack.trimmed'); + }); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + final int index = oldRoute == null ? -1 : _routes.indexOf(oldRoute); + if (newRoute == null) return; + if (index == -1) { + _routes.add(newRoute); + } else { + _routes[index] = newRoute; + } + } +} diff --git a/test/route_stack_limiter_test.dart b/test/route_stack_limiter_test.dart new file mode 100644 index 0000000..d3e76fe --- /dev/null +++ b/test/route_stack_limiter_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:app4training/features/perf/perf_logger.dart'; +import 'package:app4training/routes/route_stack_limiter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'perf_logger_test.dart'; + +void main() { + late GlobalKey navigatorKey; + + setUp(() => navigatorKey = GlobalKey()); + tearDown(PerfLogger.reset); + + Widget app() => MaterialApp( + navigatorKey: navigatorKey, + navigatorObservers: [RouteStackLimiter(maxDepth: 5)], + onGenerateRoute: + (settings) => MaterialPageRoute( + settings: settings, + builder: (_) => Text('screen ${settings.name}'), + ), + ); + + Future open(WidgetTester tester, String route) async { + navigatorKey.currentState!.pushNamed(route); + await tester.pumpAndSettle(); + } + + Future back(WidgetTester tester) async { + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + } + + testWidgets('keeps only the newest screens once the stack is too deep', ( + tester, + ) async { + await tester.pumpWidget(app()); // route '/' + for (int i = 1; i <= 6; i++) { + await open(tester, '/view/page$i/en'); + } + expect(find.text('screen /view/page6/en'), findsOneWidget); + + // Back walks through the four screens below, then the stack ends + for (int i = 5; i >= 2; i--) { + await back(tester); + expect(find.text('screen /view/page$i/en'), findsOneWidget); + } + expect(navigatorKey.currentState!.canPop(), false); + }); + + testWidgets('dialogs do not push screens out of the stack', (tester) async { + await tester.pumpWidget(app()); // route '/' + for (int i = 1; i <= 4; i++) { + await open(tester, '/view/page$i/en'); + } + unawaited( + showDialog( + context: navigatorKey.currentContext!, + builder: (_) => const Text('dialog'), + ), + ); + await tester.pumpAndSettle(); + await back(tester); // closes the dialog + + for (int i = 3; i >= 1; i--) { + await back(tester); + expect(find.text('screen /view/page$i/en'), findsOneWidget); + } + await back(tester); + expect(find.text('screen /'), findsOneWidget); + }); + + testWidgets('counts a replaced screen once (startup -> first worksheet)', ( + tester, + ) async { + await tester.pumpWidget(app()); // route '/' + navigatorKey.currentState!.pushReplacementNamed('/view/page0/en'); + await tester.pumpAndSettle(); + for (int i = 1; i <= 5; i++) { + await open(tester, '/view/page$i/en'); + } + + for (int i = 4; i >= 1; i--) { + await back(tester); + expect(find.text('screen /view/page$i/en'), findsOneWidget); + } + expect(navigatorKey.currentState!.canPop(), false); + }); + + testWidgets('dropping a screen is recorded for the perf logs', ( + tester, + ) async { + final fileSystem = startLogger(); + await tester.pumpWidget(app()); // route '/' + for (int i = 1; i <= 6; i++) { + await open(tester, '/view/page$i/en'); + } + await PerfLogger.flush(); + + final trims = (await readRecords( + fileSystem, + )).where((r) => r['name'] == 'stack.trimmed'); + // '/' and page1 were dropped, one at a time + expect(trims, hasLength(2)); + }); +} From 0f8d40297c07a32dd652e82f2aa2c46e24a7b5b5 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Wed, 28 Oct 2026 18:17:04 -0300 Subject: [PATCH 4/5] fix(view-page): keep the page load across route rebuilds --- lib/routes/view_page.dart | 36 ++++++++++-- test/view_page_test.dart | 116 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/lib/routes/view_page.dart b/lib/routes/view_page.dart index 924910d..06f9cca 100644 --- a/lib/routes/view_page.dart +++ b/lib/routes/view_page.dart @@ -18,12 +18,28 @@ import 'package:app4training/widgets/language_selection_button.dart'; /// The standard view of this app: /// Show a page (worksheet) -class ViewPage extends ConsumerWidget { +class ViewPage extends ConsumerStatefulWidget { final String page; // Name of the currently selected page final String langCode; const ViewPage(this.page, this.langCode, {super.key}); + @override + ConsumerState createState() => _ViewPageState(); +} + +class _ViewPageState extends ConsumerState { + /// Result of [checkAndLoad], kept across builds. + /// + /// The route builds a new ViewPage whenever its dependencies change (e.g. + /// the theme on a dark mode switch, also while the page is hidden under + /// another screen). Starting a new load on each of these builds flashed the + /// loading spinner and rebuilt the whole flutter_html tree from scratch. + Future? _content; + + String get page => widget.page; + String get langCode => widget.langCode; + /// First check whether the background process did something since /// the last time we checked. /// Then load the pageContent @@ -39,14 +55,26 @@ class ViewPage extends ConsumerWidget { .watch(scaffoldMessengerProvider) .showSnackBar(SnackBar(content: Text(l10n.foundBgActivity))); } - return ref.watch( + // build() listens to this provider, which also keeps it alive + return ref.read( pageContentProvider((name: page, langCode: langCode)).future, ); }); } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + // ...but load again when a finished result (content or error) changes, + // e.g. the language got updated or downloaded while the page is open. + // Not on the first load: that one is still loading. + ref.listen(pageContentProvider((name: page, langCode: langCode)), ( + previous, + next, + ) { + if ((previous != null) && !previous.isLoading) { + setState(() => _content = null); + } + }); return Scaffold( appBar: AppBar( title: const Text(Globals.appTitle), @@ -54,7 +82,7 @@ class ViewPage extends ConsumerWidget { ), drawer: MainDrawer(page, langCode), body: FutureBuilder( - future: checkAndLoad(context, ref), + future: _content ??= checkAndLoad(context, ref), builder: (BuildContext context, AsyncSnapshot snapshot) { if (kDebugMode) debugPrint(snapshot.connectionState.toString()); diff --git a/test/view_page_test.dart b/test/view_page_test.dart index fad5632..8ce5fbb 100644 --- a/test/view_page_test.dart +++ b/test/view_page_test.dart @@ -4,6 +4,7 @@ import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; import 'package:app4training/features/share/share_button.dart'; import 'package:app4training/l10n/generated/app_localizations.dart'; +import 'package:app4training/routes/routes.dart'; import 'package:app4training/routes/view_page.dart'; import 'package:app4training/widgets/language_selection_button.dart'; import 'package:file/memory.dart'; @@ -83,6 +84,114 @@ void main() { expect(find.byType(ShareButton), findsOneWidget); }); + testWidgets('switching to dark mode keeps the page on screen', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith((ref, page) async => 'TestContent'), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + child: MaterialApp( + theme: ThemeData(brightness: Brightness.light), + darkTheme: ThemeData(brightness: Brightness.dark), + themeMode: ThemeMode.system, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + // Through the app's routes: they build a new ViewPage whenever the + // route's dependencies (like the theme) change + onGenerateRoute: generateRoutes, + onGenerateInitialRoutes: + (_) => [ + generateRoutes(const RouteSettings(name: '/view/Healing/de')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('TestContent'), findsOneWidget); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + // Every frame of the theme animation: no loading spinner, no reload + for (int i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 30)); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.textContaining('TestContent'), findsOneWidget); + } + }); + + testWidgets('shows the new content when the page gets updated', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final version = NotifierProvider<_Version, int>(_Version.new); + final container = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith( + (ref, page) async => 'Content version ${ref.watch(version)}', + ), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const TestViewPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Content version 1'), findsOneWidget); + + // e.g. a language update while the page is open + container.read(version.notifier).increment(); + await tester.pumpAndSettle(); + + expect(find.textContaining('Content version 2'), findsOneWidget); + }); + + testWidgets('recovers from an error once the content is available', ( + WidgetTester tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final version = NotifierProvider<_Version, int>(_Version.new); + final container = ProviderContainer( + overrides: [ + appLanguageProvider.overrideWith(() => TestAppLanguage('de')), + pageContentProvider.overrideWith((ref, page) async { + if (ref.watch(version) == 1) { + throw LanguageNotDownloadedException('de'); + } + return 'Downloaded content'; + }), + sharedPrefsProvider.overrideWith((ref) => prefs), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const TestViewPage(), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Warnung'), findsOneWidget); + + // e.g. the language finished downloading in the background + container.read(version.notifier).increment(); + await tester.pumpAndSettle(); + + expect(find.textContaining('Downloaded content'), findsOneWidget); + }); + testWidgets('Test LanguageNotDownloadedException handling', ( WidgetTester tester, ) async { @@ -265,3 +374,10 @@ void main() { expect(readAttempts, 1); }); } + +class _Version extends Notifier { + @override + int build() => 1; + + void increment() => state++; +} From 63e2c2799947c3ca2f31af39b63e7c1af8bdfb45 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Wed, 28 Oct 2026 18:17:28 -0300 Subject: [PATCH 5/5] fix(perf): only record jank frames over two frame budgets --- lib/features/perf/perf_logger.dart | 9 +++++++-- test/perf_logger_test.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/features/perf/perf_logger.dart b/lib/features/perf/perf_logger.dart index 39ca111..df7039e 100644 --- a/lib/features/perf/perf_logger.dart +++ b/lib/features/perf/perf_logger.dart @@ -26,8 +26,13 @@ const bool kPerfLoggingEnabled = bool.fromEnvironment('ENABLE_PERF_LOGGING'); /// Keep at most this many session files on the device const int kMaxStoredPerfSessions = 20; -/// Frames slower than this count as jank and get their own record -const int kJankThresholdUs = 16700; // ~ one 60Hz frame budget +/// Frames slower than this count as jank and get their own record. +/// +/// Two 60Hz frame budgets, not one: on slow devices plain scrolling produces +/// hundreds of ~20 ms frames, which used up [kMaxJankRecords] within minutes +/// and left no records for the long frames later in the session. The frame +/// histogram still counts every frame. +const int kJankThresholdUs = 33400; /// At most this many individual jank records per session /// (the frame histogram keeps counting beyond that) diff --git a/test/perf_logger_test.dart b/test/perf_logger_test.dart index 770e59e..3175660 100644 --- a/test/perf_logger_test.dart +++ b/test/perf_logger_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io' show gzip; +import 'dart:ui' show FrameTiming; import 'package:app4training/features/perf/perf_logger.dart'; import 'package:file/file.dart'; @@ -208,6 +209,32 @@ void main() { expect(jsonDecode(content.trim())['t'], 'header'); }); + testWidgets('only frames over two frame budgets get a jank record', ( + tester, + ) async { + final fileSystem = startLogger(); + PerfLogger.attachToApp(); + FrameTiming frame(int totalMs) => FrameTiming( + vsyncStart: 0, + buildStart: 0, + buildFinish: totalMs * 1000 ~/ 2, + rasterStart: totalMs * 1000 ~/ 2, + rasterFinish: totalMs * 1000, + rasterFinishWallTime: totalMs * 1000, + ); + + // As reported by the engine: a slightly slow scrolling frame, a slow one + tester.binding.platformDispatcher.onReportTimings!([frame(25), frame(50)]); + await PerfLogger.flush(); + + final records = await readRecords(fileSystem); + final janks = records.where((r) => r['t'] == 'jank').toList(); + expect(janks, hasLength(1)); + expect(janks.single['totalUs'], 50000); + // The histogram still counts every frame + expect(records.lastWhere((r) => r['t'] == 'frames')['total'], 2); + }); + test('timestampSlug formats with ASCII digits and padding', () { expect( PerfLogger.timestampSlug(DateTime(2026, 1, 2, 3, 4, 5)),