diff --git a/.gitea/workflows/pixel10.yml b/.gitea/workflows/pixel10.yml index 8c9deda0..6a6afe5f 100644 --- a/.gitea/workflows/pixel10.yml +++ b/.gitea/workflows/pixel10.yml @@ -118,6 +118,13 @@ jobs: dart pub global activate melos 7.8.1 melos bootstrap + - name: Test Pixel 10 performance regressions + working-directory: apps/weblibre + run: >- + flutter test --no-pub + test/features/geckoview/utils/image_helper_test.dart + test/features/geckoview/domain/providers/tab_detail_state_test.dart + - name: Generate bundled assets run: | melos run update-assets --no-select diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart index a559a733..2ff2504a 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_detail_state.dart @@ -54,6 +54,14 @@ class TabProgressStates extends _$TabProgressStates { state = {...state}..[tabId] = progress; } + + void removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } } @Riverpod() @@ -88,6 +96,17 @@ class TabThumbnails extends _$TabThumbnails { state = {...state}..[tabId] = thumbnail; } + + void removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + // EquatableImage owns its ui.Image through a finalizer. Dropping the map + // reference is safer than disposing it here because an outgoing tab-preview + // frame may still hold the same wrapper briefly. + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } } @Riverpod() @@ -112,6 +131,14 @@ class TabHistoryStates extends _$TabHistoryStates { state = {...state}..[tabId] = history; } + + void removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } } @Riverpod() @@ -142,6 +169,14 @@ class TabFindResultStates extends _$TabFindResultStates { FindResultState resultFor(String tabId) => state[tabId] ?? FindResultState.$default(); + + void removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } } @Riverpod() @@ -170,6 +205,14 @@ class TabTranslationStates extends _$TabTranslationStates { state = {...state}..[tabId] = translation; } + + void removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } } @Riverpod() diff --git a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart index 456e83ed..9d654bdf 100644 --- a/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart +++ b/apps/weblibre/lib/features/geckoview/domain/providers/tab_state.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/translation.d import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -75,6 +76,14 @@ class TabStates extends _$TabStates { state = {...state}..[tabId] = next; } + void _removeAll(Set tabIds) { + if (!state.keys.any(tabIds.contains)) { + return; + } + + state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId)); + } + Future _onTabContentStateChange(TabContentState contentState) async { final current = await patchedState(contentState.id); @@ -187,6 +196,9 @@ class TabStates extends _$TabStates { bytes, targetWidth: thumbnailDecodeWidth, allowUpscaling: false, + // Periodic screenshots are almost always unique. Caching each decode + // retained up to 100 obsolete GPU images in the global icon LRU. + cacheResult: false, ), ); @@ -415,6 +427,29 @@ class TabStates extends _$TabStates { }, ); + ref.listen(tabListProvider, (previous, next) { + if (previous == null) { + // The first list can be a partial restore snapshot. There is no reliable + // removal signal until Gecko has emitted at least two snapshots. + return; + } + + final activeTabIds = next.value.toSet(); + final removedTabIds = previous.value + .where((tabId) => !activeTabIds.contains(tabId)) + .toSet(); + if (removedTabIds.isEmpty) { + return; + } + + _removeAll(removedTabIds); + ref.read(tabProgressStatesProvider.notifier).removeAll(removedTabIds); + ref.read(tabThumbnailsProvider.notifier).removeAll(removedTabIds); + ref.read(tabHistoryStatesProvider.notifier).removeAll(removedTabIds); + ref.read(tabFindResultStatesProvider.notifier).removeAll(removedTabIds); + ref.read(tabTranslationStatesProvider.notifier).removeAll(removedTabIds); + }); + ref.onDispose(() async { for (final sub in subscriptions) { await sub.cancel(); diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index 65ee9845..63353a09 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -32,15 +32,18 @@ import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; +import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; @@ -1211,6 +1214,21 @@ class TabRepository extends _$TabRepository { ref.listen( tabListProvider, (previous, next) async { + final activeTabIds = next.value.toSet(); + final removedTabIds = previous?.value + .where((tabId) => !activeTabIds.contains(tabId)) + .toSet(); + if (removedTabIds != null) { + for (final tabId in removedTabIds) { + // These families are keepAlive so tab-specific state survives while + // a tab is merely in the background. Once Gecko confirms removal, + // keeping their services and listeners serves no purpose. + ref.invalidate(tabSessionProvider(tabId: tabId)); + ref.invalidate(desktopModeProvider(tabId)); + ref.invalidate(findInPageRepositoryProvider(tabId)); + } + } + if (_suppressNextReclose) { _suppressNextReclose = false; // Drop tombstones for the tabs that just came back via undo so diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 696cdf82..2aa054e6 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -1000,6 +1000,20 @@ class BrowserScreen extends HookConsumerWidget { final pendingProxyLoadErrors = useRef({}); final selectedTabIdForProxyPrompt = ref.watch(selectedTabProvider); + ref.listen(tabListProvider, (previous, next) { + if (previous == null) return; + + final activeTabIds = next.value.toSet(); + for (final tabId in previous.value.where( + (tabId) => !activeTabIds.contains(tabId), + )) { + // UI-scoped families intentionally survive while a tab is backgrounded, + // but must not retain listeners and text state after it is closed. + ref.invalidate(toolbarVisibilityControllerProvider(tabId)); + ref.invalidate(findInPageControllerProvider(tabId)); + } + }); + Future handleProxyLoadError({ required String tabId, required String? contextId, diff --git a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart index 322547f9..4336e24c 100644 --- a/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/pwa/domain/providers.dart @@ -27,6 +27,7 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; @@ -66,6 +67,21 @@ class PwaManifestState extends _$PwaManifestState { }, ); + ref.listen(tabListProvider, (previous, next) { + if (previous == null) return; + + final activeTabIds = next.value.toSet(); + final removedTabIds = previous.value + .where((tabId) => !activeTabIds.contains(tabId)) + .toSet(); + if (!removedTabIds.any(state.containsKey)) { + return; + } + + state = {...state} + ..removeWhere((tabId, _) => removedTabIds.contains(tabId)); + }); + return {}; } } diff --git a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart index 87cb98c4..33c597e1 100644 --- a/apps/weblibre/lib/features/geckoview/utils/image_helper.dart +++ b/apps/weblibre/lib/features/geckoview/utils/image_helper.dart @@ -42,6 +42,7 @@ Future tryDecodeImage( int? targetWidth, int? targetHeight, bool allowUpscaling = true, + bool cacheResult = true, }) async { // The decode options are part of the identity of the result, not just of the // request: the same bytes decoded at a thumbnail's target width and at an @@ -55,11 +56,13 @@ Future tryDecodeImage( allowUpscaling: allowUpscaling, ); - final cached = _cache.get(identity); - if (cached?.value != null) { - return cached; - } else if (cached != null) { - _cache.remove(identity); + if (cacheResult) { + final cached = _cache.get(identity); + if (cached?.value != null) { + return cached; + } else if (cached != null) { + _cache.remove(identity); + } } try { @@ -74,7 +77,9 @@ Future tryDecodeImage( final image = EquatableImage(frameInfo.image, identity: identity); if (image.value != null && image.value!.width > 0) { - _cache.set(identity, image); + if (cacheResult) { + _cache.set(identity, image); + } return image; } } catch (e, s) { @@ -87,7 +92,9 @@ Future tryDecodeImage( targetHeight: targetHeight, ); if (svgImage != null) { - _cache.set(identity, svgImage); + if (cacheResult) { + _cache.set(identity, svgImage); + } return svgImage; } } catch (svgError, svgStackTrace) { diff --git a/apps/weblibre/pubspec.yaml b/apps/weblibre/pubspec.yaml index c9e65b4c..cf667a1f 100644 --- a/apps/weblibre/pubspec.yaml +++ b/apps/weblibre/pubspec.yaml @@ -2,7 +2,7 @@ name: weblibre description: "The Privacy-Focused & AI-Powered Research Browser" publish_to: 'none' resolution: workspace -version: 0.30.0-alpha-3+40 +version: 0.30.0-alpha-3+41 environment: sdk: '>=3.8.0 <4.0.0' diff --git a/apps/weblibre/test/features/geckoview/domain/providers/tab_detail_state_test.dart b/apps/weblibre/test/features/geckoview/domain/providers/tab_detail_state_test.dart new file mode 100644 index 00000000..d56dc4e1 --- /dev/null +++ b/apps/weblibre/test/features/geckoview/domain/providers/tab_detail_state_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart'; + +void main() { + test('closed tab progress is pruned without touching active tabs', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(tabProgressStatesProvider.notifier); + notifier.update('closed-tab', 80); + notifier.update('active-tab', 40); + + notifier.removeAll({'closed-tab', 'unknown-tab'}); + + expect(container.read(tabProgressStatesProvider), {'active-tab': 40}); + }); + + test('pruning unrelated ids does not publish a new map', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(tabProgressStatesProvider.notifier); + notifier.update('active-tab', 40); + final before = container.read(tabProgressStatesProvider); + + notifier.removeAll({'unknown-tab'}); + + expect( + identical(container.read(tabProgressStatesProvider), before), + isTrue, + ); + }); +} diff --git a/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart index 1490eec0..553c0dbe 100644 --- a/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart +++ b/apps/weblibre/test/features/geckoview/utils/image_helper_test.dart @@ -75,6 +75,27 @@ void main() { }); }); + testWidgets('tryDecodeImage can bypass the global image cache', ( + tester, + ) async { + await tester.runAsync(() async { + clearImageCache(); + final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); + + final first = await tryDecodeImage(svgBytes, cacheResult: false); + final second = await tryDecodeImage(svgBytes, cacheResult: false); + + expect(first, isNotNull); + expect(second, isNotNull); + expect(first, equals(second)); + expect(identical(first, second), isFalse); + + // These are deliberately distinct uncached image resources. + first!.dispose(); + second!.dispose(); + }); + }); + test('ImageIdentity compares structurally, not by a folded hash', () { const a = ( digest: 0x0123456789ABCDEF,