Files
TREK/client/src/components/Map/VectorBasemap.tsx
T
Maurice 1761f2bcd5 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
2026-09-08 19:00:13 +02:00

306 lines
12 KiB
TypeScript

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 {
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<typeof MaplibreGL>
/**
* 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.
*
* Both imports are dynamic, the same rule the GL renderers follow: a static one
* would pull about a megabyte into every chunk that draws a map, including the
* public share page. The engine module comes along for maplibre-gl's stylesheet,
* which the GL canvas needs to lay itself out.
*
* The layer comes from the bridge's own export rather than from the `L.maplibreGL`
* it also installs: that side effect is guarded by `Object.isExtensible(L)` and
* would fail silently — every Leaflet map blank — if a bundler ever handed it a
* sealed namespace object.
*/
async function loadLayerFactory() {
const [, bridge] = await Promise.all([
import('./engines/maplibre'),
import('@maplibre/maplibre-gl-leaflet'),
])
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 <TileLayer>
* 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.
*
* OpenFreeMap only serves vector tiles, and CARTO's raster tiles now carry a
* 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
* `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 }) {
const map = useMap()
const layerRef = useRef<GlLeafletLayer | null>(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 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
// reason the raster maps keep their template out of the build effect (#2097).
const layer = maplibreGL({
style: styleRef.current,
interactive: false,
attributionControl: false,
})
if (!attachGlLayer(map, layer, styleRef.current)) {
setGlUsable(false)
return
}
layerRef.current = layer
})()
return () => {
cancelled = true
detachBasemapLayer(layerRef.current)
layerRef.current = null
}
}, [map, glUsable])
useEffect(() => {
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 (
<TileLayer
key="webgl-fallback"
url={RASTER_FALLBACK_TILE_URL}
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
)
}
return null
}
export default VectorBasemap
/**
* 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. 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: BasemapLayer | null },
cancelled: () => boolean,
opts: { hideLabels?: boolean } = {},
): Promise<void> {
// 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 })
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
// 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.
*
* 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: 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 = () => {
for (const l of gl.getStyle()?.layers ?? []) {
if (l.type === 'symbol') gl.setLayoutProperty(l.id, 'visibility', 'none')
}
}
gl.on('style.load', apply)
// The first style may already be in when we get here.
if (gl.isStyleLoaded()) apply()
}