From 1761f2bcd585eee2c6ffa597dd93c542b6589537 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 8 Sep 2026 16:18:18 +0200 Subject: [PATCH] fix(maps): keep a browser without WebGL out of the error screen The default basemap has been a vector style since 4.1.0, and maplibre-gl builds its WebGL context inside the Map constructor. A browser that refuses one throws from there, and VectorBasemap attaches the layer inside an async effect, so the throw became an unhandled rejection no boundary could see. What actually took the page down was the teardown. Leaflet registers a layer before onAdd runs, so the half-built one stayed on the map, and the bridge's onRemove calls this._glMap.remove() without asking whether there is one. That TypeError lands in a React cleanup, which is why the error appears on the way out of a map rather than on the way in, and why it reads as "Something went wrong" when a tab changes and "TREK could not start" when the route does. The browser is now asked first, once per page load, with a one-pixel probe that hands the context straight back. Without WebGL the basemap is OSM raster tiles instead of an empty map, and the maplibre chunk, about a megabyte, is never fetched. The teardown path is safe either way: a half-built GL layer gets a no-op stand-in so the bridge can finish its own cleanup, and detaching never throws, because unmounting a map must not fail just because its basemap did. Reproduced against demo.liketrek.com with WebGL switched off, and verified with the fix: 14 raster tiles drawn, no GL canvas, no error screen. Closes #2288 --- .../components/Journey/JourneyMap.test.tsx | 3 + client/src/components/Journey/JourneyMap.tsx | 9 +- .../src/components/Map/VectorBasemap.test.tsx | 112 ++++++++- client/src/components/Map/VectorBasemap.tsx | 226 +++++++++++++++--- client/src/constants/mapDefaults.ts | 15 ++ client/src/pages/atlas/useAtlas.test.tsx | 3 + client/src/pages/atlas/useAtlas.ts | 12 +- client/src/utils/webgl.test.ts | 91 +++++++ client/src/utils/webgl.ts | 68 ++++++ 9 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 client/src/utils/webgl.test.ts create mode 100644 client/src/utils/webgl.ts 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 + } +}