From c997c659a8481aca31e521b73c10ff734dcea49e Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:36:18 +0100 Subject: [PATCH 1/5] Fix image cancellation to be stream-scoped instead of widget-scoped --- .../widgets/images/local_image_provider.dart | 4 ++-- ...ne_frame_multi_image_stream_completer.dart | 19 ++++++------------- .../widgets/images/remote_image_provider.dart | 4 ++-- .../widgets/images/thumb_hash_provider.dart | 2 +- .../widgets/images/thumbnail.widget.dart | 10 ---------- 5 files changed, 11 insertions(+), 28 deletions(-) diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index d7454c0c89a33..03b937019014f 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 6d549d4fda5bd..608da59bd0617 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,8 +9,6 @@ import 'package:flutter/painting.dart'; /// An ImageStreamCompleter with support for loading multiple images. class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { - void Function()? _onDispose; - /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). /// The [initialImage] is an optional image that will be emitted synchronously @@ -19,12 +17,11 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { Stream images, { ImageInfo? initialImage, InformationCollector? informationCollector, - void Function()? onDispose, + void Function()? onLastListenerRemoved, }) { if (initialImage != null) { setImage(initialImage); } - _onDispose = onDispose; images.listen( setImage, onError: (Object error, StackTrace stack) { @@ -37,15 +34,11 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { ); }, ); - } - @override - void onDisposed() { - final onDispose = _onDispose; - if (onDispose != null) { - _onDispose = null; - onDispose(); - } - super.onDisposed(); + addOnLastListenerRemovedCallback(() { + if (onLastListenerRemoved != null) { + onLastListenerRemoved(); + } + }); } } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 6cb68c1442c68..20db0cc1e1070 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 fcd2fca72f931..7076febe3b3c3 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 d35dd181dbee2..70a9057e1247a 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(); From 52785918938b8725660de5ed7ee403b149ddad0a Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:30:16 +0100 Subject: [PATCH 2/5] fix(OneFramePlaceholderImageStreamCompleter): make onLastListenerRemoved callback synchronous with removing the last listener --- ...ne_frame_multi_image_stream_completer.dart | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) 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 608da59bd0617..92172ab54aeec 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,6 +9,10 @@ import 'package:flutter/painting.dart'; /// An ImageStreamCompleter with support for loading multiple images. class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + + /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). /// The [initialImage] is an optional image that will be emitted synchronously @@ -22,6 +26,7 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { if (initialImage != null) { setImage(initialImage); } + _onLastListenerRemoved = onLastListenerRemoved; images.listen( setImage, onError: (Object error, StackTrace stack) { @@ -34,11 +39,28 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { ); }, ); + } + + @override + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount++; + } - addOnLastListenerRemovedCallback(() { - if (onLastListenerRemoved != null) { - onLastListenerRemoved(); - } - }); + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount--; + if (_listenerCount == 0) { + onLastListenerRemovedImmediately(); + } + } + + void onLastListenerRemovedImmediately() { + final onLastListenerRemoved = _onLastListenerRemoved; + if (onLastListenerRemoved != null) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); + } } } From 821898fcc8c1d372803d726d06f2532324577b5f Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:36:02 +0100 Subject: [PATCH 3/5] fix(OneFrameMultiImageStreamCompleter): remove unnecessary blank line in code --- .../widgets/images/one_frame_multi_image_stream_completer.dart | 1 - 1 file changed, 1 deletion(-) 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 92172ab54aeec..f2d0d2ed6d7ea 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 @@ -12,7 +12,6 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { void Function()? _onLastListenerRemoved; int _listenerCount = 0; - /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// should be the primary images to display (typically asynchronously as they load). /// The [initialImage] is an optional image that will be emitted synchronously From 81f44930321da194f57af12daa44e10c577043e9 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:10:40 +0100 Subject: [PATCH 4/5] fix(OneFramePlaceholderImageStreamCompleter): cancel pending requests when only cache listener remains --- ...ne_frame_multi_image_stream_completer.dart | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 f2d0d2ed6d7ea..aef75677b8191 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 @@ -11,6 +11,8 @@ import 'package:flutter/painting.dart'; class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { 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). @@ -23,11 +25,15 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { void Function()? onLastListenerRemoved, }) { if (initialImage != null) { + didProvideImage = true; setImage(initialImage); } _onLastListenerRemoved = onLastListenerRemoved; images.listen( - setImage, + (image) { + didProvideImage = true; + setImage(image); + }, onError: (Object error, StackTrace stack) { reportError( context: ErrorDescription('resolving a single-frame image stream'), @@ -43,23 +49,19 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { @override void addListener(ImageStreamListener listener) { super.addListener(listener); - _listenerCount++; + _listenerCount = _listenerCount + 1; } @override void removeListener(ImageStreamListener listener) { super.removeListener(listener); - _listenerCount--; - if (_listenerCount == 0) { - onLastListenerRemovedImmediately(); - } - } + _listenerCount = _listenerCount - 1; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage; - void onLastListenerRemovedImmediately() { - final onLastListenerRemoved = _onLastListenerRemoved; - if (onLastListenerRemoved != null) { - _onLastListenerRemoved = null; - onLastListenerRemoved(); + if (noListenersAfterImage || onlyCacheListenerLeft) { + _onLastListenerRemoved?.call(); } } } From 86b560c60f7ff293d9108742d3a6fe65091ccdfd Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 9 Feb 2026 02:28:49 +0100 Subject: [PATCH 5/5] fix(OneFrameMultiImageStreamCompleter): ensure onLastListenerRemoved callback is invoked only once --- .../images/one_frame_multi_image_stream_completer.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 aef75677b8191..302deca4a7a1f 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 @@ -60,8 +60,11 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage; - if (noListenersAfterImage || onlyCacheListenerLeft) { - _onLastListenerRemoved?.call(); + final onLastListenerRemoved = _onLastListenerRemoved; + + if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); } } }