diff --git a/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart index 35ad6a38d..938e921ad 100644 --- a/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart @@ -97,10 +97,20 @@ class NetworkTileImageProvider extends ImageProvider { ) { final chunkEvents = StreamController(); + var loaded = false; + // An aborted load completes with a transparent image. Until then, another + // tile with this URL would share it from the image cache, so remove it now. + abortTrigger?.then((_) { + if (!loaded) PaintingBinding.instance.imageCache.evict(key); + }); + return MultiFrameImageStreamCompleter( codec: _loadImage(key, chunkEvents.sink, decode) ..then( - (_) => unawaited(chunkEvents.close()), + (_) { + loaded = true; + unawaited(chunkEvents.close()); + }, onError: (_) => unawaited(chunkEvents.close()), ), chunkEvents: chunkEvents.stream, diff --git a/test/layer/tile_layer/tile_provider/network_image_provider_abort_test.dart b/test/layer/tile_layer/tile_provider/network_image_provider_abort_test.dart new file mode 100644 index 000000000..10a927bf9 --- /dev/null +++ b/test/layer/tile_layer/tile_provider/network_image_provider_abort_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network/image_provider/image_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; + +import '../../../test_utils/test_tile_image.dart'; + +/// Answers after 100 ms, or fails 20 ms after the request's abort trigger. +class _SlowClient extends BaseClient { + int requests = 0; + @override + Future send(BaseRequest request) { + requests++; + final result = Completer(); + Timer(const Duration(milliseconds: 100), () { + if (!result.isCompleted) { + result + .complete(StreamedResponse(Stream.value(testWhiteTileBytes), 200)); + } + }); + if (request is Abortable) { + request.abortTrigger + ?.then((_) => Timer(const Duration(milliseconds: 20), () { + if (!result.isCompleted) { + result.completeError(RequestAbortedException(request.url)); + } + })); + } + return result.future; + } +} + +void main() { + testWidgets( + 'a tile requested again while an aborted request ends gets the image', + (tester) async { + final client = _SlowClient(); + NetworkTileImageProvider provider(Future abortTrigger) => + NetworkTileImageProvider( + url: 'https://example.com/1/2/3.png', + fallbackUrl: null, + headers: const {}, + httpClient: client, + abortTrigger: abortTrigger, + silenceExceptions: false, + attemptDecodeOfHttpErrorResponses: true, + cachingProvider: const DisabledMapCachingProvider(), + ); + await tester.runAsync(() async { + final cancelA = Completer(); + provider(cancelA.future).resolve(ImageConfiguration.empty); + await Future.delayed(const Duration(milliseconds: 10)); + cancelA.complete(); + await Future.delayed(const Duration(milliseconds: 5)); + final image = Completer(); + provider(Completer().future) + .resolve(ImageConfiguration.empty) + .addListener(ImageStreamListener((info, _) => image.complete(info), + onError: image.completeError)); + final info = await image.future.timeout(const Duration(seconds: 2)); + expect(client.requests, 2); + expect(info.image.width, 256); + }); + }); +}