diff --git a/CHANGELOG.md b/CHANGELOG.md index d753ae06c4..22bd3e628a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ ## main ### ✨ Features and improvements + +- Changed `ImageRequest` to be `Promise` based ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233)) +- ⚠️ Changed the undeling worker communication from callbacks to promises. This has a breaking effect on the implementation of custom `WorkerSource` and how it behaves ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233)) +- ⚠️ Changed the `Source` interface to return promises instead of callbacks ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233)) +- ⚠️ Changed all the sources to be promises based. ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233)) +- ⚠️ Changed the `map.loadImage` method to return a `Promise` ([#3233](https://github.com/maplibre/maplibre-gl-js/pull/3233)) - _...Add new stuff here..._ ### 🐞 Bug fixes + +- Fixes a security issue in `Actor` against XSS attacks in postMessage / onmessage ([#3239](https://github.com/maplibre/maplibre-gl-js/pull/3239)) - _...Add new stuff here..._ ## 3.6.2 diff --git a/build/generate-doc-images.ts b/build/generate-doc-images.ts index 50ce85bb19..cf44d80269 100644 --- a/build/generate-doc-images.ts +++ b/build/generate-doc-images.ts @@ -2,6 +2,7 @@ import path from 'path'; import fs from 'fs'; import puppeteer from 'puppeteer'; import packageJson from '../package.json' assert { type: 'json' }; +import {sleep} from '../src/util/test/util'; const exampleName = process.argv[2]; const examplePath = path.resolve('test', 'examples'); @@ -28,10 +29,8 @@ async function createImage(exampleName) { .then(async () => { // Wait for 5 seconds on 3d model examples, since this takes longer to load. const waitTime = exampleName.includes('3d-model') ? 5000 : 1500; - await new Promise((resolve) => { - console.log(`waiting for ${waitTime} ms`); - setTimeout(resolve, waitTime); - }); + console.log(`waiting for ${waitTime} ms`); + await sleep(waitTime); }) // map.loaded() does not evaluate to true within 3 seconds, it's probably an animated example. // In this case we take the screenshot immediately. diff --git a/developer-guides/life-of-a-tile.md b/developer-guides/life-of-a-tile.md index 4ebe28631e..8e980c4d04 100644 --- a/developer-guides/life-of-a-tile.md +++ b/developer-guides/life-of-a-tile.md @@ -93,7 +93,7 @@ sequenceDiagram worker_tile->glyph manager: getImages glyph manager->>ajax: Fetch icon
images glyph manager-->>worker_tile: glyph/Image dependencies - worker_tile->>worker_tile: maybePrepare() + worker_tile->>worker_tile: wait for all requests to finish worker_tile->>worker_tile: create GlyphAtlas worker_tile->>worker_tile: create ImageAtlas worker_tile->>bucket: addFeatures @@ -169,19 +169,19 @@ sequenceDiagram map->>painter: render(style) painter->>source_cache: prepare(context) loop for each tile - source_cache->>GPU: upload vertices - source_cache->>GPU: upload image textures + source_cache->>GPU: upload vertices + source_cache->>GPU: upload image textures end loop for each layer - painter->>layer: renderLayer(pass=offscreen) - painter->>layer: renderLayer(pass=opaque) - painter->>layer: renderLayer(pass=translucent) - painter->>layer: renderLayer(pass=debug) - loop renderLayer() call for each tile - layer->>GPU: load program - layer->>GPU: drawElements() - GPU->>user: display pixels - end + painter->>layer: renderLayer(pass=offscreen) + painter->>layer: renderLayer(pass=opaque) + painter->>layer: renderLayer(pass=translucent) + painter->>layer: renderLayer(pass=debug) + loop renderLayer() call for each tile + layer->>GPU: load program + layer->>GPU: drawElements() + GPU->>user: display pixels + end end map->>map: triggerRepaint() ``` diff --git a/src/data/dem_data.ts b/src/data/dem_data.ts index b95be92b7a..90ae832c2a 100644 --- a/src/data/dem_data.ts +++ b/src/data/dem_data.ts @@ -3,20 +3,24 @@ import {RGBAImage} from '../util/image'; import {warnOnce} from '../util/util'; import {register} from '../util/web_worker_transfer'; -// DEMData is a data structure for decoding, backfilling, and storing elevation data for processing in the hillshade shaders -// data can be populated either from a pngraw image tile or from serliazed data sent back from a worker. When data is initially -// loaded from a image tile, we decode the pixel values using the appropriate decoding formula, but we store the -// elevation data as an Int32 value. we add 65536 (2^16) to eliminate negative values and enable the use of -// integer overflow when creating the texture used in the hillshadePrepare step. - -// DEMData also handles the backfilling of data from a tile's neighboring tiles. This is necessary because we use a pixel's 8 -// surrounding pixel values to compute the slope at that pixel, and we cannot accurately calculate the slope at pixels on a -// tile's edge without backfilling from neighboring tiles. - +/** + * The possible DEM encoding types + */ export type DEMEncoding = 'mapbox' | 'terrarium' | 'custom' +/** + * DEMData is a data structure for decoding, backfilling, and storing elevation data for processing in the hillshade shaders + * data can be populated either from a pngraw image tile or from serliazed data sent back from a worker. When data is initially + * loaded from a image tile, we decode the pixel values using the appropriate decoding formula, but we store the + * elevation data as an Int32 value. we add 65536 (2^16) to eliminate negative values and enable the use of + * integer overflow when creating the texture used in the hillshadePrepare step. + * + * DEMData also handles the backfilling of data from a tile's neighboring tiles. This is necessary because we use a pixel's 8 + * surrounding pixel values to compute the slope at that pixel, and we cannot accurately calculate the slope at pixels on a + * tile's edge without backfilling from neighboring tiles. + */ export class DEMData { - uid: string; + uid: string | number; data: Uint32Array; stride: number; dim: number; @@ -27,9 +31,18 @@ export class DEMData { blueFactor: number; baseShift: number; - // RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride + /** + * Constructs a `DEMData` object + * @param uid - the tile's unique id + * @param data - RGBAImage data has uniform 1px padding on all sides: square tile edge size defines stride // and dim is calculated as stride - 2. - constructor(uid: string, data: RGBAImage, encoding: DEMEncoding, redFactor = 1.0, greenFactor = 1.0, blueFactor = 1.0, baseShift = 0.0) { + * @param encoding - the encoding type of the data + * @param redFactor - the red channel factor used to unpack the data, used for `custom` encoding only + * @param greenFactor - the green channel factor used to unpack the data, used for `custom` encoding only + * @param blueFactor - the blue channel factor used to unpack the data, used for `custom` encoding only + * @param baseShift - the base shift used to unpack the data, used for `custom` encoding only + */ + constructor(uid: string | number, data: RGBAImage | ImageData, encoding: DEMEncoding, redFactor = 1.0, greenFactor = 1.0, blueFactor = 1.0, baseShift = 0.0) { this.uid = uid; if (data.height !== data.width) throw new RangeError('DEM tiles must be square'); if (encoding && !['mapbox', 'terrarium', 'custom'].includes(encoding)) { diff --git a/src/data/feature_index.ts b/src/data/feature_index.ts index 1d8ea9df41..4869eca60c 100644 --- a/src/data/feature_index.ts +++ b/src/data/feature_index.ts @@ -40,7 +40,6 @@ type QueryParameters = { }; /** - * @internal * An in memory index class to allow fast interaction with features */ export class FeatureIndex { diff --git a/src/index.test.ts b/src/index.test.ts index 628bbe2d86..16536806b4 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,6 +2,7 @@ import {config} from './util/config'; import maplibre from './index'; import {getJSON, getArrayBuffer} from './util/ajax'; import {ImageRequest} from './util/image_request'; +import {isAbortError} from './util/abort_error'; describe('maplibre', () => { beforeEach(() => { @@ -34,65 +35,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'}, (error, data) => { - expect(error).toBeFalsy(); - expect(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', () => { @@ -101,20 +92,27 @@ describe('maplibre', () => { return {cancel: () => { }}; }); - getJSON({url: 'custom://test/url/json'}, (error) => { + getJSON({url: 'custom://test/url/json'}, new AbortController()).catch((error) => { expect(error).toBeTruthy(); }); }); - test('#addProtocol - Cancel request', () => { + test('#addProtocol - Cancel request', async () => { let cancelCalled = false; maplibre.addProtocol('custom', () => { return {cancel: () => { cancelCalled = true; }}; }); - const request = getJSON({url: 'custom://test/url/json'}, () => { }); - request.cancel(); + const abortController = new AbortController(); + const promise = getJSON({url: 'custom://test/url/json'}, abortController); + abortController.abort(); + try { + await promise; + } catch (err) { + expect(isAbortError(err)).toBeTruthy(); + } + expect(cancelCalled).toBeTruthy(); }); diff --git a/src/render/glyph_atlas.ts b/src/render/glyph_atlas.ts index 0f80b336e8..6ce83f7e4c 100644 --- a/src/render/glyph_atlas.ts +++ b/src/render/glyph_atlas.ts @@ -2,7 +2,8 @@ import {AlphaImage} from '../util/image'; import {register} from '../util/web_worker_transfer'; import potpack from 'potpack'; -import type {GlyphMetrics, StyleGlyph} from '../style/style_glyph'; +import type {GlyphMetrics} from '../style/style_glyph'; +import type {GetGlyphsResponse} from '../util/actor_messages'; const padding = 1; @@ -37,11 +38,7 @@ export class GlyphAtlas { image: AlphaImage; positions: GlyphPositions; - constructor(stacks: { - [_: string]: { - [_: number]: StyleGlyph; - }; - }) { + constructor(stacks: GetGlyphsResponse) { const positions = {}; const bins = []; diff --git a/src/render/glyph_manager.test.ts b/src/render/glyph_manager.test.ts index 65c0ff72ff..60eb97f61e 100644 --- a/src/render/glyph_manager.test.ts +++ b/src/render/glyph_manager.test.ts @@ -3,144 +3,118 @@ import {GlyphManager} from './glyph_manager'; import fs from 'fs'; import {RequestManager} from '../util/request_manager'; -const glyphs = {}; -for (const glyph of parseGlyphPbf(fs.readFileSync('./test/unit/assets/0-255.pbf'))) { - glyphs[glyph.id] = glyph; -} - -const identityTransform = ((url) => ({url})) as any as RequestManager; - -const createLoadGlyphRangeStub = () => { - return jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => { - expect(stack).toBe('Arial Unicode MS'); - expect(range).toBe(0); - expect(urlTemplate).toBe('https://localhost/fonts/v1/{fontstack}/{range}.pbf'); - expect(transform).toBe(identityTransform); - setTimeout(() => callback(null, glyphs), 0); - }); -}; - -const createGlyphManager = (font?) => { - const manager = new GlyphManager(identityTransform, font); - manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf'); - return manager; -}; +describe('GlyphManager', () => { + const GLYPHS = {}; + for (const glyph of parseGlyphPbf(fs.readFileSync('./test/unit/assets/0-255.pbf'))) { + GLYPHS[glyph.id] = glyph; + } + + const identityTransform = ((url) => ({url})) as any as RequestManager; + + const createLoadGlyphRangeStub = () => { + return jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform) => { + expect(stack).toBe('Arial Unicode MS'); + expect(range).toBe(0); + expect(urlTemplate).toBe('https://localhost/fonts/v1/{fontstack}/{range}.pbf'); + expect(transform).toBe(identityTransform); + return Promise.resolve(GLYPHS); + }); + }; -afterEach(() => { - jest.clearAllMocks(); -}); + const createGlyphManager = (font?) => { + const manager = new GlyphManager(identityTransform, font); + manager.setURL('https://localhost/fonts/v1/{fontstack}/{range}.pbf'); + return manager; + }; -describe('GlyphManager', () => { + afterEach(() => { + jest.clearAllMocks(); + }); - test('GlyphManager requests 0-255 PBF', done => { + test('GlyphManager requests 0-255 PBF', async () => { createLoadGlyphRangeStub(); const manager = createGlyphManager(); - manager.getGlyphs({'Arial Unicode MS': [55]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS']['55'].metrics.advance).toBe(12); - done(); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [55]}); + expect(returnedGlyphs['Arial Unicode MS']['55'].metrics.advance).toBe(12); }); - test('GlyphManager doesn\'t request twice 0-255 PBF if a glyph is missing', done => { + test('GlyphManager doesn\'t request twice 0-255 PBF if a glyph is missing', async () => { const stub = createLoadGlyphRangeStub(); const manager = createGlyphManager(); - manager.getGlyphs({'Arial Unicode MS': [0.5]}, (err) => { - expect(err).toBeFalsy(); - expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true); - expect(stub).toHaveBeenCalledTimes(1); + await manager.getGlyphs({'Arial Unicode MS': [0.5]}); + expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true); + expect(stub).toHaveBeenCalledTimes(1); - // We remove all requests as in getGlyphs code. - delete manager.entries['Arial Unicode MS'].requests[0]; + // We remove all requests as in getGlyphs code. + delete manager.entries['Arial Unicode MS'].requests[0]; - manager.getGlyphs({'Arial Unicode MS': [0.5]}, (err) => { - expect(err).toBeFalsy(); - expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true); - expect(stub).toHaveBeenCalledTimes(1); - done(); - }); - }); + await manager.getGlyphs({'Arial Unicode MS': [0.5]}); + expect(manager.entries['Arial Unicode MS'].ranges[0]).toBe(true); + expect(stub).toHaveBeenCalledTimes(1); }); - test('GlyphManager requests remote CJK PBF', done => { - jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => { - setTimeout(() => callback(null, glyphs), 0); + test('GlyphManager requests remote CJK PBF', async () => { + jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((_stack, _range, _urlTemplate, _transform) => { + return Promise.resolve(GLYPHS); }); const manager = createGlyphManager(); - manager.getGlyphs({'Arial Unicode MS': [0x5e73]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x5e73]).toBeNull(); // The fixture returns a PBF without the glyph we requested - done(); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x5e73]}); + expect(returnedGlyphs['Arial Unicode MS'][0x5e73]).toBeNull(); // The fixture returns a PBF without the glyph we requested }); - test('GlyphManager does not cache CJK chars that should be rendered locally', done => { - jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((stack, range, urlTemplate, transform, callback) => { + test('GlyphManager does not cache CJK chars that should be rendered locally', async () => { + jest.spyOn(GlyphManager, 'loadGlyphRange').mockImplementation((_stack, range, _urlTemplate, _transform) => { const overlappingGlyphs = {}; const start = range * 256; const end = start + 256; for (let i = start, j = 0; i < end; i++, j++) { - overlappingGlyphs[i] = glyphs[j]; + overlappingGlyphs[i] = GLYPHS[j]; } - setTimeout(() => callback(null, overlappingGlyphs), 0); + return Promise.resolve(overlappingGlyphs); }); const manager = createGlyphManager('sans-serif'); //Request char that overlaps Katakana range - manager.getGlyphs({'Arial Unicode MS': [0x3005]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x3005]).not.toBeNull(); - //Request char from Katakana range (te テ) - manager.getGlyphs({'Arial Unicode MS': [0x30C6]}, (err, glyphs) => { - expect(err).toBeFalsy(); - const glyph = glyphs['Arial Unicode MS'][0x30c6]; - //Ensure that te is locally generated. - expect(glyph.bitmap.height).toBe(12); - expect(glyph.bitmap.width).toBe(12); - done(); - }); - }); + let returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x3005]}); + expect(returnedGlyphs['Arial Unicode MS'][0x3005]).not.toBeNull(); + //Request char from Katakana range (te テ) + returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30C6]}); + const glyph = returnedGlyphs['Arial Unicode MS'][0x30c6]; + //Ensure that te is locally generated. + expect(glyph.bitmap.height).toBe(12); + expect(glyph.bitmap.width).toBe(12); }); - test('GlyphManager generates CJK PBF locally', done => { + test('GlyphManager generates CJK PBF locally', async () => { const manager = createGlyphManager('sans-serif'); // character 平 - manager.getGlyphs({'Arial Unicode MS': [0x5e73]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(0.5); - done(); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x5e73]}); + expect(returnedGlyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(0.5); }); - test('GlyphManager generates Katakana PBF locally', done => { + test('GlyphManager generates Katakana PBF locally', async () => { const manager = createGlyphManager('sans-serif'); // Katakana letter te テ - manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(0.5); - done(); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30c6]}); + expect(returnedGlyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(0.5); }); - test('GlyphManager generates Hiragana PBF locally', done => { + test('GlyphManager generates Hiragana PBF locally', async () => { const manager = createGlyphManager('sans-serif'); //Hiragana letter te て - manager.getGlyphs({'Arial Unicode MS': [0x3066]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(0.5); - done(); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x3066]}); + expect(returnedGlyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(0.5); }); - test('GlyphManager caches locally generated glyphs', done => { + test('GlyphManager caches locally generated glyphs', async () => { const manager = createGlyphManager('sans-serif'); const drawSpy = GlyphManager.TinySDF.prototype.draw = jest.fn().mockImplementation(() => { @@ -148,13 +122,9 @@ describe('GlyphManager', () => { }); // Katakana letter te - manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, (err, glyphs) => { - expect(err).toBeFalsy(); - expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(24); - manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, () => { - expect(drawSpy).toHaveBeenCalledTimes(1); - done(); - }); - }); + const returnedGlyphs = await manager.getGlyphs({'Arial Unicode MS': [0x30c6]}); + expect(returnedGlyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(24); + await manager.getGlyphs({'Arial Unicode MS': [0x30c6]}); + expect(drawSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/render/glyph_manager.ts b/src/render/glyph_manager.ts index acffee4d2d..e4ffda9406 100644 --- a/src/render/glyph_manager.ts +++ b/src/render/glyph_manager.ts @@ -2,12 +2,11 @@ import {loadGlyphRange} from '../style/load_glyph_range'; import TinySDF from '@mapbox/tiny-sdf'; import {unicodeBlockLookup} from '../util/is_char_in_unicode_block'; -import {asyncAll} from '../util/util'; import {AlphaImage} from '../util/image'; import type {StyleGlyph} from '../style/style_glyph'; import type {RequestManager} from '../util/request_manager'; -import type {Callback} from '../types/callback'; +import type {GetGlyphsResponse} from '../util/actor_messages'; type Entry = { // null means we've requested the range, but the glyph wasn't included in the result. @@ -15,9 +14,7 @@ type Entry = { [id: number]: StyleGlyph | null; }; requests: { - [range: number]: Array>; + [range: number]: Promise<{[_: number]: StyleGlyph | null}>; }; ranges: { [range: number]: boolean | null; @@ -28,9 +25,7 @@ type Entry = { export class GlyphManager { requestManager: RequestManager; localIdeographFontFamily: string; - entries: { - [_: string]: Entry; - }; + entries: {[stack: string]: Entry}; url: string; // exposed as statics to enable stubbing in unit tests @@ -47,117 +42,81 @@ export class GlyphManager { this.url = url; } - getGlyphs(glyphs: { - [stack: string]: Array; - }, callback: Callback<{ - [stack: string]: { - [id: number]: StyleGlyph; - }; - }>) { - const all = []; + async getGlyphs(glyphs: {[stack: string]: Array}): Promise { + const glyphsPromises: Promise<{stack: string; id: number; glyph: StyleGlyph}>[] = []; for (const stack in glyphs) { for (const id of glyphs[stack]) { - all.push({stack, id}); + glyphsPromises.push(this._getAndCacheGlyphsPromise(stack, id)); } } - asyncAll(all, ({stack, id}, callback: Callback<{ - stack: string; - id: number; - glyph: StyleGlyph; - }>) => { - let entry = this.entries[stack]; - if (!entry) { - entry = this.entries[stack] = { - glyphs: {}, - requests: {}, - ranges: {} - }; - } + const updatedGlyphs = await Promise.all(glyphsPromises); - let glyph = entry.glyphs[id]; - if (glyph !== undefined) { - callback(null, {stack, id, glyph}); - return; - } + const result: GetGlyphsResponse = {}; - glyph = this._tinySDF(entry, stack, id); - if (glyph) { - entry.glyphs[id] = glyph; - callback(null, {stack, id, glyph}); - return; + for (const {stack, id, glyph} of updatedGlyphs) { + if (!result[stack]) { + result[stack] = {}; } + // Clone the glyph so that our own copy of its ArrayBuffer doesn't get transferred. + result[stack][id] = glyph && { + id: glyph.id, + bitmap: glyph.bitmap.clone(), + metrics: glyph.metrics + }; + } - const range = Math.floor(id / 256); - if (range * 256 > 65535) { - callback(new Error('glyphs > 65535 not supported')); - return; - } + return result; + } - if (entry.ranges[range]) { - callback(null, {stack, id, glyph}); - return; - } + async _getAndCacheGlyphsPromise(stack: string, id: number): Promise<{stack: string; id: number; glyph: StyleGlyph}> { + let entry = this.entries[stack]; + if (!entry) { + entry = this.entries[stack] = { + glyphs: {}, + requests: {}, + ranges: {} + }; + } - if (!this.url) { - callback(new Error('glyphsUrl is not set')); - return; - } + let glyph = entry.glyphs[id]; + if (glyph !== undefined) { + return {stack, id, glyph}; + } - let requests = entry.requests[range]; - if (!requests) { - requests = entry.requests[range] = []; - GlyphManager.loadGlyphRange(stack, range, this.url, this.requestManager, - (err, response?: { - [_: number]: StyleGlyph | null; - } | null) => { - if (response) { - for (const id in response) { - if (!this._doesCharSupportLocalGlyph(+id)) { - entry.glyphs[+id] = response[+id]; - } - } - entry.ranges[range] = true; - } - for (const cb of requests) { - cb(err, response); - } - delete entry.requests[range]; - }); - } + glyph = this._tinySDF(entry, stack, id); + if (glyph) { + entry.glyphs[id] = glyph; + return {stack, id, glyph}; + } - requests.push((err, result?: { - [_: number]: StyleGlyph | null; - } | null) => { - if (err) { - callback(err); - } else if (result) { - callback(null, {stack, id, glyph: result[id] || null}); - } - }); - }, (err, glyphs?: Array<{ - stack: string; - id: number; - glyph: StyleGlyph; - }> | null) => { - if (err) { - callback(err); - } else if (glyphs) { - const result = {}; - - for (const {stack, id, glyph} of glyphs) { - // Clone the glyph so that our own copy of its ArrayBuffer doesn't get transferred. - (result[stack] || (result[stack] = {}))[id] = glyph && { - id: glyph.id, - bitmap: glyph.bitmap.clone(), - metrics: glyph.metrics - }; - } - - callback(null, result); + const range = Math.floor(id / 256); + if (range * 256 > 65535) { + throw new Error('glyphs > 65535 not supported'); + } + + if (entry.ranges[range]) { + return {stack, id, glyph}; + } + + if (!this.url) { + throw new Error('glyphsUrl is not set'); + } + + if (!entry.requests[range]) { + const promise = GlyphManager.loadGlyphRange(stack, range, this.url, this.requestManager); + entry.requests[range] = promise; + } + + const response = await entry.requests[range]; + for (const id in response) { + if (!this._doesCharSupportLocalGlyph(+id)) { + entry.glyphs[+id] = response[+id]; } - }); + } + entry.ranges[range] = true; + return {stack, id, glyph: response[id] || null}; } _doesCharSupportLocalGlyph(id: number): boolean { diff --git a/src/render/image_atlas.ts b/src/render/image_atlas.ts index a86d2e69ff..492dab944d 100644 --- a/src/render/image_atlas.ts +++ b/src/render/image_atlas.ts @@ -7,6 +7,7 @@ import type {StyleImage} from '../style/style_image'; import type {ImageManager} from './image_manager'; import type {Texture} from './texture'; import type {Rect} from './glyph_atlas'; +import type {GetImagesResponse} from '../util/actor_messages'; const IMAGE_PADDING: number = 1; export {IMAGE_PADDING}; @@ -61,7 +62,6 @@ export class ImagePosition { } /** - * @internal * A class holding all the images */ export class ImageAtlas { @@ -71,7 +71,7 @@ export class ImageAtlas { haveRenderCallbacks: Array; uploaded: boolean; - constructor(icons: {[_: string]: StyleImage}, patterns: {[_: string]: StyleImage}) { + constructor(icons: GetImagesResponse, patterns: GetImagesResponse) { const iconPositions = {}, patternPositions = {}; this.haveRenderCallbacks = []; diff --git a/src/render/image_manager.ts b/src/render/image_manager.ts index 33a0d1aaf1..0e50eef309 100644 --- a/src/render/image_manager.ts +++ b/src/render/image_manager.ts @@ -11,38 +11,44 @@ import {warnOnce} from '../util/util'; import type {StyleImage} from '../style/style_image'; import type {Context} from '../gl/context'; import type {PotpackBox} from 'potpack'; -import type {Callback} from '../types/callback'; +import type {GetImagesResponse} from '../util/actor_messages'; type Pattern = { bin: PotpackBox; position: ImagePosition; }; -// When copied into the atlas texture, image data is padded by one pixel on each side. Icon -// images are padded with fully transparent pixels, while pattern images are padded with a -// copy of the image data wrapped from the opposite side. In both cases, this ensures the -// correct behavior of GL_LINEAR texture sampling mode. +/** + * When copied into the atlas texture, image data is padded by one pixel on each side. Icon + * images are padded with fully transparent pixels, while pattern images are padded with a + * copy of the image data wrapped from the opposite side. In both cases, this ensures the + * correct behavior of GL_LINEAR texture sampling mode. + */ const padding = 1; -/* - ImageManager does three things: - - 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled. - 2. Builds a texture atlas for pattern images. - 3. Rerenders renderable images once per frame - - These are disparate responsibilities and should eventually be handled by different classes. When we implement - data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time - to refactor this. +/** + * ImageManager does three things: + * + * 1. Tracks requests for icon images from tile workers and sends responses when the requests are fulfilled. + * 2. Builds a texture atlas for pattern images. + * 3. Rerenders renderable images once per frame + * + * These are disparate responsibilities and should eventually be handled by different classes. When we implement + * data-driven support for `*-pattern`, we'll likely use per-bucket pattern atlases, and that would be a good time + * to refactor this. */ export class ImageManager extends Evented { images: {[_: string]: StyleImage}; updatedImages: {[_: string]: boolean}; callbackDispatchedThisFrame: {[_: string]: boolean}; loaded: boolean; + /** + * This is used to track requests for images that are not yet available. When the image is loaded, + * the requestors will be notified. + */ requestors: Array<{ ids: Array; - callback: Callback<{[_: string]: StyleImage}>; + promiseResolve: (value: GetImagesResponse) => void; }>; patterns: {[_: string]: Pattern}; @@ -75,8 +81,8 @@ export class ImageManager extends Evented { this.loaded = loaded; if (loaded) { - for (const {ids, callback} of this.requestors) { - this._notify(ids, callback); + for (const {ids, promiseResolve} of this.requestors) { + promiseResolve(this._getImagesForIds(ids)); } this.requestors = []; } @@ -176,28 +182,30 @@ export class ImageManager extends Evented { return Object.keys(this.images); } - getImages(ids: Array, callback: Callback<{[_: string]: StyleImage}>) { - // If the sprite has been loaded, or if all the icon dependencies are already present - // (i.e. if they've been added via runtime styling), then notify the requestor immediately. - // Otherwise, delay notification until the sprite is loaded. At that point, if any of the - // dependencies are still unavailable, we'll just assume they are permanently missing. - let hasAllDependencies = true; - if (!this.isLoaded()) { - for (const id of ids) { - if (!this.images[id]) { - hasAllDependencies = false; + getImages(ids: Array): Promise { + return new Promise((resolve, _reject) => { + // If the sprite has been loaded, or if all the icon dependencies are already present + // (i.e. if they've been added via runtime styling), then notify the requestor immediately. + // Otherwise, delay notification until the sprite is loaded. At that point, if any of the + // dependencies are still unavailable, we'll just assume they are permanently missing. + let hasAllDependencies = true; + if (!this.isLoaded()) { + for (const id of ids) { + if (!this.images[id]) { + hasAllDependencies = false; + } } } - } - if (this.isLoaded() || hasAllDependencies) { - this._notify(ids, callback); - } else { - this.requestors.push({ids, callback}); - } + if (this.isLoaded() || hasAllDependencies) { + resolve(this._getImagesForIds(ids)); + } else { + this.requestors.push({ids, promiseResolve: resolve}); + } + }); } - _notify(ids: Array, callback: Callback<{[_: string]: StyleImage}>) { - const response = {}; + _getImagesForIds(ids: Array): GetImagesResponse { + const response: GetImagesResponse = {}; for (const id of ids) { let image = this.getImage(id); @@ -224,8 +232,7 @@ export class ImageManager extends Evented { warnOnce(`Image "${id}" could not be loaded. Please make sure you have added the image with map.addImage() or a "sprite" property in your style. You can provide missing images by listening for the "styleimagemissing" map event.`); } } - - callback(null, response); + return response; } // Pattern stuff diff --git a/src/source/canvas_source.ts b/src/source/canvas_source.ts index 72994df239..911c87416c 100644 --- a/src/source/canvas_source.ts +++ b/src/source/canvas_source.ts @@ -107,7 +107,7 @@ export class CanvasSource extends ImageSource { this.animate = options.animate !== undefined ? options.animate : true; } - load = () => { + async load() { this._loaded = true; if (!this.canvas) { this.canvas = (this.options.canvas instanceof HTMLCanvasElement) ? @@ -137,7 +137,7 @@ export class CanvasSource extends ImageSource { }; this._finishLoading(); - }; + } /** * Returns the HTML `canvas` element. @@ -160,7 +160,7 @@ export class CanvasSource extends ImageSource { this.pause(); } - prepare = () => { + prepare() { let resize = false; if (this.canvas.width !== this.width) { this.width = this.canvas.width; @@ -205,14 +205,14 @@ export class CanvasSource extends ImageSource { if (newTilesLoaded) { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id})); } - }; + } - serialize = (): CanvasSourceSpecification => { + serialize(): CanvasSourceSpecification { return { type: 'canvas', coordinates: this.coordinates }; - }; + } hasTransition() { return this._playing; diff --git a/src/source/geojson_source.test.ts b/src/source/geojson_source.test.ts index a5eeebbf9b..19e2718951 100644 --- a/src/source/geojson_source.test.ts +++ b/src/source/geojson_source.test.ts @@ -16,7 +16,7 @@ const wrapDispatcher = (dispatcher) => { }; const mockDispatcher = wrapDispatcher({ - send() {} + sendAsync() { return Promise.resolve({}); } }); const hawkHill = { @@ -58,10 +58,10 @@ describe('GeoJSONSource#setData', () => { opts = opts || {}; opts = extend(opts, {data: {}}); return new GeoJSONSource('id', opts, wrapDispatcher({ - send (type, data, callback) { - if (callback) { - return setTimeout(callback, 0); - } + sendAsync(_message) { + return new Promise((resolve) => { + setTimeout(() => resolve({}), 0); + }); } }), undefined); } @@ -91,8 +91,10 @@ describe('GeoJSONSource#setData', () => { test('fires "dataabort" event', done => { const source = new GeoJSONSource('id', {} as any, wrapDispatcher({ - send(type, data, callback) { - setTimeout(() => callback(null, {abandoned: true})); + sendAsync(_message) { + return new Promise((resolve) => { + setTimeout(() => resolve({abandoned: true}), 0); + }); } }), undefined); source.on('dataabort', () => { @@ -108,13 +110,15 @@ describe('GeoJSONSource#setData', () => { transformRequest: (url) => { return {url}; } } as any as RequestManager } as any; - source.actor.send = function(type, params: any, cb) { - if (type === 'geojson.loadData') { - expect(params.request.collectResourceTiming).toBeTruthy(); - setTimeout(cb, 0); - done(); - } - } as any; + source.actor.sendAsync = (message) => { + return new Promise((resolve) => { + if (message.type === 'loadData') { + expect((message.data as any).request.collectResourceTiming).toBeTruthy(); + setTimeout(() => resolve({} as any), 0); + done(); + } + }); + }; source.setData('http://localhost/nonexistent'); }); @@ -148,8 +152,10 @@ describe('GeoJSONSource#setData', () => { test('marks source as loaded before firing "dataabort" event', done => { const source = new GeoJSONSource('id', {} as any, wrapDispatcher({ - send(type, data, callback) { - setTimeout(() => callback(null, {abandoned: true})); + sendAsync(_message) { + return new Promise((resolve) => { + setTimeout(() => resolve({abandoned: true}), 0); + }); } }), undefined); source.on('dataabort', () => { @@ -163,11 +169,11 @@ describe('GeoJSONSource#setData', () => { describe('GeoJSONSource#onRemove', () => { test('broadcasts "removeSource" event', done => { const source = new GeoJSONSource('id', {data: {}} as GeoJSONSourceOptions, wrapDispatcher({ - send(type, data, callback) { - expect(callback).toBeFalsy(); - expect(type).toBe('removeSource'); - expect(data).toEqual({type: 'geojson', source: 'id'}); + sendAsync(message) { + expect(message.type).toBe('removeSource'); + expect(message.data).toEqual({type: 'geojson', source: 'id'}); done(); + return Promise.resolve({}); }, broadcast() { // Ignore @@ -187,9 +193,10 @@ describe('GeoJSONSource#update', () => { test('sends initial loadData request to dispatcher', done => { const mockDispatcher = wrapDispatcher({ - send(message) { - expect(message).toBe('geojson.loadData'); + sendAsync(message) { + expect(message.type).toBe('loadData'); done(); + return Promise.resolve({}); } }); @@ -198,9 +205,9 @@ describe('GeoJSONSource#update', () => { test('forwards geojson-vt options with worker request', done => { const mockDispatcher = wrapDispatcher({ - send(message, params) { - expect(message).toBe('geojson.loadData'); - expect(params.geojsonVtOptions).toEqual({ + sendAsync(message) { + expect(message.type).toBe('loadData'); + expect(message.data.geojsonVtOptions).toEqual({ extent: 8192, maxZoom: 10, tolerance: 4, @@ -209,6 +216,7 @@ describe('GeoJSONSource#update', () => { generateId: true }); done(); + return Promise.resolve({}); } }); @@ -223,9 +231,9 @@ describe('GeoJSONSource#update', () => { test('forwards Supercluster options with worker request', done => { const mockDispatcher = wrapDispatcher({ - send(message, params) { - expect(message).toBe('geojson.loadData'); - expect(params.superclusterOptions).toEqual({ + sendAsync(message) { + expect(message.type).toBe('loadData'); + expect(message.data.superclusterOptions).toEqual({ maxZoom: 12, minPoints: 3, extent: 8192, @@ -234,6 +242,7 @@ describe('GeoJSONSource#update', () => { generateId: true }); done(); + return Promise.resolve({}); } }); @@ -250,12 +259,13 @@ describe('GeoJSONSource#update', () => { test('modifying cluster properties after adding a source', done => { // test setCluster function on GeoJSONSource const mockDispatcher = wrapDispatcher({ - send(message, params) { - expect(message).toBe('geojson.loadData'); - expect(params.cluster).toBe(true); - expect(params.superclusterOptions.radius).toBe(80); - expect(params.superclusterOptions.maxZoom).toBe(16); + sendAsync(message) { + expect(message.type).toBe('loadData'); + expect(message.data.cluster).toBe(true); + expect(message.data.superclusterOptions.radius).toBe(80); + expect(message.data.superclusterOptions.maxZoom).toBe(16); done(); + return Promise.resolve({}); } }); new GeoJSONSource('id', { @@ -270,9 +280,9 @@ describe('GeoJSONSource#update', () => { test('forwards Supercluster options with worker request, ignore max zoom of source', done => { const mockDispatcher = wrapDispatcher({ - send(message, params) { - expect(message).toBe('geojson.loadData'); - expect(params.superclusterOptions).toEqual({ + sendAsync(message) { + expect(message.type).toBe('loadData'); + expect(message.data.superclusterOptions).toEqual({ maxZoom: 12, minPoints: 3, extent: 8192, @@ -281,6 +291,7 @@ describe('GeoJSONSource#update', () => { generateId: true }); done(); + return Promise.resolve({}); } }); @@ -309,10 +320,10 @@ describe('GeoJSONSource#update', () => { }); test('fires event when metadata loads', done => { const mockDispatcher = wrapDispatcher({ - send(message, args, callback) { - if (callback) { - setTimeout(callback, 0); - } + sendAsync(_message) { + return new Promise((resolve) => { + setTimeout(() => resolve({}), 0); + }); } }); @@ -328,8 +339,10 @@ describe('GeoJSONSource#update', () => { test('fires metadata data event even when initial request is aborted', done => { let requestCount = 0; const mockDispatcher = wrapDispatcher({ - send(message, args, callback) { - setTimeout(() => callback(null, {abandoned: requestCount++ === 0})); + sendAsync(_message) { + return new Promise((resolve) => { + setTimeout(() => resolve({abandoned: requestCount++ === 0})); + }); } }); @@ -345,10 +358,8 @@ describe('GeoJSONSource#update', () => { test('fires "error"', done => { const mockDispatcher = wrapDispatcher({ - send(message, args, callback) { - if (callback) { - setTimeout(callback.bind(null, 'error'), 0); - } + sendAsync(_message) { + return Promise.reject('error'); // eslint-disable-line prefer-promise-reject-errors } }); @@ -365,13 +376,11 @@ describe('GeoJSONSource#update', () => { test('sends loadData request to dispatcher after data update', done => { let expectedLoadDataCalls = 2; const mockDispatcher = wrapDispatcher({ - send(message, args, callback) { - if (message === 'geojson.loadData' && --expectedLoadDataCalls <= 0) { + sendAsync(message) { + if (message.type === 'loadData' && --expectedLoadDataCalls <= 0) { done(); } - if (callback) { - setTimeout(callback, 0); - } + return new Promise((resolve) => setTimeout(() => resolve({}), 0)); } }); @@ -384,7 +393,7 @@ describe('GeoJSONSource#update', () => { source.on('data', (e) => { if (e.sourceDataType === 'metadata') { source.setData({} as GeoJSON.GeoJSON); - source.loadTile(new Tile(new OverscaledTileID(0, 0, 0, 0, 0), 512), () => {}); + source.loadTile(new Tile(new OverscaledTileID(0, 0, 0, 0, 0), 512)); } }); diff --git a/src/source/geojson_source.ts b/src/source/geojson_source.ts index ada698d39d..4799e1891d 100644 --- a/src/source/geojson_source.ts +++ b/src/source/geojson_source.ts @@ -13,10 +13,10 @@ import type {Actor} from '../util/actor'; import type {Callback} from '../types/callback'; import type {GeoJSONSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {GeoJSONSourceDiff} from './geojson_source_diff'; -import type {Options, ClusterProperties} from 'supercluster'; +import type {GeoJSONWorkerOptions, LoadGeoJSONParameters} from './geojson_worker_source'; export type GeoJSONSourceOptions = GeoJSONSourceSpecification & { - workerOptions?: WorkerOptions; + workerOptions?: GeoJSONWorkerOptions; collectResourceTiming?: boolean; } @@ -28,23 +28,6 @@ export type GeoJsonSourceOptions = { clusterMinPoints?: number; generateId?: boolean; } -export type WorkerOptions = { - source?: string; - cluster?: boolean; - geojsonVtOptions?: { - buffer?: number; - tolerance?: number; - extent?: number; - maxZoom?: number; - linemetrics?: boolean; - generateId?: boolean; - }; - superclusterOptions?: Options; - clusterProperties?: ClusterProperties; - fliter?: any; - promoteId?: any; - collectResourceTiming?: boolean; -} /** * The cluster options to set @@ -131,7 +114,7 @@ export class GeoJSONSource extends Evented implements Source { reparseOverscaled: boolean; _data: GeoJSON.GeoJSON | string | undefined; _options: GeoJsonSourceOptions; - workerOptions: WorkerOptions; + workerOptions: GeoJSONWorkerOptions; map: Map; actor: Actor; _pendingLoads: number; @@ -204,9 +187,9 @@ export class GeoJSONSource extends Evented implements Source { } } - load = () => { - this._updateWorkerData(); - }; + async load() { + await this._updateWorkerData(); + } onAdd(map: Map) { this.map = map; @@ -275,7 +258,9 @@ export class GeoJSONSource extends Evented implements Source { * @returns `this` */ getClusterExpansionZoom(clusterId: number, callback: Callback): this { - this.actor.send('geojson.getClusterExpansionZoom', {clusterId, source: this.id}, callback); + this.actor.sendAsync({type: 'getClusterExpansionZoom', data: {type: this.type, clusterId, source: this.id}}) + .then((v) => callback(null, v)) + .catch((e) => callback(e)); return this; } @@ -287,7 +272,9 @@ export class GeoJSONSource extends Evented implements Source { * @returns `this` */ getClusterChildren(clusterId: number, callback: Callback>): this { - this.actor.send('geojson.getClusterChildren', {clusterId, source: this.id}, callback); + this.actor.sendAsync({type: 'getClusterChildren', data: {type: this.type, clusterId, source: this.id}}) + .then((v) => callback(null, v)) + .catch((e) => callback(e)); return this; } @@ -319,12 +306,14 @@ export class GeoJSONSource extends Evented implements Source { * ``` */ getClusterLeaves(clusterId: number, limit: number, offset: number, callback: Callback>): this { - this.actor.send('geojson.getClusterLeaves', { + this.actor.sendAsync({type: 'getClusterLeaves', data: { + type: this.type, source: this.id, clusterId, limit, offset - }, callback); + }}).then((l) => callback(null, l)) + .catch((e) => callback(e)); return this; } @@ -334,8 +323,8 @@ export class GeoJSONSource extends Evented implements Source { * using geojson-vt or supercluster as appropriate. * @param diff - the diff object */ - _updateWorkerData(diff?: GeoJSONSourceDiff) { - const options = extend({}, this.workerOptions); + async _updateWorkerData(diff?: GeoJSONSourceDiff) { + const options: LoadGeoJSONParameters = extend({}, this.workerOptions); if (diff) { options.dataDiff = diff; } else if (typeof this._data === 'string') { @@ -344,46 +333,46 @@ export class GeoJSONSource extends Evented implements Source { } else { options.data = JSON.stringify(this._data); } - + options.type = this.type; this._pendingLoads++; this.fire(new Event('dataloading', {dataType: 'source'})); - - // target {this.type}.loadData rather than literally geojson.loadData, - // so that other geojson-like source types can easily reuse this - // implementation - this.actor.send(`${this.type}.loadData`, options, (err, result) => { + try { + const result = await this.actor.sendAsync({type: 'loadData', data: options}); this._pendingLoads--; - - if (this._removed || (result && result.abandoned)) { + if (this._removed || result.abandoned) { this.fire(new Event('dataabort', {dataType: 'source'})); return; } - let resourceTiming = null; - if (result && result.resourceTiming && result.resourceTiming[this.id]) + let resourceTiming: PerformanceResourceTiming[] = null; + if (result.resourceTiming && result.resourceTiming[this.id]) { resourceTiming = result.resourceTiming[this.id].slice(0); - - if (err) { - this.fire(new ErrorEvent(err)); - return; } const data: any = {dataType: 'source'}; - if (this._collectResourceTiming && resourceTiming && resourceTiming.length > 0) + if (this._collectResourceTiming && resourceTiming && resourceTiming.length > 0) { extend(data, {resourceTiming}); + } // although GeoJSON sources contain no metadata, we fire this event to let the SourceCache // know its ok to start requesting tiles. this.fire(new Event('data', {...data, sourceDataType: 'metadata'})); this.fire(new Event('data', {...data, sourceDataType: 'content'})); - }); + } catch (err) { + this._pendingLoads--; + if (this._removed) { + this.fire(new Event('dataabort', {dataType: 'source'})); + return; + } + this.fire(new ErrorEvent(err)); + } } loaded(): boolean { return this._pendingLoads === 0; } - loadTile(tile: Tile, callback: Callback) { + async loadTile(tile: Tile): Promise { const message = !tile.actor ? 'loadTile' : 'reloadTile'; tile.actor = this.actor; const params = { @@ -399,48 +388,40 @@ export class GeoJSONSource extends Evented implements Source { promoteId: this.promoteId }; - tile.request = this.actor.send(message, params, (err, data) => { - delete tile.request; - tile.unloadVectorData(); - - if (tile.aborted) { - return callback(null); - } - - if (err) { - return callback(err); - } + tile.abortController = new AbortController(); + const data = await this.actor.sendAsync({type: message, data: params}, tile.abortController); + delete tile.abortController; + tile.unloadVectorData(); + if (!tile.aborted) { tile.loadVectorData(data, this.map.painter, message === 'reloadTile'); - - return callback(null); - }); + } } - abortTile(tile: Tile) { - if (tile.request) { - tile.request.cancel(); - delete tile.request; + async abortTile(tile: Tile) { + if (tile.abortController) { + tile.abortController.abort(); + delete tile.abortController; } tile.aborted = true; } - unloadTile(tile: Tile) { + async unloadTile(tile: Tile) { tile.unloadVectorData(); - this.actor.send('removeTile', {uid: tile.uid, type: this.type, source: this.id}); + await this.actor.sendAsync({type: 'removeTile', data: {uid: tile.uid, type: this.type, source: this.id}}); } onRemove() { this._removed = true; - this.actor.send('removeSource', {type: this.type, source: this.id}); + this.actor.sendAsync({type: 'removeSource', data: {type: this.type, source: this.id}}); } - serialize = (): GeoJSONSourceSpecification => { + serialize(): GeoJSONSourceSpecification { return extend({}, this._options, { type: this.type, data: this._data }); - }; + } hasTransition() { return false; diff --git a/src/source/geojson_worker_source.test.ts b/src/source/geojson_worker_source.test.ts index c4e1643973..c540aaa4de 100644 --- a/src/source/geojson_worker_source.test.ts +++ b/src/source/geojson_worker_source.test.ts @@ -5,7 +5,7 @@ import perf from '../util/performance'; import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import {Actor} from '../util/actor'; import {WorkerTileParameters} from './worker_source'; -import {setPerformance} from '../util/test/util'; +import {setPerformance, sleep} from '../util/test/util'; import {type FakeServer, fakeServer} from 'nise'; const actor = {send: () => {}} as any as Actor; @@ -15,7 +15,7 @@ beforeEach(() => { }); describe('reloadTile', () => { - test('does not rebuild vector data unless data has changed', done => { + test('does not rebuild vector data unless data has changed', async () => { const layers = [ { id: 'mylayer', @@ -25,12 +25,7 @@ describe('reloadTile', () => { ] as LayerSpecification[]; const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); - const originalLoadVectorData = source.loadVectorData; - let loadVectorCallCount = 0; - source.loadVectorData = function(params, callback) { - loadVectorCallCount++; - return originalLoadVectorData.call(this, params, callback); - }; + const spy = jest.spyOn(source, 'loadVectorTile'); const geoJson = { 'type': 'Feature', 'geometry': { @@ -45,49 +40,29 @@ describe('reloadTile', () => { maxZoom: 10 }; - function addData(callback) { - source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err) => { - expect(err).toBeNull(); - callback(); - }); - } + await source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters); - function reloadTile(callback) { - source.reloadTile(tileParams as any as WorkerTileParameters, (err, data) => { - expect(err).toBeNull(); - return callback(data); - }); - } + // first call should load vector data from geojson + const firstData = await source.reloadTile(tileParams as any as WorkerTileParameters); + expect(spy).toHaveBeenCalledTimes(1); - addData(() => { - // first call should load vector data from geojson - let firstData; - reloadTile(data => { - firstData = data; - }); - expect(loadVectorCallCount).toBe(1); + // second call won't give us new rawTileData + let data = await source.reloadTile(tileParams as any as WorkerTileParameters); + expect('rawTileData' in data).toBeFalsy(); + data.rawTileData = firstData.rawTileData; + expect(data).toEqual(firstData); - // second call won't give us new rawTileData - reloadTile(data => { - expect('rawTileData' in data).toBeFalsy(); - data.rawTileData = firstData.rawTileData; - expect(data).toEqual(firstData); - }); + // also shouldn't call loadVectorData again + expect(spy).toHaveBeenCalledTimes(1); - // also shouldn't call loadVectorData again - expect(loadVectorCallCount).toBe(1); - - // replace geojson data - addData(() => { - // should call loadVectorData again after changing geojson data - reloadTile(data => { - expect('rawTileData' in data).toBeTruthy(); - expect(data).toEqual(firstData); - }); - expect(loadVectorCallCount).toBe(2); - done(); - }); - }); + // replace geojson data + await source.loadData({source: 'sourceId', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters); + + // should call loadVectorData again after changing geojson data + data = await source.reloadTile(tileParams as any as WorkerTileParameters); + expect('rawTileData' in data).toBeTruthy(); + expect(data).toEqual(firstData); + expect(spy).toHaveBeenCalledTimes(2); }); }); @@ -109,7 +84,7 @@ describe('resourceTiming', () => { } } as GeoJSON.GeoJSON; - test('loadData - url', done => { + test('loadData - url', async () => { const exampleResourceTiming = { connectEnd: 473, connectStart: 473, @@ -134,19 +109,15 @@ describe('resourceTiming', () => { window.performance.getEntriesByName = jest.fn().mockReturnValue([exampleResourceTiming]); const layerIndex = new StyleLayerIndex(layers); - const source = new GeoJSONWorkerSource(actor, layerIndex, [], (params, callback) => { - callback(null, geoJson); - return {cancel: () => {}}; - }); + const source = new GeoJSONWorkerSource(actor, layerIndex, []); + source.loadGeoJSON = () => Promise.resolve(geoJson); - source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result.resourceTiming.testSource).toEqual([exampleResourceTiming]); - done(); - }); + const result = await source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters); + + expect(result.resourceTiming.testSource).toEqual([exampleResourceTiming]); }); - test('loadData - url (resourceTiming fallback method)', done => { + test('loadData - url (resourceTiming fallback method)', async () => { const sampleMarks = [100, 350]; const marks = {}; const measures = {}; @@ -169,29 +140,22 @@ describe('resourceTiming', () => { jest.spyOn(perf, 'clearMeasures').mockImplementation(() => { return null; }); const layerIndex = new StyleLayerIndex(layers); - const source = new GeoJSONWorkerSource(actor, layerIndex, [], (params, callback) => { - callback(null, geoJson); - return {cancel: () => {}}; - }); + const source = new GeoJSONWorkerSource(actor, layerIndex, []); + source.loadGeoJSON = () => Promise.resolve(geoJson); - source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result.resourceTiming.testSource).toEqual( - [{'duration': 250, 'entryType': 'measure', 'name': 'http://localhost/nonexistent', 'startTime': 100}] - ); - done(); - }); + const result = await source.loadData({source: 'testSource', request: {url: 'http://localhost/nonexistent', collectResourceTiming: true}} as LoadGeoJSONParameters); + + expect(result.resourceTiming.testSource).toEqual( + [{'duration': 250, 'entryType': 'measure', 'name': 'http://localhost/nonexistent', 'startTime': 100}] + ); }); - test('loadData - data', done => { + test('loadData - data', async () => { const layerIndex = new StyleLayerIndex(layers); const source = new GeoJSONWorkerSource(actor, layerIndex, []); - source.loadData({source: 'testSource', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result.resourceTiming).toBeUndefined(); - done(); - }); + const result = await source.loadData({source: 'testSource', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters); + expect(result.resourceTiming).toBeUndefined(); }); }); @@ -239,131 +203,105 @@ describe('loadData', () => { const layerIndex = new StyleLayerIndex(layers); function createWorker() { - const worker = new GeoJSONWorkerSource(actor, layerIndex, []); - - // Making the call to loadGeoJSON asynchronous - // allows these tests to mimic a message queue building up - // (regardless of timing) - const originalLoadGeoJSON = worker.loadGeoJSON; - worker.loadGeoJSON = function(params, callback) { - const timeout = setTimeout(() => { - originalLoadGeoJSON(params, callback); - }, 0); - - return {cancel: () => clearTimeout(timeout)}; - }; - return worker; + return new GeoJSONWorkerSource(actor, layerIndex, []); } - test('abandons previous callbacks', done => { + test('abandons previous requests', async () => { const worker = createWorker(); - let firstCallbackHasRun = false; - worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result && result.abandoned).toBeTruthy(); - firstCallbackHasRun = true; + server.respondWith(request => { + request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); - worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result && result.abandoned).toBeFalsy(); - expect(firstCallbackHasRun).toBeTruthy(); - done(); - }); + const p1 = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); + await sleep(0); + + const p2 = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); + + await sleep(0); + + server.respond(); + + const firstCallResult = await p1; + expect(firstCallResult && firstCallResult.abandoned).toBeTruthy(); + const result = await p2; + expect(result && result.abandoned).toBeFalsy(); }); - test('removeSource aborts callbacks', done => { + test('removeSource aborts requests', async () => { const worker = createWorker(); - let loadDataCallbackHasRun = false; - worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, result) => { - expect(err).toBeNull(); - expect(result && result.abandoned).toBeTruthy(); - loadDataCallbackHasRun = true; - }); - worker.removeSource({source: 'source1'}, (err) => { - expect(err).toBeFalsy(); - expect(loadDataCallbackHasRun).toBeTruthy(); - done(); + server.respondWith(request => { + request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); + + const loadPromise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); + await sleep(0); + const removePromise = worker.removeSource({source: 'source1', type: 'type'}); + await sleep(0); + + server.respond(); + + const result = await loadPromise; + expect(result && result.abandoned).toBeTruthy(); + await removePromise; }); - test('loadData with geojson creates an non-updateable source', done => { + test('loadData with geojson creates an non-updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); - worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeDefined(); - done(); - }); - }); + await worker.loadData({source: 'source1', data: JSON.stringify(geoJson)} as LoadGeoJSONParameters); + await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).rejects.toBeDefined(); }); - test('loadData with geojson creates an updateable source', done => { + test('loadData with geojson creates an updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); - worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - done(); - }); - }); + await worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters); + await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); - test('loadData with geojson network call creates an updateable source', done => { + test('loadData with geojson network call creates an updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(updateableGeoJson)); }); - worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - done(); - }); - }); - + const load1Promise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); server.respond(); + + await load1Promise; + await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); - test('loadData with geojson network call creates a non-updateable source', done => { + test('loadData with geojson network call creates a non-updateable source', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(geoJson)); }); - worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeDefined(); - done(); - }); - }); + const promise = worker.loadData({source: 'source1', request: {url: ''}} as LoadGeoJSONParameters); server.respond(); + + await promise; + + await expect(worker.loadData({source: 'source1', dataDiff: {removeAll: true}} as LoadGeoJSONParameters)).rejects.toBeDefined(); }); - test('loadData with diff updates', done => { + test('loadData with diff updates', async () => { const worker = new GeoJSONWorkerSource(actor, layerIndex, []); - worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - worker.loadData({source: 'source1', dataDiff: { - add: [{ - type: 'Feature', - id: 'update_point', - geometry: {type: 'Point', coordinates: [0, 0]}, - properties: {} - }]}} as LoadGeoJSONParameters, (err, _result) => { - expect(err).toBeNull(); - done(); - }); - }); + await worker.loadData({source: 'source1', data: JSON.stringify(updateableGeoJson)} as LoadGeoJSONParameters); + await expect(worker.loadData({source: 'source1', dataDiff: { + add: [{ + type: 'Feature', + id: 'update_point', + geometry: {type: 'Point', coordinates: [0, 0]}, + properties: {} + }] + }} as LoadGeoJSONParameters)).resolves.toBeDefined(); }); }); diff --git a/src/source/geojson_worker_source.ts b/src/source/geojson_worker_source.ts index a0fc131c70..1634d81522 100644 --- a/src/source/geojson_worker_source.ts +++ b/src/source/geojson_worker_source.ts @@ -1,5 +1,4 @@ import {getJSON} from '../util/ajax'; - import {RequestPerformance} from '../util/performance'; import rewind from '@mapbox/geojson-rewind'; import {GeoJSONWrapper} from './geojson_wrapper'; @@ -8,38 +7,46 @@ import Supercluster, {type Options as SuperclusterOptions, type ClusterPropertie import geojsonvt, {type Options as GeoJSONVTOptions} from 'geojson-vt'; import {VectorTileWorkerSource} from './vector_tile_worker_source'; import {createExpression} from '@maplibre/maplibre-gl-style-spec'; +import {isAbortError} from '../util/abort_error'; import type { WorkerTileParameters, - WorkerTileCallback, + WorkerTileResult, } from '../source/worker_source'; -import type {Actor} from '../util/actor'; -import type {StyleLayerIndex} from '../style/style_layer_index'; - -import type {LoadVectorDataCallback} from './vector_tile_worker_source'; -import type {RequestParameters, ResponseCallback} from '../util/ajax'; -import type {Callback} from '../types/callback'; -import type {Cancelable} from '../types/cancelable'; +import type {LoadVectorTileResult} from './vector_tile_worker_source'; +import type {RequestParameters} from '../util/ajax'; import {isUpdateableGeoJSON, type GeoJSONSourceDiff, applySourceDiff, toUpdateable, GeoJSONFeatureId} from './geojson_source_diff'; +import type {ClusterIDAndSource, GeoJSONWorkerSourceLoadDataResult, RemoveSourceParams} from '../util/actor_messages'; -export type LoadGeoJSONParameters = { +/** + * The geojson worker options that can be passed to the worker + */ +export type GeoJSONWorkerOptions = { + source?: string; + cluster?: boolean; + geojsonVtOptions?: GeoJSONVTOptions; + superclusterOptions?: SuperclusterOptions; + clusterProperties?: ClusterProperties; + filter?: Array; + promoteId?: string; + collectResourceTiming?: boolean; +} + +/** + * Parameters needed to load a geojson to the wokrer + */ +export type LoadGeoJSONParameters = GeoJSONWorkerOptions & { + type: 'geojson'; request?: RequestParameters; /** * Literal GeoJSON data. Must be provided if `request.url` is not. */ data?: string; dataDiff?: GeoJSONSourceDiff; - source: string; - cluster: boolean; - superclusterOptions?: SuperclusterOptions; - geojsonVtOptions?: GeoJSONVTOptions; - clusterProperties?: ClusterProperties; - filter?: Array; - promoteId?: string; }; -export type LoadGeoJSON = (params: LoadGeoJSONParameters, callback: ResponseCallback) => Cancelable; +export type LoadGeoJSON = (params: LoadGeoJSONParameters, abortController: AbortController) => Promise; type GeoJSONIndex = ReturnType | Supercluster; @@ -52,37 +59,20 @@ type GeoJSONIndex = ReturnType | Supercluster; * For a full example, see [mapbox-gl-topojson](https://github.com/developmentseed/mapbox-gl-topojson). */ export class GeoJSONWorkerSource extends VectorTileWorkerSource { - _pendingCallback: Callback<{ - resourceTiming?: {[_: string]: Array}; - abandoned?: boolean; - }>; - _pendingRequest: Cancelable; + _pendingRequest: AbortController; _geoJSONIndex: GeoJSONIndex; _dataUpdateable = new Map(); - /** - * @param loadGeoJSON - Optional method for custom loading/parsing of - * GeoJSON based on parameters passed from the main-thread Source. - * See {@link GeoJSONWorkerSource#loadGeoJSON}. - */ - constructor(actor: Actor, layerIndex: StyleLayerIndex, availableImages: Array, loadGeoJSON?: LoadGeoJSON | null) { - super(actor, layerIndex, availableImages); - this.loadVectorData = this.loadGeoJSONTile; - if (loadGeoJSON) { - this.loadGeoJSON = loadGeoJSON; - } - } - - loadGeoJSONTile(params: WorkerTileParameters, callback: LoadVectorDataCallback): (() => void) | void { + override async loadVectorTile(params: WorkerTileParameters, _abortController: AbortController): Promise { const canonical = params.tileID.canonical; if (!this._geoJSONIndex) { - return callback(null, null); // we couldn't load the file + throw new Error('Unable to parse the data into a cluster or geojson'); } const geoJSONTile = this._geoJSONIndex.getTile(canonical.z, canonical.x, canonical.y); if (!geoJSONTile) { - return callback(null, null); // nothing in the given tile + return null; } const geojsonWrapper = new GeoJSONWrapper(geoJSONTile.features); @@ -95,10 +85,10 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource { pbf = new Uint8Array(pbf); } - callback(null, { + return { vectorTile: geojsonWrapper, rawData: pbf.buffer - }); + }; } /** @@ -107,72 +97,60 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource { * can correctly serve up tiles. * * Defers to {@link GeoJSONWorkerSource#loadGeoJSON} for the fetching/parsing, - * expecting `callback(error, data)` to be called with either an error or a - * parsed GeoJSON object. * * When a `loadData` request comes in while a previous one is being processed, * the previous one is aborted. * * @param params - the parameters - * @param callback - the callback for completion or error + * @returns a promise that resolves when the data is loaded and parsed into a GeoJSON object */ - loadData(params: LoadGeoJSONParameters, callback: Callback<{ - resourceTiming?: {[_: string]: Array}; - abandoned?: boolean; - }>) { - this._pendingRequest?.cancel(); - if (this._pendingCallback) { - // Tell the foreground the previous call has been abandoned - this._pendingCallback(null, {abandoned: true}); - } - + async loadData(params: LoadGeoJSONParameters): Promise { + this._pendingRequest?.abort(); const perf = (params && params.request && params.request.collectResourceTiming) ? new RequestPerformance(params.request) : false; - this._pendingCallback = callback; - this._pendingRequest = this.loadGeoJSON(params, (err?: Error | null, data?: any | null) => { - delete this._pendingCallback; + this._pendingRequest = new AbortController(); + try { + let data = await this.loadGeoJSON(params, this._pendingRequest); delete this._pendingRequest; + if (typeof data !== 'object') { + throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`); + } + rewind(data, true); - if (err || !data) { - return callback(err); - } else if (typeof data !== 'object') { - return callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`)); - } else { - rewind(data, true); - - try { - if (params.filter) { - const compiled = createExpression(params.filter, {type: 'boolean', 'property-type': 'data-driven', overridable: false, transition: false} as any); - if (compiled.result === 'error') - throw new Error(compiled.value.map(err => `${err.key}: ${err.message}`).join(', ')); - - const features = data.features.filter(feature => compiled.value.evaluate({zoom: 0}, feature)); - data = {type: 'FeatureCollection', features}; - } - - this._geoJSONIndex = params.cluster ? - new Supercluster(getSuperclusterOptions(params)).load(data.features) : - geojsonvt(data, params.geojsonVtOptions); - } catch (err) { - return callback(err); - } + if (params.filter) { + const compiled = createExpression(params.filter, {type: 'boolean', 'property-type': 'data-driven', overridable: false, transition: false} as any); + if (compiled.result === 'error') + throw new Error(compiled.value.map(err => `${err.key}: ${err.message}`).join(', ')); + + const features = (data as any).features.filter(feature => compiled.value.evaluate({zoom: 0}, feature)); + data = {type: 'FeatureCollection', features}; + } - this.loaded = {}; - - const result = {} as { resourceTiming: any }; - if (perf) { - const resourceTimingData = perf.finish(); - // it's necessary to eval the result of getEntriesByName() here via parse/stringify - // late evaluation in the main thread causes TypeError: illegal invocation - if (resourceTimingData) { - result.resourceTiming = {}; - result.resourceTiming[params.source] = JSON.parse(JSON.stringify(resourceTimingData)); - } + this._geoJSONIndex = params.cluster ? + new Supercluster(getSuperclusterOptions(params)).load((data as any).features) : + geojsonvt(data, params.geojsonVtOptions); + + this.loaded = {}; + + const result = {} as GeoJSONWorkerSourceLoadDataResult; + if (perf) { + const resourceTimingData = perf.finish(); + // it's necessary to eval the result of getEntriesByName() here via parse/stringify + // late evaluation in the main thread causes TypeError: illegal invocation + if (resourceTimingData) { + result.resourceTiming = {}; + result.resourceTiming[params.source] = JSON.parse(JSON.stringify(resourceTimingData)); } - callback(null, result); } - }); + return result; + } catch (err) { + delete this._pendingRequest; + if (isAbortError(err)) { + return {abandoned: true}; + } + throw err; + } } /** @@ -182,108 +160,75 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource { * Otherwise, such as after a setData() call, we load the tile fresh. * * @param params - the parameters - * @param callback - the callback for completion or error + * @returns A promise that resolves when the tile is reloaded */ - reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback) { + reloadTile(params: WorkerTileParameters): Promise { const loaded = this.loaded, uid = params.uid; if (loaded && loaded[uid]) { - return super.reloadTile(params, callback); + return super.reloadTile(params); } else { - return this.loadTile(params, callback); + return this.loadTile(params); } } /** - * Fetch and parse GeoJSON according to the given params. Calls `callback` - * with `(err, data)`, where `data` is a parsed GeoJSON object. + * Fetch and parse GeoJSON according to the given params. * * GeoJSON is loaded and parsed from `params.url` if it exists, or else * expected as a literal (string or object) `params.data`. * * @param params - the parameters - * @param callback - the callback for completion or error - * @returns A Cancelable object. + * @param abortController - the abort controller that allows aborting this operation + * @returns a promise that resolves when the data is loaded */ - loadGeoJSON = (params: LoadGeoJSONParameters, callback: ResponseCallback): Cancelable => { + async loadGeoJSON(params: LoadGeoJSONParameters, abortController: AbortController): Promise { const {promoteId} = params; - // Because of same origin issues, urls must either include an explicit - // origin or absolute path. - // ie: /foo/bar.json or http://example.com/bar.json - // but not ../foo/bar.json if (params.request) { - return getJSON(params.request, ( - error?: Error, - data?: any, - cacheControl?: string, - expires?: string - ) => { - this._dataUpdateable = isUpdateableGeoJSON(data, promoteId) ? toUpdateable(data, promoteId) : undefined; - callback(error, data, cacheControl, expires); - }); - } else if (typeof params.data === 'string') { + const response = await getJSON(params.request, abortController); + this._dataUpdateable = isUpdateableGeoJSON(response.data, promoteId) ? toUpdateable(response.data, promoteId) : undefined; + return response.data; + } + if (typeof params.data === 'string') { try { const parsed = JSON.parse(params.data); this._dataUpdateable = isUpdateableGeoJSON(parsed, promoteId) ? toUpdateable(parsed, promoteId) : undefined; - callback(null, parsed); + return parsed; } catch (e) { - callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`)); + throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`); } - } else if (params.dataDiff) { - if (this._dataUpdateable) { - applySourceDiff(this._dataUpdateable, params.dataDiff, promoteId); - callback(null, {type: 'FeatureCollection', features: Array.from(this._dataUpdateable.values())}); - } else { - callback(new Error(`Cannot update existing geojson data in ${params.source}`)); - } - } else { - callback(new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`)); } - - return {cancel: () => {}}; - }; - - removeSource(params: { - source: string; - }, callback: WorkerTileCallback) { - if (this._pendingCallback) { - // Don't leak callbacks - this._pendingCallback(null, {abandoned: true}); + if (!params.dataDiff) { + throw new Error(`Input data given to '${params.source}' is not a valid GeoJSON object.`); + } + if (!this._dataUpdateable) { + throw new Error(`Cannot update existing geojson data in ${params.source}`); } - callback(); + applySourceDiff(this._dataUpdateable, params.dataDiff, promoteId); + return {type: 'FeatureCollection', features: Array.from(this._dataUpdateable.values())}; } - getClusterExpansionZoom(params: { - clusterId: number; - }, callback: Callback) { - try { - callback(null, (this._geoJSONIndex as Supercluster).getClusterExpansionZoom(params.clusterId)); - } catch (e) { - callback(e); + async removeSource(_params: RemoveSourceParams): Promise { + if (this._pendingRequest) { + this._pendingRequest.abort(); } } - getClusterChildren(params: { - clusterId: number; - }, callback: Callback>) { - try { - callback(null, (this._geoJSONIndex as Supercluster).getChildren(params.clusterId)); - } catch (e) { - callback(e); - } + getClusterExpansionZoom(params: ClusterIDAndSource): number { + return (this._geoJSONIndex as Supercluster).getClusterExpansionZoom(params.clusterId); + } + + getClusterChildren(params: ClusterIDAndSource): Array { + return (this._geoJSONIndex as Supercluster).getChildren(params.clusterId); } getClusterLeaves(params: { clusterId: number; limit: number; offset: number; - }, callback: Callback>) { - try { - callback(null, (this._geoJSONIndex as Supercluster).getLeaves(params.clusterId, params.limit, params.offset)); - } catch (e) { - callback(e); - } + }): Array { + return (this._geoJSONIndex as Supercluster).getLeaves(params.clusterId, params.limit, params.offset); } } diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts index 124b2a86cf..cbb9fab461 100644 --- a/src/source/image_source.test.ts +++ b/src/source/image_source.test.ts @@ -4,8 +4,7 @@ import {Transform} from '../geo/transform'; import {extend} from '../util/util'; import {type FakeServer, fakeServer} from 'nise'; import {RequestManager} from '../util/request_manager'; -import {Dispatcher} from '../util/dispatcher'; -import {stubAjaxGetImage} from '../util/test/util'; +import {sleep, stubAjaxGetImage} from '../util/test/util'; import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; import {VertexBuffer} from '../gl/vertex_buffer'; @@ -18,7 +17,7 @@ function createSource(options) { coordinates: [[0, 0], [1, 0], [1, 1], [0, 1]] }, options); - const source = new ImageSource('id', options, {send() {}} as any as Dispatcher, options.eventedParent); + const source = new ImageSource('id', options, {} as any, options.eventedParent); return source; } @@ -61,13 +60,14 @@ 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(); + await sleep(0); expect(source.image).toBeTruthy(); }); @@ -110,7 +110,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 +122,7 @@ describe('ImageSource', () => { coordinates: [[0, 0], [-1, 0], [-1, -1], [0, -1]] }); server.respond(); + await sleep(0); const afterSerialized = source.serialize(); expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]); }); @@ -164,7 +165,7 @@ describe('ImageSource', () => { source.tiles[String(tile.tileID.wrap)] = tile; source.image = new ImageBitmap(); // assign dummies directly so we don't need to stub the gl things - source.boundsBuffer = {} as VertexBuffer; + source.boundsBuffer = {destroy: () => {}} as VertexBuffer; source.boundsSegments = {} as SegmentVector; source.texture = {} as Texture; source.prepare(); @@ -179,22 +180,27 @@ describe('ImageSource', () => { expect(serialized.coordinates).toEqual([[0, 0], [1, 0], [1, 1], [0, 1]]); }); - test('allows using updateImage before initial image is loaded', () => { - const source = createSource({url: '/image.png'}); + test('allows using updateImage before initial image is loaded', async () => { const map = new StubMap() as any; + const source = createSource({url: '/image.png', eventedParent: map}); + // Suppress errors because we're aborting when updating. + map.on('error', () => {}); source.onAdd(map); - expect(source.image).toBeUndefined(); source.updateImage({url: '/image2.png'}); server.respond(); + await sleep(10); + expect(source.image).toBeTruthy(); }); test('cancels request if updateImage is used', () => { - const source = createSource({url: '/image.png'}); const map = new StubMap() as any; + const source = createSource({url: '/image.png', eventedParent: map}); + // Suppress errors because we're aborting. + map.on('error', () => {}); source.onAdd(map); const spy = jest.spyOn(server.requests[0] as any, 'abort'); diff --git a/src/source/image_source.ts b/src/source/image_source.ts index 8be422aa54..a27fdbd322 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) { @@ -128,30 +127,30 @@ export class ImageSource extends Evented implements Source { this.options = options; } - load = (newCoordinates?: Coordinates, successCallback?: () => void) => { + async load(newCoordinates?: Coordinates): Promise { this._loaded = false; this.fire(new Event('dataloading', {dataType: 'source'})); this.url = this.options.url; - this._request = ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), (err, image) => { + this._request = new AbortController(); + try { + const image = await ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request); 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; } - if (successCallback) { - successCallback(); - } this._finishLoading(); } - }); - }; + } catch (err) { + this._request = null; + this.fire(new ErrorEvent(err)); + } + } loaded(): boolean { return this._loaded; @@ -170,12 +169,12 @@ export class ImageSource extends Evented implements Source { } if (this._request) { - this._request.cancel(); + this._request.abort(); this._request = null; } this.options.url = options.url; - this.load(options.coordinates, () => { this.texture = null; }); + this.load(options.coordinates).finally(() => { this.texture = null; }); return this; } @@ -193,7 +192,7 @@ export class ImageSource extends Evented implements Source { onRemove() { if (this._request) { - this._request.cancel(); + this._request.abort(); this._request = null; } } @@ -245,7 +244,7 @@ export class ImageSource extends Evented implements Source { return this; } - prepare = () => { + prepare() { if (Object.keys(this.tiles).length === 0 || !this.image) { return; } @@ -279,9 +278,9 @@ export class ImageSource extends Evented implements Source { if (newTilesLoaded) { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id})); } - }; + } - loadTile(tile: Tile, callback: Callback) { + async loadTile(tile: Tile, callback?: Callback): Promise { // We have a single tile -- whose coordinates are this.tileID -- that // covers the image we want to render. If that's the one being // requested, set it up with the image; otherwise, mark the tile as @@ -291,20 +290,21 @@ export class ImageSource extends Evented implements Source { if (this.tileID && this.tileID.equals(tile.tileID.canonical)) { this.tiles[String(tile.tileID.wrap)] = tile; tile.buckets = {}; - callback(null); } else { tile.state = 'errored'; - callback(null); + } + if (callback) { + callback(); } } - serialize = (): ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification => { + serialize(): ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification { return { type: 'image', url: this.options.url, coordinates: this.coordinates }; - }; + } hasTransition() { return false; diff --git a/src/source/load_tilejson.ts b/src/source/load_tilejson.ts index 9fc6e61fc8..af38b6afc7 100644 --- a/src/source/load_tilejson.ts +++ b/src/source/load_tilejson.ts @@ -1,42 +1,36 @@ import {pick, extend} from '../util/util'; - import {getJSON} from '../util/ajax'; import {ResourceType} from '../util/request_manager'; import {browser} from '../util/browser'; import type {RequestManager} from '../util/request_manager'; -import type {Callback} from '../types/callback'; import type {TileJSON} from '../types/tilejson'; -import type {Cancelable} from '../types/cancelable'; import type {RasterDEMSourceSpecification, RasterSourceSpecification, VectorSourceSpecification} from '@maplibre/maplibre-gl-style-spec'; -export function loadTileJson( +export async function loadTileJson( options: RasterSourceSpecification | RasterDEMSourceSpecification | VectorSourceSpecification, requestManager: RequestManager, - callback: Callback -): Cancelable { - const loaded = function(err: Error, tileJSON: any) { - if (err) { - return callback(err); - } else if (tileJSON) { - const result: any = pick( - // explicit source options take precedence over TileJSON - extend(tileJSON, options), - ['tiles', 'minzoom', 'maxzoom', 'attribution', 'bounds', 'scheme', 'tileSize', 'encoding'] - ); - - if (tileJSON.vector_layers) { - result.vectorLayers = tileJSON.vector_layers; - result.vectorLayerIds = result.vectorLayers.map((layer) => { return layer.id; }); - } + abortController: AbortController, +): Promise { + let tileJSON: any = options; + if (options.url) { + const response = await getJSON(requestManager.transformRequest(options.url, ResourceType.Source), abortController); + tileJSON = response.data; + } else { + await browser.frameAsync(abortController); + } + if (tileJSON) { + const result: TileJSON = pick( + // explicit source options take precedence over TileJSON + extend(tileJSON, options), + ['tiles', 'minzoom', 'maxzoom', 'attribution', 'bounds', 'scheme', 'tileSize', 'encoding'] + ); - callback(null, result); + if (tileJSON.vector_layers) { + result.vectorLayers = tileJSON.vector_layers; + result.vectorLayerIds = result.vectorLayers.map((layer) => { return layer.id; }); } - }; - if (options.url) { - return getJSON(requestManager.transformRequest(options.url, ResourceType.Source), loaded); - } else { - return browser.frame(() => loaded(null, options)); + return result; } } diff --git a/src/source/query_features.test.ts b/src/source/query_features.test.ts index b9ff7f8610..29fa78e44f 100644 --- a/src/source/query_features.test.ts +++ b/src/source/query_features.test.ts @@ -5,7 +5,6 @@ import { import {SourceCache} from './source_cache'; import {Transform} from '../geo/transform'; import Point from '@mapbox/point-geometry'; -import {Dispatcher} from '../util/dispatcher'; describe('QueryFeatures#rendered', () => { test('returns empty object if source returns no tiles', () => { @@ -23,12 +22,8 @@ describe('QueryFeatures#source', () => { type: 'geojson', data: {type: 'FeatureCollection', features: []} }, { - getActor() { - return { - send(type, params, callback) { return callback(); } - }; - } - } as any as Dispatcher); + getActor() {} + } as any); const result = querySourceFeatures(sourceCache, {}); expect(result).toEqual([]); }); diff --git a/src/source/raster_dem_tile_source.test.ts b/src/source/raster_dem_tile_source.test.ts index 43bf16bbef..b913e9afa1 100644 --- a/src/source/raster_dem_tile_source.test.ts +++ b/src/source/raster_dem_tile_source.test.ts @@ -2,11 +2,11 @@ import {fakeServer, FakeServer} from 'nise'; import {RasterDEMTileSource} from './raster_dem_tile_source'; import {OverscaledTileID} from './tile_id'; import {RequestManager} from '../util/request_manager'; -import {Dispatcher} from '../util/dispatcher'; import {Tile} from './tile'; +import {waitForMetadataEvent} from '../util/test/util'; function createSource(options, transformCallback?) { - const source = new RasterDEMTileSource('id', options, {send() {}} as any as Dispatcher, options.eventedParent); + const source = new RasterDEMTileSource('id', options, {} as any, options.eventedParent); source.onAdd({ transform: {angle: 0, pitch: 0, showCollisionBoxes: false}, _getMapId: () => 1, @@ -21,7 +21,7 @@ function createSource(options, transformCallback?) { return source; } -describe('RasterTileSource', () => { +describe('RasterDEMTileSource', () => { let server: FakeServer; beforeEach(() => { global.fetch = null; @@ -32,7 +32,7 @@ describe('RasterTileSource', () => { server.restore(); }); - test('transforms request for TileJSON URL', done => { + test('transforms request for TileJSON URL', () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, maxzoom: 22, @@ -49,10 +49,9 @@ describe('RasterTileSource', () => { expect(transformSpy.mock.calls[0][0]).toBe('/source.json'); expect(transformSpy.mock.calls[0][1]).toBe('Source'); - done(); }); - test('transforms tile urls before requesting', done => { + test('transforms tile urls before requesting', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, maxzoom: 22, @@ -62,26 +61,23 @@ describe('RasterTileSource', () => { })); const source = createSource({url: '/source.json'}); const transformSpy = jest.spyOn(source.map._requestManager, 'transformRequest'); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - 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.mock.calls[0][0]).toBe('http://example.com/10/5/5.png'); - expect(transformSpy.mock.calls[0][1]).toBe('Tile'); - done(); - - } - }); + const promise = waitForMetadataEvent(source); server.respond(); + await promise; + const tile = { + 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.mock.calls[0][0]).toBe('http://example.com/10/5/5.png'); + expect(transformSpy.mock.calls[0][1]).toBe('Tile'); }); - test('populates neighboringTiles', done => { + + test('populates neighboringTiles', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, maxzoom: 22, @@ -89,66 +85,61 @@ describe('RasterTileSource', () => { tiles: ['http://example.com/{z}/{x}/{y}.png'] })); const source = createSource({url: '/source.json'}); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - tileID: new OverscaledTileID(10, 0, 10, 5, 5), - state: 'loading', - loadVectorData () {}, - setExpiryData() {} - } as any as Tile; - source.loadTile(tile, () => {}); - - expect(Object.keys(tile.neighboringTiles)).toEqual([ - new OverscaledTileID(10, 0, 10, 4, 5).key, - new OverscaledTileID(10, 0, 10, 6, 5).key, - new OverscaledTileID(10, 0, 10, 4, 4).key, - new OverscaledTileID(10, 0, 10, 5, 4).key, - new OverscaledTileID(10, 0, 10, 6, 4).key, - new OverscaledTileID(10, 0, 10, 4, 6).key, - new OverscaledTileID(10, 0, 10, 5, 6).key, - new OverscaledTileID(10, 0, 10, 6, 6).key - ]); - - done(); - - } - }); + const promise = waitForMetadataEvent(source); server.respond(); + await promise; + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData () {}, + setExpiryData() {} + } as any as Tile; + source.loadTile(tile); + + expect(Object.keys(tile.neighboringTiles)).toEqual([ + new OverscaledTileID(10, 0, 10, 4, 5).key, + new OverscaledTileID(10, 0, 10, 6, 5).key, + new OverscaledTileID(10, 0, 10, 4, 4).key, + new OverscaledTileID(10, 0, 10, 5, 4).key, + new OverscaledTileID(10, 0, 10, 6, 4).key, + new OverscaledTileID(10, 0, 10, 4, 6).key, + new OverscaledTileID(10, 0, 10, 5, 6).key, + new OverscaledTileID(10, 0, 10, 6, 6).key + ]); }); - test('populates neighboringTiles with wrapped tiles', done => { + test('populates neighboringTiles with wrapped tiles', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, maxzoom: 22, attribution: 'MapLibre', tiles: ['http://example.com/{z}/{x}/{y}.png'] })); + const source = createSource({url: '/source.json'}); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - tileID: new OverscaledTileID(5, 0, 5, 31, 5), - state: 'loading', - loadVectorData () {}, - setExpiryData() {} - } as any as Tile; - source.loadTile(tile, () => {}); - - expect(Object.keys(tile.neighboringTiles)).toEqual([ - new OverscaledTileID(5, 0, 5, 30, 6).key, - new OverscaledTileID(5, 0, 5, 31, 6).key, - new OverscaledTileID(5, 0, 5, 30, 5).key, - new OverscaledTileID(5, 1, 5, 0, 5).key, - new OverscaledTileID(5, 0, 5, 30, 4).key, - new OverscaledTileID(5, 0, 5, 31, 4).key, - new OverscaledTileID(5, 1, 5, 0, 4).key, - new OverscaledTileID(5, 1, 5, 0, 6).key - ]); - done(); - } - }); + const promise = waitForMetadataEvent(source); + server.respond(); + await promise; + + const tile = { + tileID: new OverscaledTileID(5, 0, 5, 31, 5), + state: 'loading', + loadVectorData() {}, + setExpiryData() {} + } as any as Tile; + source.loadTile(tile); + + expect(Object.keys(tile.neighboringTiles)).toEqual([ + new OverscaledTileID(5, 0, 5, 30, 6).key, + new OverscaledTileID(5, 0, 5, 31, 6).key, + new OverscaledTileID(5, 0, 5, 30, 5).key, + new OverscaledTileID(5, 1, 5, 0, 5).key, + new OverscaledTileID(5, 0, 5, 30, 4).key, + new OverscaledTileID(5, 0, 5, 31, 4).key, + new OverscaledTileID(5, 1, 5, 0, 4).key, + new OverscaledTileID(5, 1, 5, 0, 6).key + ]); }); it('serializes options', () => { diff --git a/src/source/raster_dem_tile_source.ts b/src/source/raster_dem_tile_source.ts index 19d052245e..dbb11342c5 100644 --- a/src/source/raster_dem_tile_source.ts +++ b/src/source/raster_dem_tile_source.ts @@ -13,9 +13,7 @@ import type {DEMEncoding} from '../data/dem_data'; import type {Source} from './source'; 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'; @@ -54,25 +52,28 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { this.baseShift = options.baseShift; } - loadTile(tile: Tile, callback: Callback) { + override async loadTile(tile: Tile): Promise { 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(); + try { + const response = await ImageRequest.getImage(request, tile.abortController, this.map._refreshExpiredTiles); + 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); + return; + } + 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 rawImageData = transfer ? img : await this.readImageNow(img); const params = { + type: this.type, uid: tile.uid, - coord: tile.tileID, source: this.id, rawImageData, encoding: this.encoding, @@ -84,38 +85,37 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { if (!tile.actor || tile.state === 'expired') { tile.actor = this.dispatcher.getActor(); - tile.actor.send('loadDEMTile', params, done); - } - } - }, this.map._refreshExpiredTiles); - - async function readImageNow(img: ImageBitmap | HTMLImageElement): Promise { - if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) { - const width = img.width + 2; - const height = img.height + 2; - try { - return new RGBAImage({width, height}, await readImageUsingVideoFrame(img, -1, -1, width, height)); - } catch (e) { - // fall-back to browser canvas decoding + /* eslint-disable require-atomic-updates */ + const data = await tile.actor.sendAsync({type: 'loadDEMTile', data: params}); + tile.dem = data; + tile.needsHillshadePrepare = true; + tile.needsTerrainPrepare = true; + tile.state = 'loaded'; + /* eslint-enable require-atomic-updates */ } } - return browser.getImageData(img, 1); - } - - function done(err, data) { - if (err) { + } catch (err) { + delete tile.abortController; + if (tile.aborted) { + tile.state = 'unloaded'; + } else if (err) { tile.state = 'errored'; - callback(err); + throw err; } + } + } - if (data) { - tile.dem = data; - tile.needsHillshadePrepare = true; - tile.needsTerrainPrepare = true; - tile.state = 'loaded'; - callback(null); + async readImageNow(img: ImageBitmap | HTMLImageElement): Promise { + if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) { + const width = img.width + 2; + const height = img.height + 2; + try { + return new RGBAImage({width, height}, await readImageUsingVideoFrame(img, -1, -1, width, height)); + } catch (e) { + // fall-back to browser canvas decoding } } + return browser.getImageData(img, 1); } _getNeighboringTiles(tileID: OverscaledTileID) { @@ -148,7 +148,7 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { return neighboringTiles; } - unloadTile(tile: Tile) { + async unloadTile(tile: Tile) { if (tile.demTexture) this.map.painter.saveTileTexture(tile.demTexture); if (tile.fbo) { tile.fbo.destroy(); @@ -159,7 +159,7 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { tile.state = 'unloaded'; if (tile.actor) { - tile.actor.send('removeDEMTile', {uid: tile.uid, source: this.id}); + await tile.actor.sendAsync({type: 'removeDEMTile', data: {type: this.type, uid: tile.uid, source: this.id}}); } } } diff --git a/src/source/raster_dem_tile_worker_source.test.ts b/src/source/raster_dem_tile_worker_source.test.ts index 0641912f57..f76e2e68a5 100644 --- a/src/source/raster_dem_tile_worker_source.test.ts +++ b/src/source/raster_dem_tile_worker_source.test.ts @@ -3,20 +3,17 @@ import {DEMData} from '../data/dem_data'; import {WorkerDEMTileParameters} from './worker_source'; describe('loadTile', () => { - test('loads DEM tile', done => { + test('loads DEM tile', async () => { const source = new RasterDEMTileWorkerSource(); - source.loadTile({ + const data = await source.loadTile({ source: 'source', uid: '0', rawImageData: {data: new Uint8ClampedArray(256), height: 8, width: 8}, dim: 256 - } as any as WorkerDEMTileParameters, (err, data) => { - if (err) done(err); - expect(Object.keys(source.loaded)).toEqual(['0']); - expect(data instanceof DEMData).toBeTruthy(); - done(); - }); + } as any as WorkerDEMTileParameters); + expect(Object.keys(source.loaded)).toEqual(['0']); + expect(data instanceof DEMData).toBeTruthy(); }); }); @@ -30,7 +27,8 @@ describe('removeTile', () => { source.removeTile({ source: 'source', - uid: '0' + uid: '0', + type: 'raster-dem', }); expect(source.loaded).toEqual({}); diff --git a/src/source/raster_dem_tile_worker_source.ts b/src/source/raster_dem_tile_worker_source.ts index d283804633..f654ca1b8d 100644 --- a/src/source/raster_dem_tile_worker_source.ts +++ b/src/source/raster_dem_tile_worker_source.ts @@ -3,7 +3,6 @@ import {RGBAImage} from '../util/image'; import type {Actor} from '../util/actor'; import type { WorkerDEMTileParameters, - WorkerDEMTileCallback, TileParameters } from './worker_source'; import {getImageData, isImageBitmap} from '../util/util'; @@ -16,17 +15,17 @@ export class RasterDEMTileWorkerSource { this.loaded = {}; } - async loadTile(params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) { + async loadTile(params: WorkerDEMTileParameters): Promise { const {uid, encoding, rawImageData, redFactor, greenFactor, blueFactor, baseShift} = params; const width = rawImageData.width + 2; const height = rawImageData.height + 2; - const imagePixels: RGBAImage = isImageBitmap(rawImageData) ? + const imagePixels: RGBAImage | ImageData = isImageBitmap(rawImageData) ? new RGBAImage({width, height}, await getImageData(rawImageData, -1, -1, width, height)) : rawImageData; const dem = new DEMData(uid, imagePixels, encoding, redFactor, greenFactor, blueFactor, baseShift); this.loaded = this.loaded || {}; this.loaded[uid] = dem; - callback(null, dem); + return dem; } removeTile(params: TileParameters) { diff --git a/src/source/raster_tile_source.test.ts b/src/source/raster_tile_source.test.ts index 74f589fd7e..76fcdfb3a1 100644 --- a/src/source/raster_tile_source.test.ts +++ b/src/source/raster_tile_source.test.ts @@ -15,9 +15,7 @@ function createSource(options, transformCallback?) { getPixelRatio() { return 1; } } as any); - source.on('error', (e) => { - throw e.error; - }); + source.on('error', () => { }); // to prevent console log of errors return source; } @@ -124,7 +122,7 @@ describe('RasterTileSource', () => { loadVectorData () {}, setExpiryData() {} } as any as Tile; - source.loadTile(tile, () => {}); + source.loadTile(tile); expect(transformSpy).toHaveBeenCalledTimes(1); expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/10/5/5.png'); expect(transformSpy.mock.calls[0][1]).toBe('Tile'); @@ -154,7 +152,7 @@ describe('RasterTileSource', () => { tileID: new OverscaledTileID(10, 0, 10, 5, 5), state: 'loading' } as any as Tile; - source.loadTile(tile, () => { + source.loadTile(tile).then(() => { expect(imageConstructorSpy).toHaveBeenCalledTimes(1); expect(tile.state).toBe('loaded'); done(); @@ -178,7 +176,7 @@ describe('RasterTileSource', () => { test('cancels TileJSON request if removed', () => { const source = createSource({url: '/source.json'}); source.onRemove(); - expect((server.requests.pop() as any).aborted).toBe(true); + expect((server.lastRequest as any).aborted).toBe(true); }); it('serializes options', () => { diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts index 66630c5099..74e6491aaa 100644 --- a/src/source/raster_tile_source.ts +++ b/src/source/raster_tile_source.ts @@ -13,8 +13,6 @@ import type {OverscaledTileID} from './tile_id'; import type {Map} from '../ui/map'; import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; -import type {Callback} from '../types/callback'; -import type {Cancelable} from '../types/cancelable'; import type { RasterSourceSpecification, RasterDEMSourceSpecification @@ -67,7 +65,7 @@ export class RasterTileSource extends Evented implements Source { _loaded: boolean; _options: RasterSourceSpecification | RasterDEMSourceSpecification; - _tileJSONRequest: Cancelable; + _tileJSONRequest: AbortController; constructor(id: string, options: RasterSourceSpecification | RasterDEMSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) { super(); @@ -87,15 +85,15 @@ export class RasterTileSource extends Evented implements Source { extend(this, pick(options, ['url', 'scheme', 'tileSize'])); } - load() { + async load() { this._loaded = false; this.fire(new Event('dataloading', {dataType: 'source'})); - this._tileJSONRequest = loadTileJson(this._options, this.map._requestManager, (err, tileJSON) => { + this._tileJSONRequest = new AbortController(); + try { + const tileJSON = await loadTileJson(this._options, this.map._requestManager, this._tileJSONRequest); this._tileJSONRequest = null; this._loaded = true; - if (err) { - this.fire(new ErrorEvent(err)); - } else if (tileJSON) { + if (tileJSON) { extend(this, tileJSON); if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom); @@ -105,7 +103,10 @@ export class RasterTileSource extends Evented implements Source { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); } - }); + } catch (err) { + this._tileJSONRequest = null; + this.fire(new ErrorEvent(err)); + } } loaded(): boolean { @@ -119,14 +120,15 @@ export class RasterTileSource extends Evented implements Source { onRemove() { if (this._tileJSONRequest) { - this._tileJSONRequest.cancel(); + this._tileJSONRequest.abort(); this._tileJSONRequest = null; } } setSourceProperty(callback: Function) { if (this._tileJSONRequest) { - this._tileJSONRequest.cancel(); + this._tileJSONRequest.abort(); + this._tileJSONRequest = null; } callback(); @@ -156,22 +158,23 @@ export class RasterTileSource extends Evented implements Source { return !this.tileBounds || this.tileBounds.contains(tileID.canonical); } - loadTile(tile: Tile, callback: Callback) { + async loadTile(tile: Tile): Promise { 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(); + try { + const response = await ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles); + 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); - + return; + } + 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}); @@ -183,25 +186,30 @@ export class RasterTileSource extends Evented implements Source { gl.texParameterf(gl.TEXTURE_2D, context.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT, context.extTextureFilterAnisotropicMax); } } - tile.state = 'loaded'; - - callback(null); } - }, this.map._refreshExpiredTiles); + } catch (err) { + delete tile.abortController; + if (tile.aborted) { + tile.state = 'unloaded'; + } else if (err) { + tile.state = 'errored'; + throw err; + } + } } - abortTile(tile: Tile, callback: Callback) { - if (tile.request) { - tile.request.cancel(); - delete tile.request; + async abortTile(tile: Tile) { + if (tile.abortController) { + tile.abortController.abort(); + delete tile.abortController; } - callback(); } - unloadTile(tile: Tile, callback: Callback) { - if (tile.texture) this.map.painter.saveTileTexture(tile.texture); - callback(); + async unloadTile(tile: Tile) { + if (tile.texture) { + this.map.painter.saveTileTexture(tile.texture); + } } hasTransition() { diff --git a/src/source/rtl_text_plugin.ts b/src/source/rtl_text_plugin.ts index 249919cad6..6e00b675ee 100644 --- a/src/source/rtl_text_plugin.ts +++ b/src/source/rtl_text_plugin.ts @@ -3,34 +3,44 @@ import {browser} from '../util/browser'; import {Event, Evented} from '../util/evented'; import {isWorker} from '../util/util'; -const status = { - unavailable: 'unavailable', // Not loaded - deferred: 'deferred', // The plugin URL has been specified, but loading has been deferred - loading: 'loading', // request in-flight - loaded: 'loaded', - error: 'error' -}; +/** + * The possible option of the plugin's status + * + * `unavailable`: Not loaded. + * + * `deferred`: The plugin URL has been specified, but loading has been deferred. + * + * `loading`: request in-flight. + * + * `loaded`: The plugin is now loaded + * + * `error`: The plugin failed to load + */ +type RTLPlginStatus = 'unavailable' | 'deferred' | 'loading' | 'loaded' | 'error'; +/** + * The RTL plugin state + */ export type PluginState = { - pluginStatus: typeof status[keyof typeof status]; + pluginStatus: RTLPlginStatus; pluginURL: string; }; /** * An error callback */ -type ErrorCallback = (error?: Error | null) => void; +type ErrorCallback = (error?: Error | string | null) => void; type PluginStateSyncCallback = (state: PluginState) => void; -let _completionCallback = null; +let _completionCallback: ErrorCallback = null; //Variables defining the current state of the plugin -let pluginStatus = status.unavailable; +let pluginStatus: RTLPlginStatus = 'unavailable'; let pluginURL = null; -export const triggerPluginCompletionEvent = function(error: Error | string) { +export const triggerPluginCompletionEvent = (error: string | Error) => { // NetworkError's are not correctly reflected by the plugin status which prevents reloading plugin if (error && typeof error === 'string' && error.indexOf('NetworkError') > -1) { - pluginStatus = status.error; + pluginStatus = 'error'; } if (_completionCallback) { @@ -44,11 +54,11 @@ function sendPluginStateToWorker() { export const evented = new Evented(); -export const getRTLTextPluginStatus = function () { +export const getRTLTextPluginStatus = () => { return pluginStatus; }; -export const registerForPluginStateChange = function(callback: PluginStateSyncCallback) { +export const registerForPluginStateChange = (callback: PluginStateSyncCallback) => { // Do an initial sync of the state callback({pluginStatus, pluginURL}); // Listen for all future state changes @@ -56,18 +66,18 @@ export const registerForPluginStateChange = function(callback: PluginStateSyncCa return callback; }; -export const clearRTLTextPlugin = function() { - pluginStatus = status.unavailable; +export const clearRTLTextPlugin = () => { + pluginStatus = 'unavailable'; pluginURL = null; _completionCallback = null; }; -export const setRTLTextPlugin = function(url: string, callback: ErrorCallback, deferred: boolean = false) { - if (pluginStatus === status.deferred || pluginStatus === status.loading || pluginStatus === status.loaded) { +export const setRTLTextPlugin = (url: string, callback?: ErrorCallback, deferred: boolean = false) => { + if (pluginStatus === 'deferred' || pluginStatus === 'loading' || pluginStatus === 'loaded') { throw new Error('setRTLTextPlugin cannot be called multiple times.'); } pluginURL = browser.resolveURL(url); - pluginStatus = status.deferred; + pluginStatus = 'deferred'; _completionCallback = callback; sendPluginStateToWorker(); @@ -77,26 +87,24 @@ export const setRTLTextPlugin = function(url: string, callback: ErrorCallback, d } }; -export const downloadRTLTextPlugin = function() { - if (pluginStatus !== status.deferred || !pluginURL) { +export const downloadRTLTextPlugin = () => { + if (pluginStatus !== 'deferred' || !pluginURL) { throw new Error('rtl-text-plugin cannot be downloaded unless a pluginURL is specified'); } - pluginStatus = status.loading; + pluginStatus = 'loading'; sendPluginStateToWorker(); - if (pluginURL) { - getArrayBuffer({url: pluginURL}, (error) => { - if (error) { - triggerPluginCompletionEvent(error); - } else { - pluginStatus = status.loaded; - sendPluginStateToWorker(); - } - }); - } + getArrayBuffer({url: pluginURL}, new AbortController()).then(() => { + pluginStatus = 'loaded'; + sendPluginStateToWorker(); + }).catch((error) => { + if (error) { + triggerPluginCompletionEvent(error); + } + }); }; export const plugin: { - applyArabicShaping: Function; + applyArabicShaping: (text: string) => string; processBidirectionalText: ((b: string, a: Array) => Array); processStyledBidirectionalText: ((c: string, b: Array, a: Array) => Array<[string, Array]>); isLoaded: () => boolean; @@ -109,32 +117,32 @@ export const plugin: { processBidirectionalText: null, processStyledBidirectionalText: null, isLoaded() { - return pluginStatus === status.loaded || // Main Thread: loaded if the completion callback returned successfully + return pluginStatus === 'loaded' || // Main Thread: loaded if the completion callback returned successfully plugin.applyArabicShaping != null; // Web-worker: loaded if the plugin functions have been compiled }, isLoading() { // Main Thread Only: query the loading status, this function does not return the correct value in the worker context. - return pluginStatus === status.loading; + return pluginStatus === 'loading'; }, setState(state: PluginState) { // Worker thread only: this tells the worker threads that the plugin is available on the Main thread - if (!isWorker()) throw new Error('Cannot set the state of the rtl-text-plugin when not in the web-worker context'); + if (!isWorker(self)) throw new Error('Cannot set the state of the rtl-text-plugin when not in the web-worker context'); pluginStatus = state.pluginStatus; pluginURL = state.pluginURL; }, isParsed(): boolean { - if (!isWorker()) throw new Error('rtl-text-plugin is only parsed on the worker-threads'); + if (!isWorker(self)) throw new Error('rtl-text-plugin is only parsed on the worker-threads'); return plugin.applyArabicShaping != null && plugin.processBidirectionalText != null && plugin.processStyledBidirectionalText != null; }, getPluginURL(): string { - if (!isWorker()) throw new Error('rtl-text-plugin url can only be queried from the worker threads'); + if (!isWorker(self)) throw new Error('rtl-text-plugin url can only be queried from the worker threads'); return pluginURL; } }; -export const lazyLoadRTLTextPlugin = function() { +export const lazyLoadRTLTextPlugin = () => { if (!plugin.isLoading() && !plugin.isLoaded() && getRTLTextPluginStatus() === 'deferred' diff --git a/src/source/source.ts b/src/source/source.ts index 34c5ce8876..622dddc5ac 100644 --- a/src/source/source.ts +++ b/src/source/source.ts @@ -12,7 +12,6 @@ import type {Event, Evented} from '../util/evented'; import type {Map} from '../ui/map'; import type {Tile} from './tile'; import type {OverscaledTileID, CanonicalTileID} from './tile_id'; -import type {Callback} from '../types/callback'; import type {CanvasSourceSpecification} from '../source/canvas_source'; const registeredSources = {} as {[key:string]: SourceClass}; @@ -33,9 +32,21 @@ export interface Source { * The id for the source. Must not be used by any existing source. */ id: string; + /** + * The minimum zoom level for the source. + */ minzoom: number; + /** + * The maximum zoom level for the source. + */ maxzoom: number; + /** + * The tile size for the source. + */ tileSize: number; + /** + * The attribution for the source. + */ attribution?: string; /** * `true` if zoom levels are rounded to the nearest integer in the source data, `false` if they are floor-ed to the nearest integer. @@ -51,22 +62,60 @@ export interface Source { */ reparseOverscaled?: boolean; vectorLayerIds?: Array; + /** + * True if the source has transiotion, false otherwise. + */ hasTransition(): boolean; + /** + * True if the source is loaded, false otherwise. + */ loaded(): boolean; + /** + * An ability to fire an event to all the listeners, see {@link Evented} + * @param event - The event to fire + */ fire(event: Event): unknown; - readonly onAdd?: (map: Map) => void; - readonly onRemove?: (map: Map) => void; - loadTile(tile: Tile, callback: Callback): void; - readonly hasTile?: (tileID: OverscaledTileID) => boolean; - readonly abortTile?: (tile: Tile, callback: Callback) => void; - readonly unloadTile?: (tile: Tile, callback: Callback) => void; + /** + * This method is called when the source is added to the map. + * @param map - The map instance + */ + onAdd?(map: Map): void; + /** + * This method is called when the source is removed from the map. + * @param map - The map instance + */ + onRemove?(map: Map): void; + /** + * This method does the heavy lifting of loading a tile. + * In most cases it will defer the work to the relevant worker source. + * @param tile - The tile to load + */ + loadTile(tile: Tile): Promise; + /** + * True is the tile is part of the source, false otherwise. + * @param tileID - The tile ID + */ + hasTile?(tileID: OverscaledTileID): boolean; + /** + * Allows to abort a tile loading. + * @param tile - The tile to abort + */ + abortTile?(tile: Tile): Promise; + /** + * Allows to unload a tile. + * @param tile - The tile to unload + */ + unloadTile?(tile: Tile): Promise; /** * @returns A plain (stringifiable) JS object representing the current state of the source. * Creating a source using the returned object as the `options` should result in a Source that is * equivalent to this one. */ serialize(): any; - readonly prepare?: () => void; + /** + * Allows to execute a prepare step before the source is used. + */ + prepare?(): void; } /** @@ -133,7 +182,3 @@ export const getSourceType = (name: string): SourceClass => { export const setSourceType = (name: string, type: SourceClass) => { registeredSources[name] = type; }; - -export interface Actor { - send(type: string, data: any, callback: Callback): void; -} diff --git a/src/source/source_cache.test.ts b/src/source/source_cache.test.ts index 0bf9e8faae..804dee9e2c 100644 --- a/src/source/source_cache.test.ts +++ b/src/source/source_cache.test.ts @@ -1,5 +1,5 @@ import {SourceCache} from './source_cache'; -import {setSourceType} from './source'; +import {Source, setSourceType} from './source'; import {Tile} from './tile'; import {OverscaledTileID} from './tile_id'; import {Transform} from '../geo/transform'; @@ -11,13 +11,16 @@ import {browser} from '../util/browser'; import {Dispatcher} from '../util/dispatcher'; import {Callback} from '../types/callback'; import {TileBounds} from './tile_bounds'; +import {sleep} from '../util/test/util'; -class SourceMock extends Evented { +class SourceMock extends Evented implements Source { id: string; minzoom: number; maxzoom: number; hasTile: (tileID: OverscaledTileID) => boolean; sourceOptions: any; + type: string; + tileSize: number; constructor(id: string, sourceOptions: any, _dispatcher, eventedParent: Evented) { super(); @@ -31,13 +34,18 @@ class SourceMock extends Evented { this.hasTile = sourceOptions.hasTile; } } - loadTile(tile: Tile, callback: Callback) { + loadTile(tile: Tile, callback?: Callback): Promise { if (this.sourceOptions.expires) { tile.setExpiryData({ expires: this.sourceOptions.expires }); } - setTimeout(callback, 0); + if (callback) { + setTimeout(callback, 0); + } else { + return new Promise(resolve => setTimeout(resolve, 0)); + } + } loaded() { return true; @@ -50,9 +58,12 @@ class SourceMock extends Evented { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); } } - abortTile() {} - unloadTile() {} + async abortTile() {} + async unloadTile() {} serialize() {} + hasTransition(): boolean { + return false; + } } // Add a mocked source type for use in these tests @@ -84,13 +95,12 @@ afterEach(() => { describe('SourceCache#addTile', () => { test('loads tile when uncached', done => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); - const sourceCache = createSourceCache({ - loadTile(tile) { - expect(tile.tileID).toEqual(tileID); - expect(tile.uses).toBe(0); - done(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + expect(tile.tileID).toEqual(tileID); + expect(tile.uses).toBe(0); + done(); + }; sourceCache.onAdd(undefined); sourceCache._addTile(tileID); }); @@ -109,17 +119,15 @@ describe('SourceCache#addTile', () => { test('updates feature state on added uncached tile', done => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); let updateFeaturesSpy; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - sourceCache.on('data', () => { - expect(updateFeaturesSpy).toHaveBeenCalledTimes(1); - done(); - }); - updateFeaturesSpy = jest.spyOn(tile, 'setFeatureState'); - tile.state = 'loaded'; - callback(); - } - }); + const sourceCache = createSourceCache({}); + sourceCache._source.loadTile = async (tile) => { + sourceCache.on('data', () => { + expect(updateFeaturesSpy).toHaveBeenCalledTimes(1); + done(); + }); + updateFeaturesSpy = jest.spyOn(tile, 'setFeatureState'); + tile.state = 'loaded'; + }; sourceCache.onAdd(undefined); sourceCache._addTile(tileID); }); @@ -129,13 +137,12 @@ describe('SourceCache#addTile', () => { let load = 0, add = 0; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - load++; - callback(); - } - }).on('dataloading', () => { add++; }); + const sourceCache = createSourceCache({}); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + load++; + }; + sourceCache.on('dataloading', () => { add++; }); const tr = new Transform(); tr.width = 512; @@ -153,12 +160,10 @@ describe('SourceCache#addTile', () => { test('updates feature state on cached tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - callback(); - } - }); + const sourceCache = createSourceCache({}); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; const tr = new Transform(); tr.width = 512; @@ -184,11 +189,10 @@ describe('SourceCache#addTile', () => { sourceCache._setTileReloadTimer = (id) => { sourceCache._timers[id] = setTimeout(() => {}, 0); }; - sourceCache._loadTile = (tile, callback) => { + sourceCache._source.loadTile = async (tile) => { tile.state = 'loaded'; tile.getExpiryTimeout = () => 1000 * 60; sourceCache._setTileReloadTimer(tileID.key, tile); - callback(); }; const tr = new Transform(); @@ -222,13 +226,12 @@ describe('SourceCache#addTile', () => { let load = 0, add = 0; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - load++; - callback(); - } - }).on('dataloading', () => { add++; }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + load++; + }; + sourceCache.on('dataloading', () => { add++; }); const t1 = sourceCache._addTile(tileID); const t2 = sourceCache._addTile(new OverscaledTileID(0, 1, 0, 0, 0)); @@ -276,16 +279,13 @@ describe('SourceCache#removeTile', () => { }); }); - test('caches (does not unload) loaded tile', done => { + test('caches (does not unload) loaded tile', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); - const sourceCache = createSourceCache({ - loadTile(tile) { - tile.state = 'loaded'; - }, - unloadTile() { - done('test failed: unloadTile has been called'); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; + sourceCache._source.unloadTile = jest.fn(); const tr = new Transform(); tr.width = 512; @@ -295,7 +295,7 @@ describe('SourceCache#removeTile', () => { sourceCache._addTile(tileID); sourceCache._removeTile(tileID.key); - done(); + expect(sourceCache._source.unloadTile).not.toHaveBeenCalled(); }); test('aborts and unloads unfinished tile', () => { @@ -303,16 +303,15 @@ describe('SourceCache#removeTile', () => { let abort = 0, unload = 0; - const sourceCache = createSourceCache({ - abortTile(tile) { - expect(tile.tileID).toEqual(tileID); - abort++; - }, - unloadTile(tile) { - expect(tile.tileID).toEqual(tileID); - unload++; - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.abortTile = async (tile) => { + expect(tile.tileID).toEqual(tileID); + abort++; + }; + sourceCache._source.unloadTile = async (tile) => { + expect(tile.tileID).toEqual(tileID); + unload++; + }; sourceCache._addTile(tileID); sourceCache._removeTile(tileID.key); @@ -325,24 +324,21 @@ describe('SourceCache#removeTile', () => { test('_tileLoaded after _removeTile skips tile.added', () => { const tileID = new OverscaledTileID(0, 0, 0, 0, 0); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.added = undefined; - sourceCache._removeTile(tileID.key); - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async () => { + sourceCache._removeTile(tileID.key); + }; sourceCache.map = {painter: {crossTileSymbolIndex: '', tileExtentVAO: {}}} as any; sourceCache._addTile(tileID); }); test('fires dataabort event', async () => { - const sourceCache = createSourceCache({ - loadTile() { - // Do not call back in order to make sure the tile is removed before it is loaded. - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = () => { + // Do not call back in order to make sure the tile is removed before it is loaded. + return new Promise(() => {}); + }; const tileID = new OverscaledTileID(0, 0, 0, 0, 0); const tile = sourceCache._addTile(tileID); const abortPromise = sourceCache.once('dataabort'); @@ -354,12 +350,10 @@ describe('SourceCache#removeTile', () => { }); test('does not fire dataabort event when the tile has already been loaded', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; const tileID = new OverscaledTileID(0, 0, 0, 0, 0); sourceCache._addTile(tileID); const onAbort = jest.fn(); @@ -370,15 +364,13 @@ describe('SourceCache#removeTile', () => { test('does not fire data event when the tile has already been aborted', () => { const onData = jest.fn(); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - sourceCache.once('dataabort', () => { - tile.state = 'loaded'; - callback(); - expect(onData).toHaveBeenCalledTimes(0); - }); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + sourceCache.once('dataabort', () => { + tile.state = 'loaded'; + expect(onData).toHaveBeenCalledTimes(0); + }); + }; sourceCache.once('data', onData); const tileID = new OverscaledTileID(0, 0, 0, 0, 0); sourceCache._addTile(tileID); @@ -437,11 +429,11 @@ describe('SourceCache / Source lifecycle', () => { const transform = new Transform(); transform.resize(511, 511); transform.zoom = 0; - const sourceCache = createSourceCache({ - loadTile (tile, callback) { - callback('error'); - } - }).on('data', (e) => { + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async () => { + throw new Error('Error loading tile'); + }; + sourceCache.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { sourceCache.update(transform); } @@ -488,13 +480,11 @@ describe('SourceCache / Source lifecycle', () => { const expected = [new OverscaledTileID(0, 0, 0, 0, 0).key, new OverscaledTileID(0, 0, 0, 0, 0).key]; expect.assertions(expected.length); - const sourceCache = createSourceCache({ - loadTile (tile, callback) { - expect(tile.tileID.key).toBe(expected.shift()); - tile.loaded = true; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + expect(tile.tileID.key).toBe(expected.shift()); + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'metadata') { @@ -511,14 +501,12 @@ describe('SourceCache / Source lifecycle', () => { transform.resize(511, 511); transform.zoom = 1; - const sourceCache = createSourceCache({ - loadTile (tile, callback) { - // this transform will try to load the four tiles at z1 and a single z0 tile - // we only expect _reloadTile to be called with the 'loaded' z0 tile - tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + // this transform will try to load the four tiles at z1 and a single z0 tile + // we only expect _reloadTile to be called with the 'loaded' z0 tile + tile.state = tile.tileID.canonical.z === 1 ? 'errored' : 'loaded'; + }; const reloadTileSpy = jest.spyOn(sourceCache, '_reloadTile'); sourceCache.on('data', (e) => { @@ -595,12 +583,10 @@ describe('SourceCache#update', () => { transform.resize(511, 511); transform.zoom = 0; - const sourceCache = createSourceCache({ - loadTile: (tile, callback) => { - tile.state = 'loaded'; - callback(null); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -629,12 +615,10 @@ describe('SourceCache#update', () => { transform.resize(511, 511); transform.zoom = 0; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = (tile.tileID.key === new OverscaledTileID(0, 0, 0, 0, 0).key) ? 'loaded' : 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = (tile.tileID.key === new OverscaledTileID(0, 0, 0, 0, 0).key) ? 'loaded' : 'loading'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -663,12 +647,10 @@ describe('SourceCache#update', () => { transform.zoom = 0; transform.center = new LngLat(360, 0); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loaded' : 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = (tile.tileID.key === new OverscaledTileID(0, 1, 0, 0, 0).key) ? 'loaded' : 'loading'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -696,14 +678,12 @@ describe('SourceCache#update', () => { transform.resize(511, 511); transform.zoom = 2; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.timeAdded = Infinity; - tile.state = 'loaded'; - tile.registerFadeDuration(100); - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.timeAdded = Infinity; + tile.state = 'loaded'; + tile.registerFadeDuration(100); + }; (sourceCache._source as any).type = 'raster'; @@ -732,14 +712,12 @@ describe('SourceCache#update', () => { transform.resize(511, 511); transform.zoom = 0; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.timeAdded = Infinity; - tile.state = 'loaded'; - tile.registerFadeDuration(100); - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.timeAdded = Infinity; + tile.state = 'loaded'; + tile.registerFadeDuration(100); + }; (sourceCache._source as any).type = 'raster'; @@ -765,15 +743,12 @@ describe('SourceCache#update', () => { transform.resize(511, 511); transform.zoom = 1; - const sourceCache = createSourceCache({ - + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { // not setting fadeEndTime because class Tile default is 0, and need to be tested - loadTile(tile, callback) { - tile.timeAdded = Date.now(); - tile.state = 'loaded'; - callback(); - } - }); + tile.timeAdded = Date.now(); + tile.state = 'loaded'; + }; (sourceCache._source as any).type = 'raster'; @@ -802,14 +777,12 @@ describe('SourceCache#update', () => { let time = start; jest.spyOn(browser, 'now').mockImplementation(() => time); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.timeAdded = browser.now(); - tile.state = 'loaded'; - tile.fadeEndTime = browser.now() + fadeTime; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.timeAdded = browser.now(); + tile.state = 'loaded'; + tile.fadeEndTime = browser.now() + fadeTime; + }; (sourceCache._source as any).type = 'raster'; @@ -845,13 +818,10 @@ describe('SourceCache#update', () => { // use slightly offset center so that sort order is better defined transform.center = new LngLat(-0.001, 0.001); - const sourceCache = createSourceCache({ - reparseOverscaled: true, - loadTile(tile, callback) { - tile.state = tile.tileID.overscaledZ === 16 ? 'loaded' : 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache({reparseOverscaled: true}); + sourceCache._source.loadTile = async (tile) => { + tile.state = tile.tileID.overscaledZ === 16 ? 'loaded' : 'loading'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -910,12 +880,10 @@ describe('SourceCache#_updateRetainedTiles', () => { test('loads ideal tiles if they exist', () => { const stateCache = {}; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = stateCache[tile.tileID.key] || 'errored'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = stateCache[tile.tileID.key] || 'errored'; + }; const getTileSpy = jest.spyOn(sourceCache, 'getTile'); const idealTile = new OverscaledTileID(1, 0, 1, 1, 1); @@ -926,12 +894,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('retains all loaded children ', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'errored'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + }; const idealTile = new OverscaledTileID(3, 0, 3, 1, 2); sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined); @@ -966,12 +932,10 @@ describe('SourceCache#_updateRetainedTiles', () => { test('adds parent tile if ideal tile errors and no child tiles are loaded', () => { const stateCache = {}; - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = stateCache[tile.tileID.key] || 'errored'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = stateCache[tile.tileID.key] || 'errored'; + }; jest.spyOn(sourceCache, '_addTile'); const getTileSpy = jest.spyOn(sourceCache, 'getTile'); @@ -997,12 +961,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('don\'t use wrong parent tile', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'errored'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + }; const idealTile = new OverscaledTileID(2, 0, 2, 0, 0); sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined); @@ -1031,12 +993,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('use parent tile when ideal tile is not loaded', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTile = new OverscaledTileID(1, 0, 1, 0, 1); const parentTile = new OverscaledTileID(0, 0, 0, 0, 0); sourceCache._tiles[idealTile.key] = new Tile(idealTile, undefined); @@ -1076,12 +1036,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('don\'t load parent if all immediate children are loaded', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTile = new OverscaledTileID(2, 0, 2, 1, 1); const loadedTiles = [new OverscaledTileID(3, 0, 3, 2, 2), new OverscaledTileID(3, 0, 3, 3, 2), new OverscaledTileID(3, 0, 3, 2, 3), new OverscaledTileID(3, 0, 3, 3, 3)]; @@ -1098,12 +1056,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('prefer loaded child tiles to parent tiles', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTile = new OverscaledTileID(1, 0, 1, 0, 0); const loadedTiles = [new OverscaledTileID(0, 0, 0, 0, 0), new OverscaledTileID(2, 0, 2, 0, 0)]; loadedTiles.forEach(t => { @@ -1144,13 +1100,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('don\'t use tiles below minzoom', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - }, - minzoom: 2 - }); + const sourceCache = createSourceCache({minzoom: 2}); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTile = new OverscaledTileID(2, 0, 2, 0, 0); const loadedTiles = [new OverscaledTileID(1, 0, 1, 0, 0)]; loadedTiles.forEach(t => { @@ -1161,6 +1114,8 @@ describe('SourceCache#_updateRetainedTiles', () => { const getTileSpy = jest.spyOn(sourceCache, 'getTile'); const retained = sourceCache._updateRetainedTiles([idealTile], 2); + sleep(10); + expect(getTileSpy.mock.calls.map((c) => { return c[0]; })).toEqual([]); expect(retained).toEqual({ @@ -1171,13 +1126,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('use overzoomed tile above maxzoom', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - }, - maxzoom: 2 - }); + const sourceCache = createSourceCache({maxzoom: 2}); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTile = new OverscaledTileID(2, 0, 2, 0, 0); const getTileSpy = jest.spyOn(sourceCache, 'getTile'); @@ -1199,12 +1151,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('dont\'t ascend multiple times if a tile is not found', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const idealTiles = [new OverscaledTileID(8, 0, 8, 0, 0), new OverscaledTileID(8, 0, 8, 1, 0)]; const getTileSpy = jest.spyOn(sourceCache, 'getTile'); @@ -1241,12 +1191,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('Only retain loaded parent tile when zooming in', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; let idealTiles = [new OverscaledTileID(9, 0, 9, 0, 0), new OverscaledTileID(9, 0, 9, 1, 0)]; let retained = sourceCache._updateRetainedTiles(idealTiles, 9); @@ -1280,12 +1228,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('Only retain loaded child tile when zooming out', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; let idealTiles = [new OverscaledTileID(7, 0, 7, 0, 0), new OverscaledTileID(7, 0, 7, 1, 0)]; let retained = sourceCache._updateRetainedTiles(idealTiles, 7); @@ -1319,13 +1265,10 @@ describe('SourceCache#_updateRetainedTiles', () => { }); test('adds correct loaded parent tiles for overzoomed tiles', () => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loading'; - callback(); - }, - maxzoom: 7 - }); + const sourceCache = createSourceCache({maxzoom: 7}); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + }; const loadedTiles = [new OverscaledTileID(7, 0, 7, 0, 0), new OverscaledTileID(7, 0, 7, 1, 0)]; loadedTiles.forEach(t => { sourceCache._tiles[t.key] = new Tile(t, undefined); @@ -1352,16 +1295,15 @@ describe('SourceCache#clearTiles', () => { let abort = 0, unload = 0; - const sourceCache = createSourceCache({ - abortTile(tile) { - expect(tile.tileID).toEqual(coord); - abort++; - }, - unloadTile(tile) { - expect(tile.tileID).toEqual(coord); - unload++; - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.abortTile = async (tile) => { + expect(tile.tileID).toEqual(coord); + abort++; + }; + sourceCache._source.unloadTile = async (tile) => { + expect(tile.tileID).toEqual(coord); + unload++; + }; sourceCache.onAdd(undefined); sourceCache._addTile(coord); @@ -1401,13 +1343,10 @@ describe('SourceCache#tilesIn', () => { transform.zoom = 1; transform.center = new LngLat(0, 1); - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - tile.additionalRadius = 0; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1447,16 +1386,14 @@ describe('SourceCache#tilesIn', () => { test('reparsed overscaled tiles', () => { const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - tile.additionalRadius = 0; - callback(); - }, reparseOverscaled: true, minzoom: 1, maxzoom: 1, tileSize: 512 }); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1498,12 +1435,14 @@ describe('SourceCache#tilesIn', () => { test('overscaled tiles', done => { const sourceCache = createSourceCache({ - loadTile(tile, callback) { tile.state = 'loaded'; callback(); }, reparseOverscaled: false, minzoom: 1, maxzoom: 1, tileSize: 512 }); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1521,12 +1460,10 @@ describe('SourceCache#tilesIn', () => { describe('source cache loaded', () => { test('SourceCache#loaded (no errors)', done => { - const sourceCache = createSourceCache({ - loadTile(tile, callback) { - tile.state = 'loaded'; - callback(); - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loaded'; + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1545,11 +1482,11 @@ describe('source cache loaded', () => { }); test('SourceCache#loaded (with errors)', done => { - const sourceCache = createSourceCache({ - loadTile(tile) { - tile.state = 'errored'; - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + throw new Error('Error'); + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1568,11 +1505,11 @@ describe('source cache loaded', () => { }); test('SourceCache#loaded (unused)', done => { - const sourceCache = createSourceCache({ - loadTile(tile) { - tile.state = 'errored'; - } - }, false); + const sourceCache = createSourceCache(undefined, false); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + throw new Error('Error'); + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1584,11 +1521,11 @@ describe('source cache loaded', () => { }); test('SourceCache#loaded (unusedForTerrain)', done => { - const sourceCache = createSourceCache({ - loadTile(tile) { - tile.state = 'errored'; - } - }, false); + const sourceCache = createSourceCache(undefined, false); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + throw new Error('Error'); + }; sourceCache.usedForTerrain = false; sourceCache.on('data', (e) => { @@ -1601,11 +1538,11 @@ describe('source cache loaded', () => { }); test('SourceCache#loaded (not loaded when no update)', done => { - const sourceCache = createSourceCache({ - loadTile(tile) { - tile.state = 'errored'; - } - }); + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'errored'; + throw new Error('Error'); + }; sourceCache.on('data', (e) => { if (e.sourceDataType === 'metadata') { @@ -1617,18 +1554,19 @@ describe('source cache loaded', () => { }); test('SourceCache#loaded (on last tile load)', done => { - const sourceCache = createSourceCache({ - hasTile(tileID: OverscaledTileID) { - return !this.tileBounds || this.tileBounds.contains(tileID.canonical); - }, - loadTile(tile, callback) { - tile.state = 'loading'; + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + return new Promise((resolve) => { setTimeout(() => { tile.state = 'loaded'; - callback(); + resolve(); }); - } - }); + }); + }; + sourceCache._source.hasTile = function (tileID: OverscaledTileID) { + return !this.tileBounds || this.tileBounds.contains(tileID.canonical); + }; const tr = new Transform(); tr.zoom = 10; @@ -1654,27 +1592,28 @@ describe('source cache loaded', () => { test('SourceCache#loaded (tiles outside bounds, idle)', done => { const japan = new TileBounds([122.74, 19.33, 149.0, 45.67]); - const sourceCache = createSourceCache({ - onAdd() { - if (this.sourceOptions.noLoad) return; - if (this.sourceOptions.error) { - this.fire(new ErrorEvent(this.sourceOptions.error)); - } else { - this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); - this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); - } - }, - hasTile(tileID: OverscaledTileID) { - return japan.contains(tileID.canonical); - }, - loadTile(tile, callback) { - tile.state = 'loading'; + const sourceCache = createSourceCache(); + sourceCache._source.loadTile = async (tile) => { + tile.state = 'loading'; + return new Promise((resolve) => { setTimeout(() => { tile.state = 'loaded'; - callback(); + resolve(); }); + }); + }; + sourceCache._source.onAdd = function() { + if (this.sourceOptions.noLoad) return; + if (this.sourceOptions.error) { + this.fire(new ErrorEvent(this.sourceOptions.error)); + } else { + this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); + this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); } - }); + }; + sourceCache._source.hasTile = (tileID: OverscaledTileID) => { + return japan.contains(tileID.canonical); + }; sourceCache.on('data', (e) => { if (e.sourceDataType !== 'idle') { @@ -1842,7 +1781,7 @@ describe('SourceCache reloads expiring tiles', () => { expiryDate.setMilliseconds(expiryDate.getMilliseconds() + 50); const sourceCache = createSourceCache({expires: expiryDate}); - sourceCache._reloadTile = (id, state) => { + sourceCache._reloadTile = async (id, state) => { expect(state).toBe('expired'); done(); }; diff --git a/src/source/source_cache.ts b/src/source/source_cache.ts index 8a6d997fb4..d10a1ee640 100644 --- a/src/source/source_cache.ts +++ b/src/source/source_cache.ts @@ -18,7 +18,6 @@ import type {Style} from '../style/style'; import type {Dispatcher} from '../util/dispatcher'; import type {Transform} from '../geo/transform'; import type {TileState} from './tile'; -import type {Callback} from '../types/callback'; import type {SourceSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {MapSourceDataEvent} from '../ui/events'; import {Terrain} from '../render/terrain'; @@ -105,7 +104,7 @@ export class SourceCache extends Evented { this._source = createSource(id, options, dispatcher, this); this._tiles = {}; - this._cache = new TileCache(0, this._unloadTile.bind(this)); + this._cache = new TileCache(0, (tile) => this._unloadTile(tile)); this._timers = {}; this._cacheTimers = {}; this._maxTileCacheSize = null; @@ -171,18 +170,29 @@ export class SourceCache extends Evented { if (this.transform) this.update(this.transform, this.terrain); } - _loadTile(tile: Tile, callback: Callback) { - return this._source.loadTile(tile, callback); + async _loadTile(tile: Tile, id: string, state: TileState): Promise { + try { + await this._source.loadTile(tile); + this._tileLoaded(tile, id, state); + } catch (err) { + tile.state = 'errored'; + if ((err as any).status !== 404) { + this._source.fire(new ErrorEvent(err, {tile})); + } else { + // continue to try loading parent/children tiles if a tile doesn't exist (404) + this.update(this.transform, this.terrain); + } + } } _unloadTile(tile: Tile) { if (this._source.unloadTile) - return this._source.unloadTile(tile, () => {}); + this._source.unloadTile(tile); } _abortTile(tile: Tile) { if (this._source.abortTile) - this._source.abortTile(tile, () => {}); + this._source.abortTile(tile); this._source.fire(new Event('dataabort', {tile, coord: tile.tileID, dataType: 'source'})); } @@ -254,7 +264,7 @@ export class SourceCache extends Evented { } } - _reloadTile(id: string, state: TileState) { + async _reloadTile(id: string, state: TileState) { const tile = this._tiles[id]; // this potentially does not address all underlying @@ -269,19 +279,10 @@ export class SourceCache extends Evented { if (tile.state !== 'loading') { tile.state = state; } - - this._loadTile(tile, this._tileLoaded.bind(this, tile, id, state)); + await this._loadTile(tile, id, state); } - _tileLoaded(tile: Tile, id: string, previousState: TileState, err?: Error | null) { - if (err) { - tile.state = 'errored'; - if ((err as any).status !== 404) this._source.fire(new ErrorEvent(err, {tile})); - // continue to try loading parent/children tiles if a tile doesn't exist (404) - else this.update(this.transform, this.terrain); - return; - } - + _tileLoaded(tile: Tile, id: string, previousState: TileState) { tile.timeAdded = browser.now(); if (previousState === 'expired') tile.refreshedUponExpiration = true; this._setTileReloadTimer(id, tile); @@ -814,7 +815,7 @@ export class SourceCache extends Evented { if (!tile) { tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor()); - this._loadTile(tile, this._tileLoaded.bind(this, tile, tileID.key, tile.state)); + this._loadTile(tile, tileID.key, tile.state); } tile.uses++; diff --git a/src/source/tile.ts b/src/source/tile.ts index cc180ec561..32c0a6b8df 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'; @@ -47,7 +46,6 @@ import {ExpiryData} from '../util/ajax'; export type TileState = 'loading' | 'loaded' | 'reloading' | 'unloaded' | 'errored' | 'expired'; /** - * @internal * A tile object is the combination of a Coordinate, which defines * its place, as well as a unique ID and data tracking for its content */ @@ -81,12 +79,12 @@ export class Tile { aborted: boolean; needsHillshadePrepare: boolean; needsTerrainPrepare: boolean; - request: Cancelable; + abortController: AbortController; texture: any; fbo: Framebuffer; demTexture: Texture; refreshedUponExpiration: boolean; - reloadCallback: any; + reloadPromise: {resolve: () => void; reject: () => void}; resourceTiming: Array; queryPadding: number; diff --git a/src/source/vector_tile_source.test.ts b/src/source/vector_tile_source.test.ts index d2ff756f8f..f955ba1831 100644 --- a/src/source/vector_tile_source.test.ts +++ b/src/source/vector_tile_source.test.ts @@ -6,8 +6,9 @@ import {OverscaledTileID} from './tile_id'; import {Evented} from '../util/evented'; import {RequestManager} from '../util/request_manager'; import fixturesSource from '../../test/unit/assets/source.json' assert {type: 'json'}; -import {getMockDispatcher, getWrapDispatcher} from '../util/test/util'; +import {getMockDispatcher, getWrapDispatcher, sleep, waitForMetadataEvent} from '../util/test/util'; import {Map} from '../ui/map'; +import {WorkerTileParameters} from './worker_source'; function createSource(options, transformCallback?, clearTiles = () => {}) { const source = new VectorTileSource('id', options, getMockDispatcher(), options.eventedParent); @@ -19,9 +20,7 @@ function createSource(options, transformCallback?, clearTiles = () => {}) { getPixelRatio() { return 1; } } as any as Map); - source.on('error', (e) => { - throw e.error; - }); + source.on('error', () => { }); // to prevent console log of errors return source; } @@ -37,7 +36,7 @@ describe('VectorTileSource', () => { server.restore(); }); - test('can be constructed from TileJSON', done => { + test('can be constructed from TileJSON', async () => { const source = createSource({ minzoom: 1, maxzoom: 10, @@ -45,33 +44,26 @@ describe('VectorTileSource', () => { tiles: ['http://example.com/{z}/{x}/{y}.png'] }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']); - expect(source.minzoom).toBe(1); - expect(source.maxzoom).toBe(10); - expect((source as Source).attribution).toBe('MapLibre'); - done(); - } - }); + await waitForMetadataEvent(source); + expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']); + expect(source.minzoom).toBe(1); + expect(source.maxzoom).toBe(10); + expect((source as Source).attribution).toBe('MapLibre'); }); - test('can be constructed from a TileJSON URL', done => { + test('can be constructed from a TileJSON URL', async () => { server.respondWith('/source.json', JSON.stringify(fixturesSource)); const source = createSource({url: '/source.json'}); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']); - expect(source.minzoom).toBe(1); - expect(source.maxzoom).toBe(10); - expect((source as Source).attribution).toBe('MapLibre'); - done(); - } - }); - + const promise = waitForMetadataEvent(source); server.respond(); + + await promise; + expect(source.tiles).toEqual(['http://example.com/{z}/{x}/{y}.png']); + expect(source.minzoom).toBe(1); + expect(source.maxzoom).toBe(10); + expect((source as Source).attribution).toBe('MapLibre'); }); test('transforms the request for TileJSON URL', () => { @@ -94,7 +86,7 @@ describe('VectorTileSource', () => { server.respond(); }); - test('fires "dataloading" event', done => { + test('fires "dataloading" event', async () => { server.respondWith('/source.json', JSON.stringify(fixturesSource)); const evented = new Evented(); let dataloadingFired = false; @@ -102,13 +94,11 @@ describe('VectorTileSource', () => { dataloadingFired = true; }); const source = createSource({url: '/source.json', eventedParent: evented}); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - if (!dataloadingFired) done('test failed: dataloading not fired'); - done(); - } - }); + const promise = waitForMetadataEvent(source); server.respond(); + + await promise; + expect(dataloadingFired).toBeTruthy(); }); test('serialize URL', () => { @@ -138,7 +128,7 @@ describe('VectorTileSource', () => { }); function testScheme(scheme, expectedURL) { - test(`scheme "${scheme}"`, done => { + test(`scheme "${scheme}"`, async () => { const source = createSource({ minzoom: 1, maxzoom: 10, @@ -147,85 +137,150 @@ describe('VectorTileSource', () => { scheme }); + let receivedMessage = null; + source.dispatcher = getWrapDispatcher()({ - send(type, params) { - expect(type).toBe('loadTile'); - expect(expectedURL).toBe(params.request.url); - done(); + sendAsync(message) { + receivedMessage = message; + return Promise.resolve({}); } }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') source.loadTile({ - tileID: new OverscaledTileID(10, 0, 10, 5, 5) - } as any as Tile, () => {}); - }); + await waitForMetadataEvent(source); + await source.loadTile({ + loadVectorData() {}, + tileID: new OverscaledTileID(10, 0, 10, 5, 5) + } as any as Tile); + + expect(receivedMessage.type).toBe('loadTile'); + expect(expectedURL).toBe((receivedMessage.data as WorkerTileParameters).request.url); }); } testScheme('xyz', 'http://example.com/10/5/5.png'); testScheme('tms', 'http://example.com/10/5/1018.png'); - test('transforms tile urls before requesting', done => { + test('transforms tile urls before requesting', async () => { server.respondWith('/source.json', JSON.stringify(fixturesSource)); const source = createSource({url: '/source.json'}); const transformSpy = jest.spyOn(source.map._requestManager, 'transformRequest'); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - 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'); - done(); + const promise = waitForMetadataEvent(source); + server.respond(); + await promise; + + const tile = { + 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'); + }); + + test('loads a tile even in case of 404', async () => { + server.respondWith('/source.json', JSON.stringify(fixturesSource)); + + const source = createSource({url: '/source.json'}); + source.dispatcher = getWrapDispatcher()({ + sendAsync(_message) { + const error = new Error(); + (error as any).status = 404; + return Promise.reject(error); + } + }); + const promise = waitForMetadataEvent(source); + server.respond(); + await promise; + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData: jest.fn(), + setExpiryData() {} + } as any as Tile; + await source.loadTile(tile); + expect(tile.loadVectorData).toHaveBeenCalledTimes(1); + }); + + test('does not load a tile in case of error', async () => { + server.respondWith('/source.json', JSON.stringify(fixturesSource)); + + const source = createSource({url: '/source.json'}); + source.dispatcher = getWrapDispatcher()({ + async sendAsync(_message) { + throw new Error('Error'); } }); + const promise = waitForMetadataEvent(source); + server.respond(); + await promise; + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData: jest.fn(), + setExpiryData() {} + } as any as Tile; + await expect(source.loadTile(tile)).rejects.toThrow('Error'); + expect(tile.loadVectorData).toHaveBeenCalledTimes(0); + }); + + test('loads an empty tile received from worker', async () => { + server.respondWith('/source.json', JSON.stringify(fixturesSource)); + const source = createSource({url: '/source.json'}); + source.dispatcher = getWrapDispatcher()({ + sendAsync(_message) { + return Promise.resolve(null); + } + }); + + const promise = waitForMetadataEvent(source); server.respond(); + await promise; + + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData: jest.fn(), + setExpiryData() {} + } as any as Tile; + await source.loadTile(tile); + expect(tile.loadVectorData).toHaveBeenCalledTimes(1); }); - test('reloads a loading tile properly', done => { + test('reloads a loading tile properly', async () => { const source = createSource({ tiles: ['http://example.com/{z}/{x}/{y}.png'] }); const events = []; source.dispatcher = getWrapDispatcher()({ - send(type, params, cb) { - events.push(type); - if (cb) setTimeout(cb, 0); - return 1; + sendAsync(message) { + events.push(message.type); + return Promise.resolve({}); } }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - tileID: new OverscaledTileID(10, 0, 10, 5, 5), - state: 'loading', - loadVectorData () { - this.state = 'loaded'; - events.push('tileLoaded'); - }, - setExpiryData() {} - } as any as Tile; - source.loadTile(tile, () => {}); - expect(tile.state).toBe('loading'); - source.loadTile(tile, () => { - expect(events).toEqual( - ['loadTile', 'tileLoaded', 'reloadTile', 'tileLoaded'] - ); - done(); - }); - } - }); + await waitForMetadataEvent(source); + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData () { + this.state = 'loaded'; + events.push('tileLoaded'); + }, + setExpiryData() {} + } as any as Tile; + const initialLoadPromise = source.loadTile(tile); + + expect(tile.state).toBe('loading'); + await source.loadTile(tile); + expect(events).toEqual(['loadTile', 'tileLoaded', 'reloadTile', 'tileLoaded']); + await expect(initialLoadPromise).resolves.toBeUndefined(); }); - test('respects TileJSON.bounds', done => { + test('respects TileJSON.bounds', async () => { const source = createSource({ minzoom: 0, maxzoom: 22, @@ -233,16 +288,13 @@ describe('VectorTileSource', () => { tiles: ['http://example.com/{z}/{x}/{y}.png'], bounds: [-47, -7, -45, -5] }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy(); - expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy(); - done(); - } - }); + await waitForMetadataEvent(source); + + expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy(); + expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy(); }); - test('does not error on invalid bounds', done => { + test('does not error on invalid bounds', async () => { const source = createSource({ minzoom: 0, maxzoom: 22, @@ -251,15 +303,11 @@ describe('VectorTileSource', () => { bounds: [-47, -7, -45, 91] }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - expect(source.tileBounds.bounds).toEqual({_sw: {lng: -47, lat: -7}, _ne: {lng: -45, lat: 90}}); - done(); - } - }); + await waitForMetadataEvent(source); + expect(source.tileBounds.bounds).toEqual({_sw: {lng: -47, lat: -7}, _ne: {lng: -45, lat: 90}}); }); - test('respects TileJSON.bounds when loaded from TileJSON', done => { + test('respects TileJSON.bounds when loaded from TileJSON', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, maxzoom: 22, @@ -269,51 +317,47 @@ describe('VectorTileSource', () => { })); const source = createSource({url: '/source.json'}); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy(); - expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy(); - done(); - } - }); + const promise = waitForMetadataEvent(source); server.respond(); + + await promise; + expect(source.hasTile(new OverscaledTileID(8, 0, 8, 96, 132))).toBeFalsy(); + expect(source.hasTile(new OverscaledTileID(8, 0, 8, 95, 132))).toBeTruthy(); }); - test('respects collectResourceTiming parameter on source', done => { + test('respects collectResourceTiming parameter on source', async () => { const source = createSource({ tiles: ['http://example.com/{z}/{x}/{y}.png'], collectResourceTiming: true }); + let receivedMessage = null; source.dispatcher = getWrapDispatcher()({ - send(type, params, cb) { - expect(params.request.collectResourceTiming).toBeTruthy(); - setTimeout(cb, 0); - done(); + sendAsync(message) { + receivedMessage = message; // do nothing for cache size check dispatch source.dispatcher = getMockDispatcher(); - return 1; + return Promise.resolve({}); } }); - source.on('data', (e) => { - if (e.sourceDataType === 'metadata') { - const tile = { - tileID: new OverscaledTileID(10, 0, 10, 5, 5), - state: 'loading', - loadVectorData () {}, - setExpiryData() {} - } as any as Tile; - source.loadTile(tile, () => {}); - } - }); + await waitForMetadataEvent(source); + const tile = { + 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(); }); test('cancels TileJSON request if removed', () => { const source = createSource({url: '/source.json'}); source.onRemove(); - expect((server as any).lastRequest.aborted).toBe(true); + expect((server.lastRequest as any).aborted).toBe(true); }); test('supports url property updates', () => { @@ -349,6 +393,7 @@ describe('VectorTileSource', () => { const source = createSource({tiles: ['http://example.com/{z}/{x}/{y}.pbf']}, undefined, clearTiles); source.setTiles(['http://example2.com/{z}/{x}/{y}.pbf']); expect(clearTiles.mock.calls).toHaveLength(0); + await sleep(0); await source.once('data'); expect(clearTiles.mock.calls).toHaveLength(1); }); diff --git a/src/source/vector_tile_source.ts b/src/source/vector_tile_source.ts index 8cdb19c349..326f1a7256 100644 --- a/src/source/vector_tile_source.ts +++ b/src/source/vector_tile_source.ts @@ -10,9 +10,8 @@ import type {OverscaledTileID} from './tile_id'; import type {Map} from '../ui/map'; import type {Dispatcher} from '../util/dispatcher'; import type {Tile} from './tile'; -import type {Callback} from '../types/callback'; -import type {Cancelable} from '../types/cancelable'; import type {VectorSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {WorkerTileResult} from './worker_source'; export type VectorTileSourceOptions = VectorSourceSpecification & { collectResourceTiming?: boolean; @@ -72,7 +71,7 @@ export class VectorTileSource extends Evented implements Source { tileBounds: TileBounds; reparseOverscaled: boolean; isTileClipped: boolean; - _tileJSONRequest: Cancelable; + _tileJSONRequest: AbortController; _loaded: boolean; constructor(id: string, options: VectorTileSourceOptions, dispatcher: Dispatcher, eventedParent: Evented) { @@ -101,16 +100,16 @@ export class VectorTileSource extends Evented implements Source { this.setEventedParent(eventedParent); } - load = () => { + async load() { this._loaded = false; this.fire(new Event('dataloading', {dataType: 'source'})); - this._tileJSONRequest = loadTileJson(this._options, this.map._requestManager, (err, tileJSON) => { + this._tileJSONRequest = new AbortController(); + try { + const tileJSON = await loadTileJson(this._options, this.map._requestManager, this._tileJSONRequest); this._tileJSONRequest = null; this._loaded = true; this.map.style.sourceCaches[this.id].clearTiles(); - if (err) { - this.fire(new ErrorEvent(err)); - } else if (tileJSON) { + if (tileJSON) { extend(this, tileJSON); if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom); @@ -120,8 +119,11 @@ export class VectorTileSource extends Evented implements Source { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'})); this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'})); } - }); - }; + } catch (err) { + this._tileJSONRequest = null; + this.fire(new ErrorEvent(err)); + } + } loaded(): boolean { return this._loaded; @@ -138,7 +140,7 @@ export class VectorTileSource extends Evented implements Source { setSourceProperty(callback: Function) { if (this._tileJSONRequest) { - this._tileJSONRequest.cancel(); + this._tileJSONRequest.abort(); } callback(); @@ -177,16 +179,16 @@ export class VectorTileSource extends Evented implements Source { onRemove() { if (this._tileJSONRequest) { - this._tileJSONRequest.cancel(); + this._tileJSONRequest.abort(); this._tileJSONRequest = null; } } - serialize = (): VectorSourceSpecification => { + serialize(): VectorSourceSpecification { return extend({}, this._options); - }; + } - loadTile(tile: Tile, callback: Callback) { + async loadTile(tile: Tile): Promise { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); const params = { request: this.map._requestManager.transformRequest(url, ResourceType.Tile), @@ -201,56 +203,68 @@ export class VectorTileSource extends Evented implements Source { promoteId: this.promoteId }; params.request.collectResourceTiming = this._collectResourceTiming; - + let messageType: 'loadTile' | 'reloadTile' = 'reloadTile'; if (!tile.actor || tile.state === 'expired') { tile.actor = this.dispatcher.getActor(); - tile.request = tile.actor.send('loadTile', params, done.bind(this)); + messageType = 'loadTile'; } else if (tile.state === 'loading') { - // schedule tile reloading after it has been loaded - tile.reloadCallback = callback; - } else { - tile.request = tile.actor.send('reloadTile', params, done.bind(this)); + return new Promise((resolve, reject) => { + tile.reloadPromise = {resolve, reject}; + }); } + tile.abortController = new AbortController(); + try { + const data = await tile.actor.sendAsync({type: messageType, data: params}, tile.abortController); + delete tile.abortController; - function done(err, data) { - delete tile.request; - - if (tile.aborted) - return callback(null); + if (tile.aborted) { + return; + } + this._afterTileLoadWorkerResponse(tile, data); + } catch (err) { + delete tile.abortController; + if (tile.aborted) { + return; + } if (err && err.status !== 404) { - return callback(err); + throw err; } + this._afterTileLoadWorkerResponse(tile, null); + } + } - if (data && data.resourceTiming) - tile.resourceTiming = data.resourceTiming; - - if (this.map._refreshExpiredTiles && data) tile.setExpiryData(data); - tile.loadVectorData(data, this.map.painter); + private _afterTileLoadWorkerResponse(tile: Tile, data: WorkerTileResult) { + if (data && data.resourceTiming) { + tile.resourceTiming = data.resourceTiming; + } - callback(null); + if (data && this.map._refreshExpiredTiles) { + tile.setExpiryData(data); + } + tile.loadVectorData(data, this.map.painter); - if (tile.reloadCallback) { - this.loadTile(tile, tile.reloadCallback); - tile.reloadCallback = null; - } + if (tile.reloadPromise) { + const reloadPromise = tile.reloadPromise; + tile.reloadPromise = null; + this.loadTile(tile).then(reloadPromise.resolve).catch(reloadPromise.reject); } } - abortTile(tile: Tile) { - if (tile.request) { - tile.request.cancel(); - delete tile.request; + async abortTile(tile: Tile): Promise { + if (tile.abortController) { + tile.abortController.abort(); + delete tile.abortController; } if (tile.actor) { - tile.actor.send('abortTile', {uid: tile.uid, type: this.type, source: this.id}, undefined); + await tile.actor.sendAsync({type: 'abortTile', data: {uid: tile.uid, type: this.type, source: this.id}}); } } - unloadTile(tile: Tile) { + async unloadTile(tile: Tile): Promise { tile.unloadVectorData(); if (tile.actor) { - tile.actor.send('removeTile', {uid: tile.uid, type: this.type, source: this.id}, undefined); + await tile.actor.sendAsync({type: 'removeTile', data: {uid: tile.uid, type: this.type, source: this.id}}); } } diff --git a/src/source/vector_tile_worker_source.test.ts b/src/source/vector_tile_worker_source.test.ts index 73c82201f4..60f4aefb90 100644 --- a/src/source/vector_tile_worker_source.test.ts +++ b/src/source/vector_tile_worker_source.test.ts @@ -2,16 +2,17 @@ import fs from 'fs'; import path from 'path'; import vt from '@mapbox/vector-tile'; import Protobuf from 'pbf'; -import {VectorTileWorkerSource} from '../source/vector_tile_worker_source'; +import {LoadVectorData, VectorTileWorkerSource} from '../source/vector_tile_worker_source'; import {StyleLayerIndex} from '../style/style_layer_index'; import {fakeServer, type FakeServer} from 'nise'; -import {Actor} from '../util/actor'; +import {IActor} from '../util/actor'; import {TileParameters, WorkerTileParameters, WorkerTileResult} from './worker_source'; import {WorkerTile} from './worker_tile'; -import {setPerformance} from '../util/test/util'; +import {setPerformance, sleep} from '../util/test/util'; +import {ABORT_ERROR} from '../util/abort_error'; describe('vector tile worker source', () => { - const actor = {send: () => {}} as any as Actor; + const actor = {sendAsync: () => Promise.resolve({})} as IActor; let server: FakeServer; beforeEach(() => { @@ -24,51 +25,45 @@ describe('vector tile worker source', () => { server.restore(); jest.clearAllMocks(); }); - test('VectorTileWorkerSource#abortTile aborts pending request', () => { + test('VectorTileWorkerSource#abortTile aborts pending request', async () => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); - source.loadTile({ + const loadPromise = source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/abort'} - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeFalsy(); - }); + } as any as WorkerTileParameters); - source.abortTile({ + const abortPromise = source.abortTile({ source: 'source', uid: 0 - } as any as TileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeFalsy(); - }); + } as any as TileParameters); expect(source.loading).toEqual({}); + await expect(abortPromise).resolves.toBeFalsy(); + await expect(loadPromise).rejects.toThrow(ABORT_ERROR); }); - test('VectorTileWorkerSource#removeTile removes loaded tile', () => { + test('VectorTileWorkerSource#removeTile removes loaded tile', async () => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); source.loaded = { '0': {} as WorkerTile }; - source.removeTile({ + const res = await source.removeTile({ source: 'source', uid: 0 - } as any as TileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeFalsy(); - }); + } as any as TileParameters); + expect(res).toBeUndefined(); expect(source.loaded).toEqual({}); }); - test('VectorTileWorkerSource#reloadTile reloads a previously-loaded tile', () => { + test('VectorTileWorkerSource#reloadTile reloads a previously-loaded tile', async () => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); - const parse = jest.fn(); + const parse = jest.fn().mockReturnValue(Promise.resolve({} as WorkerTileResult)); source.loaded = { '0': { @@ -78,18 +73,15 @@ describe('vector tile worker source', () => { } as any as WorkerTile }; - const callback = jest.fn(); - source.reloadTile({uid: 0} as any as WorkerTileParameters, callback); + const reloadPromise = source.reloadTile({uid: 0} as any as WorkerTileParameters); expect(parse).toHaveBeenCalledTimes(1); - - parse.mock.calls[0][4](); - expect(callback).toHaveBeenCalledTimes(1); + await expect(reloadPromise).resolves.toBeTruthy(); }); - test('VectorTileWorkerSource#loadTile reparses tile if the reloadTile has been called during parsing', (done) => { + test('VectorTileWorkerSource#loadTile reparses tile if the reloadTile has been called during parsing', async () => { const rawTileData = new Uint8Array([]); - function loadVectorData(params, callback) { - return callback(null, { + const loadVectorData: LoadVectorData = async (_params, _abortController) => { + return { vectorTile: { layers: { test: { @@ -112,8 +104,8 @@ describe('vector tile worker source', () => { } } as any as vt.VectorTile, rawData: rawTileData - }); - } + }; + }; const layerIndex = new StyleLayerIndex([{ id: 'test', @@ -127,52 +119,51 @@ describe('vector tile worker source', () => { } }]); - const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => { - const res = setTimeout(() => callback(null, - type === 'getImages' ? - {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : - {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}} - )); - - return { - cancel: () => clearTimeout(res) - }; - }); - const actor = { - send - } as unknown as Actor; - const source = new VectorTileWorkerSource(actor, layerIndex, ['hello'], loadVectorData); + sendAsync: (message: {type: string; data: unknown}, abortController: AbortController) => { + return new Promise((resolve, _reject) => { + const res = setTimeout(() => { + const response = message.type === 'getImages' ? + {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : + {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}; + resolve(response); + }, 100); + abortController.signal.addEventListener('abort', () => { + clearTimeout(res); + }); + }); + } + }; + const source = new VectorTileWorkerSource(actor, layerIndex, ['hello']); + source.loadVectorTile = loadVectorData; source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf'} - } as any as WorkerTileParameters, () => { - done.fail('should not be called'); - }); + } as any as WorkerTileParameters).then(() => expect(false).toBeTruthy()); - source.reloadTile({ + // allow promise to run + await sleep(0); + + const res = await source.reloadTile({ source: 'source', - uid: '0', + uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeDefined(); - expect(res.rawTileData).toBeDefined(); - expect(res.rawTileData).toStrictEqual(rawTileData); - done(); - }); + } as any as WorkerTileParameters); + expect(res).toBeDefined(); + expect(res.rawTileData).toBeDefined(); + expect(res.rawTileData).toStrictEqual(rawTileData); }); - test('VectorTileWorkerSource#loadTile reparses tile if reloadTile is called during reparsing', (done) => { + test('VectorTileWorkerSource#loadTile reparses tile if reloadTile is called during reparsing', async () => { const rawTileData = new Uint8Array([]); - function loadVectorData(params, callback) { - return callback(null, { + const loadVectorData: LoadVectorData = async (_params, _abortController) => { + return { vectorTile: new vt.VectorTile(new Protobuf(rawTileData)), rawData: rawTileData - }); - } + }; + }; const layerIndex = new StyleLayerIndex([{ id: 'test', @@ -181,41 +172,39 @@ describe('vector tile worker source', () => { type: 'fill' }]); - const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData); + const source = new VectorTileWorkerSource(actor, layerIndex, []); + source.loadVectorTile = loadVectorData; const parseWorkerTileMock = jest .spyOn(WorkerTile.prototype, 'parse') - .mockImplementation(function(data, layerIndex, availableImages, actor, callback) { + .mockImplementation(function(_data, _layerIndex, _availableImages, _actor) { this.status = 'parsing'; - window.setTimeout(() => callback(null, {} as WorkerTileResult), 10); + return new Promise((resolve) => { + setTimeout(() => resolve({} as WorkerTileResult), 20); + }); }); - let loadCallbackCalled = false; - source.loadTile({ + const loadPromise = source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf'} - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeDefined(); - loadCallbackCalled = true; - }); + } as any as WorkerTileParameters); - source.reloadTile({ + // let the promise start + await sleep(0); + + const res = await source.reloadTile({ source: 'source', uid: '0', tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res).toBeDefined(); - expect(parseWorkerTileMock).toHaveBeenCalledTimes(2); - expect(loadCallbackCalled).toBeTruthy(); - done(); - }); + } as any as WorkerTileParameters); + expect(res).toBeDefined(); + expect(parseWorkerTileMock).toHaveBeenCalledTimes(2); + await expect(loadPromise).resolves.toBeTruthy(); }); - test('VectorTileWorkerSource#reloadTile does not reparse tiles with no vectorTile data but does call callback', () => { + test('VectorTileWorkerSource#reloadTile does not reparse tiles with no vectorTile data but does call callback', async () => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); const parse = jest.fn(); @@ -226,18 +215,35 @@ describe('vector tile worker source', () => { } as any as WorkerTile }; - const callback = jest.fn(); - - source.reloadTile({uid: 0} as any as WorkerTileParameters, callback); + await source.reloadTile({uid: 0} as any as WorkerTileParameters); expect(parse).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledTimes(1); + }); + + test('VectorTileWorkerSource#loadTile returns null for an empty tile', async () => { + const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); + source.loadVectorTile = (_params, _abortController) => Promise.resolve(null); + const parse = jest.fn(); + + server.respondWith(request => { + request.respond(200, {'Content-Type': 'application/pbf'}, 'something...'); + }); + + const promise = source.loadTile({ + source: 'source', + uid: 0, + tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, + request: {url: 'http://localhost:2900/faketile.pbf'} + } as any as WorkerTileParameters); + server.respond(); + + expect(parse).not.toHaveBeenCalled(); + expect(await promise).toBeNull(); }); - test('VectorTileWorkerSource#returns a good error message when failing to parse a tile', () => { + test('VectorTileWorkerSource#returns a good error message when failing to parse a tile', done => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); const parse = jest.fn(); - const callback = jest.fn(); server.respondWith(request => { request.respond(200, {'Content-Type': 'application/pbf'}, 'something...'); @@ -248,19 +254,19 @@ describe('vector tile worker source', () => { uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf'} - } as any as WorkerTileParameters, callback); + } as any as WorkerTileParameters).catch((err) => { + expect(err.message).toContain('Unable to parse the tile at'); + done(); + }); server.respond(); expect(parse).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback.mock.calls[0][0].message).toContain('Unable to parse the tile at'); }); - test('VectorTileWorkerSource#returns a good error message when failing to parse a gzipped tile', () => { + test('VectorTileWorkerSource#returns a good error message when failing to parse a gzipped tile', done => { const source = new VectorTileWorkerSource(actor, new StyleLayerIndex(), []); const parse = jest.fn(); - const callback = jest.fn(); server.respondWith(new Uint8Array([0x1f, 0x8b]).buffer); @@ -269,26 +275,27 @@ describe('vector tile worker source', () => { uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf'} - } as any as WorkerTileParameters, callback); + } as any as WorkerTileParameters).catch((err) => { + expect(err.message).toContain('gzipped'); + done(); + }); server.respond(); expect(parse).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback.mock.calls[0][0].message).toContain('gzipped'); }); - test('VectorTileWorkerSource provides resource timing information', done => { + test('VectorTileWorkerSource provides resource timing information', async () => { const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf')); - function loadVectorData(params, callback) { - return callback(null, { + const loadVectorData: LoadVectorData = async (_params, _abortController) => { + return { vectorTile: new vt.VectorTile(new Protobuf(rawTileData)), rawData: rawTileData, cacheControl: null, expires: null - }); - } + }; + }; const exampleResourceTiming = { connectEnd: 473, @@ -318,33 +325,33 @@ describe('vector tile worker source', () => { type: 'fill' }]); - const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData); + const source = new VectorTileWorkerSource(actor, layerIndex, []); + source.loadVectorTile = loadVectorData; window.performance.getEntriesByName = jest.fn().mockReturnValue([exampleResourceTiming]); - source.loadTile({ + const res = await source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true} - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res.resourceTiming[0]).toEqual(exampleResourceTiming); - done(); - }); + } as any as WorkerTileParameters); + + expect(res.resourceTiming[0]).toEqual(exampleResourceTiming); + }); - test('VectorTileWorkerSource provides resource timing information (fallback method)', done => { + test('VectorTileWorkerSource provides resource timing information (fallback method)', async () => { const rawTileData = fs.readFileSync(path.join(__dirname, '/../../test/unit/assets/mbsv5-6-18-23.vector.pbf')); - function loadVectorData(params, callback) { - return callback(null, { + const loadVectorData: LoadVectorData = async (_params, _abortController) => { + return { vectorTile: new vt.VectorTile(new Protobuf(rawTileData)), rawData: rawTileData, cacheControl: null, expires: null - }); - } + }; + }; const layerIndex = new StyleLayerIndex([{ id: 'test', @@ -353,7 +360,8 @@ describe('vector tile worker source', () => { type: 'fill' }]); - const source = new VectorTileWorkerSource(actor, layerIndex, [], loadVectorData); + const source = new VectorTileWorkerSource(actor, layerIndex, []); + source.loadVectorTile = loadVectorData; const sampleMarks = [100, 350]; const marks = {}; @@ -374,17 +382,15 @@ describe('vector tile worker source', () => { return null; }); - source.loadTile({ + const res = await source.loadTile({ source: 'source', uid: 0, tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0, w: 0}}, request: {url: 'http://localhost:2900/faketile.pbf', collectResourceTiming: true} - } as any as WorkerTileParameters, (err, res) => { - expect(err).toBeFalsy(); - expect(res.resourceTiming[0]).toEqual( - {'duration': 250, 'entryType': 'measure', 'name': 'http://localhost:2900/faketile.pbf', 'startTime': 100} - ); - done(); - }); + } as any as WorkerTileParameters); + + expect(res.resourceTiming[0]).toEqual( + {'duration': 250, 'entryType': 'measure', 'name': 'http://localhost:2900/faketile.pbf', 'startTime': 100} + ); }); }); diff --git a/src/source/vector_tile_worker_source.ts b/src/source/vector_tile_worker_source.ts index 7c7fca9def..9828305bdb 100644 --- a/src/source/vector_tile_worker_source.ts +++ b/src/source/vector_tile_worker_source.ts @@ -9,13 +9,12 @@ import {RequestPerformance} from '../util/performance'; import type { WorkerSource, WorkerTileParameters, - WorkerTileCallback, - TileParameters + TileParameters, + WorkerTileResult } from '../source/worker_source'; -import type {Actor} from '../util/actor'; +import type {IActor} from '../util/actor'; import type {StyleLayerIndex} from '../style/style_layer_index'; -import type {Callback} from '../types/callback'; import type {VectorTile} from '@mapbox/vector-tile'; export type LoadVectorTileResult = { @@ -30,61 +29,19 @@ type FetchingState = { resourceTiming: any; } -/** - * The callback when finished loading vector data - */ -export type LoadVectorDataCallback = Callback; - export type AbortVectorData = () => void; -export type LoadVectorData = (params: WorkerTileParameters, callback: LoadVectorDataCallback) => AbortVectorData | void; - -/** - * Loads a vector tile - */ -function loadVectorTile(params: WorkerTileParameters, callback: LoadVectorDataCallback) { - const request = getArrayBuffer(params.request, (err?: Error | null, data?: ArrayBuffer | null, cacheControl?: string | null, expires?: string | null) => { - if (err) { - callback(err); - } else if (data) { - try { - const vectorTile = new vt.VectorTile(new Protobuf(data)); - callback(null, { - 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}`; - } - callback(new Error(errorMessage)); - } - } - }); - return () => { - request.cancel(); - callback(); - }; -} +export type LoadVectorData = (params: WorkerTileParameters, abortController: AbortController) => Promise; /** * The {@link WorkerSource} implementation that supports {@link VectorTileSource}. * This class is designed to be easily reused to support custom source types * for data formats that can be parsed/converted into an in-memory VectorTile - * representation. To do so, create it with - * `new VectorTileWorkerSource(actor, styleLayers, customLoadVectorDataFunction)`. + * representation. To do so, override its `loadVectorTile` method. */ export class VectorTileWorkerSource implements WorkerSource { - actor: Actor; + actor: IActor; layerIndex: StyleLayerIndex; availableImages: Array; - loadVectorData: LoadVectorData; fetching: {[_: string]: FetchingState }; loading: {[_: string]: WorkerTile}; loaded: {[_: string]: WorkerTile}; @@ -95,38 +52,62 @@ export class VectorTileWorkerSource implements WorkerSource { * {@link VectorTileWorkerSource#loadTile}. The default implementation simply * loads the pbf at `params.url`. */ - constructor(actor: Actor, layerIndex: StyleLayerIndex, availableImages: Array, loadVectorData?: LoadVectorData | null) { + constructor(actor: IActor, layerIndex: StyleLayerIndex, availableImages: Array) { this.actor = actor; this.layerIndex = layerIndex; this.availableImages = availableImages; - this.loadVectorData = loadVectorData || loadVectorTile; this.fetching = {}; this.loading = {}; this.loaded = {}; } + /** + * Loads a vector tile + */ + 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); + } + } + /** * Implements {@link WorkerSource#loadTile}. Delegates to * {@link VectorTileWorkerSource#loadVectorData} (which by default expects * a `params.url` property) for fetching and producing a VectorTile object. */ - loadTile(params: WorkerTileParameters, callback: WorkerTileCallback) { - const uid = params.uid; - - if (!this.loading) - this.loading = {}; + async loadTile(params: WorkerTileParameters): Promise { + const tileUid = params.uid; const perf = (params && params.request && params.request.collectResourceTiming) ? new RequestPerformance(params.request) : false; - const workerTile = this.loading[uid] = new WorkerTile(params); - workerTile.abort = this.loadVectorData(params, (err, response) => { - delete this.loading[uid]; + const workerTile = new WorkerTile(params); + this.loading[tileUid] = workerTile; - if (err || !response) { - workerTile.status = 'done'; - this.loaded[uid] = workerTile; - return callback(err); + const abortController = new AbortController(); + workerTile.abort = abortController; + try { + const response = await this.loadVectorTile(params, abortController); + delete this.loading[tileUid]; + if (!response) { + return null; } const rawTileData = response.rawData; @@ -144,85 +125,75 @@ export class VectorTileWorkerSource implements WorkerSource { } workerTile.vectorTile = response.vectorTile; - workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor, (err, result) => { - delete this.fetching[uid]; - if (err || !result) return callback(err); + const parsePromise = workerTile.parse(response.vectorTile, this.layerIndex, this.availableImages, this.actor); + this.loaded[tileUid] = workerTile; + // keep the original fetching state so that reload tile can pick it up if the original parse is cancelled by reloads' parse + this.fetching[tileUid] = {rawTileData, cacheControl, resourceTiming}; + try { + const result = await parsePromise; // Transferring a copy of rawTileData because the worker needs to retain its copy. - callback(null, extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming)); - }); - - this.loaded = this.loaded || {}; - this.loaded[uid] = workerTile; - // keep the original fetching state so that reload tile can pick it up if the original parse is cancelled by reloads' parse - this.fetching[uid] = {rawTileData, cacheControl, resourceTiming}; - }) as AbortVectorData; + return extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming); + } finally { + delete this.fetching[tileUid]; + } + } catch (err) { + delete this.loading[tileUid]; + workerTile.status = 'done'; + this.loaded[tileUid] = workerTile; + throw err; + } } /** * Implements {@link WorkerSource#reloadTile}. */ - reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback) { - const loaded = this.loaded; + async reloadTile(params: WorkerTileParameters): Promise { const uid = params.uid; - if (loaded && loaded[uid]) { - const workerTile = loaded[uid]; - workerTile.showCollisionBoxes = params.showCollisionBoxes; - if (workerTile.status === 'parsing') { - workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, (err, result) => { - if (err || !result) return callback(err, result); - - // if we have cancelled the original parse, make sure to pass the rawTileData from the original fetch - let parseResult; - if (this.fetching[uid]) { - const {rawTileData, cacheControl, resourceTiming} = this.fetching[uid]; - delete this.fetching[uid]; - parseResult = extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming); - } else { - parseResult = result; - } - - callback(null, parseResult); - }); - } else if (workerTile.status === 'done') { - // if there was no vector tile data on the initial load, don't try and re-parse tile - if (workerTile.vectorTile) { - workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor, callback); - } else { - callback(); - } + if (!this.loaded || !this.loaded[uid]) { + throw new Error('Should not be trying to reload a tile that was never loaded or has been removed'); + } + const workerTile = this.loaded[uid]; + workerTile.showCollisionBoxes = params.showCollisionBoxes; + if (workerTile.status === 'parsing') { + const result = await workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor); + // if we have cancelled the original parse, make sure to pass the rawTileData from the original fetch + let parseResult: WorkerTileResult; + if (this.fetching[uid]) { + const {rawTileData, cacheControl, resourceTiming} = this.fetching[uid]; + delete this.fetching[uid]; + parseResult = extend({rawTileData: rawTileData.slice(0)}, result, cacheControl, resourceTiming); + } else { + parseResult = result; } + return parseResult; + + } + // if there was no vector tile data on the initial load, don't try and re-parse tile + if (workerTile.status === 'done' && workerTile.vectorTile) { + // this seems like a missing case where cache control is lost? see #3309 + return workerTile.parse(workerTile.vectorTile, this.layerIndex, this.availableImages, this.actor); } } /** * Implements {@link WorkerSource#abortTile}. - * - * @param params - The tile parameters - * @param callback - The callback */ - abortTile(params: TileParameters, callback: WorkerTileCallback) { - const loading = this.loading, - uid = params.uid; + async abortTile(params: TileParameters): Promise { + const loading = this.loading; + const uid = params.uid; if (loading && loading[uid] && loading[uid].abort) { - loading[uid].abort(); + loading[uid].abort.abort(); delete loading[uid]; } - callback(); } /** * Implements {@link WorkerSource#removeTile}. - * - * @param params - The tile parameters - * @param callback - The callback */ - removeTile(params: TileParameters, callback: WorkerTileCallback) { - const loaded = this.loaded, - uid = params.uid; - if (loaded && loaded[uid]) { - delete loaded[uid]; + async removeTile(params: TileParameters): Promise { + if (this.loaded && this.loaded[params.uid]) { + delete this.loaded[params.uid]; } - callback(); } } diff --git a/src/source/video_source.ts b/src/source/video_source.ts index 5aa966dfc5..b14f436a50 100644 --- a/src/source/video_source.ts +++ b/src/source/video_source.ts @@ -66,7 +66,7 @@ export class VideoSource extends ImageSource { this.options = options; } - load = () => { + async load() { this._loaded = false; const options = this.options; @@ -74,29 +74,30 @@ export class VideoSource extends ImageSource { for (const url of options.urls) { this.urls.push(this.map._requestManager.transformRequest(url, ResourceType.Source).url); } - - getVideo(this.urls, (err, video) => { + try { + const video = await getVideo(this.urls); this._loaded = true; - if (err) { - this.fire(new ErrorEvent(err)); - } else if (video) { - this.video = video; - this.video.loop = true; - - // Start repainting when video starts playing. hasTransition() will then return - // true to trigger additional frames as long as the videos continues playing. - this.video.addEventListener('playing', () => { - this.map.triggerRepaint(); - }); - - if (this.map) { - this.video.play(); - } - - this._finishLoading(); + if (!video) { + return; } - }); - }; + this.video = video; + this.video.loop = true; + + // Start repainting when video starts playing. hasTransition() will then return + // true to trigger additional frames as long as the videos continues playing. + this.video.addEventListener('playing', () => { + this.map.triggerRepaint(); + }); + + if (this.map) { + this.video.play(); + } + + this._finishLoading(); + } catch (err) { + this.fire(new ErrorEvent(err)); + } + } /** * Pauses the video. @@ -152,7 +153,7 @@ export class VideoSource extends ImageSource { * * @returns `this` */ - prepare = (): this => { + prepare(): this { if (Object.keys(this.tiles).length === 0 || this.video.readyState < 2) { return; // not enough data for current position } @@ -189,15 +190,15 @@ export class VideoSource extends ImageSource { if (newTilesLoaded) { this.fire(new Event('data', {dataType: 'source', sourceDataType: 'idle', sourceId: this.id})); } - }; + } - serialize = (): VideoSourceSpecification => { + serialize(): VideoSourceSpecification { return { type: 'video', urls: this.urls, coordinates: this.coordinates }; - }; + } hasTransition() { return this.video && !this.video.paused; diff --git a/src/source/worker.test.ts b/src/source/worker.test.ts index 26d320972c..2b5189075e 100644 --- a/src/source/worker.test.ts +++ b/src/source/worker.test.ts @@ -1,46 +1,50 @@ import {fakeServer} from 'nise'; import Worker from './worker'; import {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; -import {Cancelable} from '../types/cancelable'; import {WorkerGlobalScopeInterface} from '../util/web_worker'; import {CanonicalTileID, OverscaledTileID} from './tile_id'; -import {TileParameters, WorkerSource, WorkerTileCallback, WorkerTileParameters} from './worker_source'; +import {WorkerSource, WorkerTileParameters, WorkerTileResult} from './worker_source'; import {plugin as globalRTLTextPlugin} from './rtl_text_plugin'; -import {ActorTarget} from '../util/actor'; - -const _self = { - addEventListener() {} -} as any as WorkerGlobalScopeInterface & ActorTarget; +import {ActorTarget, IActor} from '../util/actor'; class WorkerSourceMock implements WorkerSource { availableImages: string[]; - constructor(private actor: any) {} - loadTile(_: WorkerTileParameters, __: WorkerTileCallback): void { - this.actor.send('main thread task', {}, () => {}, null); + constructor(private actor: IActor) {} + loadTile(_: WorkerTileParameters): Promise { + return this.actor.sendAsync({type: 'loadTile', data: {} as any}, new AbortController()); } - reloadTile(_: WorkerTileParameters, __: WorkerTileCallback): void { + reloadTile(_: WorkerTileParameters): Promise { throw new Error('Method not implemented.'); } - abortTile(_: TileParameters, __: WorkerTileCallback): void { + abortTile(_: WorkerTileParameters): Promise { throw new Error('Method not implemented.'); } - removeTile(_: TileParameters, __: WorkerTileCallback): void { + removeTile(_: WorkerTileParameters): Promise { throw new Error('Method not implemented.'); } } -describe('load tile', () => { - test('calls callback on error', done => { - const server = fakeServer.create(); +describe('Worker register RTLTextPlugin', () => { + let worker: Worker; + let _self: WorkerGlobalScopeInterface & ActorTarget; + + beforeEach(() => { + _self = { + addEventListener() {} + } as any; + worker = new Worker(_self); global.fetch = null; - const worker = new Worker(_self); - worker.loadTile('0', { + }); + + test('should validate handlers execution in worker for load tile', done => { + const server = fakeServer.create(); + worker.actor.messageHandlers['loadTile']('0', { type: 'vector', source: 'source', uid: '0', tileID: {overscaledZ: 0, wrap: 0, canonical: {x: 0, y: 0, z: 0} as CanonicalTileID} as any as OverscaledTileID, request: {url: '/error'}// Sinon fake server gives 404 responses by default - } as WorkerTileParameters & { type: string }, (err) => { + } as WorkerTileParameters).catch((err) => { expect(err).toBeTruthy(); server.restore(); done(); @@ -49,42 +53,38 @@ describe('load tile', () => { }); test('isolates different instances\' data', () => { - const worker = new Worker(_self); - - worker.setLayers('0', [ + worker.actor.messageHandlers['setLayers']('0', [ {id: 'one', type: 'circle'} as LayerSpecification - ], () => {}); + ]); - worker.setLayers('1', [ + worker.actor.messageHandlers['setLayers']('1', [ {id: 'one', type: 'circle'} as LayerSpecification, {id: 'two', type: 'circle'} as LayerSpecification, - ], () => {}); + ]); expect(worker.layerIndexes[0]).not.toBe(worker.layerIndexes[1]); }); test('worker source messages dispatched to the correct map instance', done => { - const worker = new Worker(_self); - const workerName = 'test'; + const extenalSourceName = 'test'; - worker.actor.send = (type, data, callback, mapId): Cancelable => { - expect(type).toBe('main thread task'); - expect(mapId).toBe('999'); + worker.actor.sendAsync = (message, abortController) => { + expect(message.type).toBe('loadTile'); + expect(message.targetMapId).toBe('999'); + expect(abortController).toBeDefined(); done(); - return {cancel: () => {}}; + return Promise.resolve({} as any); }; - _self.registerWorkerSource(workerName, WorkerSourceMock); + _self.registerWorkerSource(extenalSourceName, WorkerSourceMock); expect(() => { - _self.registerWorkerSource(workerName, WorkerSourceMock); - }).toThrow(`Worker source with name "${workerName}" already registered.`); + _self.registerWorkerSource(extenalSourceName, WorkerSourceMock); + }).toThrow(`Worker source with name "${extenalSourceName}" already registered.`); - worker.loadTile('999', {type: 'test'} as WorkerTileParameters & { type: string }, () => {}); + worker.actor.messageHandlers['loadTile']('999', {type: extenalSourceName} as WorkerTileParameters); }); -}); -describe('register RTLTextPlugin', () => { test('should not throw and set values in plugin', () => { jest.spyOn(globalRTLTextPlugin, 'isParsed').mockImplementation(() => { return false; @@ -97,9 +97,9 @@ describe('register RTLTextPlugin', () => { }; _self.registerRTLTextPlugin(rtlTextPlugin); - expect(globalRTLTextPlugin['applyArabicShaping']).toBe('test'); - expect(globalRTLTextPlugin['processBidirectionalText']).toBe('test'); - expect(globalRTLTextPlugin['processStyledBidirectionalText']).toBe('test'); + expect(globalRTLTextPlugin.applyArabicShaping).toBe('test'); + expect(globalRTLTextPlugin.processBidirectionalText).toBe('test'); + expect(globalRTLTextPlugin.processStyledBidirectionalText).toBe('test'); }); test('should throw if already parsed', () => { @@ -117,37 +117,25 @@ describe('register RTLTextPlugin', () => { _self.registerRTLTextPlugin(rtlTextPlugin); }).toThrow('RTL text plugin already registered.'); }); -}); -describe('set Referrer', () => { test('Referrer is set', () => { - const worker = new Worker(_self); - worker.setReferrer('fakeId', 'myMap'); + worker.actor.messageHandlers['setReferrer']('fakeId', 'myMap'); expect(worker.referrer).toBe('myMap'); }); -}); -describe('load worker source', () => { test('calls callback on error', done => { const server = fakeServer.create(); - global.fetch = null; - const worker = new Worker(_self); - worker.loadWorkerSource('0', { - url: '/error', - }, (err) => { + worker.actor.messageHandlers['loadWorkerSource']('0', '/error').catch((err) => { expect(err).toBeTruthy(); server.restore(); done(); }); server.respond(); }); -}); -describe('set images', () => { test('set images', () => { - const worker = new Worker(_self); expect(worker.availableImages['0']).toBeUndefined(); - worker.setImages('0', ['availableImages'], () => {}); + worker.actor.messageHandlers['setImages']('0', ['availableImages']); expect(worker.availableImages['0']).toEqual(['availableImages']); }); }); diff --git a/src/source/worker.ts b/src/source/worker.ts index 74ae806d46..2c7d1f7b53 100644 --- a/src/source/worker.ts +++ b/src/source/worker.ts @@ -2,23 +2,22 @@ import {Actor, ActorTarget} from '../util/actor'; import {StyleLayerIndex} from '../style/style_layer_index'; import {VectorTileWorkerSource} from './vector_tile_worker_source'; import {RasterDEMTileWorkerSource} from './raster_dem_tile_worker_source'; -import {GeoJSONWorkerSource} from './geojson_worker_source'; +import {GeoJSONWorkerSource, LoadGeoJSONParameters} from './geojson_worker_source'; import {plugin as globalRTLTextPlugin} from './rtl_text_plugin'; import {isWorker} from '../util/util'; import type { WorkerSource, + WorkerSourceConstructor, WorkerTileParameters, WorkerDEMTileParameters, - WorkerTileCallback, - WorkerDEMTileCallback, TileParameters } from '../source/worker_source'; import type {WorkerGlobalScopeInterface} from '../util/web_worker'; -import type {Callback} from '../types/callback'; import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {PluginState} from './rtl_text_plugin'; +import type {ClusterIDAndSource, GetClusterLeavesParams, RemoveSourceParams, UpdateLayersParamaeters} from '../util/actor_messages'; /** * The Worker class responsidble for background thread related execution @@ -28,11 +27,13 @@ export default class Worker { actor: Actor; layerIndexes: {[_: string]: StyleLayerIndex}; availableImages: {[_: string]: Array}; - workerSourceTypes: { - [_: string]: { - new (...args: any): WorkerSource; - }; - }; + externalWorkerSourceTypes: { [_: string]: WorkerSourceConstructor }; + /** + * This holds a cache for the already created worker source instances. + * The cache is build with the following hierarchy: + * [mapId][sourceType][sourceName]: worker source instance + * sourceType can be 'vector' for example + */ workerSources: { [_: string]: { [_: string]: { @@ -40,6 +41,12 @@ export default class Worker { }; }; }; + /** + * This holds a cache for the already created DEM worker source instances. + * The cache is build with the following hierarchy: + * [mapId][sourceType]: DEM worker source instance + * sourceType can be 'raster-dem' for example + */ demWorkerSources: { [_: string]: { [_: string]: RasterDEMTileWorkerSource; @@ -49,162 +56,156 @@ export default class Worker { constructor(self: WorkerGlobalScopeInterface & ActorTarget) { this.self = self; - this.actor = new Actor(self, this); + this.actor = new Actor(self); this.layerIndexes = {}; this.availableImages = {}; - this.workerSourceTypes = { - vector: VectorTileWorkerSource, - geojson: GeoJSONWorkerSource - }; - - // [mapId][sourceType][sourceName] => worker source instance this.workerSources = {}; this.demWorkerSources = {}; + this.externalWorkerSourceTypes = {}; - this.self.registerWorkerSource = (name: string, WorkerSource: { - new (...args: any): WorkerSource; - }) => { - if (this.workerSourceTypes[name]) { + this.self.registerWorkerSource = (name: string, WorkerSource: WorkerSourceConstructor) => { + if (this.externalWorkerSourceTypes[name]) { throw new Error(`Worker source with name "${name}" already registered.`); } - this.workerSourceTypes[name] = WorkerSource; + this.externalWorkerSourceTypes[name] = WorkerSource; }; // This is invoked by the RTL text plugin when the download via the `importScripts` call has finished, and the code has been parsed. this.self.registerRTLTextPlugin = (rtlTextPlugin: { - applyArabicShaping: Function; - processBidirectionalText: ((b: string, a: Array) => Array); - processStyledBidirectionalText?: ((c: string, b: Array, a: Array) => Array<[string, Array]>); + applyArabicShaping: typeof globalRTLTextPlugin.applyArabicShaping; + processBidirectionalText: typeof globalRTLTextPlugin.processBidirectionalText; + processStyledBidirectionalText?: typeof globalRTLTextPlugin.processStyledBidirectionalText; }) => { if (globalRTLTextPlugin.isParsed()) { throw new Error('RTL text plugin already registered.'); } - globalRTLTextPlugin['applyArabicShaping'] = rtlTextPlugin.applyArabicShaping; - globalRTLTextPlugin['processBidirectionalText'] = rtlTextPlugin.processBidirectionalText; - globalRTLTextPlugin['processStyledBidirectionalText'] = rtlTextPlugin.processStyledBidirectionalText; + globalRTLTextPlugin.applyArabicShaping = rtlTextPlugin.applyArabicShaping; + globalRTLTextPlugin.processBidirectionalText = rtlTextPlugin.processBidirectionalText; + globalRTLTextPlugin.processStyledBidirectionalText = rtlTextPlugin.processStyledBidirectionalText; }; - } - setReferrer(mapID: string, referrer: string) { - this.referrer = referrer; - } + this.actor.registerMessageHandler('loadDEMTile', (mapId: string, params: WorkerDEMTileParameters) => { + return this._getDEMWorkerSource(mapId, params.source).loadTile(params); + }); - setImages(mapId: string, images: Array, callback: WorkerTileCallback) { - this.availableImages[mapId] = images; - for (const workerSource in this.workerSources[mapId]) { - const ws = this.workerSources[mapId][workerSource]; - for (const source in ws) { - ws[source].availableImages = images; - } - } - callback(); - } + this.actor.registerMessageHandler('removeDEMTile', async (mapId: string, params: TileParameters) => { + this._getDEMWorkerSource(mapId, params.source).removeTile(params); + }); - setLayers(mapId: string, layers: Array, callback: WorkerTileCallback) { - this.getLayerIndex(mapId).replace(layers); - callback(); - } + this.actor.registerMessageHandler('getClusterExpansionZoom', async (mapId: string, params: ClusterIDAndSource) => { + return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterExpansionZoom(params); + }); - updateLayers(mapId: string, params: { - layers: Array; - removedIds: Array; - }, callback: WorkerTileCallback) { - this.getLayerIndex(mapId).update(params.layers, params.removedIds); - callback(); - } + this.actor.registerMessageHandler('getClusterChildren', async (mapId: string, params: ClusterIDAndSource) => { + return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterChildren(params); + }); - loadTile(mapId: string, params: WorkerTileParameters & { - type: string; - }, callback: WorkerTileCallback) { - this.getWorkerSource(mapId, params.type, params.source).loadTile(params, callback); - } + this.actor.registerMessageHandler('getClusterLeaves', async (mapId: string, params: GetClusterLeavesParams) => { + return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).getClusterLeaves(params); + }); - loadDEMTile(mapId: string, params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) { - this.getDEMWorkerSource(mapId, params.source).loadTile(params, callback); - } + this.actor.registerMessageHandler('loadData', (mapId: string, params: LoadGeoJSONParameters) => { + return (this._getWorkerSource(mapId, params.type, params.source) as GeoJSONWorkerSource).loadData(params); + }); - reloadTile(mapId: string, params: WorkerTileParameters & { - type: string; - }, callback: WorkerTileCallback) { - this.getWorkerSource(mapId, params.type, params.source).reloadTile(params, callback); - } + this.actor.registerMessageHandler('loadTile', (mapId: string, params: WorkerTileParameters) => { + return this._getWorkerSource(mapId, params.type, params.source).loadTile(params); + }); - abortTile(mapId: string, params: TileParameters & { - type: string; - }, callback: WorkerTileCallback) { - this.getWorkerSource(mapId, params.type, params.source).abortTile(params, callback); - } + this.actor.registerMessageHandler('reloadTile', (mapId: string, params: WorkerTileParameters) => { + return this._getWorkerSource(mapId, params.type, params.source).reloadTile(params); + }); - removeTile(mapId: string, params: TileParameters & { - type: string; - }, callback: WorkerTileCallback) { - this.getWorkerSource(mapId, params.type, params.source).removeTile(params, callback); - } + this.actor.registerMessageHandler('abortTile', (mapId: string, params: TileParameters) => { + return this._getWorkerSource(mapId, params.type, params.source).abortTile(params); + }); - removeDEMTile(mapId: string, params: TileParameters) { - this.getDEMWorkerSource(mapId, params.source).removeTile(params); - } + this.actor.registerMessageHandler('removeTile', (mapId: string, params: TileParameters) => { + return this._getWorkerSource(mapId, params.type, params.source).removeTile(params); + }); - removeSource(mapId: string, params: { - source: string; - } & { - type: string; - }, callback: WorkerTileCallback) { + this.actor.registerMessageHandler('removeSource', async (mapId: string, params: RemoveSourceParams) => { + if (!this.workerSources[mapId] || + !this.workerSources[mapId][params.type] || + !this.workerSources[mapId][params.type][params.source]) { + return; + } - if (!this.workerSources[mapId] || - !this.workerSources[mapId][params.type] || - !this.workerSources[mapId][params.type][params.source]) { - return; - } + const worker = this.workerSources[mapId][params.type][params.source]; + delete this.workerSources[mapId][params.type][params.source]; - const worker = this.workerSources[mapId][params.type][params.source]; - delete this.workerSources[mapId][params.type][params.source]; + if (worker.removeSource !== undefined) { + worker.removeSource(params); + } + }); + + this.actor.registerMessageHandler('setReferrer', async (_mapId: string, params: string) => { + this.referrer = params; + }); + + this.actor.registerMessageHandler('syncRTLPluginState', (mapId: string, params: PluginState) => { + return this._syncRTLPluginState(mapId, params); + }); + + this.actor.registerMessageHandler('loadWorkerSource', async (_mapId: string, params: string) => { + try { + this.self.importScripts(params); + } catch (ex) { + // This is done since some error messages are not serializable + throw ex.toString(); + } + }); - if (worker.removeSource !== undefined) { - worker.removeSource(params, callback); - } else { - callback(); - } + this.actor.registerMessageHandler('setImages', (mapId: string, params: string[]) => { + return this._setImages(mapId, params); + }); + + this.actor.registerMessageHandler('updateLayers', async (mapId: string, params: UpdateLayersParamaeters) => { + this._getLayerIndex(mapId).update(params.layers, params.removedIds); + }); + + this.actor.registerMessageHandler('setLayers', async (mapId: string, params: Array) => { + this._getLayerIndex(mapId).replace(params); + }); } - /** - * Load a {@link WorkerSource} script at params.url. The script is run - * (using importScripts) with `registerWorkerSource` in scope, which is a - * function taking `(name, workerSourceObject)`. - */ - loadWorkerSource(map: string, params: { - url: string; - }, callback: Callback) { - try { - this.self.importScripts(params.url); - callback(); - } catch (e) { - callback(e.toString()); + private async _setImages(mapId: string, images: Array): Promise { + this.availableImages[mapId] = images; + for (const workerSource in this.workerSources[mapId]) { + const ws = this.workerSources[mapId][workerSource]; + for (const source in ws) { + ws[source].availableImages = images; + } } } - syncRTLPluginState(map: string, state: PluginState, callback: Callback) { + private async _syncRTLPluginState(map: string, state: PluginState): Promise { try { globalRTLTextPlugin.setState(state); const pluginURL = globalRTLTextPlugin.getPluginURL(); if ( globalRTLTextPlugin.isLoaded() && - !globalRTLTextPlugin.isParsed() && - pluginURL != null // Not possible when `isLoaded` is true, but keeps flow happy + !globalRTLTextPlugin.isParsed() && + pluginURL != null // Not possible when `isLoaded` is true, but keeps flow happy ) { this.self.importScripts(pluginURL); const complete = globalRTLTextPlugin.isParsed(); - const error = complete ? undefined : new Error(`RTL Text Plugin failed to import scripts from ${pluginURL}`); - callback(error, complete); + if (complete) { + return complete; + } + throw new Error(`RTL Text Plugin failed to import scripts from ${pluginURL}`); } - } catch (e) { - callback(e.toString()); + return false; + } catch (ex) { + // This is done since some error messages are not serializable + throw ex.toString(); + } } - getAvailableImages(mapId: string) { + private _getAvailableImages(mapId: string) { let availableImages = this.availableImages[mapId]; if (!availableImages) { @@ -214,7 +215,7 @@ export default class Worker { return availableImages; } - getLayerIndex(mapId: string) { + private _getLayerIndex(mapId: string) { let layerIndexes = this.layerIndexes[mapId]; if (!layerIndexes) { layerIndexes = this.layerIndexes[mapId] = new StyleLayerIndex(); @@ -222,7 +223,14 @@ export default class Worker { return layerIndexes; } - getWorkerSource(mapId: string, sourceType: string, sourceName: string): WorkerSource { + /** + * This is basically a lazy initialization of a worker per mapId and sourceType and sourceName + * @param mapId - the mapId + * @param sourceType - the source type - 'vector' for example + * @param sourceName - the source name - 'osm' for example + * @returns a new instance or a cached one + */ + private _getWorkerSource(mapId: string, sourceType: string, sourceName: string): WorkerSource { if (!this.workerSources[mapId]) this.workerSources[mapId] = {}; if (!this.workerSources[mapId][sourceType]) @@ -230,30 +238,47 @@ export default class Worker { if (!this.workerSources[mapId][sourceType][sourceName]) { // use a wrapped actor so that we can attach a target mapId param - // to any messages invoked by the WorkerSource + // to any messages invoked by the WorkerSource, this is very important when there are multiple maps const actor = { - send: (type, data, callback) => { - this.actor.send(type, data, callback, mapId); + sendAsync: (message, abortController) => { + message.targetMapId = mapId; + return this.actor.sendAsync(message, abortController); } }; - this.workerSources[mapId][sourceType][sourceName] = new (this.workerSourceTypes[sourceType] as any)((actor as any), this.getLayerIndex(mapId), this.getAvailableImages(mapId)); + switch (sourceType) { + case 'vector': + this.workerSources[mapId][sourceType][sourceName] = new VectorTileWorkerSource(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId)); + break; + case 'geojson': + this.workerSources[mapId][sourceType][sourceName] = new GeoJSONWorkerSource(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId)); + break; + default: + this.workerSources[mapId][sourceType][sourceName] = new (this.externalWorkerSourceTypes[sourceType])(actor, this._getLayerIndex(mapId), this._getAvailableImages(mapId)); + break; + } } return this.workerSources[mapId][sourceType][sourceName]; } - getDEMWorkerSource(mapId: string, source: string) { + /** + * This is basically a lazy initialization of a worker per mapId and source + * @param mapId - the mapId + * @param sourceType - the source type - 'raster-dem' for example + * @returns a new instance or a cached one + */ + private _getDEMWorkerSource(mapId: string, sourceType: string) { if (!this.demWorkerSources[mapId]) this.demWorkerSources[mapId] = {}; - if (!this.demWorkerSources[mapId][source]) { - this.demWorkerSources[mapId][source] = new RasterDEMTileWorkerSource(); + if (!this.demWorkerSources[mapId][sourceType]) { + this.demWorkerSources[mapId][sourceType] = new RasterDEMTileWorkerSource(); } - return this.demWorkerSources[mapId][source]; + return this.demWorkerSources[mapId][sourceType]; } } -if (isWorker()) { - (self as any).worker = new Worker(self as any); +if (isWorker(self)) { + self.worker = new Worker(self); } diff --git a/src/source/worker_source.ts b/src/source/worker_source.ts index fd6741b446..8f5306ca42 100644 --- a/src/source/worker_source.ts +++ b/src/source/worker_source.ts @@ -1,4 +1,4 @@ -import type {RequestParameters} from '../util/ajax'; +import type {ExpiryData, RequestParameters} from '../util/ajax'; import type {RGBAImage, AlphaImage} from '../util/image'; import type {GlyphPositions} from '../render/glyph_atlas'; import type {ImageAtlas} from '../render/image_atlas'; @@ -6,21 +6,31 @@ import type {OverscaledTileID} from './tile_id'; import type {Bucket} from '../data/bucket'; import type {FeatureIndex} from '../data/feature_index'; import type {CollisionBoxArray} from '../data/array_types.g'; -import type {DEMData, DEMEncoding} from '../data/dem_data'; +import type {DEMEncoding} from '../data/dem_data'; import type {StyleGlyph} from '../style/style_glyph'; import type {StyleImage} from '../style/style_image'; import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; +import {RemoveSourceParams as RemoveSourceParameters} from '../util/actor_messages'; +import type {IActor} from '../util/actor'; +import type {StyleLayerIndex} from '../style/style_layer_index'; +/** + * Parameters to identify a tile + */ export type TileParameters = { + type: string; source: string; - uid: string; + uid: string | number; }; +/** + * Parameters that are send when requesting to load a tile to the worker + */ export type WorkerTileParameters = TileParameters & { tileID: OverscaledTileID; - request: RequestParameters; + request?: RequestParameters; zoom: number; - maxZoom: number; + maxZoom?: number; tileSize: number; promoteId: PromoteIdSpecification; pixelRatio: number; @@ -29,14 +39,11 @@ export type WorkerTileParameters = TileParameters & { returnDependencies?: boolean; }; +/** + * The paremeters needed in order to load a DEM tile + */ export type WorkerDEMTileParameters = TileParameters & { - coord: { - z: number; - x: number; - y: number; - w: number; - }; - rawImageData: RGBAImage | ImageBitmap; + rawImageData: RGBAImage | ImageBitmap | ImageData; encoding: DEMEncoding; redFactor: number; greenFactor: number; @@ -45,10 +52,9 @@ export type WorkerDEMTileParameters = TileParameters & { }; /** - * @internal * The worker tile's result type */ -export type WorkerTileResult = { +export type WorkerTileResult = ExpiryData & { buckets: Array; imageAtlas: ImageAtlas; glyphAtlasImage: AlphaImage; @@ -68,51 +74,44 @@ export type WorkerTileResult = { glyphPositions?: GlyphPositions | null; }; -export type WorkerTileCallback = (error?: Error | null, result?: WorkerTileResult | null) => void; -export type WorkerDEMTileCallback = (err?: Error | null, result?: DEMData | null) => void; +/** + * This is how the @see {@link WorkerSource} constructor should look like. + */ +export interface WorkerSourceConstructor { + new (actor: IActor, layerIndex: StyleLayerIndex, availableImages: Array): WorkerSource; +} /** - * May be implemented by custom source types to provide code that can be run on - * the WebWorkers. In addition to providing a custom - * {@link WorkerSource#loadTile}, any other methods attached to a `WorkerSource` - * implementation may also be targeted by the {@link Source} via - * `dispatcher.getActor().send('source-type.methodname', params, callback)`. - * + * `WorkerSource` should be implemented by custom source types to provide code that can be run on the WebWorkers. + * Each of the methods has a relevant event that triggers it from the main thread with the relevant parameters. * @see {@link Map#addSourceType} */ export interface WorkerSource { availableImages: Array; - // Disabled due to https://github.com/facebook/flow/issues/5208 - // constructor(actor: Actor, layerIndex: StyleLayerIndex): WorkerSource; /** * Loads a tile from the given params and parse it into buckets ready to send * back to the main thread for rendering. Should call the callback with: * `{ buckets, featureIndex, collisionIndex, rawTileData}`. */ - loadTile(params: WorkerTileParameters, callback: WorkerTileCallback): void; + loadTile(params: WorkerTileParameters): Promise; /** * Re-parses a tile that has already been loaded. Yields the same data as * {@link WorkerSource#loadTile}. */ - reloadTile(params: WorkerTileParameters, callback: WorkerTileCallback): void; + reloadTile(params: WorkerTileParameters): Promise; /** * Aborts loading a tile that is in progress. */ - abortTile(params: TileParameters, callback: WorkerTileCallback): void; + abortTile(params: TileParameters): Promise; /** * Removes this tile from any local caches. */ - removeTile(params: TileParameters, callback: WorkerTileCallback): void; + removeTile(params: TileParameters): Promise; /** * Tells the WorkerSource to abort in-progress tasks and release resources. * The foreground Source is responsible for ensuring that 'removeSource' is * the last message sent to the WorkerSource. */ - removeSource?: ( - params: { - source: string; - }, - callback: WorkerTileCallback - ) => void; + removeSource?: (params: RemoveSourceParameters) => Promise; } diff --git a/src/source/worker_tile.test.ts b/src/source/worker_tile.test.ts index c914ee28d0..813bc71e56 100644 --- a/src/source/worker_tile.test.ts +++ b/src/source/worker_tile.test.ts @@ -3,7 +3,6 @@ import {GeoJSONWrapper, Feature} from '../source/geojson_wrapper'; import {OverscaledTileID} from '../source/tile_id'; import {StyleLayerIndex} from '../style/style_layer_index'; import {WorkerTileParameters} from './worker_source'; -import {Actor} from '../util/actor'; import {VectorTile} from '@mapbox/vector-tile'; function createWorkerTile() { @@ -27,7 +26,7 @@ function createWrapper() { } describe('worker tile', () => { - test('WorkerTile#parse', done => { + test('WorkerTile#parse', async () => { const layerIndex = new StyleLayerIndex([{ id: 'test', source: 'source', @@ -35,14 +34,11 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - tile.parse(createWrapper(), layerIndex, [], {} as Actor, (err, result) => { - expect(err).toBeFalsy(); - expect(result.buckets[0]).toBeTruthy(); - done(); - }); + const result = await tile.parse(createWrapper(), layerIndex, [], {} as any); + expect(result.buckets[0]).toBeTruthy(); }); - test('WorkerTile#parse skips hidden layers', done => { + test('WorkerTile#parse skips hidden layers', async () => { const layerIndex = new StyleLayerIndex([{ id: 'test-hidden', source: 'source', @@ -51,14 +47,11 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - tile.parse(createWrapper(), layerIndex, [], {} as Actor, (err, result) => { - expect(err).toBeFalsy(); - expect(result.buckets).toHaveLength(0); - done(); - }); + const result = await tile.parse(createWrapper(), layerIndex, [], {} as any); + expect(result.buckets).toHaveLength(0); }); - test('WorkerTile#parse skips layers without a corresponding source layer', done => { + test('WorkerTile#parse skips layers without a corresponding source layer', async () => { const layerIndex = new StyleLayerIndex([{ id: 'test', source: 'source', @@ -67,14 +60,11 @@ describe('worker tile', () => { }]); const tile = createWorkerTile(); - tile.parse({layers: {}}, layerIndex, [], {} as Actor, (err, result) => { - expect(err).toBeFalsy(); - expect(result.buckets).toHaveLength(0); - done(); - }); + const result = await tile.parse({layers: {}}, layerIndex, [], {} as any); + expect(result.buckets).toHaveLength(0); }); - test('WorkerTile#parse warns once when encountering a v1 vector tile layer', done => { + test('WorkerTile#parse warns once when encountering a v1 vector tile layer', async () => { const layerIndex = new StyleLayerIndex([{ id: 'test', source: 'source', @@ -93,14 +83,11 @@ describe('worker tile', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const tile = createWorkerTile(); - tile.parse(data, layerIndex, [], {} as Actor, (err) => { - expect(err).toBeFalsy(); - expect(spy.mock.calls[0][0]).toMatch(/does not use vector tile spec v2/); - done(); - }); + await tile.parse(data, layerIndex, [], {} as any); + expect(spy.mock.calls[0][0]).toMatch(/does not use vector tile spec v2/); }); - test('WorkerTile#parse would request all types of dependencies', done => { + test('WorkerTile#parse would request all types of dependencies', async () => { const tile = createWorkerTile(); const layerIndex = new StyleLayerIndex([{ id: '1', @@ -144,29 +131,25 @@ describe('worker tile', () => { } } as any as VectorTile; - const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => { - setTimeout(() => callback(null, - type === 'getImages' ? - {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : - {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}} - )); + const sendAsync = jest.fn().mockImplementation((message: {type: string; data: any}) => { + const response = message.type === 'getImages' ? + {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : + {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}; + return Promise.resolve(response); }); const actorMock = { - send - } as unknown as Actor; - tile.parse(data, layerIndex, ['hello'], actorMock, (err, result) => { - expect(err).toBeFalsy(); - expect(result).toBeDefined(); - expect(send).toHaveBeenCalledTimes(3); - expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'icons'}), expect.any(Function)); - expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'patterns'}), expect.any(Function)); - expect(send).toHaveBeenCalledWith('getGlyphs', expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}}), expect.any(Function)); - done(); - }); + sendAsync + }; + const result = await tile.parse(data, layerIndex, ['hello'], actorMock); + expect(result).toBeDefined(); + expect(sendAsync).toHaveBeenCalledTimes(3); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'icons'})}), expect.any(Object)); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'patterns'})}), expect.any(Object)); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}})}), expect.any(Object)); }); - test('WorkerTile#parse would cancel and only event once on repeated reparsing', done => { + test('WorkerTile#parse would cancel and only event once on repeated reparsing', async () => { const tile = createWorkerTile(); const layerIndex = new StyleLayerIndex([{ id: '1', @@ -211,35 +194,33 @@ describe('worker tile', () => { } as any as VectorTile; let cancelCount = 0; - const send = jest.fn().mockImplementation((type: string, data: unknown, callback: Function) => { - const res = setTimeout(() => callback(null, - type === 'getImages' ? - {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : - {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}} - )); - - return { - cancel: () => { + const sendAsync = jest.fn().mockImplementation((message: {type: string; data: unknown}, abortController: AbortController) => { + return new Promise((resolve, _reject) => { + const res = setTimeout(() => { + const response = message.type === 'getImages' ? + {'hello': {width: 1, height: 1, data: new Uint8Array([0])}} : + {'StandardFont-Bold': {width: 1, height: 1, data: new Uint8Array([0])}}; + resolve(response); + } + ); + abortController.signal.addEventListener('abort', () => { cancelCount += 1; clearTimeout(res); - } - }; + }); + }); }); const actorMock = { - send - } as unknown as Actor; - tile.parse(data, layerIndex, ['hello'], actorMock, () => done.fail('should not be called')); - tile.parse(data, layerIndex, ['hello'], actorMock, () => done.fail('should not be called')); - tile.parse(data, layerIndex, ['hello'], actorMock, (err, result) => { - expect(err).toBeFalsy(); - expect(result).toBeDefined(); - expect(cancelCount).toBe(6); - expect(send).toHaveBeenCalledTimes(9); - expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'icons'}), expect.any(Function)); - expect(send).toHaveBeenCalledWith('getImages', expect.objectContaining({'icons': ['hello'], 'type': 'patterns'}), expect.any(Function)); - expect(send).toHaveBeenCalledWith('getGlyphs', expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}}), expect.any(Function)); - done(); - }); + sendAsync + }; + tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy()); + tile.parse(data, layerIndex, ['hello'], actorMock).then(() => expect(false).toBeTruthy()); + const result = await tile.parse(data, layerIndex, ['hello'], actorMock); + expect(result).toBeDefined(); + expect(cancelCount).toBe(6); + expect(sendAsync).toHaveBeenCalledTimes(9); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'icons'})}), expect.any(Object)); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'icons': ['hello'], 'type': 'patterns'})}), expect.any(Object)); + expect(sendAsync).toHaveBeenCalledWith(expect.objectContaining({data: expect.objectContaining({'source': 'source', 'type': 'glyphs', 'stacks': {'StandardFont-Bold': [101, 115, 116]}})}), expect.any(Object)); }); }); diff --git a/src/source/worker_tile.ts b/src/source/worker_tile.ts index 3a13b4ce5f..35c8770cc4 100644 --- a/src/source/worker_tile.ts +++ b/src/source/worker_tile.ts @@ -13,22 +13,20 @@ import {EvaluationParameters} from '../style/evaluation_parameters'; import {OverscaledTileID} from './tile_id'; import type {Bucket} from '../data/bucket'; -import type {Actor} from '../util/actor'; +import type {IActor} from '../util/actor'; import type {StyleLayer} from '../style/style_layer'; import type {StyleLayerIndex} from '../style/style_layer_index'; -import type {StyleImage} from '../style/style_image'; -import type {StyleGlyph} from '../style/style_glyph'; import type { WorkerTileParameters, - WorkerTileCallback, + WorkerTileResult, } from '../source/worker_source'; import type {PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec'; import type {VectorTile} from '@mapbox/vector-tile'; -import {Cancelable} from '../types/cancelable'; +import type {GetGlyphsResponse, GetImagesResponse} from '../util/actor_messages'; export class WorkerTile { tileID: OverscaledTileID; - uid: string; + uid: string | number; zoom: number; pixelRatio: number; tileSize: number; @@ -43,10 +41,9 @@ export class WorkerTile { data: VectorTile; collisionBoxArray: CollisionBoxArray; - abort: (() => void); + abort: AbortController; vectorTile: VectorTile; - inFlightDependencies: Cancelable[]; - dependencySentinel: number; + inFlightDependencies: AbortController[]; constructor(params: WorkerTileParameters) { this.tileID = new OverscaledTileID(params.tileID.overscaledZ, params.tileID.wrap, params.tileID.canonical.z, params.tileID.canonical.x, params.tileID.canonical.y); @@ -61,10 +58,9 @@ export class WorkerTile { this.returnDependencies = !!params.returnDependencies; this.promoteId = params.promoteId; this.inFlightDependencies = []; - this.dependencySentinel = -1; } - parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: Actor, callback: WorkerTileCallback) { + async parse(data: VectorTile, layerIndex: StyleLayerIndex, availableImages: Array, actor: IActor): Promise { this.status = 'parsing'; this.data = data; @@ -132,114 +128,72 @@ export class WorkerTile { } } - let error: Error; - let glyphMap: { - [_: string]: { - [_: number]: StyleGlyph; - }; - }; - let iconMap: {[_: string]: StyleImage}; - let patternMap: {[_: string]: StyleImage}; - const stacks = mapObject(options.glyphDependencies, (glyphs) => Object.keys(glyphs).map(Number)); - this.inFlightDependencies.forEach((request) => request?.cancel()); + this.inFlightDependencies.forEach((request) => request?.abort()); this.inFlightDependencies = []; - // cancelling seems to be not sufficient, we seems to still manage to get a callback hit, so use a sentinel to drop stale results - const dependencySentinel = ++this.dependencySentinel; + let getGlyphsPromise = Promise.resolve({}); if (Object.keys(stacks).length) { - this.inFlightDependencies.push(actor.send('getGlyphs', {uid: this.uid, stacks, source: this.source, tileID: this.tileID, type: 'glyphs'}, (err, result) => { - if (dependencySentinel !== this.dependencySentinel) { - return; - } - if (!error) { - error = err; - glyphMap = result; - maybePrepare.call(this); - } - })); - } else { - glyphMap = {}; + const abortController = new AbortController(); + this.inFlightDependencies.push(abortController); + getGlyphsPromise = actor.sendAsync({type: 'getGlyphs', data: {stacks, source: this.source, tileID: this.tileID, type: 'glyphs'}}, abortController); } const icons = Object.keys(options.iconDependencies); + let getIconsPromise = Promise.resolve({}); if (icons.length) { - this.inFlightDependencies.push(actor.send('getImages', {icons, source: this.source, tileID: this.tileID, type: 'icons'}, (err, result) => { - if (dependencySentinel !== this.dependencySentinel) { - return; - } - if (!error) { - error = err; - iconMap = result; - maybePrepare.call(this); - } - })); - } else { - iconMap = {}; + const abortController = new AbortController(); + this.inFlightDependencies.push(abortController); + getIconsPromise = actor.sendAsync({type: 'getImages', data: {icons, source: this.source, tileID: this.tileID, type: 'icons'}}, abortController); } const patterns = Object.keys(options.patternDependencies); + let getPatternsPromise = Promise.resolve({}); if (patterns.length) { - this.inFlightDependencies.push(actor.send('getImages', {icons: patterns, source: this.source, tileID: this.tileID, type: 'patterns'}, (err, result) => { - if (dependencySentinel !== this.dependencySentinel) { - return; - } - if (!error) { - error = err; - patternMap = result; - maybePrepare.call(this); - } - })); - } else { - patternMap = {}; + const abortController = new AbortController(); + this.inFlightDependencies.push(abortController); + getPatternsPromise = actor.sendAsync({type: 'getImages', data: {icons: patterns, source: this.source, tileID: this.tileID, type: 'patterns'}}, abortController); } - maybePrepare.call(this); - - function maybePrepare() { - if (error) { - return callback(error); - } else if (glyphMap && iconMap && patternMap) { - const glyphAtlas = new GlyphAtlas(glyphMap); - const imageAtlas = new ImageAtlas(iconMap, patternMap); - - for (const key in buckets) { - const bucket = buckets[key]; - if (bucket instanceof SymbolBucket) { - recalculateLayers(bucket.layers, this.zoom, availableImages); - performSymbolLayout({ - bucket, - glyphMap, - glyphPositions: glyphAtlas.positions, - imageMap: iconMap, - imagePositions: imageAtlas.iconPositions, - showCollisionBoxes: this.showCollisionBoxes, - canonical: this.tileID.canonical - }); - } else if (bucket.hasPattern && - (bucket instanceof LineBucket || - bucket instanceof FillBucket || - bucket instanceof FillExtrusionBucket)) { - recalculateLayers(bucket.layers, this.zoom, availableImages); - bucket.addFeatures(options, this.tileID.canonical, imageAtlas.patternPositions); - } - } - - this.status = 'done'; - callback(null, { - buckets: Object.values(buckets).filter(b => !b.isEmpty()), - featureIndex, - collisionBoxArray: this.collisionBoxArray, - glyphAtlasImage: glyphAtlas.image, - imageAtlas, - // Only used for benchmarking: - glyphMap: this.returnDependencies ? glyphMap : null, - iconMap: this.returnDependencies ? iconMap : null, - glyphPositions: this.returnDependencies ? glyphAtlas.positions : null + const [glyphMap, iconMap, patternMap] = await Promise.all([getGlyphsPromise, getIconsPromise, getPatternsPromise]); + const glyphAtlas = new GlyphAtlas(glyphMap); + const imageAtlas = new ImageAtlas(iconMap, patternMap); + + for (const key in buckets) { + const bucket = buckets[key]; + if (bucket instanceof SymbolBucket) { + recalculateLayers(bucket.layers, this.zoom, availableImages); + performSymbolLayout({ + bucket, + glyphMap, + glyphPositions: glyphAtlas.positions, + imageMap: iconMap, + imagePositions: imageAtlas.iconPositions, + showCollisionBoxes: this.showCollisionBoxes, + canonical: this.tileID.canonical }); + } else if (bucket.hasPattern && + (bucket instanceof LineBucket || + bucket instanceof FillBucket || + bucket instanceof FillExtrusionBucket)) { + recalculateLayers(bucket.layers, this.zoom, availableImages); + bucket.addFeatures(options, this.tileID.canonical, imageAtlas.patternPositions); } } + + this.status = 'done'; + return { + buckets: Object.values(buckets).filter(b => !b.isEmpty()), + featureIndex, + collisionBoxArray: this.collisionBoxArray, + glyphAtlasImage: glyphAtlas.image, + imageAtlas, + // Only used for benchmarking: + glyphMap: this.returnDependencies ? glyphMap : null, + iconMap: this.returnDependencies ? iconMap : null, + glyphPositions: this.returnDependencies ? glyphAtlas.positions : null + }; } } diff --git a/src/style/load_glyph_range.test.ts b/src/style/load_glyph_range.test.ts index 12500f9ebf..f79334a301 100644 --- a/src/style/load_glyph_range.test.ts +++ b/src/style/load_glyph_range.test.ts @@ -5,7 +5,7 @@ import {loadGlyphRange} from './load_glyph_range'; import {fakeServer} from 'nise'; import {bufferToArrayBuffer} from '../util/test/util'; -test('loadGlyphRange', done => { +test('loadGlyphRange', async () => { global.fetch = null; const transform = jest.fn().mockImplementation((url) => { @@ -17,25 +17,24 @@ test('loadGlyphRange', done => { const server = fakeServer.create(); server.respondWith(bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/0-255.pbf')))); - loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager, (err, result) => { - expect(err).toBeFalsy(); - expect(transform).toHaveBeenCalledTimes(1); - expect(transform).toHaveBeenCalledWith('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf', 'Glyphs'); - - expect(Object.keys(result)).toHaveLength(223); - for (const key in result) { - const id = Number(key); - const glyph = result[id]; - - expect(glyph.id).toBe(Number(id)); - expect(glyph.metrics).toBeTruthy(); - expect(typeof glyph.metrics.width).toBe('number'); - expect(typeof glyph.metrics.height).toBe('number'); - expect(typeof glyph.metrics.top).toBe('number'); - expect(typeof glyph.metrics.advance).toBe('number'); - } - done(); - }); + const promise = loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager); server.respond(); + const result = await promise; + + expect(transform).toHaveBeenCalledTimes(1); + expect(transform).toHaveBeenCalledWith('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf', 'Glyphs'); + + expect(Object.keys(result)).toHaveLength(223); + for (const key in result) { + const id = Number(key); + const glyph = result[id]; + + expect(glyph.id).toBe(Number(id)); + expect(glyph.metrics).toBeTruthy(); + expect(typeof glyph.metrics.width).toBe('number'); + expect(typeof glyph.metrics.height).toBe('number'); + expect(typeof glyph.metrics.top).toBe('number'); + expect(typeof glyph.metrics.advance).toBe('number'); + } expect(server.requests[0].url).toBe('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf'); }); diff --git a/src/style/load_glyph_range.ts b/src/style/load_glyph_range.ts index 6a454caa90..ba63c10a4b 100644 --- a/src/style/load_glyph_range.ts +++ b/src/style/load_glyph_range.ts @@ -5,15 +5,11 @@ import {parseGlyphPbf} from './parse_glyph_pbf'; import type {StyleGlyph} from './style_glyph'; import type {RequestManager} from '../util/request_manager'; -import type {Callback} from '../types/callback'; -export function loadGlyphRange(fontstack: string, +export async function loadGlyphRange(fontstack: string, range: number, urlTemplate: string, - requestManager: RequestManager, - callback: Callback<{ - [_: number]: StyleGlyph | null; - }>) { + requestManager: RequestManager): Promise<{[_: number]: StyleGlyph | null}> { const begin = range * 256; const end = begin + 255; @@ -22,17 +18,15 @@ export function loadGlyphRange(fontstack: string, ResourceType.Glyphs ); - getArrayBuffer(request, (err?: Error | null, data?: ArrayBuffer | null) => { - if (err) { - callback(err); - } else if (data) { - const glyphs = {}; + const response = await getArrayBuffer(request, new AbortController()); + if (!response || !response.data) { + throw new Error(`Could not load glyph range. range: ${range}, ${begin}-${end}`); + } + const glyphs = {}; - for (const glyph of parseGlyphPbf(data)) { - glyphs[glyph.id] = glyph; - } + for (const glyph of parseGlyphPbf(response.data)) { + glyphs[glyph.id] = glyph; + } - callback(null, glyphs); - } - }); + return glyphs; } diff --git a/src/style/load_sprite.test.ts b/src/style/load_sprite.test.ts index 6aaa068f0e..e55d849347 100644 --- a/src/style/load_sprite.test.ts +++ b/src/style/load_sprite.test.ts @@ -3,26 +3,28 @@ import path from 'path'; import {RequestManager} from '../util/request_manager'; import {loadSprite} from './load_sprite'; import {type FakeServer, fakeServer} from 'nise'; -import * as util from '../util/util'; import {bufferToArrayBuffer} from '../util/test/util'; +import {ABORT_ERROR} from '../util/abort_error'; +import * as util from '../util/util'; 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(); }); - test('backwards compatibility: single string is treated as a URL for the default sprite', done => { + test('backwards compatibility: single string is treated as a URL for the default sprite', async () => { const transform = jest.fn().mockImplementation((url, type) => { return {url, type}; }); @@ -32,31 +34,29 @@ describe('loadSprite', () => { server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString()); server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); - loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, (err, result) => { - expect(err).toBeFalsy(); + const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, new AbortController()); - expect(transform).toHaveBeenCalledTimes(2); - expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON'); - expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage'); + server.respond(); - expect(Object.keys(result)).toHaveLength(1); - expect(Object.keys(result)[0]).toBe('default'); + const result = await promise; - Object.values(result['default']).forEach(styleImage => { - expect(styleImage.spriteData).toBeTruthy(); - expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); - }); + expect(transform).toHaveBeenCalledTimes(2); + expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON'); + expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage'); - done(); - }); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('default'); - server.respond(); + Object.values(result['default']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); + }); expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); }); - test('array of objects support', done => { + test('array of objects support', async () => { const transform = jest.fn().mockImplementation((url, type) => { return {url, type}; }); @@ -68,40 +68,38 @@ describe('loadSprite', () => { server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.json')).toString()); server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.png')))); - loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, (err, result) => { - expect(err).toBeFalsy(); + const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, new AbortController()); - expect(transform).toHaveBeenCalledTimes(4); - expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON'); - expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage'); - expect(transform).toHaveBeenNthCalledWith(3, 'http://localhost:9966/test/unit/assets/sprite2.json', 'SpriteJSON'); - expect(transform).toHaveBeenNthCalledWith(4, 'http://localhost:9966/test/unit/assets/sprite2.png', 'SpriteImage'); + server.respond(); - expect(Object.keys(result)).toHaveLength(2); - expect(Object.keys(result)[0]).toBe('sprite1'); - expect(Object.keys(result)[1]).toBe('sprite2'); + const result = await promise; + expect(transform).toHaveBeenCalledTimes(4); + expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1.json', 'SpriteJSON'); + expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1.png', 'SpriteImage'); + expect(transform).toHaveBeenNthCalledWith(3, 'http://localhost:9966/test/unit/assets/sprite2.json', 'SpriteJSON'); + expect(transform).toHaveBeenNthCalledWith(4, 'http://localhost:9966/test/unit/assets/sprite2.png', 'SpriteImage'); - Object.values(result['sprite1']).forEach(styleImage => { - expect(styleImage.spriteData).toBeTruthy(); - expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); - }); + expect(Object.keys(result)).toHaveLength(2); + expect(Object.keys(result)[0]).toBe('sprite1'); + expect(Object.keys(result)[1]).toBe('sprite2'); - Object.values(result['sprite2']).forEach(styleImage => { - expect(styleImage.spriteData).toBeTruthy(); - expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); - }); + Object.values(result['sprite1']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); + }); - done(); + Object.values(result['sprite2']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); }); - server.respond(); expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); expect(server.requests[2].url).toBe('http://localhost:9966/test/unit/assets/sprite2.json'); expect(server.requests[3].url).toBe('http://localhost:9966/test/unit/assets/sprite2.png'); }); - test('error in callback', done => { + test('server returns error', async () => { const transform = jest.fn().mockImplementation((url, type) => { return {url, type}; }); @@ -109,21 +107,14 @@ describe('loadSprite', () => { const manager = new RequestManager(transform); server.respondWith((xhr) => xhr.respond(500)); - let last = false; - loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, (err, result) => { - expect(err).toBeTruthy(); - expect(result).toBeUndefined(); - if (!last) { - done(); - last = true; - } - }); - + const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, new AbortController()); server.respond(); + + await expect(promise).rejects.toThrow(/AJAXError.*500.*/); expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); }); - test('request canceling', done => { + test('request canceling', async () => { const transform = jest.fn().mockImplementation((url, type) => { return {url, type}; }); @@ -133,25 +124,20 @@ describe('loadSprite', () => { server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString()); server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); - const cancelable = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, () => {}); - - setTimeout(() => { - cancelable.cancel(); + const abortController = new AbortController(); + const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, abortController); + abortController.abort(); - expect((server.requests[0] as any).aborted).toBeTruthy(); - expect((server.requests[1] as any).aborted).toBeTruthy(); + expect((server.requests[0] as any).aborted).toBeTruthy(); + expect((server.requests[1] as any).aborted).toBeTruthy(); - done(); - }); - - setTimeout(() => { - server.respond(); - expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); - expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); - }, 10); + await expect(promise).rejects.toThrow(ABORT_ERROR); + server.respond(); + expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); + expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); }); - test('pixelRatio is respected', done => { + test('pixelRatio is respected', async () => { const transform = jest.fn().mockImplementation((url, type) => { return {url, type}; }); @@ -161,25 +147,22 @@ describe('loadSprite', () => { server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1@2x.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString()); server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1@2x.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); - loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 2, (err, result) => { - expect(err).toBeFalsy(); - - expect(transform).toHaveBeenCalledTimes(2); - expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1@2x.json', 'SpriteJSON'); - expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1@2x.png', 'SpriteImage'); + const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 2, new AbortController()); + server.respond(); - expect(Object.keys(result)).toHaveLength(1); - expect(Object.keys(result)[0]).toBe('default'); + const result = await promise; + expect(transform).toHaveBeenCalledTimes(2); + expect(transform).toHaveBeenNthCalledWith(1, 'http://localhost:9966/test/unit/assets/sprite1@2x.json', 'SpriteJSON'); + expect(transform).toHaveBeenNthCalledWith(2, 'http://localhost:9966/test/unit/assets/sprite1@2x.png', 'SpriteImage'); - Object.values(result['default']).forEach(styleImage => { - expect(styleImage.spriteData).toBeTruthy(); - expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); - }); + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('default'); - done(); + Object.values(result['default']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); }); - server.respond(); expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1@2x.json'); expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1@2x.png'); }); diff --git a/src/style/load_sprite.ts b/src/style/load_sprite.ts index 2a36afe710..ebe5a021b7 100644 --- a/src/style/load_sprite.ts +++ b/src/style/load_sprite.ts @@ -1,4 +1,4 @@ -import {getJSON} from '../util/ajax'; +import {GetResourceResponse, getJSON} from '../util/ajax'; import {ImageRequest} from '../util/image_request'; import {ResourceType} from '../util/request_manager'; @@ -6,82 +6,53 @@ import {browser} from '../util/browser'; import {coerceSpriteToArray} from '../util/style'; import type {SpriteSpecification} from '@maplibre/maplibre-gl-style-spec'; -import type {StyleImage} from './style_image'; +import type {SpriteJSON, StyleImage} from './style_image'; import type {RequestManager} from '../util/request_manager'; -import type {Callback} from '../types/callback'; -import type {Cancelable} from '../types/cancelable'; -export function loadSprite( +export type LoadSpriteResult = { + [spriteName: string]: { + [id: string]: StyleImage; + }; +} + +export async function loadSprite( originalSprite: SpriteSpecification, requestManager: RequestManager, pixelRatio: number, - callback: Callback<{[spriteName: string]: {[id: string]: StyleImage}}> -): Cancelable { + abortController: AbortController, +): Promise { const spriteArray = coerceSpriteToArray(originalSprite); - const spriteArrayLength = spriteArray.length; const format = pixelRatio > 1 ? '@2x' : ''; - const combinedRequestsMap: {[requestKey: string]: Cancelable} = {}; - const jsonsMap: {[id: string]: any} = {}; - const imagesMap: {[id: string]: (HTMLImageElement | ImageBitmap)} = {}; + const jsonsMap: {[id: string]: Promise>} = {}; + const imagesMap: {[id: string]: Promise>} = {}; 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 - combinedRequestsMap[jsonRequestKey] = getJSON(jsonRequestParameters, (err?: Error | null, data?: any | null) => { - delete combinedRequestsMap[jsonRequestKey]; - jsonsMap[id] = data; - doOnceCompleted(callback, jsonsMap, imagesMap, err, spriteArrayLength); - }); + jsonsMap[id] = getJSON(jsonRequestParameters, abortController); 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) => { - delete combinedRequestsMap[imageRequestKey]; - imagesMap[id] = img; - doOnceCompleted(callback, jsonsMap, imagesMap, err, spriteArrayLength); - }); + imagesMap[id] = ImageRequest.getImage(imageRequestParameters, abortController); } - return { - cancel() { - for (const requst of Object.values(combinedRequestsMap)) { - requst.cancel(); - } - } - }; + await Promise.all([...Object.values(jsonsMap), ...Object.values(imagesMap)]); + return doOnceCompleted(jsonsMap, imagesMap); } /** - * @param callbackFunc - the callback function (both erro and success) * @param jsonsMap - JSON data map * @param imagesMap - image data map - * @param err - error object - * @param expectedResultCounter - number of expected JSON or Image results when everything is finished, respectively. */ -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; - } +async function doOnceCompleted( + jsonsMap:{[id: string]: Promise>}, + imagesMap:{[id: string]: Promise>}): Promise { const result = {} as {[spriteName: string]: {[id: string]: StyleImage}}; for (const spriteName in jsonsMap) { result[spriteName] = {}; - const context = browser.getImageCanvasContext(imagesMap[spriteName]); - const json = jsonsMap[spriteName]; + const context = browser.getImageCanvasContext((await imagesMap[spriteName]).data); + const json = (await jsonsMap[spriteName]).data; for (const id in json) { const {width, height, x, y, sdf, pixelRatio, stretchX, stretchY, content} = json[id]; @@ -90,5 +61,5 @@ function doOnceCompleted( } } - callbackFunc(null, result); + return result; } diff --git a/src/style/style.test.ts b/src/style/style.test.ts index bdc325c83d..946f85ebdc 100644 --- a/src/style/style.test.ts +++ b/src/style/style.test.ts @@ -80,12 +80,13 @@ function createStyle(map = getStubMap()) { } let server: FakeServer; -let mockConsoleError; +let mockConsoleError: jest.SpyInstance; beforeEach(() => { global.fetch = null; server = fakeServer.create(); mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => { }); + clearRTLTextPlugin(); }); afterEach(() => { @@ -95,12 +96,8 @@ afterEach(() => { describe('Style', () => { test('registers plugin state change listener', () => { - clearRTLTextPlugin(); - - jest.spyOn(Style, 'registerForPluginStateChange'); const style = new Style(getStubMap()); const mockStyleDispatcherBroadcast = jest.spyOn(style.dispatcher, 'broadcast'); - expect(Style.registerForPluginStateChange).toHaveBeenCalledTimes(1); setRTLTextPlugin('/plugin.js', undefined); expect(mockStyleDispatcherBroadcast.mock.calls[0][0]).toBe('syncRTLPluginState'); @@ -111,7 +108,6 @@ describe('Style', () => { }); test('loads plugin immediately if already registered', done => { - clearRTLTextPlugin(); server.respondWith('/plugin.js', 'doesn\'t matter'); setRTLTextPlugin('/plugin.js', (error) => { expect(error).toMatch(/Cannot set the state of the rtl-text-plugin when not in the web-worker context/); @@ -151,13 +147,12 @@ describe('Style', () => { clearRTLTextPlugin(); server.respondWith('/plugin.js', 'doesn\'t matter'); const _broadcast = style.dispatcher.broadcast; - style.dispatcher.broadcast = function (type, state, callback) { + style.dispatcher.broadcast = function (type, state) { if (type === 'syncRTLPluginState') { // Mock a response from four workers saying they've loaded the plugin - callback(undefined, [true, true, true, true]); - } else { - _broadcast(type, state, callback); + return Promise.resolve([true, true, true, true]) as any; } + return _broadcast(type, state); }; setRTLTextPlugin('/plugin.js', (error) => { expect(error).toBeUndefined(); @@ -291,7 +286,7 @@ describe('Style#loadJSON', () => { }); }); - test('Validate sprite image extraction', done => { + test('Validate sprite image extraction', async () => { // Stubbing to bypass Web APIs that supported by jsdom: // * `URL.createObjectURL` in ajax.getImage (https://github.com/tmpvar/jsdom/issues/1721) // * `canvas.getContext('2d')` in browser.getImageData @@ -311,25 +306,23 @@ describe('Style#loadJSON', () => { 'sprite': 'http://example.com/sprite' }); - style.once('data', (e) => { - expect(e.target).toBe(style); - expect(e.dataType).toBe('style'); + const firstDataEvent = await style.once('data'); + expect(firstDataEvent.target).toBe(style); + expect(firstDataEvent.dataType).toBe('style'); - style.once('data', (e) => { - expect(e.target).toBe(style); - expect(e.dataType).toBe('style'); - style.imageManager.getImages(['image1'], (error, response) => { - const image = response['image1']; - expect(image.data).toBeInstanceOf(RGBAImage); - expect(image.data.width).toBe(1); - expect(image.data.height).toBe(1); - expect(image.pixelRatio).toBe(1); - done(); - }); - }); + const secondDataPromise = style.once('data'); - server.respond(); - }); + server.respond(); + + const secondDateEvent = await secondDataPromise; + expect(secondDateEvent.target).toBe(style); + expect(secondDateEvent.dataType).toBe('style'); + const response = await style.imageManager.getImages(['image1']); + const image = response['image1']; + expect(image.data).toBeInstanceOf(RGBAImage); + expect(image.data.width).toBe(1); + expect(image.data.height).toBe(1); + expect(image.pixelRatio).toBe(1); }); test('validates the style', done => { @@ -563,9 +556,10 @@ describe('Style#_load', () => { }); const _broadcastSpyOn = jest.spyOn(style.dispatcher, 'broadcast') - .mockImplementation((type: string, data) => { + .mockImplementation((type, data) => { dispatchType = type; dispatchData = data; + return Promise.resolve({} as any); }); style._load(styleSpec, {}); @@ -577,7 +571,7 @@ describe('Style#_load', () => { expect(dispatchData[0].id).toBe('background'); // cleanup - _broadcastSpyOn.mockReset(); + _broadcastSpyOn.mockRestore(); }); test('validate style when validate option is true', () => { @@ -643,18 +637,16 @@ describe('Style#_remove', () => { }); }); - test('deregisters plugin listener', done => { + test('deregisters plugin listener', async () => { const style = new Style(getStubMap()); style.loadJSON(createStyleJSON()); const mockStyleDispatcherBroadcast = jest.spyOn(style.dispatcher, 'broadcast'); - style.on('style.load', () => { - style._remove(); + await style.once('style.load'); + style._remove(); - rtlTextPluginEvented.fire(new Event('pluginStateChange')); - expect(mockStyleDispatcherBroadcast).not.toHaveBeenCalledWith('syncRTLPluginState'); - done(); - }); + rtlTextPluginEvented.fire(new Event('pluginStateChange')); + expect(mockStyleDispatcherBroadcast).not.toHaveBeenCalledWith('syncRTLPluginState'); }); }); @@ -688,6 +680,7 @@ describe('Style#update', () => { expect(value['layers'].map((layer) => { return layer.id; })).toEqual(['first', 'third']); expect(value['removedIds']).toEqual(['second']); done(); + return Promise.resolve({} as any); }; style.update({} as EvaluationParameters); @@ -1972,6 +1965,7 @@ describe('Style#setFilter', () => { expect(value['layers'][0].id).toBe('symbol'); expect(value['layers'][0].filter).toEqual(['==', 'id', 1]); done(); + return Promise.resolve({} as any); }; style.setFilter('symbol', ['==', 'id', 1]); @@ -2010,6 +2004,7 @@ describe('Style#setFilter', () => { expect(value['layers'][0].id).toBe('symbol'); expect(value['layers'][0].filter).toEqual(['==', 'id', 2]); done(); + return Promise.resolve({} as any); }; filter[2] = 2; style.setFilter('symbol', filter); @@ -2069,6 +2064,7 @@ describe('Style#setFilter', () => { expect(value['layers'][0].id).toBe('symbol'); expect(value['layers'][0].filter).toBe('notafilter'); done(); + return Promise.resolve({} as any); }; style.setFilter('symbol', 'notafilter' as any as FilterSpecification, {validate: false}); @@ -2108,6 +2104,7 @@ describe('Style#setLayerZoomRange', () => { expect(key).toBe('updateLayers'); expect(value['layers'].map((layer) => { return layer.id; })).toEqual(['symbol']); done(); + return Promise.resolve({} as any); }; style.setLayerZoomRange('symbol', 5, 12); expect(style.getLayer('symbol').minzoom).toBe(5); @@ -2516,6 +2513,7 @@ describe('Style#addSourceType', () => { if (type === 'loadWorkerSource') { done('test failed'); } + return Promise.resolve({} as any); }; style.addSourceType('foo', sourceType, (arg1, arg2) => { @@ -2532,9 +2530,9 @@ describe('Style#addSourceType', () => { style.dispatcher.broadcast = (type, params) => { if (type === 'loadWorkerSource') { - expect(params['name']).toBe('bar'); - expect(params['url']).toBe('worker-source.js'); + expect(params).toBe('worker-source.js'); done(); + return Promise.resolve({} as any); } }; diff --git a/src/style/style.ts b/src/style/style.ts index a43d0ad4c2..c8b98c42df 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -43,12 +43,10 @@ const emitValidationErrors = (evented: Evented, errors?: ReadonlyArray<{ import type {Map} from '../ui/map'; import type {Transform} from '../geo/transform'; import type {StyleImage} from './style_image'; -import type {StyleGlyph} from './style_glyph'; import type {Callback} from '../types/callback'; import type {EvaluationParameters} from './evaluation_parameters'; import type {Placement} from '../symbol/placement'; -import type {Cancelable} from '../types/cancelable'; -import type {RequestParameters, ResponseCallback} from '../util/ajax'; +import type {GetResourceResponse, RequestParameters} from '../util/ajax'; import type { LayerSpecification, FilterSpecification, @@ -59,7 +57,7 @@ import type { } from '@maplibre/maplibre-gl-style-spec'; import type {CustomLayerInterface} from './style_layer/custom_style_layer'; import type {Validator} from './validate_style'; -import type {OverscaledTileID} from '../source/tile_id'; +import type {GetGlyphsParamerters, GetGlyphsResponse, GetImagesParamerters, GetImagesResponse} from '../util/actor_messages'; const supportedDiffOperations = pick(diffOperations, [ 'addLayer', @@ -209,8 +207,9 @@ export class Style extends Evented { lineAtlas: LineAtlas; light: Light; - _request: Cancelable; - _spriteRequest: Cancelable; + _frameRequest: AbortController; + _loadStyleRequest: AbortController; + _spriteRequest: AbortController; _layers: {[_: string]: StyleLayer}; _serializedLayers: {[_: string]: LayerSpecification}; _order: Array; @@ -236,13 +235,20 @@ export class Style extends Evented { placement: Placement; z: number; - static registerForPluginStateChange: typeof registerForPluginStateChange; - constructor(map: Map, options: StyleOptions = {}) { super(); this.map = map; - this.dispatcher = new Dispatcher(getGlobalWorkerPool(), this, map._getMapId()); + this.dispatcher = new Dispatcher(getGlobalWorkerPool(), map._getMapId()); + this.dispatcher.registerMessageHandler('getGlyphs', (mapId, params) => { + return this.getGlyphs(mapId, params); + }); + this.dispatcher.registerMessageHandler('getImages', (mapId, params) => { + return this.getImages(mapId, params); + }); + this.dispatcher.registerMessageHandler('getResource', (mapId, params, abortController) => { + return this.getResource(mapId, params, abortController); + }); this.imageManager = new ImageManager(); this.imageManager.setEventedParent(this); this.glyphManager = new GlyphManager(map._requestManager, options.localIdeographFontFamily); @@ -263,29 +269,31 @@ export class Style extends Evented { this.dispatcher.broadcast('setReferrer', getReferrer()); const self = this; - this._rtlTextPluginCallback = Style.registerForPluginStateChange((event) => { + this._rtlTextPluginCallback = registerForPluginStateChange((event) => { const state = { pluginStatus: event.pluginStatus, pluginURL: event.pluginURL }; - self.dispatcher.broadcast('syncRTLPluginState', state, (err, results) => { - triggerPluginCompletionEvent(err); - if (results) { + self.dispatcher.broadcast('syncRTLPluginState', state) + .then((results) => { + triggerPluginCompletionEvent(undefined); + if (!results) { + return; + } const allComplete = results.every((elem) => elem); - if (allComplete) { - for (const id in self.sourceCaches) { - const sourceType = self.sourceCaches[id].getSource().type; - if (sourceType === 'vector' || sourceType === 'geojson') { - // Non-vector sources don't have any symbols buckets to reload when the RTL text plugin loads - // They also load more quickly, so they're more likely to have already displaying tiles - // that would be unnecessarily booted by the plugin load event - self.sourceCaches[id].reload(); // Should be a no-op if the plugin loads before any tiles load - } + if (!allComplete) { + return; + } + for (const id in self.sourceCaches) { + const sourceType = self.sourceCaches[id].getSource().type; + if (sourceType === 'vector' || sourceType === 'geojson') { + // Non-vector sources don't have any symbols buckets to reload when the RTL text plugin loads + // They also load more quickly, so they're more likely to have already displaying tiles + // that would be unnecessarily booted by the plugin load event + self.sourceCaches[id].reload(); // Should be a no-op if the plugin loads before any tiles load } } - } - - }); + }).catch((err: string) => triggerPluginCompletionEvent(err)); }); this.on('data', (event) => { @@ -319,12 +327,14 @@ export class Style extends Evented { options.validate : true; const request = this.map._requestManager.transformRequest(url, ResourceType.Style); - this._request = getJSON(request, (error?: Error | null, json?: any | null) => { - this._request = null; + this._loadStyleRequest = new AbortController(); + getJSON(request, this._loadStyleRequest).then((response) => { + this._loadStyleRequest = null; + this._load(response.data, options, previousStyle); + }).catch((error) => { + this._loadStyleRequest = null; if (error) { this.fire(new ErrorEvent(error)); - } else if (json) { - this._load(json, options, previousStyle); } }); } @@ -332,11 +342,12 @@ export class Style extends Evented { loadJSON(json: StyleSpecification, options: StyleSetterOptions & StyleSwapOptions = {}, previousStyle?: StyleSpecification) { this.fire(new Event('dataloading', {dataType: 'style'})); - this._request = browser.frame(() => { - this._request = null; + this._frameRequest = new AbortController(); + browser.frameAsync(this._frameRequest).then(() => { + this._frameRequest = null; options.validate = options.validate !== false; this._load(json, options, previousStyle); - }); + }).catch(() => {}); // ignore abort } loadEmpty() { @@ -396,11 +407,11 @@ export class Style extends Evented { _loadSprite(sprite: SpriteSpecification, isUpdate: boolean = false, completion: (err: Error) => void = undefined) { this.imageManager.setLoaded(false); - this._spriteRequest = loadSprite(sprite, this.map._requestManager, this.map.getPixelRatio(), (err, images) => { + this._spriteRequest = new AbortController(); + let err: Error; + loadSprite(sprite, this.map._requestManager, this.map.getPixelRatio(), this._spriteRequest).then((images) => { this._spriteRequest = null; - if (err) { - this.fire(new ErrorEvent(err)); - } else if (images) { + if (images) { for (const spriteId in images) { this._spritesImagesIds[spriteId] = []; @@ -428,7 +439,11 @@ export class Style extends Evented { } } } - + }).catch((error) => { + this._spriteRequest = null; + err = error; + this.fire(new ErrorEvent(err)); + }).finally(() => { this.imageManager.setLoaded(true); this._availableImages = this.imageManager.listImages(); @@ -1411,10 +1426,7 @@ export class Style extends Evented { return callback(null, null); } - this.dispatcher.broadcast('loadWorkerSource', { - name, - url: SourceType.workerSourceURL - }, callback); + this.dispatcher.broadcast('loadWorkerSource', SourceType.workerSourceURL.toString()).then(() => callback()).catch(callback); } getLight() { @@ -1461,12 +1473,16 @@ export class Style extends Evented { } _remove(mapRemoved: boolean = true) { - if (this._request) { - this._request.cancel(); - this._request = null; + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; + } + if (this._loadStyleRequest) { + this._loadStyleRequest.abort(); + this._loadStyleRequest = null; } if (this._spriteRequest) { - this._spriteRequest.cancel(); + this._spriteRequest.abort(); this._spriteRequest = null; } rtlTextPluginEvented.off('pluginStateChange', this._rtlTextPluginCallback); @@ -1583,17 +1599,8 @@ export class Style extends Evented { // Callbacks from web workers - getImages( - mapId: string, - params: { - icons: Array; - source: string; - tileID: OverscaledTileID; - type: string; - }, - callback: Callback<{[_: string]: StyleImage}> - ) { - this.imageManager.getImages(params.icons, callback); + async getImages(mapId: string | number, params: GetImagesParamerters): Promise { + const images = await this.imageManager.getImages(params.icons); // Apply queued image changes before setting the tile's dependencies so that the tile // is not reloaded unnecessarily. Without this forced update the reload could happen in cases @@ -1609,29 +1616,22 @@ export class Style extends Evented { if (sourceCache) { sourceCache.setDependencies(params.tileID.key, params.type, params.icons); } + return images; } - getGlyphs( - mapId: string, - params: { - stacks: {[_: string]: Array}; - source: string; - tileID: OverscaledTileID; - type: string; - }, - callback: Callback<{[_: string]: {[_: number]: StyleGlyph}}> - ) { - this.glyphManager.getGlyphs(params.stacks, callback); + async getGlyphs(mapId: string | number, params: GetGlyphsParamerters): Promise { + const glypgs = await this.glyphManager.getGlyphs(params.stacks); const sourceCache = this.sourceCaches[params.source]; if (sourceCache) { // we are not setting stacks as dependencies since for now // we just need to know which tiles have glyph dependencies sourceCache.setDependencies(params.tileID.key, params.type, ['']); } + return glypgs; } - getResource(mapId: string, params: RequestParameters, callback: ResponseCallback): Cancelable { - return makeRequest(params, callback); + getResource(mapId: string | number, params: RequestParameters, abortController: AbortController): Promise> { + return makeRequest(params, abortController); } getGlyphsUrl() { @@ -1741,5 +1741,3 @@ export class Style extends Evented { } } } - -Style.registerForPluginStateChange = registerForPluginStateChange; diff --git a/src/style/style_glyph.ts b/src/style/style_glyph.ts index 35efaad542..29dd48ac0a 100644 --- a/src/style/style_glyph.ts +++ b/src/style/style_glyph.ts @@ -1,5 +1,8 @@ import type {AlphaImage} from '../util/image'; +/** + * Some metices related to a glyph + */ export type GlyphMetrics = { width: number; height: number; @@ -13,7 +16,6 @@ export type GlyphMetrics = { }; /** - * @internal * A style glyph type */ export type StyleGlyph = { diff --git a/src/style/style_image.ts b/src/style/style_image.ts index bcfadccab7..e3b2106150 100644 --- a/src/style/style_image.ts +++ b/src/style/style_image.ts @@ -2,6 +2,13 @@ import {RGBAImage} from '../util/image'; import type {Map} from '../ui/map'; +export type SpriteJSON = {[id: string]: StyleImageMetadata & { + width: number; + height: number; + x: number; + y: number; +};} + /** * The sprite data */ diff --git a/src/types/tilejson.ts b/src/types/tilejson.ts index a2542eb397..b5ba81fba6 100644 --- a/src/types/tilejson.ts +++ b/src/types/tilejson.ts @@ -12,4 +12,6 @@ export type TileJSON = { maxzoom?: number; bounds?: [number, number, number, number]; center?: [number, number, number]; + vectorLayers: any; + vectorLayerIds: Array; }; diff --git a/src/ui/control/geolocate_control.test.ts b/src/ui/control/geolocate_control.test.ts index 00b3186fa9..49d518bb7c 100644 --- a/src/ui/control/geolocate_control.test.ts +++ b/src/ui/control/geolocate_control.test.ts @@ -1,6 +1,6 @@ import geolocation from 'mock-geolocation'; import {LngLatBounds} from '../../geo/lng_lat_bounds'; -import {createMap, beforeMapTest} from '../../util/test/util'; +import {createMap, beforeMapTest, sleep} from '../../util/test/util'; import {GeolocateControl} from './geolocate_control'; jest.mock('../../util/geolocation_support', () => ( { @@ -30,7 +30,7 @@ describe('GeolocateControl with no options', () => { beforeEach(() => { beforeMapTest(); map = createMap(undefined, undefined); - (checkGeolocationSupport as any as jest.SpyInstance).mockImplementationOnce((cb) => cb(true)); + (checkGeolocationSupport as any as jest.SpyInstance).mockImplementationOnce(() => Promise.resolve(true)); }); afterEach(() => { @@ -38,15 +38,19 @@ describe('GeolocateControl with no options', () => { }); test('is disabled when there\'s no support', async () => { - (checkGeolocationSupport as any as jest.SpyInstance).mockReset().mockImplementationOnce((cb) => cb(false)); + (checkGeolocationSupport as any as jest.SpyInstance).mockReset().mockImplementationOnce(() => Promise.resolve(false)); const geolocate = new GeolocateControl(undefined); + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); map.addControl(geolocate); + await sleep(0); expect(geolocate._geolocateButton.disabled).toBeTruthy(); + spy.mockRestore(); }); test('is enabled when there no support', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); + await sleep(0); expect(geolocate._geolocateButton.disabled).toBeFalsy(); }); @@ -59,33 +63,33 @@ describe('GeolocateControl with no options', () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); - await new Promise(process.nextTick); + await sleep(0); expect(geolocate._geolocateButton.disabled).toBeFalsy(); }); - test('error event', done => { + test('error event', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); - - geolocate.on('error', (error) => { - expect(error.code).toBe(2); - expect(error.message).toBe('error message'); - done(); - }); + const errorPromise = geolocate.once('error'); geolocate._geolocateButton.dispatchEvent(click); + geolocation.sendError({code: 2, message: 'error message'}); + const error = await errorPromise; + + expect(error.code).toBe(2); + expect(error.message).toBe('error message'); }); - test('does not throw if removed quickly', done => { + test('does not throw if removed quickly', () => { (checkGeolocationSupport as any as jest.SpyInstance).mockReset() - .mockImplementationOnce((cb) => { - return Promise.resolve(true) - .then(result => { - expect(() => cb(result)).not.toThrow(); - }) - .finally(done); + .mockImplementationOnce(() => { + return new Promise(resolve => { + setTimeout(() => { + resolve(true); + }, 10); + }); }); const geolocate = new GeolocateControl(undefined); @@ -93,73 +97,75 @@ describe('GeolocateControl with no options', () => { map.removeControl(geolocate); }); - test('outofmaxbounds event in active lock state', done => { + test('outofmaxbounds event in active lock state', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); + await sleep(0); map.setMaxBounds([[0, 0], [10, 10]]); geolocate._watchState = 'ACTIVE_LOCK'; const click = new window.Event('click'); - geolocate.on('outofmaxbounds', (position) => { - expect(geolocate._watchState).toBe('ACTIVE_ERROR'); - expect(position.coords.latitude).toBe(10); - expect(position.coords.longitude).toBe(20); - expect(position.coords.accuracy).toBe(3); - expect(position.timestamp).toBe(4); - done(); - }); + const outofmaxboundsPromise = geolocate.once('outofmaxbounds'); geolocate._geolocateButton.dispatchEvent(click); geolocation.send({latitude: 10, longitude: 20, accuracy: 3, timestamp: 4}); + const position = await outofmaxboundsPromise; + + expect(geolocate._watchState).toBe('ACTIVE_ERROR'); + expect(position.coords.latitude).toBe(10); + expect(position.coords.longitude).toBe(20); + expect(position.coords.accuracy).toBe(3); + expect(position.timestamp).toBe(4); }); - test('outofmaxbounds event in background state', done => { + test('outofmaxbounds event in background state', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); + await sleep(0); map.setMaxBounds([[0, 0], [10, 10]]); geolocate._watchState = 'BACKGROUND'; const click = new window.Event('click'); - geolocate.on('outofmaxbounds', (position) => { - expect(geolocate._watchState).toBe('BACKGROUND_ERROR'); - expect(position.coords.latitude).toBe(10); - expect(position.coords.longitude).toBe(20); - expect(position.coords.accuracy).toBe(3); - expect(position.timestamp).toBe(4); - done(); - }); + const promise = geolocate.once('outofmaxbounds'); geolocate._geolocateButton.dispatchEvent(click); geolocation.send({latitude: 10, longitude: 20, accuracy: 3, timestamp: 4}); + const position = await promise; + expect(geolocate._watchState).toBe('BACKGROUND_ERROR'); + expect(position.coords.latitude).toBe(10); + expect(position.coords.longitude).toBe(20); + expect(position.coords.accuracy).toBe(3); + expect(position.timestamp).toBe(4); }); - test('geolocate event', done => { + test('geolocate event', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); - geolocate.on('geolocate', (position) => { - expect(position.coords.latitude).toBe(10); - expect(position.coords.longitude).toBe(20); - expect(position.coords.accuracy).toBe(30); - expect(position.timestamp).toBe(40); - done(); - }); + const promise = geolocate.once('geolocate'); geolocate._geolocateButton.dispatchEvent(click); geolocation.send({latitude: 10, longitude: 20, accuracy: 30, timestamp: 40}); + + const position = await promise; + expect(position.coords.latitude).toBe(10); + expect(position.coords.longitude).toBe(20); + expect(position.coords.accuracy).toBe(30); + expect(position.timestamp).toBe(40); }); - test('trigger', () => { + test('trigger', async () => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); - + await sleep(0); expect(geolocate.trigger()).toBeTruthy(); }); - test('trigger and then error when tracking user location should get to active error state', () => { + test('trigger and then error when tracking user location should get to active error state', async () => { const geolocate = new GeolocateControl({trackUserLocation: true}); map.addControl(geolocate); + await sleep(0); geolocate.trigger(); geolocation.sendError({code: 2, message: 'error message'}); @@ -184,7 +190,7 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const moveEndPromise = map.once('moveend'); @@ -194,7 +200,7 @@ describe('GeolocateControl with no options', () => { expect(map.getZoom()).toBe(10); }); - test('with removed before Geolocation callback', () => { + test('was removed before Geolocation support was checked', () => { expect(() => { const geolocate = new GeolocateControl(undefined); map.addControl(geolocate); @@ -212,7 +218,7 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const moveEndPromise = map.once('moveend'); @@ -232,7 +238,7 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const moveEndPromise = map.once('moveend'); @@ -264,7 +270,7 @@ describe('GeolocateControl with no options', () => { ).toBeFalsy(); }); - test('watching map updates recenter on location with dot', done => { + test('watching map updates recenter on location with dot', async () => { const geolocate = new GeolocateControl({ trackUserLocation: true, showUserLocation: true, @@ -273,38 +279,32 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); - let moveendCount = 0; - map.once('moveend', () => { - // moveend was being called a second time, this ensures that we don't run the tests a second time - if (moveendCount > 0) return; - moveendCount++; - - expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '10.0000', lng: '20.0000'}); - expect(geolocate._userLocationDotMarker._map).toBeTruthy(); - expect( - geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale') - ).toBeFalsy(); - map.once('moveend', () => { - expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '40.0000', lng: '50.0000'}); - geolocate.once('error', () => { - expect(geolocate._userLocationDotMarker._map).toBeTruthy(); - expect( - geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale') - ).toBeTruthy(); - done(); - }); - geolocation.changeError({code: 2, message: 'position unavaliable'}); - }); - geolocation.change({latitude: 40, longitude: 50, accuracy: 60}); - }); + const firstMoveEnd = map.once('moveend'); geolocate._geolocateButton.dispatchEvent(click); geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + + await firstMoveEnd; + + expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '10.0000', lng: '20.0000'}); + expect(geolocate._userLocationDotMarker._map).toBeTruthy(); + expect( + geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale') + ).toBeFalsy(); + const secontMoveEnd = map.once('moveend'); + geolocation.change({latitude: 40, longitude: 50, accuracy: 60}); + await secontMoveEnd; + expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '40.0000', lng: '50.0000'}); + const errorPromise = geolocate.once('error'); + geolocation.changeError({code: 2, message: 'position unavaliable'}); + await errorPromise; + expect(geolocate._userLocationDotMarker._map).toBeTruthy(); + expect(geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')).toBeTruthy(); }); - test('watching map background event', done => { + test('watching map background event', async () => { const geolocate = new GeolocateControl({ trackUserLocation: true, fitBoundsOptions: { @@ -312,32 +312,25 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); - let moveendCount = 0; - map.once('moveend', () => { - // moveend was being called a second time, this ensures that we don't run the tests a second time - if (moveendCount > 0) return; - moveendCount++; - - geolocate.once('trackuserlocationend', () => { - expect(map.getCenter()).toEqual({lng: 10, lat: 5}); - done(); - }); - - // manually pan the map away from the geolocation position which should trigger the 'trackuserlocationend' event above - map.jumpTo({ - center: [10, 5] - }); - }); + const moveEndPromise = map.once('moveend'); // click the button to activate it into the enabled watch state geolocate._geolocateButton.dispatchEvent(click); // send through a location update which should reposition the map and trigger the 'moveend' event above geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + await moveEndPromise; + const trackPromise = geolocate.once('trackuserlocationend'); + // manually pan the map away from the geolocation position which should trigger the 'trackuserlocationend' event above + map.jumpTo({ + center: [10, 5] + }); + await trackPromise; + expect(map.getCenter()).toEqual({lng: 10, lat: 5}); }); - test('watching map background state', done => { + test('watching map background state', async () => { const geolocate = new GeolocateControl({ trackUserLocation: true, fitBoundsOptions: { @@ -345,33 +338,26 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); - let moveendCount = 0; - map.once('moveend', () => { - // moveend was being called a second time, this ensures that we don't run the tests a second time - if (moveendCount > 0) return; - moveendCount++; - - map.once('moveend', () => { - geolocate.once('geolocate', () => { - expect(map.getCenter()).toEqual({lng: 10, lat: 5}); - done(); - }); - // update the geolocation position, since we are in background state when 'geolocate' is triggered above, the camera shouldn't have changed - geolocation.change({latitude: 0, longitude: 0, accuracy: 10}); - }); - - // manually pan the map away from the geolocation position which should trigger the 'moveend' event above - map.jumpTo({ - center: [10, 5] - }); - }); + const moveEndPromise = map.once('moveend'); // click the button to activate it into the enabled watch state geolocate._geolocateButton.dispatchEvent(click); // send through a location update which should reposition the map and trigger the 'moveend' event above geolocation.send({latitude: 10, longitude: 20, accuracy: 30}); + await moveEndPromise; + const secondMoveEnd = map.once('moveend'); + // manually pan the map away from the geolocation position which should trigger the 'moveend' event above + map.jumpTo({ + center: [10, 5] + }); + await secondMoveEnd; + const geolocatePromise = geolocate.once('geolocate'); + // update the geolocation position, since we are in background state when 'geolocate' is triggered above, the camera shouldn't have changed + geolocation.change({latitude: 0, longitude: 0, accuracy: 10}); + await geolocatePromise; + expect(map.getCenter()).toEqual({lng: 10, lat: 5}); }); test('trackuserlocationstart event', async () => { @@ -382,7 +368,7 @@ describe('GeolocateControl with no options', () => { } }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const promise = geolocate.once('trackuserlocationstart'); @@ -396,7 +382,7 @@ describe('GeolocateControl with no options', () => { trackUserLocation: true, }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const geolocatePromise = geolocate.once('geolocate'); @@ -413,7 +399,7 @@ describe('GeolocateControl with no options', () => { trackUserLocation: true, }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const geolocatePromise = geolocate.once('geolocate'); @@ -434,7 +420,7 @@ describe('GeolocateControl with no options', () => { showAccuracyCircle: false, }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const geolocatePromise = geolocate.once('geolocate'); @@ -456,7 +442,7 @@ describe('GeolocateControl with no options', () => { showUserLocation: true, }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const geolocatePromise = geolocate.once('geolocate'); @@ -474,12 +460,10 @@ describe('GeolocateControl with no options', () => { map.zoomTo(12, {duration: 0}); await zoomendPromise; expect(geolocate._circleElement.style.width).toBe('79px'); - console.log(geolocate._circleElement.style.width); zoomendPromise = map.once('zoomend'); map.zoomTo(10, {duration: 0}); await zoomendPromise; expect(geolocate._circleElement.style.width).toBe('20px'); - console.log(geolocate._circleElement.style.width); zoomendPromise = map.once('zoomend'); // test with smaller radius @@ -487,12 +471,10 @@ describe('GeolocateControl with no options', () => { map.zoomTo(20, {duration: 0}); await zoomendPromise; expect(geolocate._circleElement.style.width).toBe('19982px'); - console.log(geolocate._circleElement.style.width); zoomendPromise = map.once('zoomend'); map.zoomTo(18, {duration: 0}); await zoomendPromise; expect(geolocate._circleElement.style.width).toBe('4996px'); - console.log(geolocate._circleElement.style.width); }); test('shown even if trackUserLocation = false', async () => { @@ -502,7 +484,7 @@ describe('GeolocateControl with no options', () => { showAccuracyCircle: true, }); map.addControl(geolocate); - + await sleep(0); const click = new window.Event('click'); const geolocatePromise = geolocate.once('geolocate'); diff --git a/src/ui/control/geolocate_control.ts b/src/ui/control/geolocate_control.ts index 1399435f87..88c9c1f131 100644 --- a/src/ui/control/geolocate_control.ts +++ b/src/ui/control/geolocate_control.ts @@ -234,7 +234,7 @@ export class GeolocateControl extends Evented implements IControl { onAdd(map: Map) { this._map = map; this._container = DOM.create('div', 'maplibregl-ctrl maplibregl-ctrl-group'); - checkGeolocationSupport(this._setupUI); + checkGeolocationSupport().then((supported) => this._setupUI(supported)); return this._container; } @@ -519,8 +519,7 @@ export class GeolocateControl extends Evented implements IControl { this._map.on('zoom', this._onZoom); } - this._geolocateButton.addEventListener('click', - this.trigger.bind(this)); + this._geolocateButton.addEventListener('click', () => this.trigger()); this._setup = true; diff --git a/src/ui/events.ts b/src/ui/events.ts index 8d2dd6fad3..ce73398947 100644 --- a/src/ui/events.ts +++ b/src/ui/events.ts @@ -438,7 +438,7 @@ export type MapStyleDataEvent = MapLibreEvent & { * * @group Event Related */ -export type MapSourceDataEvent = MapLibreEvent & { +export type MapSourceDataEvent = MapLibreEvent & { dataType: 'source'; /** * True if the event has a `dataType` of `source` and the source has no outstanding network requests. diff --git a/src/ui/handler/dblclick_zoom.test.ts b/src/ui/handler/dblclick_zoom.test.ts index 33f762a7f6..d4d231f9c3 100644 --- a/src/ui/handler/dblclick_zoom.test.ts +++ b/src/ui/handler/dblclick_zoom.test.ts @@ -55,37 +55,31 @@ describe('dbclick_zoom', () => { map.remove(); }); - test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300ms apart', done => { + test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300ms apart', async () => { const map = createMap(); const zoom = jest.fn(); map.on('zoomstart', zoom); - simulateDoubleTap(map, 100).then(() => { - expect(zoom).toHaveBeenCalled(); - - map.remove(); - done(); - }); + await simulateDoubleTap(map, 100); + expect(zoom).toHaveBeenCalled(); + map.remove(); }); - test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', done => { + test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', async () => { const map = createMap(); const zoom = jest.fn(); map.on('zoom', zoom); - simulateDoubleTap(map, 500).then(() => { - expect(zoom).not.toHaveBeenCalled(); - - map.remove(); - done(); - }); + await simulateDoubleTap(map, 500); + expect(zoom).not.toHaveBeenCalled(); + map.remove(); }); - test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are in different locations', done => { + test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are in different locations', async () => { const map = createMap(); const zoom = jest.fn(); @@ -93,26 +87,20 @@ describe('dbclick_zoom', () => { const canvas = map.getCanvas(); - const simulateTwoDifferentTaps = () => { - return new Promise(resolve => { - simulate.touchstart(canvas, {touches: [{clientX: 0, clientY: 0}]}); + await new Promise(resolve => { + simulate.touchstart(canvas, {touches: [{clientX: 0, clientY: 0}]}); + simulate.touchend(canvas); + setTimeout(() => { + simulate.touchstart(canvas, {touches: [{clientX: 30.5, clientY: 30.5}]}); simulate.touchend(canvas); - setTimeout(() => { - simulate.touchstart(canvas, {touches: [{clientX: 30.5, clientY: 30.5}]}); - simulate.touchend(canvas); - map._renderTaskQueue.run(); - resolve(undefined); - }, 100); - }); - }; - - simulateTwoDifferentTaps().then(() => { - expect(zoom).not.toHaveBeenCalled(); - - map.remove(); - done(); + map._renderTaskQueue.run(); + resolve(undefined); + }, 100); }); + expect(zoom).not.toHaveBeenCalled(); + + map.remove(); }); test('DoubleClickZoomHandler zooms on the second touchend event of a double tap', () => { @@ -149,7 +137,7 @@ describe('dbclick_zoom', () => { }); - test('DoubleClickZoomHandler does not zoom on double tap if second touchend is >300ms after first touchstart', done => { + test('DoubleClickZoomHandler does not zoom on double tap if second touchend is >300ms after first touchstart', async () => { const map = createMap(); const zoom = jest.fn(); @@ -157,23 +145,17 @@ describe('dbclick_zoom', () => { const canvas = map.getCanvas(); - const simulateSlowSecondTap = () => { - return new Promise(resolve => { - simulate.touchstart(canvas); + await new Promise(resolve => { + simulate.touchstart(canvas); + simulate.touchend(canvas); + simulate.touchstart(canvas); + setTimeout(() => { simulate.touchend(canvas); - simulate.touchstart(canvas); - setTimeout(() => { - simulate.touchend(canvas); - map._renderTaskQueue.run(); - resolve(undefined); - }, 300); - }); - }; - - simulateSlowSecondTap().then(() => { - expect(zoom).not.toHaveBeenCalled(); - - done(); + map._renderTaskQueue.run(); + resolve(undefined); + }, 300); }); + + expect(zoom).not.toHaveBeenCalled(); }); }); diff --git a/src/ui/map.test.ts b/src/ui/map.test.ts index ad577fc75e..3676fef015 100755 --- a/src/ui/map.test.ts +++ b/src/ui/map.test.ts @@ -1,5 +1,5 @@ import {Map, MapOptions} from './map'; -import {createMap, setErrorWebGlContext, beforeMapTest} from '../util/test/util'; +import {createMap, setErrorWebGlContext, beforeMapTest, sleep} from '../util/test/util'; import {LngLat} from '../geo/lng_lat'; import {Tile} from '../source/tile'; import {OverscaledTileID} from '../source/tile_id'; @@ -288,7 +288,7 @@ describe('Map', () => { }); - test('setStyle back to the first style should work', done => { + test('setStyle back to the first style should work', async () => { const redStyle = {version: 8 as const, sources: {}, layers: [ {id: 'background', type: 'background' as const, paint: {'background-color': 'red'}}, ]}; @@ -296,13 +296,13 @@ describe('Map', () => { {id: 'background', type: 'background' as const, paint: {'background-color': 'blue'}}, ]}; const map = createMap({style: redStyle}); + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); map.setStyle(blueStyle); - map.once('style.load', () => { - map.setStyle(redStyle); - const serializedStyle = map.style.serialize(); - expect(serializedStyle.layers[0].paint['background-color']).toBe('red'); - done(); - }); + await map.once('style.load'); + map.setStyle(redStyle); + const serializedStyle = map.style.serialize(); + expect(serializedStyle.layers[0].paint['background-color']).toBe('red'); + spy.mockRestore(); }); test('style transform overrides unmodified map transform', done => { @@ -554,39 +554,42 @@ describe('Map', () => { describe('#is_Loaded', () => { - test('Map#isSourceLoaded', done => { + test('Map#isSourceLoaded', async () => { const style = createStyle(); const map = createMap({style}); - map.on('load', () => { + await map.once('load'); + const promise = new Promise((resolve) => { map.on('data', (e) => { if (e.dataType === 'source' && e.sourceDataType === 'idle') { expect(map.isSourceLoaded('geojson')).toBe(true); - done(); + resolve(); } }); - map.addSource('geojson', createStyleSource()); - expect(map.isSourceLoaded('geojson')).toBe(false); }); + map.addSource('geojson', createStyleSource()); + expect(map.isSourceLoaded('geojson')).toBe(false); + await promise; }); - test('Map#isSourceLoaded (equivalent to event.isSourceLoaded)', done => { + test('Map#isSourceLoaded (equivalent to event.isSourceLoaded)', async () => { const style = createStyle(); const map = createMap({style}); - map.on('load', () => { - map.on('data', (e) => { + await map.once('load'); + const promise = new Promise((resolve) => { + map.on('data', (e: MapSourceDataEvent) => { if (e.dataType === 'source' && 'source' in e) { - const sourceDataEvent = e as MapSourceDataEvent; - expect(map.isSourceLoaded('geojson')).toBe(sourceDataEvent.isSourceLoaded); - if (sourceDataEvent.sourceDataType === 'idle') { - done(); + expect(map.isSourceLoaded('geojson')).toBe(e.isSourceLoaded); + if (e.sourceDataType === 'idle') { + resolve(); } } }); - map.addSource('geojson', createStyleSource()); - expect(map.isSourceLoaded('geojson')).toBe(false); }); + map.addSource('geojson', createStyleSource()); + expect(map.isSourceLoaded('geojson')).toBe(false); + await promise; }); test('Map#isStyleLoaded', done => { @@ -930,7 +933,7 @@ describe('Map', () => { observerCallback(); observerCallback(); expect(resizeSpy).toHaveBeenCalledTimes(1); - await new Promise((resolve) => { setTimeout(resolve, 100); }); + await sleep(100); expect(resizeSpy).toHaveBeenCalledTimes(2); }); @@ -1519,6 +1522,7 @@ describe('Map', () => { map.style.dispatcher.broadcast = function (key, value: any) { expect(key).toBe('updateLayers'); expect(value.layers.map((layer) => { return layer.id; })).toEqual(['symbol']); + return Promise.resolve({} as any); }; map.setLayoutProperty('symbol', 'text-transform', 'lowercase'); @@ -2205,9 +2209,11 @@ describe('Map', () => { describe('error event', () => { test('logs errors to console when it has NO listeners', () => { + // to avoid seeing error in the console in Jest + let stub = jest.spyOn(console, 'error').mockImplementation(() => {}); const map = createMap(); - const stub = jest.spyOn(console, 'error').mockImplementation(() => {}); stub.mockReset(); + stub = jest.spyOn(console, 'error').mockImplementation(() => {}); const error = new Error('test'); map.fire(new ErrorEvent(error)); expect(stub).toHaveBeenCalledTimes(1); @@ -2387,7 +2393,7 @@ describe('Map', () => { }); - test('map fires `styleimagemissing` for missing icons', done => { + test('map fires `styleimagemissing` for missing icons', async () => { const map = createMap(); const id = 'missing-image'; @@ -2402,14 +2408,12 @@ describe('Map', () => { expect(map.hasImage(id)).toBeFalsy(); - map.style.imageManager.getImages([id], (alwaysNull, generatedImage) => { - expect(generatedImage[id].data.width).toEqual(sampleImage.width); - expect(generatedImage[id].data.height).toEqual(sampleImage.height); - expect(generatedImage[id].data.data).toEqual(sampleImage.data); - expect(called).toBe(id); - expect(map.hasImage(id)).toBeTruthy(); - done(); - }); + const generatedImage = await map.style.imageManager.getImages([id]); + expect(generatedImage[id].data.width).toEqual(sampleImage.width); + expect(generatedImage[id].data.height).toEqual(sampleImage.height); + expect(generatedImage[id].data.data).toEqual(sampleImage.data); + expect(called).toBe(id); + expect(map.hasImage(id)).toBeTruthy(); }); test('map getImage matches addImage, uintArray', () => { diff --git a/src/ui/map.ts b/src/ui/map.ts index 4ecd20d645..ab33791f36 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -3,7 +3,7 @@ import {browser} from '../util/browser'; import {DOM} from '../util/dom'; import packageJSON from '../../package.json' assert {type: 'json'}; -import {getJSON} from '../util/ajax'; +import {GetResourceResponse, getJSON} from '../util/ajax'; import {ImageRequest} from '../util/image_request'; import type {GetImageCallback} from '../util/image_request'; @@ -49,7 +49,6 @@ import type {DoubleClickZoomHandler} from './handler/shim/dblclick_zoom'; import type {TwoFingersTouchZoomRotateHandler} from './handler/shim/two_fingers_touch'; import {defaultLocale} from './default_locale'; import type {TaskID} from '../util/task_queue'; -import type {Cancelable} from '../types/cancelable'; import type { FilterSpecification, StyleSpecification, @@ -468,7 +467,7 @@ export class Map extends Camera { _canvas: HTMLCanvasElement; _maxTileCacheSize: number; _maxTileCacheZoomLevels: number; - _frame: Cancelable; + _frameRequest: AbortController; _styleDirty: boolean; _sourcesDirty: boolean; _placementDirty: boolean; @@ -1828,11 +1827,11 @@ export class Map extends Camera { if (typeof style === 'string') { const url = style; const request = this._requestManager.transformRequest(url, ResourceType.Style); - getJSON(request, (error?: Error | null, json?: any | null) => { + getJSON(request, new AbortController()).then((response) => { + this._updateDiff(response.data, options); + }).catch((error) => { if (error) { this.fire(new ErrorEvent(error)); - } else if (json) { - this._updateDiff(json, options); } }); } else if (typeof style === 'object') { @@ -2305,21 +2304,25 @@ export class Map extends Camera { * domains must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS). * * @param url - The URL of the image file. Image file must be in png, webp, or jpg format. - * @param callback - Expecting `callback(error, data)`. Called when the image has loaded or with an error argument if there is an error. + * @param callback - if not provided, this method will return a promise, otherwise use `callback(error, data)`. Called when the image has loaded or with an error argument if there is an error. * * @example * Load an image from an external URL. * ```ts - * map.loadImage('http://placekitten.com/50/50', function(error, image) { - * if (error) throw error; - * // Add the loaded image to the style's sprite with the ID 'kitten'. - * map.addImage('kitten', image); - * }); + * const response = await map.loadImage('http://placekitten.com/50/50'); + * // Add the loaded image to the style's sprite with the ID 'kitten'. + * map.addImage('kitten', response.data); * ``` * @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); + loadImage(url: string, callback?: GetImageCallback): Promise> { + if (!callback) { + return ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController()); + } else { + ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController()) + .then((response) => callback(null, response.data, {cacheControl: response.cacheControl, expires: response.expires})) + .catch(callback); + } } /** @@ -3038,9 +3041,9 @@ export class Map extends Camera { _contextLost = (event: any) => { event.preventDefault(); - if (this._frame) { - this._frame.cancel(); - this._frame = null; + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; } this.fire(new Event('webglcontextlost', {originalEvent: event})); }; @@ -3253,9 +3256,9 @@ export class Map extends Camera { redraw(): this { if (this.style) { // cancel the scheduled update - if (this._frame) { - this._frame.cancel(); - this._frame = null; + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; } this._render(0); } @@ -3277,9 +3280,9 @@ export class Map extends Camera { for (const control of this._controls) control.onRemove(this); this._controls = []; - if (this._frame) { - this._frame.cancel(); - this._frame = null; + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; } this._renderTaskQueue.clear(); this.painter.destroy(); @@ -3322,12 +3325,13 @@ export class Map extends Camera { * @see [Add an animated icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-image-animated/) */ triggerRepaint() { - if (this.style && !this._frame) { - this._frame = browser.frame((paintStartTimeStamp: number) => { + if (this.style && !this._frameRequest) { + this._frameRequest = new AbortController(); + browser.frameAsync(this._frameRequest).then((paintStartTimeStamp: number) => { PerformanceUtils.frame(paintStartTimeStamp); - this._frame = null; + this._frameRequest = null; this._render(paintStartTimeStamp); - }); + }).catch(() => {}); // ignore abort error } } diff --git a/src/util/abort_error.ts b/src/util/abort_error.ts new file mode 100644 index 0000000000..0deb738f8c --- /dev/null +++ b/src/util/abort_error.ts @@ -0,0 +1,21 @@ +/** + * An error message to use when an operation is aborted + */ +export const ABORT_ERROR = 'AbortError'; + +/** + * Check if an error is an abort error + * @param error - An error object + * @returns - true if the error is an abort error + */ +export function isAbortError(error: Error): boolean { + return error.message === ABORT_ERROR; +} + +/** + * Use this when you need to create an abort error. + * @returns An error object with the message "AbortError" + */ +export function createAbortError(): Error { + return new Error(ABORT_ERROR); +} diff --git a/src/util/actor.test.ts b/src/util/actor.test.ts index 06ad375df5..d1759aea8d 100644 --- a/src/util/actor.test.ts +++ b/src/util/actor.test.ts @@ -1,106 +1,189 @@ import {Actor, ActorTarget} from './actor'; -import {workerFactory} from './web_worker'; -import {MessageBus} from '../../test/unit/lib/web_worker_mock'; - -const originalWorker = global.Worker; - -function setTestWorker(MockWorker: { new(...args: any): any}) { - (global as any).Worker = function Worker(_: string) { - const parentListeners = []; - const workerListeners = []; - const parentBus = new MessageBus(workerListeners, parentListeners); - const workerBus = new MessageBus(parentListeners, workerListeners); - - parentBus.target = workerBus; - workerBus.target = parentBus; - - new MockWorker(workerBus); - - return parentBus; - }; +import {WorkerGlobalScopeInterface, workerFactory} from './web_worker'; +import {setGlobalWorker} from '../../test/unit/lib/web_worker_mock'; +import {sleep} from './test/util'; +import {ABORT_ERROR, createAbortError} from './abort_error'; + +class MockWorker { + self: any; + actor: Actor; + constructor(self) { + this.self = self; + this.actor = new Actor(self); + } } describe('Actor', () => { + let originalWorker; + beforeAll(() => { + originalWorker = global.Worker; + setGlobalWorker(MockWorker); + }); afterAll(() => { global.Worker = originalWorker; }); - test('forwards responses to correct callback', done => { - setTestWorker(class MockWorker { - self: any; - actor: Actor; - constructor(self) { - this.self = self; - this.actor = new Actor(self, this); - } - getTile(mapId, params, callback) { - setTimeout(callback, 0, null, params); - } - getWorkerSource() { return null; } + test('forwards responses to correct handler', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', async (_mapId, params) => { + await sleep(0); + return params.clusterId; }); - const worker = workerFactory(); + const m1 = new Actor(worker, '1'); + const m2 = new Actor(worker, '2'); - const m1 = new Actor(worker, {} as any, '1'); - const m2 = new Actor(worker, {} as any, '2'); + const p1 = m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}}); + const p2 = m2.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 4104}}); - let callbackCount = 0; - m1.send('getTile', {value: 1729}, (err, response) => { - expect(err).toBeFalsy(); - expect(response).toEqual({value: 1729}); - callbackCount++; - if (callbackCount === 2) { - done(); - } + await Promise.all([p1, p2]); + await expect(p1).resolves.toBe(1729); + await expect(p2).resolves.toBe(4104); + }); + + 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 sleep(200); + return params.clusterId; }); - m2.send('getTile', {value: 4104}, (err, response) => { - expect(err).toBeFalsy(); - expect(response).toEqual({value: 4104}); - callbackCount++; - if (callbackCount === 2) { - done(); - } + + const m1 = new Actor(worker, '1'); + + let received = false; + const abortController = new AbortController(); + const p1 = m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}}, abortController) + .then(() => { received = true; }) + .catch(() => { received = true; }); + + abortController.abort(); + + const p2 = new Promise((resolve) => (setTimeout(resolve, 500))); + + await Promise.any([p1, p2]); + expect(received).toBeFalsy(); + }); + + test('aborting a request will successfully abort it', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + let gotAbortSignal = false; + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', (_mapId, _params, handlerAbortController) => { + return new Promise((resolve, reject) => { + handlerAbortController.signal.addEventListener('abort', () => { + gotAbortSignal = true; + reject(createAbortError()); + }); + setTimeout(resolve, 200); + }); }); + + const m1 = new Actor(worker, '1'); + + let received = false; + const abortController = new AbortController(); + m1.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}}, abortController) + .then(() => { received = true; }) + .catch(() => { received = true; }); + + abortController.abort(); + + await sleep(500); + + expect(received).toBeFalsy(); + expect(gotAbortSignal).toBeTruthy(); }); - test('targets worker-initiated messages to correct map instance', done => { - let workerActor; + test('cancel a request that must be queued will not call the method at all', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); - setTestWorker(class MockWorker { - self: any; - actor: Actor; - constructor(self) { - this.self = self; - this.actor = workerActor = new Actor(self, this); - } - getWorkerSource() { return null; } - }); + const spy = jest.fn().mockReturnValue(Promise.resolve({})); + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy); - const worker = workerFactory(); + let received = false; + const abortController = new AbortController(); + const p1 = actor.sendAsync({type: 'getClusterExpansionZoom', data: {type: 'geojson', source: '', clusterId: 1729}, mustQueue: true}, abortController) + .then(() => { received = true; }) + .catch(() => { received = true; }); - new Actor(worker, { - test () { done(); } - } as any, '1'); - new Actor(worker, { - test () { - done('test failed'); - } - } as any, '2'); + abortController.abort(); + + const p2 = new Promise((resolve) => (setTimeout(resolve, 500))); - workerActor.send('test', {}, () => {}, '1'); + await Promise.any([p1, p2]); + expect(received).toBeFalsy(); + expect(spy).not.toHaveBeenCalled(); }); test('#remove unbinds event listener', done => { const actor = new Actor({ - addEventListener (type, callback, useCapture) { + addEventListener(type, callback, useCapture) { this._addEventListenerArgs = [type, callback, useCapture]; }, - removeEventListener (type, callback, useCapture) { + removeEventListener(type, callback, useCapture) { expect([type, callback, useCapture]).toEqual(this._addEventListenerArgs); done(); } - } as ActorTarget, {} as any, null); + } as ActorTarget, null); actor.remove(); }); + test('send a messege that is rejected', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); + + worker.worker.actor.registerMessageHandler('abortTile', () => Promise.reject(createAbortError())); + + await expect(async () => actor.sendAsync({type: 'abortTile', data: {} as any})).rejects.toThrow(ABORT_ERROR); + }); + + test('send a messege that must be queued, it should still arrive', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); + + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', () => Promise.resolve(42)); + + const response = await actor.sendAsync({type: 'getClusterExpansionZoom', data: {} as any, mustQueue: true}); + + expect(response).toBe(42); + }); + + test('send a messege is not registered should throw', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); + + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', () => Promise.resolve(42)); + + await expect(async () => actor.sendAsync({type: 'abortTile', data: {} as any})).rejects.toThrow(/Could not find a registered handler for.*/); + }); + + test('should not process a message with the wrong map id', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); + + worker.worker.actor.mapId = '2'; + + const spy = jest.fn().mockReturnValue(Promise.resolve({})); + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy); + + actor.sendAsync({type: 'getClusterExpansionZoom', data: {} as any, targetMapId: '1'}); + + await sleep(100); + + expect(spy).not.toHaveBeenCalled(); + }); + + test('should not process a message with the wrong origin', async () => { + const worker = workerFactory() as any as WorkerGlobalScopeInterface & ActorTarget; + const actor = new Actor(worker, '1'); + + const spy = jest.fn().mockReturnValue(Promise.resolve({})); + worker.worker.actor.registerMessageHandler('getClusterExpansionZoom', spy); + + actor.target.postMessage({type: 'getClusterExpansionZoom', data: {} as any, origin: 'https://example.com'}); + + await sleep(100); + + expect(spy).not.toHaveBeenCalled(); + }); }); diff --git a/src/util/actor.ts b/src/util/actor.ts index 5bdc7d3291..654bcf2526 100644 --- a/src/util/actor.ts +++ b/src/util/actor.ts @@ -1,14 +1,13 @@ -import {isWorker} from './util'; +import {Subscription, isWorker, subscribe} from './util'; import {serialize, deserialize, Serialized} from './web_worker_transfer'; import {ThrottledInvoker} from './throttled_invoker'; import type {Transferable} from '../types/transferable'; -import type {Cancelable} from '../types/cancelable'; -import type {WorkerSource} from '../source/worker_source'; -import type {OverscaledTileID} from '../source/tile_id'; -import type {Callback} from '../types/callback'; -import type {StyleGlyph} from '../style/style_glyph'; +import type {ActorMessage, MessageType, RequestResponseMessageMap} from './actor_messages'; +/** + * An interface to be sent to the actor in order for it to allow communication between the worker and the main thread + */ export interface ActorTarget { addEventListener: typeof window.addEventListener; removeEventListener: typeof window.removeEventListener; @@ -16,173 +15,159 @@ export interface ActorTarget { terminate?: () => void; } -export interface WorkerSourceProvider { - getWorkerSource(mapId: string | number, sourceType: string, sourceName: string): WorkerSource; -} - -export interface GlyphsProvider { - getGlyphs(mapId: string, params: { - stacks: {[_: string]: Array}; - source: string; - tileID: OverscaledTileID; - type: string; - }, - callback: Callback<{[_: string]: {[_: number]: StyleGlyph}}> - ); -} - -export type MessageType = '' | '' | -'geojson.getClusterExpansionZoom' | 'geojson.getClusterChildren' | 'geojson.getClusterLeaves' | 'geojson.loadData' | -'removeSource' | 'loadWorkerSource' | 'loadDEMTile' | 'removeDEMTile' | -'removeTile' | 'reloadTile' | 'abortTile' | 'loadTile' | 'getTile' | -'getGlyphs' | 'getImages' | 'setImages' | -'syncRTLPluginState' | 'setReferrer' | 'setLayers' | 'updateLayers'; - -export type MessageData = { +/** + * This is used to define the parameters of the message that is sent to the worker and back + */ +type MessageData = { id: string; - type: MessageType; + type: MessageType | '' | ''; + origin: string; data?: Serialized; targetMapId?: string | number | null; mustQueue?: boolean; error?: Serialized | null; - hasCallback?: boolean; sourceMapId: string | number | null; } -export type Message = { - data: MessageData; +type ResolveReject = { + resolve: (value?: RequestResponseMessageMap[MessageType][1]) => void; + reject: (reason?: Error) => void; } +/** + * This interface allowing to substitute only the sendAsync method of the Actor class. + */ +export interface IActor { + sendAsync(message: ActorMessage, abortController?: AbortController): Promise; +} + +export type MessageHandler = (mapId: string | number, params: RequestResponseMessageMap[T][0], abortController?: AbortController) => Promise + /** * An implementation of the [Actor design pattern](http://en.wikipedia.org/wiki/Actor_model) * that maintains the relationship between asynchronous tasks and the objects * that spin them off - in this case, tasks like parsing parts of styles, * owned by the styles */ -export class Actor { +export class Actor implements IActor { target: ActorTarget; - parent: WorkerSourceProvider | GlyphsProvider; mapId: string | number | null; - callbacks: { [x: number]: Function}; + resolveRejects: { [x: string]: ResolveReject}; name: string; tasks: { [x: number]: MessageData }; taskQueue: Array; - cancelCallbacks: { [x: number]: () => void }; + abortControllers: { [x: number | string]: AbortController }; invoker: ThrottledInvoker; globalScope: ActorTarget; + messageHandlers: { [x in MessageType]?: MessageHandler}; + subscription: Subscription; /** * @param target - The target * @param parent - The parent * @param mapId - A unique identifier for the Map instance using this Actor. */ - constructor(target: ActorTarget, parent: WorkerSourceProvider | GlyphsProvider, mapId?: string | number) { + constructor(target: ActorTarget, mapId?: string | number) { this.target = target; - this.parent = parent; this.mapId = mapId; - this.callbacks = {}; + this.resolveRejects = {}; this.tasks = {}; this.taskQueue = []; - this.cancelCallbacks = {}; - this.invoker = new ThrottledInvoker(this.process); - this.target.addEventListener('message', this.receive, false); - this.globalScope = isWorker() ? target : window; + this.abortControllers = {}; + this.messageHandlers = {}; + this.invoker = new ThrottledInvoker(() => this.process()); + this.subscription = subscribe(this.target, 'message', (message) => this.receive(message), false); + this.globalScope = isWorker(self) ? target : window; + } + + registerMessageHandler(type: T, handler: MessageHandler) { + this.messageHandlers[type] = handler; } /** * Sends a message from a main-thread map to a Worker or from a Worker back to * a main-thread map instance. - * - * @param type - The name of the target method to invoke or '[source-type].[source-name].name' for a method on a WorkerSource. - * @param targetMapId - A particular mapId to which to send this message. + * @param message - the message to send + * @param abortController - an optional AbortController to abort the request + * @returns a promise that will be resolved with the response data */ - send( - type: MessageType, - data: unknown, - callback?: Function | null, - targetMapId?: string | null, - mustQueue: boolean = false - ): Cancelable { - // We're using a string ID instead of numbers because they are being used as object keys - // anyway, and thus stringified implicitly. We use random IDs because an actor may receive - // message from multiple other actors which could run in different execution context. A - // linearly increasing ID could produce collisions. - const id = Math.round((Math.random() * 1e18)).toString(36).substring(0, 10); - if (callback) { - this.callbacks[id] = callback; - } - const buffers: Array = []; - const message: MessageData = { - id, - type, - hasCallback: !!callback, - targetMapId, - mustQueue, - sourceMapId: this.mapId, - data: serialize(data, buffers) - }; - - this.target.postMessage(message, {transfer: buffers}); - return { - cancel: () => { - if (callback) { - // Set the callback to null so that it never fires after the request is aborted. - delete this.callbacks[id]; - } - const cancelMessage: MessageData = { - id, - type: '', - targetMapId, - sourceMapId: this.mapId - }; - this.target.postMessage(cancelMessage); + sendAsync(message: ActorMessage, abortController?: AbortController): Promise { + return new Promise((resolve, reject) => { + // We're using a string ID instead of numbers because they are being used as object keys + // anyway, and thus stringified implicitly. We use random IDs because an actor may receive + // message from multiple other actors which could run in different execution context. A + // linearly increasing ID could produce collisions. + const id = Math.round((Math.random() * 1e18)).toString(36).substring(0, 10); + this.resolveRejects[id] = { + resolve, + reject + }; + if (abortController) { + abortController.signal.addEventListener('abort', () => { + delete this.resolveRejects[id]; + const cancelMessage: MessageData = { + id, + type: '', + origin: location.origin, + targetMapId: message.targetMapId, + sourceMapId: this.mapId + }; + this.target.postMessage(cancelMessage); + // In case of abort the current behavior is to keep the promise pending. + }, {once: true}); } - }; + const buffers: Array = []; + const messageToPost: MessageData = { + ...message, + id, + sourceMapId: this.mapId, + origin: location.origin, + data: serialize(message.data, buffers) + }; + this.target.postMessage(messageToPost, {transfer: buffers}); + }); } - receive = (message: Message) => { + receive(message: {data: MessageData}) { const data = message.data; const id = data.id; - - if (!id) { + if (data.origin !== location.origin) { return; } - if (data.targetMapId && this.mapId !== data.targetMapId) { return; } - if (data.type === '') { // Remove the original request from the queue. This is only possible if it // hasn't been kicked off yet. The id will remain in the queue, but because // there is no associated task, it will be dropped once it's time to execute it. delete this.tasks[id]; - const cancel = this.cancelCallbacks[id]; - delete this.cancelCallbacks[id]; - if (cancel) { - cancel(); - } - } else { - if (isWorker() || data.mustQueue) { - // In workers, store the tasks that we need to process before actually processing them. This - // is necessary because we want to keep receiving messages, and in particular, - // messages. Some tasks may take a while in the worker thread, so before - // executing the next task in our queue, postMessage preempts this and - // messages can be processed. We're using a MessageChannel object to get throttle the - // process() flow to one at a time. - this.tasks[id] = data; - this.taskQueue.push(id); - this.invoker.trigger(); - } else { - // In the main thread, process messages immediately so that other work does not slip in - // between getting partial data back from workers. - this.processTask(id, data); + const abortController = this.abortControllers[id]; + delete this.abortControllers[id]; + if (abortController) { + abortController.abort(); } + return; + } + if (isWorker(self) || data.mustQueue) { + // In workers, store the tasks that we need to process before actually processing them. This + // is necessary because we want to keep receiving messages, and in particular, + // messages. Some tasks may take a while in the worker thread, so before + // executing the next task in our queue, postMessage preempts this and + // messages can be processed. We're using a MessageChannel object to get throttle the + // process() flow to one at a time. + this.tasks[id] = data; + this.taskQueue.push(id); + this.invoker.trigger(); + return; } - }; + // In the main thread, process messages immediately so that other work does not slip in + // between getting partial data back from workers. + this.processTask(id, data); + } - process = () => { - if (!this.taskQueue.length) { + process() { + if (this.taskQueue.length === 0) { return; } const id = this.taskQueue.shift(); @@ -191,7 +176,7 @@ export class Actor { // Schedule another process call if we know there's more to process _before_ invoking the // current task. This is necessary so that processing continues even if the current task // doesn't execute successfully. - if (this.taskQueue.length) { + if (this.taskQueue.length > 0) { this.invoker.trigger(); } if (!task) { @@ -200,64 +185,56 @@ export class Actor { } this.processTask(id, task); - }; + } - processTask(id: string, task: MessageData) { + async processTask(id: string, task: MessageData) { if (task.type === '') { - // The done() function in the counterpart has been called, and we are now - // firing the callback in the originating actor, if there is one. - const callback = this.callbacks[id]; - delete this.callbacks[id]; - if (callback) { - // If we get a response, but don't have a callback, the request was canceled. - if (task.error) { - callback(deserialize(task.error)); - } else { - callback(null, deserialize(task.data)); - } + // The `completeTask` function in the counterpart actor has been called, and we are now + // resolving or rejecting the promise in the originating actor, if there is one. + const resolveReject = this.resolveRejects[id]; + delete this.resolveRejects[id]; + if (!resolveReject) { + // If we get a response, but don't have a resolve or reject, the request was canceled. + return; } - } else { - let completed = false; - const buffers: Array = []; - const done = task.hasCallback ? (err: Error, data?: any) => { - completed = true; - delete this.cancelCallbacks[id]; - const responseMessage: MessageData = { - id, - type: '', - sourceMapId: this.mapId, - error: err ? serialize(err) : null, - data: serialize(data, buffers) - }; - this.target.postMessage(responseMessage, {transfer: buffers}); - } : (_) => { - completed = true; - }; - - let callback: Cancelable = null; - const params = deserialize(task.data); - if (this.parent[task.type]) { - // task.type == 'loadTile', 'removeTile', etc. - callback = this.parent[task.type](task.sourceMapId, params, done); - } else if ('getWorkerSource' in this.parent) { - // task.type == sourcetype.method - const keys = task.type.split('.'); - const scope = this.parent.getWorkerSource(task.sourceMapId, keys[0], (params as any).source); - callback = scope[keys[1]](params, done); + if (task.error) { + resolveReject.reject(deserialize(task.error) as Error); } else { - // No function was found. - done(new Error(`Could not find function ${task.type}`)); - } - - if (!completed && callback && callback.cancel) { - // Allows canceling the task as long as it hasn't been completed yet. - this.cancelCallbacks[id] = callback.cancel; + resolveReject.resolve(deserialize(task.data)); } + return; } + if (!this.messageHandlers[task.type]) { + this.completeTask(id, new Error(`Could not find a registered handler for ${task.type}`)); + return; + } + const params = deserialize(task.data) as RequestResponseMessageMap[MessageType][0]; + const abortController = new AbortController(); + this.abortControllers[id] = abortController; + try { + const data = await this.messageHandlers[task.type](task.sourceMapId, params, abortController); + this.completeTask(id, null, data); + } catch (err) { + this.completeTask(id, err); + } + } + + completeTask(id: string, err: Error, data?: RequestResponseMessageMap[MessageType][1]) { + const buffers: Array = []; + delete this.abortControllers[id]; + const responseMessage: MessageData = { + id, + type: '', + sourceMapId: this.mapId, + origin: location.origin, + error: err ? serialize(err) : null, + data: serialize(data, buffers) + }; + this.target.postMessage(responseMessage, {transfer: buffers}); } remove() { this.invoker.remove(); - this.target.removeEventListener('message', this.receive, false); + this.subscription.unsubscribe(); } } diff --git a/src/util/actor_messages.ts b/src/util/actor_messages.ts new file mode 100644 index 0000000000..474b565232 --- /dev/null +++ b/src/util/actor_messages.ts @@ -0,0 +1,124 @@ +import type {LoadGeoJSONParameters} from '../source/geojson_worker_source'; +import type {TileParameters, WorkerDEMTileParameters, WorkerTileParameters, WorkerTileResult} from '../source/worker_source'; +import type {DEMData} from '../data/dem_data'; +import type {StyleImage} from '../style/style_image'; +import type {StyleGlyph} from '../style/style_glyph'; +import type {PluginState} from '../source/rtl_text_plugin'; +import type {LayerSpecification} from '@maplibre/maplibre-gl-style-spec'; +import type {OverscaledTileID} from '../source/tile_id'; +import type {GetResourceResponse, RequestParameters} from './ajax'; + +/** + * The parameters needed in order to get information about the cluster + */ +export type ClusterIDAndSource = { + type: 'geojson'; + clusterId: number; + source: string; +}; + +/** + * Parameters needed to get the leaves of a cluster + */ +export type GetClusterLeavesParams = ClusterIDAndSource & { limit: number; offset: number }; + +/** + * The result of the call to load a geojson source + */ +export type GeoJSONWorkerSourceLoadDataResult = { + resourceTiming?: {[_: string]: Array}; + abandoned?: boolean; +}; + +/** + * Parameters needed to remove a source + */ +export type RemoveSourceParams = { + source: string; + type: string; +} + +/** + * Parameters needed to update the layers + */ +export type UpdateLayersParamaeters = { + layers: Array; + removedIds: Array; +} + +/** + * Parameters needed to get the images + */ +export type GetImagesParamerters = { + icons: Array; + source: string; + tileID: OverscaledTileID; + type: string; +} + +/** + * Parameters needed to get the glyphs + */ +export type GetGlyphsParamerters = { + type: string; + stacks: {[_: string]: Array}; + source: string; + tileID: OverscaledTileID; +} + +/** + * A response object returned when requesting glyphs + */ +export type GetGlyphsResponse = { + [stack: string]: { + [id: number]: StyleGlyph; + }; +} + +/** + * A response object returned when requesting images + */ +export type GetImagesResponse = {[_: string]: StyleImage} + +/** + * This is basically a mapping between all the calls that are made to and from the workers. + * The key is the event name, the first parameter is the event input type, and the last parameter is the output type. + */ +export type RequestResponseMessageMap = { + 'loadDEMTile': [WorkerDEMTileParameters, DEMData]; + 'getClusterExpansionZoom': [ClusterIDAndSource, number]; + 'getClusterChildren': [ClusterIDAndSource, Array]; + 'getClusterLeaves': [GetClusterLeavesParams, Array]; + 'loadData': [LoadGeoJSONParameters, GeoJSONWorkerSourceLoadDataResult]; + 'loadTile': [WorkerTileParameters, WorkerTileResult]; + 'reloadTile': [WorkerTileParameters, WorkerTileResult]; + 'getGlyphs': [GetGlyphsParamerters, GetGlyphsResponse]; + 'getImages': [GetImagesParamerters, GetImagesResponse]; + 'setImages': [string[], void]; + 'setLayers': [Array, void]; + 'updateLayers': [UpdateLayersParamaeters, void]; + 'syncRTLPluginState': [PluginState, boolean]; + 'setReferrer': [string, void]; + 'removeSource': [RemoveSourceParams, void]; + 'loadWorkerSource': [string, void]; + 'removeTile': [TileParameters, void]; + 'abortTile': [TileParameters, void]; + 'removeDEMTile': [TileParameters, void]; + 'getResource': [RequestParameters, GetResourceResponse]; +} + +/** + * All the possible message types that can be sent to and from the worker + */ +export type MessageType = keyof RequestResponseMessageMap; + +/** + * The message to be sent by the actor + */ +export type ActorMessage = { + type: T; + data: RequestResponseMessageMap[T][0]; + targetMapId?: string | number | null; + mustQueue?: boolean; + sourceMapId?: string | number | null; +}; diff --git a/src/util/ajax.test.ts b/src/util/ajax.test.ts index f3a6146cf6..c7df501a3b 100644 --- a/src/util/ajax.test.ts +++ b/src/util/ajax.test.ts @@ -1,13 +1,11 @@ import { getArrayBuffer, getJSON, - postData, AJAXError, sameOrigin } from './ajax'; import {fakeServer, type FakeServer} from 'nise'; -import {destroyFetchMock, FetchMock, RequestMock, setupFetchMock} from './test/mock_fetch'; function readAsText(blob) { return new Promise((resolve, reject) => { @@ -28,70 +26,66 @@ 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 => { + test('getJSON', async () => { server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, '{"foo": "bar"}'); }); - getJSON({url: ''}, (error, body) => { - expect(error).toBeFalsy(); - expect(body).toEqual({foo: 'bar'}); - done(); - }); + const promise = getJSON({url: ''}, new AbortController()); server.respond(); + + const body = await promise; + expect(body.data).toEqual({foo: 'bar'}); }); - test('getJSON, invalid syntax', done => { + test('getJSON, invalid syntax', async () => { server.respondWith(request => { request.respond(200, {'Content-Type': 'application/json'}, 'how do i even'); }); - getJSON({url: ''}, (error) => { - expect(error).toBeTruthy(); - done(); - }); + const promise = getJSON({url: ''}, new AbortController()); server.respond(); + try { + await promise; + } catch (error) { + expect(error).toBeTruthy(); + } }); - test('getJSON, 404', done => { + test('getJSON, 404', async () => { server.respondWith(request => { request.respond(404, undefined, '404 Not Found'); }); - getJSON({url: 'http://example.com/test.json'}, async (error) => { + const promise = getJSON({url: 'http://example.com/test.json'}, new AbortController()); + server.respond(); + + try { + 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.json'); expect(body).toBe('404 Not Found'); - done(); - }); - server.respond(); - }); - - 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', () => { @@ -144,34 +138,34 @@ describe('ajax', () => { }); describe('requests parameters', () => { - let fetch: FetchMock; - beforeEach(() => { - fetch = setupFetchMock(); - }); + test('should be provided to fetch API in getArrayBuffer function', async () => { + server.respondWith(new ArrayBuffer(1)); + + const promise = getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, new AbortController()); + server.respond(); + await promise; - afterEach(() => { - destroyFetchMock(); + 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 getArrayBuffer function', (done) => { - getArrayBuffer({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, () => { - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(expect.objectContaining({url: 'http://example.com/test-params.json', method: 'GET', cache: 'force-cache'})); - expect((fetch.mock.calls[0][0] as RequestMock).headers.get('Authorization')).toBe('Bearer 123'); + test('should be provided to fetch API in getJSON function', async () => { - done(); + server.respondWith(request => { + request.respond(200, {'Content-Type': 'application/json'}, '{"foo": "bar"}'); }); - }); - test('should be provided to fetch API in getJSON function', (done) => { - getJSON({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, () => { - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(expect.objectContaining({url: 'http://example.com/test-params.json', method: 'GET', cache: 'force-cache'})); - expect((fetch.mock.calls[0][0] as RequestMock).headers.get('Authorization')).toBe('Bearer 123'); + const promise = getJSON({url: 'http://example.com/test-params.json', cache: 'force-cache', headers: {'Authorization': 'Bearer 123'}}, new AbortController()); + server.respond(); + await promise; - done(); - }); + 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'); }); }); }); diff --git a/src/util/ajax.ts b/src/util/ajax.ts index 8119d208ca..bd27e08e92 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 {createAbortError} from './abort_error'; -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 @@ -38,7 +43,7 @@ export type RequestParameters = { */ body?: string; /** - * Response body type to be returned `'string' | 'json' | 'arrayBuffer'`. + * Response body type to be returned. */ type?: 'string' | 'json' | 'arrayBuffer' | 'image'; /** @@ -55,6 +60,13 @@ export type RequestParameters = { cache?: RequestCache; }; +/** + * The response object returned from a successful AJAx request + */ +export type GetResourceResponse = ExpiryData & { + data: T; +} + /** * The response callback used in various places */ @@ -62,7 +74,7 @@ export type ResponseCallback = ( error?: Error | null, data?: T | null, cacheControl?: string | null, - expires?: string | null + expires?: string | Date | null ) => void; /** @@ -104,24 +116,28 @@ export class AJAXError extends Error { } } -// Ensure that we're sending the correct referrer from blob URL worker bundles. -// For files loaded from the local file system, `location.origin` will be set -// to the string(!) "null" (Firefox), or "file://" (Chrome, Safari, Edge, IE), -// and we will set an empty referrer. Otherwise, we're using the document's URL. -/* global self */ -export const getReferrer = isWorker() ? - () => (self as any).worker && (self as any).worker.referrer : - () => (window.location.protocol === 'blob:' ? window.parent : window).location.href; +/** + * Ensure that we're sending the correct referrer from blob URL worker bundles. + * For files loaded from the local file system, `location.origin` will be set + * to the string(!) "null" (Firefox), or "file://" (Chrome, Safari, Edge), + * and we will set an empty referrer. Otherwise, we're using the document's URL. + */ +export const getReferrer = () => isWorker(self) ? + self.worker && self.worker.referrer : + (window.location.protocol === 'blob:' ? window.parent : window).location.href; export const getProtocolAction = url => config.REGISTERED_PROTOCOLS[url.substring(0, url.indexOf('://'))]; -// Determines whether a URL is a file:// URL. This is obviously the case if it begins -// with file://. Relative URLs are also file:// URLs iff the original document was loaded -// via a file:// URL. +/** + * Determines whether a URL is a file:// URL. This is obviously the case if it begins + * with file://. Relative URLs are also file:// URLs iff the original document was loaded + * via a file:// URL. + * @param url - The URL to check + * @returns `true` if the URL is a file:// URL, `false` otherwise + */ const isFileURL = url => /^file:/.test(url) || (/^file:/.test(getReferrer()) && !/^\w+:/.test(url)); -function makeFetchRequest(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { - const controller = new AbortController(); +async function makeFetchRequest(requestParameters: RequestParameters, abortController: AbortController): Promise> { const request = new Request(requestParameters.url, { method: requestParameters.method || 'GET', body: requestParameters.body, @@ -129,160 +145,142 @@ function makeFetchRequest(requestParameters: RequestParameters, callback: Respon headers: requestParameters.headers, cache: requestParameters.cache, referrer: getReferrer(), - signal: controller.signal + signal: abortController.signal }); - let complete = false; - let aborted = false; if (requestParameters.type === 'json') { request.headers.set('Accept', 'application/json'); } - const validateOrFetch = (err, cachedResponse?, responseIsFresh?) => { - if (aborted) return; + const response = await fetch(request); + if (!response.ok) { + const body = await response.blob(); + throw new AJAXError(response.status, response.statusText, requestParameters.url, body); + } + const parsePromise = (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') ? response.arrayBuffer() : + requestParameters.type === 'json' ? response.json() : + response.text(); + const result = await parsePromise; + if (abortController.signal.aborted) { + throw createAbortError(); + } + return {data: result, cacheControl: response.headers.get('Cache-Control'), expires: response.headers.get('Expires')}; +} + +function makeXMLHttpRequest(requestParameters: RequestParameters, abortController: AbortController): Promise> { + return new Promise((resolve, reject) => { + const xhr: XMLHttpRequest = new XMLHttpRequest(); - 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); - } + xhr.open(requestParameters.method || 'GET', requestParameters.url, true); + if (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') { + xhr.responseType = 'arraybuffer'; } - - if (cachedResponse && responseIsFresh) { - return finishRequest(cachedResponse); + for (const k in requestParameters.headers) { + xhr.setRequestHeader(k, requestParameters.headers[k]); } - - if (cachedResponse) { - // We can't do revalidation with 'If-None-Match' because then the - // request doesn't have simple cors headers. + if (requestParameters.type === 'json') { + xhr.responseType = 'text'; + xhr.setRequestHeader('Accept', 'application/json'); } - - fetch(request).then(response => { - if (response.ok) { - return finishRequest(response); - - } else { - return response.blob().then(body => callback(new AJAXError(response.status, response.statusText, requestParameters.url, body))); - } - }).catch(error => { - if (error.code === 20) { - // silence expected AbortError + xhr.withCredentials = requestParameters.credentials === 'include'; + xhr.onerror = () => { + reject(new Error(xhr.statusText)); + }; + xhr.onload = () => { + if (abortController.signal.aborted) { 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); - - return {cancel: () => { - aborted = true; - if (!complete) controller.abort(); - }}; -} - -function makeXMLHttpRequest(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { - const xhr: XMLHttpRequest = new XMLHttpRequest(); - - xhr.open(requestParameters.method || 'GET', requestParameters.url, true); - if (requestParameters.type === 'arrayBuffer' || requestParameters.type === 'image') { - xhr.responseType = 'arraybuffer'; - } - for (const k in requestParameters.headers) { - xhr.setRequestHeader(k, requestParameters.headers[k]); - } - if (requestParameters.type === 'json') { - xhr.responseType = 'text'; - xhr.setRequestHeader('Accept', 'application/json'); - } - xhr.withCredentials = requestParameters.credentials === 'include'; - xhr.onerror = () => { - callback(new Error(xhr.statusText)); - }; - xhr.onload = () => { - if (((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) && xhr.response !== null) { - let data: unknown = xhr.response; - if (requestParameters.type === 'json') { - // We're manually parsing JSON here to get better error messages. - try { - data = JSON.parse(xhr.response); - } catch (err) { - return callback(err); + if (((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) && xhr.response !== null) { + let data: unknown = xhr.response; + if (requestParameters.type === 'json') { + // We're manually parsing JSON here to get better error messages. + try { + data = JSON.parse(xhr.response); + } catch (err) { + reject(err); + return; + } } + resolve({data, cacheControl: xhr.getResponseHeader('Cache-Control'), expires: xhr.getResponseHeader('Expires')}); + } else { + const body = new Blob([xhr.response], {type: xhr.getResponseHeader('Content-Type')}); + reject(new AJAXError(xhr.status, xhr.statusText, requestParameters.url, body)); } - callback(null, data, xhr.getResponseHeader('Cache-Control'), xhr.getResponseHeader('Expires')); - } else { - const body = new Blob([xhr.response], {type: xhr.getResponseHeader('Content-Type')}); - callback(new AJAXError(xhr.status, xhr.statusText, requestParameters.url, body)); - } - }; - xhr.send(requestParameters.body); - return {cancel: () => xhr.abort()}; + }; + abortController.signal.addEventListener('abort', () => { + xhr.abort(); + reject(createAbortError()); + }); + xhr.send(requestParameters.body); + }); } -export const makeRequest = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { - // We're trying to use the Fetch API if possible. However, in some situations we can't use it: - // - IE11 doesn't support it at all. In this case, we dispatch the request to the main thread so - // that we can get an accruate referrer header. - // - Safari exposes window.AbortController, but it doesn't work actually abort any requests in - // some versions (see https://bugs.webkit.org/show_bug.cgi?id=174980#c2) - // - Requests for resources with the file:// URI scheme don't work with the Fetch API either. In - // this case we unconditionally use XHR on the current thread since referrers don't matter. +/** + * We're trying to use the Fetch API if possible. However, requests for resources with the file:// URI scheme don't work with the Fetch API. + * In this case we unconditionally use XHR on the current thread since referrers don't matter. + * This method can also use the registered method if `addProtocol` was called. + * @param requestParameters - The request parameters + * @param abortController - The abort controller allowing to cancel the request + * @returns a promise resolving to the response, including cache control and expiry data + */ +export const makeRequest = function(requestParameters: RequestParameters, abortController: AbortController): Promise> { if (/:\/\//.test(requestParameters.url) && !(/^https?:|^file:/.test(requestParameters.url))) { - if (isWorker() && (self as any).worker && (self as any).worker.actor) { - return (self as any).worker.actor.send('getResource', requestParameters, callback); + if (isWorker(self) && self.worker && self.worker.actor) { + return self.worker.actor.sendAsync({type: 'getResource', data: requestParameters}, abortController); } - if (!isWorker()) { - const action = getProtocolAction(requestParameters.url) || makeFetchRequest; - return action(requestParameters, callback); + if (!isWorker(self) && getProtocolAction(requestParameters.url)) { + return promiseFromAddProtocolCallback(getProtocolAction(requestParameters.url))(requestParameters, abortController); } } if (!isFileURL(requestParameters.url)) { if (fetch && Request && AbortController && Object.prototype.hasOwnProperty.call(Request.prototype, 'signal')) { - return makeFetchRequest(requestParameters, callback); + return silenceOnAbort(makeFetchRequest(requestParameters, abortController), abortController); } - if (isWorker() && (self as any).worker && (self as any).worker.actor) { - const queueOnMainThread = true; - return (self as any).worker.actor.send('getResource', requestParameters, callback, undefined, queueOnMainThread); + if (isWorker(self) && self.worker && self.worker.actor) { + return self.worker.actor.sendAsync({type: 'getResource', data: requestParameters, mustQueue: true}, abortController); } } - return makeXMLHttpRequest(requestParameters, callback); + return makeXMLHttpRequest(requestParameters, abortController); }; -export const getJSON = function(requestParameters: RequestParameters, callback: ResponseCallback): Cancelable { - return makeRequest(extend(requestParameters, {type: 'json'}), callback); -}; +// This needs to be removed in general, see #3308 +function silenceOnAbort(promise: Promise, abortController: AbortController): Promise { + return new Promise((resolve, reject) => { + promise + .then(result => { if (!abortController.signal.aborted) resolve(result); }) + .catch(error => { if (!abortController.signal.aborted) reject(error); }); + }); +} + +function promiseFromAddProtocolCallback(method: (requestParameters: RequestParameters, callback: ResponseCallback) => Cancelable): (requestParameters: RequestParameters, abortController: AbortController) => Promise> { + return (requestParameters: RequestParameters, abortController: AbortController): Promise> => { + return new Promise>((resolve, reject) => { + const callback = (err: Error, data: any, cacheControl: string | null, expires: string | null) => { + if (err) { + reject(err); + } else { + resolve({data, cacheControl, expires}); + } + }; + const canelable = method(requestParameters, callback); + abortController.signal.addEventListener('abort', () => { + canelable.cancel(); + reject(createAbortError()); + }); + }); + }; +} -export const getArrayBuffer = function( - requestParameters: RequestParameters, - callback: ResponseCallback -): Cancelable { - return makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), callback); +export const getJSON = (requestParameters: RequestParameters, abortController: AbortController): Promise<{data: T} & ExpiryData> => { + return makeRequest(extend(requestParameters, {type: 'json'}), abortController); }; -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 makeRequest(extend(requestParameters, {type: 'arrayBuffer'}), abortController); }; export function sameOrigin(inComingUrl: string) { - // URL class should be available everywhere - // https://developer.mozilla.org/en-US/docs/Web/API/URL - // In addtion, a relative URL "/foo" or "./foo" will throw exception in its ctor, + // A relative URL "/foo" or "./foo" will throw exception in URL's ctor, // try-catch is expansive so just use a heuristic check to avoid it // also check data URL if (!inComingUrl || @@ -295,23 +293,21 @@ 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 { + +export const getVideo = (urls: Array): Promise => { const video: HTMLVideoElement = window.document.createElement('video'); video.muted = true; - video.onloadstart = function() { - callback(null, video); - }; - for (let i = 0; i < urls.length; i++) { - const s: HTMLSourceElement = window.document.createElement('source'); - if (!sameOrigin(urls[i])) { - video.crossOrigin = 'Anonymous'; + return new Promise((resolve) => { + video.onloadstart = () => { + resolve(video); + }; + for (const url of urls) { + const s: HTMLSourceElement = window.document.createElement('source'); + if (!sameOrigin(url)) { + video.crossOrigin = 'Anonymous'; + } + s.src = url; + video.appendChild(s); } - s.src = urls[i]; - video.appendChild(s); - } - return {cancel: () => {}}; + }); }; diff --git a/src/util/browser.ts b/src/util/browser.ts index 6ba3b6f1e7..bf2c2a0e1d 100755 --- a/src/util/browser.ts +++ b/src/util/browser.ts @@ -1,3 +1,4 @@ +import {createAbortError} from './abort_error'; import type {Cancelable} from '../types/cancelable'; const now = typeof performance !== 'undefined' && performance && performance.now ? @@ -21,6 +22,16 @@ export const browser = { return {cancel: () => cancelAnimationFrame(frame)}; }, + frameAsync(abortController: AbortController): Promise { + return new Promise((resolve, reject) => { + const frame = requestAnimationFrame(resolve); + abortController.signal.addEventListener('abort', () => { + cancelAnimationFrame(frame); + reject(createAbortError()); + }); + }); + }, + getImageData(img: HTMLImageElement | ImageBitmap, padding: number = 0): ImageData { const context = this.getImageCanvasContext(img); return context.getImageData(-padding, -padding, img.width as number + 2 * padding, img.height as number + 2 * padding); diff --git a/src/util/dispatcher.test.ts b/src/util/dispatcher.test.ts index c1e78a0dee..7c3fe66c6b 100644 --- a/src/util/dispatcher.test.ts +++ b/src/util/dispatcher.test.ts @@ -17,7 +17,7 @@ describe('Dispatcher', () => { } } as any as WorkerPool; - const dispatcher = new Dispatcher(workerPool, {} as any, mapId); + const dispatcher = new Dispatcher(workerPool, mapId); expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers); dispatcher.remove(); expect(dispatcher.actors).toHaveLength(0); @@ -42,7 +42,7 @@ describe('Dispatcher', () => { } } as any as WorkerPool; - let dispatcher = new Dispatcher(workerPool, {} as any, mapId); + let dispatcher = new Dispatcher(workerPool, mapId); expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers); // Remove dispatcher, but map is not disposed (During style change) @@ -51,7 +51,7 @@ describe('Dispatcher', () => { expect(releaseCalled).toHaveLength(0); // Create new instance of dispatcher - dispatcher = new Dispatcher(workerPool, {} as any, mapId); + dispatcher = new Dispatcher(workerPool, mapId); expect(dispatcher.actors.map((actor) => { return actor.target; })).toEqual(workers); dispatcher.remove(true); // mapRemoved = true expect(dispatcher.actors).toHaveLength(0); @@ -67,7 +67,7 @@ describe('Dispatcher', () => { WorkerPool.workerCount = 4; const workerPool = new WorkerPool(); - const dispatcher = new Dispatcher(workerPool, {} as any, mapId); + const dispatcher = new Dispatcher(workerPool, mapId); dispatcher.remove(); expect(actorsRemoved).toHaveLength(4); }); diff --git a/src/util/dispatcher.ts b/src/util/dispatcher.ts index fd9b15dc42..70baa75fd8 100644 --- a/src/util/dispatcher.ts +++ b/src/util/dispatcher.ts @@ -1,8 +1,8 @@ -import {asyncAll} from './util'; -import {Actor, GlyphsProvider, MessageType} from './actor'; +import {Actor, MessageHandler} from './actor'; import type {WorkerPool} from './worker_pool'; import type {WorkerSource} from '../source/worker_source'; /* eslint-disable-line */ // this is used for the docs' import +import type {MessageType, RequestResponseMessageMap} from './actor_messages'; /** * Responsible for sending messages from a {@link Source} to an associated * {@link WorkerSource}. @@ -13,7 +13,7 @@ export class Dispatcher { currentActor: number; id: string | number; - constructor(workerPool: WorkerPool, parent: GlyphsProvider, mapId: string | number) { + constructor(workerPool: WorkerPool, mapId: string | number) { this.workerPool = workerPool; this.actors = []; this.currentActor = 0; @@ -21,7 +21,7 @@ export class Dispatcher { const workers = this.workerPool.acquire(mapId); for (let i = 0; i < workers.length; i++) { const worker = workers[i]; - const actor = new Actor(worker, parent, mapId); + const actor = new Actor(worker, mapId); actor.name = `Worker ${i}`; this.actors.push(actor); } @@ -31,11 +31,12 @@ export class Dispatcher { /** * Broadcast a message to all Workers. */ - broadcast(type: MessageType, data: unknown, cb?: (...args: any[]) => any) { - cb = cb || function () {}; - asyncAll(this.actors, (actor, done) => { - actor.send(type, data, done); - }, cb); + broadcast(type: T, data: RequestResponseMessageMap[T][0]): Promise { + const promises: Promise[] = []; + for (const actor of this.actors) { + promises.push(actor.sendAsync({type, data})); + } + return Promise.all(promises); } /** @@ -52,4 +53,10 @@ export class Dispatcher { this.actors = []; if (mapRemoved) this.workerPool.release(this.id); } + + public registerMessageHandler(type: T, handler: MessageHandler) { + for (const actor of this.actors) { + actor.registerMessageHandler(type, handler); + } + } } diff --git a/src/util/geolocation_support.test.ts b/src/util/geolocation_support.test.ts index 7641a40471..45f0ff6106 100644 --- a/src/util/geolocation_support.test.ts +++ b/src/util/geolocation_support.test.ts @@ -5,51 +5,39 @@ describe('checkGeolocationSupport', () => { jest.resetModules(); }); - test('it should return false if geolocation is not defined', done => { - checkGeolocationSupport((returnValue: boolean) => { - expect(returnValue).toBeFalsy(); - done(); - }); + test('it should return false if geolocation is not defined', async () => { + await expect(checkGeolocationSupport()).resolves.toBeFalsy(); }); - test('it should return the cached value on second call', done => { - checkGeolocationSupport((returnValue) => { - expect(returnValue).toBeFalsy(); - (window.navigator as any).geolocation = {}; - checkGeolocationSupport((rv) => { - expect(rv).toBe(returnValue); - done(); - }); - }); + test('it should return the cached value on second call', async () => { + const returnValue = await checkGeolocationSupport(); + expect(returnValue).toBeFalsy(); + (window.navigator as any).geolocation = {}; + const rv = await checkGeolocationSupport(); + expect(rv).toBe(returnValue); }); - test('it should return the true if geolocation is defined', done => { + test('it should return the true if geolocation is defined', async () => { (window.navigator as any).geolocation = {}; - checkGeolocationSupport((returnValue) => { - expect(returnValue).toBeTruthy(); - done(); - }, true); + const returnValue = await checkGeolocationSupport(true); + expect(returnValue).toBeTruthy(); }); - test('it should check permissions if possible', done => { + test('it should check permissions if possible', async () => { (window.navigator as any).geolocation = {}; (window.navigator as any).permissions = { query: () => Promise.resolve({state: 'granted'}) }; - checkGeolocationSupport((returnValue) => { - expect(returnValue).toBeTruthy(); - done(); - }, true); + const returnValue = await checkGeolocationSupport(true); + expect(returnValue).toBeTruthy(); }); - test('it should check permissions and geolocation for iOS 16 promise rejection', done => { + test('it should check permissions and geolocation for iOS 16 promise rejection', async () => { (window.navigator as any).geolocation = undefined; (window.navigator as any).permissions = { query: () => Promise.reject(new Error('pemissions error')) }; - checkGeolocationSupport((returnValue) => { - expect(returnValue).toBeFalsy(); - done(); - }, true); + const returnValue = await checkGeolocationSupport(true); + expect(returnValue).toBeFalsy(); }); }); diff --git a/src/util/geolocation_support.ts b/src/util/geolocation_support.ts index d64940a0a0..e0da3c3f94 100644 --- a/src/util/geolocation_support.ts +++ b/src/util/geolocation_support.ts @@ -1,24 +1,23 @@ let supportsGeolocation; -export function checkGeolocationSupport(callback: (supported: boolean) => void, forceRecalculation = false): void { +export async function checkGeolocationSupport(forceRecalculation = false): Promise { if (supportsGeolocation !== undefined && !forceRecalculation) { - callback(supportsGeolocation); - } else if (window.navigator.permissions !== undefined) { - // navigator.permissions has incomplete browser support - // http://caniuse.com/#feat=permissions-api - // Test for the case where a browser disables Geolocation because of an - // insecure origin - window.navigator.permissions.query({name: 'geolocation'}).then((p) => { - supportsGeolocation = p.state !== 'denied'; - callback(supportsGeolocation); - }).catch(() => { - // Fix for iOS16 which rejects query but still supports geolocation - supportsGeolocation = !!window.navigator.geolocation; - callback(supportsGeolocation); - }); - - } else { + return supportsGeolocation; + } + if (window.navigator.permissions === undefined) { supportsGeolocation = !!window.navigator.geolocation; - callback(supportsGeolocation); + return supportsGeolocation; + } + // navigator.permissions has incomplete browser support + // http://caniuse.com/#feat=permissions-api + // Test for the case where a browser disables Geolocation because of an + // insecure origin + try { + const permissions = await window.navigator.permissions.query({name: 'geolocation'}); + supportsGeolocation = permissions.state !== 'denied'; // eslint-disable-line require-atomic-updates + } catch { + // Fix for iOS16 which rejects query but still supports geolocation + supportsGeolocation = !!window.navigator.geolocation; // eslint-disable-line require-atomic-updates } + return supportsGeolocation; } diff --git a/src/util/image.ts b/src/util/image.ts index 27b13c815d..b3878dc06b 100644 --- a/src/util/image.ts +++ b/src/util/image.ts @@ -82,7 +82,6 @@ function copyImage(srcImg: any, dstImg: any, srcPt: Point2D, dstPt: Point2D, siz } /** - * @internal * An image with alpha color value */ export class AlphaImage { diff --git a/src/util/image_request.test.ts b/src/util/image_request.test.ts index b1f9724883..93ffca8252 100644 --- a/src/util/image_request.test.ts +++ b/src/util/image_request.test.ts @@ -1,8 +1,9 @@ import {config} from './config'; import {webpSupported} from './webp_supported'; -import {stubAjaxGetImage} from './test/util'; +import {sleep, stubAjaxGetImage} from './test/util'; import {fakeServer, type FakeServer} from 'nise'; -import {ImageRequest, ImageRequestQueueItem} from './image_request'; +import {ImageRequest} from './image_request'; +import {isAbortError} from './abort_error'; import * as ajax from './ajax'; describe('ImageRequest', () => { @@ -17,73 +18,86 @@ describe('ImageRequest', () => { server.restore(); }); - test('getImage respects maxParallelImageRequests', done => { + test('getImage respects maxParallelImageRequests', async () => { server.respondWith(request => request.respond(200, {'Content-Type': 'image/png'}, '')); 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); - callbackCount++; - if (callbackCount === 2) { - done(); - } - } - for (let i = 0; i < maxRequests + 1; i++) { - ImageRequest.getImage({url: ''}, callback); + const promises: Promise[] = []; + for (let i = 0; i < maxRequests + 5; i++) { + promises.push(ImageRequest.getImage({url: ''}, new AbortController())); + } expect(server.requests).toHaveLength(maxRequests); - server.requests[0].respond(undefined, undefined, undefined); - server.requests[1].respond(undefined, undefined, undefined); + server.requests[0].respond(200); + await promises[0]; + expect(server.requests).toHaveLength(maxRequests + 1); + server.requests[1].respond(200); + await promises[1]; + expect(server.requests).toHaveLength(maxRequests + 2); + }); + + test('getImage respects maxParallelImageRequests and continues to respond even when server returns 404', async () => { + server.respondWith(request => request.respond(404)); + + const maxRequests = config.MAX_PARALLEL_IMAGE_REQUESTS; + + for (let i = 0; i < maxRequests + 5; i++) { + ImageRequest.getImage({url: ''}, new AbortController()).catch(() => {}); + } + expect(server.requests).toHaveLength(maxRequests); + server.respond(); + await sleep(0); + expect(server.requests).toHaveLength(maxRequests + 5); }); - test('Cancel: getImage cancelling frees up request for maxParallelImageRequests', done => { - server.respondWith(request => request.respond(200, {'Content-Type': 'image/png'}, '')); + test('Cancel: getImage cancelling frees up request for maxParallelImageRequests', async () => { 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(isAbortError(e)).toBeTruthy()); + abortController.abort(); + await sleep(0); } 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(isAbortError(e)).toBeTruthy()); // 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(); + await sleep(0); 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 +106,123 @@ describe('ImageRequest', () => { // mock webp support webpSupported.supported = true; - ImageRequest.getImage({url: ''}, () => { done(); }); + const promise = ImageRequest.getImage({url: ''}, new AbortController()); server.respond(); + + await expect(promise).resolves.toBeDefined(); }); - 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 +232,50 @@ 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).catch(() => {}); 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 sleep(0); // Queue should move forward and next request is made expect(server.requests).toHaveLength(maxRequests + 1); @@ -280,10 +283,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 +293,7 @@ describe('ImageRequest', () => { // On server response, next image queued should not be the cancelled image server.requests[1].respond(200); + await sleep(0); expect(callbackCounter).toBe(1); expect(server.requests).toHaveLength(maxRequests + 2); // Verify that the last request made skipped the cancelled image request @@ -306,12 +309,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 +331,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 +351,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 +366,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 +377,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 +394,15 @@ describe('ImageRequest', () => { // unleash it by removing the throttling client ImageRequest.removeThrottleControl(throttlingIndex); + await sleep(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); } }); }); diff --git a/src/util/image_request.ts b/src/util/image_request.ts index e00b395e1f..c8c0163161 100644 --- a/src/util/image_request.ts +++ b/src/util/image_request.ts @@ -1,10 +1,9 @@ -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'; import {config} from './config'; +import {createAbortError} from './abort_error'; /** * The callback that is being called after an image was fetched @@ -13,13 +12,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 +94,60 @@ 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 = {}; - } - 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--; - } - - // in the case of cancelling, it WILL move on - processQueue(); + 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,*/*'; } - }; + extend(requestParameters, {type: 'image'}); + const request: ImageRequestQueueItem = { + abortController, + requestParameters, + supportImageRefresh, + state: 'queued', + onError: (error: Error) => { + reject(error); + }, + onSuccess: (response) => { + resolve(response); + } + }; - imageRequestQueue.push(request); - processQueue(); - return request; + imageRequestQueue.push(request); + 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 @@ -177,49 +158,34 @@ export namespace ImageRequest { // let makeRequest handle it. // - HtmlImageElement request automatically adds accept header for all the browser supported images const canUseHTMLImageElement = supportImageRefresh === false && - !isWorker() && + !isWorker(self) && !getProtocolAction(requestParameters.url) && (!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); - }); - }; - - 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; + currentParallelImageRequests++; + + const getImagePromise = canUseHTMLImageElement ? + getImageUsingHtmlImage(requestParameters, abortController) : + makeRequest(requestParameters, abortController); + + 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 +205,46 @@ 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 = ''; - } - }; + reject(createAbortError()); + }); + + 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/task_queue.ts b/src/util/task_queue.ts index b7389e9ed6..1718bee66d 100644 --- a/src/util/task_queue.ts +++ b/src/util/task_queue.ts @@ -1,4 +1,4 @@ -export type TaskID = number; // can't mark opaque due to https://github.com/flowtype/flow-remove-types/pull/61 +export type TaskID = number; type Task = { callback: (timeStamp: number) => void; diff --git a/src/util/test/mock_fetch.ts b/src/util/test/mock_fetch.ts deleted file mode 100644 index db6e7b13cc..0000000000 --- a/src/util/test/mock_fetch.ts +++ /dev/null @@ -1,51 +0,0 @@ -export class RequestMock implements Partial { - public readonly cache: RequestCache; - public readonly headers: Headers = new Headers(); - public readonly method?: string; - public readonly url?: string; - - public get signal(): AbortSignal { - return null; - } - - constructor(input: RequestInfo | URL, init?: RequestInit) { - this.cache = typeof input === 'object' && 'cache' in input ? input.cache : init.cache; - this.method = typeof input === 'object' && 'method' in input ? input.method : init.method; - this.url = typeof input === 'object' && 'url' in input ? input.url : input.toString(); - this.headers = typeof input === 'object' && 'headers' in input ? new Headers(input.headers) : new Headers(init.headers || {}); - } -} - -class AbortControllerMock { - public signal: AbortSignal; - - public abort(): void {} -} - -export type FetchMock = jest.Mock, [input: RequestInfo | URL, init?: RequestInit], any>; - -let _AbortController: typeof AbortController; -let _Request: typeof Request; -let _fetch: typeof fetch; - -export function destroyFetchMock(): void { - global.AbortController = _AbortController ?? global.AbortController; - global.Request = _Request ?? global.Request; - global.fetch = _fetch ?? global.fetch; -} - -export function setupFetchMock(): FetchMock { - _AbortController = _AbortController ?? global.AbortController; - _Request = _Request ?? global.Request; - _fetch = _fetch ?? global.fetch; - - const fetchMock = jest.fn(async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { - return {}; - }); - - global.AbortController = AbortControllerMock; - global.Request = RequestMock as unknown as typeof Request; - global.fetch = fetchMock as any; - - return fetchMock; -} diff --git a/src/util/test/util.ts b/src/util/test/util.ts index 39e63097ad..75514e39a1 100644 --- a/src/util/test/util.ts +++ b/src/util/test/util.ts @@ -2,6 +2,8 @@ import {Map} from '../../ui/map'; import {extend} from '../../util/util'; import {Dispatcher} from '../../util/dispatcher'; import {setWebGlContext} from './mock_webgl'; +import {IActor} from '../actor'; +import type {Evented} from '../evented'; export function createMap(options?, callback?) { const container = window.document.createElement('div'); @@ -96,10 +98,10 @@ export function beforeMapTest() { } export function getWrapDispatcher() { - const wrapDispatcher = (dispatcher) => { + const wrapDispatcher = (actor: IActor) => { return { getActor() { - return dispatcher; + return actor; } } as any as Dispatcher; }; @@ -111,7 +113,7 @@ export function getMockDispatcher() { const wrapDispatcher = getWrapDispatcher(); const mockDispatcher = wrapDispatcher({ - send() {} + sendAsync() { return Promise.resolve({}); }, }); return mockDispatcher; @@ -128,7 +130,9 @@ export function stubAjaxGetImage(createImageBitmap) { set(url: string) { if (url === 'error') { this.onerror(); - } else this.onload(); + } else if (this.onload) { + this.onload(); + } } }); } @@ -144,3 +148,22 @@ export function bufferToArrayBuffer(data: Buffer): ArrayBuffer { data.copy(view); return view.buffer; } + +/** + * This allows test to wait for a certain amount of time before continuing. + * @param milliseconds - the amount of time to wait in milliseconds + * @returns - a promise that resolves after the specified amount of time + */ +export const sleep = (milliseconds: number = 0) => { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +}; + +export function waitForMetadataEvent(source: Evented): Promise { + return new Promise((resolve) => { + source.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + resolve(); + } + }); + }); +} diff --git a/src/util/throttled_invoker.ts b/src/util/throttled_invoker.ts index f697a23652..e16522662c 100644 --- a/src/util/throttled_invoker.ts +++ b/src/util/throttled_invoker.ts @@ -1,40 +1,41 @@ /** - * Invokes the wrapped function in a non-blocking way when trigger() is called. Invocation requests - * are ignored until the function was actually invoked. + * Invokes the wrapped function in a non-blocking way when trigger() is called. + * Invocation requests are ignored until the function was actually invoked. */ export class ThrottledInvoker { _channel: MessageChannel; _triggered: boolean; - _callback: Function; + _methodToThrottle: Function; - constructor(callback: Function) { - this._callback = callback; + constructor(methodToThrottle: Function) { + this._methodToThrottle = methodToThrottle; this._triggered = false; if (typeof MessageChannel !== 'undefined') { this._channel = new MessageChannel(); this._channel.port2.onmessage = () => { this._triggered = false; - this._callback(); + this._methodToThrottle(); }; } } trigger() { - if (!this._triggered) { - this._triggered = true; - if (this._channel) { - this._channel.port1.postMessage(true); - } else { - setTimeout(() => { - this._triggered = false; - this._callback(); - }, 0); - } + if (this._triggered) { + return; + } + this._triggered = true; + if (this._channel) { + this._channel.port1.postMessage(true); + } else { + setTimeout(() => { + this._triggered = false; + this._methodToThrottle(); + }, 0); } } remove() { delete this._channel; - this._callback = () => {}; + this._methodToThrottle = () => {}; } } 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 13e0c7db9e..0c182a1db9 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -1,8 +1,8 @@ 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'; /** * Given a value `t` that varies between 0 and 1, return @@ -65,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. @@ -384,7 +357,7 @@ export function sphericalToCartesian([r, azimuthal, polar]: [number, number, num * * @returns `true` if the when run in the web-worker context. */ -export function isWorker(): boolean { +export function isWorker(self: any): self is WorkerGlobalScopeInterface { // @ts-ignore return typeof WorkerGlobalScope !== 'undefined' && typeof self !== 'undefined' && self instanceof WorkerGlobalScope; } @@ -481,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='; @@ -502,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 @@ -667,3 +642,30 @@ export async function getImageData( } return readImageDataUsingOffscreenCanvas(image, x, y, width, height); } + +export interface Subscription { + unsubscribe(): void; +} + +export interface Subscriber { + addEventListener: typeof window.addEventListener; + removeEventListener: typeof window.removeEventListener; +} + +/** + * This method is used in order to register an event listener using a lambda function. + * The return value will allow unsubscribing from the event, without the need to store the method reference. + * @param target - The target + * @param message - The message + * @param listener - The listener + * @param options - The options + * @returns a subscription object that can be used to unsubscribe from the event + */ +export function subscribe(target: Subscriber, message: keyof WindowEventMap, listener: (...args: any) => void, options: boolean | AddEventListenerOptions): Subscription { + target.addEventListener(message, listener, options); + return { + unsubscribe: () => { + target.removeEventListener(message, listener, options); + } + }; +} diff --git a/src/util/web_worker.ts b/src/util/web_worker.ts index 6a2db7e576..e26bc74090 100644 --- a/src/util/web_worker.ts +++ b/src/util/web_worker.ts @@ -1,16 +1,12 @@ import {config} from './config'; - -import type {WorkerSource} from '../source/worker_source'; +import type {default as MaplibreWorker} from '../source/worker'; +import type {WorkerSourceConstructor} from '../source/worker_source'; export interface WorkerGlobalScopeInterface { importScripts(...urls: Array): void; - registerWorkerSource: ( - b: string, - a: { - new(...args: any): WorkerSource; - } - ) => void; + registerWorkerSource: (sourceName: string, sourceConstrucor: WorkerSourceConstructor) => void; registerRTLTextPlugin: (_: any) => void; + worker: MaplibreWorker; } export function workerFactory() { diff --git a/src/util/web_worker_transfer.ts b/src/util/web_worker_transfer.ts index 49fb990e42..026b1a12ad 100644 --- a/src/util/web_worker_transfer.ts +++ b/src/util/web_worker_transfer.ts @@ -7,10 +7,16 @@ import {AJAXError} from './ajax'; import type {Transferable} from '../types/transferable'; import {isImageBitmap} from './util'; +/** + * A class that is serizlized to and json, that can be constructed back to the original class in the worker or in the main thread + */ type SerializedObject = { [_: string]: S; }; +/** + * All the possible values that can be serialized and sent to and from the worker + */ export type Serialized = null | void | boolean | number | string | Boolean | Number | String | Date | RegExp | ArrayBuffer | ArrayBufferView | ImageData | ImageBitmap | Blob | Array | SerializedObject; type Registry = { @@ -156,7 +162,7 @@ export function serialize(input: unknown, transferables?: Array | const klass = (input.constructor as any); const name = klass._classRegistryKey; if (!name) { - throw new Error('can\'t serialize object of unregistered class'); + throw new Error(`can't serialize object of unregistered class ${klass.name}`); } if (!registry[name]) throw new Error(`${name} is not registered.`); diff --git a/test/bench/lib/create_map.ts b/test/bench/lib/create_map.ts index 1ab43afa79..def8226abf 100644 --- a/test/bench/lib/create_map.ts +++ b/test/bench/lib/create_map.ts @@ -27,9 +27,9 @@ const createMap = (options: any): Promise => { .on(options.idle ? 'idle' : 'load', () => { if (options.stubRender) { // If there's a pending rerender, cancel it. - if (map._frame) { - map._frame.cancel(); - map._frame = null; + if (map._frameRequest) { + map._frameRequest.abort(); + map._frameRequest = null; } } resolve(map); diff --git a/test/bench/lib/tile_parser.ts b/test/bench/lib/tile_parser.ts index cc0098ce09..b765b4c00c 100644 --- a/test/bench/lib/tile_parser.ts +++ b/test/bench/lib/tile_parser.ts @@ -14,6 +14,7 @@ import type {WorkerTileResult} from '../../../src/source/worker_source'; import type {OverscaledTileID} from '../../../src/source/tile_id'; import type {TileJSON} from '../../../src/types/tilejson'; import type {Map} from '../../../src/ui/map'; +import type {IActor} from '../../../src/util/actor'; class StubMap extends Evented { style: Style; @@ -57,9 +58,7 @@ export default class TileParser { icons: any; glyphs: any; style: Style; - actor: { - send: Function; - }; + actor: IActor; constructor(styleJSON: StyleSpecification, sourceID: string) { this.styleJSON = styleJSON; @@ -69,41 +68,33 @@ export default class TileParser { this.icons = {}; } - loadImages(params: any, callback: Function) { + async loadImages(params: any) { const key = JSON.stringify(params); - if (this.icons[key]) { - callback(null, this.icons[key]); - } else { - this.style.getImages('', params, (err, icons) => { - this.icons[key] = icons; - callback(err, icons); - }); + if (!this.icons[key]) { + this.icons[key] = await this.style.getImages('', params); } + return this.icons[key]; } - loadGlyphs(params: any, callback: Function) { + async loadGlyphs(params: any) { const key = JSON.stringify(params); - if (this.glyphs[key]) { - callback(null, this.glyphs[key]); - } else { - this.style.getGlyphs('', params, (err, glyphs) => { - this.glyphs[key] = glyphs; - callback(err, glyphs); - }); + if (!this.glyphs[key]) { + this.glyphs[key] = await this.style.getGlyphs('', params); } + return this.glyphs[key]; } setup(): Promise { const parser = this; this.actor = { - send(action, params, callback) { - setTimeout(() => { - if (action === 'getImages') { - parser.loadImages(params, callback); - } else if (action === 'getGlyphs') { - parser.loadGlyphs(params, callback); - } else throw new Error(`Invalid action ${action}`); - }, 0); + sendAsync(message) { + if (message.type === 'getImages') { + return parser.loadImages(message.data); + } + if (message.type === 'getGlyphs') { + return parser.loadGlyphs(message.data); + } + throw new Error(`Invalid action ${message.type}`); } }; @@ -130,6 +121,7 @@ export default class TileParser { returnDependencies?: boolean ): Promise { const workerTile = new WorkerTile({ + type: 'benchmark', tileID: tile.tileID, zoom: tile.tileID.overscaledZ, tileSize: 512, @@ -145,14 +137,6 @@ export default class TileParser { const vectorTile = new VT.VectorTile(new Protobuf(tile.buffer)); - return new Promise((resolve, reject) => { - workerTile.parse(vectorTile, this.layerIndex, [], ((this.actor as any)), (err, result) => { - if (err) { - reject(err); - } else { - resolve(result); - } - }); - }); + return workerTile.parse(vectorTile, this.layerIndex, [], this.actor); } } diff --git a/test/build/min.test.ts b/test/build/min.test.ts index e65eaad00b..2f2c67b55f 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 = 761777; + const expectedBytes = 768888; expect(actualBytes - expectedBytes).toBeLessThan(increaseQuota); expect(expectedBytes - actualBytes).toBeLessThan(decreaseQuota); diff --git a/test/build/sourcemaps.test.ts b/test/build/sourcemaps.test.ts index b14f0aaf00..25c5f49c0e 100644 --- a/test/build/sourcemaps.test.ts +++ b/test/build/sourcemaps.test.ts @@ -83,6 +83,6 @@ describe('main sourcemap', () => { const s1 = setMinus(actualEntriesInSourcemapJSON, expectedEntriesInSourcemapJSON); expect(s1.length).toBeLessThan(5); const s2 = setMinus(expectedEntriesInSourcemapJSON, actualEntriesInSourcemapJSON); - expect(s2.length).toBeLessThan(15); + expect(s2.length).toBeLessThan(16); }); }); diff --git a/test/integration/browser/browser.test.ts b/test/integration/browser/browser.test.ts index b0cb7f5885..a232547030 100644 --- a/test/integration/browser/browser.test.ts +++ b/test/integration/browser/browser.test.ts @@ -5,6 +5,7 @@ import type {Server} from 'http'; import type {AddressInfo} from 'net'; import type {Map} from '../../../src/ui/map'; import type {default as MapLibreGL} from '../../../src/index'; +import {sleep} from '../../../src/util/test/util'; const testWidth = 800; const testHeight = 600; @@ -109,7 +110,7 @@ describe('Browser tests', () => { steps: 10 }); await page.mouse.up(); - await new Promise(r => setTimeout(r, 200)); + await sleep(200); return page.evaluate(() => { return map.getCenter(); @@ -135,7 +136,7 @@ describe('Browser tests', () => { await page.setViewport({width: 400, height: 400, deviceScaleFactor: 2}); - await new Promise(r => setTimeout(r, 200)); + await sleep(200); const canvas = await page.$('.maplibregl-canvas'); const canvasBB = await canvas?.boundingBox(); @@ -149,7 +150,7 @@ describe('Browser tests', () => { document.getElementById('map')!.style.width = '200px'; document.getElementById('map')!.style.height = '200px'; }); - await new Promise(resolve => setTimeout(resolve, 1000)); + await sleep(1000); const canvas = await page.$('.maplibregl-canvas'); const canvasBB = await canvas?.boundingBox(); diff --git a/test/integration/render/tests/debug/raster/expected-macos-m2.png b/test/integration/render/tests/debug/raster/expected-macos-m2.png new file mode 100644 index 0000000000..cceff22965 Binary files /dev/null and b/test/integration/render/tests/debug/raster/expected-macos-m2.png differ diff --git a/test/integration/render/tests/debug/tile-raster/expected-macos-m2.png b/test/integration/render/tests/debug/tile-raster/expected-macos-m2.png new file mode 100644 index 0000000000..071e69ca8f Binary files /dev/null and b/test/integration/render/tests/debug/tile-raster/expected-macos-m2.png differ diff --git a/test/integration/render/tests/debug/tile/expected-macos-m2.png b/test/integration/render/tests/debug/tile/expected-macos-m2.png new file mode 100644 index 0000000000..c02a8f0c3c Binary files /dev/null and b/test/integration/render/tests/debug/tile/expected-macos-m2.png differ diff --git a/test/integration/render/tests/raster-masking/overlapping-zoom/expected_macos-m2.png b/test/integration/render/tests/raster-masking/overlapping-zoom/expected_macos-m2.png new file mode 100644 index 0000000000..ef9c3c99cb Binary files /dev/null and b/test/integration/render/tests/raster-masking/overlapping-zoom/expected_macos-m2.png differ diff --git a/test/unit/lib/web_worker_mock.ts b/test/unit/lib/web_worker_mock.ts index 9f450cecc5..59e2bb23e0 100644 --- a/test/unit/lib/web_worker_mock.ts +++ b/test/unit/lib/web_worker_mock.ts @@ -8,6 +8,7 @@ export class MessageBus implements WorkerGlobalScopeInterface, ActorTarget { target: MessageBus; registerWorkerSource: any; registerRTLTextPlugin: any; + worker: any; constructor(addListeners: Array, postListeners: Array) { this.addListeners = addListeners; @@ -47,16 +48,22 @@ export class MessageBus implements WorkerGlobalScopeInterface, ActorTarget { importScripts() { } } -(global as any).Worker = function Worker(_: string) { - const parentListeners = []; - const workerListeners = []; - const parentBus = new MessageBus(workerListeners, parentListeners); - const workerBus = new MessageBus(parentListeners, workerListeners); +export function setGlobalWorker(MockWorker: { new(...args: any): any}) { + (global as any).Worker = function Worker(_: string) { + const parentListeners = []; + const workerListeners = []; + const parentBus = new MessageBus(workerListeners, parentListeners); + const workerBus = new MessageBus(parentListeners, workerListeners); - parentBus.target = workerBus; - workerBus.target = parentBus; + parentBus.target = workerBus; + workerBus.target = parentBus; - new MapLibreWorker(workerBus); + const worker = new MockWorker(workerBus); + parentBus.worker = worker; + + return parentBus; + }; +} + +setGlobalWorker(MapLibreWorker); - return parentBus; -}; diff --git a/typedoc.json b/typedoc.json index 4842dc3ca2..c753d2e059 100644 --- a/typedoc.json +++ b/typedoc.json @@ -13,6 +13,8 @@ "excludeExternals": true, "excludeInternal": true, "excludeNotDocumented": true, + "treatWarningsAsErrors": true, + "intentionallyNotExported": ["CollisionBoxArray"], "internalModule": "maplibregl", "entryPoints": ["./src/index.ts"], "navigation": {