Pull Request Review

PR #69 — Fix route disruption map blanking after pan/zoom interactions

Repository
Ekho-Labs / port-routes-dashboard-internal
Branch
fix/route-disruption-map-viewstate → main
Commit
9741aece
Author
darknessest (impl. by agent sisyphus)
Diff
1 file  +35 -2
01

Overview

Cause A — WebGL context leak
Per-render WebGL context creation exhausts the browser pool
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.
WARNING: Too many active WebGL contexts. Oldest context will be lost.
Cause B — Missing view-state clamps
sanitizeViewState never reached main; raw controller values flowed into state
An earlier fix (commit 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.
Scope: Only the Route Disruption map was affected. 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.
02

Causal Flow

Per interaction (every render)
Once per session (cached)
Hover a node to trace the causal path
Before (broken) PR #61 regression path
  1. User pan / zoom
    Pointer or wheel event on the DeckGL canvas
  2. DeckGL onViewStateChange
    Controller emits raw view-state object
  3. setViewState(raw)
    Unclamped zoom, latitude, pitch, bearing written to state
  4. React re-render of RouteDisruptionMap
    State change triggers full component re-render
  5. detectWebGLSupport(document) — inline call
    No cache; runs unconditionally on every render pass
    every render
  6. New probe canvas + WebGL context created
    Each call allocates a real GPU context; never released
  7. Browser WebGL context pool (~16 cap) overflows
    Pool saturates after ~16 interactions
  8. Browser evicts oldest contexts
    LRU eviction targets the first-created contexts
  9. MapLibre + deck.gl live contexts lost
    The oldest contexts are the live map ones
  10. Canvas permanently blank
    DOM overlays (legend, popup) keep rendering; map tiles gone
After (fixed) PR #69 corrected path
  1. User pan / zoom
    Same pointer or wheel event on the DeckGL canvas
  2. onViewStateChange
    Controller emits raw view-state object
  3. sanitizeViewState(nextViewState)
    Clamps zoom [1.2–5.5], latitude ±85, pins pitch=0, bearing=0; normalizes longitude
  4. setViewState(sanitized)
    Safe, clamped values written to state
  5. React re-render of RouteDisruptionMap
    State change triggers re-render as before
  6. isWebGLSupported() — reads cache
    Checks module-level webglSupportCache; probe already ran at mount
    once per session
  7. Zero new WebGL contexts created
    Context pool stays well below the ~16 cap
  8. Map stays live
    Basemap tiles, route arcs, and port dots remain visible at every step
03

Regression Timeline

Apr 2026 92a7081a
"Fix route disruption full route and map state"
Added 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.
Never merged to main
Aug 2026 PR #58 — 0be7d428
"Dashboard UI pass"
Based on pre-fix base tree 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.
Landed without clamps
PR #61 — 1c0edd83
"Fix cold-cache API stampede"
Introduced 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.
Introduced blanking regression
PR #69 — 9741aece
"Fix route disruption map blanking after pan/zoom interactions"
Caches WebGL detection once per session via module-level webglSupportCache and restores the lost sanitizeViewState clamps from commit 92a7081a. Both root causes addressed in a single 37-line change.
Both causes fixed
04

File Changes

web/src/components/route-disruption/route-disruption-map.tsx  —  +35  -2
1 Module constants — 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.

Why Extracting the bounds as named constants makes the intent explicit and prevents the magic numbers from being scattered across the clamping logic.
2 Module-level cache — 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.

Why Stops per-render WebGL context creation. The probe runs at most once per session regardless of how many times the component re-renders, keeping the browser's context pool well below the ~16-context eviction threshold.
3 View-state sanitizer — 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.

Why Restores the behavior from commit 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.
4 Call-site updates in 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.

Why These are the two active regressions: the per-render context leak and the missing clamps. Both are fixed at the call site with minimal diff surface.
05

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:

pan 6x aggressive wheel zoom-out pan 8x zoom-in pan
Unfixed run Map blanks; never recovers

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 (0 – 58,453)
0 Start: 55,156 58,453
0 After blank: 17,391 58,453
Fixed run Map stays live throughout

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.

Screenshot bytes (0 – 58,453)
0 Min: 26,602 Max: 58,453
Build clean. npm run build (tsc -b + Vite) exits 0 with no errors.    708 unit tests pass, 0 fail.
06

Verdict

Approved

Single-component fix, root cause proven by before/after reproduction. Risk: low. No API, schema or dependency changes.

Single component Root cause proven Risk: low No API changes No schema changes No dependency changes