From 561469b82608b977b43b90428cb24921bfc31722 Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:59:29 +0100 Subject: [PATCH] fix(mobile): handle image stream completion when no image is emitted (#25984) * Fix image cancellation to be stream-scoped instead of widget-scoped * fix(OneFramePlaceholderImageStreamCompleter): make onLastListenerRemoved callback synchronous with removing the last listener * fix(OneFrameMultiImageStreamCompleter): remove unnecessary blank line in code * fix(OneFramePlaceholderImageStreamCompleter): cancel pending requests when only cache listener remains * fix(OneFrameMultiImageStreamCompleter): ensure onLastListenerRemoved callback is invoked only once --- .../widgets/images/local_image_provider.dart | 4 +- ...ne_frame_multi_image_stream_completer.dart | 39 ++++++++++++++----- .../widgets/images/remote_image_provider.dart | 4 +- .../widgets/images/thumb_hash_provider.dart | 2 +- .../widgets/images/thumbnail.widget.dart | 10 ----- 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index d7454c0c89..03b9370190 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -31,7 +31,7 @@ class LocalThumbProvider extends CancellableImageProvider DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } @@ -76,7 +76,7 @@ class LocalFullImageProvider extends CancellableImageProvider('Id', key.id), DiagnosticsProperty('Size', key.size), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } diff --git a/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart index 6d549d4fda..302deca4a7 100644 --- a/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart +++ b/mobile/lib/presentation/widgets/images/one_frame_multi_image_stream_completer.dart @@ -9,7 +9,10 @@ import 'package:flutter/painting.dart'; /// An ImageStreamCompleter with support for loading multiple images. class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { - void Function()? _onDispose; + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + // True once setImage() has been called at least once. + bool didProvideImage = false; /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). @@ -19,14 +22,18 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { Stream images, { ImageInfo? initialImage, InformationCollector? informationCollector, - void Function()? onDispose, + void Function()? onLastListenerRemoved, }) { if (initialImage != null) { + didProvideImage = true; setImage(initialImage); } - _onDispose = onDispose; + _onLastListenerRemoved = onLastListenerRemoved; images.listen( - setImage, + (image) { + didProvideImage = true; + setImage(image); + }, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving a single-frame image stream'), @@ -40,12 +47,24 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { } @override - void onDisposed() { - final onDispose = _onDispose; - if (onDispose != null) { - _onDispose = null; - onDispose(); + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount = _listenerCount + 1; + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount = _listenerCount - 1; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage; + + final onLastListenerRemoved = _onLastListenerRemoved; + + if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); } - super.onDisposed(); } } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 6cb68c1442..20db0cc1e1 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -32,7 +32,7 @@ class RemoteImageProvider extends CancellableImageProvider DiagnosticsProperty('Image provider', this), DiagnosticsProperty('URL', key.url), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } @@ -76,7 +76,7 @@ class RemoteFullImageProvider extends CancellableImageProvider('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), ], - onDispose: cancel, + onLastListenerRemoved: cancel, ); } diff --git a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart index fcd2fca72f..7076febe3b 100644 --- a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart +++ b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart @@ -17,7 +17,7 @@ class ThumbHashProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel); + return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel); } Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index d35dd181db..70a9057e12 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -233,16 +233,6 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix @override void dispose() { - final imageProvider = widget.imageProvider; - if (imageProvider is CancellableImageProvider) { - imageProvider.cancel(); - } - - final thumbhashProvider = widget.thumbhashProvider; - if (thumbhashProvider is CancellableImageProvider) { - thumbhashProvider.cancel(); - } - _fadeController.removeStatusListener(_onAnimationStatusChanged); _fadeController.dispose(); _stopListeningToStream();