Skip to content

Commit c36bd57

Browse files
clement-igonetclaudeHarelM
authored
Treat an empty raster or raster-DEM tile response as no data (#1551) (#8207)
* Represent an empty tile response as no data instead of a fake 1x1 image Since #3428 an empty (HTTP 204) response was decoded into a fabricated 1x1 ImageBitmap so that empty raster tiles could be cached. That marker leaked into every image consumer: for raster-dem it became a DEM with dim 1 whose backfillBorder against a real neighbor threw "dem dimension mismatch" and broke terrain rendering (#1551). ImageRequest now resolves an empty payload with null data, keeping the expiry headers. Raster and raster-dem sources mark such tiles loaded without content. The image source, sprite loading and Map.loadImage surface an explicit error instead of silently using a 1x1 image. Co-Authored-By: Claude <noreply@anthropic.com> * Skip color-relief tiles without a DEM before reading from it A loaded raster-dem tile can now carry no DEM at all, and the first-tile setup reads getUnpackVector() from it before the existing guard is reached, so a colour-relief layer threw on every frame where an empty tile sorted first. Reported by @johncarmack1984 in review. Co-Authored-By: Claude <noreply@anthropic.com> * Apply review suggestions: drop the unreachable transparent fallback, document the rejection An empty response no longer reaches getImageUsingCreateImageBitmap, so the byteLength branch and its transparent placeholder can go. loadImage now says it rejects when the response carries no image data. Suggested by @johncarmack1984 in review. Co-Authored-By: Claude <noreply@anthropic.com> * Apply review suggestions Shorter changelog entry, the two inline explanations moved into tsdoc, and a plain else in image_source: transformAndGetImage resolves a response object or throws, never undefined. * Update CHANGELOG.md --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Harel M <harel.mazor@gmail.com>
1 parent 3e7c271 commit c36bd57

12 files changed

Lines changed: 123 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- _...Add new stuff here..._
44

55
### 🐞 Bug fixes
6+
- Treat an empty tile response (e.g. HTTP 204) as no data: raster-DEM tiles now load without elevation instead of failing with a `dem dimension mismatch` error, and empty raster tiles render as transparent ([#1551](https://github.com/maplibre/maplibre-gl-js/issues/1551)) (by [@clement-igonet](https://github.com/clement-igonet))
67
- _...Add new stuff here..._
78

89
## 6.7.0

src/source/image_source.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
265265
this.coordinates = newCoordinates;
266266
}
267267
this._finishLoading();
268+
} else {
269+
this.fire(new ErrorEvent(new Error(`Could not load image ${this.url}: the response is empty`)));
268270
}
269271
} catch (err) {
270272
// In case of abort error, the aborter may have started a new request so we don't want to clear its abort controller.

src/source/raster_dem_tile_source.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,32 @@ describe('RasterDEMTileSource', () => {
107107
expect(getImageSpy.mock.calls[0][3]).toEqual({colorSpaceConversion: 'none'});
108108
});
109109

110+
test('an empty tile response loads as a tile without DEM data', async () => {
111+
server.respondWith('/source.json', JSON.stringify({
112+
minzoom: 0,
113+
maxzoom: 22,
114+
tiles: ['http://example.com/{z}/{x}/{y}.png']
115+
}));
116+
const source = createSource({url: '/source.json'});
117+
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
118+
const promise = waitForMetadataEvent(source);
119+
await sleep(0);
120+
server.respond();
121+
await promise;
122+
const tile = {
123+
tileID: new OverscaledTileID(10, 0, 10, 5, 5),
124+
state: 'loading',
125+
loadVectorData () {},
126+
setExpiryData() {},
127+
actor: {sendAsync: vi.fn()}
128+
} as any as Tile;
129+
await source.loadTile(tile);
130+
131+
expect(tile.state).toBe('loaded');
132+
expect(tile.dem).toBeUndefined();
133+
expect((tile.actor as any).sendAsync).not.toHaveBeenCalled();
134+
});
135+
110136
test('can asynchronously transform tile request', async () => {
111137
server.respondWith('http://example.com/10/5/5.png',
112138
[200, {'Content-Type': 'image/png', 'Content-Length': 1, 'Cache-Control': 'max-age=100'}, '0']

src/source/raster_dem_tile_source.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,18 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
6464
tile.state = 'unloaded';
6565
return;
6666
}
67-
if (response?.data) {
68-
const img = response.data;
67+
if (response) {
6968
if (this.map._refreshExpiredTiles && (response.cacheControl || response.expires)) {
7069
tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires});
7170
}
71+
// An empty response (e.g. HTTP 204 for a missing DEM tile) carries no elevation
72+
// data: treat the tile as loaded without a DEM instead of building a degenerate
73+
// one that would fail against its neighbors in backfillBorder (#1551).
74+
if (!response.data) {
75+
tile.state = 'loaded';
76+
return;
77+
}
78+
const img = response.data;
7279
const transfer = isImageBitmap(img) && offscreenCanvasSupported();
7380
const rawImageData = transfer ? img : await this.readImageNow(img);
7481
const params = {

src/source/raster_tile_source.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,34 @@ describe('RasterTileSource', () => {
160160
expect(transformSpy.mock.calls[0][1]).toBe('Tile');
161161
});
162162

163+
test('an empty tile response loads as a transparent tile, so the parent does not show through', async () => {
164+
server.respondWith('/source.json', JSON.stringify({
165+
minzoom: 0,
166+
maxzoom: 22,
167+
tiles: ['http://example.com/{z}/{x}/{y}.png']
168+
}));
169+
const source = createSource({url: '/source.json'});
170+
vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: null});
171+
const update = vi.fn();
172+
source.map.painter = {context: {}, getTileTexture: () => ({update})} as any;
173+
const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
174+
await sleep(0);
175+
server.respond();
176+
await promise;
177+
const tile = {
178+
tileID: new OverscaledTileID(10, 0, 10, 5, 5),
179+
state: 'loading',
180+
loadVectorData () {},
181+
setExpiryData() {}
182+
} as any as Tile;
183+
await source.loadTile(tile);
184+
185+
expect(tile.state).toBe('loaded');
186+
expect(update).toHaveBeenCalledWith(
187+
expect.objectContaining({width: 1, height: 1, data: new Uint8Array(4)}),
188+
expect.anything());
189+
});
190+
163191
test('can asynchronously transform tile request', async () => {
164192
server.respondWith('http://example.com/10/5/5.png',
165193
[200, {'Content-Type': 'image/png', 'Content-Length': 1, 'Cache-Control': 'max-age=100'}, '0']

src/source/raster_tile_source.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {MapSourceDataEvent, type SourceEventType} from '../ui/events.ts';
88
import {loadTileJson} from './load_tilejson.ts';
99
import {TileBounds} from '../tile/tile_bounds.ts';
1010
import {Texture} from '../webgl/texture.ts';
11+
import {RGBAImage} from '../util/image.ts';
1112
import {isAbortError} from '../util/abort_error.ts';
1213

1314
import type {Source} from './source.ts';
@@ -221,13 +222,16 @@ export class RasterTileSource extends Evented<SourceEventType> implements Source
221222
tile.state = 'unloaded';
222223
return;
223224
}
224-
if (response?.data) {
225+
if (response) {
225226
if (this.map._refreshExpiredTiles && (response.cacheControl || response.expires)) {
226227
tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires});
227228
}
228229
const context = this.map.painter.context;
229230
const gl = context.gl;
230-
const img = response.data;
231+
// An empty response (e.g. HTTP 204) is a tile that exists but has no content:
232+
// it is drawn as fully transparent, which keeps it distinct from a missing
233+
// tile (404), where the parent tile shows through instead.
234+
const img = response.data ?? new RGBAImage({width: 1, height: 1}, new Uint8Array(4));
231235
tile.texture = this.map.painter.getTileTexture(img.width);
232236
if (tile.texture) {
233237
tile.texture.update(img, {useMipmap: true, premultiply});

src/style/load_sprite.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ async function doOnceCompleted(
6262
for (const spriteName in jsonsMap) {
6363
result[spriteName] = {};
6464

65-
const context = browser.getImageCanvasContext((await imagesMap[spriteName]).data);
65+
const image = (await imagesMap[spriteName]).data;
66+
if (!image) {
67+
throw new Error(`Could not load sprite image for ${spriteName}: the response is empty`);
68+
}
69+
const context = browser.getImageCanvasContext(image);
6670
const json = (await jsonsMap[spriteName]).data;
6771

6872
for (const id in json) {

src/ui/map.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3359,7 +3359,7 @@ export class Map extends Evented<MapEventType> {
33593359
* domains must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS).
33603360
*
33613361
* @param url - The URL of the image file. Image file must be in png, webp, or jpg format.
3362-
* @returns a promise that is resolved when the image is loaded
3362+
* @returns a promise that is resolved when the image is loaded, or rejected when the response has no image data (for example an HTTP 204)
33633363
*
33643364
* @example
33653365
* Load an image from an external URL.
@@ -3371,7 +3371,11 @@ export class Map extends Evented<MapEventType> {
33713371
* @see [Add an icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-an-icon-to-the-map/)
33723372
*/
33733373
async loadImage(url: string): Promise<GetResourceResponse<HTMLImageElement | ImageBitmap>> {
3374-
return ImageRequest.getImage(await this._requestManager.transformRequest(url, ResourceType.Image), new AbortController());
3374+
const response = await ImageRequest.getImage(await this._requestManager.transformRequest(url, ResourceType.Image), new AbortController());
3375+
if (!response.data) {
3376+
throw new Error(`Could not load image ${url}: the response is empty`);
3377+
}
3378+
return response;
33753379
}
33763380

33773381
/**

src/util/image_request.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,29 @@ describe('ImageRequest', () => {
131131
expect(createImageBitmapSpy).toHaveBeenCalledWith(expect.any(Blob), options);
132132
});
133133

134-
test('getImage using createImageBitmap throws exception', async () => {
134+
test('getImage resolves an empty response (e.g. HTTP 204) with null data and keeps the expiry headers', async () => {
135135
server.respondWith(request => { request.respond(200, {'Content-Type': 'image/png',
136136
'Cache-Control': 'cache',
137137
'Expires': 'expires'}, ''); });
138138

139+
const createImageBitmapSpy = vi.fn();
140+
stubAjaxGetImage(createImageBitmapSpy);
141+
const promise = ImageRequest.getImage({url: ''}, new AbortController());
142+
server.respond();
143+
144+
const response = await promise;
145+
146+
expect(response.data).toBeNull();
147+
expect(response.cacheControl).toBe('cache');
148+
expect(response.expires).toBe('expires');
149+
expect(createImageBitmapSpy).not.toHaveBeenCalled();
150+
});
151+
152+
test('getImage using createImageBitmap throws exception', async () => {
153+
server.respondWith(request => { request.respond(200, {'Content-Type': 'image/png',
154+
'Cache-Control': 'cache',
155+
'Expires': 'expires'}, '0'); });
156+
139157
stubAjaxGetImage(() => Promise.reject(new Error('error')));
140158

141159
const promise = ImageRequest.getImage({url: ''}, new AbortController());
@@ -149,7 +167,7 @@ describe('ImageRequest', () => {
149167
const makeRequestSky = vi.spyOn(ajax, 'makeRequest');
150168
server.respondWith(request => { request.respond(200, {'Content-Type': 'image/png',
151169
'Cache-Control': 'cache',
152-
'Expires': 'expires'}, ''); });
170+
'Expires': 'expires'}, '0'); });
153171

154172
const promise = ImageRequest.getImage({url: ''}, new AbortController());
155173

src/util/image_request.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ export namespace ImageRequest {
172172
}
173173
};
174174

175+
/**
176+
* Runs one queued image request.
177+
*
178+
* An empty response body (e.g. HTTP 204 for a tile without content) resolves with
179+
* `data: null`, keeping the expiry headers, so callers can handle the absence
180+
* explicitly and re-request when the headers say so.
181+
*/
175182
const doImageRequest = async (itemInQueue: ImageRequestQueueItem) => {
176183
itemInQueue.state = 'running';
177184
const {requestParameters, supportImageRefresh, imageBitmapOptions, onError, onSuccess, abortController} = itemInQueue;
@@ -205,7 +212,9 @@ export namespace ImageRequest {
205212
// User using addProtocol can directly return HTMLImageElement/ImageBitmap type
206213
// If HtmlImageElement is used to get image then response type will be HTMLImageElement
207214
onSuccess(response);
208-
} else if (response.data) {
215+
} else if (!response.data || response.data.byteLength === 0) {
216+
onSuccess({data: null, cacheControl: response.cacheControl, expires: response.expires});
217+
} else {
209218
const img = await arrayBufferToCanvasImageSource(response.data, imageBitmapOptions);
210219
onSuccess({data: img, cacheControl: response.cacheControl, expires: response.expires});
211220
}

0 commit comments

Comments
 (0)