Files
TREK/client/src/utils/webgl.ts
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

69 lines
2.7 KiB
TypeScript

/**
* 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
}
}