Pull Request Review
PR #69 — Fix route disruption map blanking after pan/zoom interactions
Overview
detectWebGLSupport() was called on every React render of
RouteDisruptionMap. Each call creates a probe canvas and a real WebGL
context. Pan/zoom interactions trigger re-renders; the browser's ~16-context pool
fills up, and the browser evicts the oldest contexts — the live
MapLibre and deck.gl ones — permanently blanking the canvas.
DOM overlays (legend, popup) keep rendering because they are not WebGL-backed.
92a7081a) added sanitizeViewState
with zoom, latitude, pitch, and bearing clamps. It was never merged into main:
PR #58 was diffed against a pre-fix base tree (f52d3828) and landed
the map file without those clamps. As a result,
onViewStateChange passed raw controller values straight into state
— unclamped zoom, latitude, pitch, and bearing.
detectWebGLSupport has a single call site in the codebase —
RouteDisruptionMap. The Map tab's port-map never calls it and was
not impacted. The blanking was reported in light mode but affects both themes.
Causal Flow
-
User pan / zoomPointer or wheel event on the DeckGL canvas
-
DeckGL
onViewStateChangeController emits raw view-state object -
setViewState(raw)Unclamped zoom, latitude, pitch, bearing written to state -
React re-render of
RouteDisruptionMapState change triggers full component re-render -
detectWebGLSupport(document)— inline callNo cache; runs unconditionally on every render passevery render -
New probe canvas + WebGL context createdEach call allocates a real GPU context; never released
-
Browser WebGL context pool (~16 cap) overflowsPool saturates after ~16 interactions
-
Browser evicts oldest contextsLRU eviction targets the first-created contexts
-
MapLibre + deck.gl live contexts lostThe oldest contexts are the live map ones
-
Canvas permanently blankDOM overlays (legend, popup) keep rendering; map tiles gone
-
User pan / zoomSame pointer or wheel event on the DeckGL canvas
-
onViewStateChangeController emits raw view-state object -
sanitizeViewState(nextViewState)Clamps zoom [1.2–5.5], latitude ±85, pins pitch=0, bearing=0; normalizes longitude -
setViewState(sanitized)Safe, clamped values written to state -
React re-render of
RouteDisruptionMapState change triggers re-render as before -
isWebGLSupported()— reads cacheChecks module-levelonce per sessionwebglSupportCache; probe already ran at mount -
Zero new WebGL contexts createdContext pool stays well below the ~16 cap
-
Map stays liveBasemap tiles, route arcs, and port dots remain visible at every step
Regression Timeline
sanitizeViewState with zoom, latitude, pitch, and bearing clamps.
This fix was correct but was never merged into main —
git log -S sanitizeViewState on main returns empty.
f52d3828. Landed the map file
without the sanitizeViewState clamps, silently
overwriting the branch that had them. Raw controller values now flow
directly into state on every interaction.
detectWebGLSupport with a per-render inline call site
in RouteDisruptionMap. This is the commit that caused the actual
blanking regression: each render now allocates a new WebGL context, exhausting
the browser pool after ~16 interactions.
webglSupportCache
and restores the lost sanitizeViewState clamps from commit
92a7081a. Both root causes addressed in a single 37-line change.
File Changes
MIN_INTERACTION_ZOOM, MAX_INTERACTION_ZOOM
Added two module-level numeric constants:
MIN_INTERACTION_ZOOM = 1.2 and
MAX_INTERACTION_ZOOM = 5.5.
These define the permitted zoom range for the Route Disruption map and are
referenced by sanitizeViewState.
webglSupportCache + isWebGLSupported()
Added a module-level variable webglSupportCache (initially
null) and a lazy accessor isWebGLSupported().
On the first call, the function runs the probe canvas + WebGL context check
and stores the boolean result in the cache. Every subsequent call returns the
cached value immediately. An explanatory comment guards against re-inlining
the probe at the call site.
sanitizeViewState(nextViewState)
Added sanitizeViewState(nextViewState): normalizes longitude
to the −180–180 range, clamps latitude to ±85, clamps
zoom to [MIN_INTERACTION_ZOOM, MAX_INTERACTION_ZOOM]
(1.2–5.5), and pins pitch and bearing to 0.
92a7081a that was lost when
PR #58 landed against a stale base. Without these clamps, the DeckGL
controller can emit out-of-range values that destabilize the map state.
RouteDisruptionMap
Two call-site changes inside the component body:
1. The WebGL support check now calls isWebGLSupported() (the
cached accessor) instead of the bare detectWebGLSupport(document)
inline call.
2. The onViewStateChange handler now wraps the controller value
in sanitizeViewState before passing it to setViewState,
so only safe, clamped values ever reach React state.
Verification Evidence
Test Harness
Headless Chromium (Playwright) driving the real dev-server map in light mode.
/api/route-disruptions mocked with fixture data generated by calling
buildRouteDisruptionRoutes directly — no MongoDB connection required.
Script interaction sequence:
Map-region screenshot byte count collapsed from 55,156 to 17,391 bytes — blank white canvas confirmed visually. Map never recovers after the pool overflows. Repeated console warnings:
WARNING: Too many active WebGL contexts. Oldest context will be lost.
Screenshot bytes remain in the range 26,602–58,453 across all interaction phases. Basemap tiles, route arcs, and port dots visibly rendered at every step. Zero WebGL context warnings in the console.
npm run build (tsc -b + Vite) exits 0 with no errors.
708 unit tests pass, 0 fail.
Verdict
Single-component fix, root cause proven by before/after reproduction. Risk: low. No API, schema or dependency changes.