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

92 lines
3.3 KiB
TypeScript

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