[Web] Allow specifying the strategy on when to use <img> element to display images (#159917)

This PR follows the discussion under
https://github.com/flutter/flutter/pull/157755 and adds a flag to
determine when `<img>` elements are used. By default the feature is
turned off.

Instead of just a boolean for on & off, I made it an enum to accept
multiple options. Notably, an `always` option can be useful when the
developer wants a unified experience regardless of the image origin
(such as when displaying an image from arbitrary URLs.)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Tong Mu 2025-01-13 22:16:18 -08:00 committed by GitHub
parent 3f981117eb
commit c16b9c52e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 378 additions and 84 deletions

View File

@ -21,7 +21,12 @@ typedef _SimpleDecoderCallback = Future<ui.Codec> Function(ui.ImmutableBuffer bu
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage>
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<image_provider.NetworkIm
@override
final Map<String, String>? headers;
@override
final image_provider.WebHtmlElementStrategy webHtmlElementStrategy;
@override
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
return SynchronousFuture<NetworkImage>(this);

View File

@ -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 <img> 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<ui.Codec> Function(ui.ImmutableBuffer buffer);
@ -40,17 +40,17 @@ void debugRestoreHttpRequestFactory() {
httpRequestFactory = _httpClient;
}
/// The default <img> element factory.
/// The default HTML element factory.
web.HTMLImageElement _imgElementFactory() {
return web.document.createElement('img') as web.HTMLImageElement;
}
/// The factory function that creates <img> 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 <img> 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<image_provider.NetworkImage>
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<image_provider.NetworkIm
@override
final Map<String, String>? headers;
@override
final image_provider.WebHtmlElementStrategy webHtmlElementStrategy;
@override
Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
return SynchronousFuture<NetworkImage>(this);
@ -136,19 +144,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
) async {
assert(key == this);
final Uri resolved = Uri.base.resolve(key.url);
final bool containsNetworkImageHeaders = key.headers?.isNotEmpty ?? false;
// We use a different method when headers are set because the
// `ui_web.createImageCodecFromUrl` method is not capable of handling headers.
if (containsNetworkImageHeaders) {
// It is not possible to load an <img> 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
// <img> tag without the headers.
Future<ImageStreamCompleter> 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<image_provider.NetworkIm
debugLabel: key.url,
informationCollector: _imageStreamInformationCollector(key),
);
} else if (isSkiaWeb) {
try {
// Resolve the Codec before passing it to
// [MultiFrameImageStreamCompleter] so any errors aren't reported
// twice (once from the MultiFrameImageStreamCompleter and again
// from the wrapping [ForwardingImageStreamCompleter]).
final ui.Codec codec = await _fetchImageBytes(decode);
return MultiFrameImageStreamCompleter(
chunkEvents: chunkEvents.stream,
codec: Future<ui.Codec>.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 <img>
// element instead.
final web.HTMLImageElement imageElement = imgElementFactory();
imageElement.src = key.url;
// Decode the <img> element before creating the ImageStreamCompleter
// to avoid double reporting the error.
await imageElement.decode().toDart;
return OneFrameImageStreamCompleter(
Future<ImageInfo>.value(WebImageInfo(imageElement, debugLabel: key.url)),
informationCollector: _imageStreamInformationCollector(key),
)..debugLabel = key.url;
}
} else {
}
Future<ImageStreamCompleter> loadViaImgElement() async {
// If we failed to fetch the bytes, try to load the image in an <img>
// element instead.
final web.HTMLImageElement imageElement = imgElementFactory();
imageElement.src = key.url;
// Decode the <img> element before creating the ImageStreamCompleter
// to avoid double reporting the error.
await imageElement.decode().toDart;
return OneFrameImageStreamCompleter(
Future<ImageInfo>.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 <img> 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 <img> 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 <img> tag
@ -198,6 +198,7 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
// [MultiFrameImageStreamCompleter] so any errors aren't reported
// twice (once from the MultiFrameImageStreamCompleter) and again
// from the wrapping [ForwardingImageStreamCompleter].
final Uri resolved = Uri.base.resolve(key.url);
final ui.Codec codec = await ui_web.createImageCodecFromUrl(
resolved,
chunkCallback: (int bytes, int total) {
@ -212,6 +213,21 @@ class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkIm
informationCollector: _imageStreamInformationCollector(key),
);
}
switch (webHtmlElementStrategy) {
case image_provider.WebHtmlElementStrategy.never:
return loadViaDecode();
case image_provider.WebHtmlElementStrategy.prefer:
return loadViaImgElement();
case image_provider.WebHtmlElementStrategy.fallback:
try {
// Await here so that errors occurred during the asynchronous process
// of `loadViaDecode` are caught and triggers `loadViaImgElement`.
return await loadViaDecode();
} catch (e) {
return loadViaImgElement();
}
}
}
Future<ui.Codec> _fetchImageBytes(_SimpleDecoderCallback decode) async {

View File

@ -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 <img> 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 <img> element is used.
/// still be displayed if an HTML element is used.
class WebImageInfo implements ImageInfo {
@override
ImageInfo clone() => _unsupported();

View File

@ -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 <img> 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 <img> element is used.
/// still be displayed if an HTML element is used.
class WebImageInfo implements ImageInfo {
/// Creates a new [WebImageInfo] from a given <img> element.
/// Creates a new [WebImageInfo] from a given HTML element.
WebImageInfo(this.htmlImage, {this.debugLabel});
/// The <img> element used to display this image. This <img> 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

View File

@ -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<ResizeImageKey> {
}
}
/// 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<NetworkImage> {
///
/// 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<String, String>? headers}) =
network_image.NetworkImage;
///
/// The [webHtmlElementStrategy] option is by default
/// [WebHtmlElementStrategy.never].
const factory NetworkImage(
String url, {
double scale,
Map<String, String>? 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<NetworkImage> {
/// When running Flutter on the web, headers are not used.
Map<String, String>? 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);

View File

@ -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 <img> 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 `<img>` 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 `<img>` 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.

View File

@ -54,7 +54,8 @@ class ImgElementPlatformView extends StatelessWidget {
}
}
/// A widget which displays and lays out an underlying `<img>` 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 `<img>` 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 `<img>` 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 `<img>` element similarly to [RenderImage].
/// Lays out and positions the child HTML element similarly to [RenderImage].
class RenderWebImage extends RenderShiftedBox {
/// Creates a new [RenderWebImage].
RenderWebImage({

View File

@ -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<String, String>? 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);

View File

@ -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(<int>[])).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<void>? imageCompleter;
await tester.runAsync(() async {
imageCompleter = Completer<void>();
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<void>? imageCompleter;
@ -141,28 +190,82 @@ void runTests() {
expect(imageInfo, isA<WebImageInfo>());
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(<int>[])).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<void>? imageCompleter;
await tester.runAsync(() async {
imageCompleter = Completer<void>();
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(<int>[])).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<void>? 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<WebImageInfo>());
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: <String, String>{'flutter': 'flutter', 'second': 'second'},
);
ImageInfo? imageInfo;
Object? recordedError;
Completer<void>? imageCompleter;
await tester.runAsync(() async {
imageCompleter = Completer<void>();
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<WebImageInfo>()));
}, skip: !isSkiaWeb);
testWidgets('Image renders an image using a Platform View if the image info is WebImageInfo', (

View File

@ -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<JSAny?> decode() {
if (_resultAssigned) {
return switch (_resultSuccessful) {
true => Future<JSAny?>.value().toJS,
false => Future<JSAny?>.error(Error()).toJS,
};
}
_callbacksAssigned = true;
return JSPromise<JSAny?>(
(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;