fix(maps): draw with Leaflet wherever WebGL2 is missing

MapLibre 6 and Mapbox GL 3 both throw out of their Map constructor without
WebGL2. Every place that builds a GL map now asks one helper first,
components/Map/glSupport.ts, and a browser without WebGL2 gets the Leaflet
map with the user's tiles instead of a map that fails to start:

- the trip map (MapViewAuto) and the journey maps (JourneyMapAuto) pick
  their renderer through useGlRenderer, which folds in the Mapbox token
  rule they each had;
- the settings preview, desktop and phone, shows the Leaflet preview
  through useMapSettingsForm and says why under it;
- the tour planner keeps the 2D map when 3D is asked for and says that
  3D needs WebGL2;
- the PDF route map and the Leaflet vector basemap already fell back and
  now report a GPU failure too.

Behind the probe, buildGlMap wraps each constructor (MapViewGL,
JourneyMapGL, the settings preview, the terrain map, the PDF map). A GPU
that claims WebGL2 and still refuses the context is reported through
utils/webgl, which turns the answer to no and tells every mounted map,
so they switch to Leaflet without a reload; any other error is still
thrown. Two new strings, map.webgl2.fallback and map.webgl2.terrain, in
all 27 locales.
This commit is contained in:
Maurice
2026-10-24 23:53:43 +02:00
parent 1696162d22
commit e28bd2fc99
47 changed files with 543 additions and 32 deletions
+5 -5
View File
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { MapView } from './MapView'
import ErrorBoundary from '../shared/ErrorBoundary'
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
import { useGlRenderer } from './glSupport'
import { useRoadtripHazards } from './useRoadtripHazards'
import { useTranslation } from '../../i18n/TranslationContext'
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
@@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) {
const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
// Fall back to Leaflet when Mapbox is selected but no token is set,
// so trip planner never shows an empty map due to a missing token.
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
: null
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
// browser has no WebGL2 (both GL engines need it), so the trip planner never
// shows an empty map or one that fails to start.
const glProvider = useGlRenderer(provider, token)
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
// maplibre-gl out of each other's downloads.
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
@@ -0,0 +1,88 @@
/**
* The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*).
*
* A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map
* with the user's tiles, never a map that fails to start: up front when the probe
* says no, and the moment the GL map's own constructor is refused when it said yes.
*/
import { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap } from './glSupport';
import { MapViewAuto } from './MapViewAuto';
const gl = vi.hoisted(() => ({ refuse: false }));
vi.mock('./MapView', () => ({
MapView: () => <div data-testid="leaflet-map" />,
}));
/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */
function FakeGlMap() {
useEffect(() => {
buildGlMap(() => {
if (!gl.refuse) return {};
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
throw err;
});
}, []);
return <div data-testid="gl-map" />;
}
vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap }));
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
const realGetContext = HTMLCanvasElement.prototype.getContext;
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
available && type === 'webgl2' ? ctx : null
) as unknown as typeof realGetContext;
}
beforeEach(() => {
resetAllStores();
resetWebGLProbe();
gl.refuse = false;
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } });
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('MapViewAuto without WebGL2', () => {
it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => {
browserWithWebGL2(true);
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('gl-map')).toBeInTheDocument();
expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => {
browserWithWebGL2(false);
render(<MapViewAuto places={[]} />);
expect(screen.getByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => {
browserWithWebGL2(true);
gl.refuse = true;
render(<MapViewAuto places={[]} />);
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
});
});
+5 -5
View File
@@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import { buildGlMap } from './glSupport'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -1104,13 +1105,12 @@ export function MapViewGL({
antialias: mapboxQuality,
}
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
// re-projecting the map center — a line that drifts with the bearing, so a
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
if (isMapLibre) mapOptions.aroundCenter = false
const map = new gl.Map(mapOptions as any)
// Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then.
const map = buildGlMap(() => new gl.Map(mapOptions as any))
if (!map) return
mapRef.current = map
popupRef.current = new gl.Popup({
closeButton: false,
+4 -1
View File
@@ -7,7 +7,8 @@ import {
RASTER_FALLBACK_TILE_URL,
attributionForTile,
} from '../../constants/mapDefaults'
import { hasWebGL } from '../../utils/webgl'
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
import { isGpuInitFailure } from './glSupport'
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
/** The Leaflet layer maplibre-gl-leaflet hands back. */
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
layer.addTo(map)
} catch (err) {
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
if (isGpuInitFailure(err)) reportWebGLFailure()
detachBasemapLayer(layer)
return false
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
*
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
* What is under test is the one decision every GL map goes through: the user's
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
* GPU that claimed WebGL2 refuses the context after all.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
const realGetContext = HTMLCanvasElement.prototype.getContext;
/** A browser whose canvas hands out a webgl2 context, or none. */
function browserWithWebGL2(available: boolean) {
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
return getContext;
}
function gpuError(): Error {
const err = new Error('WebGL2 is required to display this map.');
err.name = 'GPUInitializationError';
return err;
}
beforeEach(() => {
resetWebGLProbe();
});
afterEach(() => {
HTMLCanvasElement.prototype.getContext = realGetContext;
resetWebGLProbe();
});
describe('pickGlRenderer', () => {
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
});
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
});
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
});
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
const webgl2 = vi.fn(() => true);
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
expect(webgl2).not.toHaveBeenCalled();
});
});
describe('isGpuInitFailure', () => {
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
expect(isGpuInitFailure(gpuError())).toBe(true);
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
});
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
expect(isGpuInitFailure(null)).toBe(false);
});
});
describe('buildGlMap', () => {
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
const map = { remove: vi.fn() };
expect(buildGlMap(() => map)).toBe(map);
});
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
browserWithWebGL2(true);
expect(hasWebGL()).toBe(true);
expect(
buildGlMap(() => {
throw gpuError();
})
).toBeNull();
expect(hasWebGL()).toBe(false);
});
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
browserWithWebGL2(true);
expect(() =>
buildGlMap(() => {
throw new TypeError('style is undefined');
})
).toThrow('style is undefined');
expect(hasWebGL()).toBe(true);
});
});
describe('useGlRenderer', () => {
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
const getContext = browserWithWebGL2(false);
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
expect(result.current).toBeNull();
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
});
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
browserWithWebGL2(true);
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
const terrain = renderHook(() => useWebGL2());
expect(trip.result.current).toBe('maplibre-gl');
expect(terrain.result.current).toBe(true);
act(() => {
buildGlMap(() => {
throw gpuError();
});
});
expect(trip.result.current).toBeNull();
expect(terrain.result.current).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from 'react';
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
import type { GlMapProvider } from './glProviders';
/*
* Whether a map may be drawn with a GL engine at all, for every place that would
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
*
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
* constructor without it. A browser that cannot give it gets the Leaflet map with
* the user's tiles instead, never a map that fails to start. Like the GL
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
*/
/**
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
* as it can run here. Mapbox also needs a token, MapLibre does not.
*/
export function pickGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined,
webgl2: () => boolean = hasWebGL
): GlMapProvider | null {
const wanted: GlMapProvider | null =
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
return wanted && webgl2() ? wanted : null;
}
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
function useWebGLFailures(): number {
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
}
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
export function useGlRenderer(
provider: string | null | undefined,
mapboxToken: string | null | undefined
): GlMapProvider | null {
useWebGLFailures();
return pickGlRenderer(provider, mapboxToken);
}
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
export function useWebGL2(): boolean {
useWebGLFailures();
return hasWebGL();
}
/**
* The GPU refusing a context, as either engine reports it from its Map
* constructor. Matched on the name and message because the class itself lives in
* the maplibre chunk, and Mapbox throws a plain Error.
*/
export function isGpuInitFailure(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
}
/**
* Builds a GL map, the second line of defence behind the probe: a GPU that
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
* and the caller gets null instead of a throw. Anything else is a bug and is
* thrown on.
*/
export function buildGlMap<T>(construct: () => T): T | null {
try {
return construct();
} catch (err) {
if (!isGpuInitFailure(err)) throw err;
reportWebGLFailure();
return null;
}
}