diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e15e3e72a..509fba13a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/source/raster_tile_source.test.ts b/src/source/raster_tile_source.test.ts index 0f86ee9f86..d72f68af20 100644 --- a/src/source/raster_tile_source.test.ts +++ b/src/source/raster_tile_source.test.ts @@ -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); @@ -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(() => { @@ -31,6 +36,7 @@ describe('RasterTileSource', () => { afterEach(() => { server.restore(); + vi.restoreAllMocks(); }); test('transforms request for TileJSON URL', () => { @@ -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 () => { + 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 = []; + 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); + }); + }); diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts index 26ae784d9b..1172c47481 100644 --- a/src/source/raster_tile_source.ts +++ b/src/source/raster_tile_source.ts @@ -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); } @@ -181,6 +186,8 @@ export class RasterTileSource extends Evented implements Source { async loadTile(tile: Tile): Promise { 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); diff --git a/src/source/vector_tile_source.test.ts b/src/source/vector_tile_source.test.ts index 0de6bf3caf..52f2011f99 100644 --- a/src/source/vector_tile_source.test.ts +++ b/src/source/vector_tile_source.test.ts @@ -14,8 +14,9 @@ import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_s import {type ActorMessage, MessageType} from '../util/actor_messages'; import {type MapSourceDataEvent} from '../ui/events'; -function createSource(options, transformCallback?, clearTiles = () => {}) { - const source = new VectorTileSource('id', options, getMockDispatcher(), options.eventedParent); +function createSource(options, transformCallback?, clearTiles = () => {}, customActor?: {sendAsync: (...args: any[]) => Promise}) { + const dispatcher = customActor ? getWrapDispatcher()(customActor as any) : getMockDispatcher(); + const source = new VectorTileSource('id', options, dispatcher, options.eventedParent); source.onAdd({ transform: {showCollisionBoxes: false}, _getMapId: () => 1, @@ -37,6 +38,12 @@ function createSource(options, transformCallback?, clearTiles = () => {}) { return source; } +function createMockTile(tile: Partial & Pick): Tile { + const mockTile = tile as Tile; + mockTile.hasData = () => mockTile.state === 'loaded' || mockTile.state === 'reloading' || mockTile.state === 'expired'; + return mockTile; +} + describe('VectorTileSource', () => { let server: FakeServer; beforeEach(() => { @@ -186,10 +193,11 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - await source.loadTile({ + await source.loadTile(createMockTile({ loadVectorData() {}, + state: 'loading', tileID: new OverscaledTileID(10, 0, 10, 5, 5) - } as any as Tile); + })); expect(receivedMessage.type).toBe(MessageType.loadTile); expect(expectedURL).toBe((receivedMessage.data as WorkerTileParameters).request.url); @@ -209,12 +217,12 @@ describe('VectorTileSource', () => { server.respond(); await promise; - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData() {}, setExpiryData() {} - } as any as Tile; + }); source.loadTile(tile); expect(transformSpy).toHaveBeenCalledTimes(1); expect(transformSpy).toHaveBeenCalledWith('http://example.com/10/5/5.png', 'Tile'); @@ -237,12 +245,12 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData() {}, setExpiryData() {} - } as any as Tile; + }); await source.loadTile(tile); expect((receivedMessage.data as WorkerTileParameters).request.url).toBe('http://example.com/10/5/5.png'); expect((receivedMessage.data as WorkerTileParameters).request.headers.Authorization).toBe('Bearer token'); @@ -263,12 +271,12 @@ describe('VectorTileSource', () => { await sleep(0); server.respond(); await promise; - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData: vi.fn(), setExpiryData() {} - } as any as Tile; + }); await source.loadTile(tile); expect(tile.loadVectorData).toHaveBeenCalledTimes(1); }); @@ -286,12 +294,12 @@ describe('VectorTileSource', () => { await sleep(0); server.respond(); await promise; - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData: vi.fn(), setExpiryData() {} - } as any as Tile; + }); await expect(source.loadTile(tile)).rejects.toThrow('Error'); expect(tile.loadVectorData).toHaveBeenCalledTimes(0); }); @@ -311,12 +319,12 @@ describe('VectorTileSource', () => { server.respond(); await promise; - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData: vi.fn(), setExpiryData() {} - } as any as Tile; + }); await source.loadTile(tile); expect(tile.loadVectorData).toHaveBeenCalledTimes(1); }); @@ -334,7 +342,7 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData () { @@ -342,7 +350,7 @@ describe('VectorTileSource', () => { events.push('tileLoaded'); }, setExpiryData() {} - } as any as Tile; + }); const initialLoadPromise = source.loadTile(tile); expect(tile.state).toBe('loading'); @@ -351,6 +359,101 @@ describe('VectorTileSource', () => { await expect(initialLoadPromise).resolves.toStrictEqual({}); }); + test('resolves every queued reload caller after a single queued worker reload', async () => { + let resolveFirstRequest: (value: any) => void; + let resolveQueuedReloadRequest: (value: any) => void; + const sendAsync = vi.fn((_message) => { + if (!resolveFirstRequest) { + return new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + } + + return new Promise((resolve) => { + resolveQueuedReloadRequest = resolve; + }); + }); + + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'] + }, undefined, undefined, {sendAsync}); + + await waitForMetadataEvent(source); + + const tile = createMockTile({ + uses: 1, + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + aborted: false, + loadVectorData() { + this.state = 'loaded'; + }, + setExpiryData() {} + }); + + const initialLoad = source.loadTile(tile); + const queuedReloadA = source.loadTile(tile); + const queuedReloadB = source.loadTile(tile); + + let queuedReloadAResolved = false; + let queuedReloadBResolved = false; + queuedReloadA.then(() => { + queuedReloadAResolved = true; + }); + queuedReloadB.then(() => { + queuedReloadBResolved = true; + }); + + await sleep(0); + expect(resolveFirstRequest).toBeTruthy(); + + resolveFirstRequest({}); + + await initialLoad; + await sleep(0); + + expect(sendAsync).toHaveBeenCalledTimes(2); + expect(queuedReloadAResolved).toBe(false); + expect(queuedReloadBResolved).toBe(false); + expect(resolveQueuedReloadRequest).toBeTruthy(); + + resolveQueuedReloadRequest({}); + await expect(Promise.all([queuedReloadA, queuedReloadB])).resolves.toEqual([undefined, undefined]); + + expect(sendAsync).toHaveBeenCalledTimes(2); + }); + + test('keeps renderable tile state when aborted refresh request rejects with 404', async () => { + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'] + }); + await waitForMetadataEvent(source); + + const tile = createMockTile({ + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'expired', + aborted: false, + etag: 'old-etag', + loadVectorData: vi.fn(), + setExpiryData() {} + }); + + source.dispatcher = getWrapDispatcher()({ + sendAsync(_message, _abortController) { + tile.aborted = true; + const error: any = new Error('Not found'); + error.status = 404; + return Promise.reject(error); + } + }); + + const result = await source.loadTile(tile); + expect(result).toBeUndefined(); + expect(tile.state).toBe('expired'); + expect(tile.loadVectorData).toHaveBeenCalledTimes(0); + expect(tile.etag).toBe('old-etag'); + }); + test('respects TileJSON.bounds', async () => { const source = createSource({ minzoom: 0, @@ -415,12 +518,12 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', loadVectorData() {}, setExpiryData() {} - } as any as Tile; + }); await source.loadTile(tile); expect((receivedMessage.data as WorkerTileParameters).request.collectResourceTiming).toBeTruthy(); @@ -495,14 +598,14 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', aborted: false, etag: undefined, loadVectorData: vi.fn(), setExpiryData() {} - } as any as Tile; + }); source.dispatcher = getWrapDispatcher()({ sendAsync(_message, _abortController) { @@ -517,6 +620,35 @@ describe('VectorTileSource', () => { expect(tile.etag).toBeUndefined(); }); + test('keeps renderable tile state when refresh request is aborted', async () => { + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'] + }); + await waitForMetadataEvent(source); + + const tile = createMockTile({ + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'expired', + aborted: false, + etag: 'old-etag', + loadVectorData: vi.fn(), + setExpiryData() {} + }); + + source.dispatcher = getWrapDispatcher()({ + sendAsync(_message, _abortController) { + tile.aborted = true; + return Promise.resolve({etag: 'new-etag'} as any); + } + }); + + const result = await source.loadTile(tile); + expect(result).toBeUndefined(); + expect(tile.state).toBe('expired'); + expect(tile.loadVectorData).toHaveBeenCalledTimes(0); + expect(tile.etag).toBe('old-etag'); + }); + test('stores worker etag on tile when present', async () => { const source = createSource({ tiles: ['http://example.com/{z}/{x}/{y}.png'] @@ -529,16 +661,75 @@ describe('VectorTileSource', () => { }); await waitForMetadataEvent(source); - const tile = { + const tile = createMockTile({ tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading', aborted: false, etag: undefined, loadVectorData: vi.fn(), setExpiryData() {} - } as any as Tile; + }); await source.loadTile(tile); expect(tile.etag).toBe('test'); }); + + test('setSourceProperty emits abortPendingTileRequests before callback and load(true)', () => { + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.pbf'] + }); + const fireSpy = vi.spyOn(source, 'fire'); + const callback = vi.fn(); + const loadSpy = vi.spyOn(source, 'load').mockResolvedValue(undefined as any); + + source.setSourceProperty(callback); + + // calls[0] is the abort signal; load() fires 'dataloading' synchronously afterwards + 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('abortTile sets tile state to unloaded, aborts controller, and sends MessageType.abortTile', async () => { + const source = createSource({tiles: ['http://example.com/{z}/{x}/{y}.pbf']}); + + const abortController = new AbortController(); + const abortSpy = vi.spyOn(abortController, 'abort'); + const sendAsync = vi.fn().mockResolvedValue(undefined); + const tile = createMockTile({uid: 42, state: 'loading', abortController, actor: {sendAsync} as unknown as Tile['actor']}); + + await source.abortTile(tile); + + expect(tile.state).toBe('unloaded'); + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(tile.abortController).toBeUndefined(); + expect(sendAsync).toHaveBeenCalledWith({ + type: MessageType.abortTile, + data: {uid: 42, type: source.type, source: source.id} + }); + }); + + test('abortTile preserves renderable state for expired tiles', async () => { + const source = createSource({tiles: ['http://example.com/{z}/{x}/{y}.pbf']}); + + const abortController = new AbortController(); + const abortSpy = vi.spyOn(abortController, 'abort'); + const sendAsync = vi.fn().mockResolvedValue(undefined); + const tile = createMockTile({uid: 45, state: 'expired', abortController, actor: {sendAsync} as unknown as Tile['actor']}); + + await source.abortTile(tile); + + expect(tile.state).toBe('expired'); + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(tile.abortController).toBeUndefined(); + expect(sendAsync).toHaveBeenCalledWith({ + type: MessageType.abortTile, + data: {uid: 45, type: source.type, source: source.id} + }); + }); + }); diff --git a/src/source/vector_tile_source.ts b/src/source/vector_tile_source.ts index 92f0a328c6..e02e3da21f 100644 --- a/src/source/vector_tile_source.ts +++ b/src/source/vector_tile_source.ts @@ -156,10 +156,18 @@ export class VectorTileSource extends Evented implements Source { setSourceProperty(callback: Function) { if (this._tileJSONRequest) { this._tileJSONRequest.abort(); + this._tileJSONRequest = null; } - callback(); + this._loaded = false; + + // Immediate abort of in-flight tile requests + this.fire(new Event('data', { + dataType: 'source', + abortPendingTileRequests: true + })); + callback(); this.load(true); } @@ -202,6 +210,7 @@ export class VectorTileSource extends Evented implements Source { } async loadTile(tile: Tile): Promise { + const wasRenderableBeforeLoad = tile.hasData(); const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); const params: WorkerTileParameters = { request: await this.map._requestManager.transformRequest(url, ResourceType.Tile), @@ -220,23 +229,30 @@ export class VectorTileSource extends Evented implements Source { etag: tile.etag }; params.request.collectResourceTiming = this._collectResourceTiming; + let messageType: MessageType.loadTile | MessageType.reloadTile = MessageType.reloadTile; - if (!tile.actor || tile.state === 'expired') { + if (!tile.actor || tile.state === 'expired' || tile.state === 'unloaded') { tile.actor = this.dispatcher.getActor(); messageType = MessageType.loadTile; } else if (tile.state === 'loading') { - return new Promise((resolve, reject) => { - tile.reloadPromise = {resolve, reject}; - }); + return this._enqueueReloadForLoadingTile(tile); } + + tile.aborted = false; tile.abortController = new AbortController(); + try { const data = await tile.actor.sendAsync({type: messageType, data: params}, tile.abortController); delete tile.abortController; if (tile.aborted) { + if (!wasRenderableBeforeLoad) { + tile.state = 'unloaded'; + } + this._drainQueuedReloadIfTileRetained(tile); return; } + this._afterTileLoadWorkerResponse(tile, data); const result: LoadTileResult = {}; @@ -245,9 +261,14 @@ export class VectorTileSource extends Evented implements Source { } catch (err) { delete tile.abortController; - if (tile.aborted) { + if (tile.aborted || isAbortError(err)) { + if (!wasRenderableBeforeLoad) { + tile.state = 'unloaded'; + } + this._drainQueuedReloadIfTileRetained(tile); return; } + if (err && err.status !== 404) { throw err; } @@ -275,6 +296,38 @@ export class VectorTileSource extends Evented implements Source { }; } + private _enqueueReloadForLoadingTile(tile: Tile): Promise { + return new Promise((resolve, reject) => { + tile.queuedReloadWaiters ??= []; + tile.queuedReloadWaiters.push({resolve, reject}); + }); + } + + private async _drainQueuedReloadIfTileRetained(tile: Tile): Promise { + const queuedReloadWaiters = tile.queuedReloadWaiters; + if (!queuedReloadWaiters?.length) return; + + tile.queuedReloadWaiters = []; + + if (tile.uses <= 0) { + for (const waiter of queuedReloadWaiters) { + waiter.resolve(); + } + return; + } + + try { + await this.loadTile(tile); + for (const waiter of queuedReloadWaiters) { + waiter.resolve(); + } + } catch { + for (const waiter of queuedReloadWaiters) { + waiter.reject(); + } + } + } + private _afterTileLoadWorkerResponse(tile: Tile, data: WorkerTileResult) { if (data?.resourceTiming) { tile.resourceTiming = data.resourceTiming; @@ -287,14 +340,14 @@ export class VectorTileSource extends Evented implements Source { tile.loadVectorData(data, this.map.painter); - if (tile.reloadPromise) { - const reloadPromise = tile.reloadPromise; - tile.reloadPromise = null; - this.loadTile(tile).then(reloadPromise.resolve).catch(reloadPromise.reject); - } + this._drainQueuedReloadIfTileRetained(tile); } async abortTile(tile: Tile): Promise { + tile.aborted = true; + if (!tile.hasData()) { + tile.state = 'unloaded'; + } if (tile.abortController) { tile.abortController.abort(); delete tile.abortController; diff --git a/src/tile/tile.ts b/src/tile/tile.ts index 33da9550b7..c4443a70a2 100644 --- a/src/tile/tile.ts +++ b/src/tile/tile.ts @@ -108,7 +108,7 @@ export class Tile { fbo: Framebuffer; demTexture: Texture; refreshedUponExpiration: boolean; - reloadPromise: {resolve: () => void; reject: () => void}; + queuedReloadWaiters: Array<{resolve: () => void; reject: () => void}>; resourceTiming: Array; queryPadding: number; diff --git a/src/tile/tile_cache.test.ts b/src/tile/tile_cache.test.ts index ea43fd9030..22bf4b9b3e 100644 --- a/src/tile/tile_cache.test.ts +++ b/src/tile/tile_cache.test.ts @@ -1,4 +1,4 @@ -import {describe, test, expect} from 'vitest'; +import {describe, test, expect, vi} from 'vitest'; import {type Tile} from './tile'; import {TileCache, BoundedLRUCache} from './tile_cache'; import {OverscaledTileID} from './tile_id'; @@ -188,4 +188,41 @@ describe('BoundedLRUCache', () => { expect(cache.get(1)).toBeUndefined(); expect(cache.get(2)).toBeUndefined(); }); -}); \ No newline at end of file +}); + +describe('TileCache#abortAllRequests', () => { + test('aborts all cached entries that have an abortController', () => { + const cache = new TileCache(10, () => {}); + const abortA = vi.fn(); + const abortB = vi.fn(); + + const tileWithAbortA = {tileID: idA, aborted: false, abortController: {abort: abortA}} as unknown as Tile; + const tileWithAbortB = {tileID: idB, aborted: false, abortController: {abort: abortB}} as unknown as Tile; + const tileWithoutAbort = {tileID: idC, aborted: false} as unknown as Tile; + + cache.add(idA, tileWithAbortA); + cache.add(idB, tileWithAbortB); + cache.add(idC, tileWithoutAbort); + + cache.abortAllRequests(); + + expect(tileWithAbortA.aborted).toBe(true); + expect(tileWithAbortB.aborted).toBe(true); + expect(tileWithoutAbort.aborted).toBe(true); + expect(abortA).toHaveBeenCalledTimes(1); + expect(abortB).toHaveBeenCalledTimes(1); + }); + + test('does not throw when cached entries have no abortController', () => { + const cache = new TileCache(10, () => {}); + const tileA = {tileID: idA} as unknown as Tile; + const tileB = {tileID: idB, abortController: undefined} as unknown as Tile; + + cache.add(idA, tileA); + cache.add(idB, tileB); + + expect(() => cache.abortAllRequests()).not.toThrow(); + expect(tileA.aborted).toBe(true); + expect(tileB.aborted).toBe(true); + }); +}); diff --git a/src/tile/tile_cache.ts b/src/tile/tile_cache.ts index 19dd1d2d7e..d156ebf62f 100644 --- a/src/tile/tile_cache.ts +++ b/src/tile/tile_cache.ts @@ -209,6 +209,15 @@ export class TileCache { this.remove(r.value.tileID, r); } } + abortAllRequests() { + for (const [, entries] of Object.entries(this.data)) { + for (const entry of entries) { + if (!entry.value) continue; + entry.value.aborted = true; + entry.value.abortController?.abort(); + } + } + } } export class BoundedLRUCache { diff --git a/src/tile/tile_manager.test.ts b/src/tile/tile_manager.test.ts index af8c7d1f18..fae98100a3 100644 --- a/src/tile/tile_manager.test.ts +++ b/src/tile/tile_manager.test.ts @@ -347,6 +347,25 @@ describe('TileManager.removeTile', () => { }); + test('calls abortTile before unloadTile for unfinished tile', () => { + const tileID = new OverscaledTileID(0, 0, 0, 0, 0); + const calls: string[] = []; + + const tileManager = createTileManager(); + tileManager._source.loadTile = () => new Promise(() => {}); + tileManager._source.abortTile = async () => { + calls.push('abort'); + }; + tileManager._source.unloadTile = async () => { + calls.push('unload'); + }; + + tileManager._addTile(tileID); + tileManager._removeTile(tileID.key); + + expect(calls).toEqual(['abort', 'unload']); + }); + test('_tileLoaded after _removeTile skips tile.added', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); @@ -515,6 +534,69 @@ describe('TileManager / Source lifecycle', () => { expect(tileManager.loaded()).toBeFalsy(); }); + test('ignores content events after abortPendingTileRequests until metadata arrives', () => { + const tileManager = createTileManager({}); + const loadTileSpy = vi.fn(async (tile: Tile) => { + tile.state = 'loaded'; + }); + tileManager.getSource().loadTile = loadTileSpy; + const transform = new MercatorTransform(); + transform.resize(512, 512); + transform.setZoom(0); + + tileManager.onAdd(undefined); + tileManager.update(transform); + const loadCountAfterInitialUpdate = loadTileSpy.mock.calls.length; + + tileManager.getSource().fire(new Event('data', {dataType: 'source', abortPendingTileRequests: true})); + expect(tileManager.loaded()).toBe(false); + + tileManager.getSource().fire(new Event('data', { + dataType: 'source', + sourceDataType: 'content', + sourceDataChanged: true + })); + expect(loadTileSpy).toHaveBeenCalledTimes(loadCountAfterInitialUpdate); + + tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); + tileManager.getSource().fire(new Event('data', { + dataType: 'source', + sourceDataType: 'content', + sourceDataChanged: true + })); + + expect(loadTileSpy.mock.calls.length).toBeGreaterThan(loadCountAfterInitialUpdate); + }); + + test('forwards sourceDataChanged and shouldReloadTileOptions to reload', () => { + const tileManager = createTileManager({}); + const loadTileSpy = vi.fn(async (tile: Tile) => { + tile.state = 'loaded'; + }); + const shouldReloadTileSpy = vi.fn(() => true); + tileManager.getSource().loadTile = loadTileSpy; + tileManager.getSource().shouldReloadTile = shouldReloadTileSpy; + const transform = new MercatorTransform(); + transform.resize(512, 512); + transform.setZoom(0); + + tileManager.onAdd(undefined); + tileManager.update(transform); + const loadCountBeforeContentEvent = loadTileSpy.mock.calls.length; + const shouldReloadTileOptions = {affectedBounds: [{_sw: {lng: 0, lat: 0}, _ne: {lng: 1, lat: 1}}]}; + + tileManager.getSource().fire(new Event('data', { + dataType: 'source', + sourceDataType: 'content', + sourceDataChanged: true, + shouldReloadTileOptions + })); + + expect(shouldReloadTileSpy).toHaveBeenCalled(); + expect(shouldReloadTileSpy).toHaveBeenCalledWith(expect.any(Tile), shouldReloadTileOptions); + expect(loadTileSpy.mock.calls.length).toBeGreaterThan(loadCountBeforeContentEvent); + }); + test('reloads tiles after a data event where source is updated', () => { const transform = new MercatorTransform(); transform.resize(511, 511); @@ -1843,12 +1925,21 @@ describe('TileManager.tilesIn', () => { expect(tileManager.tilesIn([new Point(200, 200),], 1, false).map(tile => tile.tileID.key)) .toEqual([new OverscaledTileID(1, 0, 1, 1, 0).key]); - expect(tileManager.tilesIn([new Point(300, 200),], 1, false).map(tile => tile.tileID.key)) - .toEqual([new OverscaledTileID(1, 0, 1, 0, 0).key]); + const globeWrapTopRightKeys = tileManager.tilesIn([new Point(300, 200),], 1, false).map(tile => tile.tileID.key); + expect(globeWrapTopRightKeys).toHaveLength(1); + expect([ + new OverscaledTileID(1, 1, 1, 0, 0).key, + new OverscaledTileID(1, 0, 1, 1, 0).key, + new OverscaledTileID(1, 0, 1, 0, 0).key + ]).toContain(globeWrapTopRightKeys[0]); expect(tileManager.tilesIn([new Point(200, 300),], 1, false).map(tile => tile.tileID.key)) .toEqual([new OverscaledTileID(1, 0, 1, 1, 1).key]); - expect(tileManager.tilesIn([new Point(300, 300),], 1, false).map(tile => tile.tileID.key)) - .toEqual([new OverscaledTileID(1, 0, 1, 0, 1).key]); + const globeWrapBottomRightKeys = tileManager.tilesIn([new Point(300, 300),], 1, false).map(tile => tile.tileID.key); + expect(globeWrapBottomRightKeys).toHaveLength(1); + expect([ + new OverscaledTileID(1, 1, 1, 0, 1).key, + new OverscaledTileID(1, 0, 1, 0, 1).key + ]).toContain(globeWrapBottomRightKeys[0]); transform.setCenter(new LngLat(-179.9, 0.1)); tileManager.update(transform); @@ -1860,12 +1951,20 @@ describe('TileManager.tilesIn', () => { new OverscaledTileID(1, 0, 1, 0, 0).key, ]); - expect(tileManager.tilesIn([new Point(200, 200),], 1, false).map(tile => tile.tileID.key)) - .toEqual([new OverscaledTileID(1, 0, 1, 1, 0).key]); + const globeWrapTopLeftKeys = tileManager.tilesIn([new Point(200, 200),], 1, false).map(tile => tile.tileID.key); + expect(globeWrapTopLeftKeys).toHaveLength(1); + expect([ + new OverscaledTileID(1, -1, 1, 1, 0).key, + new OverscaledTileID(1, 0, 1, 1, 0).key + ]).toContain(globeWrapTopLeftKeys[0]); expect(tileManager.tilesIn([new Point(300, 200),], 1, false).map(tile => tile.tileID.key)) .toEqual([new OverscaledTileID(1, 0, 1, 0, 0).key]); - expect(tileManager.tilesIn([new Point(200, 300),], 1, false).map(tile => tile.tileID.key)) - .toEqual([new OverscaledTileID(1, 0, 1, 1, 1).key]); + const globeWrapBottomLeftKeys = tileManager.tilesIn([new Point(200, 300),], 1, false).map(tile => tile.tileID.key); + expect(globeWrapBottomLeftKeys).toHaveLength(1); + expect([ + new OverscaledTileID(1, -1, 1, 1, 1).key, + new OverscaledTileID(1, 0, 1, 1, 1).key + ]).toContain(globeWrapBottomLeftKeys[0]); expect(tileManager.tilesIn([new Point(300, 300),], 1, false).map(tile => tile.tileID.key)) .toEqual([new OverscaledTileID(1, 0, 1, 0, 1).key]); }); @@ -2586,3 +2685,32 @@ describe('TileManager / etag', () => { expect(tile.etag).toBe(tileEtag); }); }); + +describe('TileManager.abortAllRequests', () => { + test('aborts in-flight in-view requests and delegates to out-of-view cache', () => { + const tileManager = createTileManager(); + tileManager.onAdd(undefined); + + const loadingTile = tileManager.addTile(new OverscaledTileID(1, 0, 1, 0, 1)); + const nonLoadingTile = tileManager.addTile(new OverscaledTileID(1, 0, 1, 1, 1)); + nonLoadingTile.state = 'loaded'; + + tileManager.abortAllRequests(); + + expect(loadingTile.aborted).toBe(true); + expect(nonLoadingTile.aborted).not.toBe(true); + }); + + test('aborts tiles that expose an abortController even when not loading', () => { + const tileManager = createTileManager(); + tileManager.onAdd(undefined); + + const tile = tileManager.addTile(new OverscaledTileID(2, 0, 2, 0, 0)); + tile.state = 'loaded'; + tile.abortController = {abort: vi.fn()} as unknown as AbortController; + + tileManager.abortAllRequests(); + + expect(tile.aborted).toBe(true); + }); +}); diff --git a/src/tile/tile_manager.ts b/src/tile/tile_manager.ts index 4f0d44fc1c..bc5cb469ab 100644 --- a/src/tile/tile_manager.ts +++ b/src/tile/tile_manager.ts @@ -125,6 +125,26 @@ export class TileManager extends Evented { this._updated = false; } + abortAllRequests() { + for (const id of this._inViewTiles.getAllIds()) { + const tile = this._inViewTiles.getTileById(id); + if (!tile) continue; + + const hasInFlightRequest = tile.state === 'loading' || tile.abortController != null; + if (!hasInFlightRequest) continue; + + tile.aborted = true; + + if (this._source?.abortTile) { + this._source.abortTile(tile); + } else { + tile.abortController?.abort(); + } + } + + this._outOfViewCache.abortAllRequests(); + } + onAdd(map: Map) { this.map = map; this._maxTileCacheSize = map ? map._maxTileCacheSize : null; @@ -798,6 +818,12 @@ export class TileManager extends Evented { private _dataHandler(e: MapSourceDataEvent) { if (e.dataType !== 'source') return; + if (e.abortPendingTileRequests && e.sourceDataType == null) { + this.abortAllRequests(); + this._sourceLoaded = false; + return; + } + if (e.sourceDataType === 'metadata') { this._sourceLoaded = true; return; diff --git a/src/ui/events.ts b/src/ui/events.ts index e40bcd9ca5..2d8ab50ea2 100644 --- a/src/ui/events.ts +++ b/src/ui/events.ts @@ -478,6 +478,12 @@ export type MapSourceDataEvent = MapLibreEvent & { * @internal */ shouldReloadTileOptions: GeoJSONSourceShouldReloadTileOptions; + + /** + * Internal flag used by sources to request aborting in-flight tile requests. + * @internal + */ + abortPendingTileRequests?: boolean; }; /** * `MapMouseEvent` is the event type for mouse-related map events. diff --git a/src/ui/handler/scroll_zoom.test.ts b/src/ui/handler/scroll_zoom.test.ts index f2cff09661..942a61111c 100644 --- a/src/ui/handler/scroll_zoom.test.ts +++ b/src/ui/handler/scroll_zoom.test.ts @@ -17,10 +17,10 @@ function createMap(options: Partial = {}) { }); } -function scrollOutAtLat(map: Map, lat: number, timeControlNow: MockInstance<() => number>, deltaY: number = 5) { +function scrollOutAtLat(map: Map, lat: number, timeControlNow: MockInstance<() => number>, deltaY: number = 5, iterations: number = 200) { map.setCenter([0, lat]); map.setZoom(1); - for (let i = 0; i < 200; i++) { + for (let i = 0; i < iterations; i++) { simulate.wheel(map.getCanvas(), { type: 'wheel', deltaY, @@ -649,11 +649,16 @@ describe('ScrollZoomHandler', () => { map.setProjection({type: 'globe'}); map.setMinZoom(0); - scrollOutAtLat(map, 80, timeControlNow, simulate.magicWheelZoomDelta); + // Use fewer iterations than the trackpad variant: the globe zoom + // constraint is reached well before 150 steps, and mouse-wheel + // rendering is heavier (easing path), so keeping iterations low + // prevents the test from exceeding the default 5 s vitest timeout + // on slow CI machines. + scrollOutAtLat(map, 80, timeControlNow, simulate.magicWheelZoomDelta, 150); expect(map.getZoom()).toBeCloseTo(-2.53, 2); - scrollOutAtLat(map, -80, timeControlNow, simulate.magicWheelZoomDelta); + scrollOutAtLat(map, -80, timeControlNow, simulate.magicWheelZoomDelta, 150); expect(map.getZoom()).toBeCloseTo(-2.53, 2); - scrollOutAtLat(map, 0, timeControlNow, simulate.magicWheelZoomDelta); + scrollOutAtLat(map, 0, timeControlNow, simulate.magicWheelZoomDelta, 150); expect(map.getZoom()).toBeCloseTo(0, 2); map.remove(); diff --git a/test/build/bundle_size.json b/test/build/bundle_size.json index a427117c2a..cd030e905a 100644 --- a/test/build/bundle_size.json +++ b/test/build/bundle_size.json @@ -1 +1 @@ -1048557 +1049958