From 68a00c4424264d1e614a9e745bf5702e75b8d768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Neboj=C5=A1a=20Cvetkovi=C4=87?= Date: Mon, 7 Sep 2026 00:09:52 -0600 Subject: [PATCH 1/2] fix(tile_layer): Don't mark a failed tile ready to display without an error image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readyToDisplay documents itself as true when "loading errored but an error image is configured", but _display() set it on any error regardless. A tile that failed and has nothing to paint therefore counted as covering its area, so renderTiles discarded the coarser ancestor standing in for it and the area went blank instead of staying upscaled. Services whose coverage depth varies by location show this plainly: past the deepest level available at a given place the tiles 404, and zooming in lost detail that was already on screen. Set it from errorImage, as documented. Tiles that have an error image are unaffected, and must stay ready — opacity is gated on this, so the error image would otherwise never be painted. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/layer/tile_layer/tile_image.dart | 10 ++-- test/layer/tile_layer/tile_image_test.dart | 60 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 test/layer/tile_layer/tile_image_test.dart diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 71fe06a54..c77e4cb32 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -176,15 +176,17 @@ class TileImage extends ChangeNotifier { } } - // Initiates fading in and marks this TileImage as readyToDisplay when fading - // finishes. If fading is disabled or a loading error occurred this TileImage - // becomes readyToDisplay immediately. + // Called once loading has settled, successfully or not. Initiates fading in + // and marks this TileImage as readyToDisplay when fading finishes; immediate + // if fading is disabled, or if loading failed and there is an errorImage to + // paint instead. A failure with nothing to paint never becomes + // readyToDisplay. void _display() { final previouslyLoaded = loadFinishedAt != null; loadFinishedAt = DateTime.now(); if (loadError) { - _readyToDisplay = true; + _readyToDisplay = errorImage != null; if (!_disposed) notifyListeners(); return; } diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart new file mode 100644 index 000000000..9948370de --- /dev/null +++ b/test/layer/tile_layer/tile_image_test.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_utils/test_tile_image.dart'; + +void main() { + Future loadFailingTile({ImageProvider? errorImage}) { + final loaded = Completer(); + final tileImage = TileImage( + coordinates: const TileCoordinates(0, 0, 0), + vsync: const _TestTickerProvider(), + imageProvider: _FailingImageProvider(), + onLoadComplete: (_) => loaded.complete(), + onLoadError: (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + cancelLoading: Completer(), + errorImage: errorImage, + )..load(); + return loaded.future.then((_) => tileImage); + } + + test('a failed tile with no error image is not ready to display', () async { + final tileImage = await loadFailingTile(); + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isFalse); + }); + + test('a failed tile with an error image is ready to display', () async { + final tileImage = await loadFailingTile(errorImage: testWhiteTileImage); + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isTrue); + }); +} + +class _FailingImageProvider extends ImageProvider<_FailingImageProvider> { + @override + Future<_FailingImageProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + _FailingImageProvider key, + ImageDecoderCallback decode, + ) => + OneFrameImageStreamCompleter(Future.error(Exception('tile unavailable'))); +} + +class _TestTickerProvider implements TickerProvider { + const _TestTickerProvider(); + + @override + Ticker createTicker(TickerCallback onTick) => Ticker((_) {}); +} From 87140450d79e85f6aa3727ed5b303ee1d1a243b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Neboj=C5=A1a=20Cvetkovi=C4=87?= Date: Thu, 10 Sep 2026 10:22:39 -0600 Subject: [PATCH 2/2] fix(tile_layer): Treat the transparent placeholder as a failed load A TileProvider that cannot throw returns TileProvider.transparentImage instead, usually when a request is cancelled as its tile leaves the viewport. That arrives as an ordinary successful 1x1 decode, so the tile is marked loaded while painting nothing, and counts as covering its area. The cancelled tile itself is being disposed, so it is not the one that suffers. A tile created while that request is still in flight is: it resolves the same URL, attaches to the same pending image stream, and receives the placeholder. It is live, it claims to be loaded, and it paints nothing, so the layer drops the ancestor that was standing in for it. Report it as a load error so that ancestor is kept, and evict it for the benefit of providers that return the placeholder without evicting it themselves. It reaches errorTileCallback but is not printed: abandoning a load is routine while panning. Depends on the readyToDisplay fix in the previous commit; without it a load error still counts as covering and this changes a blank tile into a hole. --- lib/src/layer/tile_layer/tile_image.dart | 25 ++++++++++++++++++++++ lib/src/layer/tile_layer/tile_layer.dart | 3 ++- test/layer/tile_layer/tile_image_test.dart | 23 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index c77e4cb32..08674aa44 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -157,6 +157,18 @@ class TileImage extends ChangeNotifier { } void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { + if (imageInfo.image.width == 1 && imageInfo.image.height == 1) { + // TileProvider.transparentImage. Evicted so the next tile at these + // coordinates refetches instead of hitting it in the URL-keyed cache. + try { + unawaited(imageProvider.evict().catchError((Object _) => false)); + } catch (_) { + // As in `dispose`, `evict` can also throw synchronously. + } + _onImageLoadError(const TileLoadAbandonedException(), null); + return; + } + loadError = false; this.imageInfo = imageInfo; @@ -260,3 +272,16 @@ class TileImage extends ChangeNotifier { return 'TileImage($coordinates, readyToDisplay: $_readyToDisplay)'; } } + +/// Thrown when a tile resolves to [TileProvider.transparentImage], which a +/// [TileProvider] returns in place of an error it cannot throw, usually a +/// cancellation. +class TileLoadAbandonedException implements Exception { + /// Create a [TileLoadAbandonedException]. + const TileLoadAbandonedException(); + + @override + String toString() => + 'TileLoadAbandonedException: the tile provider returned the transparent ' + 'placeholder instead of tile data'; +} diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index c53e27ad0..7e30e365c 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -722,7 +722,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { zoom.round().clamp(widget.minNativeZoom, widget.maxNativeZoom); void _onTileLoadError(TileImage tile, Object error, StackTrace? stackTrace) { - debugPrint(error.toString()); + // An abandoned load is routine while panning, not a fault to report. + if (error is! TileLoadAbandonedException) debugPrint(error.toString()); widget.errorTileCallback?.call(tile, error, stackTrace); } diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart index 9948370de..4a9462f93 100644 --- a/test/layer/tile_layer/tile_image_test.dart +++ b/test/layer/tile_layer/tile_image_test.dart @@ -9,6 +9,8 @@ import 'package:flutter_test/flutter_test.dart'; import '../../test_utils/test_tile_image.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + Future loadFailingTile({ImageProvider? errorImage}) { final loaded = Completer(); final tileImage = TileImage( @@ -37,6 +39,27 @@ void main() { expect(tileImage.loadError, isTrue); expect(tileImage.readyToDisplay, isTrue); }); + + test('the transparent placeholder counts as a failure, not a tile', () async { + final loaded = Completer(); + Object? reportedError; + final tileImage = TileImage( + coordinates: const TileCoordinates(0, 0, 0), + vsync: const _TestTickerProvider(), + imageProvider: MemoryImage(TileProvider.transparentImage), + onLoadComplete: (_) => loaded.complete(), + onLoadError: (_, error, __) => reportedError = error, + tileDisplay: const TileDisplay.instantaneous(), + cancelLoading: Completer(), + errorImage: null, + )..load(); + await loaded.future; + + expect(tileImage.loadError, isTrue); + expect(tileImage.readyToDisplay, isFalse); + expect(tileImage.imageInfo, isNull); + expect(reportedError, isA()); + }); } class _FailingImageProvider extends ImageProvider<_FailingImageProvider> {