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] 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((_) {}); +}