From 9c43970137b050b1c0dcc9186fa8bf9403665c11 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 14 Oct 2026 04:03:29 +0300 Subject: [PATCH 01/15] fix: Avoid decoding static images again when a listener is re-added --- cached_network_image/CHANGELOG.md | 4 +++ .../multi_image_stream_completer.dart | 6 +++- cached_network_image/pubspec.yaml | 2 +- .../test/image_stream_completer_test.dart | 32 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index a70f48b0..b8f68328 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,3 +1,7 @@ +## [4.0.5] - 2026-10-14 + +* Stop decoding a static image again each time a listener is re-added, which blanked images on web after navigating back + ## [4.0.4] - 2026-09-30 * Bump `flutter_cache_manager` to `^3.4.5`, `material_ui` to `^1.3.0`, `octo_image` to `^2.1.1` and `mocktail` to `^1.0.5` diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 88504680..d84ffdfc 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -186,7 +186,11 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { @override void addListener(ImageStreamListener listener) { __hadAtLeastOneListener = true; - if (!hasListeners && _codec != null) _decodeNextFrameAndSchedule(); + if (!hasListeners && + _codec != null && + (_framesEmitted == 0 || _codec!.frameCount > 1)) { + _decodeNextFrameAndSchedule(); + } super.addListener(listener); } diff --git a/cached_network_image/pubspec.yaml b/cached_network_image/pubspec.yaml index 95c18e5c..677ef082 100644 --- a/cached_network_image/pubspec.yaml +++ b/cached_network_image/pubspec.yaml @@ -6,7 +6,7 @@ topics: - cache - image - network-image -version: 4.0.4 +version: 4.0.5 environment: sdk: ^3.12.0 diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 00abd8cd..a436326e 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -718,6 +718,38 @@ void main() { handle.dispose(); }); + testWidgets( + 'static images are not decoded again when a listener is re-added', + (WidgetTester tester) async { + final mockCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + var emittedImages = 0; + listener(ImageInfo image, bool synchronousCall) => emittedImages += 1; + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + mockCodec.completeNextFrame(FakeFrameInfo(Duration.zero, image20x10)); + await tester.idle(); + + imageStream.removeListener(ImageStreamListener(listener)); + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + + expect(mockCodec.numFramesAsked, 1); + expect(emittedImages, 2); + + handle.dispose(); + }, + ); + testWidgets('multiple stream listeners', (WidgetTester tester) async { final mockCodec = MockCodec(); mockCodec.frameCount = 2; From a74d0f4911a9f5a8a7072ccabd081295064cf2aa Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 04:28:53 +0300 Subject: [PATCH 02/15] test: Add tests for image stream completer behavior with listeners and its states --- .../test/image_stream_completer_test.dart | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index a436326e..17685f2d 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -1061,4 +1061,192 @@ void main() { expect(firstCodec.numFramesAsked, 3); expect(secondCodec.numFramesAsked, 1); }); + + testWidgets('animated images resume decoding when a listener is re-added', ( + WidgetTester tester, + ) async { + final mockCodec = MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + var emittedImages = 0; + listener(ImageInfo image, bool synchronousCall) => emittedImages += 1; + imageStream.addListener(ImageStreamListener(listener)); + + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + + final FrameInfo frame1 = FakeFrameInfo( + const Duration(milliseconds: 200), + image20x10, + ); + final FrameInfo frame2 = FakeFrameInfo( + const Duration(milliseconds: 400), + image200x100, + ); + + // Complete and emit 1st frame (which also requests 2nd frame) + mockCodec.completeNextFrame(frame1); + await tester.idle(); + await tester.pump(); + expect(emittedImages, 1); + expect(mockCodec.numFramesAsked, 2); + + // Complete 2nd frame while listener is removed + mockCodec.completeNextFrame(frame2); + imageStream.removeListener(ImageStreamListener(listener)); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 400)); + + // Still 2 frames asked while no listener is attached + expect(mockCodec.numFramesAsked, 2); + + // Re-adding listener asks for a new frame because frameCount > 1 + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + expect(mockCodec.numFramesAsked, 3); + expect(emittedImages, 2); + + // Complete the new frame and verify animation continues emitting + final FrameInfo frame3 = FakeFrameInfo( + const Duration(milliseconds: 200), + image300x100, + ); + mockCodec.completeNextFrame(frame3); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 400)); + expect(emittedImages, 3); + + // Clean up pending animation timer + imageStream.removeListener(ImageStreamListener(listener)); + handle.dispose(); + }); + + testWidgets( + 'second static codec arriving with no listeners is decoded when listener is re-added', + (WidgetTester tester) async { + final firstCodec = MockCodec(); + final secondCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(firstCodec); + await tester.idle(); + firstCodec.completeNextFrame(FakeFrameInfo(Duration.zero, image20x10)); + await tester.idle(); + + expect(firstCodec.numFramesAsked, 1); + expect(emittedImages.length, 1); + + imageStream.removeListener(ImageStreamListener(listener)); + + codecStream.add(secondCodec); + await tester.idle(); + expect(secondCodec.numFramesAsked, 0); + + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + expect(secondCodec.numFramesAsked, 1); + + final secondFrame = FakeFrameInfo(Duration.zero, image200x100); + secondCodec.completeNextFrame(secondFrame.clone()); + await tester.idle(); + + expect(emittedImages.last.image.isCloneOf(secondFrame.image), true); + + handle.dispose(); + }, + ); + + testWidgets( + 'static image finishing decode without listeners is kept for next listener', + (WidgetTester tester) async { + final mockCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + var emittedImages = 0; + listener(ImageInfo image, bool synchronousCall) => emittedImages += 1; + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + expect(mockCodec.numFramesAsked, 1); + + imageStream.removeListener(ImageStreamListener(listener)); + + mockCodec.completeNextFrame(FakeFrameInfo(Duration.zero, image20x10)); + await tester.idle(); + expect(emittedImages, 0); + + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + + expect(mockCodec.numFramesAsked, 1); + expect(emittedImages, 1); + + handle.dispose(); + }, + ); + + testWidgets( + 're-adding listener while static decode is in flight does not decode twice', + (WidgetTester tester) async { + final mockCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + var emittedImages = 0; + listener(ImageInfo image, bool synchronousCall) => emittedImages += 1; + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + expect(mockCodec.numFramesAsked, 1); + + imageStream.removeListener(ImageStreamListener(listener)); + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + + expect(mockCodec.numFramesAsked, 1); + + mockCodec.completeNextFrame(FakeFrameInfo(Duration.zero, image20x10)); + await tester.idle(); + + expect(mockCodec.numFramesAsked, 1); + expect(emittedImages, 1); + + handle.dispose(); + }, + ); } From 4f5da8f36c17c0b0a8ea863cae4b6518c9ff85bf Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 04:36:53 +0300 Subject: [PATCH 03/15] fix: Prevent redundant decoding of static images during listener re-addition --- .../multi_image_stream_completer.dart | 77 ++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index d84ffdfc..3a74f12c 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -71,6 +71,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { // How many frames have been emitted so far. int _framesEmitted = 0; + bool _currentCodecHasImage = false; + bool _isDecoding = false; Timer? _timer; StreamSubscription? _chunkSubscription; @@ -86,12 +88,16 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _switchToNewCodec() { _framesEmitted = 0; _timer = null; + _currentCodecHasImage = false; + _codec = _nextImageCodec; _handleCodecReady(_nextImageCodec!); _nextImageCodec = null; } void _handleCodecReady(ui.Codec codec) { _codec = codec; + _currentCodecHasImage = false; + _isDecoding = false; if (hasListeners) { _decodeNextFrameAndSchedule(); @@ -136,38 +142,51 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { } Future _decodeNextFrameAndSchedule() async { - try { - _nextFrame = await _codec!.getNextFrame(); - } on Object catch (exception, stack) { - reportError( - context: ErrorDescription('resolving an image frame'), - exception: exception, - stack: stack, - informationCollector: _informationCollector, - silent: true, - ); + if (_isDecoding) { return; } - if (_codec!.frameCount == 1) { - // ImageStreamCompleter listeners removed while waiting for next frame to - // be decoded. - // There's no reason to emit the frame without active listeners. - if (!hasListeners) { + _isDecoding = true; + final codec = _codec; + try { + try { + _nextFrame = await codec!.getNextFrame(); + } on Object catch (exception, stack) { + reportError( + context: ErrorDescription('resolving an image frame'), + exception: exception, + stack: stack, + informationCollector: _informationCollector, + silent: true, + ); return; } - - // This is not an animated image, just return it and don't schedule more - // frames. - _emitFrame( - ImageInfo( - image: _nextFrame!.image, - scale: _scale, - debugLabel: debugLabel, - ), - ); - return; + if (_codec != codec || __disposed) { + _nextFrame?.image.dispose(); + _nextFrame = null; + return; + } + if (_codec!.frameCount == 1) { + // This is not an animated image, just return it and don't schedule more + // frames. + if (!__disposed) { + _emitFrame( + ImageInfo( + image: _nextFrame!.image.clone(), + scale: _scale, + debugLabel: debugLabel, + ), + ); + } + _nextFrame!.image.dispose(); + _nextFrame = null; + return; + } + _scheduleAppFrame(); + } finally { + if (_codec == codec) { + _isDecoding = false; + } } - _scheduleAppFrame(); } void _scheduleAppFrame() { @@ -180,6 +199,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _emitFrame(ImageInfo imageInfo) { setImage(imageInfo); + _currentCodecHasImage = true; _framesEmitted += 1; } @@ -188,7 +208,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { __hadAtLeastOneListener = true; if (!hasListeners && _codec != null && - (_framesEmitted == 0 || _codec!.frameCount > 1)) { + !_isDecoding && + (!_currentCodecHasImage || _codec!.frameCount > 1)) { _decodeNextFrameAndSchedule(); } super.addListener(listener); From b17ca65dc9ed9fa1384ec290b4b30aca5b50f57f Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 04:37:17 +0300 Subject: [PATCH 04/15] chore: Bump version to 4.0.6 --- cached_network_image/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cached_network_image/pubspec.yaml b/cached_network_image/pubspec.yaml index 677ef082..3e87f507 100644 --- a/cached_network_image/pubspec.yaml +++ b/cached_network_image/pubspec.yaml @@ -6,7 +6,7 @@ topics: - cache - image - network-image -version: 4.0.5 +version: 4.0.6 environment: sdk: ^3.12.0 From 80a2766df922aeba54aaea80ccc71551f263977f Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 04:40:33 +0300 Subject: [PATCH 05/15] docs(changelog): Document 4.0.6 for static image re-decode fix --- cached_network_image/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index b8f68328..792c9559 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,3 +1,7 @@ +## [4.0.6] - 2026-10-17 + +* Fix blank images on Firefox and Safari after route navigation by avoiding re-decoding already-decoded static images when a listener is re-added. + ## [4.0.5] - 2026-10-14 * Stop decoding a static image again each time a listener is re-added, which blanked images on web after navigating back From f807f1cb240fbafa2dbc3de382b3521e4e3d2214 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 11:34:02 +0300 Subject: [PATCH 06/15] fix: Simplify disposal logic for static images during decoding --- .../multi_image_stream_completer.dart | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 3a74f12c..25d05a2d 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -88,8 +88,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _switchToNewCodec() { _framesEmitted = 0; _timer = null; - _currentCodecHasImage = false; - _codec = _nextImageCodec; _handleCodecReady(_nextImageCodec!); _nextImageCodec = null; } @@ -146,47 +144,44 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { return; } _isDecoding = true; - final codec = _codec; + final codec = _codec!; + final ui.FrameInfo frame; try { - try { - _nextFrame = await codec!.getNextFrame(); - } on Object catch (exception, stack) { - reportError( - context: ErrorDescription('resolving an image frame'), - exception: exception, - stack: stack, - informationCollector: _informationCollector, - silent: true, - ); - return; - } - if (_codec != codec || __disposed) { - _nextFrame?.image.dispose(); - _nextFrame = null; - return; - } - if (_codec!.frameCount == 1) { - // This is not an animated image, just return it and don't schedule more - // frames. - if (!__disposed) { - _emitFrame( - ImageInfo( - image: _nextFrame!.image.clone(), - scale: _scale, - debugLabel: debugLabel, - ), - ); - } - _nextFrame!.image.dispose(); - _nextFrame = null; - return; - } - _scheduleAppFrame(); - } finally { + frame = await codec.getNextFrame(); + } on Object catch (exception, stack) { if (_codec == codec) { _isDecoding = false; } + reportError( + context: ErrorDescription('resolving an image frame'), + exception: exception, + stack: stack, + informationCollector: _informationCollector, + silent: true, + ); + return; } + // A newer codec replaced this one while decoding; it resets _isDecoding. + if (_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. + _emitFrame( + ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel), + ); + return; + } + _nextFrame?.image.dispose(); + _nextFrame = frame; + _scheduleAppFrame(); } void _scheduleAppFrame() { @@ -198,8 +193,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { } void _emitFrame(ImageInfo imageInfo) { - setImage(imageInfo); _currentCodecHasImage = true; + setImage(imageInfo); _framesEmitted += 1; } From 47649c712fdaeeb54bd21563b568768d8244ce47 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Sat, 17 Oct 2026 11:42:20 +0300 Subject: [PATCH 07/15] docs(changelog): Replace version 4.0.6 to 4.0.5 and clarify static image decoding issues --- cached_network_image/CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 792c9559..600043c8 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,10 +1,8 @@ -## [4.0.6] - 2026-10-17 +## [4.0.5] - 2026-10-17 -* Fix blank images on Firefox and Safari after route navigation by avoiding re-decoding already-decoded static images when a listener is re-added. - -## [4.0.5] - 2026-10-14 - -* Stop decoding a static image again each time a listener is re-added, which blanked images on web after navigating back +* 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 +* Ignore listener re-adds while a frame is already being decoded, and retry decoding after a failed decode +* Add tests for static and animated listener re-add, failed decodes and codec switches during decoding ## [4.0.4] - 2026-09-30 From 2d941ac60cfbb3777cf3a2409485921020fc854f Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:11:36 +0300 Subject: [PATCH 08/15] fix: Reset emitted frame count when a new codec is ready --- .../lib/src/image_provider/multi_image_stream_completer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 25d05a2d..39cbf959 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -86,7 +86,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { bool __disposed = false; void _switchToNewCodec() { - _framesEmitted = 0; _timer = null; _handleCodecReady(_nextImageCodec!); _nextImageCodec = null; @@ -94,6 +93,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _handleCodecReady(ui.Codec codec) { _codec = codec; + _framesEmitted = 0; _currentCodecHasImage = false; _isDecoding = false; From 77a350348fa297c7170aa3844b58ef90f079b2f0 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:12:06 +0300 Subject: [PATCH 09/15] refactor: Replace _currentCodecHasImage with _framesEmitted --- .../lib/src/image_provider/multi_image_stream_completer.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 39cbf959..d697043f 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -71,7 +71,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { // How many frames have been emitted so far. int _framesEmitted = 0; - bool _currentCodecHasImage = false; bool _isDecoding = false; Timer? _timer; StreamSubscription? _chunkSubscription; @@ -94,7 +93,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _handleCodecReady(ui.Codec codec) { _codec = codec; _framesEmitted = 0; - _currentCodecHasImage = false; _isDecoding = false; if (hasListeners) { @@ -193,7 +191,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { } void _emitFrame(ImageInfo imageInfo) { - _currentCodecHasImage = true; setImage(imageInfo); _framesEmitted += 1; } @@ -204,7 +201,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (!hasListeners && _codec != null && !_isDecoding && - (!_currentCodecHasImage || _codec!.frameCount > 1)) { + (_framesEmitted == 0 || _codec!.frameCount > 1)) { _decodeNextFrameAndSchedule(); } super.addListener(listener); From d43fbf88ffe36a3e40eee5587a210b56edee37ec Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:13:11 +0300 Subject: [PATCH 10/15] fix: Resume waiting animated frame when a listener is re-added --- .../multi_image_stream_completer.dart | 12 ++++--- .../test/image_stream_completer_test.dart | 33 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index d697043f..5dc443c6 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -198,11 +198,13 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { @override void addListener(ImageStreamListener listener) { __hadAtLeastOneListener = true; - if (!hasListeners && - _codec != null && - !_isDecoding && - (_framesEmitted == 0 || _codec!.frameCount > 1)) { - _decodeNextFrameAndSchedule(); + if (!hasListeners && _codec != null) { + if (_nextFrame != null) { + // Show the frame decoded while nobody listened instead of skipping it. + _scheduleAppFrame(); + } else if (_framesEmitted == 0 || _codec!.frameCount > 1) { + _decodeNextFrameAndSchedule(); + } } super.addListener(listener); } diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 17685f2d..4283ce41 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -711,8 +711,12 @@ void main() { // listeners to the stream expect(mockCodec.numFramesAsked, 2); + // Re-adding shows the waiting 2nd frame before asking for the 3rd. imageStream.addListener(ImageStreamListener(listener)); - await tester.idle(); // let nextFrameFuture complete + await tester.idle(); + expect(mockCodec.numFramesAsked, 2); + + await tester.pump(); expect(mockCodec.numFramesAsked, 3); handle.dispose(); @@ -1075,8 +1079,11 @@ void main() { scale: 1.0, ); - var emittedImages = 0; - listener(ImageInfo image, bool synchronousCall) => emittedImages += 1; + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + if (!synchronousCall) emittedImages.add(image); + } + imageStream.addListener(ImageStreamListener(listener)); final handle = imageStream.keepAlive(); @@ -1097,10 +1104,10 @@ void main() { mockCodec.completeNextFrame(frame1); await tester.idle(); await tester.pump(); - expect(emittedImages, 1); + expect(emittedImages.length, 1); expect(mockCodec.numFramesAsked, 2); - // Complete 2nd frame while listener is removed + // Complete 2nd frame while listener is removed, so it waits mockCodec.completeNextFrame(frame2); imageStream.removeListener(ImageStreamListener(listener)); await tester.idle(); @@ -1108,14 +1115,20 @@ void main() { // Still 2 frames asked while no listener is attached expect(mockCodec.numFramesAsked, 2); + expect(emittedImages.length, 1); - // Re-adding listener asks for a new frame because frameCount > 1 + // Re-adding listener shows the waiting 2nd frame instead of decoding + // another one imageStream.addListener(ImageStreamListener(listener)); await tester.idle(); + expect(mockCodec.numFramesAsked, 2); + + await tester.pump(); + expect(emittedImages.length, 2); + expect(emittedImages[1].image.isCloneOf(frame2.image), true); expect(mockCodec.numFramesAsked, 3); - expect(emittedImages, 2); - // Complete the new frame and verify animation continues emitting + // Complete the next frame and verify animation continues emitting final FrameInfo frame3 = FakeFrameInfo( const Duration(milliseconds: 200), image300x100, @@ -1123,9 +1136,9 @@ void main() { mockCodec.completeNextFrame(frame3); await tester.idle(); await tester.pump(const Duration(milliseconds: 400)); - expect(emittedImages, 3); + expect(emittedImages.length, 3); + expect(emittedImages[2].image.isCloneOf(frame3.image), true); - // Clean up pending animation timer imageStream.removeListener(ImageStreamListener(listener)); handle.dispose(); }); From 72651364d67f6560b283f9ca28c0ba46b74dec53 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:13:43 +0300 Subject: [PATCH 11/15] fix: Drop waiting animated frame when the codec changes --- .../lib/src/image_provider/multi_image_stream_completer.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 5dc443c6..7bef2cec 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -94,6 +94,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _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; if (hasListeners) { _decodeNextFrameAndSchedule(); From 4f611d53672b0eba8998f9ed324cb50a5900feb5 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:13:43 +0300 Subject: [PATCH 12/15] test: Add tests for animated re-listen, codec changes and failed decodes --- .../test/image_stream_completer_test.dart | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 4283ce41..4193b9ae 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -53,6 +53,7 @@ class MockCodec implements Codec { void failNextFrame(String err) { _nextFrameCompleter.completeError(err); + _nextFrameCompleter = Completer(); } @override @@ -1143,6 +1144,72 @@ void main() { handle.dispose(); }); + testWidgets( + 'animated frame waiting on its timer resumes when a listener is re-added', + (WidgetTester tester) async { + final mockCodec = MockCodec(); + mockCodec.frameCount = 2; + mockCodec.repetitionCount = -1; + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + if (!synchronousCall) emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + + final FrameInfo frame1 = FakeFrameInfo( + const Duration(milliseconds: 200), + image20x10, + ); + final FrameInfo frame2 = FakeFrameInfo( + const Duration(milliseconds: 200), + image200x100, + ); + + mockCodec.completeNextFrame(frame1); + await tester.idle(); + await tester.pump(); + expect(emittedImages.length, 1); + expect(mockCodec.numFramesAsked, 2); + + // The 2nd frame is ready but the 1st frame's duration hasn't passed, so + // it waits on a timer + mockCodec.completeNextFrame(frame2); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 100)); + expect(emittedImages.length, 1); + + // Removing the listener cancels the timer + imageStream.removeListener(ImageStreamListener(listener)); + await tester.pump(const Duration(milliseconds: 200)); + expect(emittedImages.length, 1); + expect(mockCodec.numFramesAsked, 2); + + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + expect(mockCodec.numFramesAsked, 2); + + await tester.pump(); + expect(emittedImages.length, 2); + expect(emittedImages[1].image.isCloneOf(frame2.image), true); + expect(mockCodec.numFramesAsked, 3); + + imageStream.removeListener(ImageStreamListener(listener)); + handle.dispose(); + }, + ); + testWidgets( 'second static codec arriving with no listeners is decoded when listener is re-added', (WidgetTester tester) async { @@ -1262,4 +1329,215 @@ void main() { handle.dispose(); }, ); + + testWidgets( + 'animated codec arriving after a static placeholder shows all its frames', + (WidgetTester tester) async { + final placeholderCodec = MockCodec(); + final animatedCodec = MockCodec(); + animatedCodec.frameCount = 3; + animatedCodec.repetitionCount = 0; + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + + codecStream.add(placeholderCodec); + await tester.idle(); + placeholderCodec.completeNextFrame( + FakeFrameInfo(Duration.zero, image20x10.clone()), + ); + await tester.idle(); + expect(emittedImages.length, 1); + + codecStream.add(animatedCodec); + await tester.idle(); + expect(animatedCodec.numFramesAsked, 1); + + final frames = [ + FakeFrameInfo(const Duration(milliseconds: 200), image200x100), + FakeFrameInfo(const Duration(milliseconds: 200), image300x100), + FakeFrameInfo(const Duration(milliseconds: 200), image20x10), + ]; + + animatedCodec.completeNextFrame(frames[0]); + await tester.idle(); + await tester.pump(); + expect(animatedCodec.numFramesAsked, 2); + + animatedCodec.completeNextFrame(frames[1]); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(animatedCodec.numFramesAsked, 3); + + animatedCodec.completeNextFrame(frames[2]); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + + // Played once, so no 4th frame is asked for + expect(animatedCodec.numFramesAsked, 3); + expect(emittedImages.length, 4); + expect(emittedImages[1].image.isCloneOf(frames[0].image), true); + expect(emittedImages[2].image.isCloneOf(frames[1].image), true); + expect(emittedImages[3].image.isCloneOf(frames[2].image), true); + }, + ); + + testWidgets( + 'waiting animated frame is dropped when a new codec arrives without listeners', + (WidgetTester tester) async { + final animatedCodec = MockCodec(); + animatedCodec.frameCount = 2; + animatedCodec.repetitionCount = -1; + final staticCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + if (!synchronousCall) emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(animatedCodec); + await tester.idle(); + + animatedCodec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10), + ); + await tester.idle(); + await tester.pump(); + expect(emittedImages.length, 1); + expect(animatedCodec.numFramesAsked, 2); + + // The 2nd frame is decoded while nobody is listening, so it waits + imageStream.removeListener(ImageStreamListener(listener)); + final waitingFrame = FakeFrameInfo( + const Duration(milliseconds: 200), + image200x100.clone(), + ); + animatedCodec.completeNextFrame(waitingFrame); + await tester.idle(); + await tester.pump(); + + codecStream.add(staticCodec); + await tester.idle(); + expect(staticCodec.numFramesAsked, 0); + expect(waitingFrame.image.debugDisposed, true); + + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + expect(staticCodec.numFramesAsked, 1); + + final staticFrame = FakeFrameInfo(Duration.zero, image300x100); + staticCodec.completeNextFrame(staticFrame); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(emittedImages.length, 2); + expect(emittedImages[1].image.isCloneOf(staticFrame.image), true); + + handle.dispose(); + }, + ); + + testWidgets('failed decode does not block later decodes', ( + WidgetTester tester, + ) async { + final mockCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + final handle = imageStream.keepAlive(); + + codecStream.add(mockCodec); + await tester.idle(); + expect(mockCodec.numFramesAsked, 1); + + mockCodec.failNextFrame('frame completion error'); + await tester.idle(); + expect(tester.takeException(), 'frame completion error'); + expect(emittedImages, isEmpty); + + // A failed decode leaves no image, so the next listener decodes again + imageStream.removeListener(ImageStreamListener(listener)); + imageStream.addListener(ImageStreamListener(listener)); + await tester.idle(); + expect(mockCodec.numFramesAsked, 2); + + final frame = FakeFrameInfo(Duration.zero, image20x10); + mockCodec.completeNextFrame(frame); + await tester.idle(); + + expect(emittedImages.single.image.isCloneOf(frame.image), true); + + handle.dispose(); + }); + + testWidgets( + 'frame from a codec replaced mid-decode is disposed, not emitted', + (WidgetTester tester) async { + final firstCodec = MockCodec(); + final secondCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + + codecStream.add(firstCodec); + await tester.idle(); + expect(firstCodec.numFramesAsked, 1); + + // The second codec arrives while the first one is still decoding + codecStream.add(secondCodec); + await tester.idle(); + expect(secondCodec.numFramesAsked, 1); + + final staleFrame = FakeFrameInfo(Duration.zero, image20x10.clone()); + firstCodec.completeNextFrame(staleFrame); + await tester.idle(); + expect(emittedImages, isEmpty); + expect(staleFrame.image.debugDisposed, true); + + final freshFrame = FakeFrameInfo(Duration.zero, image200x100); + secondCodec.completeNextFrame(freshFrame); + await tester.idle(); + + expect(emittedImages.single.image.isCloneOf(freshFrame.image), true); + }, + ); } From b713af55a71ee4f5c3dd84a81279e3e78e6f0aa6 Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:13:50 +0300 Subject: [PATCH 13/15] chore: Set version to 4.0.5 --- cached_network_image/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cached_network_image/pubspec.yaml b/cached_network_image/pubspec.yaml index 3e87f507..677ef082 100644 --- a/cached_network_image/pubspec.yaml +++ b/cached_network_image/pubspec.yaml @@ -6,7 +6,7 @@ topics: - cache - image - network-image -version: 4.0.6 +version: 4.0.5 environment: sdk: ^3.12.0 From 6302090d2077a8e40219eceb861208fe709b6d3a Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:13:56 +0300 Subject: [PATCH 14/15] docs(changelog): Document animated re-listen and codec reset fixes --- cached_network_image/CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 600043c8..0bdfa127 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,8 +1,12 @@ ## [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 -* Ignore listener re-adds while a frame is already being decoded, and retry decoding after a failed decode -* Add tests for static and animated listener re-add, failed decodes and codec switches during decoding +* Resume an animated image from its waiting frame when a listener is re-added, instead of skipping that frame +* Reset the frame count when a new codec arrives, so an animation that follows a placeholder shows all of its frames +* Discard frames from a codec that has been replaced, whether still decoding or waiting to be shown, so a stale cached image is no longer shown over the refreshed one +* Dispose frames that finish decoding after the completer is disposed, instead of leaking them +* Ignore listener re-adds while a frame is already being decoded; a failed decode no longer blocks later decodes +* Add tests for listener re-add, failed decodes and codec switches during decoding ## [4.0.4] - 2026-09-30 From f5a053fe0aa224d27e93abea7a07444ad168024f Mon Sep 17 00:00:00 2001 From: Shihab Kandil Date: Wed, 21 Oct 2026 02:16:37 +0300 Subject: [PATCH 15/15] fix: Guard app frame callback against a dropped waiting frame --- .../multi_image_stream_completer.dart | 2 +- .../test/image_stream_completer_test.dart | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 7bef2cec..52e89627 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -106,7 +106,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _handleAppFrame(Duration timestamp) { _frameCallbackScheduled = false; - if (!hasListeners) return; + if (!hasListeners || _nextFrame == null) return; if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) { _emitFrame( ImageInfo( diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 4193b9ae..b879ab11 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -1456,6 +1456,53 @@ void main() { }, ); + testWidgets( + 'new codec arriving before a scheduled animated frame is shown does not throw', + (WidgetTester tester) async { + final animatedCodec = MockCodec(); + animatedCodec.frameCount = 2; + animatedCodec.repetitionCount = -1; + final staticCodec = MockCodec(); + final codecStream = StreamController(); + + final ImageStreamCompleter imageStream = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + + final emittedImages = []; + listener(ImageInfo image, bool synchronousCall) { + emittedImages.add(image); + } + + imageStream.addListener(ImageStreamListener(listener)); + + codecStream.add(animatedCodec); + await tester.idle(); + + // The 1st frame is decoded and waits for the next app frame + animatedCodec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10), + ); + await tester.idle(); + + // A new codec arrives before that app frame + codecStream.add(staticCodec); + await tester.idle(); + expect(staticCodec.numFramesAsked, 1); + + await tester.pump(); + expect(tester.takeException(), isNull); + expect(emittedImages, isEmpty); + + final staticFrame = FakeFrameInfo(Duration.zero, image200x100); + staticCodec.completeNextFrame(staticFrame); + await tester.idle(); + + expect(emittedImages.single.image.isCloneOf(staticFrame.image), true); + }, + ); + testWidgets('failed decode does not block later decodes', ( WidgetTester tester, ) async {