diff --git a/src/index.test.ts b/src/index.test.ts index 0dd3327e27..b754938267 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -34,64 +34,55 @@ describe('maplibre', () => { expect(Object.keys(config.REGISTERED_PROTOCOLS)).toHaveLength(0); }); - test('#addProtocol - getJSON', done => { + test('#addProtocol - getJSON', async () => { let protocolCallbackCalled = false; maplibre.addProtocol('custom', (reqParam, callback) => { protocolCallbackCalled = true; callback(null, {'foo': 'bar'}); return {cancel: () => {}}; }); - getJSON({url: 'custom://test/url/json'}, new AbortController()).then((response) => { - expect(response.data).toEqual({foo: 'bar'}); - expect(protocolCallbackCalled).toBeTruthy(); - done(); - }); + const response = await getJSON({url: 'custom://test/url/json'}, new AbortController()); + expect(response.data).toEqual({foo: 'bar'}); + expect(protocolCallbackCalled).toBeTruthy(); }); - test('#addProtocol - getArrayBuffer', done => { + test('#addProtocol - getArrayBuffer', async () => { let protocolCallbackCalled = false; - maplibre.addProtocol('custom', (reqParam, callback) => { + maplibre.addProtocol('custom', (_reqParam, callback) => { protocolCallbackCalled = true; - callback(null, new ArrayBuffer(1)); + callback(null, new ArrayBuffer(1), 'cache-control', 'expires'); return {cancel: () => {}}; }); - getArrayBuffer({url: 'custom://test/url/getArrayBuffer'}, async (error, data) => { - expect(error).toBeFalsy(); - expect(data).toBeInstanceOf(ArrayBuffer); - expect(protocolCallbackCalled).toBeTruthy(); - done(); - }); + const response = await getArrayBuffer({url: 'custom://test/url/getArrayBuffer'}, new AbortController()); + expect(response.data).toBeInstanceOf(ArrayBuffer); + expect(response.cacheControl).toBe('cache-control'); + expect(response.expires).toBe('expires'); + expect(protocolCallbackCalled).toBeTruthy(); }); - test('#addProtocol - returning ImageBitmap for getImage', done => { + test('#addProtocol - returning ImageBitmap for getImage', async () => { let protocolCallbackCalled = false; - maplibre.addProtocol('custom', (reqParam, callback) => { + maplibre.addProtocol('custom', (_reqParam, callback) => { protocolCallbackCalled = true; callback(null, new ImageBitmap()); return {cancel: () => {}}; }); - ImageRequest.getImage({url: 'custom://test/url/getImage'}, async (error, img) => { - expect(error).toBeFalsy(); - expect(img).toBeInstanceOf(ImageBitmap); - expect(protocolCallbackCalled).toBeTruthy(); - done(); - }); + const img = await ImageRequest.getImage({url: 'custom://test/url/getImage'}, new AbortController()); + expect(img.data).toBeInstanceOf(ImageBitmap); + expect(protocolCallbackCalled).toBeTruthy(); }); - test('#addProtocol - returning HTMLImageElement for getImage', done => { + test('#addProtocol - returning HTMLImageElement for getImage', async () => { let protocolCallbackCalled = false; maplibre.addProtocol('custom', (reqParam, callback) => { protocolCallbackCalled = true; callback(null, new Image()); return {cancel: () => {}}; }); - ImageRequest.getImage({url: 'custom://test/url/getImage'}, async (error, img) => { - expect(error).toBeFalsy(); - expect(img).toBeInstanceOf(HTMLImageElement); - expect(protocolCallbackCalled).toBeTruthy(); - done(); - }); + const img = await ImageRequest.getImage({url: 'custom://test/url/getImage'}, new AbortController()); + expect(img.data).toBeInstanceOf(HTMLImageElement); + expect(protocolCallbackCalled).toBeTruthy(); }); test('#addProtocol - error', () => { diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts index 124b2a86cf..759b449576 100644 --- a/src/source/image_source.test.ts +++ b/src/source/image_source.test.ts @@ -61,13 +61,15 @@ describe('ImageSource', () => { expect(source.tileSize).toBe(512); }); - test('fires dataloading event', () => { + test('fires dataloading event', async () => { const source = createSource({url: '/image.png'}); source.on('dataloading', (e) => { expect(e.dataType).toBe('source'); }); source.onAdd(new StubMap() as any); server.respond(); + // HM TODO: move this to a utility method + await new Promise((resolve) => (setTimeout(resolve, 0))); expect(source.image).toBeTruthy(); }); @@ -110,7 +112,7 @@ describe('ImageSource', () => { expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]); }); - test('sets coordinates via updateImage', () => { + test('sets coordinates via updateImage', async () => { const source = createSource({url: '/image.png'}); const map = new StubMap() as any; source.onAdd(map); @@ -122,6 +124,7 @@ describe('ImageSource', () => { coordinates: [[0, 0], [-1, 0], [-1, -1], [0, -1]] }); server.respond(); + await new Promise((resolve) => (setTimeout(resolve, 0))); const afterSerialized = source.serialize(); expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]); }); @@ -179,15 +182,16 @@ describe('ImageSource', () => { expect(serialized.coordinates).toEqual([[0, 0], [1, 0], [1, 1], [0, 1]]); }); - test('allows using updateImage before initial image is loaded', () => { + test('allows using updateImage before initial image is loaded', async () => { const source = createSource({url: '/image.png'}); const map = new StubMap() as any; source.onAdd(map); - expect(source.image).toBeUndefined(); source.updateImage({url: '/image2.png'}); server.respond(); + await new Promise((resolve) => (setTimeout(resolve, 10))); + expect(source.image).toBeTruthy(); }); diff --git a/src/source/image_source.ts b/src/source/image_source.ts index 8be422aa54..9c9693c29b 100644 --- a/src/source/image_source.ts +++ b/src/source/image_source.ts @@ -20,7 +20,6 @@ import type { ImageSourceSpecification, VideoSourceSpecification } from '@maplibre/maplibre-gl-style-spec'; -import {Cancelable} from '../types/cancelable'; /** * Four geographical coordinates, @@ -107,7 +106,7 @@ export class ImageSource extends Evented implements Source { boundsBuffer: VertexBuffer; boundsSegments: SegmentVector; _loaded: boolean; - _request: Cancelable; + _request: AbortController; /** @internal */ constructor(id: string, options: ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) { @@ -134,14 +133,13 @@ export class ImageSource extends Evented implements Source { this.url = this.options.url; - this._request = ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), (err, image) => { + this._request = new AbortController(); + ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request).then((image) => { this._request = null; this._loaded = true; - if (err) { - this.fire(new ErrorEvent(err)); - } else if (image) { - this.image = image; + if (image && image.data) { + this.image = image.data; if (newCoordinates) { this.coordinates = newCoordinates; } @@ -150,6 +148,9 @@ export class ImageSource extends Evented implements Source { } this._finishLoading(); } + }).catch((err) => { + this._request = null; + this.fire(new ErrorEvent(err)); }); }; @@ -170,7 +171,7 @@ export class ImageSource extends Evented implements Source { } if (this._request) { - this._request.cancel(); + this._request.abort(); this._request = null; } @@ -193,7 +194,7 @@ export class ImageSource extends Evented implements Source { onRemove() { if (this._request) { - this._request.cancel(); + this._request.abort(); this._request = null; } } diff --git a/src/source/raster_dem_tile_source.ts b/src/source/raster_dem_tile_source.ts index c154e9cc22..f446f26789 100644 --- a/src/source/raster_dem_tile_source.ts +++ b/src/source/raster_dem_tile_source.ts @@ -15,7 +15,6 @@ import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; import type {Callback} from '../types/callback'; import type {RasterDEMSourceSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {ExpiryData} from '../util/ajax'; import {isOffscreenCanvasDistorted} from '../util/offscreen_canvas_distorted'; import {RGBAImage} from '../util/image'; @@ -58,16 +57,17 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); const request = this.map._requestManager.transformRequest(url, ResourceType.Tile); tile.neighboringTiles = this._getNeighboringTiles(tile.tileID); - tile.request = ImageRequest.getImage(request, async (err: Error, img: (HTMLImageElement | ImageBitmap), expiry: ExpiryData) => { - delete tile.request; + tile.abortController = new AbortController(); + ImageRequest.getImage(request, tile.abortController, this.map._refreshExpiredTiles).then(async (response) => { + delete tile.abortController; if (tile.aborted) { tile.state = 'unloaded'; callback(null); - } else if (err) { - tile.state = 'errored'; - callback(err); - } else if (img) { - if (this.map._refreshExpiredTiles) tile.setExpiryData(expiry); + } else if (response && response.data) { + const img = response.data; + if (this.map._refreshExpiredTiles && response.cacheControl && response.expires) { + tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires}); + } const transfer = isImageBitmap(img) && offscreenCanvasSupported(); const rawImageData = transfer ? img : await readImageNow(img); const params = { @@ -98,7 +98,16 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { } } } - }, this.map._refreshExpiredTiles); + }).catch((err) => { + delete tile.abortController; + if (tile.aborted) { + tile.state = 'unloaded'; + callback(null); + } else if (err) { + tile.state = 'errored'; + callback(err); + } + }); async function readImageNow(img: ImageBitmap | HTMLImageElement): Promise { if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) { diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts index 66630c5099..b556b07974 100644 --- a/src/source/raster_tile_source.ts +++ b/src/source/raster_tile_source.ts @@ -158,20 +158,19 @@ export class RasterTileSource extends Evented implements Source { loadTile(tile: Tile, callback: Callback) { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); - tile.request = ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), (err, img, expiry) => { - delete tile.request; - + tile.abortController = new AbortController(); + ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles).then((response) => { + delete tile.abortController; if (tile.aborted) { tile.state = 'unloaded'; callback(null); - } else if (err) { - tile.state = 'errored'; - callback(err); - } else if (img) { - if (this.map._refreshExpiredTiles && expiry) tile.setExpiryData(expiry); - + } else if (response && response.data) { + if (this.map._refreshExpiredTiles && response.cacheControl && response.expires) { + tile.setExpiryData({cacheControl: response.cacheControl, expires: response.expires}); + } const context = this.map.painter.context; const gl = context.gl; + const img = response.data; tile.texture = this.map.painter.getTileTexture(img.width); if (tile.texture) { tile.texture.update(img, {useMipmap: true}); @@ -188,13 +187,22 @@ export class RasterTileSource extends Evented implements Source { callback(null); } - }, this.map._refreshExpiredTiles); + }).catch((err) => { + delete tile.abortController; + if (tile.aborted) { + tile.state = 'unloaded'; + callback(null); + } else if (err) { + tile.state = 'errored'; + callback(err); + } + }); } abortTile(tile: Tile, callback: Callback) { - if (tile.request) { - tile.request.cancel(); - delete tile.request; + if (tile.abortController) { + tile.abortController.abort(); + delete tile.abortController; } callback(); } diff --git a/src/source/rtl_text_plugin.ts b/src/source/rtl_text_plugin.ts index 48a538fff5..9023cca51e 100644 --- a/src/source/rtl_text_plugin.ts +++ b/src/source/rtl_text_plugin.ts @@ -83,16 +83,14 @@ export const downloadRTLTextPlugin = () => { } pluginStatus = status.loading; sendPluginStateToWorker(); - if (pluginURL) { - getArrayBuffer({url: pluginURL}, (error) => { - if (error) { - triggerPluginCompletionEvent(error); - } else { - pluginStatus = status.loaded; - sendPluginStateToWorker(); - } - }); - } + getArrayBuffer({url: pluginURL}, new AbortController()).then(() => { + pluginStatus = status.loaded; + sendPluginStateToWorker(); + }).catch((error) => { + if (error) { + triggerPluginCompletionEvent(error); + } + }); }; export const plugin: { diff --git a/src/source/tile.ts b/src/source/tile.ts index 27d7669a1a..42ea755747 100644 --- a/src/source/tile.ts +++ b/src/source/tile.ts @@ -28,7 +28,6 @@ import type {OverscaledTileID} from './tile_id'; import type {Framebuffer} from '../gl/framebuffer'; import type {Transform} from '../geo/transform'; import type {LayerFeatureStates} from './source_state'; -import type {Cancelable} from '../types/cancelable'; import type {FilterSpecification} from '@maplibre/maplibre-gl-style-spec'; import type Point from '@mapbox/point-geometry'; import {mat4} from 'gl-matrix'; @@ -81,8 +80,6 @@ export class Tile { aborted: boolean; needsHillshadePrepare: boolean; needsTerrainPrepare: boolean; - // HM TODO: remove this once we migrate to abort contoller - request: Cancelable; abortController: AbortController; texture: any; fbo: Framebuffer; diff --git a/src/source/vector_tile_worker_source.ts b/src/source/vector_tile_worker_source.ts index d3dcee4e3c..77ba3dd13a 100644 --- a/src/source/vector_tile_worker_source.ts +++ b/src/source/vector_tile_worker_source.ts @@ -38,8 +38,6 @@ export type LoadVectorDataCallback = Callback; export type AbortVectorData = () => void; export type LoadVectorData = (params: WorkerTileParameters, abortController: AbortController) => Promise; -let me = 0; - /** * The {@link WorkerSource} implementation that supports {@link VectorTileSource}. * This class is designed to be easily reused to support custom source types @@ -55,7 +53,6 @@ export class VectorTileWorkerSource implements WorkerSource { fetching: {[_: string]: FetchingState }; loading: {[_: string]: WorkerTile}; loaded: {[_: string]: WorkerTile}; - my: number; /** * @param loadVectorData - Optional method for custom loading of a VectorTile @@ -71,46 +68,32 @@ export class VectorTileWorkerSource implements WorkerSource { this.fetching = {}; this.loading = {}; this.loaded = {}; - this.my = me++; } /** * Loads a vector tile */ - loadVectorTile(params: WorkerTileParameters, abortController: AbortController): Promise { - return new Promise((resolve, reject) => { - const request = getArrayBuffer(params.request, (err?: Error | null, data?: ArrayBuffer | null, cacheControl?: string | null, expires?: string | null) => { - if (err) { - reject(err); - return; - } - if (data) { - try { - const vectorTile = new vt.VectorTile(new Protobuf(data)); - resolve({ - vectorTile, - rawData: data, - cacheControl, - expires - }); - } catch (ex) { - const bytes = new Uint8Array(data); - const isGzipped = bytes[0] === 0x1f && bytes[1] === 0x8b; - let errorMessage = `Unable to parse the tile at ${params.request.url}, `; - if (isGzipped) { - errorMessage += 'please make sure the data is not gzipped and that you have configured the relevant header in the server'; - } else { - errorMessage += `got error: ${ex.messge}`; - } - reject(new Error(errorMessage)); - } - } - }); - abortController.signal.addEventListener('abort', () => { - request.cancel(); - reject(new Error('AbortError')); - }); - }); + private async loadVectorTile(params: WorkerTileParameters, abortController: AbortController): Promise { + const response = await getArrayBuffer(params.request, abortController); + try { + const vectorTile = new vt.VectorTile(new Protobuf(response.data)); + return { + vectorTile, + rawData: response.data, + cacheControl: response.cacheControl, + expires: response.expires + }; + } catch (ex) { + const bytes = new Uint8Array(response.data); + const isGzipped = bytes[0] === 0x1f && bytes[1] === 0x8b; + let errorMessage = `Unable to parse the tile at ${params.request.url}, `; + if (isGzipped) { + errorMessage += 'please make sure the data is not gzipped and that you have configured the relevant header in the server'; + } else { + errorMessage += `got error: ${ex.messge}`; + } + throw new Error(errorMessage); + } } /** diff --git a/src/style/load_glyph_range.ts b/src/style/load_glyph_range.ts index 6a454caa90..c21850d063 100644 --- a/src/style/load_glyph_range.ts +++ b/src/style/load_glyph_range.ts @@ -22,17 +22,15 @@ export function loadGlyphRange(fontstack: string, ResourceType.Glyphs ); - getArrayBuffer(request, (err?: Error | null, data?: ArrayBuffer | null) => { - if (err) { - callback(err); - } else if (data) { + getArrayBuffer(request, new AbortController()).then((response) => { + if (response.data) { const glyphs = {}; - for (const glyph of parseGlyphPbf(data)) { + for (const glyph of parseGlyphPbf(response.data)) { glyphs[glyph.id] = glyph; } callback(null, glyphs); } - }); + }).catch((err) => { callback(err); }); } diff --git a/src/style/load_sprite.test.ts b/src/style/load_sprite.test.ts index 6aaa068f0e..721009bc7e 100644 --- a/src/style/load_sprite.test.ts +++ b/src/style/load_sprite.test.ts @@ -11,12 +11,13 @@ describe('loadSprite', () => { let server: FakeServer; beforeEach(() => { - jest.spyOn(util, 'arrayBufferToImageBitmap').mockImplementation((data: ArrayBuffer, callback: (err?: Error | null, image?: ImageBitmap | null) => void) => { - createImageBitmap(new ImageData(1024, 824)).then((imgBitmap) => { - callback(null, imgBitmap); - }).catch((e) => { - callback(new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`)); - }); + jest.spyOn(util, 'arrayBufferToImageBitmap').mockImplementation(async (_data: ArrayBuffer) => { + try { + const img = await createImageBitmap(new ImageData(1024, 824)); + return img; + } catch (e) { + throw new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`); + } }); global.fetch = null; server = fakeServer.create(); diff --git a/src/style/load_sprite.ts b/src/style/load_sprite.ts index 9a107f0ee8..8b3edafb2f 100644 --- a/src/style/load_sprite.ts +++ b/src/style/load_sprite.ts @@ -21,40 +21,40 @@ export function loadSprite( const spriteArrayLength = spriteArray.length; const format = pixelRatio > 1 ? '@2x' : ''; - const combinedRequestsMap: {[requestKey: string]: Cancelable} = {}; - const getJsonRequestsMap: {[requestKey: string]: AbortController} = {}; + const combinedRequestsMap: {[requestKey: string]: AbortController} = {}; const jsonsMap: {[id: string]: any} = {}; const imagesMap: {[id: string]: (HTMLImageElement | ImageBitmap)} = {}; for (const {id, url} of spriteArray) { const jsonRequestParameters = requestManager.transformRequest(requestManager.normalizeSpriteURL(url, format, '.json'), ResourceType.SpriteJSON); const jsonRequestKey = `${id}_${jsonRequestParameters.url}`; // use id_url as requestMap key to make sure it is unique - getJsonRequestsMap[jsonRequestKey] = new AbortController(); - getJSON(jsonRequestParameters, getJsonRequestsMap[jsonRequestKey]).then((response) => { - delete getJsonRequestsMap[jsonRequestKey]; + combinedRequestsMap[jsonRequestKey] = new AbortController(); + getJSON(jsonRequestParameters, combinedRequestsMap[jsonRequestKey]).then((response) => { + delete combinedRequestsMap[jsonRequestKey]; jsonsMap[id] = response.data; - doOnceCompleted(callback, jsonsMap, imagesMap, null, spriteArrayLength); + doOnceCompleted(callback, jsonsMap, imagesMap, spriteArrayLength); }).catch((err) => { - delete getJsonRequestsMap[jsonRequestKey]; + delete combinedRequestsMap[jsonRequestKey]; callback(err); }); const imageRequestParameters = requestManager.transformRequest(requestManager.normalizeSpriteURL(url, format, '.png'), ResourceType.SpriteImage); const imageRequestKey = `${id}_${imageRequestParameters.url}`; // use id_url as requestMap key to make sure it is unique - combinedRequestsMap[imageRequestKey] = ImageRequest.getImage(imageRequestParameters, (err, img) => { + combinedRequestsMap[imageRequestKey] = new AbortController(); + ImageRequest.getImage(imageRequestParameters, combinedRequestsMap[imageRequestKey]).then((response) => { delete combinedRequestsMap[imageRequestKey]; - imagesMap[id] = img; - doOnceCompleted(callback, jsonsMap, imagesMap, err, spriteArrayLength); + imagesMap[id] = response.data; + doOnceCompleted(callback, jsonsMap, imagesMap, spriteArrayLength); + }).catch((err) => { + delete combinedRequestsMap[imageRequestKey]; + callback(err); }); } return { cancel() { for (const requst of Object.values(combinedRequestsMap)) { - requst.cancel(); - } - for (const controller of Object.values(getJsonRequestsMap)) { - controller.abort(); + requst.abort(); } } }; @@ -71,14 +71,7 @@ function doOnceCompleted( callbackFunc:Callback<{[spriteName: string]: {[id: string]: StyleImage}}>, jsonsMap:{[id: string]: any}, imagesMap:{[id: string]: (HTMLImageElement | ImageBitmap)}, - err: Error, expectedResultCounter: number): void { - - if (err) { - callbackFunc(err); - return; - } - if (expectedResultCounter !== Object.values(jsonsMap).length || expectedResultCounter !== Object.values(imagesMap).length) { // not done yet, nothing to do return; diff --git a/src/ui/map.ts b/src/ui/map.ts index 9c2bc81d86..e7fac57939 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -2319,7 +2319,9 @@ export class Map extends Camera { * @see [Add an icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-image/) */ loadImage(url: string, callback: GetImageCallback) { - ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), callback); + ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController()) + .then((response) => callback(null, response.data, {cacheControl: response.cacheControl, expires: response.expires})) + .catch(callback); } /** diff --git a/src/util/actor.test.ts b/src/util/actor.test.ts index 288e7695af..d977f07f64 100644 --- a/src/util/actor.test.ts +++ b/src/util/actor.test.ts @@ -41,7 +41,7 @@ describe('Actor', () => { await Promise.all([p1, p2]); }); - test('cancel a request does not reject or resolves a promise', async () => { + test('cancel a request does not reject or resolve a promise', async () => { const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', async (_mapId, params) => { await new Promise((resolve) => (setTimeout(resolve, 200))); @@ -64,7 +64,7 @@ describe('Actor', () => { expect(received).toBeFalsy(); }); - test('cancel a request of a canceable registrated callback will cancel it', async () => { + test('cancel a request of a cancelable registered callback will cancel it', async () => { const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; let gotAbortSignal = false; worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', (_mapId, _params, handlerAbortController) => { diff --git a/src/util/ajax.test.ts b/src/util/ajax.test.ts index 84afc0b75f..915f9390eb 100644 --- a/src/util/ajax.test.ts +++ b/src/util/ajax.test.ts @@ -1,7 +1,6 @@ import { getArrayBuffer, getJSON, - postData, AJAXError, sameOrigin } from './ajax'; @@ -27,20 +26,23 @@ describe('ajax', () => { server.restore(); }); - test('getArrayBuffer, 404', done => { + test('getArrayBuffer, 404', async () => { server.respondWith(request => { request.respond(404, undefined, '404 Not Found'); }); - getArrayBuffer({url: 'http://example.com/test.bin'}, async (error) => { + + try { + const promise = getArrayBuffer({url: 'http://example.com/test.bin'}, new AbortController()); + server.respond(); + await promise; + } catch (error) { const ajaxError = error as AJAXError; const body = await readAsText(ajaxError.body); expect(ajaxError.status).toBe(404); expect(ajaxError.statusText).toBe('Not Found'); expect(ajaxError.url).toBe('http://example.com/test.bin'); expect(body).toBe('404 Not Found'); - done(); - }); - server.respond(); + } }); test('getJSON', done => { @@ -84,17 +86,6 @@ describe('ajax', () => { } }); - test('postData, 204(no content): no error', done => { - server.respondWith(request => { - request.respond(204, undefined, undefined); - }); - postData({url: 'api.mapbox.com'}, (error) => { - expect(error).toBeNull(); - done(); - }); - server.respond(); - }); - test('sameOrigin method', () => { jest.spyOn(window, 'location', 'get').mockReturnValue({ protocol: 'https:', @@ -146,19 +137,17 @@ describe('ajax', () => { describe('requests parameters', () => { - test('should be provided to fetch API in getArrayBuffer function', (done) => { + test('should be provided to fetch API in getArrayBuffer function', async () => { server.respondWith(new ArrayBuffer(1)); - getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, () => { - - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toBe('http://example.com/test-params.json'); - expect(server.requests[0].method).toBe('GET'); - expect(server.requests[0].requestHeaders['Authorization']).toBe('Bearer 123'); - done(); - }); - + const promise = getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, new AbortController()); server.respond(); + await promise; + + expect(server.requests).toHaveLength(1); + expect(server.requests[0].url).toBe('http://example.com/test-params.json'); + expect(server.requests[0].method).toBe('GET'); + expect(server.requests[0].requestHeaders['Authorization']).toBe('Bearer 123'); }); test('should be provided to fetch API in getJSON function', async () => { diff --git a/src/util/ajax.ts b/src/util/ajax.ts index 84aeb7ec9b..b33eaa10c5 100644 --- a/src/util/ajax.ts +++ b/src/util/ajax.ts @@ -1,9 +1,14 @@ -import {extend, warnOnce, isWorker} from './util'; +import {extend, isWorker} from './util'; import {config} from './config'; import type {Callback} from '../types/callback'; import type {Cancelable} from '../types/cancelable'; +/** + * A type used to store the tile's expiration date and cache control definition + */ +export type ExpiryData = {cacheControl?: string | null; expires?: Date | string | null}; + /** * A `RequestParameters` object to be returned from Map.options.transformRequest callbacks. * @example @@ -55,6 +60,10 @@ export type RequestParameters = { cache?: RequestCache; }; +export type GetResourceResponse = ExpiryData & { + data: T; +} + /** * The response callback used in various places */ @@ -138,56 +147,35 @@ function makeFetchRequest(requestParameters: RequestParameters, callback: Respon request.headers.set('Accept', 'application/json'); } - const validateOrFetch = (err, cachedResponse?, responseIsFresh?) => { + const validateOrFetch = async () => { if (aborted) return; - if (err) { - // Do fetch in case of cache error. - // HTTP pages in Edge trigger a security error that can be ignored. - if (err.message !== 'SecurityError') { - warnOnce(err); - } - } - - if (cachedResponse && responseIsFresh) { - return finishRequest(cachedResponse); - } - - if (cachedResponse) { - // We can't do revalidation with 'If-None-Match' because then the - // request doesn't have simple cors headers. - } - - fetch(request).then(response => { - if (response.ok) { - return finishRequest(response); - } else { + try { + const response = await fetch(request); + if (!response.ok) { return response.blob().then(body => callback(new AJAXError(response.status, response.statusText, requestParameters.url, body))); } - }).catch(error => { + const parsePromise = (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') ? response.arrayBuffer() : + requestParameters.type === 'json' ? response.json() : + response.text(); + try { + const result = await parsePromise; + if (aborted) return; + complete = true; + callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires')); + } catch (err) { + if (!aborted) callback(new Error(err.message)); + } + } catch (error) { if (error.code === 20) { // silence expected AbortError return; } callback(new Error(error.message)); - }); - }; - - const finishRequest = (response) => { - ( - (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') ? response.arrayBuffer() : - requestParameters.type === 'json' ? response.json() : - response.text() - ).then(result => { - if (aborted) return; - complete = true; - callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires')); - }).catch(err => { - if (!aborted) callback(new Error(err.message)); - }); + } }; - validateOrFetch(null, null); + validateOrFetch(); return {cancel: () => { aborted = true; @@ -280,12 +268,21 @@ export const getJSON = (requestParameters: RequestParameters, abortController }); }; -export const getArrayBuffer = (requestParameters: RequestParameters, callback: ResponseCallback): Cancelable => { - return makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), callback); -}; - -export const postData = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { - return makeRequest(extend(requestParameters, {method: 'POST'}), callback); +export const getArrayBuffer = (requestParameters: RequestParameters, abortController: AbortController): Promise<{data: ArrayBuffer} & ExpiryData> => { + return new Promise<{data: ArrayBuffer}& ExpiryData>((resolve, reject) => { + const callback = (err: Error, data: ArrayBuffer, cacheControl: string | null, expires: string | null) => { + if (err) { + reject(err); + } else { + resolve({data, cacheControl, expires}); + } + }; + const canelable = makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), callback); + abortController.signal.addEventListener('abort', () => { + canelable.cancel(); + reject(new Error('AbortError')); + }); + }); }; export function sameOrigin(inComingUrl: string) { @@ -304,10 +301,7 @@ export function sameOrigin(inComingUrl: string) { const locationObj = window.location; return urlObj.protocol === locationObj.protocol && urlObj.host === locationObj.host; } -/** - * A type used to store the tile's expiration date and cache control definition - */ -export type ExpiryData = {cacheControl?: string | null; expires?: Date | string | null}; + export const getVideo = function(urls: Array, callback: Callback): Cancelable { const video: HTMLVideoElement = window.document.createElement('video'); video.muted = true; diff --git a/src/util/image_request.test.ts b/src/util/image_request.test.ts index b1f9724883..c124b01e55 100644 --- a/src/util/image_request.test.ts +++ b/src/util/image_request.test.ts @@ -2,7 +2,7 @@ import {config} from './config'; import {webpSupported} from './webp_supported'; import {stubAjaxGetImage} from './test/util'; import {fakeServer, type FakeServer} from 'nise'; -import {ImageRequest, ImageRequestQueueItem} from './image_request'; +import {ImageRequest} from './image_request'; import * as ajax from './ajax'; describe('ImageRequest', () => { @@ -22,23 +22,24 @@ describe('ImageRequest', () => { const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS; let callbackCount = 0; - function callback(err) { - if (err) return; - // last request is only added after we got a response from one of the previous ones - expect(server.requests).toHaveLength(maxRequests + callbackCount); + + const promiseCallback = () => { callbackCount++; if (callbackCount === 2) { done(); } - } + }; + + for (let i = 0; i < maxRequests + 5; i++) { + ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback); - for (let i = 0; i < maxRequests + 1; i++) { - ImageRequest.getImage({url: ''}, callback); } expect(server.requests).toHaveLength(maxRequests); - server.requests[0].respond(undefined, undefined, undefined); - server.requests[1].respond(undefined, undefined, undefined); + server.requests[0].respond(200, undefined, undefined); + expect(server.requests).toHaveLength(maxRequests + callbackCount); + server.requests[1].respond(200, undefined, undefined); + expect(server.requests).toHaveLength(maxRequests + callbackCount); }); test('Cancel: getImage cancelling frees up request for maxParallelImageRequests', done => { server.respondWith(request => request.respond(200, {'Content-Type': 'image/png'}, '')); @@ -46,44 +47,47 @@ describe('ImageRequest', () => { const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS; for (let i = 0; i < maxRequests + 1; i++) { - ImageRequest.getImage({url: ''}, () => done('test failed: getImage callback was called')).cancel(); + const abortController = new AbortController(); + ImageRequest.getImage({url: ''}, abortController).catch((e) => expect(e.message).toBe('AbortError')); + abortController.abort(); } expect(server.requests).toHaveLength(maxRequests + 1); done(); }); - test('Cancel: getImage requests that were once queued are still abortable', done => { + test('Cancel: getImage requests that were once queued are still abortable', async () => { const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS; - const requests = []; + const abortControllers: AbortController[] = []; for (let i = 0; i < maxRequests; i++) { - requests.push(ImageRequest.getImage({url: ''}, () => {})); + const abortController = new AbortController(); + abortControllers.push(abortController); + ImageRequest.getImage({url: ''}, abortController).catch(() => {}); } // the limit of allowed requests is reached expect(server.requests).toHaveLength(maxRequests); const queuedURL = 'this-is-the-queued-request'; - const queued = ImageRequest.getImage({url: queuedURL}, () => done('test failed: getImage callback was called')); + const abortController = new AbortController(); + ImageRequest.getImage({url: queuedURL}, abortController).catch((e) => expect(e.message).toBe('AbortError')); // the new requests is queued because the limit is reached expect(server.requests).toHaveLength(maxRequests); // cancel the first request to let the queued request start - requests[0].cancel(); + abortControllers[0].abort(); expect(server.requests).toHaveLength(maxRequests + 1); // abort the previously queued request and confirm that it is aborted const queuedRequest = server.requests[server.requests.length - 1]; expect(queuedRequest.url).toBe(queuedURL); expect((queuedRequest as any).aborted).toBeUndefined(); - queued.cancel(); + abortController.abort(); expect((queuedRequest as any).aborted).toBe(true); - - done(); }); - test('getImage sends accept/webp when supported', done => { + test('getImage sends accept/webp when supported', async () => { server.respondWith((request) => { expect(request.requestHeaders.accept.includes('image/webp')).toBeTruthy(); request.respond(200, {'Content-Type': 'image/webp'}, ''); @@ -92,133 +96,124 @@ describe('ImageRequest', () => { // mock webp support webpSupported.supported = true; - ImageRequest.getImage({url: ''}, () => { done(); }); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); server.respond(); + + await promise; + expect(true).toBeTruthy(); }); - test('getImage uses createImageBitmap when supported', done => { + test('getImage uses createImageBitmap when supported', async () => { server.respondWith(request => request.respond(200, {'Content-Type': 'image/png', 'Cache-Control': 'cache', 'Expires': 'expires'}, '')); stubAjaxGetImage(() => Promise.resolve(new ImageBitmap())); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); + server.respond(); - ImageRequest.getImage({url: ''}, (err, img, expiry) => { - if (err) done(err); - expect(img).toBeInstanceOf(ImageBitmap); - expect(expiry.cacheControl).toBe('cache'); - expect(expiry.expires).toBe('expires'); - done(); - }); + const response = await promise; - server.respond(); + expect(response.data).toBeInstanceOf(ImageBitmap); + expect(response.cacheControl).toBe('cache'); + expect(response.expires).toBe('expires'); }); - test('getImage using createImageBitmap throws exception', done => { + test('getImage using createImageBitmap throws exception', async () => { server.respondWith(request => request.respond(200, {'Content-Type': 'image/png', 'Cache-Control': 'cache', 'Expires': 'expires'}, '')); stubAjaxGetImage(() => Promise.reject(new Error('error'))); - ImageRequest.getImage({url: ''}, (err, img) => { - expect(img).toBeFalsy(); - if (err) done(); - }); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); server.respond(); + + await expect(promise).rejects.toThrow(); }); - test('getImage uses HTMLImageElement when createImageBitmap is not supported', done => { + test('getImage uses HTMLImageElement when createImageBitmap is not supported', async () => { const makeRequestSky = jest.spyOn(ajax, 'makeRequest'); server.respondWith(request => request.respond(200, {'Content-Type': 'image/png', 'Cache-Control': 'cache', 'Expires': 'expires'}, '')); - ImageRequest.getImage({url: ''}, (err, img, expiry) => { - if (err) done(`get image failed with error ${err.message}`); - expect(img).toBeInstanceOf(HTMLImageElement); - expect(expiry.cacheControl).toBe('cache'); - expect(expiry.expires).toBe('expires'); - done(); - }); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); server.respond(); expect(makeRequestSky).toHaveBeenCalledTimes(1); makeRequestSky.mockClear(); + const response = await promise; + expect(response.data).toBeInstanceOf(HTMLImageElement); + expect(response.cacheControl).toBe('cache'); + expect(response.expires).toBe('expires'); }); - test('getImage using HTMLImageElement with same-origin credentials', done => { + test('getImage using HTMLImageElement with same-origin credentials', async () => { const makeRequestSky = jest.spyOn(ajax, 'makeRequest'); - ImageRequest.getImage({url: '', credentials: 'same-origin'}, (err, img: HTMLImageElement) => { - if (err) done(err); - expect(img).toBeInstanceOf(HTMLImageElement); - expect(img.crossOrigin).toBe('anonymous'); - done(); - }, false); + const promise = ImageRequest.getImage({url: '', credentials: 'same-origin'}, new AbortController(), false); expect(makeRequestSky).toHaveBeenCalledTimes(0); makeRequestSky.mockClear(); + + const response = await promise; + + expect(response.data).toBeInstanceOf(HTMLImageElement); + expect((response.data as HTMLImageElement).crossOrigin).toBe('anonymous'); }); - test('getImage using HTMLImageElement with include credentials', done => { + test('getImage using HTMLImageElement with include credentials', async () => { const makeRequestSky = jest.spyOn(ajax, 'makeRequest'); - ImageRequest.getImage({url: '', credentials: 'include'}, (err, img: HTMLImageElement) => { - if (err) done(err); - expect(img).toBeInstanceOf(HTMLImageElement); - expect(img.crossOrigin).toBe('use-credentials'); - done(); - }, false); + const promise = ImageRequest.getImage({url: '', credentials: 'include'}, new AbortController(), false); expect(makeRequestSky).toHaveBeenCalledTimes(0); makeRequestSky.mockClear(); + + const response = await promise; + + expect(response.data).toBeInstanceOf(HTMLImageElement); + expect((response.data as HTMLImageElement).crossOrigin).toBe('use-credentials'); }); - test('getImage using HTMLImageElement with accept header', done => { + test('getImage using HTMLImageElement with accept header', async () => { const makeRequestSky = jest.spyOn(ajax, 'makeRequest'); - ImageRequest.getImage({url: '', credentials: 'include', headers: {accept: 'accept'}}, - (err, img: HTMLImageElement) => { - if (err) done(err); - expect(img).toBeInstanceOf(HTMLImageElement); - expect(img.crossOrigin).toBe('use-credentials'); - done(); - }, false); + const promise = ImageRequest.getImage({url: '', credentials: 'include', headers: {accept: 'accept'}}, new AbortController(), false); expect(makeRequestSky).toHaveBeenCalledTimes(0); makeRequestSky.mockClear(); + + const response = await promise; + expect(response.data).toBeInstanceOf(HTMLImageElement); + expect((response.data as HTMLImageElement).crossOrigin).toBe('use-credentials'); }); test('getImage uses makeRequest when custom Headers are added', () => { const makeRequestSky = jest.spyOn(ajax, 'makeRequest'); - ImageRequest.getImage({url: '', credentials: 'include', headers: {custom: 'test', accept: 'image'}}, - () => {}, - false); + ImageRequest.getImage({url: '', credentials: 'include', headers: {custom: 'test', accept: 'image'}}, new AbortController(), false); expect(makeRequestSky).toHaveBeenCalledTimes(1); makeRequestSky.mockClear(); }); - test('getImage request returned 404 response for fetch request', done => { + test('getImage request returned 404 response for fetch request', async () => { server.respondWith(request => request.respond(404)); - ImageRequest.getImage({url: ''}, (err) => { - if (err) done(); - else done('Image download should have failed'); - }); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); server.respond(); + + await expect(promise).rejects.toThrow('Not Found'); }); - test('getImage request failed for HTTPImageRequest', done => { - ImageRequest.getImage({url: 'error'}, (err) => { - if (err) done(); - else done('Image download should have failed'); - }, false); + test('getImage request failed for HTTPImageRequest', async () => { + const promise = ImageRequest.getImage({url: 'error'}, new AbortController(), false); + await expect(promise).rejects.toThrow(/Could not load image.*/); }); - test('Cancel: getImage request cancelled for HTTPImageRequest', done => { + test('Cancel: getImage request cancelled for HTTPImageRequest', async () => { let imageUrl; const requestUrl = 'test'; // eslint-disable-next-line accessor-pairs @@ -228,51 +223,51 @@ describe('ImageRequest', () => { } }); - const request = ImageRequest.getImage({url: requestUrl}, () => { - done('Callback should not be called in case image request is cancelled'); - }, false); + const abortController = new AbortController(); + ImageRequest.getImage({url: requestUrl}, abortController, false); expect(imageUrl).toBe(requestUrl); - expect(request.cancelled).toBeFalsy(); - request.cancel(); - expect(request.cancelled).toBeTruthy(); + expect(abortController.signal.aborted).toBeFalsy(); + abortController.abort(); + expect(abortController.signal.aborted).toBeTruthy(); expect(imageUrl).toBe(''); - done(); }); - test('Cancel: getImage request cancelled', done => { + test('Cancel: getImage request cancelled', async () => { server.respondWith(request => request.respond(200, {'Content-Type': 'image/png', 'Cache-Control': 'cache', 'Expires': 'expires'}, '')); - const request = ImageRequest.getImage({url: ''}, () => { - done('Callback should not be called in case image request is cancelled'); - }); + const abortController = new AbortController(); + let response = false; + ImageRequest.getImage({url: ''}, abortController) + .then(() => { response = true; }) + .catch(() => { response = true; }); - expect(request.cancelled).toBeFalsy(); - request.cancel(); - expect(request.cancelled).toBeTruthy(); + abortController.abort(); server.respond(); - done(); + + expect(response).toBeFalsy(); }); - test('Cancel: Cancellation of an image which has not yet been requested', () => { + test('Cancel: Cancellation of an image which has not yet been requested', async () => { const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS; let callbackCounter = 0; - function callback() { - callbackCounter++; - } - const requests: ImageRequestQueueItem[] = []; + const promiseCallback = () => { callbackCounter++; }; + + const abortConstollers: {url: string; abortController: AbortController}[] = []; for (let i = 0; i < maxRequests + 100; i++) { - requests.push(ImageRequest.getImage({url: `${i}`}, callback)); + const url = `${i}`; + const abortController = new AbortController(); + abortConstollers.push({url, abortController}); + ImageRequest.getImage({url}, abortController).then(promiseCallback).catch(() => {}); } - // Request should have been initiated - expect(requests[0].innerRequest).toBeDefined(); - requests[0].cancel(); + abortConstollers[0].abortController.abort(); + await new Promise((resolve) => (setTimeout(resolve, 0))); // Queue should move forward and next request is made expect(server.requests).toHaveLength(maxRequests + 1); @@ -280,10 +275,9 @@ describe('ImageRequest', () => { expect(callbackCounter).toBe(0); // Cancel request which is not yet issued. It should not fire callback - const nextRequestInQueue = requests[server.requests.length]; - expect(nextRequestInQueue.innerRequest).toBeUndefined(); - const cancelledImageUrl = nextRequestInQueue.requestParameters.url; - nextRequestInQueue.cancel(); + const nextRequestInQueue = abortConstollers[server.requests.length]; + const cancelledImageUrl = nextRequestInQueue.url; + nextRequestInQueue.abortController.abort(); // Queue should not move forward as cancelled image was sitting in queue expect(server.requests).toHaveLength(maxRequests + 1); @@ -291,6 +285,7 @@ describe('ImageRequest', () => { // On server response, next image queued should not be the cancelled image server.requests[1].respond(200); + await new Promise((resolve) => (setTimeout(resolve, 0))); expect(callbackCounter).toBe(1); expect(server.requests).toHaveLength(maxRequests + 2); // Verify that the last request made skipped the cancelled image request @@ -306,12 +301,10 @@ describe('ImageRequest', () => { callbackHandles.push(ImageRequest.addThrottleControl(() => true)); let callbackCounter = 0; - function callback() { - callbackCounter++; - } + const promiseCallback = () => { callbackCounter++; }; for (let i = 0; i < maxRequestsPerFrame + 1; i++) { - ImageRequest.getImage({url: ''}, callback); + ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback); } expect(server.requests).toHaveLength(maxRequestsPerFrame); @@ -330,12 +323,10 @@ describe('ImageRequest', () => { const controlId = ImageRequest.addThrottleControl(() => true); let callbackCounter = 0; - function callback() { - callbackCounter++; - } + const promiseCallback = () => { callbackCounter++; }; for (let i = 0; i < maxRequests; i++) { - ImageRequest.getImage({url: ''}, callback); + ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback); } // Should only fire request to a max allowed per frame @@ -352,12 +343,10 @@ describe('ImageRequest', () => { const controlId = ImageRequest.addThrottleControl(() => false); let callbackCounter = 0; - function callback() { - callbackCounter++; - } + const promiseCallback = () => { callbackCounter++; }; for (let i = 0; i < maxRequests + 100; i++) { - ImageRequest.getImage({url: ''}, callback); + ImageRequest.getImage({url: ''}, new AbortController()).then(promiseCallback); } // all should be processed because throttle control is returning false @@ -369,7 +358,7 @@ describe('ImageRequest', () => { ImageRequest.removeThrottleControl(controlId); }); - test('throttling: removing throttling client will process all requests', () => { + test('throttling: removing throttling client will process all requests', async () => { const requestParameter = {'Content-Type': 'image/png', url: ''}; const maxRequestsPerFrame = config.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME; @@ -380,16 +369,13 @@ describe('ImageRequest', () => { ImageRequest.addThrottleControl(() => throttlingClient); } - let callbackCounter = 0; - function callback() { - callbackCounter++; - } - // make 2 times + 1 more requests const requestsMade = 2 * maxRequestsPerFrame + 1; - const imageResults: ImageRequestQueueItem[] = []; + const completedMap: {[index: number]: boolean} = {}; for (let i = 0; i < requestsMade; i++) { - imageResults.push(ImageRequest.getImage(requestParameter, callback)); + const promise = ImageRequest.getImage(requestParameter, new AbortController()); + promise.catch(() => {}); + promise.then(() => { completedMap[i] = true; }); } // up to the config value @@ -400,14 +386,18 @@ describe('ImageRequest', () => { // unleash it by removing the throttling client ImageRequest.removeThrottleControl(throttlingIndex); + await new Promise((resolve) => (setTimeout(resolve, 0))); expect(server.requests).toHaveLength(requestsMade); // all pending - expect(callbackCounter).toBe(1); + expect(Object.keys(completedMap)).toHaveLength(1); // everything should still be pending except itemIndexToComplete for (let i = 0; i < maxRequestsPerFrame + 1; i++) { - expect(imageResults[i].completed).toBe(i === itemIndexToComplete); + expect(completedMap[i]).toBe(i === itemIndexToComplete ? true : undefined); } }); + + // HM TODO: write a test that all requests are returning 404 and make sure that the queue is not stuck + }); diff --git a/src/util/image_request.ts b/src/util/image_request.ts index 3da75e0583..0c92859404 100644 --- a/src/util/image_request.ts +++ b/src/util/image_request.ts @@ -1,6 +1,4 @@ -import type {Cancelable} from '../types/cancelable'; -import {RequestParameters, ExpiryData, makeRequest, sameOrigin, getProtocolAction} from './ajax'; -import type {Callback} from '../types/callback'; +import {RequestParameters, ExpiryData, makeRequest, sameOrigin, getProtocolAction, GetResourceResponse} from './ajax'; import {arrayBufferToImageBitmap, arrayBufferToImage, extend, isWorker, isImageBitmap} from './util'; import {webpSupported} from './webp_supported'; @@ -13,13 +11,13 @@ export type GetImageCallback = (error?: Error | null, image?: HTMLImageElement | type ImageQueueThrottleControlCallback = () => boolean; -export type ImageRequestQueueItem = Cancelable & { +export type ImageRequestQueueItem = { requestParameters: RequestParameters; supportImageRefresh: boolean; - callback: GetImageCallback; - cancelled: boolean; - completed: boolean; - innerRequest?: Cancelable; + state: 'queued' | 'running' | 'completed'; + abortController: AbortController; + onError: (error: Error) => void; + onSuccess: (response: GetResourceResponse) => void; } type ImageQueueThrottleCallbackDictionary = { @@ -95,78 +93,70 @@ export namespace ImageRequest { * @returns `true` if any callback is causing the queue to be throttled. */ const isThrottled = (): boolean => { - const allControlKeys = Object.keys(throttleControlCallbacks); - let throttleingRequested = false; - if (allControlKeys.length > 0) { - for (const key of allControlKeys) { - throttleingRequested = throttleControlCallbacks[key](); - if (throttleingRequested) { - break; - } + for (const key of Object.keys(throttleControlCallbacks)) { + if (throttleControlCallbacks[key]()) { + return true; } } - return throttleingRequested; + return false; }; /** * Request to load an image. * @param requestParameters - Request parameters. - * @param callback - Callback to issue when the request completes. + * @param abortController - allows to abort the request. * @param supportImageRefresh - `true`, if the image request need to support refresh based on cache headers. - * @returns Cancelable request. + * @returns - A promise resolved when the image is loaded. */ - export const getImage = ( - requestParameters: RequestParameters, - callback: GetImageCallback, - supportImageRefresh: boolean = true - ): ImageRequestQueueItem => { - if (webpSupported.supported) { - if (!requestParameters.headers) { - requestParameters.headers = {}; + export const getImage = (requestParameters: RequestParameters, abortController: AbortController, supportImageRefresh: boolean = true): Promise> => { + return new Promise>((resolve, reject) => { + if (webpSupported.supported) { + if (!requestParameters.headers) { + requestParameters.headers = {}; + } + requestParameters.headers.accept = 'image/webp,*/*'; } - requestParameters.headers.accept = 'image/webp,*/*'; - } - - const request:ImageRequestQueueItem = { - requestParameters, - supportImageRefresh, - callback, - cancelled: false, - completed: false, - cancel: () => { - if (!request.completed && !request.cancelled) { - request.cancelled = true; - - // Only reduce currentParallelImageRequests, if the image request was issued. - if (request.innerRequest) { - request.innerRequest.cancel(); - currentParallelImageRequests--; - } + extend(requestParameters, {type: 'image'}); + const request: ImageRequestQueueItem = { + abortController, + requestParameters, + supportImageRefresh, + state: 'queued', + onError: (error: Error) => { + reject(error); + }, + onSuccess: (response) => { + resolve(response); + } + }; - // in the case of cancelling, it WILL move on - processQueue(); + imageRequestQueue.push(request); + request.abortController.signal.addEventListener('abort', () => { + if (request.state === 'completed' || request.state === 'queued') { + return; } - } - }; + // Only reduce currentParallelImageRequests, if the image request was issued. + currentParallelImageRequests--; - imageRequestQueue.push(request); - processQueue(); - return request; + // in the case of cancelling, it WILL move on + processQueue(); + }); + processQueue(); + }); }; - const arrayBufferToCanvasImageSource = (data: ArrayBuffer, callback: Callback) => { + const arrayBufferToCanvasImageSource = (data: ArrayBuffer): Promise => { const imageBitmapSupported = typeof createImageBitmap === 'function'; if (imageBitmapSupported) { - arrayBufferToImageBitmap(data, callback); + return arrayBufferToImageBitmap(data); } else { - arrayBufferToImage(data, callback); + return arrayBufferToImage(data); } }; - const doImageRequest = (itemInQueue: ImageRequestQueueItem): Cancelable => { - const {requestParameters, supportImageRefresh, callback} = itemInQueue; - extend(requestParameters, {type: 'image'}); - + const doImageRequest = async (itemInQueue: ImageRequestQueueItem) => { + itemInQueue.state = 'running'; + const {requestParameters, supportImageRefresh, onError, onSuccess, abortController} = itemInQueue; // - If refreshExpiredTiles is false, then we can use HTMLImageElement to download raster images. // - Fetch/XHR (via MakeRequest API) will be used to download images for following scenarios: // 1. Style image sprite will had a issue with HTMLImageElement as described @@ -182,44 +172,41 @@ export namespace ImageRequest { (!requestParameters.headers || Object.keys(requestParameters.headers).reduce((acc, item) => acc && item === 'accept', true)); - const action = canUseHTMLImageElement ? getImageUsingHtmlImage : makeRequest; - return action( - requestParameters, - (err?: Error | null, - data?: HTMLImageElement | ImageBitmap | ArrayBuffer | null, - cacheControl?: string | null, - expires?: string | null) => { - onImageResponse(itemInQueue, callback, err, data, cacheControl, expires); + currentParallelImageRequests++; + + const getImagePromise = canUseHTMLImageElement ? + getImageUsingHtmlImage(requestParameters, abortController) : + new Promise>((resolve, reject) => { + const callback = (error: Error | null, data: HTMLImageElement | ImageBitmap | null, cacheControl?: string, expires?: string) => { + if (error) { + reject(error); + } else { + resolve({data, cacheControl, expires}); + } + }; + const cancelable = makeRequest(requestParameters, callback); + abortController.signal.addEventListener('abort', () => { + cancelable.cancel(); + }); }); - }; - const onImageResponse = ( - itemInQueue: ImageRequestQueueItem, - callback:GetImageCallback, - err?: Error | null, - data?: HTMLImageElement | ImageBitmap | ArrayBuffer | null, - cacheControl?: string | null, - expires?: string | null): void => { - if (err) { - callback(err); - } else if (data instanceof HTMLImageElement || isImageBitmap(data)) { - // User using addProtocol can directly return HTMLImageElement/ImageBitmap type - // If HtmlImageElement is used to get image then response type will be HTMLImageElement - callback(null, data); - } else if (data) { - const decoratedCallback = (imgErr?: Error | null, imgResult?: CanvasImageSource | null) => { - if (imgErr != null) { - callback(imgErr); - } else if (imgResult != null) { - callback(null, imgResult as (HTMLImageElement | ImageBitmap), {cacheControl, expires}); - } - }; - arrayBufferToCanvasImageSource(data, decoratedCallback); - } - if (!itemInQueue.cancelled) { - itemInQueue.completed = true; + try { + const response = await getImagePromise; + delete itemInQueue.abortController; + itemInQueue.state = 'completed'; + if (response.data instanceof HTMLImageElement || isImageBitmap(response.data)) { + // User using addProtocol can directly return HTMLImageElement/ImageBitmap type + // If HtmlImageElement is used to get image then response type will be HTMLImageElement + onSuccess(response as GetResourceResponse); + } else if (response.data) { + const img = await arrayBufferToCanvasImageSource(response.data); + onSuccess({data: img, cacheControl: response.cacheControl, expires: response.expires}); + } + } catch (err) { + delete itemInQueue.abortController; + onError(err); + } finally { currentParallelImageRequests--; - processQueue(); } }; @@ -239,49 +226,45 @@ export namespace ImageRequest { numImageRequests++) { const topItemInQueue: ImageRequestQueueItem = imageRequestQueue.shift(); - if (topItemInQueue.cancelled) { + if (topItemInQueue.abortController.signal.aborted) { numImageRequests--; continue; } - - const innerRequest = doImageRequest(topItemInQueue); - - currentParallelImageRequests++; - - topItemInQueue.innerRequest = innerRequest; + doImageRequest(topItemInQueue); } }; - const getImageUsingHtmlImage = (requestParameters: RequestParameters, callback: GetImageCallback): Cancelable => { - const image = new Image() as HTMLImageElementWithPriority; - const url = requestParameters.url; - let requestCancelled = false; - const credentials = requestParameters.credentials; - if (credentials && credentials === 'include') { - image.crossOrigin = 'use-credentials'; - } else if ((credentials && credentials === 'same-origin') || !sameOrigin(url)) { - image.crossOrigin = 'anonymous'; - } + const getImageUsingHtmlImage = (requestParameters: RequestParameters, abortController: AbortController): Promise> => { + return new Promise>((resolve, reject) => { - image.fetchPriority = 'high'; - image.onload = () => { - callback(null, image); - image.onerror = image.onload = null; - }; - image.onerror = () => { - if (!requestCancelled) { - callback(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.')); + const image = new Image() as HTMLImageElementWithPriority; + const url = requestParameters.url; + const credentials = requestParameters.credentials; + if (credentials && credentials === 'include') { + image.crossOrigin = 'use-credentials'; + } else if ((credentials && credentials === 'same-origin') || !sameOrigin(url)) { + image.crossOrigin = 'anonymous'; } - image.onerror = image.onload = null; - }; - image.src = url; - return { - cancel: () => { - requestCancelled = true; + + abortController.signal.addEventListener('abort', () => { // Set src to '' to actually cancel the request image.src = ''; - } - }; + }); + + image.fetchPriority = 'high'; + image.onload = () => { + image.onerror = image.onload = null; + resolve({data: image}); + }; + image.onerror = () => { + image.onerror = image.onload = null; + if (abortController.signal.aborted) { + return; + } + reject(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.')); + }; + image.src = url; + }); }; } diff --git a/src/util/test/util.ts b/src/util/test/util.ts index bc091806b5..d8d70d9927 100644 --- a/src/util/test/util.ts +++ b/src/util/test/util.ts @@ -128,7 +128,9 @@ export function stubAjaxGetImage(createImageBitmap) { set(url: string) { if (url === 'error') { this.onerror(); - } else this.onload(); + } else if (this.onload) { + this.onload(); + } } }); } diff --git a/src/util/util.test.ts b/src/util/util.test.ts index 47ede250b3..2754108c8f 100644 --- a/src/util/util.test.ts +++ b/src/util/util.test.ts @@ -1,5 +1,5 @@ import Point from '@mapbox/point-geometry'; -import {arraysIntersect, asyncAll, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util'; +import {arraysIntersect, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util'; import {Canvas} from 'canvas'; describe('util', () => { @@ -14,50 +14,6 @@ describe('util', () => { expect(pick({a: 1, b: 2, c: 3}, ['a', 'c', 'd'])).toEqual({a: 1, c: 3}); expect(typeof uniqueId() === 'number').toBeTruthy(); - test('asyncAll - sync', done => { - expect(asyncAll([0, 1, 2], (data, callback) => { - callback(null, data); - }, (err, results) => { - expect(err).toBeFalsy(); - expect(results).toEqual([0, 1, 2]); - })).toBeUndefined(); - done(); - }); - - test('asyncAll - async', done => { - expect(asyncAll([4, 0, 1, 2], (data, callback) => { - setTimeout(() => { - callback(null, data); - }, data); - }, (err, results) => { - expect(err).toBeFalsy(); - expect(results).toEqual([4, 0, 1, 2]); - done(); - })).toBeUndefined(); - }); - - test('asyncAll - error', done => { - expect(asyncAll([4, 0, 1, 2], (data, callback) => { - setTimeout(() => { - callback(new Error('hi'), data); - }, data); - }, (err, results) => { - expect(err && err.message).toBe('hi'); - expect(results).toEqual([4, 0, 1, 2]); - done(); - })).toBeUndefined(); - }); - - test('asyncAll - empty', done => { - expect(asyncAll([], (data, callback) => { - callback(null, 'foo'); - }, (err, results) => { - expect(err).toBeFalsy(); - expect(results).toEqual([]); - })).toBeUndefined(); - done(); - }); - test('isPowerOfTwo', done => { expect(isPowerOfTwo(1)).toBe(true); expect(isPowerOfTwo(2)).toBe(true); @@ -116,20 +72,6 @@ describe('util', () => { done(); }); - test('asyncAll', done => { - let expectedValue = 1; - asyncAll([], (callback) => { callback(); }, () => { - expect('immediate callback').toBeTruthy(); - }); - asyncAll([1, 2, 3], (number, callback) => { - expect(number).toBe(expectedValue++); - expect(callback instanceof Function).toBeTruthy(); - callback(null, 0); - }, () => { - done(); - }); - }); - test('mapObject', () => { expect.assertions(6); expect(mapObject({}, () => { expect(false).toBeTruthy(); })).toEqual({}); diff --git a/src/util/util.ts b/src/util/util.ts index 7d0be7ab67..a91d305b96 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -1,6 +1,5 @@ import Point from '@mapbox/point-geometry'; import UnitBezier from '@mapbox/unitbezier'; -import type {Callback} from '../types/callback'; import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted'; import type {Size} from './image'; import type {WorkerGlobalScopeInterface} from './web_worker'; @@ -66,33 +65,6 @@ export function wrap(n: number, min: number, max: number): number { return (w === min) ? max : w; } -/** - * Call an asynchronous function on an array of arguments, - * calling `callback` with the completed results of all calls. - * - * @param array - input to each call of the async function. - * @param fn - an async function with signature (data, callback) - * @param callback - a callback run after all async work is done. - * called with an array, containing the results of each async call. - */ -export function asyncAll( - array: Array, - fn: (item: Item, fnCallback: Callback) => void, - callback: Callback> -) { - if (!array.length) { return callback(null, []); } - let remaining = array.length; - const results = new Array(array.length); - let error = null; - array.forEach((item, i) => { - fn(item, (err, result) => { - if (err) error = err; - results[i] = (result as any as Result); // https://github.com/facebook/flow/issues/2123 - if (--remaining === 0) callback(error, results); - }); - }); -} - /** * Compute the difference between the keys in one object and the keys * in another object. @@ -482,16 +454,16 @@ export function isImageBitmap(image: any): image is ImageBitmap { * ArrayBuffers. * * @param data - Data to convert - * @param callback - A callback executed after the conversion is finished. Invoked with error (if any) as the first argument and resulting image bitmap (when no error) as the second + * @returns - A promise resolved when the conversion is finished */ -export function arrayBufferToImageBitmap(data: ArrayBuffer, callback: (err?: Error | null, image?: ImageBitmap | null) => void) { +export const arrayBufferToImageBitmap = async (data: ArrayBuffer): Promise => { const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'}); - createImageBitmap(blob).then((imgBitmap) => { - callback(null, imgBitmap); - }).catch((e) => { - callback(new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`)); - }); -} + try { + return createImageBitmap(blob); + } catch (e) { + throw new Error(`Could not load image because of ${e.message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`); + } +}; const transparentPngUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII='; @@ -503,23 +475,25 @@ const transparentPngUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA * ArrayBuffers. * * @param data - Data to convert - * @param callback - A callback executed after the conversion is finished. Invoked with error (if any) as the first argument and resulting image element (when no error) as the second - */ -export function arrayBufferToImage(data: ArrayBuffer, callback: (err?: Error | null, image?: HTMLImageElement | null) => void) { - const img: HTMLImageElement = new Image(); - img.onload = () => { - callback(null, img); - URL.revokeObjectURL(img.src); - // prevent image dataURI memory leak in Safari; - // but don't free the image immediately because it might be uploaded in the next frame - // https://github.com/mapbox/mapbox-gl-js/issues/10226 - img.onload = null; - window.requestAnimationFrame(() => { img.src = transparentPngUrl; }); - }; - img.onerror = () => callback(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.')); - const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'}); - img.src = data.byteLength ? URL.createObjectURL(blob) : transparentPngUrl; -} + * @returns - A promise resolved when the conversion is finished + */ +export const arrayBufferToImage = (data: ArrayBuffer): Promise => { + return new Promise((resolve, reject) => { + const img: HTMLImageElement = new Image(); + img.onload = () => { + resolve(img); + URL.revokeObjectURL(img.src); + // prevent image dataURI memory leak in Safari; + // but don't free the image immediately because it might be uploaded in the next frame + // https://github.com/mapbox/mapbox-gl-js/issues/10226 + img.onload = null; + window.requestAnimationFrame(() => { img.src = transparentPngUrl; }); + }; + img.onerror = () => reject(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.')); + const blob: Blob = new Blob([new Uint8Array(data)], {type: 'image/png'}); + img.src = data.byteLength ? URL.createObjectURL(blob) : transparentPngUrl; + }); +}; /** * Computes the webcodecs VideoFrame API options to select a rectangle out of diff --git a/test/build/min.test.ts b/test/build/min.test.ts index f3b5572edb..44daef1d78 100644 --- a/test/build/min.test.ts +++ b/test/build/min.test.ts @@ -36,7 +36,7 @@ describe('test min build', () => { const decreaseQuota = 4096; // feel free to update this value after you've checked that it has changed on purpose :-) - const expectedBytes = 766666; + const expectedBytes = 767777; expect(actualBytes - expectedBytes).toBeLessThan(increaseQuota); expect(expectedBytes - actualBytes).toBeLessThan(decreaseQuota);