perf(web): release track map listeners explicitly
This commit is contained in:
117
vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx
Normal file
117
vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx
Normal file
@@ -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(<TrackMap
|
||||||
|
points={points}
|
||||||
|
stops={stops}
|
||||||
|
activeIndex={0}
|
||||||
|
showStops
|
||||||
|
follow
|
||||||
|
onSelectIndex={() => 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);
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
|
|||||||
const mapRef = useRef<AMapMap | null>(null);
|
const mapRef = useRef<AMapMap | null>(null);
|
||||||
const amapRef = useRef<AMapLike | null>(null);
|
const amapRef = useRef<AMapLike | null>(null);
|
||||||
const overlaysRef = useRef<AMapOverlay[]>([]);
|
const overlaysRef = useRef<AMapOverlay[]>([]);
|
||||||
|
const overlayListenersRef = useRef<Array<{ overlay: AMapOverlay; handler: () => void }>>([]);
|
||||||
const currentMarkerRef = useRef<AMapOverlay | null>(null);
|
const currentMarkerRef = useRef<AMapOverlay | null>(null);
|
||||||
const passedPathRef = useRef<AMapOverlay | null>(null);
|
const passedPathRef = useRef<AMapOverlay | null>(null);
|
||||||
const pathRef = useRef<[number, number][]>([]);
|
const pathRef = useRef<[number, number][]>([]);
|
||||||
@@ -58,10 +59,12 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing);
|
if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing);
|
||||||
|
overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler));
|
||||||
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
||||||
currentMarkerRef.current?.setMap?.(null);
|
currentMarkerRef.current?.setMap?.(null);
|
||||||
mapRef.current?.destroy();
|
mapRef.current?.destroy();
|
||||||
overlaysRef.current = [];
|
overlaysRef.current = [];
|
||||||
|
overlayListenersRef.current = [];
|
||||||
currentMarkerRef.current = null;
|
currentMarkerRef.current = null;
|
||||||
passedPathRef.current = null;
|
passedPathRef.current = null;
|
||||||
pathRef.current = [];
|
pathRef.current = [];
|
||||||
@@ -74,8 +77,10 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
|
|||||||
const AMap = amapRef.current;
|
const AMap = amapRef.current;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (state !== 'ready' || !map || !valid.length || !AMap) return;
|
if (state !== 'ready' || !map || !valid.length || !AMap) return;
|
||||||
|
overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler));
|
||||||
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
||||||
currentMarkerRef.current?.setMap?.(null);
|
currentMarkerRef.current?.setMap?.(null);
|
||||||
|
overlayListenersRef.current = [];
|
||||||
|
|
||||||
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
|
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
|
||||||
pathRef.current = path;
|
pathRef.current = path;
|
||||||
@@ -89,9 +94,12 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
|
|||||||
const last = valid[valid.length - 1];
|
const last = valid[valid.length - 1];
|
||||||
const start = new AMap.Marker({ position: path[0], anchor: 'center', content: markerContent('start', '始'), zIndex: 115 });
|
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 });
|
const end = new AMap.Marker({ position: path[path.length - 1], anchor: 'center', content: markerContent('end', '终'), zIndex: 115 });
|
||||||
start.on?.('click', () => selectRef.current(first.index));
|
const startHandler = () => selectRef.current(first.index);
|
||||||
end.on?.('click', () => selectRef.current(last.index));
|
const endHandler = () => selectRef.current(last.index);
|
||||||
|
start.on?.('click', startHandler);
|
||||||
|
end.on?.('click', endHandler);
|
||||||
const overlays: AMapOverlay[] = [fullPath, passedPath, start, end];
|
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 (showStops) stops.slice(0, 80).forEach((stop, index) => {
|
||||||
if (!isValidAMapCoordinate(stop.longitude, stop.latitude)) return;
|
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',
|
position: wgs84ToGcj02(stop.longitude, stop.latitude), anchor: 'center',
|
||||||
content: markerContent('stop', String(index + 1)), zIndex: 105
|
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);
|
overlays.push(marker);
|
||||||
|
overlayListeners.push({ overlay: marker, handler: stopHandler });
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = valid.find(({ index }) => index === activeIndex) ?? first;
|
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 });
|
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
|
||||||
currentMarkerRef.current = current;
|
currentMarkerRef.current = current;
|
||||||
overlaysRef.current = overlays;
|
overlaysRef.current = overlays;
|
||||||
|
overlayListenersRef.current = overlayListeners;
|
||||||
map.add([...overlays, current]);
|
map.add([...overlays, current]);
|
||||||
map.setFitView([fullPath], false, [72, 72, 168, 72]);
|
map.setFitView([fullPath], false, [72, 72, 168, 72]);
|
||||||
}, [points, showStops, state, stops, valid]);
|
}, [points, showStops, state, stops, valid]);
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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
|
### 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.
|
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.
|
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.
|
4. Preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
|
||||||
|
|||||||
Reference in New Issue
Block a user