From 639564b96d5b6c71f265f667e17d15025701abcb Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 01:32:55 +0800 Subject: [PATCH] perf(web): release track map listeners explicitly --- .../apps/web/src/v2/map/TrackMap.test.tsx | 117 ++++++++++++++++++ .../apps/web/src/v2/map/TrackMap.tsx | 17 ++- .../docs/frontend-production-readiness.md | 18 ++- 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx new file mode 100644 index 00000000..ab7bf834 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx @@ -0,0 +1,117 @@ +import { cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { HistoryLocationRow, TrackStop } from '../../api/types'; +import type { AMapLike } from '../../integrations/amap'; +import { TrackMap } from './TrackMap'; + +const overlayOff = vi.fn(); +const overlaySetMap = vi.fn(); +const mapOff = vi.fn(); +const mapDestroy = vi.fn(); +const markerListeners: Array<{ overlay: TestOverlay; event: string; handler: () => void }> = []; + +class TestOverlay { + on = vi.fn((event: string, handler: () => void) => markerListeners.push({ overlay: this, event, handler })); + off = overlayOff; + setMap = overlaySetMap; + setPosition = vi.fn(); + setPath = vi.fn(); +} + +class TestMap { + add = vi.fn(); + addControl = vi.fn(); + setFitView = vi.fn(); + destroy = mapDestroy; + on = vi.fn(); + off = mapOff; + panTo = vi.fn(); +} + +class TestScale {} +class TestToolBar {} +class TestSize {} +class TestPixel {} +class TestMassMarks {} + +function amapMock(): AMapLike { + return { + Map: TestMap as unknown as AMapLike['Map'], + Marker: TestOverlay as unknown as AMapLike['Marker'], + Polyline: TestOverlay as unknown as AMapLike['Polyline'], + Scale: TestScale, + ToolBar: TestToolBar, + Size: TestSize as unknown as AMapLike['Size'], + Pixel: TestPixel as unknown as AMapLike['Pixel'], + MassMarks: TestMassMarks as unknown as AMapLike['MassMarks'] + }; +} + +const points = [{ + vin: 'LTEST000000000001', + plate: '粤A12345', + protocol: 'JT808', + deviceTime: '2026-07-16T00:00:00Z', + serverTime: '2026-07-16T00:00:01Z', + longitude: 113.26, + latitude: 23.13, + speedKmh: 0, + totalMileageKm: 100 +}, { + vin: 'LTEST000000000001', + plate: '粤A12345', + protocol: 'JT808', + deviceTime: '2026-07-16T00:10:00Z', + serverTime: '2026-07-16T00:10:01Z', + longitude: 113.28, + latitude: 23.15, + speedKmh: 30, + totalMileageKm: 105 +}] as HistoryLocationRow[]; + +const stops = [{ + index: 0, + startTime: '2026-07-16T00:00:00Z', + endTime: '2026-07-16T00:05:00Z', + durationSeconds: 300, + pointCount: 1, + longitude: 113.26, + latitude: 23.13, + sampledIndex: 0, + evidence: 'GPS 推断' +}] as TrackStop[]; + +afterEach(() => { + cleanup(); + delete window.__LINGNIU_APP_CONFIG__; + delete window.AMapLoader; + overlayOff.mockReset(); + overlaySetMap.mockReset(); + mapOff.mockReset(); + mapDestroy.mockReset(); + markerListeners.length = 0; +}); + +test('detaches every marker listener and map listener on unmount', async () => { + window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; + window.AMapLoader = { load: vi.fn(async () => amapMock()) }; + const view = render( undefined} + onFollowChange={() => undefined} + />); + + await waitFor(() => expect(markerListeners).toHaveLength(3)); + view.unmount(); + + for (const listener of markerListeners) { + expect(overlayOff).toHaveBeenCalledWith(listener.event, listener.handler); + } + expect(mapOff).toHaveBeenCalledWith('dragstart', expect.any(Function)); + expect(overlaySetMap).toHaveBeenCalled(); + expect(mapDestroy).toHaveBeenCalledTimes(1); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx index 5a763c58..89b8dce8 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx @@ -21,6 +21,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele const mapRef = useRef(null); const amapRef = useRef(null); const overlaysRef = useRef([]); + const overlayListenersRef = useRef void }>>([]); const currentMarkerRef = useRef(null); const passedPathRef = useRef(null); const pathRef = useRef<[number, number][]>([]); @@ -58,10 +59,12 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele return () => { cancelled = true; if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); + overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler)); overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); currentMarkerRef.current?.setMap?.(null); mapRef.current?.destroy(); overlaysRef.current = []; + overlayListenersRef.current = []; currentMarkerRef.current = null; passedPathRef.current = null; pathRef.current = []; @@ -74,8 +77,10 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele const AMap = amapRef.current; const map = mapRef.current; if (state !== 'ready' || !map || !valid.length || !AMap) return; + overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler)); overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); currentMarkerRef.current?.setMap?.(null); + overlayListenersRef.current = []; const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude)); pathRef.current = path; @@ -89,9 +94,12 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele const last = valid[valid.length - 1]; const start = new AMap.Marker({ position: path[0], anchor: 'center', content: markerContent('start', '始'), zIndex: 115 }); const end = new AMap.Marker({ position: path[path.length - 1], anchor: 'center', content: markerContent('end', '终'), zIndex: 115 }); - start.on?.('click', () => selectRef.current(first.index)); - end.on?.('click', () => selectRef.current(last.index)); + const startHandler = () => selectRef.current(first.index); + const endHandler = () => selectRef.current(last.index); + start.on?.('click', startHandler); + end.on?.('click', endHandler); const overlays: AMapOverlay[] = [fullPath, passedPath, start, end]; + const overlayListeners = [{ overlay: start, handler: startHandler }, { overlay: end, handler: endHandler }]; if (showStops) stops.slice(0, 80).forEach((stop, index) => { if (!isValidAMapCoordinate(stop.longitude, stop.latitude)) return; @@ -99,14 +107,17 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele position: wgs84ToGcj02(stop.longitude, stop.latitude), anchor: 'center', content: markerContent('stop', String(index + 1)), zIndex: 105 }); - marker.on?.('click', () => selectRef.current(Math.min(points.length - 1, Math.max(0, stop.sampledIndex)))); + const stopHandler = () => selectRef.current(Math.min(points.length - 1, Math.max(0, stop.sampledIndex))); + marker.on?.('click', stopHandler); overlays.push(marker); + overlayListeners.push({ overlay: marker, handler: stopHandler }); }); const active = valid.find(({ index }) => index === activeIndex) ?? first; const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 }); currentMarkerRef.current = current; overlaysRef.current = overlays; + overlayListenersRef.current = overlayListeners; map.add([...overlays, current]); map.setFitView([fullPath], false, [72, 72, 168, 72]); }, [points, showStops, state, stops, valid]); diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 1a20bceb..ee301fd1 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -2,6 +2,22 @@ This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. +## 2026-07-16: 50-cycle authenticated route soak + +The production ECS application completed 50 authenticated `monitor -> history -> statistics -> tracks -> monitor` cycles. Chrome performance and heap metrics were sampled every five cycles. Because the Browser channel does not permit `HeapProfiler.collectGarbage`, the gate uses post-warmup and 15-second-idle low-water marks instead of comparing transient allocation peaks. + +| Metric | After 10 warm cycles and natural collection | After 50 cycles and 15 seconds idle | Result | +| --- | ---: | ---: | --- | +| JS heap used | 26.52 MB | 28.53 MB | Stable; +2.01 MB | +| Event listeners | 880 | 686 | No monotonic growth | +| DOM nodes | 7,763 | 4,811 | No monotonic growth | +| Detached script states | 0 | 0 | Fully released | +| Live page DOM | — | 1 AMap iframe / 1 map container | Only the active monitor map remains | + +Transient peaks reached 165.83 MB while route effects and AMap instances overlapped, then returned to the low-water mark after natural collection. The final page remained `/monitor`, rendered live fleet statistics, and had zero console warnings or errors. This proves that the explicit query, map and listener cleanup prevents sustained growth under this route pattern; it does not replace a future allocation profile for data-heavy operator interactions. + +The audit found one remaining lifecycle asymmetry: track start, end and stop markers registered anonymous click handlers and relied on overlay removal to release them. Track overlays now retain each handler identity and call `off('click', handler)` before redraw and unmount. `TrackMap.test.tsx` asserts every marker handler, the map drag handler, overlays and the map instance are released. + ## 2026-07-16: monitor lifecycle and responsive rendering The monitor's high-volume fleet, map and selected-vehicle queries now use immediate inactive-cache collection instead of inheriting the global five-minute retention. The detail card owns its detail, alert and address queries, so collapsing or clearing the card unmounts its observers while the separate selected-vehicle stream continues to move the map. Repeated viewport and vehicle-selection tests assert that only the active payload remains and that detail-card queries reach zero after unmount. @@ -40,7 +56,7 @@ The navigation and progressive-loading direction follows mature observability sy ### Remaining audit queue -1. Capture a Chrome heap profile across at least 50 `monitor -> history -> statistics -> tracks` cycles and inspect retained AMap objects and detached DOM nodes. +1. Capture an allocation profile for data-heavy operator interactions: loaded history series, long track playback and repeated Excel exports. 2. Bound or explicitly evict other high-cardinality query families, especially arbitrary history windows and track playback ranges. 3. Retire or migrate the remaining legacy `App.tsx` integration suite after its compatibility coverage is replaced in V2. 4. Preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.