Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### 🐞 Bug fixes
- _...Add new stuff here..._
- Fix: Add proper cancellation of in-flight raster/vector tile requests on setUrl ([#7149](https://github.com/maplibre/maplibre-gl-js/pull/7149) (by [@pcardinal](https://github.com/pcardinal))

## 5.21.0

Expand Down
143 changes: 143 additions & 0 deletions src/source/raster_tile_source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {fakeServer, type FakeServer} from 'nise';
import {type Tile} from '../tile/tile';
import {sleep, stubAjaxGetImage, waitForEvent} from '../util/test/util';
import {type MapSourceDataEvent} from '../ui/events';
import * as loadTileJSONModule from './load_tilejson';

function createSource(options, transformCallback?) {
const source = new RasterTileSource('id', options, {send() {}} as any as Dispatcher, options.eventedParent);
Expand All @@ -22,6 +23,10 @@ function createSource(options, transformCallback?) {
return source;
}

function isAbortPendingTileRequestsEvent(event: MapSourceDataEvent) {
return event.type === 'data' && event.abortPendingTileRequests === true;
}

describe('RasterTileSource', () => {
let server: FakeServer;
beforeEach(() => {
Expand All @@ -31,6 +36,7 @@ describe('RasterTileSource', () => {

afterEach(() => {
server.restore();
vi.restoreAllMocks();
});

test('transforms request for TileJSON URL', () => {
Expand Down Expand Up @@ -402,4 +408,141 @@ describe('RasterTileSource', () => {
await expect(loadPromise).resolves.toBeUndefined();
expect(tile.state).toBe('unloaded');
});

test('loads tile after previous abort flag was set', async () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see an abort flag in the test, am I missing anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The abort flag is set in the test fixture on the tile object (aborted: true) before calling loadTile, but that was not very explicit. I updated the test to assert the initial flag (expect(tile.aborted).toBe(true)) and then verify it is reset to false after the load succeeds.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not simply set it to true instead of asserting? Or have the server response with an abort response?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, and I updated the test in that direction.

Instead of setting aborted to true and then asserting the same value, the test now follows the real runtime sequence:

Start a first tile load.
Abort it in flight through the tile AbortController (and mark aborted as true, as happens in runtime).
Verify the tile ends in unloaded state.
Start a second load and let the server return 200.
Verify the tile loads successfully and aborted is reset to false.
I did not use a server-side “abort response” because this abort path is client-side cancellation, not an HTTP error contract. Simulating the in-flight client abort is closer to production behavior and gives better regression coverage.
Also, I used tile.aborted = true manually to stay consistent with the existing testing patterns in this file.

server.respondWith('/source.json', JSON.stringify({
minzoom: 0,
maxzoom: 22,
attribution: 'MapLibre',
tiles: ['http://example.com/{z}/{x}/{y}.png'],
bounds: [-47, -7, -45, -5]
}));
server.respondWith('http://example.com/10/5/5.png',
[200, {'Content-Type': 'image/png', 'Content-Length': 1}, '0']
);

const source = createSource({url: '/source.json'});
source.map.painter = {context: {}, getTileTexture: () => ({update: () => {}})} as any;
source.map._refreshExpiredTiles = true;

const sourcePromise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
await sleep(0);
server.respond();
await sourcePromise;

const tile = {
tileID: new OverscaledTileID(10, 0, 10, 5, 5),
state: 'loading',
setExpiryData() {}
} as any as Tile;

// First load: abort in-flight to simulate a real previous abort
const firstPromise = source.loadTile(tile);
await sleep(0);
tile.abortController.abort();
tile.aborted = true;
server.respond();
await firstPromise;
expect(tile.state).toBe('unloaded');

// Second load: should clear the stale abort flag and succeed
tile.state = 'loading';
const tilePromise = source.loadTile(tile);
await sleep(0);
server.respond();
await tilePromise;

expect(tile.state).toBe('loaded');
expect(tile.aborted).toBe(false);
});

test('setSourceProperty emits abortPendingTileRequests before callback and load(true)', () => {
const source = createSource({
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
const fireSpy = vi.spyOn(source, 'fire');
const callback = vi.fn();
const loadSpy = vi.spyOn(source, 'load');

source.setSourceProperty(callback);

// calls[0] is the abort signal; load() fires 'dataloading' synchronously as calls[1]
expect(fireSpy.mock.calls[0][0]).toMatchObject({type: 'data', abortPendingTileRequests: true});
expect(fireSpy.mock.invocationCallOrder[0]).toBeLessThan(callback.mock.invocationCallOrder[0]);
expect(callback.mock.invocationCallOrder[0]).toBeLessThan(loadSpy.mock.invocationCallOrder[0]);

expect(callback).toHaveBeenCalledTimes(1);
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(loadSpy).toHaveBeenCalledWith(true);
});

test('setUrl emits abortPendingTileRequests and calls load(true)', () => {
const source = createSource({
tiles: ['http://example.com/{z}/{x}/{y}.png']
});
const loadSpy = vi.spyOn(source, 'load');
const events: Array<MapSourceDataEvent> = [];
source.on('data', (e: MapSourceDataEvent) => events.push(e));

source.setUrl('http://localhost:2900/source2.json');

expect(events.some((e) => isAbortPendingTileRequestsEvent(e))).toBe(true);
expect(source.url).toBe('http://localhost:2900/source2.json');
expect(loadSpy).toHaveBeenCalledWith(true);
});

test('load ignores AbortError from TileJSON request', async () => {
const source = createSource({
tiles: ['http://example.com/{z}/{x}/{y}.png']
});

const abortError = new Error('aborted');
abortError.name = 'AbortError';
vi.spyOn(loadTileJSONModule, 'loadTileJson').mockRejectedValueOnce(abortError);

const onError = vi.fn();
source.on('error', onError);

await source.load(true);

expect(onError).not.toHaveBeenCalled();
});

test('load emits error event on TileJSON network error (non-abort)', async () => {
const tileJSONUrl = '/source-network-error.json';
let requestCount = 0;
server.respondWith(tileJSONUrl, (xhr) => {
requestCount++;
if (requestCount === 1) {
xhr.respond(200, {'Content-Type': 'application/json'}, JSON.stringify({
minzoom: 0,
maxzoom: 22,
attribution: 'MapLibre',
tiles: ['http://example.com/{z}/{x}/{y}.png']
}));
return;
}

xhr.respond(500, {'Content-Type': 'text/plain'}, 'server error');
});

const source = createSource({url: tileJSONUrl});

const metadataPromise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
await sleep(0);
server.respond();
await metadataPromise;

const onError = vi.fn();
source.on('error', onError);

const loadPromise = source.load(true);
await sleep(0);
server.respond();
await loadPromise;

expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0].error.status).toBe(500);
});

});
9 changes: 8 additions & 1 deletion src/source/raster_tile_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,13 @@ export class RasterTileSource extends Evented implements Source {
this._tileJSONRequest = null;
}

callback();
// Request to cancel tiles in flight, without depending on map.style.tileManagers
this.fire(new Event('data', {
dataType: 'source',
abortPendingTileRequests: true
} ));

callback();
this.load(true);
}

Expand Down Expand Up @@ -181,6 +186,8 @@ export class RasterTileSource extends Evented implements Source {

async loadTile(tile: Tile): Promise<void> {
const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme);
// A tile can be reused after a previous abort; clear stale abort state before a new request.
tile.aborted = false;
tile.abortController = new AbortController();
try {
const response = await ImageRequest.getImage(await this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles);
Expand Down
Loading
Loading