Skip to content

Commit a1ef040

Browse files
authored
fix(basemaps): make the basemap control's Mapbox styles load (#1488)
* fix(basemaps): make the basemap control's Mapbox styles load Every Mapbox basemap in the basemap control produced `name: unknown property "name"` and a blank map. The control applies a Mapbox style correctly — it passes MapLibre a `transformStyle` that rewrites the `mapbox://` sprite/glyphs/source URLs and swaps Mapbox's `projection: { name }` for the spec's `{ type }`, plus `validate: false`. But GeoLibre is store-driven: the change is written to `basemapStyleUrl` and MapController re-applies it with a plain `map.setStyle(url)`, which drops both. MapLibre's validator then rejects `projection.name`, and `Style._load` returns on the first validation error, so the whole style is abandoned. The three styles with no `projection` block (satellite-v9, navigation-day/night-v1) passed validation but still rendered empty, since nothing resolves `mapbox://`. Teach GeoLibre's own style path about Mapbox instead of relying on the control's: `mapbox-style.ts` fetches the descriptor and converts it, and MapController applies the result. That also covers the paths the control is not part of — a project reopened with a Mapbox basemap, and a split-view pane, both of which build a MapController straight from the saved URL. The token comes back out of the URL's `access_token` parameter, so no credential plumbing is needed to *apply* a style and a saved project stays self-contained. A generation counter drops a descriptor whose basemap the user has already switched away from. Also wire `MAPBOX_TOKEN` / `VITE_MAPBOX_ACCESS_TOKEN` through to the control's `mapboxAccessToken`, matching the bare→prefixed bridge the Google Maps and Cesium keys use, so the panel's API-keys field is pre-filled instead of demanding a paste. Pushed only when set, like the other panel-enterable providers. Verified in the browser against all of Streets/Satellite in single and split view: sprite, fonts, TileJSON and tiles all 200, zero console errors, and non-Mapbox styles still switch cleanly afterwards. * Address CodeRabbit review feedback - Don't wedge the controller when the Mapbox descriptor fetch fails. setStyle tore down styleReady/paint values/the layer control up front, but the Mapbox path only reaches map.setStyle after an async fetch — a rejection left no style.load coming to rebuild any of it, so layer syncing, basemap visibility/opacity and the layer control stayed dead over a still-rendered old style. Teardown now happens in beginStyleSwap immediately before each map.setStyle, so a failed fetch changes nothing. Verified by routing the descriptor to a 500: the previous style keeps rendering, all six control groups survive, and the next basemap switch applies normally. - Don't log the token-bearing style URL. The URL carries the user's access_token, so the failure warning now logs a redacted descriptor id via redactMapboxStyleUrl (origin + path, no query), covered by tests. - Test that the access_token is trimmed when the query string pads it. - Test that transformMapboxStyle leaves the nested source URL alone, not just the top-level sprite/projection — an in-place rewrite would have passed the old assertions. - Document the prefixed-over-bare precedence on getMapboxAccessToken. A Settings entry overrides a baked token under the same VITE_ name; the bare MAPBOX_TOKEN is a fallback for when nothing was baked in, not an override. The docstring implied otherwise. * Address CodeRabbit review feedback - Abort a superseded Mapbox descriptor request. The generation counter already kept a stale style from being applied, but the fetch itself kept running; a rapid run of basemap changes (or a request that never settles) left several in flight, each holding a controller closure alive. applyStyleToMap now keeps an AbortController for the active load, aborts it when starting a newer generation and from destroy(), and passes its signal to loadMapboxStyle — which already accepted one, so this also retires a parameter that was left unwired. An abort rejects with AbortError, but only ever on a generation that has already been superseded (abort follows the ++ in applyStyleToMap, and destroy() nulls the map first), so both handlers return before the warning — a cancelled request is never reported as a failure. * Cover the Mapbox descriptor abort with tests 93fe1aa wired an AbortController into applyStyleToMap but left the behavior untested. Adds three cases against a stub map and a fetch that never settles on its own: - a newer Mapbox basemap aborts the request it supersedes, and the abort rejection produces no failed-style console warning, - a switch to a plain (non-Mapbox) style URL aborts the pending request too, and applies synchronously without a second fetch, - destroy() aborts a request still in flight.
1 parent 1591edb commit a1ef040

9 files changed

Lines changed: 692 additions & 7 deletions

File tree

apps/geolibre-desktop/vite.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ if (!process.env.VITE_CESIUM_TOKEN) {
7676
}
7777
}
7878

79+
// Mapbox access token for the basemap control's Mapbox styles: same
80+
// bare→prefixed bridge as the Google Maps and Cesium keys. `MAPBOX_TOKEN` is the
81+
// spelling Mapbox's own tooling uses, so accept it from the shell or an .env
82+
// file and surface it as `VITE_MAPBOX_ACCESS_TOKEN`; getMapboxAccessToken() then
83+
// lets a runtime Settings override win over this build-time value.
84+
if (!process.env.VITE_MAPBOX_ACCESS_TOKEN) {
85+
const mapboxAccessToken =
86+
process.env.MAPBOX_TOKEN || FILE_ENV.VITE_MAPBOX_ACCESS_TOKEN || FILE_ENV.MAPBOX_TOKEN;
87+
if (mapboxAccessToken) {
88+
process.env.VITE_MAPBOX_ACCESS_TOKEN = mapboxAccessToken;
89+
}
90+
}
91+
7992
// Earth Engine OAuth client ID: same bare→prefixed bridge as the Google Maps
8093
// and Cesium keys. The app reads `import.meta.env.VITE_GEE_OAUTH_CLIENT_ID`, so
8194
// a bare `GEE_OAUTH_CLIENT_ID` (shell/.zshrc or an .env file) is surfaced under

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export {
9191
export {
9292
getCesiumIonToken,
9393
getGoogleMapsApiKey,
94+
getMapboxAccessToken,
9495
getProtomapsApiKey,
9596
getProtomapsStyleUrl,
9697
getRuntimeEnvironment,

packages/core/src/runtime-env.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,36 @@ export function getGoogleMapsApiKey(env?: Record<string, string | undefined>): s
9090
return trimmed || undefined;
9191
}
9292

93+
/**
94+
* Resolves the Mapbox access token from the runtime environment.
95+
*
96+
* The basemap control's Mapbox styles authenticate with the user's own token.
97+
* It is supplied via `VITE_MAPBOX_ACCESS_TOKEN` (baked in at build time from the
98+
* bare `MAPBOX_TOKEN` env var, which `vite.config.ts` copies into the prefixed
99+
* name — the spelling Mapbox's own tooling uses) or set at runtime through
100+
* Settings → Environment variables (`window.__GEOLIBRE_RUNTIME_ENV__`, which
101+
* bypasses Vite's envPrefix allowlist, so a bare `MAPBOX_TOKEN` entry works
102+
* there too). When unset, the Mapbox basemaps prompt for a token in the basemap
103+
* panel's API keys view instead.
104+
*
105+
* Note the precedence, shared with {@link getGoogleMapsApiKey} and
106+
* {@link getCesiumIonToken}: the prefixed name always wins over the bare one,
107+
* and `getRuntimeEnvironment` merges build-time and runtime vars into one
108+
* record. So a build that baked in `VITE_MAPBOX_ACCESS_TOKEN` is overridden at
109+
* runtime by a Settings entry under that *same* prefixed name; a bare
110+
* `MAPBOX_TOKEN` entry is a fallback for when nothing was baked in, not a way
111+
* to override a baked token.
112+
*
113+
* @param env - Environment record (defaults to the runtime environment);
114+
* injectable for testing.
115+
* @returns The trimmed token, or undefined when unset.
116+
*/
117+
export function getMapboxAccessToken(env?: Record<string, string | undefined>): string | undefined {
118+
const runtimeEnv = env ?? getRuntimeEnvironment();
119+
const trimmed = runtimeEnv.VITE_MAPBOX_ACCESS_TOKEN?.trim() || runtimeEnv.MAPBOX_TOKEN?.trim();
120+
return trimmed || undefined;
121+
}
122+
93123
/**
94124
* Resolves the Cesium Ion access token from the runtime environment.
95125
*

packages/map/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export {
5353
type ProtomapsFlavor,
5454
type ProtomapsBasemapStyleOptions,
5555
} from "./protomaps-basemap";
56+
export {
57+
isMapboxStyleUrl,
58+
loadMapboxStyle,
59+
mapboxAccessTokenFromStyleUrl,
60+
redactMapboxStyleUrl,
61+
resolveMapboxInternalUrl,
62+
transformMapboxStyle,
63+
} from "./mapbox-style";
5664
export {
5765
ensureRemotePMTilesArchive,
5866
hasPMTilesArchive,

packages/map/src/map-controller.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
vectorTileStyleLayerIds,
4141
} from "./layer-sync";
4242
import { installGlobePopupOcclusion } from "./globe-popup-occlusion";
43+
import { isMapboxStyleUrl, loadMapboxStyle, redactMapboxStyleUrl } from "./mapbox-style";
4344
import { PlanetaryScaleControl } from "./planetary-scale-control";
4445
import { getOfflineBasemapStyle, isOfflineBasemapSentinel } from "./protomaps-basemap";
4546
import { ResetBearingControl } from "./reset-bearing-control";
@@ -380,6 +381,12 @@ export class MapController {
380381
// (reentrancy guard against a sync loop). See syncLayerControlState.
381382
private refreshingStyleEditor = false;
382383
private basemapStyleUrl = DEFAULT_BASEMAP;
384+
// Bumped on every style application so an asynchronously resolved style (the
385+
// Mapbox path in applyStyleToMap) can tell whether it is still the current one.
386+
private styleGeneration = 0;
387+
// Aborts the in-flight Mapbox descriptor request, so a superseded basemap
388+
// change (or destroy) does not leave the fetch running.
389+
private pendingMapboxStyleAbort: AbortController | null = null;
383390
private basemapVisible = true;
384391
private basemapOpacity = 1;
385392
private mapPreferences: MapPreferences = DEFAULT_PROJECT_PREFERENCES.map;
@@ -422,9 +429,14 @@ export class MapController {
422429
const maxPitch = clampNumber(mapPreferences.maxPitch, 0, DEFAULT_MAX_PITCH);
423430
this.mapPreferences = mapPreferences;
424431
this.basemapStyleUrl = options.styleUrl ?? DEFAULT_BASEMAP;
432+
// A Mapbox descriptor has to be fetched and rewritten before MapLibre will
433+
// take it (see applyStyleToMap), which the Map constructor cannot wait for.
434+
// Start blank and apply it below, as soon as the listeners are wired — the
435+
// path a project saved with a Mapbox basemap and a split-view pane both take.
436+
const deferMapboxStyle = isMapboxStyleUrl(this.basemapStyleUrl);
425437
this.map = new maplibregl.Map({
426438
container,
427-
style: resolveMapStyle(this.basemapStyleUrl),
439+
style: deferMapboxStyle ? createBlankMapStyle() : resolveMapStyle(this.basemapStyleUrl),
428440
center: view?.center ?? [-100, 40],
429441
zoom: view?.zoom ?? 2,
430442
bearing: view?.bearing ?? 0,
@@ -496,6 +508,10 @@ export class MapController {
496508
this.addAttributionControl();
497509
this.addLogoControl();
498510
this.addMaptoolkitLogoControl();
511+
// Kick off the deferred Mapbox descriptor fetch now that `style.load` and
512+
// `styledata` are wired, so the real style is treated exactly like a later
513+
// basemap switch rather than racing the listeners above.
514+
if (deferMapboxStyle) this.applyStyleToMap(this.basemapStyleUrl);
499515
return this.map;
500516
}
501517

@@ -863,6 +879,7 @@ export class MapController {
863879
clearTimeout(this.layerControlStyleRefreshTimer);
864880
this.layerControlStyleRefreshTimer = null;
865881
}
882+
this.abortPendingMapboxStyle();
866883
this.map?.remove();
867884
this.map = null;
868885
this.styleReady = false;
@@ -872,16 +889,99 @@ export class MapController {
872889
setStyle(url: string): void {
873890
if (!this.map) return;
874891
this.basemapStyleUrl = url;
875-
this.styleReady = false;
876-
this.basemapOriginalPaintValues.clear();
877-
this.removeLayerControl();
878-
this.map.setStyle(resolveMapStyle(url));
892+
this.applyStyleToMap(url);
879893
// Switching to/from a planetary basemap changes the active body (the store's
880894
// ellipsoid subscription runs first, so the singleton is already current),
881895
// so redraw the scale bar for the new radius without waiting for a pan.
882896
this.scaleControl?.refresh();
883897
}
884898

899+
/**
900+
* Tears down the state that belongs to the outgoing style, immediately before
901+
* the incoming one is handed to MapLibre. `style.load` rebuilds all of it.
902+
*
903+
* Deliberately called per style application rather than at the top of
904+
* `setStyle`: the Mapbox path below only reaches `map.setStyle` after an
905+
* asynchronous fetch, and tearing down first would leave the controller
906+
* wedged if that fetch failed — `styleReady` stuck false with no `style.load`
907+
* coming to clear it, so layer syncing, basemap visibility/opacity and the
908+
* layer control would all stay disabled over a still-rendered old style.
909+
*/
910+
private beginStyleSwap(): void {
911+
this.styleReady = false;
912+
this.basemapOriginalPaintValues.clear();
913+
this.removeLayerControl();
914+
}
915+
916+
/**
917+
* Hands a basemap style URL to MapLibre.
918+
*
919+
* Everything except Mapbox resolves synchronously, so `setStyle` is handed the
920+
* URL (or the inline style a GeoLibre sentinel expands to) directly. Mapbox
921+
* style descriptors are Mapbox-flavored and must be fetched and rewritten
922+
* before MapLibre will accept them (see ./mapbox-style), which makes that path
923+
* asynchronous: a generation counter drops the result of a swap the user has
924+
* already superseded, so a slow descriptor can never overwrite a newer
925+
* basemap. Validation is off for those, matching how the basemap control
926+
* applies them — the descriptor is transformed to spec, not authored here.
927+
*/
928+
private applyStyleToMap(url: string): void {
929+
const map = this.map;
930+
if (!map) return;
931+
const generation = ++this.styleGeneration;
932+
// Drop any descriptor still in flight for the basemap this one replaces:
933+
// its result is already destined for the generation check below, so the
934+
// request is pure waste. Also covers a request that never settles, which
935+
// would otherwise keep its closure (and this controller) alive.
936+
this.abortPendingMapboxStyle();
937+
938+
if (!isMapboxStyleUrl(url)) {
939+
this.beginStyleSwap();
940+
map.setStyle(resolveMapStyle(url));
941+
return;
942+
}
943+
944+
const pending = new AbortController();
945+
this.pendingMapboxStyleAbort = pending;
946+
void loadMapboxStyle(url, pending.signal)
947+
.then((style) => {
948+
if (this.map !== map || this.styleGeneration !== generation) return;
949+
this.beginStyleSwap();
950+
map.setStyle(style, { validate: false });
951+
})
952+
.catch((error: unknown) => {
953+
if (this.map !== map || this.styleGeneration !== generation) return;
954+
// Nothing was torn down (beginStyleSwap runs only on success), so the
955+
// controller stays fully live over the style it already had — better
956+
// than blanking the map. The basemap control watches its own parallel
957+
// setStyle and rolls the basemap back through the store when a provider
958+
// style fails, which arrives here as a newer generation.
959+
// The URL carries the user's access_token, so log the descriptor id only.
960+
console.warn(
961+
`Failed to load the Mapbox basemap style "${redactMapboxStyleUrl(url)}".`,
962+
error,
963+
);
964+
})
965+
.finally(() => {
966+
if (this.pendingMapboxStyleAbort === pending) {
967+
this.pendingMapboxStyleAbort = null;
968+
}
969+
});
970+
}
971+
972+
/**
973+
* Cancels an in-flight Mapbox descriptor request, if any.
974+
*
975+
* An abort rejects the fetch with an `AbortError`, but that rejection always
976+
* lands on a superseded generation (this is only called after bumping it, or
977+
* from `destroy`, which nulls the map), so the handlers above return before
978+
* reaching the warning — no aborted request is ever reported as a failure.
979+
*/
980+
private abortPendingMapboxStyle(): void {
981+
this.pendingMapboxStyleAbort?.abort();
982+
this.pendingMapboxStyleAbort = null;
983+
}
984+
885985
setBasemapVisible(visible: boolean): void {
886986
this.basemapVisible = visible;
887987
this.applyBasemapVisibility();

0 commit comments

Comments
 (0)