fix(mobile): images loads sometimes cancel too early (#27067)

* refactor listener tracking for image stream completers and fix early cancel call

* fix: improve cache listener identification in image stream tracking

* add documentation and test cases for listener tracking in ImageStreamCompleter

* fix: remove unnecessary image provision flag from listener tracking

* fix: override setImage method in cache aware listener tracker mixin

* fix: rename test file
This commit is contained in:
Luis Nachtigall
2026-03-27 15:35:50 +01:00
committed by GitHub
parent be0fc403d8
commit 306a3b8c7f
4 changed files with 279 additions and 66 deletions
@@ -3,24 +3,21 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show InformationCollector;
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
/// which makes resource cleanup possible when no longer needed.
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once any image or the codec has been provided.
// Until then the image cache holds one listener, so "last real listener gone"
// is _listenerCount == 1, not 0.
bool didProvideImage = false;
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter with CacheAwareListenerTrackerMixin {
AnimatedImageStreamCompleter._({
required super.codec,
required super.scale,
required bool hadInitialImage,
super.informationCollector,
void Function()? onLastListenerRemoved,
}) : _onLastListenerRemoved = onLastListenerRemoved;
}) {
setupListenerTracking(hadInitialImage: hadInitialImage, onLastListenerRemoved: onLastListenerRemoved);
}
factory AnimatedImageStreamCompleter({
required Stream<Object> stream,
@@ -33,23 +30,21 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
final self = AnimatedImageStreamCompleter._(
codec: codecCompleter.future,
scale: scale,
hadInitialImage: initialImage != null,
informationCollector: informationCollector,
onLastListenerRemoved: onLastListenerRemoved,
);
if (initialImage != null) {
self.didProvideImage = true;
self.setImage(initialImage);
}
stream.listen(
(item) {
if (item is ImageInfo) {
self.didProvideImage = true;
self.setImage(item);
} else if (item is ui.Codec) {
if (!codecCompleter.isCompleted) {
self.didProvideImage = true;
codecCompleter.complete(item);
}
}
@@ -70,27 +65,4 @@ class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
return self;
}
@override
void addListener(ImageStreamListener listener) {
super.addListener(listener);
_listenerCount++;
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
if (onlyCacheListenerLeft || noListenersAfterCodec) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/painting.dart';
/// Tracks listeners on an [ImageStreamCompleter] to safely cancel in-flight
/// network requests without interfering with [ImageCache] internals.
///
/// ### Problem
/// Cancelling fetches when the listener count drops to 1 (cache only) or 0
/// is unsafe due to three framework behaviours:
///
/// 1. **Memory-pressure eviction** — `ImageCache.clear()` removes the cache
/// listener while UI widgets still need the image. A count-based check
/// would cancel the active fetch, leaving the UI with no image.
/// 2. **Synchronous detach during `putIfAbsent`** — When an `initialImage`
/// is provided, the cache attaches, receives the frame, and detaches
/// synchronously *before* the UI widget can attach. Count reaches 0 and
/// would trigger a false cancel.
/// 3. **Listener misidentification** — After the cache detaches (via 1 or 2),
/// the next UI listener could be mistaken for the cache listener, causing
/// incorrect cancellations when that widget is disposed.
///
/// ### Solution: First-Listener Heuristic
/// The cache is always the first listener attached (via `putIfAbsent`). This
/// mixin records that identity once and uses it for all subsequent decisions:
///
/// * **Identity locking** — The first listener is assumed to be the cache.
/// Once identified, `_hasIdentifiedCacheListener` prevents reassignment.
/// * **Targeted cancellation** — Cancel only when the identified cache
/// listener is the sole remaining listener and no image has been delivered.
/// * **Sync-removal bypass** — When `hadInitialImage` is set, the first
/// synchronous removal of the cache listener is ignored so the fetch
/// survives until the UI attaches.
mixin CacheAwareListenerTrackerMixin on ImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
bool _hadInitialImage = false;
bool _hasIgnoredFirstSyncRemoval = false;
ImageStreamListener? _cacheListener;
bool _hasIdentifiedCacheListener = false;
/// Initializes the tracking state. Must be called in the subclass constructor.
void setupListenerTracking({required bool hadInitialImage, void Function()? onLastListenerRemoved}) {
_hadInitialImage = hadInitialImage;
_onLastListenerRemoved = onLastListenerRemoved;
}
@override
void addListener(ImageStreamListener listener) {
if (!_hasIdentifiedCacheListener) {
_hasIdentifiedCacheListener = true;
_cacheListener = listener;
}
_listenerCount++;
super.addListener(listener);
}
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount--;
final bool isCacheListener = listener == _cacheListener;
if (isCacheListener) {
_cacheListener = null;
}
if (_hadInitialImage && !_hasIgnoredFirstSyncRemoval && isCacheListener) {
_hasIgnoredFirstSyncRemoval = true;
return;
}
final bool onlyCacheListenerLeft = _listenerCount == 1 && _cacheListener != null;
final bool completelyAbandoned = _listenerCount == 0;
if (onlyCacheListenerLeft || completelyAbandoned) {
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
}
}
}
}
@@ -6,14 +6,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/cache_aware_listener_tracker.mixin.dart';
/// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter with CacheAwareListenerTrackerMixin {
/// 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
@@ -24,14 +20,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
InformationCollector? informationCollector,
void Function()? onLastListenerRemoved,
}) {
setupListenerTracking(hadInitialImage: initialImage != null, onLastListenerRemoved: onLastListenerRemoved);
if (initialImage != null) {
didProvideImage = true;
setImage(initialImage);
}
_onLastListenerRemoved = onLastListenerRemoved;
images.listen(
(image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) {
@@ -45,26 +41,4 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
},
);
}
@override
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();
}
}
}