diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index 3fecd4748..d32123535 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -86,6 +86,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({ }), })); vi.mock('../Map/engines/maplibre', () => ({ default: {} })); +// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These +// cases are about the GL layer, so the probe says yes here. +vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} })); const entriesWithCoords = [ { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 28d230dff..de9c1fe91 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -4,7 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { useCartoApiKey } from '../../hooks/useTileUrl' import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl' import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults' -import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap' +import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap' import { escapeHtml, type JourneyTrack } from '@trek/shared' export interface MapMarkerItem { @@ -162,7 +162,8 @@ function JourneyMap( const tileUrlRef = useRef(tileUrl) tileUrlRef.current = tileUrl const tileLayerRef = useRef(null) - const glLayerRef = useRef(null) + // GL layer or the raster stand-in a browser without WebGL gets instead (#2288). + const glLayerRef = useRef(null) // The vector basemap loads async; a map torn down before it lands must not get one. const cancelledRef = useRef(false) const containerRef = useRef(null) @@ -379,7 +380,7 @@ function JourneyMap( map.remove() mapRef.current = null tileLayerRef.current = null - glLayerRef.current?.remove() + detachBasemapLayer(glLayerRef.current) glLayerRef.current = null markersRef.current.clear() } @@ -389,7 +390,7 @@ function JourneyMap( // marker and track it just drew. A vector basemap restyles instead, which also // avoids spending a WebGL context on every theme toggle. useEffect(() => { - if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl) + if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl) else tileLayerRef.current?.setUrl(tileUrl) }, [tileUrl]) diff --git a/client/src/components/Map/VectorBasemap.test.tsx b/client/src/components/Map/VectorBasemap.test.tsx index 8cc0a1e6d..60d870f63 100644 --- a/client/src/components/Map/VectorBasemap.test.tsx +++ b/client/src/components/Map/VectorBasemap.test.tsx @@ -10,8 +10,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, waitFor } from '@testing-library/react' import type * as L from 'leaflet' import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet' -import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap' -import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults' +import { hasWebGL } from '../../utils/webgl' +import { + VectorBasemap, + attachVectorBasemap, + detachBasemapLayer, + hideLabelLayers, + restyleBasemap, + type BasemapLayer, + type GlLeafletLayer, +} from './VectorBasemap' +import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults' const addAttribution = vi.fn() /** Only the two things the component touches; casting keeps the mock honest about that. */ @@ -20,7 +29,16 @@ const leafletMap = asMap({ attributionControl: { addAttribution } }) /** A map built without an attribution control, like the atlas. */ const bareMap = asMap({}) -vi.mock('react-leaflet', () => ({ useMap: () => leafletMap })) +// TileLayer as well as useMap: the component renders one when the browser has no +// WebGL, and jsdom is exactly such a browser. +vi.mock('react-leaflet', () => ({ + useMap: () => leafletMap, + TileLayer: ({ url }: { url: string }) =>
, +})) + +// jsdom has no WebGL, so without this every case below would take the raster +// branch. The suite is about the GL wiring; the raster branch has its own cases. +vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() })) vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } })) vi.mock('@maplibre/maplibre-gl-leaflet', () => { const maplibreGL = vi.fn(() => { @@ -201,3 +219,91 @@ describe('hideLabelLayers', () => { expect(() => hideLabelLayers(layer)).not.toThrow() }) }) + +/** + * A browser that will not give MapLibre a WebGL context (#2288). + * + * Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all + * land here. Before the probe, the throw came out of an async effect nobody could + * catch, and the layer it left behind took the page down on the way out. + */ +describe('VectorBasemap without WebGL', () => { + const noWebGL = vi.mocked(hasWebGL) + + /** A map real Leaflet will accept, which the raster stand-in needs. */ + const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } }) + + beforeEach(() => { + noWebGL.mockReturnValue(false) + }) + + it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => { + // Not loading it is half the point: about a megabyte for a browser that could + // never have drawn it. + const { findByTestId } = render() + + const tiles = await findByTestId('raster-basemap') + expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL) + expect(maplibreGL).not.toHaveBeenCalled() + }) + + it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => { + const { unmount } = render() + // The crash in #2288 came from teardown, not from the failed attach. + expect(() => unmount()).not.toThrow() + }) + + it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => { + const ref: { current: BasemapLayer | null } = { current: null } + const map = rasterMap() + + await attachVectorBasemap(map, OFM_POSITRON, ref, () => false) + + expect(maplibreGL).not.toHaveBeenCalled() + expect(ref.current).not.toBeNull() + // OpenStreetMap rather than OpenFreeMap: different tiles, different credit. + expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright')) + }) + + it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => { + const ref: { current: BasemapLayer | null } = { current: null } + await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true) + expect(ref.current).toBeNull() + }) + + it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => { + const ref: { current: BasemapLayer | null } = { current: null } + await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false) + + // Both are GL-only operations. Called on raster tiles they must do nothing + // rather than reach for a maplibre map that is not there. + expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow() + expect(() => hideLabelLayers(ref.current!)).not.toThrow() + }) +}) + +describe('detachBasemapLayer', () => { + it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => { + // This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove + // calls this._glMap.remove() without asking whether there is one, and after a + // refused context there is not. It ran inside a React cleanup, so the throw + // reached a boundary and replaced the page. + const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) { + this._glMap!.remove() + }) + const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer + + expect(() => detachBasemapLayer(halfBuilt)).not.toThrow() + expect(remove).toHaveBeenCalled() + }) + + it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => { + // Unmounting a map is never allowed to fail because its basemap did. + const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer + expect(() => detachBasemapLayer(layer)).not.toThrow() + }) + + it('FE-COMP-VECBM-020: nothing to detach is not an error', () => { + expect(() => detachBasemapLayer(null)).not.toThrow() + }) +}) diff --git a/client/src/components/Map/VectorBasemap.tsx b/client/src/components/Map/VectorBasemap.tsx index ba6512ba0..235612aa3 100644 --- a/client/src/components/Map/VectorBasemap.tsx +++ b/client/src/components/Map/VectorBasemap.tsx @@ -1,12 +1,30 @@ -import { useEffect, useRef } from 'react' -import { useMap } from 'react-leaflet' -import type * as L from 'leaflet' +import { useEffect, useRef, useState } from 'react' +import { TileLayer, useMap } from 'react-leaflet' +import L from 'leaflet' import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet' -import { attributionForTile } from '../../constants/mapDefaults' +import { + RASTER_FALLBACK_MAX_ZOOM, + RASTER_FALLBACK_TILE_URL, + attributionForTile, +} from '../../constants/mapDefaults' +import { hasWebGL } from '../../utils/webgl' +import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload' /** The Leaflet layer maplibre-gl-leaflet hands back. */ export type GlLeafletLayer = InstanceType +/** + * What a map ended up drawing with. Every caller keeps one ref for both kinds, so + * there is no code path that knows only about the GL layer and falls over the + * raster stand-in the moment a browser has no WebGL (#2288). + */ +export type BasemapLayer = GlLeafletLayer | L.TileLayer + +/** A GL layer rather than the raster stand-in. Prototype method, so `in` finds it. */ +function isGlLayer(layer: BasemapLayer): layer is GlLeafletLayer { + return 'getMaplibreMap' in layer +} + /** * Load the layer factory. * @@ -28,11 +46,105 @@ async function loadLayerFactory() { return bridge.maplibreGL } +/** The factory, or null when its chunk never arrived. A dead chunk is not the basemap's problem. */ +async function loadLayerFactoryOrNull() { + try { + return await loadLayerFactory() + } catch (err) { + // A chunk gone after a deploy is the global handler's case and heals itself + // with one reload; here it only means there is no GL layer to attach. + if (isChunkLoadError(err)) reloadOnceForChunk() + else console.warn('[basemap] the maplibre chunk did not load', err) + return null + } +} + /** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */ function creditBasemap(map: L.Map, style: string): void { map.attributionControl?.addAttribution(attributionForTile(style)) } +/** + * Make a half-built GL layer safe to take off a map. + * + * maplibre-gl-leaflet's onRemove calls `this._glMap.remove()` without asking + * whether there is one, and after a refused WebGL context there is not. That + * second throw is what turned a missing basemap into a crash: it lands in a React + * cleanup, where a boundary picks it up and replaces the page (#2288). A no-op + * stand-in lets the bridge run the rest of its own teardown, the pane child and + * the zoom-animation listener, and skip only the line that cannot work. A healthy + * layer already has a GL map and is left alone. + */ +function disarmGlLayer(layer: GlLeafletLayer): void { + const internals = layer as unknown as { _glMap?: { remove: () => void } | null } + if (!internals._glMap) internals._glMap = { remove: () => {} } +} + +/** + * Hang a GL layer into a Leaflet map and say whether it took. + * + * maplibre-gl builds its WebGL context inside the Map constructor, which Leaflet + * reaches through onAdd, so `addTo` is where a browser without WebGL throws. The + * probe catches nearly every case; what is left is a context budget that ran out + * between probe and attach, which is real on a page holding several maps. Either + * way the layer has to come back off: Leaflet registers it before onAdd runs, so + * a broken one left behind is handed to the retile effect and to every later + * map.remove(). + */ +function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolean { + try { + layer.addTo(map) + } catch (err) { + console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err) + detachBasemapLayer(layer) + return false + } + creditBasemap(map, style) + return true +} + +/** + * The raster stand-in, attached imperatively for the maps that build Leaflet + * themselves. The react-leaflet callers get the same tiles through the + * below instead, the way they already draw a user's own raster template. + */ +function attachRasterFallback(map: L.Map): L.TileLayer { + const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, { + maxZoom: RASTER_FALLBACK_MAX_ZOOM, + referrerPolicy: 'strict-origin-when-cross-origin', + } as L.TileLayerOptions) + layer.addTo(map) + creditBasemap(map, RASTER_FALLBACK_TILE_URL) + return layer +} + +/** + * Take a basemap off its map. Never throws: unmounting a map is not allowed to + * fail because its basemap did (#2288). + */ +export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void { + if (!layer) return + if (isGlLayer(layer)) disarmGlLayer(layer) + try { + layer.remove() + } catch (err) { + console.warn('[basemap] teardown failed, leaving the layer where it is', err) + } +} + +/** + * Retile in place. Swapping the layer would drop a WebGL context per theme + * toggle, and browsers cap those in the low teens. + * + * The raster stand-in has nothing to swap: it draws one template, and there is no + * keyless dark OSM raster to switch to, so a theme change leaves it alone rather + * than refetching the same tiles. + */ +export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void { + if (!layer || !isGlLayer(layer)) return + layer.getMaplibreMap()?.setStyle(style) +} + /** * A MapLibre vector style as the basemap of a Leaflet map. * @@ -40,21 +152,37 @@ function creditBasemap(map: L.Map, style: string): void { * watermark unless the operator has requested a key by mail, so the basemap of * every Leaflet map here is a style document rather than a tile template. * maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which - * leaves everything above it alone: markers, GeoJSON, clusters, plugin layers - * and the panes they live in are untouched, and Leaflet's CSS puts + * leaves everything above it alone: markers, GeoJSON, clusters, plugin layers and + * the panes they live in are untouched, and Leaflet's CSS puts * `pointer-events: none` on the layer's canvas, so clicks fall through to them. + * + * WebGL is not a given, though. Hardware acceleration switched off, a blocklisted + * driver, a VM or a remote desktop all end with maplibre-gl throwing out of its + * constructor, so the browser is asked first and gets raster tiles when the answer + * is no (#2288). Asking is also what keeps the maplibre chunk off the wire for a + * browser that could never have used it. */ -export function VectorBasemap({ style }: { style: string }): null { +export function VectorBasemap({ style }: { style: string }) { const map = useMap() const layerRef = useRef(null) const styleRef = useRef(style) styleRef.current = style + // State rather than a plain constant: the probe can say yes and the attach can + // still fail once the page has spent its context budget, and that has to reach + // the render or the raster tiles never appear. + const [glUsable, setGlUsable] = useState(hasWebGL) useEffect(() => { + if (!glUsable) return let cancelled = false + void (async () => { - const maplibreGL = await loadLayerFactory() + const maplibreGL = await loadLayerFactoryOrNull() if (cancelled) return + if (!maplibreGL) { + setGlUsable(false) + return + } // The style is read through a ref, never from the dependency list: a style // change must retile in place rather than tear the layer down, the same @@ -64,68 +192,106 @@ export function VectorBasemap({ style }: { style: string }): null { interactive: false, attributionControl: false, }) + if (!attachGlLayer(map, layer, styleRef.current)) { + setGlUsable(false) + return + } layerRef.current = layer - layer.addTo(map) - creditBasemap(map, styleRef.current) })() return () => { cancelled = true - layerRef.current?.remove() + detachBasemapLayer(layerRef.current) layerRef.current = null } - }, [map]) + }, [map, glUsable]) - // Restyle in place. Swapping the layer would drop a WebGL context per theme - // toggle, and browsers cap those in the low teens. useEffect(() => { - layerRef.current?.getMaplibreMap()?.setStyle(style) + restyleBasemap(layerRef.current, style) }, [style]) + // No WebGL, no vector tiles. Raster keeps the map readable instead of leaving + // markers, routes and clusters floating over grey; see mapDefaults for why this + // particular source. The satellite toggle in MapView was always raster and is + // unaffected either way. + if (!glUsable) { + return ( + + ) + } return null } export default VectorBasemap /** - * The same layer for the maps that build Leaflet imperatively rather than + * The same basemap for the maps that build Leaflet imperatively rather than * through react-leaflet: the journey map and the atlas. * - * Loading is async because maplibre-gl only ever arrives through a dynamic - * import, so the caller passes a `cancelled` probe: a map torn down mid-load - * must not get a layer attached to it afterwards. + * Loading is async because maplibre-gl only ever arrives through a dynamic import, + * so the caller passes a `cancelled` probe: a map torn down mid-load must not get + * a layer attached to it afterwards. What lands in `ref` is either the GL layer or + * the raster stand-in, which is why callers go through restyleBasemap() and + * detachBasemapLayer() rather than reaching for getMaplibreMap() themselves. */ export async function attachVectorBasemap( map: L.Map, style: string, - ref: { current: GlLeafletLayer | null }, + ref: { current: BasemapLayer | null }, cancelled: () => boolean, opts: { hideLabels?: boolean } = {}, ): Promise { - const maplibreGL = await loadLayerFactory() + // Same gate as the component: a browser without WebGL never pays for the + // maplibre chunk and gets raster tiles rather than an empty map (#2288). + if (!hasWebGL()) { + if (!cancelled()) ref.current = attachRasterFallback(map) + return + } + + const maplibreGL = await loadLayerFactoryOrNull() if (cancelled()) return + if (!maplibreGL) { + ref.current = attachRasterFallback(map) + return + } const layer = maplibreGL({ style, interactive: false, attributionControl: false }) - ref.current = layer - layer.addTo(map) // The raster layer this replaces carried the credit, and OpenFreeMap asks for - // one of its own. - creditBasemap(map, style) + // one of its own; attachGlLayer adds it once the layer is actually on the map. + if (!attachGlLayer(map, layer, style)) { + ref.current = attachRasterFallback(map) + return + } + ref.current = layer if (opts.hideLabels) hideLabelLayers(layer) } /** * Drop every label from a vector style. * - * The atlas draws country names into its own fills, so a basemap that repeats - * them underneath is noise. OpenFreeMap has no label-free style, but its labels - * are all `symbol` layers, while borders and coastlines are `line` and `fill`, - * so hiding that one type leaves the geography intact. + * The atlas draws country names into its own fills, so a basemap that repeats them + * underneath is noise. OpenFreeMap has no label-free style, but its labels are all + * `symbol` layers, while borders and coastlines are `line` and `fill`, so hiding + * that one type leaves the geography intact. * * Bound to `style.load` rather than run once: the event fires again after every * setStyle, so a theme switch keeps the labels off without a second call site. */ -export function hideLabelLayers(layer: GlLeafletLayer): void { +export function hideLabelLayers(layer: BasemapLayer): void { + // Nothing to hide on the raster stand-in: OSM bakes its labels into the tile. + // A WebGL-less atlas therefore shows country names twice, which is cosmetic and + // the price of having a basemap at all, because the only keyless alternative is + // none. + if (!isGlLayer(layer)) return const gl = layer.getMaplibreMap() if (!gl) return const apply = () => { diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index d97731181..f00b1d593 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -22,6 +22,21 @@ export const SATELLITE_TILE_ATTRIBUTION = 'Imagery © Esri, Maxar, Earthstar Geographics' export const SATELLITE_TILE_MAXZOOM = 19 +/** + * The basemap for a browser that will not give MapLibre a WebGL context (#2288). + * + * OpenFreeMap serves vector tiles only, so the app default needs a GL canvas, and + * with WebGL off there is nothing to draw it on. Of the keyless raster sources + * TREK already points at, this is the only street map: the ESRI layer above is + * imagery, and the CARTO templates below have carried an "API KEY REQUIRED" + * watermark since 26.08.2026. It is preset one in the Map settings tab, so this + * aims no new traffic at OSM's servers, and the volume is bounded by how few + * browsers refuse a context in the first place. Attribution is required and comes + * from attributionForTile(), which already reads this host as OpenStreetMap. + */ +export const RASTER_FALLBACK_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' +export const RASTER_FALLBACK_MAX_ZOOM = 19 + // OpenFreeMap, the default basemap since CARTO began watermarking keyless tiles // on 26.08.2026 and moved its key behind a request by mail. No key, no // registration, no request limits, commercial use allowed, attribution required. diff --git a/client/src/pages/atlas/useAtlas.test.tsx b/client/src/pages/atlas/useAtlas.test.tsx index 73418b3d9..8c01a4f10 100644 --- a/client/src/pages/atlas/useAtlas.test.tsx +++ b/client/src/pages/atlas/useAtlas.test.tsx @@ -99,6 +99,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({ }), })); vi.mock('../../components/Map/engines/maplibre', () => ({ default: {} })); +// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These +// cases are about the GL layer, so the probe says yes here. +vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} })); vi.mock('leaflet', () => { // The bounds a country layer reports carry its own code, so the map's bounds can diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts index 644bf6c1c..d242116cf 100644 --- a/client/src/pages/atlas/useAtlas.ts +++ b/client/src/pages/atlas/useAtlas.ts @@ -4,7 +4,7 @@ import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18 import { useSettingsStore } from '../../store/settingsStore' import { useTileUrl } from '../../hooks/useTileUrl' import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults' -import { attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from '../../components/Map/VectorBasemap' +import { attachVectorBasemap, detachBasemapLayer, hideLabelLayers, restyleBasemap, type BasemapLayer } from '../../components/Map/VectorBasemap' import { isVectorStyle } from '../../utils/tileUrl' import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client' import L from 'leaflet' @@ -92,7 +92,8 @@ export function useAtlas() { const tileUrlRef = useRef(tileUrl) tileUrlRef.current = tileUrl const tileLayersRef = useRef([]) - const glLayerRef = useRef(null) + // GL layer or the raster stand-in a browser without WebGL gets instead (#2288). + const glLayerRef = useRef(null) // The vector basemap loads async; a map torn down before it lands must not get one. const cancelledRef = useRef(false) const mapRef = useRef(null) @@ -457,7 +458,7 @@ export function useAtlas() { renderedRegionSigRef.current = '' tileLayersRef.current = [] cancelledRef.current = true - glLayerRef.current?.remove() + detachBasemapLayer(glLayerRef.current) glLayerRef.current = null } }, [dark, loading]) @@ -470,9 +471,10 @@ export function useAtlas() { if (isVectorStyle(tileUrl)) { const layer = glLayerRef.current if (!layer) return - layer.getMaplibreMap()?.setStyle(tileUrl) + restyleBasemap(layer, tileUrl) // setStyle drops the layer list, and style.load fires again with the new - // one, so the label rule has to be re-armed rather than assumed. + // one, so the label rule has to be re-armed rather than assumed. Both calls + // are no-ops on the raster stand-in, which has neither styles nor layers. hideLabelLayers(layer) return } diff --git a/client/src/utils/webgl.test.ts b/client/src/utils/webgl.test.ts new file mode 100644 index 000000000..c69cecce6 --- /dev/null +++ b/client/src/utils/webgl.test.ts @@ -0,0 +1,91 @@ +/** + * The WebGL probe (#2288). + * + * What is under test is the contract the basemap depends on: ask the browser + * once, ask it the way maplibre-gl asks, give the context straight back, and + * never throw whatever the browser does. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { hasWebGL, resetWebGLProbe } from './webgl' + +const realGetContext = HTMLCanvasElement.prototype.getContext + +/** A context that records whether its lose-context extension was used. */ +function fakeContext() { + const loseContext = vi.fn() + return { ctx: { getExtension: vi.fn(() => ({ loseContext })) }, loseContext } +} + +beforeEach(() => { + resetWebGLProbe() + vi.restoreAllMocks() +}) + +afterEach(() => { + HTMLCanvasElement.prototype.getContext = realGetContext + resetWebGLProbe() +}) + +describe('hasWebGL', () => { + it('FE-UTIL-WEBGL-001: says yes when the browser hands back a context', () => { + const { ctx } = fakeContext() + HTMLCanvasElement.prototype.getContext = vi.fn(() => ctx) as unknown as typeof realGetContext + + expect(hasWebGL()).toBe(true) + }) + + it('FE-UTIL-WEBGL-002: says no when it hands back null, which is WebGL switched off', () => { + // jsdom answers this way too, so the whole suite would take the raster path + // if this were the other way round. + HTMLCanvasElement.prototype.getContext = vi.fn(() => null) as unknown as typeof realGetContext + + expect(hasWebGL()).toBe(false) + }) + + it('FE-UTIL-WEBGL-003: falls back to webgl1 when only webgl2 is refused', () => { + // Exactly the order maplibre-gl asks in. A stricter probe would black out + // maps that would in fact have drawn. + const { ctx } = fakeContext() + const getContext = vi.fn((type: string) => (type === 'webgl2' ? null : ctx)) + HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext + + expect(hasWebGL()).toBe(true) + expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2', 'webgl']) + }) + + it('FE-UTIL-WEBGL-004: gives the probe context straight back', () => { + // Contexts are a capped resource, around sixteen in Chrome. A probe that + // keeps one is a context the map behind it may not get. + const { ctx, loseContext } = fakeContext() + HTMLCanvasElement.prototype.getContext = vi.fn(() => ctx) as unknown as typeof realGetContext + + hasWebGL() + + expect(ctx.getExtension).toHaveBeenCalledWith('WEBGL_lose_context') + expect(loseContext).toHaveBeenCalled() + }) + + it('FE-UTIL-WEBGL-005: asks once and remembers, however often it is called', () => { + const { ctx } = fakeContext() + const getContext = vi.fn(() => ctx) + HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext + + hasWebGL() + hasWebGL() + hasWebGL() + + // One probe, one context. A planner holding a map and a settings preview + // would otherwise spend three. + expect(getContext).toHaveBeenCalledTimes(1) + }) + + it('FE-UTIL-WEBGL-006: a browser that throws out of getContext counts as no', () => { + // Some locked-down builds throw rather than returning null, and an answer is + // never worth an exception. + HTMLCanvasElement.prototype.getContext = vi.fn(() => { + throw new Error('WebGL is disabled by policy') + }) as unknown as typeof realGetContext + + expect(hasWebGL()).toBe(false) + }) +}) diff --git a/client/src/utils/webgl.ts b/client/src/utils/webgl.ts new file mode 100644 index 000000000..92a0be3b1 --- /dev/null +++ b/client/src/utils/webgl.ts @@ -0,0 +1,68 @@ +/** + * Whether this browser will hand MapLibre a WebGL context. + * + * The default basemap has been a vector style since 4.1.0, and maplibre-gl builds + * its GL context inside the Map constructor: no context, no map, and the throw + * lands in a place no error boundary was ever meant to see (#2288). Asking a + * one-pixel probe first is cheaper and quieter than finding out from a stack + * trace, and it keeps the maplibre chunk, about a megabyte, off the wire for a + * browser that could never have used it. + * + * "Cannot" is a bigger group than broken hardware: hardware acceleration turned + * off in Chrome or Firefox, webgl.disabled in about:config, a driver on the + * browser's blocklist, a VM or remote desktop with no GL at all, and privacy + * builds that switch it off against fingerprinting. + */ + +/** + * Probed once per page load, then remembered. + * + * A repeat probe costs a live WebGL context each time, and every browser caps how + * many it keeps: Chrome evicts the oldest at around sixteen. On a planner with a + * map and a settings preview open, a chatty probe is a map going blank. + */ +let probed: boolean | null = null + +export function hasWebGL(): boolean { + if (probed === null) probed = probe() + return probed +} + +/** Test seam. Nothing in the app clears this: the answer cannot change mid-session. */ +export function resetWebGLProbe(): void { + probed = null +} + +function probe(): boolean { + // jsdom and anything SSR-ish have no canvas implementation, and "no WebGL" is + // the honest answer for them rather than a special case. + if (typeof document === 'undefined') return false + + try { + const canvas = document.createElement('canvas') + // One pixel. The probe never draws, and a full backing store would allocate + // megabytes for an answer that is one bit wide. + canvas.width = 1 + canvas.height = 1 + + // Exactly the order maplibre-gl asks in (Map._setupPainter): webgl2 first, + // webgl1 second. A stricter probe would black out maps that would in fact + // have drawn; a looser one would let the throw through anyway. + const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as WebGLRenderingContext | null + if (!gl) { + console.warn('[basemap] this browser refuses a WebGL context, vector basemaps are unavailable') + return false + } + + // Hand the context back now instead of waiting for the canvas to be + // collected. Contexts are a capped resource and the GC is under no obligation + // to hurry, so a probe that keeps one is a context the map behind it may not + // get. + gl.getExtension('WEBGL_lose_context')?.loseContext() + return true + } catch { + // A few locked-down builds throw out of getContext rather than returning + // null, and an answer is never worth an exception. + return false + } +}