Skip to content
Merged
6 changes: 6 additions & 0 deletions cached_network_image/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [4.0.6] - 2026-10-29

* Animated images no longer leak decoded frames or codecs when removed, including when released from the image cache
* A newer refreshed image is no longer replaced by an older pending refresh
* Refreshed images appear after playback finishes or resumes, without cutting a displayed animation frame short

## [4.0.5] - 2026-10-17

* Stop decoding a static image again each time a listener is re-added, which blanked images on web (Firefox and Safari) after navigating back to a route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
/// should be a stream with the images that should be shown. The
/// [chunkEvents] should indicate the [ImageChunkEvent]s of the first image
/// to show.
///
/// Takes ownership of every codec delivered on [codec]; do not reuse them
/// elsewhere. Replaced codecs, codecs held at disposal, and late results
/// are disposed by this completer.
MultiImageStreamCompleter({
required Stream<ui.Codec> codec,
required this._scale,
Expand All @@ -23,10 +27,12 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
InformationCollector? informationCollector,
}) : _informationCollector = informationCollector {
this.debugLabel = debugLabel;
codec.listen(
_codecSubscription = codec.listen(
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
(event) {
if (_timer != null) {
_nextImageCodec = event;
if (_disposed) {
event.dispose();
} else if (_timer != null) {
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
_replacePendingCodec(event);
} else {
_handleCodecReady(event);
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
}
Expand Down Expand Up @@ -73,31 +79,32 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
int _framesEmitted = 0;
bool _isDecoding = false;
Timer? _timer;
StreamSubscription<ui.Codec>? _codecSubscription;
StreamSubscription<ImageChunkEvent>? _chunkSubscription;

// Used to guard against registering multiple _handleAppFrame callbacks for the same frame.
bool _frameCallbackScheduled = false;

/// We must avoid disposing a completer if it never had a listener, even
/// if all [keepAlive] handles get disposed.
bool __hadAtLeastOneListener = false;

bool __disposed = false;
// Guards asynchronous work after the base class invokes onDisposed.
bool _disposed = false;

void _switchToNewCodec() {
_timer = null;
_handleCodecReady(_nextImageCodec!);
_nextImageCodec = null;
}

void _handleCodecReady(ui.Codec codec) {
// Every activation supersedes a pending refresh, even when listener
// removal cancelled its timer before a newer codec arrived.
_replacePendingCodec(codec);
_nextImageCodec = null;
if (_codec != codec) _codec?.dispose();
_codec = codec;
_framesEmitted = 0;
_isDecoding = false;
// A frame still waiting from the previous codec must not be shown as part
// of this one.
_nextFrame?.image.dispose();
_nextFrame = null;
_discardNextFrame();

if (hasListeners) {
_decodeNextFrameAndSchedule();
Expand All @@ -108,23 +115,26 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
_frameCallbackScheduled = false;
if (!hasListeners || _nextFrame == null) return;
if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) {
// Transfer ownership before callbacks can remove the last listener.
final frame = _nextFrame!;
final codec = _codec!;
_nextFrame = null;
_emitFrame(
ImageInfo(
image: _nextFrame!.image,
scale: _scale,
debugLabel: debugLabel,
),
ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel),
);
_shownTimestamp = timestamp;
_frameDuration = _nextFrame!.duration;
_nextFrame = null;
_frameDuration = frame.duration;
if (_disposed || _codec != codec) return;
if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) {
_switchToNewCodec();
} else {
if (!hasListeners) return;
final completedCycles = _framesEmitted ~/ _codec!.frameCount;
if (_codec!.repetitionCount == -1 ||
completedCycles <= _codec!.repetitionCount) {
_decodeNextFrameAndSchedule();
} else {
_timer = null;
}
}
return;
Expand All @@ -151,9 +161,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
try {
frame = await codec.getNextFrame();
} on Object catch (exception, stack) {
if (_codec == codec) {
_isDecoding = false;
}
if (_disposed || _codec != codec) return;
_isDecoding = false;
reportError(
context: ErrorDescription('resolving an image frame'),
exception: exception,
Expand All @@ -163,16 +172,12 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
);
return;
}
// A newer codec replaced this one while decoding; it resets _isDecoding.
if (_codec != codec) {
// Replacing the codec or disposing the completer invalidates this frame.
if (_disposed || _codec != codec) {
frame.image.dispose();
return;
}
_isDecoding = false;
if (__disposed) {
frame.image.dispose();
return;
}
if (codec.frameCount == 1) {
// This is not an animated image, just return it and don't schedule more
// frames.
Expand All @@ -181,7 +186,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
);
return;
}
_nextFrame?.image.dispose();
_discardNextFrame();
_nextFrame = frame;
_scheduleAppFrame();
}
Expand All @@ -195,13 +200,13 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
}

void _emitFrame(ImageInfo imageInfo) {
setImage(imageInfo);
// Callbacks may synchronously activate a new codec and reset this count.
_framesEmitted += 1;
setImage(imageInfo);
}

@override
void addListener(ImageStreamListener listener) {
__hadAtLeastOneListener = true;
if (!hasListeners && _codec != null) {
if (_nextFrame != null) {
// Show the frame decoded while nobody listened instead of skipping it.
Expand All @@ -219,56 +224,40 @@ class MultiImageStreamCompleter extends ImageStreamCompleter {
if (!hasListeners) {
_timer?.cancel();
_timer = null;
__maybeDispose();
}
}

int __keepAliveHandles = 0;

@override
ImageStreamCompleterHandle keepAlive() {
final delegateHandle = super.keepAlive();
return _MultiImageStreamCompleterHandle(this, delegateHandle);
void _replacePendingCodec(ui.Codec? next) {
final previous = _nextImageCodec;
_nextImageCodec = next;
if (previous != next && previous != _codec) previous?.dispose();
}

void __maybeDispose() {
if (!__hadAtLeastOneListener ||
__disposed ||
hasListeners ||
__keepAliveHandles != 0) {
return;
}

__disposed = true;

_chunkSubscription?.onData(null);
_chunkSubscription?.cancel();
_chunkSubscription = null;
void _discardNextFrame() {
_nextFrame?.image.dispose();
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
_nextFrame = null;
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
}
}

class _MultiImageStreamCompleterHandle implements ImageStreamCompleterHandle {
_MultiImageStreamCompleterHandle(this._completer, this._delegateHandle) {
_completer!.__keepAliveHandles += 1;
}
static void _disposeCodec(ui.Codec codec) => codec.dispose();

MultiImageStreamCompleter? _completer;
final ImageStreamCompleterHandle _delegateHandle;
static void _ignoreCodecError(Object error, StackTrace stack) {}

/// Call this method to signal the [ImageStreamCompleter] that it can now be
/// disposed when its last listener drops.
///
/// This method must only be called once per object.
@override
void dispose() {
assert(_completer != null);
assert(_completer!.__keepAliveHandles > 0);
assert(!_completer!.__disposed);

_delegateHandle.dispose();

_completer!.__keepAliveHandles -= 1;
_completer!.__maybeDispose();
_completer = null;
void onDisposed() {
super.onDisposed();
_disposed = true;
_timer?.cancel();
_timer = null;
_discardNextFrame();
// Cancelling can drop a codec still being created by the producer. Drain
// late results with static callbacks so the stream cannot retain us.
_codecSubscription?.onData(_disposeCodec);
Comment thread
kevmo314 marked this conversation as resolved.
Resolved
_codecSubscription?.onError(_ignoreCodecError);
_codecSubscription = null;
_chunkSubscription?.cancel();
_chunkSubscription = null;
_replacePendingCodec(null);
_codec?.dispose();
_codec = null;
}
}
2 changes: 1 addition & 1 deletion cached_network_image/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ topics:
- cache
- image
- network-image
version: 4.0.5
version: 4.0.6

environment:
sdk: ^3.12.0
Expand Down
Loading