From 0a9933159ad23da0f44f1da0ca64ac7190a26a5c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 25 Oct 2026 23:24:25 -0400 Subject: [PATCH 1/9] test: reproduce retained decoded animation frames --- .../test/image_stream_completer_test.dart | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index b879ab11..28284181 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -124,6 +124,128 @@ void main() { ); }); + testWidgets('disposes a decoded frame waiting for the first app frame', ( + WidgetTester tester, + ) async { + final codecStream = StreamController(); + final codec = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + final completer = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + var emittedImages = 0; + final listener = ImageStreamListener((image, _) { + emittedImages++; + image.dispose(); + }); + completer.addListener(listener); + codecStream.add(codec); + await tester.idle(); + + final frame = FakeFrameInfo( + const Duration(milliseconds: 200), + image20x10.clone(), + ); + codec.completeNextFrame(frame); + await tester.idle(); + expect(frame.image.debugDisposed, false); + expect(emittedImages, 0); + + // The frame has decoded but has not yet been emitted by an app frame. + completer.removeListener(listener); + expect(frame.image.debugDisposed, true); + await tester.pump(); + expect(emittedImages, 0); + await codecStream.close(); + }); + + testWidgets( + 'disposes a waiting animation frame when last listener is removed', + (WidgetTester tester) async { + final codecStream = StreamController(); + final codec = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + final completer = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + var emittedImages = 0; + final listener = ImageStreamListener((image, _) { + emittedImages++; + image.dispose(); + }); + completer.addListener(listener); + codecStream.add(codec); + await tester.idle(); + + codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + expect(emittedImages, 1); + expect(codec.numFramesAsked, 2); + + final waitingFrame = FakeFrameInfo( + const Duration(milliseconds: 200), + image200x100.clone(), + ); + codec.completeNextFrame(waitingFrame); + await tester.idle(); + // Schedule a delay while the first frame is still being displayed. + await tester.pump(const Duration(milliseconds: 50)); + expect(waitingFrame.image.debugDisposed, false); + expect(emittedImages, 1); + + completer.removeListener(listener); + expect(waitingFrame.image.debugDisposed, true); + await tester.pump(const Duration(milliseconds: 200)); + expect(emittedImages, 1); + expect(codec.numFramesAsked, 2); + await codecStream.close(); + }, + ); + + testWidgets( + 'retains waiting frame until final keep-alive handle is released', + (WidgetTester tester) async { + final codecStream = StreamController(); + final codec = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + final completer = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + final listener = ImageStreamListener((image, _) => image.dispose()); + completer.addListener(listener); + final firstHandle = completer.keepAlive(); + final lastHandle = completer.keepAlive(); + codecStream.add(codec); + await tester.idle(); + + final frame = FakeFrameInfo( + const Duration(milliseconds: 200), + image20x10.clone(), + ); + codec.completeNextFrame(frame); + await tester.idle(); + + completer.removeListener(listener); + expect(frame.image.debugDisposed, false); + firstHandle.dispose(); + expect(frame.image.debugDisposed, false); + lastHandle.dispose(); + expect(frame.image.debugDisposed, true); + await tester.pump(); + expect(codec.numFramesAsked, 1); + await codecStream.close(); + }, + ); + testWidgets('Decoding starts when a listener is added after codec is ready', ( WidgetTester tester, ) async { From c2270f95f1af1ce4a82704e04be24fa653bbdc78 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 25 Oct 2026 23:24:25 -0400 Subject: [PATCH 2/9] fix: dispose queued animation frame with image completer --- cached_network_image/CHANGELOG.md | 4 ++++ .../lib/src/image_provider/multi_image_stream_completer.dart | 4 ++++ cached_network_image/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 0bdfa127..1a2fbc0b 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,3 +1,7 @@ +## [4.0.6] - 2026-10-25 + +* Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released + ## [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 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 52e89627..3292c3b5 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 @@ -241,6 +241,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { __disposed = true; + // A decoded animation frame may still be waiting for an app frame. + _nextFrame?.image.dispose(); + _nextFrame = null; + _chunkSubscription?.onData(null); _chunkSubscription?.cancel(); _chunkSubscription = null; 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 c1a2990b3322072821d032550f74c92c5be9425f Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 25 Oct 2026 23:34:31 -0400 Subject: [PATCH 3/9] fix: detach animation frame before notifying listeners --- cached_network_image/CHANGELOG.md | 1 + .../multi_image_stream_completer.dart | 13 +++-- .../test/image_stream_completer_test.dart | 51 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 1a2fbc0b..cc3d04c3 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,6 +1,7 @@ ## [4.0.6] - 2026-10-25 * Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released +* Safely transfer frame ownership when a listener removes itself or releases the final keep-alive handle during frame delivery ## [4.0.5] - 2026-10-17 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 3292c3b5..88131a2b 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 @@ -108,16 +108,15 @@ 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!; + _nextFrame = null; _emitFrame( - ImageInfo( - image: _nextFrame!.image, - scale: _scale, - debugLabel: debugLabel, - ), + ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel), ); + if (__disposed) return; _shownTimestamp = timestamp; - _frameDuration = _nextFrame!.duration; - _nextFrame = null; + _frameDuration = frame.duration; if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) { _switchToNewCodec(); } else { diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 28284181..99b0ee8d 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -246,6 +246,57 @@ void main() { }, ); + for (final keptAlive in [false, true]) { + testWidgets( + keptAlive + ? 'last keep-alive handle can be released during animated frame delivery' + : 'last listener can remove itself during animated frame delivery', + (WidgetTester tester) async { + final codecStream = StreamController(); + final codec = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + final completer = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + ); + ImageStreamCompleterHandle? handle; + var emittedImages = 0; + late final ImageStreamListener listener; + listener = ImageStreamListener((image, _) { + emittedImages++; + try { + completer.removeListener(listener); + handle?.dispose(); + // The delivered clone remains owned by its listener. + expect(image.image.debugDisposed, false); + } finally { + image.dispose(); + } + }); + completer.addListener(listener); + if (keptAlive) handle = completer.keepAlive(); + codecStream.add(codec); + await tester.idle(); + + final frame = FakeFrameInfo( + const Duration(milliseconds: 200), + image20x10.clone(), + ); + codec.completeNextFrame(frame); + await tester.idle(); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(emittedImages, 1); + expect(frame.image.debugDisposed, true); + // Final disposal during delivery must not request another frame. + expect(codec.numFramesAsked, 1); + await codecStream.close(); + }, + ); + } + testWidgets('Decoding starts when a listener is added after codec is ready', ( WidgetTester tester, ) async { From 97c6616ee763a984fe2ef61675832b9ccb1a0586 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 28 Oct 2026 00:57:38 -0400 Subject: [PATCH 4/9] fix: release codecs through framework disposal lifecycle --- cached_network_image/CHANGELOG.md | 2 + .../multi_image_stream_completer.dart | 99 +++---- .../test/image_stream_completer_test.dart | 272 +++++++++++++----- 3 files changed, 236 insertions(+), 137 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index cc3d04c3..49287986 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -2,6 +2,8 @@ * Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released * Safely transfer frame ownership when a listener removes itself or releases the final keep-alive handle during frame delivery +* Release active and pending codecs on disposal, dispose replaced codecs, and cancel the codec subscription using the framework disposal hook +* Pause animation decoding when the final listener removes itself while a keep-alive handle remains ## [4.0.5] - 2026-10-17 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 88131a2b..477e1ba5 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 @@ -23,9 +23,14 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { InformationCollector? informationCollector, }) : _informationCollector = informationCollector { this.debugLabel = debugLabel; - codec.listen( + _codecSubscription = codec.listen( (event) { - if (_timer != null) { + if (_disposed) { + event.dispose(); + } else if (_timer != null) { + if (_nextImageCodec != event && _nextImageCodec != _codec) { + _nextImageCodec?.dispose(); + } _nextImageCodec = event; } else { _handleCodecReady(event); @@ -73,31 +78,30 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { int _framesEmitted = 0; bool _isDecoding = false; Timer? _timer; + StreamSubscription? _codecSubscription; StreamSubscription? _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!); + final codec = _nextImageCodec!; _nextImageCodec = null; + _handleCodecReady(codec); } void _handleCodecReady(ui.Codec codec) { + 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(); @@ -114,9 +118,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _emitFrame( ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel), ); - if (__disposed) return; + if (_disposed) return; _shownTimestamp = timestamp; _frameDuration = frame.duration; + if (!hasListeners) return; if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) { _switchToNewCodec(); } else { @@ -150,9 +155,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, @@ -168,7 +172,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { return; } _isDecoding = false; - if (__disposed) { + if (_disposed) { frame.image.dispose(); return; } @@ -180,7 +184,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { ); return; } - _nextFrame?.image.dispose(); + _discardNextFrame(); _nextFrame = frame; _scheduleAppFrame(); } @@ -200,7 +204,6 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { @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. @@ -218,60 +221,28 @@ 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 __maybeDispose() { - if (!__hadAtLeastOneListener || - __disposed || - hasListeners || - __keepAliveHandles != 0) { - return; - } - - __disposed = true; - - // A decoded animation frame may still be waiting for an app frame. + void _discardNextFrame() { _nextFrame?.image.dispose(); _nextFrame = null; - - _chunkSubscription?.onData(null); - _chunkSubscription?.cancel(); - _chunkSubscription = null; } -} - -class _MultiImageStreamCompleterHandle implements ImageStreamCompleterHandle { - _MultiImageStreamCompleterHandle(this._completer, this._delegateHandle) { - _completer!.__keepAliveHandles += 1; - } - - MultiImageStreamCompleter? _completer; - final ImageStreamCompleterHandle _delegateHandle; - /// 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(); + _codecSubscription?.cancel(); + _codecSubscription = null; + _chunkSubscription?.cancel(); + _chunkSubscription = null; + if (_nextImageCodec != _codec) _nextImageCodec?.dispose(); + _nextImageCodec = null; + _codec?.dispose(); + _codec = null; } } diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 99b0ee8d..91621e18 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -37,6 +37,7 @@ class MockCodec implements Codec { int repetitionCount = 1; int numFramesAsked = 0; + int disposeCount = 0; Completer _nextFrameCompleter = Completer(); @@ -57,7 +58,34 @@ class MockCodec implements Codec { } @override - void dispose() {} + void dispose() { + disposeCount++; + } +} + +class AnimatedCompleterFixture { + AnimatedCompleterFixture({void Function(ImageInfo)? onImage}) { + completer = MultiImageStreamCompleter(codec: codecStream.stream, scale: 1); + listener = ImageStreamListener((image, _) { + emittedImages++; + try { + onImage?.call(image); + } finally { + image.dispose(); + } + }); + completer.addListener(listener); + codecStream.add(codec); + addTearDown(codecStream.close); + } + + final codecStream = StreamController(); + final codec = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + late final MultiImageStreamCompleter completer; + late final ImageStreamListener listener; + int emittedImages = 0; } class FakeEventReportingImageStreamCompleter extends ImageStreamCompleter { @@ -127,21 +155,10 @@ void main() { testWidgets('disposes a decoded frame waiting for the first app frame', ( WidgetTester tester, ) async { - final codecStream = StreamController(); - final codec = MockCodec() - ..frameCount = 2 - ..repetitionCount = -1; - final completer = MultiImageStreamCompleter( - codec: codecStream.stream, - scale: 1.0, - ); - var emittedImages = 0; - final listener = ImageStreamListener((image, _) { - emittedImages++; - image.dispose(); - }); - completer.addListener(listener); - codecStream.add(codec); + final fixture = AnimatedCompleterFixture(); + final codec = fixture.codec; + final completer = fixture.completer; + final listener = fixture.listener; await tester.idle(); final frame = FakeFrameInfo( @@ -151,34 +168,22 @@ void main() { codec.completeNextFrame(frame); await tester.idle(); expect(frame.image.debugDisposed, false); - expect(emittedImages, 0); + expect(fixture.emittedImages, 0); // The frame has decoded but has not yet been emitted by an app frame. completer.removeListener(listener); expect(frame.image.debugDisposed, true); await tester.pump(); - expect(emittedImages, 0); - await codecStream.close(); + expect(fixture.emittedImages, 0); }); testWidgets( 'disposes a waiting animation frame when last listener is removed', (WidgetTester tester) async { - final codecStream = StreamController(); - final codec = MockCodec() - ..frameCount = 2 - ..repetitionCount = -1; - final completer = MultiImageStreamCompleter( - codec: codecStream.stream, - scale: 1.0, - ); - var emittedImages = 0; - final listener = ImageStreamListener((image, _) { - emittedImages++; - image.dispose(); - }); - completer.addListener(listener); - codecStream.add(codec); + final fixture = AnimatedCompleterFixture(); + final codec = fixture.codec; + final completer = fixture.completer; + final listener = fixture.listener; await tester.idle(); codec.completeNextFrame( @@ -186,7 +191,7 @@ void main() { ); await tester.idle(); await tester.pump(); - expect(emittedImages, 1); + expect(fixture.emittedImages, 1); expect(codec.numFramesAsked, 2); final waitingFrame = FakeFrameInfo( @@ -198,33 +203,25 @@ void main() { // Schedule a delay while the first frame is still being displayed. await tester.pump(const Duration(milliseconds: 50)); expect(waitingFrame.image.debugDisposed, false); - expect(emittedImages, 1); + expect(fixture.emittedImages, 1); completer.removeListener(listener); expect(waitingFrame.image.debugDisposed, true); await tester.pump(const Duration(milliseconds: 200)); - expect(emittedImages, 1); + expect(fixture.emittedImages, 1); expect(codec.numFramesAsked, 2); - await codecStream.close(); }, ); testWidgets( 'retains waiting frame until final keep-alive handle is released', (WidgetTester tester) async { - final codecStream = StreamController(); - final codec = MockCodec() - ..frameCount = 2 - ..repetitionCount = -1; - final completer = MultiImageStreamCompleter( - codec: codecStream.stream, - scale: 1.0, - ); - final listener = ImageStreamListener((image, _) => image.dispose()); - completer.addListener(listener); + final fixture = AnimatedCompleterFixture(); + final codec = fixture.codec; + final completer = fixture.completer; + final listener = fixture.listener; final firstHandle = completer.keepAlive(); final lastHandle = completer.keepAlive(); - codecStream.add(codec); await tester.idle(); final frame = FakeFrameInfo( @@ -242,7 +239,6 @@ void main() { expect(frame.image.debugDisposed, true); await tester.pump(); expect(codec.numFramesAsked, 1); - await codecStream.close(); }, ); @@ -252,31 +248,18 @@ void main() { ? 'last keep-alive handle can be released during animated frame delivery' : 'last listener can remove itself during animated frame delivery', (WidgetTester tester) async { - final codecStream = StreamController(); - final codec = MockCodec() - ..frameCount = 2 - ..repetitionCount = -1; - final completer = MultiImageStreamCompleter( - codec: codecStream.stream, - scale: 1.0, - ); ImageStreamCompleterHandle? handle; - var emittedImages = 0; - late final ImageStreamListener listener; - listener = ImageStreamListener((image, _) { - emittedImages++; - try { - completer.removeListener(listener); + late final AnimatedCompleterFixture fixture; + fixture = AnimatedCompleterFixture( + onImage: (image) { + fixture.completer.removeListener(fixture.listener); handle?.dispose(); // The delivered clone remains owned by its listener. expect(image.image.debugDisposed, false); - } finally { - image.dispose(); - } - }); - completer.addListener(listener); - if (keptAlive) handle = completer.keepAlive(); - codecStream.add(codec); + }, + ); + final codec = fixture.codec; + if (keptAlive) handle = fixture.completer.keepAlive(); await tester.idle(); final frame = FakeFrameInfo( @@ -288,15 +271,158 @@ void main() { await tester.pump(); expect(tester.takeException(), isNull); - expect(emittedImages, 1); + expect(fixture.emittedImages, 1); expect(frame.image.debugDisposed, true); // Final disposal during delivery must not request another frame. expect(codec.numFramesAsked, 1); - await codecStream.close(); }, ); } + testWidgets('final disposal cancels codec events and releases the codec', ( + tester, + ) async { + final fixture = AnimatedCompleterFixture(); + await tester.idle(); + final firstHandle = fixture.completer.keepAlive(); + final lastHandle = fixture.completer.keepAlive(); + fixture.completer.removeListener(fixture.listener); + firstHandle.dispose(); + expect(fixture.codec.disposeCount, 0); + expect(fixture.codecStream.hasListener, true); + lastHandle.dispose(); + expect(fixture.codec.disposeCount, 1); + expect(fixture.codecStream.hasListener, false); + final lateFrame = FakeFrameInfo(Duration.zero, image20x10.clone()); + fixture.codec.completeNextFrame(lateFrame); + await tester.idle(); + expect(lateFrame.image.debugDisposed, true); + expect(tester.takeException(), isNull); + }); + + testWidgets('replacing a codec disposes it while its decode is in flight', ( + tester, + ) async { + final fixture = AnimatedCompleterFixture(); + await tester.idle(); + final replacement = MockCodec(); + fixture.codecStream.add(replacement); + await tester.idle(); + expect(fixture.codec.disposeCount, 1); + expect(replacement.disposeCount, 0); + fixture.codec.failNextFrame('disposed codec'); + await tester.idle(); + expect(tester.takeException(), isNull); + fixture.completer.removeListener(fixture.listener); + expect(replacement.disposeCount, 1); + }); + + testWidgets('pending codecs are disposed on replacement and final disposal', ( + tester, + ) async { + final fixture = AnimatedCompleterFixture(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + final pending = MockCodec(); + final replacement = MockCodec(); + fixture.codecStream.add(pending); + await tester.idle(); + fixture.codecStream.add(replacement); + await tester.idle(); + expect(pending.disposeCount, 1); + expect(fixture.codec.disposeCount, 0); + expect(replacement.disposeCount, 0); + fixture.completer.removeListener(fixture.listener); + expect(fixture.codec.disposeCount, 1); + expect(replacement.disposeCount, 1); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + }); + + testWidgets('switching to a pending codec releases the previous codec', ( + tester, + ) async { + final fixture = AnimatedCompleterFixture(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + final pending = MockCodec(); + fixture.codecStream.add(pending); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(fixture.codec.disposeCount, 1); + expect(pending.numFramesAsked, 1); + expect(pending.disposeCount, 0); + fixture.completer.removeListener(fixture.listener); + expect(pending.disposeCount, 1); + }); + + testWidgets('base disposal releases codecs before any listener was added', ( + tester, + ) async { + final stream = StreamController(); + addTearDown(stream.close); + final codec = MockCodec(); + final completer = MultiImageStreamCompleter(codec: stream.stream, scale: 1); + final handle = completer.keepAlive(); + stream.add(codec); + await tester.idle(); + handle.dispose(); + expect(codec.disposeCount, 1); + expect(stream.hasListener, false); + }); + + testWidgets('listener removal with keep-alive pauses decoding until resumed', ( + tester, + ) async { + late final AnimatedCompleterFixture fixture; + fixture = AnimatedCompleterFixture( + onImage: (_) { + fixture.completer.removeListener(fixture.listener); + }, + ); + final handle = fixture.completer.keepAlive(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + expect(fixture.codec.numFramesAsked, 1); + expect(fixture.codec.disposeCount, 0); + // A fresh listener resumes the next frame rather than replaying the first. + final listener = ImageStreamListener((image, _) => image.dispose()); + fixture.completer.addListener(listener); + await tester.idle(); + expect(fixture.codec.numFramesAsked, 2); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(fixture.codec.numFramesAsked, 3); + fixture.completer.removeListener(listener); + handle.dispose(); + expect(fixture.codec.disposeCount, 1); + }); + testWidgets('Decoding starts when a listener is added after codec is ready', ( WidgetTester tester, ) async { From 0d29678954a32a5147eea91382d02a8bff082203 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 28 Oct 2026 01:19:12 -0400 Subject: [PATCH 5/9] fix: preserve refresh selection and dispose late codecs --- cached_network_image/CHANGELOG.md | 4 +- .../multi_image_stream_completer.dart | 11 +- .../test/image_stream_completer_test.dart | 117 +++++++++++++++++- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 49287986..5247c154 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -2,8 +2,8 @@ * Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released * Safely transfer frame ownership when a listener removes itself or releases the final keep-alive handle during frame delivery -* Release active and pending codecs on disposal, dispose replaced codecs, and cancel the codec subscription using the framework disposal hook -* Pause animation decoding when the final listener removes itself while a keep-alive handle remains +* Release active and pending codecs on disposal, dispose replaced codecs, and drain late codec results without retaining the disposed completer +* Pause animation decoding when the final listener removes itself while a keep-alive handle remains, selecting any pending refresh before pausing ## [4.0.5] - 2026-10-17 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 477e1ba5..85027896 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 @@ -121,10 +121,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (_disposed) return; _shownTimestamp = timestamp; _frameDuration = frame.duration; - if (!hasListeners) 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) { @@ -229,6 +229,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _nextFrame = null; } + static void _disposeCodec(ui.Codec codec) => codec.dispose(); + + static void _ignoreCodecError(Object error, StackTrace stack) {} + @override void onDisposed() { super.onDisposed(); @@ -236,7 +240,10 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _timer?.cancel(); _timer = null; _discardNextFrame(); - _codecSubscription?.cancel(); + // 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); + _codecSubscription?.onError(_ignoreCodecError); _codecSubscription = null; _chunkSubscription?.cancel(); _chunkSubscription = null; diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 91621e18..73098ecd 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -279,7 +279,7 @@ void main() { ); } - testWidgets('final disposal cancels codec events and releases the codec', ( + testWidgets('final disposal drains codec events and releases the codec', ( tester, ) async { final fixture = AnimatedCompleterFixture(); @@ -292,7 +292,17 @@ void main() { expect(fixture.codecStream.hasListener, true); lastHandle.dispose(); expect(fixture.codec.disposeCount, 1); - expect(fixture.codecStream.hasListener, false); + expect(fixture.codecStream.hasListener, true); + final lateCodec = MockCodec(); + fixture.codecStream.add(lateCodec); + fixture.codecStream.addError(StateError('late codec failure')); + await tester.idle(); + expect(lateCodec.disposeCount, 1); + expect(lateCodec.numFramesAsked, 0); + addTearDown(() async { + await fixture.codecStream.close(); + expect(fixture.codecStream.hasListener, false); + }); final lateFrame = FakeFrameInfo(Duration.zero, image20x10.clone()); fixture.codec.completeNextFrame(lateFrame); await tester.idle(); @@ -386,7 +396,10 @@ void main() { await tester.idle(); handle.dispose(); expect(codec.disposeCount, 1); - expect(stream.hasListener, false); + final lateCodec = MockCodec(); + stream.add(lateCodec); + await tester.idle(); + expect(lateCodec.disposeCount, 1); }); testWidgets('listener removal with keep-alive pauses decoding until resumed', ( @@ -423,6 +436,104 @@ void main() { expect(fixture.codec.disposeCount, 1); }); + testWidgets( + 'pending refresh is selected when delivery removes last listener', + (tester) async { + late final AnimatedCompleterFixture fixture; + fixture = AnimatedCompleterFixture( + onImage: (_) { + if (fixture.emittedImages == 2) { + fixture.completer.removeListener(fixture.listener); + } + }, + ); + fixture.codec.repetitionCount = 0; + final handle = fixture.completer.keepAlive(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + final refresh = MockCodec(); + fixture.codecStream.add(refresh); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(fixture.emittedImages, 2); + expect(fixture.codec.disposeCount, 1); + expect(refresh.numFramesAsked, 0); + + final images = []; + final listener = ImageStreamListener((image, _) => images.add(image)); + fixture.completer.addListener(listener); + await tester.idle(); + expect(refresh.numFramesAsked, 1); + expect(fixture.codec.numFramesAsked, 2); + refresh.completeNextFrame( + FakeFrameInfo(Duration.zero, image300x100.clone()), + ); + await tester.idle(); + expect(images.length, 2); + expect(images.last.image.isCloneOf(image300x100), true); + for (final image in images) { + image.dispose(); + } + fixture.completer.removeListener(listener); + handle.dispose(); + expect(refresh.disposeCount, 1); + }, + ); + + for (final asyncGenerator in [false, true]) { + Stream codecStream(Future future) async* { + yield await future; + } + + testWidgets( + 'late codec is disposed after completer disposal (async generator: $asyncGenerator)', + (tester) async { + final future = Completer(); + final stream = asyncGenerator + ? codecStream(future.future) + : future.future.asStream(); + final completer = MultiImageStreamCompleter(codec: stream, scale: 1); + final listener = ImageStreamListener((image, _) => image.dispose()); + completer.addListener(listener); + await tester.idle(); + completer.removeListener(listener); + final codec = MockCodec(); + future.complete(codec); + await tester.idle(); + expect(codec.disposeCount, 1); + expect(codec.numFramesAsked, 0); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'late codec error is ignored after disposal (async generator: $asyncGenerator)', + (tester) async { + final future = Completer(); + final stream = asyncGenerator + ? codecStream(future.future) + : future.future.asStream(); + final completer = MultiImageStreamCompleter(codec: stream, scale: 1); + final listener = ImageStreamListener((image, _) => image.dispose()); + completer.addListener(listener); + await tester.idle(); + completer.removeListener(listener); + future.completeError(StateError('late decode failure')); + await tester.idle(); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('Decoding starts when a listener is added after codec is ready', ( WidgetTester tester, ) async { From 2204b8f8aa147033b72b03963942780d4de0eba0 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 28 Oct 2026 04:47:33 -0400 Subject: [PATCH 6/9] fix: discard superseded codecs and preserve refresh frame counts --- cached_network_image/CHANGELOG.md | 2 + .../multi_image_stream_completer.dart | 27 ++-- .../test/image_stream_completer_test.dart | 127 ++++++++++++++++++ 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 5247c154..f32d9504 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -3,6 +3,8 @@ * Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released * Safely transfer frame ownership when a listener removes itself or releases the final keep-alive handle during frame delivery * Release active and pending codecs on disposal, dispose replaced codecs, and drain late codec results without retaining the disposed completer +* Discard superseded pending refreshes when a newer codec arrives after listener removal, preventing older images from returning on resume +* Preserve the new animation's frame count when a listener synchronously replaces the codec during frame delivery * Pause animation decoding when the final listener removes itself while a keep-alive handle remains, selecting any pending refresh before pausing ## [4.0.5] - 2026-10-17 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 85027896..9165edae 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 @@ -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 codec, required this._scale, @@ -89,12 +93,15 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _switchToNewCodec() { _timer = null; - final codec = _nextImageCodec!; - _nextImageCodec = null; - _handleCodecReady(codec); + _handleCodecReady(_nextImageCodec!); } void _handleCodecReady(ui.Codec codec) { + // Every activation supersedes a pending refresh, even when listener + // removal cancelled its timer before a newer codec arrived. + final pending = _nextImageCodec; + _nextImageCodec = null; + if (pending != codec && pending != _codec) pending?.dispose(); if (_codec != codec) _codec?.dispose(); _codec = codec; _framesEmitted = 0; @@ -114,11 +121,12 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (_isFirstFrame() || _hasFrameDurationPassed(timestamp)) { // Transfer ownership before callbacks can remove the last listener. final frame = _nextFrame!; + final codec = _codec!; _nextFrame = null; _emitFrame( ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel), ); - if (_disposed) return; + if (_disposed || _codec != codec) return; _shownTimestamp = timestamp; _frameDuration = frame.duration; if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) { @@ -166,16 +174,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. @@ -198,8 +202,9 @@ 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 diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 73098ecd..325f12a7 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -384,6 +384,133 @@ void main() { expect(pending.disposeCount, 1); }); + for (final scenario in [ + (pause: false, resume: false), + (pause: true, resume: false), + (pause: true, resume: true), + ]) { + final pauseBeforeRefresh = scenario.pause; + final resumeBeforeRefresh = scenario.resume; + testWidgets( + 'newest refresh supersedes parked codec (pause: $pauseBeforeRefresh, resume: $resumeBeforeRefresh)', + (tester) async { + final fixture = AnimatedCompleterFixture(); + final handle = fixture.completer.keepAlive(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + final waitingFrame = FakeFrameInfo( + const Duration(milliseconds: 200), + image200x100.clone(), + ); + fixture.codec.completeNextFrame(waitingFrame); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + final parked = MockCodec(); + fixture.codecStream.add(parked); + await tester.idle(); + if (pauseBeforeRefresh) { + fixture.completer.removeListener(fixture.listener); + if (resumeBeforeRefresh) { + fixture.completer.addListener(fixture.listener); + } + } + final newest = MockCodec() + ..frameCount = 2 + ..repetitionCount = -1; + fixture.codecStream.add(newest); + await tester.idle(); + expect(parked.disposeCount, 1); + expect(parked.numFramesAsked, 0); + if (pauseBeforeRefresh && !resumeBeforeRefresh) { + expect(newest.numFramesAsked, 0); + fixture.completer.addListener(fixture.listener); + await tester.idle(); + } + if (!pauseBeforeRefresh) { + // The current animation finishes its cycle before the refresh. + await tester.pump(const Duration(milliseconds: 200)); + } + expect(fixture.codec.disposeCount, 1); + expect(newest.numFramesAsked, 1); + // Play beyond a full cycle: no superseded codec may return. + for (var frame = 0; frame < 3; frame++) { + newest.completeNextFrame( + FakeFrameInfo( + const Duration(milliseconds: 200), + image300x100.clone(), + ), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + } + expect(waitingFrame.image.debugDisposed, true); + expect(newest.numFramesAsked, 4); + expect(newest.disposeCount, 0); + expect(parked.numFramesAsked, 0); + fixture.completer.removeListener(fixture.listener); + handle.dispose(); + expect(newest.disposeCount, 1); + expect(parked.disposeCount, 1); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'synchronous refresh during delivery preserves new animation cycle', + (tester) async { + final stream = StreamController(sync: true); + addTearDown(stream.close); + final original = MockCodec() + ..frameCount = 2 + ..repetitionCount = 0; + final refresh = MockCodec() + ..frameCount = 2 + ..repetitionCount = 0; + final completer = MultiImageStreamCompleter( + codec: stream.stream, + scale: 1, + ); + var delivered = 0; + final listener = ImageStreamListener((image, _) { + delivered++; + image.dispose(); + if (delivered == 1) stream.add(refresh); + }); + completer.addListener(listener); + stream.add(original); + original.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + expect(original.disposeCount, 1); + expect(refresh.numFramesAsked, 1); + refresh.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(refresh.numFramesAsked, 2); + refresh.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image300x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 200)); + expect(delivered, 3); + expect(original.numFramesAsked, 1); + expect(refresh.numFramesAsked, 2); + expect(refresh.disposeCount, 0); + completer.removeListener(listener); + expect(refresh.disposeCount, 1); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('base disposal releases codecs before any listener was added', ( tester, ) async { From 49f307192dcc1394bef129985f5178c4e1af2039 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 28 Oct 2026 04:55:00 -0400 Subject: [PATCH 7/9] docs: clarify pending refresh selection at cycle boundaries --- cached_network_image/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index f32d9504..7a16684e 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -5,7 +5,7 @@ * Release active and pending codecs on disposal, dispose replaced codecs, and drain late codec results without retaining the disposed completer * Discard superseded pending refreshes when a newer codec arrives after listener removal, preventing older images from returning on resume * Preserve the new animation's frame count when a listener synchronously replaces the codec during frame delivery -* Pause animation decoding when the final listener removes itself while a keep-alive handle remains, selecting any pending refresh before pausing +* Pause animation decoding when the final listener removes itself while a keep-alive handle remains, selecting a pending refresh at cycle boundaries before pausing ## [4.0.5] - 2026-10-17 From e20b842dd0f0057535fe1ff8045daf0c55268d3c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 29 Oct 2026 07:04:49 -0400 Subject: [PATCH 8/9] fix: activate completed-animation refreshes and preserve frame timing --- cached_network_image/CHANGELOG.md | 9 +- .../multi_image_stream_completer.dart | 21 +-- .../test/image_stream_completer_test.dart | 120 ++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index 7a16684e..c4a5ba24 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,11 +1,8 @@ ## [4.0.6] - 2026-10-25 -* Dispose an already-decoded animation frame when the image stream completer is disposed, including when the last keep-alive handle is released -* Safely transfer frame ownership when a listener removes itself or releases the final keep-alive handle during frame delivery -* Release active and pending codecs on disposal, dispose replaced codecs, and drain late codec results without retaining the disposed completer -* Discard superseded pending refreshes when a newer codec arrives after listener removal, preventing older images from returning on resume -* Preserve the new animation's frame count when a listener synchronously replaces the codec during frame delivery -* Pause animation decoding when the final listener removes itself while a keep-alive handle remains, selecting a pending refresh at cycle boundaries before pausing +* 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 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 9165edae..8b467aa1 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 @@ -32,10 +32,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (_disposed) { event.dispose(); } else if (_timer != null) { - if (_nextImageCodec != event && _nextImageCodec != _codec) { - _nextImageCodec?.dispose(); - } - _nextImageCodec = event; + _replacePendingCodec(event); } else { _handleCodecReady(event); } @@ -99,9 +96,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { void _handleCodecReady(ui.Codec codec) { // Every activation supersedes a pending refresh, even when listener // removal cancelled its timer before a newer codec arrived. - final pending = _nextImageCodec; + _replacePendingCodec(codec); _nextImageCodec = null; - if (pending != codec && pending != _codec) pending?.dispose(); if (_codec != codec) _codec?.dispose(); _codec = codec; _framesEmitted = 0; @@ -126,9 +122,9 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _emitFrame( ImageInfo(image: frame.image, scale: _scale, debugLabel: debugLabel), ); - if (_disposed || _codec != codec) return; _shownTimestamp = timestamp; _frameDuration = frame.duration; + if (_disposed || _codec != codec) return; if (_framesEmitted % _codec!.frameCount == 0 && _nextImageCodec != null) { _switchToNewCodec(); } else { @@ -137,6 +133,8 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (_codec!.repetitionCount == -1 || completedCycles <= _codec!.repetitionCount) { _decodeNextFrameAndSchedule(); + } else { + _timer = null; } } return; @@ -229,6 +227,12 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { } } + void _replacePendingCodec(ui.Codec? next) { + final previous = _nextImageCodec; + _nextImageCodec = next; + if (previous != next && previous != _codec) previous?.dispose(); + } + void _discardNextFrame() { _nextFrame?.image.dispose(); _nextFrame = null; @@ -252,8 +256,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { _codecSubscription = null; _chunkSubscription?.cancel(); _chunkSubscription = null; - if (_nextImageCodec != _codec) _nextImageCodec?.dispose(); - _nextImageCodec = null; + _replacePendingCodec(null); _codec?.dispose(); _codec = null; } diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 325f12a7..e68a80d9 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -358,6 +358,69 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('refresh after a completed play-once animation is decoded', ( + tester, + ) async { + final fixture = AnimatedCompleterFixture(); + fixture.codec.repetitionCount = 0; + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + expect(fixture.emittedImages, 2); + final refresh = MockCodec(); + fixture.codecStream.add(refresh); + await tester.idle(); + expect(refresh.numFramesAsked, 1); + refresh.completeNextFrame( + FakeFrameInfo(Duration.zero, image300x100.clone()), + ); + await tester.idle(); + expect(fixture.emittedImages, 3); + fixture.completer.removeListener(fixture.listener); + expect(fixture.codec.disposeCount, 1); + expect(refresh.disposeCount, 1); + }); + + for (final pendingIsActive in [false, true]) { + testWidgets( + 'repeated pending codec is disposed once (active: $pendingIsActive)', + (tester) async { + final fixture = AnimatedCompleterFixture(); + await tester.idle(); + fixture.codec.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 200), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + fixture.codec.completeNextFrame( + FakeFrameInfo( + const Duration(milliseconds: 200), + image200x100.clone(), + ), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + final pending = pendingIsActive ? fixture.codec : MockCodec(); + fixture.codecStream.add(pending); + fixture.codecStream.add(pending); + await tester.idle(); + expect(pending.disposeCount, 0); + fixture.completer.removeListener(fixture.listener); + expect(pending.disposeCount, 1); + expect(fixture.codec.disposeCount, 1); + }, + ); + } + testWidgets('switching to a pending codec releases the previous codec', ( tester, ) async { @@ -511,6 +574,63 @@ void main() { }, ); + testWidgets( + 'synchronous refresh preserves the delivered non-first frame duration', + (tester) async { + final stream = StreamController(sync: true); + addTearDown(stream.close); + final original = MockCodec()..frameCount = 3; + final refresh = MockCodec() + ..frameCount = 2 + ..repetitionCount = 0; + final completer = MultiImageStreamCompleter( + codec: stream.stream, + scale: 1, + ); + var delivered = 0; + final listener = ImageStreamListener((image, _) { + delivered++; + image.dispose(); + if (delivered == 2) stream.add(refresh); + }); + completer.addListener(listener); + addTearDown(() { + if (completer.hasListeners) completer.removeListener(listener); + }); + stream.add(original); + original.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 100), image20x10.clone()), + ); + await tester.idle(); + await tester.pump(); + original.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 400), image200x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 100)); + refresh.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 50), image300x100.clone()), + ); + await tester.idle(); + await tester.pump(); + expect(delivered, 2); + await tester.pump(const Duration(milliseconds: 399)); + expect(delivered, 2); + await tester.pump(const Duration(milliseconds: 1)); + expect(delivered, 3); + expect(original.numFramesAsked, 2); + expect(original.disposeCount, 1); + refresh.completeNextFrame( + FakeFrameInfo(const Duration(milliseconds: 50), image300x100.clone()), + ); + await tester.idle(); + await tester.pump(const Duration(milliseconds: 50)); + expect(delivered, 4); + completer.removeListener(listener); + expect(refresh.disposeCount, 1); + }, + ); + testWidgets('base disposal releases codecs before any listener was added', ( tester, ) async { From bc8f25fede7ea48f290f70d029bce8d565ee8c73 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 29 Oct 2026 08:00:38 -0400 Subject: [PATCH 9/9] docs: set 4.0.6 release date to October 29 --- cached_network_image/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cached_network_image/CHANGELOG.md b/cached_network_image/CHANGELOG.md index c4a5ba24..226522dc 100644 --- a/cached_network_image/CHANGELOG.md +++ b/cached_network_image/CHANGELOG.md @@ -1,4 +1,4 @@ -## [4.0.6] - 2026-10-25 +## [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