From d1e769fb7c573586f9fd90e5b1bf6a6b487fb52b Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 8 Feb 2026 17:37:21 +0100 Subject: [PATCH] improve image lifecycle handling --- .../geckoview/domain/providers/tab_state.dart | 5 ++ .../domain/providers/tab_state.g.dart | 2 +- .../providers/web_extensions_state.dart | 8 +-- .../providers/web_extensions_state.g.dart | 2 +- .../browser_modules/bottom_app_bar.dart | 8 ++- .../widgets/extension_badge_icon.dart | 11 ++-- .../presentation/widgets/tab_icon.dart | 21 ++++--- .../widgets/tab_view/tab_preview.dart | 13 ++-- .../search_modules/history_suggestions.dart | 16 +++-- .../widgets/search_modules/tab_search.dart | 9 ++- .../geckoview/utils/image_helper.dart | 7 +-- .../presentation/widgets/safe_raw_image.dart | 59 +++++++++++++++++++ app/lib/presentation/widgets/url_icon.dart | 6 +- .../widgets/website_title_tile.dart | 10 ++-- 14 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 app/lib/presentation/widgets/safe_raw_image.dart diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 19ed61a9..43bce19a 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -331,6 +331,11 @@ class TabStates extends _$TabStates { ); ref.onDispose(() async { + // Dispose all remaining tab images + for (final tab in state.values) { + _disposeTabImages(tab); + } + // Cancel all stream subscriptions for (final sub in subscriptions) { await sub.cancel(); diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index 195280c5..d957e5bb 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -41,7 +41,7 @@ final class TabStatesProvider } } -String _$tabStatesHash() => r'2d18c0c2105ce53bd13e6a6dff1d33c1b531919d'; +String _$tabStatesHash() => r'985d2eb87c59c3a113b8bbcf9d96267c42a2f543'; abstract class _$TabStates extends $Notifier> { Map build(); diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart index 754a09b1..7233680b 100644 --- a/app/lib/features/geckoview/domain/providers/web_extensions_state.dart +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.dart @@ -71,8 +71,8 @@ class WebExtensionsState extends _$WebExtensionsState { } else { if (state.containsKey(extensionId)) { state = {...state}..remove(extensionId); - // Dispose the cached image when extension is removed - _imageCache.remove(extensionId)?.dispose(); + // remove() triggers onEvict which handles disposal + _imageCache.remove(extensionId); } } } @@ -83,9 +83,7 @@ class WebExtensionsState extends _$WebExtensionsState { final image = await tryDecodeImage(bytes); if (image != null) { - // Dispose old image only after successfully creating new one - _imageCache.get(extensionId)?.dispose(); - + // set() will evict the old entry via onEvict callback, which handles disposal _imageCache.set(extensionId, image); if (state.containsKey(extensionId)) { diff --git a/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart index b2c8e1fe..53c46102 100644 --- a/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/web_extensions_state.g.dart @@ -62,7 +62,7 @@ final class WebExtensionsStateProvider } String _$webExtensionsStateHash() => - r'd74f739a2f33395c10e30a81c8031f2f2630e418'; + r'28cd9c99bc167fa1a051eccf3d23917a417e54ea'; final class WebExtensionsStateFamily extends $Family with diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 228ea427..e09806ea 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -56,6 +56,7 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/icons/weblibre_icons.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -537,8 +538,11 @@ class QuickTabSwitcher extends HookConsumerWidget { }, itemAvatar: (item) => item.tabState?.icon.mapNotNull( - (icon) => icon.value.mapNotNull( - (image) => RawImage(image: image, height: 24, width: 24), + (icon) => SafeRawImage( + image: icon, + height: 24, + width: 24, + fallback: UrlIcon([item.url], iconSize: 24), ), ) ?? UrlIcon([item.url], iconSize: 16), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart index f88736f2..43d20a59 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; class ExtensionBadgeIcon extends StatelessWidget { final WebExtensionState state; @@ -36,11 +37,11 @@ class ExtensionBadgeIcon extends StatelessWidget { textColor: state.badgeTextColor, backgroundColor: state.badgeBackgroundColor, child: RepaintBoundary( - child: - state.icon?.value.mapNotNull( - (image) => RawImage(image: image, width: 24, height: 24), - ) ?? - const SizedBox(width: 24, height: 24), + child: SafeRawImage( + image: state.icon, + width: 24, + height: 24, + ), ), ); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart index 8d76e238..c87e6873 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_icon.dart @@ -22,9 +22,11 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/services/generic_website.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; class TabIcon extends HookConsumerWidget { final TabState tabState; @@ -36,17 +38,16 @@ class TabIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final icon = useCachedFuture(() async { - final faviconIcon = tabState.icon?.value; - - if (faviconIcon != null) { - return faviconIcon; + if (tabState.icon case final EquatableImage tabIcon + when !tabIcon.isDisposed) { + return tabIcon; } - final icon = await ref + final cachedIcon = await ref .read(genericWebsiteServiceProvider.notifier) .getCachedIcon(tabState.url); - return icon?.image.value; + return cachedIcon?.image; }, [tabState.icon, tabState.url]); return Skeletonizer( @@ -56,8 +57,12 @@ class TabIcon extends HookConsumerWidget { child: RepaintBoundary( child: icon.data.mapNotNull( - (icon) => - RawImage(image: icon, height: iconSize, width: iconSize), + (image) => SafeRawImage( + image: image, + height: iconSize, + width: iconSize, + fallback: Icon(MdiIcons.web, size: iconSize), + ), ) ?? Icon(MdiIcons.web, size: iconSize), ), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 1ef2000e..7f86ee51 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/entitie import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -210,7 +211,7 @@ class GridTabPreview extends HookConsumerWidget { ], ), const SizedBox(height: 6), - if (tabState.thumbnail?.value != null) + if (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed) Expanded( child: ClipRRect( borderRadius: const BorderRadius.only( @@ -220,8 +221,8 @@ class GridTabPreview extends HookConsumerWidget { child: SizedBox( width: double.infinity, child: RepaintBoundary( - child: RawImage( - image: tabState.thumbnail!.value, + child: SafeRawImage( + image: tabState.thumbnail, fit: BoxFit.fitWidth, ), ), @@ -290,10 +291,10 @@ class ListTabPreview extends HookConsumerWidget { onTap: onTap, onLongPress: onLongPress, contentPadding: const EdgeInsets.only(left: 4), - leading: (tabState.thumbnail?.value != null) + leading: (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed) ? RepaintBoundary( - child: RawImage( - image: tabState.thumbnail!.value, + child: SafeRawImage( + image: tabState.thumbnail, fit: BoxFit.fitHeight, ), ) diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index 38890078..c639110e 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/eng import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class HistorySuggestions extends HookConsumerWidget { @@ -92,15 +93,12 @@ class HistorySuggestions extends HookConsumerWidget { return ListTile( leading: RepaintBoundary( - child: - icon.data?.value.mapNotNull( - (image) => RawImage( - image: image, - height: 24, - width: 24, - ), - ) ?? - const Icon(MdiIcons.web, size: 24), + child: SafeRawImage( + image: icon.data, + height: 24, + width: 24, + fallback: const Icon(MdiIcons.web, size: 24), + ), ), title: suggestion.title.mapNotNull( (title) => Text( diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index f060eff9..a537fc2c 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -39,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/text_highlight.dart'; @@ -187,9 +188,11 @@ class TabSearch extends HookConsumerWidget { leading: RepaintBoundary( child: result.icon.mapNotNull( - (icon) => icon.value.mapNotNull( - (image) => - RawImage(image: image, height: 24, width: 24), + (icon) => SafeRawImage( + image: icon, + height: 24, + width: 24, + fallback: UrlIcon([result.url], iconSize: 24), ), ) ?? UrlIcon([result.url], iconSize: 24), diff --git a/app/lib/features/geckoview/utils/image_helper.dart b/app/lib/features/geckoview/utils/image_helper.dart index dc3d2957..d15d8515 100644 --- a/app/lib/features/geckoview/utils/image_helper.dart +++ b/app/lib/features/geckoview/utils/image_helper.dart @@ -25,12 +25,9 @@ import 'package:weblibre/core/logger.dart'; import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/utils/lru_cache.dart'; -final _cache = LRUCache( - 100, - onEvict: (image) => image.dispose(), -); +final _cache = LRUCache(100); -/// Clears the global image cache, disposing all cached images. +/// Clears the global image decode cache. void clearImageCache() { _cache.clear(); } diff --git a/app/lib/presentation/widgets/safe_raw_image.dart b/app/lib/presentation/widgets/safe_raw_image.dart new file mode 100644 index 00000000..f3e2b2eb --- /dev/null +++ b/app/lib/presentation/widgets/safe_raw_image.dart @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/widgets.dart'; +import 'package:weblibre/domain/entities/equatable_image.dart'; + +/// A safe wrapper around [RawImage] that guards against disposed images. +/// +/// Checks [EquatableImage.isDisposed] before rendering. When the image +/// is null or disposed, renders [fallback] (defaults to an empty SizedBox +/// matching the requested dimensions). +class SafeRawImage extends StatelessWidget { + final EquatableImage? image; + final double? width; + final double? height; + final BoxFit? fit; + final Widget? fallback; + + const SafeRawImage({ + super.key, + required this.image, + this.width, + this.height, + this.fit, + this.fallback, + }); + + @override + Widget build(BuildContext context) { + final uiImage = image?.value; + + if (uiImage == null) { + return fallback ?? SizedBox(width: width, height: height); + } + + return RawImage( + image: uiImage, + width: width, + height: height, + fit: fit, + ); + } +} diff --git a/app/lib/presentation/widgets/url_icon.dart b/app/lib/presentation/widgets/url_icon.dart index 478cd237..aa6d0559 100644 --- a/app/lib/presentation/widgets/url_icon.dart +++ b/app/lib/presentation/widgets/url_icon.dart @@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/domain/services/generic_website.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; class UrlIcon extends HookConsumerWidget { final double iconSize; @@ -46,11 +47,12 @@ class UrlIcon extends HookConsumerWidget { dimension: iconSize, child: (icon.data != null) ? RepaintBoundary( - child: RawImage( - image: icon.data?.image.value, + child: SafeRawImage( + image: icon.data?.image, height: iconSize, width: iconSize, fit: BoxFit.fill, + fallback: Icon(MdiIcons.web, size: iconSize), ), ) : Icon(MdiIcons.web, size: iconSize), diff --git a/app/lib/presentation/widgets/website_title_tile.dart b/app/lib/presentation/widgets/website_title_tile.dart index d2881d09..928b8a33 100644 --- a/app/lib/presentation/widgets/website_title_tile.dart +++ b/app/lib/presentation/widgets/website_title_tile.dart @@ -25,6 +25,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/presentation/controllers/website_title.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; class WebsiteTitleTile extends HookConsumerWidget { @@ -45,10 +46,11 @@ class WebsiteTitleTile extends HookConsumerWidget { leading: RepaintBoundary( child: info.favicon.mapNotNull( - (favicon) => RawImage( - image: favicon.image.value, + (favicon) => SafeRawImage( + image: favicon.image, height: 24, width: 24, + fallback: const Icon(MdiIcons.web, size: 24), ), ) ?? const Icon(MdiIcons.web, size: 24), @@ -71,8 +73,8 @@ class WebsiteTitleTile extends HookConsumerWidget { ); }, loading: () => ListTile( - leading: RawImage( - image: initialTabState.favicon?.image.value, + leading: SafeRawImage( + image: initialTabState.favicon?.image, height: 24, width: 24, ),