From 9dfbe1e65368d15f14a334dde24dda5a755b6071 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 13 Jul 2026 20:01:15 +0200 Subject: [PATCH] fix(web): eliminate Leaflet _leaflet_pos crash on geo-map unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Geographic Performance map threw "Cannot read properties of undefined (reading '_leaflet_pos')" during client-side navigation. An animated fitBounds/setView schedules a deferred rAF/transitionend callback that reads the map pane's _leaflet_pos; if the page unmounts before it fires, the pane is torn down and the callback crashes. - FitBounds: pass { animate: false } so the camera move applies synchronously and leaves no dangling animation callback. - GeoMap: memoize mappableLocations so FitBounds' effect (and the camera move) only re-runs on real data changes, not on every parent re-render — closing the amplified race window. MapCleanup's map.remove() stays as defense-in-depth for instance teardown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/geographic-performance/GeoMap.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx index 96c8a5bd..257a0e73 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/GeoMap.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { MapContainer, TileLayer, CircleMarker, Tooltip, useMap } from "react-leaflet"; import "leaflet/dist/leaflet.css"; @@ -81,10 +81,15 @@ function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) useEffect(() => { if (locations.length === 0) return; const bounds = locations.map((l) => l.coords) as [number, number][]; + // animate: false applies the camera move synchronously. An animated move schedules a + // deferred requestAnimationFrame/transitionend callback that reads the map pane's + // _leaflet_pos; if the component unmounts (client-side navigation) before it fires, the + // pane is already torn down and the callback throws "Cannot read properties of undefined + // (reading '_leaflet_pos')". A synchronous move leaves no such dangling callback. if (bounds.length === 1) { - map.setView(bounds[0], 6); + map.setView(bounds[0], 6, { animate: false }); } else { - map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 }); + map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10, animate: false }); } }, [locations, map]); @@ -92,12 +97,19 @@ function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) } export default function GeoMap({ locations, globalAvgDuration, selectedLocation, onLocationSelect }: GeoMapProps) { - const mappableLocations = locations - .map((loc) => { - const coords = parseCoords(loc.loc); - return coords ? { ...loc, coords } : null; - }) - .filter((l): l is NonNullable => l !== null); + // Memoize so the derived array keeps a stable identity across re-renders. FitBounds' effect + // depends on this list; a fresh array every render would re-run it (and re-fire the camera + // move) on every parent re-render, not just when the location data actually changes. + const mappableLocations = useMemo( + () => + locations + .map((loc) => { + const coords = parseCoords(loc.loc); + return coords ? { ...loc, coords } : null; + }) + .filter((l): l is NonNullable => l !== null), + [locations] + ); if (mappableLocations.length === 0) { return (