From 16f2722a5b917fb1597f94cf39b913ed3e1a6df3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jul 2026 14:45:38 -0400 Subject: [PATCH] perf: async startup cache sweep, memoize chat name color the orphaned-cache-file sweep ran a synchronous recursive listSync on the UI isolate at startup against a cache that can hold 10k files; use the async listing instead. memoize adjustChatNameColor keyed by (color, background, target contrast) since chat repeats the same handful of palette colors thousands of times and low-contrast ones pay a 16-step search per call. Co-Authored-By: Claude Fable 5 --- lib/cache_manager.dart | 5 ++++- lib/utils.dart | 18 ++++++++++++++++++ test/utils_test.dart | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/cache_manager.dart b/lib/cache_manager.dart index b3d61f48..d6d30879 100644 --- a/lib/cache_manager.dart +++ b/lib/cache_manager.dart @@ -23,7 +23,10 @@ class CustomCacheManager { return; } - final allFiles = cacheDir.listSync(recursive: true).toList(); + // Async listing — the cache can hold up to maxNrOfCacheObjects (10k) + // files, and a synchronous recursive listing that large would block the + // UI isolate during startup. + final allFiles = await cacheDir.list(recursive: true).toList(); await _repo.open(); final cachedObjects = await _repo.getAllObjects(); diff --git a/lib/utils.dart b/lib/utils.dart index f0b35278..dbf2e0fd 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -24,6 +24,12 @@ double _contrastRatio(Color a, Color b) { return (hi + 0.05) / (lo + 0.05); } +/// Memoized results for [adjustChatNameColor]. Chat renders the same handful +/// of Twitch palette colors thousands of times per session, and every call +/// costs luminance math (plus a 16-step search for low-contrast colors) — the +/// answer only depends on this key, so compute it once per combination. +final _adjustedNameColorCache = <(int, int, double), Color>{}; + /// Adjusts [color] just enough to reach [targetContrast] on the app's background. /// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls /// back to theme.colorScheme.surface. @@ -44,6 +50,18 @@ Color adjustChatNameColor( bg = theme.colorScheme.surface; } + final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast); + final cached = _adjustedNameColorCache[cacheKey]; + if (cached != null) return cached; + + final adjusted = _adjustNameColorUncached(color, bg, targetContrast); + // Custom hex name colors are unbounded in theory; keep the cache small. + if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear(); + _adjustedNameColorCache[cacheKey] = adjusted; + return adjusted; +} + +Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) { if (_contrastRatio(color, bg) >= targetContrast) return color; final hsl0 = HSLColor.fromColor(color); diff --git a/test/utils_test.dart b/test/utils_test.dart index 846540f6..7d359aad 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -198,6 +198,45 @@ void main() { expect(adjustedColor, isNot(const Color(0xFF00FF00))); }); + testWidgets('memoized results are stable and keyed by background', + (tester) async { + late Color first; + late Color second; + late Color onLightBackground; + const darkBackground = Color(0xFF121212); + const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg + + await tester.pumpWidget( + buildTestWidget( + scaffoldBackground: darkBackground, + child: Builder( + builder: (context) { + // Repeated calls with identical inputs must return the same + // adjusted color (second call is served from the memo cache). + first = adjustChatNameColor(context, lowContrastColor); + second = adjustChatNameColor(context, lowContrastColor); + // A different background must not be served the dark-background + // cache entry: near-black already passes on white, so it should + // come back unchanged. + onLightBackground = adjustChatNameColor( + context, + lowContrastColor, + background: const Color(0xFFFFFFFF), + ); + return const SizedBox(); + }, + ), + ), + ); + + expect(second, first); + expect( + first.computeLuminance(), + greaterThan(lowContrastColor.computeLuminance()), + ); + expect(onLightBackground, lowContrastColor); + }); + testWidgets('handles transparent scaffold background', (tester) async { late Color adjustedColor; const darkColor = Color(0xFF000000);