diff --git a/packages/flutter/lib/src/painting/_network_image_io.dart b/packages/flutter/lib/src/painting/_network_image_io.dart index 71633cd2ae..e59d471161 100644 --- a/packages/flutter/lib/src/painting/_network_image_io.dart +++ b/packages/flutter/lib/src/painting/_network_image_io.dart @@ -21,7 +21,12 @@ typedef _SimpleDecoderCallback = Future Function(ui.ImmutableBuffer bu class NetworkImage extends image_provider.ImageProvider implements image_provider.NetworkImage { /// Creates an object that fetches the image at the given URL. - const NetworkImage(this.url, {this.scale = 1.0, this.headers}); + const NetworkImage( + this.url, { + this.scale = 1.0, + this.headers, + this.webHtmlElementStrategy = image_provider.WebHtmlElementStrategy.never, + }); @override final String url; @@ -32,6 +37,9 @@ class NetworkImage extends image_provider.ImageProvider? headers; + @override + final image_provider.WebHtmlElementStrategy webHtmlElementStrategy; + @override Future obtainKey(image_provider.ImageConfiguration configuration) { return SynchronousFuture(this); diff --git a/packages/flutter/lib/src/painting/_network_image_web.dart b/packages/flutter/lib/src/painting/_network_image_web.dart index 22b809b9de..c02726e47d 100644 --- a/packages/flutter/lib/src/painting/_network_image_web.dart +++ b/packages/flutter/lib/src/painting/_network_image_web.dart @@ -18,9 +18,9 @@ import 'image_stream.dart'; /// used for testing purposes. typedef HttpRequestFactory = web.XMLHttpRequest Function(); -/// The type for an overridable factory function for creating elements, -/// used for testing purposes. -typedef ImgElementFactory = web.HTMLImageElement Function(); +/// The type for an overridable factory function for creating HTML elements to +/// display images, used for testing purposes. +typedef HtmlElementFactory = web.HTMLImageElement Function(); // Method signature for _loadAsync decode callbacks. typedef _SimpleDecoderCallback = Future Function(ui.ImmutableBuffer buffer); @@ -40,17 +40,17 @@ void debugRestoreHttpRequestFactory() { httpRequestFactory = _httpClient; } -/// The default element factory. +/// The default HTML element factory. web.HTMLImageElement _imgElementFactory() { return web.document.createElement('img') as web.HTMLImageElement; } -/// The factory function that creates elements, can be overridden for +/// The factory function that creates HTML elements, can be overridden for /// tests. @visibleForTesting -ImgElementFactory imgElementFactory = _imgElementFactory; +HtmlElementFactory imgElementFactory = _imgElementFactory; -/// Restores the default element factory. +/// Restores the default HTML element factory. @visibleForTesting void debugRestoreImgElementFactory() { imgElementFactory = _imgElementFactory; @@ -63,7 +63,12 @@ void debugRestoreImgElementFactory() { class NetworkImage extends image_provider.ImageProvider implements image_provider.NetworkImage { /// Creates an object that fetches the image at the given URL. - const NetworkImage(this.url, {this.scale = 1.0, this.headers}); + const NetworkImage( + this.url, { + this.scale = 1.0, + this.headers, + this.webHtmlElementStrategy = image_provider.WebHtmlElementStrategy.never, + }); @override final String url; @@ -74,6 +79,9 @@ class NetworkImage extends image_provider.ImageProvider? headers; + @override + final image_provider.WebHtmlElementStrategy webHtmlElementStrategy; + @override Future obtainKey(image_provider.ImageConfiguration configuration) { return SynchronousFuture(this); @@ -136,19 +144,7 @@ class NetworkImage extends image_provider.ImageProvider element and pass the headers with - // the request to fetch the image. Since the user has provided headers, - // this function should assume the headers are required to resolve to - // the correct resource and should not attempt to load the image in an - // tag without the headers. - + Future loadViaDecode() async { // Resolve the Codec before passing it to // [MultiFrameImageStreamCompleter] so any errors aren't reported // twice (once from the MultiFrameImageStreamCompleter and again @@ -161,34 +157,38 @@ class NetworkImage extends image_provider.ImageProvider.value(codec), - scale: key.scale, - debugLabel: key.url, - informationCollector: _imageStreamInformationCollector(key), - ); - } catch (e) { - // If we failed to fetch the bytes, try to load the image in an - // element instead. - final web.HTMLImageElement imageElement = imgElementFactory(); - imageElement.src = key.url; - // Decode the element before creating the ImageStreamCompleter - // to avoid double reporting the error. - await imageElement.decode().toDart; - return OneFrameImageStreamCompleter( - Future.value(WebImageInfo(imageElement, debugLabel: key.url)), - informationCollector: _imageStreamInformationCollector(key), - )..debugLabel = key.url; - } - } else { + } + + Future loadViaImgElement() async { + // If we failed to fetch the bytes, try to load the image in an + // element instead. + final web.HTMLImageElement imageElement = imgElementFactory(); + imageElement.src = key.url; + // Decode the element before creating the ImageStreamCompleter + // to avoid double reporting the error. + await imageElement.decode().toDart; + return OneFrameImageStreamCompleter( + Future.value(WebImageInfo(imageElement, debugLabel: key.url)), + informationCollector: _imageStreamInformationCollector(key), + )..debugLabel = key.url; + } + + final bool containsNetworkImageHeaders = key.headers?.isNotEmpty ?? false; + // When headers are set, the image can only be loaded by decoding. + // + // For the HTML renderer, `ui_web.createImageCodecFromUrl` method is not + // capable of handling headers. + // + // For CanvasKit and Skwasm, it is not possible to load an element and + // pass the headers with the request to fetch the image. Since the user has + // provided headers, this function should assume the headers are required to + // resolve to the correct resource and should not attempt to load the image + // in an tag without the headers. + if (containsNetworkImageHeaders) { + return loadViaDecode(); + } + + if (!isSkiaWeb) { // This branch is only hit by the HTML renderer, which is deprecated. The // HTML renderer supports loading images with CORS restrictions, so we // don't need to catch errors and try loading the image in an tag @@ -198,6 +198,7 @@ class NetworkImage extends image_provider.ImageProvider _fetchImageBytes(_SimpleDecoderCallback decode) async { diff --git a/packages/flutter/lib/src/painting/_web_image_info_io.dart b/packages/flutter/lib/src/painting/_web_image_info_io.dart index a160c279ba..85308308a4 100644 --- a/packages/flutter/lib/src/painting/_web_image_info_io.dart +++ b/packages/flutter/lib/src/painting/_web_image_info_io.dart @@ -7,12 +7,12 @@ import 'dart:ui' as ui; import 'image_stream.dart'; /// An [ImageInfo] object indicating that the image can only be displayed in -/// an element, and no [dart:ui.Image] can be created for it. +/// an HTML element, and no [dart:ui.Image] can be created for it. /// /// This occurs on the web when the image resource is from a different origin /// and is not configured for CORS. Since the image bytes cannot be directly /// fetched, [ui.Image]s cannot be created from it. However, the image can -/// still be displayed if an element is used. +/// still be displayed if an HTML element is used. class WebImageInfo implements ImageInfo { @override ImageInfo clone() => _unsupported(); diff --git a/packages/flutter/lib/src/painting/_web_image_info_web.dart b/packages/flutter/lib/src/painting/_web_image_info_web.dart index f3240aa41a..6b663860d7 100644 --- a/packages/flutter/lib/src/painting/_web_image_info_web.dart +++ b/packages/flutter/lib/src/painting/_web_image_info_web.dart @@ -8,18 +8,18 @@ import '../web.dart' as web; import 'image_stream.dart'; /// An [ImageInfo] object indicating that the image can only be displayed in -/// an element, and no [dart:ui.Image] can be created for it. +/// an HTML element, and no [dart:ui.Image] can be created for it. /// /// This occurs on the web when the image resource is from a different origin /// and is not configured for CORS. Since the image bytes cannot be directly /// fetched, [Image]s cannot be created from it. However, the image can -/// still be displayed if an element is used. +/// still be displayed if an HTML element is used. class WebImageInfo implements ImageInfo { - /// Creates a new [WebImageInfo] from a given element. + /// Creates a new [WebImageInfo] from a given HTML element. WebImageInfo(this.htmlImage, {this.debugLabel}); - /// The element used to display this image. This element has - /// already been decoded, so size information can be retrieved from it. + /// The HTML element used to display this image. This HTML element has already + /// decoded the image, so size information can be retrieved from it. final web.HTMLImageElement htmlImage; @override diff --git a/packages/flutter/lib/src/painting/image_provider.dart b/packages/flutter/lib/src/painting/image_provider.dart index a56a391a9b..e50aa71c6d 100644 --- a/packages/flutter/lib/src/painting/image_provider.dart +++ b/packages/flutter/lib/src/painting/image_provider.dart @@ -6,6 +6,7 @@ // late BuildContext context; /// @docImport 'package:flutter/widgets.dart'; +/// @docImport '_web_image_info_io.dart'; library; import 'dart:async'; @@ -1467,10 +1468,46 @@ class ResizeImage extends ImageProvider { } } +/// The strategy for [Image.network] and [NetworkImage] to decide whether to +/// display images in HTML elements contained in a platform view instead of +/// fetching bytes. +/// +/// See [Image.network] for more explanation on the impact. +/// +/// This option is only effective on the Web platform. Other platforms always +/// display network images by fetching bytes. +enum WebHtmlElementStrategy { + /// Only show images by fetching bytes, and report errors if the fetch + /// encounters errors. + never, + + /// Prefer fetching bytes to display images, and fall back to HTML elements + /// when fetching bytes is not available. + /// + /// This strategy uses HTML elements only if `headers` is empty and the fetch + /// encounters errors. Errors may still be reported if neither approach works. + fallback, + + /// Prefer HTML elements to display images, and fall back to fetching bytes + /// when HTML elements do not work. + /// + /// This strategy fetches bytes only if `headers` is not empty, since HTML + /// elements do not support headers. Errors may still be reported if neither + /// approach works. + prefer, +} + /// Fetches the given URL from the network, associating it with the given scale. /// /// The image will be cached regardless of cache headers from the server. /// +/// Typically this class resolves to an image stream that ultimately produces +/// [dart:ui.Image]s. On the Web platform, the [webHtmlElementStrategy] +/// parameter can be used to make the image stream ultimately produce an +/// [WebImageInfo] instead, which makes [Image.network] display the image as an +/// HTML element in a platform view. The feature is by default turned off +/// ([WebHtmlElementStrategy.never]). See [Image.network] for more explanation. +/// /// See also: /// /// * [Image.network] for a shorthand of an [Image] widget backed by [NetworkImage]. @@ -1484,8 +1521,15 @@ abstract class NetworkImage extends ImageProvider { /// /// The [scale] argument is the linear scale factor for drawing this image at /// its intended size. See [ImageInfo.scale] for more information. - const factory NetworkImage(String url, {double scale, Map? headers}) = - network_image.NetworkImage; + /// + /// The [webHtmlElementStrategy] option is by default + /// [WebHtmlElementStrategy.never]. + const factory NetworkImage( + String url, { + double scale, + Map? headers, + WebHtmlElementStrategy webHtmlElementStrategy, + }) = network_image.NetworkImage; /// The URL from which the image will be fetched. String get url; @@ -1498,6 +1542,17 @@ abstract class NetworkImage extends ImageProvider { /// When running Flutter on the web, headers are not used. Map? get headers; + /// On the Web platform, specifies when the image is loaded as a + /// [WebImageInfo], which causes [Image.network] to display the image in an + /// HTML element in a platform view. + /// + /// See [Image.network] for more explanation. + /// + /// Defaults to [WebHtmlElementStrategy.never]. + /// + /// Has no effect on other platforms, which always fetch bytes. + WebHtmlElementStrategy get webHtmlElementStrategy; + @override ImageStreamCompleter loadBuffer(NetworkImage key, DecoderBufferCallback decode); diff --git a/packages/flutter/lib/src/widgets/_web_image_io.dart b/packages/flutter/lib/src/widgets/_web_image_io.dart index e7c7050cca..d4520480db 100644 --- a/packages/flutter/lib/src/widgets/_web_image_io.dart +++ b/packages/flutter/lib/src/widgets/_web_image_io.dart @@ -6,7 +6,7 @@ import '../painting/_web_image_info_io.dart'; import 'basic.dart'; import 'framework.dart'; -/// A [Widget] that displays an image that is backed by an element. +/// A [Widget] that displays an image that is backed by an HTML element. class RawWebImage extends StatelessWidget { /// Creates a [RawWebImage]. RawWebImage({ @@ -22,7 +22,7 @@ class RawWebImage extends StatelessWidget { throw UnsupportedError('Cannot create a $RawWebImage when not running on the web'); } - /// The underlying `` element to be displayed. + /// The underlying HTML element to be displayed. final WebImageInfo image; /// A debug label explaining the image. @@ -34,7 +34,7 @@ class RawWebImage extends StatelessWidget { /// The requested height for this widget. final double? height; - /// How the `` should be inscribed in the box constraining it. + /// How the HTML element should be inscribed in the box constraining it. final BoxFit? fit; /// How the image should be aligned in the box constraining it. diff --git a/packages/flutter/lib/src/widgets/_web_image_web.dart b/packages/flutter/lib/src/widgets/_web_image_web.dart index 7139ad408a..79710ed8c8 100644 --- a/packages/flutter/lib/src/widgets/_web_image_web.dart +++ b/packages/flutter/lib/src/widgets/_web_image_web.dart @@ -54,7 +54,8 @@ class ImgElementPlatformView extends StatelessWidget { } } -/// A widget which displays and lays out an underlying `` platform view. +/// A widget which displays and lays out an underlying HTML element in a +/// platform view. class RawWebImage extends SingleChildRenderObjectWidget { /// Creates a [RawWebImage]. RawWebImage({ @@ -68,7 +69,7 @@ class RawWebImage extends SingleChildRenderObjectWidget { this.matchTextDirection = false, }) : super(child: ImgElementPlatformView(image.htmlImage.src)); - /// The underlying `` element to be displayed. + /// The underlying HTML element to be displayed. final WebImageInfo image; /// A debug label explaining the image. @@ -80,7 +81,7 @@ class RawWebImage extends SingleChildRenderObjectWidget { /// The requested height for this widget. final double? height; - /// How the `` should be inscribed in the box constraining it. + /// How the HTML element should be inscribed in the box constraining it. final BoxFit? fit; /// How the image should be aligned in the box constraining it. @@ -117,7 +118,7 @@ class RawWebImage extends SingleChildRenderObjectWidget { } } -/// Lays out and positions the child `` element similarly to [RenderImage]. +/// Lays out and positions the child HTML element similarly to [RenderImage]. class RenderWebImage extends RenderShiftedBox { /// Creates a new [RenderWebImage]. RenderWebImage({ diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 72312e71ff..d2d6762ede 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -393,6 +393,34 @@ class Image extends StatefulWidget { /// In the case where the network image is on the Web platform, the [cacheWidth] /// and [cacheHeight] parameters are ignored as the web engine delegates /// image decoding to the web which does not support custom decode sizes. + /// + /// ### Same-origin policy on Web + /// + /// Due to browser restriction on Cross-Origin Resource Sharing (CORS), + /// Flutter on the Web platform can not fetch images from other origins + /// (domain, scheme, or port) than the origin that hosts the app, unless the + /// image hosting origin explicitly allows so. CORS errors can be resolved + /// by configuring the image hosting server. More information can be + /// found at Mozilla's introduction on + /// [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) + /// and + /// [CORS errors](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors). + /// + /// If it's not possible to configure the host, such as when images are hosted + /// on a CDN or from arbitrary URLs, the app can set the + /// `webHtmlElementStrategy` parameter of [Image.network] to display the image + /// in an HTML element, which bypasses the same-origin policy. + /// + /// The HTML element is placed in a platform view, and therefore has the + /// following drawbacks: + /// + /// * Suboptimal performance. + /// * Can't be captured by screenshot widgets. + /// * The `headers` argument must be null or empty. + /// * Some image options are ignored, including [opacity], [colorBlendMode], + /// [repeat], filtering, and blurring. + /// + /// By default, this feature is turned off ([WebHtmlElementStrategy.never]). Image.network( String src, { super.key, @@ -418,10 +446,16 @@ class Image extends StatefulWidget { Map? headers, int? cacheWidth, int? cacheHeight, + WebHtmlElementStrategy webHtmlElementStrategy = WebHtmlElementStrategy.never, }) : image = ResizeImage.resizeIfNeeded( cacheWidth, cacheHeight, - NetworkImage(src, scale: scale, headers: headers), + NetworkImage( + src, + scale: scale, + headers: headers, + webHtmlElementStrategy: webHtmlElementStrategy, + ), ), assert(cacheWidth == null || cacheWidth > 0), assert(cacheHeight == null || cacheHeight > 0); diff --git a/packages/flutter/test/painting/_network_image_test_web.dart b/packages/flutter/test/painting/_network_image_test_web.dart index 6827a91525..a65af41a65 100644 --- a/packages/flutter/test/painting/_network_image_test_web.dart +++ b/packages/flutter/test/painting/_network_image_test_web.dart @@ -97,7 +97,53 @@ void runTests() { ); }); - testWidgets('emits a WebImageInfo if the image is cross-origin', (WidgetTester tester) async { + testWidgets('When strategy is default, emits an error if the image is cross-origin', ( + WidgetTester tester, + ) async { + final TestHttpRequest failingRequest = + TestHttpRequest() + ..status = 500 + ..mockEvent = MockEvent('load', web.Event('bytes inaccessible')) + ..response = (Uint8List.fromList([])).buffer; + + httpRequestFactory = () { + return failingRequest.getMock() as web_shim.XMLHttpRequest; + }; + + imgElementFactory = () { + throw UnimplementedError(); + }; + + const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame4.png'); + ImageInfo? imageInfo; + Object? recordedError; + Completer? imageCompleter; + await tester.runAsync(() async { + imageCompleter = Completer(); + final ImageStream stream = networkImage.resolve(ImageConfiguration.empty); + stream.addListener( + ImageStreamListener( + (ImageInfo info, bool isSync) { + imageInfo = info; + imageCompleter!.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + recordedError = error; + imageCompleter!.complete(); + }, + ), + ); + }); + await tester.runAsync(() async { + await imageCompleter!.future; + }); + expect(recordedError, isNotNull); + expect(imageInfo, isNull); + }, skip: !isSkiaWeb); + + testWidgets('When strategy is .fallback, emits a WebImageInfo if the image is cross-origin', ( + WidgetTester tester, + ) async { final TestHttpRequest failingRequest = TestHttpRequest() ..status = 500 @@ -113,7 +159,10 @@ void runTests() { return testImg.getMock() as web_shim.HTMLImageElement; }; - const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame4.png'); + const NetworkImage networkImage = NetworkImage( + 'https://www.example.com/images/frame5.png', + webHtmlElementStrategy: WebHtmlElementStrategy.fallback, + ); ImageInfo? imageInfo; Object? recordedError; Completer? imageCompleter; @@ -141,28 +190,82 @@ void runTests() { expect(imageInfo, isA()); final WebImageInfo webImageInfo = imageInfo! as WebImageInfo; - expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame4.png')); + expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame5.png')); }, skip: !isSkiaWeb); - testWidgets('emits an error if the image is cross-origin but fails to decode', ( + testWidgets( + 'When strategy is .fallback, emits an error if the image is cross-origin but fails to decode', + (WidgetTester tester) async { + final TestHttpRequest failingRequest = + TestHttpRequest() + ..status = 500 + ..mockEvent = MockEvent('load', web.Event('bytes inaccessible')) + ..response = (Uint8List.fromList([])).buffer; + final TestImgElement testImg = TestImgElement(); + + httpRequestFactory = () { + return failingRequest.getMock() as web_shim.XMLHttpRequest; + }; + + imgElementFactory = () { + return testImg.getMock() as web_shim.HTMLImageElement; + }; + + const NetworkImage networkImage = NetworkImage( + 'https://www.example.com/images/frame6.png', + webHtmlElementStrategy: WebHtmlElementStrategy.fallback, + ); + ImageInfo? imageInfo; + Object? recordedError; + Completer? imageCompleter; + await tester.runAsync(() async { + imageCompleter = Completer(); + final ImageStream stream = networkImage.resolve(ImageConfiguration.empty); + stream.addListener( + ImageStreamListener( + (ImageInfo info, bool isSync) { + imageInfo = info; + imageCompleter!.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + recordedError = error; + imageCompleter!.complete(); + }, + ), + ); + }); + await tester.runAsync(() async { + testImg.decodeFailure(); + await imageCompleter!.future; + }); + expect(recordedError, isNotNull); + expect(imageInfo, isNull); + }, + skip: !isSkiaWeb, + ); + + testWidgets('When strategy is .prefer, emits an WebImageInfo if the image is same-origin', ( WidgetTester tester, ) async { - final TestHttpRequest failingRequest = + final TestHttpRequest testHttpRequest = TestHttpRequest() - ..status = 500 - ..mockEvent = MockEvent('load', web.Event('bytes inaccessible')) - ..response = (Uint8List.fromList([])).buffer; + ..status = 200 + ..mockEvent = MockEvent('load', web.Event('test error')) + ..response = (Uint8List.fromList(kTransparentImage)).buffer; final TestImgElement testImg = TestImgElement(); httpRequestFactory = () { - return failingRequest.getMock() as web_shim.XMLHttpRequest; + return testHttpRequest.getMock() as web_shim.XMLHttpRequest; }; imgElementFactory = () { return testImg.getMock() as web_shim.HTMLImageElement; }; - const NetworkImage networkImage = NetworkImage('https://www.example.com/images/frame5.png'); + const NetworkImage networkImage = NetworkImage( + 'https://www.example.com/images/frame7.png', + webHtmlElementStrategy: WebHtmlElementStrategy.prefer, + ); ImageInfo? imageInfo; Object? recordedError; Completer? imageCompleter; @@ -183,11 +286,65 @@ void runTests() { ); }); await tester.runAsync(() async { - testImg.decodeFailure(); + testImg.decodeSuccess(); await imageCompleter!.future; }); - expect(recordedError, isNotNull); - expect(imageInfo, isNull); + expect(recordedError, isNull); + expect(imageInfo, isA()); + + final WebImageInfo webImageInfo = imageInfo! as WebImageInfo; + expect(webImageInfo.htmlImage.src, equals('https://www.example.com/images/frame7.png')); + }, skip: !isSkiaWeb); + + testWidgets('When strategy is .prefer, emits a normal image if headers is not null', ( + WidgetTester tester, + ) async { + final TestHttpRequest testHttpRequest = + TestHttpRequest() + ..status = 200 + ..mockEvent = MockEvent('load', web.Event('test error')) + ..response = (Uint8List.fromList(kTransparentImage)).buffer; + final TestImgElement testImg = TestImgElement(); + + httpRequestFactory = () { + return testHttpRequest.getMock() as web_shim.XMLHttpRequest; + }; + + imgElementFactory = () { + return testImg.getMock() as web_shim.HTMLImageElement; + }; + + const NetworkImage networkImage = NetworkImage( + 'https://www.example.com/images/frame8.png', + webHtmlElementStrategy: WebHtmlElementStrategy.prefer, + headers: {'flutter': 'flutter', 'second': 'second'}, + ); + ImageInfo? imageInfo; + Object? recordedError; + Completer? imageCompleter; + await tester.runAsync(() async { + imageCompleter = Completer(); + final ImageStream stream = networkImage.resolve(ImageConfiguration.empty); + stream.addListener( + ImageStreamListener( + (ImageInfo info, bool isSync) { + imageInfo = info; + imageCompleter!.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + recordedError = error; + imageCompleter!.complete(); + }, + ), + ); + }); + await tester.runAsync(() async { + testImg.decodeSuccess(); + await imageCompleter!.future; + }); + expect(recordedError, isNull); + expect(imageInfo, isNotNull); + expect(imageInfo, isNot(isA())); }, skip: !isSkiaWeb); testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo', ( diff --git a/packages/flutter/test/painting/_test_http_request.dart b/packages/flutter/test/painting/_test_http_request.dart index 048493ddca..5ca2ea7f56 100644 --- a/packages/flutter/test/painting/_test_http_request.dart +++ b/packages/flutter/test/painting/_test_http_request.dart @@ -129,10 +129,23 @@ class TestImgElement { int naturalWidth = -1; int naturalHeight = -1; - late JSFunction _resolveFunc; - late JSFunction _rejectFunc; + // Either `decode` or `decodeSuccess/Failure` may be called first. + // The following fields allow properly handling either case. + bool _callbacksAssigned = false; + late final JSFunction _resolveFunc; + late final JSFunction _rejectFunc; + + bool _resultAssigned = false; + late final bool _resultSuccessful; JSPromise decode() { + if (_resultAssigned) { + return switch (_resultSuccessful) { + true => Future.value().toJS, + false => Future.error(Error()).toJS, + }; + } + _callbacksAssigned = true; return JSPromise( (JSFunction resolveFunc, JSFunction rejectFunc) { _resolveFunc = resolveFunc; @@ -142,11 +155,21 @@ class TestImgElement { } void decodeSuccess() { - _resolveFunc.callAsFunction(); + if (_callbacksAssigned) { + _resolveFunc.callAsFunction(); + } else { + _resultAssigned = true; + _resultSuccessful = true; + } } void decodeFailure() { - _rejectFunc.callAsFunction(); + if (_callbacksAssigned) { + _rejectFunc.callAsFunction(); + } else { + _resultAssigned = true; + _resultSuccessful = false; + } } web.HTMLImageElement getMock() => _mock as web.HTMLImageElement;