Skip to content

Commit e08a5b5

Browse files
stepankuzmingithub-actions[bot]
authored andcommitted
Improve GeolocateControl timeout
GitOrigin-RevId: 5af4fdd060e61512c0eaef208f443e281f6df19e
1 parent 7a63e01 commit e08a5b5

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

src/ui/control/geolocate_control.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
131131
_geolocateButton: HTMLButtonElement;
132132
_geolocationWatchID: number;
133133
_timeoutId?: number;
134+
_requestTimeoutId?: number;
134135
_watchState: WatchState;
135136
_lastKnownPosition?: GeolocationPosition;
136137
_userLocationDotMarker: Marker;
@@ -173,6 +174,8 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
173174
}
174175

175176
onRemove() {
177+
this._clearRequestTimeout();
178+
176179
// clear the geolocation watch if exists
177180
if (this._geolocationWatchID !== undefined) {
178181
this.options.geolocation.clearWatch(this._geolocationWatchID);
@@ -275,6 +278,8 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
275278
return;
276279
}
277280

281+
this._clearRequestTimeout();
282+
278283
if (this._isOutOfMapMaxBounds(position)) {
279284
this._setErrorState();
280285

@@ -420,6 +425,8 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
420425
return;
421426
}
422427

428+
this._clearRequestTimeout();
429+
423430
if (this.options.trackUserLocation) {
424431
if (error.code === 1) {
425432
// PERMISSION_DENIED
@@ -462,6 +469,25 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
462469
this._timeoutId = undefined;
463470
}
464471

472+
// Workaround for browsers that fail to call geolocation callbacks (https://github.com/mapbox/mapbox-gl-js/issues/12531)
473+
_startRequestTimeout() {
474+
this._clearRequestTimeout();
475+
const timeout = this.options.positionOptions.timeout;
476+
if (!timeout) return;
477+
478+
this._requestTimeoutId = window.setTimeout(() => {
479+
const error = {code: 3, message: 'Geolocation request timed out'} as GeolocationPositionError;
480+
this._onError(error);
481+
}, timeout);
482+
}
483+
484+
_clearRequestTimeout() {
485+
if (this._requestTimeoutId !== undefined) {
486+
clearTimeout(this._requestTimeoutId);
487+
this._requestTimeoutId = undefined;
488+
}
489+
}
490+
465491
_setupUI(supported: boolean) {
466492
if (this._map === undefined) {
467493
// This control was removed from the map before geolocation
@@ -683,13 +709,17 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
683709
this._geolocationWatchID = this.options.geolocation.watchPosition(
684710
this._onSuccess, this._onError, positionOptions);
685711

712+
this._startRequestTimeout();
713+
686714
if (this.options.showUserHeading) {
687715
this._addDeviceOrientationListener();
688716
}
689717
}
690718
} else {
691719
this.options.geolocation.getCurrentPosition(this._onSuccess, this._onError, this.options.positionOptions);
692720

721+
this._startRequestTimeout();
722+
693723
// This timeout ensures that we still call finish() even if
694724
// the user declines to share their location in Firefox
695725
this._timeoutId = window.setTimeout(this._finish, 10000 /* 10sec */);
@@ -765,6 +795,7 @@ class GeolocateControl extends Evented<GeolocateControlEvents> implements IContr
765795
}
766796

767797
_clearWatch() {
798+
this._clearRequestTimeout();
768799
this.options.geolocation.clearWatch(this._geolocationWatchID);
769800

770801
window.removeEventListener('deviceorientation', this._onDeviceOrientation);

test/unit/ui/control/geolocate.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import {test, beforeAll, beforeEach, expect, vi, createMap} from '../../../util/vitest';
1+
import {test, describe, beforeAll, beforeEach, afterEach, expect, vi, createMap} from '../../../util/vitest';
22
import GeolocateControl from '../../../../src/ui/control/geolocate_control';
33
import {mockGeolocation} from '../../../util/mock_geolocation';
44

5+
import type {GeolocateControlOptions} from '../../../../src/ui/control/geolocate_control';
6+
57
beforeAll(() => {
68
mockGeolocation.use();
79
});
@@ -975,3 +977,133 @@ test('GeolocateControl button click centers camera even when followUserLocation
975977
mockGeolocation.send({latitude: 10, longitude: 20, accuracy: 30});
976978
});
977979
});
980+
981+
describe('GeolocateControl geolocation timeout', () => {
982+
const DEFAULT_TIMEOUT_MS = 6000;
983+
const CUSTOM_TIMEOUT_MS = 3000;
984+
985+
async function setupGeolocationTimeoutTest(controlOptions: GeolocateControlOptions = {}) {
986+
vi.useFakeTimers();
987+
const map = createMap();
988+
const geolocate = new GeolocateControl({
989+
trackUserLocation: true,
990+
...controlOptions
991+
});
992+
map.addControl(geolocate);
993+
// Flush pending microtasks from control setup
994+
await vi.advanceTimersByTimeAsync(0);
995+
const errorHandler = vi.fn();
996+
geolocate.on('error', errorHandler);
997+
return {map, geolocate, errorHandler};
998+
}
999+
1000+
afterEach(() => {
1001+
vi.useRealTimers();
1002+
});
1003+
1004+
test('triggers error when no response', async () => {
1005+
const {geolocate, errorHandler} = await setupGeolocationTimeoutTest();
1006+
1007+
geolocate.trigger();
1008+
1009+
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS);
1010+
1011+
expect(errorHandler).toHaveBeenCalledTimes(1);
1012+
const error = errorHandler.mock.calls[0][0] as GeolocationPositionError;
1013+
expect(error.code).toEqual(3); // TIMEOUT
1014+
expect(error.message).toEqual('Geolocation request timed out');
1015+
});
1016+
1017+
test('respects custom positionOptions.timeout', async () => {
1018+
const {errorHandler, geolocate} = await setupGeolocationTimeoutTest({
1019+
positionOptions: {timeout: CUSTOM_TIMEOUT_MS}
1020+
});
1021+
1022+
geolocate.trigger();
1023+
1024+
// Advance less than custom timeout
1025+
await vi.advanceTimersByTimeAsync(CUSTOM_TIMEOUT_MS - 1000);
1026+
expect(errorHandler).not.toHaveBeenCalled();
1027+
1028+
// Advance past custom timeout
1029+
await vi.advanceTimersByTimeAsync(1000);
1030+
expect(errorHandler).toHaveBeenCalledTimes(1);
1031+
const error = errorHandler.mock.calls[0][0] as GeolocationPositionError;
1032+
expect(error.code).toEqual(3);
1033+
});
1034+
1035+
test('cleared on success', async () => {
1036+
const {geolocate, errorHandler} = await setupGeolocationTimeoutTest();
1037+
1038+
geolocate.trigger();
1039+
1040+
// Send success before timeout
1041+
await vi.advanceTimersByTimeAsync(CUSTOM_TIMEOUT_MS);
1042+
mockGeolocation.send({latitude: 10, longitude: 20, accuracy: 30});
1043+
1044+
// Advance well past the timeout
1045+
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS);
1046+
1047+
expect(errorHandler).not.toHaveBeenCalled();
1048+
});
1049+
1050+
test('cleared on error', async () => {
1051+
const {geolocate, errorHandler} = await setupGeolocationTimeoutTest();
1052+
1053+
geolocate.trigger();
1054+
1055+
// Send error before timeout
1056+
await vi.advanceTimersByTimeAsync(CUSTOM_TIMEOUT_MS);
1057+
mockGeolocation.changeError({code: 2, message: 'position unavailable'});
1058+
1059+
// Advance well past the timeout
1060+
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS);
1061+
1062+
// Only one error (the explicit one, not the timeout)
1063+
expect(errorHandler).toHaveBeenCalledTimes(1);
1064+
const error = errorHandler.mock.calls[0][0] as GeolocationPositionError;
1065+
expect(error.code).toEqual(2);
1066+
});
1067+
1068+
test('cleared on remove', async () => {
1069+
const {map, geolocate, errorHandler} = await setupGeolocationTimeoutTest();
1070+
1071+
geolocate.trigger();
1072+
1073+
// Remove control before timeout
1074+
await vi.advanceTimersByTimeAsync(CUSTOM_TIMEOUT_MS);
1075+
map.removeControl(geolocate);
1076+
1077+
// Advance well past the timeout
1078+
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS);
1079+
1080+
expect(errorHandler).not.toHaveBeenCalled();
1081+
});
1082+
1083+
test('no timeout when positionOptions.timeout is 0', async () => {
1084+
const {geolocate, errorHandler} = await setupGeolocationTimeoutTest({
1085+
positionOptions: {timeout: 0}
1086+
});
1087+
1088+
geolocate.trigger();
1089+
1090+
// Advance a very long time
1091+
await vi.advanceTimersByTimeAsync(60000);
1092+
1093+
expect(errorHandler).not.toHaveBeenCalled();
1094+
});
1095+
1096+
test('works in one-time mode', async () => {
1097+
const {geolocate, errorHandler} = await setupGeolocationTimeoutTest({
1098+
trackUserLocation: false
1099+
});
1100+
1101+
geolocate.trigger();
1102+
1103+
await vi.advanceTimersByTimeAsync(DEFAULT_TIMEOUT_MS);
1104+
1105+
expect(errorHandler).toHaveBeenCalledTimes(1);
1106+
const error = errorHandler.mock.calls[0][0] as GeolocationPositionError;
1107+
expect(error.code).toEqual(3); // TIMEOUT
1108+
});
1109+
});

0 commit comments

Comments
 (0)