improve image lifecycle handling

This commit is contained in:
Fabian Freund
2026-02-08 17:37:21 +01:00
parent 6f3a84fb31
commit d1e769fb7c
14 changed files with 126 additions and 51 deletions
@@ -331,6 +331,11 @@ class TabStates extends _$TabStates {
); );
ref.onDispose(() async { ref.onDispose(() async {
// Dispose all remaining tab images
for (final tab in state.values) {
_disposeTabImages(tab);
}
// Cancel all stream subscriptions // Cancel all stream subscriptions
for (final sub in subscriptions) { for (final sub in subscriptions) {
await sub.cancel(); await sub.cancel();
@@ -41,7 +41,7 @@ final class TabStatesProvider
} }
} }
String _$tabStatesHash() => r'2d18c0c2105ce53bd13e6a6dff1d33c1b531919d'; String _$tabStatesHash() => r'985d2eb87c59c3a113b8bbcf9d96267c42a2f543';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> { abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build(); Map<String, TabState> build();
@@ -71,8 +71,8 @@ class WebExtensionsState extends _$WebExtensionsState {
} else { } else {
if (state.containsKey(extensionId)) { if (state.containsKey(extensionId)) {
state = {...state}..remove(extensionId); state = {...state}..remove(extensionId);
// Dispose the cached image when extension is removed // remove() triggers onEvict which handles disposal
_imageCache.remove(extensionId)?.dispose(); _imageCache.remove(extensionId);
} }
} }
} }
@@ -83,9 +83,7 @@ class WebExtensionsState extends _$WebExtensionsState {
final image = await tryDecodeImage(bytes); final image = await tryDecodeImage(bytes);
if (image != null) { if (image != null) {
// Dispose old image only after successfully creating new one // set() will evict the old entry via onEvict callback, which handles disposal
_imageCache.get(extensionId)?.dispose();
_imageCache.set(extensionId, image); _imageCache.set(extensionId, image);
if (state.containsKey(extensionId)) { if (state.containsKey(extensionId)) {
@@ -62,7 +62,7 @@ final class WebExtensionsStateProvider
} }
String _$webExtensionsStateHash() => String _$webExtensionsStateHash() =>
r'd74f739a2f33395c10e30a81c8031f2f2630e418'; r'28cd9c99bc167fa1a051eccf3d23917a417e54ea';
final class WebExtensionsStateFamily extends $Family final class WebExtensionsStateFamily extends $Family
with with
@@ -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/cached_future.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/icons/weblibre_icons.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/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -537,8 +538,11 @@ class QuickTabSwitcher extends HookConsumerWidget {
}, },
itemAvatar: (item) => itemAvatar: (item) =>
item.tabState?.icon.mapNotNull( item.tabState?.icon.mapNotNull(
(icon) => icon.value.mapNotNull( (icon) => SafeRawImage(
(image) => RawImage(image: image, height: 24, width: 24), image: icon,
height: 24,
width: 24,
fallback: UrlIcon([item.url], iconSize: 24),
), ),
) ?? ) ??
UrlIcon([item.url], iconSize: 16), UrlIcon([item.url], iconSize: 16),
@@ -20,6 +20,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/web_extension.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 { class ExtensionBadgeIcon extends StatelessWidget {
final WebExtensionState state; final WebExtensionState state;
@@ -36,11 +37,11 @@ class ExtensionBadgeIcon extends StatelessWidget {
textColor: state.badgeTextColor, textColor: state.badgeTextColor,
backgroundColor: state.badgeBackgroundColor, backgroundColor: state.badgeBackgroundColor,
child: RepaintBoundary( child: RepaintBoundary(
child: child: SafeRawImage(
state.icon?.value.mapNotNull( image: state.icon,
(image) => RawImage(image: image, width: 24, height: 24), width: 24,
) ?? height: 24,
const SizedBox(width: 24, height: 24), ),
), ),
); );
} }
@@ -22,9 +22,11 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.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/domain/services/generic_website.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
class TabIcon extends HookConsumerWidget { class TabIcon extends HookConsumerWidget {
final TabState tabState; final TabState tabState;
@@ -36,17 +38,16 @@ class TabIcon extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final icon = useCachedFuture(() async { final icon = useCachedFuture(() async {
final faviconIcon = tabState.icon?.value; if (tabState.icon case final EquatableImage tabIcon
when !tabIcon.isDisposed) {
if (faviconIcon != null) { return tabIcon;
return faviconIcon;
} }
final icon = await ref final cachedIcon = await ref
.read(genericWebsiteServiceProvider.notifier) .read(genericWebsiteServiceProvider.notifier)
.getCachedIcon(tabState.url); .getCachedIcon(tabState.url);
return icon?.image.value; return cachedIcon?.image;
}, [tabState.icon, tabState.url]); }, [tabState.icon, tabState.url]);
return Skeletonizer( return Skeletonizer(
@@ -56,8 +57,12 @@ class TabIcon extends HookConsumerWidget {
child: RepaintBoundary( child: RepaintBoundary(
child: child:
icon.data.mapNotNull( icon.data.mapNotNull(
(icon) => (image) => SafeRawImage(
RawImage(image: icon, height: iconSize, width: iconSize), image: image,
height: iconSize,
width: iconSize,
fallback: Icon(MdiIcons.web, size: iconSize),
),
) ?? ) ??
Icon(MdiIcons.web, size: iconSize), Icon(MdiIcons.web, size: iconSize),
), ),
@@ -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/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/presentation/hooks/menu_controller.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/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper; import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
@@ -210,7 +211,7 @@ class GridTabPreview extends HookConsumerWidget {
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
if (tabState.thumbnail?.value != null) if (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed)
Expanded( Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@@ -220,8 +221,8 @@ class GridTabPreview extends HookConsumerWidget {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: RepaintBoundary( child: RepaintBoundary(
child: RawImage( child: SafeRawImage(
image: tabState.thumbnail!.value, image: tabState.thumbnail,
fit: BoxFit.fitWidth, fit: BoxFit.fitWidth,
), ),
), ),
@@ -290,10 +291,10 @@ class ListTabPreview extends HookConsumerWidget {
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
contentPadding: const EdgeInsets.only(left: 4), contentPadding: const EdgeInsets.only(left: 4),
leading: (tabState.thumbnail?.value != null) leading: (tabState.thumbnail != null && !tabState.thumbnail!.isDisposed)
? RepaintBoundary( ? RepaintBoundary(
child: RawImage( child: SafeRawImage(
image: tabState.thumbnail!.value, image: tabState.thumbnail,
fit: BoxFit.fitHeight, fit: BoxFit.fitHeight,
), ),
) )
@@ -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/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/failure_widget.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'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class HistorySuggestions extends HookConsumerWidget { class HistorySuggestions extends HookConsumerWidget {
@@ -92,15 +93,12 @@ class HistorySuggestions extends HookConsumerWidget {
return ListTile( return ListTile(
leading: RepaintBoundary( leading: RepaintBoundary(
child: child: SafeRawImage(
icon.data?.value.mapNotNull( image: icon.data,
(image) => RawImage( height: 24,
image: image, width: 24,
height: 24, fallback: const Icon(MdiIcons.web, size: 24),
width: 24, ),
),
) ??
const Icon(MdiIcons.web, size: 24),
), ),
title: suggestion.title.mapNotNull( title: suggestion.title.mapNotNull(
(title) => Text( (title) => Text(
@@ -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/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.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/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/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/text_highlight.dart'; import 'package:weblibre/utils/text_highlight.dart';
@@ -187,9 +188,11 @@ class TabSearch extends HookConsumerWidget {
leading: RepaintBoundary( leading: RepaintBoundary(
child: child:
result.icon.mapNotNull( result.icon.mapNotNull(
(icon) => icon.value.mapNotNull( (icon) => SafeRawImage(
(image) => image: icon,
RawImage(image: image, height: 24, width: 24), height: 24,
width: 24,
fallback: UrlIcon([result.url], iconSize: 24),
), ),
) ?? ) ??
UrlIcon([result.url], iconSize: 24), UrlIcon([result.url], iconSize: 24),
@@ -25,12 +25,9 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/utils/lru_cache.dart'; import 'package:weblibre/utils/lru_cache.dart';
final _cache = LRUCache<int, EquatableImage>( final _cache = LRUCache<int, EquatableImage>(100);
100,
onEvict: (image) => image.dispose(),
);
/// Clears the global image cache, disposing all cached images. /// Clears the global image decode cache.
void clearImageCache() { void clearImageCache() {
_cache.clear(); _cache.clear();
} }
@@ -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 <http://www.gnu.org/licenses/>.
*/
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,
);
}
}
+4 -2
View File
@@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/domain/services/generic_website.dart'; import 'package:weblibre/domain/services/generic_website.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
class UrlIcon extends HookConsumerWidget { class UrlIcon extends HookConsumerWidget {
final double iconSize; final double iconSize;
@@ -46,11 +47,12 @@ class UrlIcon extends HookConsumerWidget {
dimension: iconSize, dimension: iconSize,
child: (icon.data != null) child: (icon.data != null)
? RepaintBoundary( ? RepaintBoundary(
child: RawImage( child: SafeRawImage(
image: icon.data?.image.value, image: icon.data?.image,
height: iconSize, height: iconSize,
width: iconSize, width: iconSize,
fit: BoxFit.fill, fit: BoxFit.fill,
fallback: Icon(MdiIcons.web, size: iconSize),
), ),
) )
: Icon(MdiIcons.web, size: iconSize), : Icon(MdiIcons.web, size: iconSize),
@@ -25,6 +25,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/presentation/controllers/website_title.dart'; import 'package:weblibre/presentation/controllers/website_title.dart';
import 'package:weblibre/presentation/widgets/failure_widget.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'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class WebsiteTitleTile extends HookConsumerWidget { class WebsiteTitleTile extends HookConsumerWidget {
@@ -45,10 +46,11 @@ class WebsiteTitleTile extends HookConsumerWidget {
leading: RepaintBoundary( leading: RepaintBoundary(
child: child:
info.favicon.mapNotNull( info.favicon.mapNotNull(
(favicon) => RawImage( (favicon) => SafeRawImage(
image: favicon.image.value, image: favicon.image,
height: 24, height: 24,
width: 24, width: 24,
fallback: const Icon(MdiIcons.web, size: 24),
), ),
) ?? ) ??
const Icon(MdiIcons.web, size: 24), const Icon(MdiIcons.web, size: 24),
@@ -71,8 +73,8 @@ class WebsiteTitleTile extends HookConsumerWidget {
); );
}, },
loading: () => ListTile( loading: () => ListTile(
leading: RawImage( leading: SafeRawImage(
image: initialTabState.favicon?.image.value, image: initialTabState.favicon?.image,
height: 24, height: 24,
width: 24, width: 24,
), ),