diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 54d978f09a3..07bdf2b0492 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -1299,6 +1299,26 @@ class _ImageState extends State with WidgetsBindingObserver { _completerHandle = _imageStream!.completer!.keepAlive(); } + // It's almost time to remove the last listener, which triggers the + // disposal. But before that, add an ephemeral listener to potentially + // suppress errors. + // + // Reason: When an app provides an `Image` widget with an `errorBuilder`, it + // expects the widget to never report errors through `FlutterError` in any + // cases. This is hard if the stream fails after the disposal, because an + // image stream must have no listeners to be disposed, which then has + // nothing to suppress the errors. This is solve with the help of an + // ephemeral listener, which also suppresses the error but does not hinder + // disposal. For more details, see + // https://github.com/flutter/flutter/issues/97077 . + if (_imageStream!.completer != null && widget.errorBuilder != null) { + _imageStream!.completer!.addEphemeralErrorListener(( + Object exception, + StackTrace? stackTrace, + ) { + // Intentionally blank. + }); + } _imageStream!.removeListener(_getListener()); _isListeningToStream = false; } diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index 0c721f15cc6..8629578555c 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -2137,6 +2137,126 @@ void main() { codec.dispose(); }, ); + + testWidgets('errorBuilder prevents FlutterError report even if widget is disposed', ( + WidgetTester tester, + ) async { + // This test verifies that if an errorBuilder is provided, FlutterError.reportError + // is NOT called, even if the Image widget is removed from the tree before the + // image load fails. Regression test for https://github.com/flutter/flutter/issues/97077. + + // 1. Setup: Capture FlutterError reports + final List reportedErrors = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = reportedErrors.add; + addTearDown(() { + FlutterError.onError = oldHandler; + }); // Ensure handler is restored + + final _TestImageProvider provider = _TestImageProvider(); + final Exception testException = Exception('Network failed'); + final StackTrace testStack = StackTrace.current; + + Widget buildImage() { + return Directionality( + textDirection: TextDirection.ltr, + child: Image( + image: provider, + errorBuilder: (_, _, _) => const SizedBox(width: 10, height: 10), + ), + ); + } + + // 2. Pump the widget with the Image. + await tester.pumpWidget(buildImage()); + expect(find.byType(Image), findsOneWidget); + expect(reportedErrors, isEmpty); // No errors yet + + // 3. Remove the Image widget from the tree. + await tester.pumpWidget(const SizedBox.shrink()); + expect(find.byType(Image), findsNothing); + + // 4. Now, make the image provider fail *after* the widget state is disposed. + provider.fail(testException, testStack); + + // 5. Allow asynchronous error propagation to complete robustly. + await tester.pumpAndSettle(); + // Restore the handler now in case `expect`s in step 6 fail. + FlutterError.onError = oldHandler; + + // 6. CRITICAL ASSERTION: Verify that no FlutterError was reported via the onError handler + expect( + reportedErrors, + isEmpty, + reason: 'FlutterError.onError should not be called when an errorBuilder was provided.', + ); + // Also check takeException as a standard backup. + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'errorBuilder prevents FlutterError report only if errorBuilder is non-null when widget is disposed', + (WidgetTester tester) async { + // This test verifies that if an errorBuilder is provided, FlutterError.reportError + // is called, only if the errorBuilder stays present when the widget is unmounted. + + // 1. Setup: Capture FlutterError reports + final List reportedErrors = []; + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + FlutterError.onError = reportedErrors.add; + addTearDown(() { + FlutterError.onError = oldHandler; + }); // Ensure handler is restored + + final _TestImageProvider provider = _TestImageProvider(); + final Exception testException = Exception('Network failed'); + final StackTrace testStack = StackTrace.current; + + // Function to build the widget with the Image + Widget buildImage({required bool hasErrorBuilder}) { + return Directionality( + textDirection: TextDirection.ltr, + child: Image( + image: provider, + errorBuilder: + hasErrorBuilder ? (_, _, _) => const SizedBox(width: 10, height: 10) : null, + ), + ); + } + + // 2. Pump the widget with an errorBuilder + await tester.pumpWidget(buildImage(hasErrorBuilder: true)); + expect(find.byType(Image), findsOneWidget); + expect(reportedErrors, isEmpty); // No errors yet + + // 3. Update the widget with no errorBuilder + await tester.pumpWidget(buildImage(hasErrorBuilder: false)); + expect(find.byType(Image), findsOneWidget); + expect(reportedErrors, isEmpty); // No errors yet + + // 4. Remove the Image widget from the tree. + await tester.pumpWidget(const SizedBox.shrink()); + expect(find.byType(Image), findsNothing); + + // 5. Now, make the image provider fail *after* the widget state is disposed. + provider.fail(testException, testStack); + + // 5. Allow asynchronous error propagation to complete robustly. + await tester.pumpAndSettle(); + // Restore the handler now in case `expect`s in step 6 fail. + FlutterError.onError = oldHandler; + + // 6. Verify that a FlutterError was reported via the onError handler + expect( + reportedErrors, + isNotEmpty, + reason: + 'FlutterError.onError should be called when an errorBuilder was not provided eventually.', + ); + // Also check takeException as a standard backup. + expect(tester.takeException(), isNull); + }, + ); } @immutable