perf(monitor): throttle selected address lookups
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cleanup, render, renderHook, waitFor } from '@testing-library/react';
|
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
@@ -7,6 +7,7 @@ import { useMonitorData, useMonitorVehicleCard } from './useMonitorData';
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,3 +127,47 @@ test('collapsed vehicle card does not issue or retain detail-only queries', asyn
|
|||||||
view.unmount();
|
view.unmount();
|
||||||
client.clear();
|
client.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('throttles moving vehicle address lookups, publishes the latest point, and switches vehicles immediately', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(api, 'vehicleDetail').mockResolvedValue({} as never);
|
||||||
|
vi.spyOn(api, 'alertEventsV2').mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||||
|
const address = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '测试地址' });
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
function ActiveCard({ vehicle }: { vehicle: { vin: string; longitude: number; latitude: number } }) {
|
||||||
|
useMonitorVehicleCard(vehicle.vin, vehicle as never, true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const view = render(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-001', longitude: 113.260041, latitude: 23.130041 }} /></QueryClientProvider>);
|
||||||
|
|
||||||
|
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(1);
|
||||||
|
expect(address.mock.calls[0]?.[0]?.get('longitude')).toBe('113.2600');
|
||||||
|
expect(address.mock.calls[0]?.[0]?.get('latitude')).toBe('23.1300');
|
||||||
|
|
||||||
|
view.rerender(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-001', longitude: 113.260049, latitude: 23.130049 }} /></QueryClientProvider>);
|
||||||
|
await act(async () => { await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
view.rerender(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-001', longitude: 113.270041, latitude: 23.140041 }} /></QueryClientProvider>);
|
||||||
|
await act(async () => { vi.advanceTimersByTime(30_000); await Promise.resolve(); });
|
||||||
|
view.rerender(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-001', longitude: 113.280041, latitude: 23.150041 }} /></QueryClientProvider>);
|
||||||
|
await act(async () => { vi.advanceTimersByTime(29_999); await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => { vi.advanceTimersByTime(1); await Promise.resolve(); await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(2);
|
||||||
|
expect(address.mock.calls[1]?.[0]?.get('longitude')).toBe('113.2800');
|
||||||
|
expect(address.mock.calls[1]?.[0]?.get('latitude')).toBe('23.1500');
|
||||||
|
|
||||||
|
view.rerender(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-002', longitude: 114.310041, latitude: 22.910041 }} /></QueryClientProvider>);
|
||||||
|
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(3);
|
||||||
|
expect(address.mock.calls[2]?.[0]?.get('longitude')).toBe('114.3100');
|
||||||
|
|
||||||
|
view.rerender(<QueryClientProvider client={client}><ActiveCard vehicle={{ vin: 'VIN-002', longitude: 114.320041, latitude: 22.920041 }} /></QueryClientProvider>);
|
||||||
|
view.unmount();
|
||||||
|
await act(async () => { vi.advanceTimersByTime(60_000); await Promise.resolve(); });
|
||||||
|
expect(address).toHaveBeenCalledTimes(3);
|
||||||
|
client.clear();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types';
|
import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types';
|
||||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||||
@@ -24,6 +25,67 @@ export const MONITOR_REFRESH = {
|
|||||||
export const MONITOR_CACHE = { mapGcTime: 0 } as const;
|
export const MONITOR_CACHE = { mapGcTime: 0 } as const;
|
||||||
|
|
||||||
export const MAX_MONITOR_SEARCH_TERMS = 100;
|
export const MAX_MONITOR_SEARCH_TERMS = 100;
|
||||||
|
export const MONITOR_ADDRESS_REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
type MonitorAddressPoint = {
|
||||||
|
vin: string;
|
||||||
|
key: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function monitorAddressPoint(vin: string, longitude?: number, latitude?: number): MonitorAddressPoint | undefined {
|
||||||
|
if (!vin || !Number.isFinite(longitude) || !Number.isFinite(latitude)
|
||||||
|
|| Math.abs(longitude ?? 0) > 180 || Math.abs(latitude ?? 0) > 90
|
||||||
|
|| (longitude === 0 && latitude === 0)) return undefined;
|
||||||
|
const roundedLongitude = Number(longitude!.toFixed(4));
|
||||||
|
const roundedLatitude = Number(latitude!.toFixed(4));
|
||||||
|
return {
|
||||||
|
vin,
|
||||||
|
key: `${roundedLongitude.toFixed(4)},${roundedLatitude.toFixed(4)}`,
|
||||||
|
longitude: roundedLongitude,
|
||||||
|
latitude: roundedLatitude
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMonitorAddressPoint(vin: string, longitude?: number, latitude?: number) {
|
||||||
|
const next = monitorAddressPoint(vin, longitude, latitude);
|
||||||
|
const [published, setPublished] = useState<MonitorAddressPoint>();
|
||||||
|
const pendingRef = useRef<MonitorAddressPoint>();
|
||||||
|
const lastPublishedAtRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!next) {
|
||||||
|
pendingRef.current = undefined;
|
||||||
|
lastPublishedAtRef.current = 0;
|
||||||
|
setPublished((current) => current === undefined ? current : undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!published || published.vin !== next.vin) {
|
||||||
|
pendingRef.current = undefined;
|
||||||
|
lastPublishedAtRef.current = Date.now();
|
||||||
|
setPublished(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (published.key === next.key) {
|
||||||
|
pendingRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRef.current = next;
|
||||||
|
const elapsed = Date.now() - lastPublishedAtRef.current;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const pending = pendingRef.current;
|
||||||
|
if (!pending) return;
|
||||||
|
pendingRef.current = undefined;
|
||||||
|
lastPublishedAtRef.current = Date.now();
|
||||||
|
setPublished(pending);
|
||||||
|
}, Math.max(0, MONITOR_ADDRESS_REFRESH_MS - elapsed));
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [next?.key, next?.vin, published?.key, published?.vin]);
|
||||||
|
|
||||||
|
return published;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMonitorSearchTerms(value: string) {
|
export function parseMonitorSearchTerms(value: string) {
|
||||||
const terms: string[] = [];
|
const terms: string[] = [];
|
||||||
@@ -119,8 +181,7 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
|
|||||||
const enabled = Boolean(vin);
|
const enabled = Boolean(vin);
|
||||||
const longitude = vehicle?.longitude;
|
const longitude = vehicle?.longitude;
|
||||||
const latitude = vehicle?.latitude;
|
const latitude = vehicle?.latitude;
|
||||||
const hasCoordinate = Number.isFinite(longitude) && Number.isFinite(latitude)
|
const addressPoint = useMonitorAddressPoint(vin, longitude, latitude);
|
||||||
&& Math.abs(longitude ?? 0) <= 180 && Math.abs(latitude ?? 0) <= 90;
|
|
||||||
|
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
queryKey: ['monitor', 'vehicle-card', 'detail', vin],
|
queryKey: ['monitor', 'vehicle-card', 'detail', vin],
|
||||||
@@ -138,12 +199,12 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
|
|||||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||||
});
|
});
|
||||||
const address = useQuery({
|
const address = useQuery({
|
||||||
queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude],
|
queryKey: ['monitor', 'vehicle-card', 'address', addressPoint?.vin, addressPoint?.key],
|
||||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
|
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
|
||||||
longitude: longitude!.toFixed(6),
|
longitude: addressPoint!.longitude.toFixed(4),
|
||||||
latitude: latitude!.toFixed(6)
|
latitude: addressPoint!.latitude.toFixed(4)
|
||||||
}), signal),
|
}), signal),
|
||||||
enabled: enabled && hasCoordinate,
|
enabled: enabled && addressPoint?.vin === vin,
|
||||||
staleTime: 60 * 60_000,
|
staleTime: 60 * 60_000,
|
||||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
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: throttled selected-vehicle address lookup
|
||||||
|
|
||||||
|
The selected vehicle position refreshes every ten seconds so its map marker, ripple and tracking camera remain current. The detail card previously used every six-decimal coordinate as part of the reverse-geocode query key, so a moving vehicle could create a new remote AMap request on every realtime response: up to 360 calls per hour for one selected vehicle in one operator tab. AMap documents reverse geocoding as an HTTP/HTTPS remote service that converts an input coordinate into a structured address, while TanStack Query caches independently by query key: <https://lbs.amap.com/api/webservice/guide/api/georegeo>, <https://tanstack.com/query/latest/docs/framework/react/guides/query-keys>.
|
||||||
|
|
||||||
|
Map position remains on the ten-second realtime cadence, but the textual address now has a separate one-minute publication window. Coordinates are rounded to four decimals before becoming query keys so stationary GPS jitter shares one address result. Continuous movement publishes the latest pending point at the fixed minute boundary rather than postponing forever; selecting another VIN publishes immediately, and unmounting the detail card cancels its pending timer. This bounds a continuously moving selection to at most 60 reverse-geocode calls per hour per tab, an 83.3% reduction before jitter coalescing.
|
||||||
|
|
||||||
|
Lifecycle coverage proves the first lookup is immediate, same-cell jitter causes no request, movement cannot request before the boundary, the latest point wins at the boundary, switching VIN is immediate, and unmount prevents a delayed request.
|
||||||
|
|
||||||
## 2026-07-16: interaction-scoped QR dependency
|
## 2026-07-16: interaction-scoped QR dependency
|
||||||
|
|
||||||
The monitor route statically imported the QR encoder even though it is used only after an operator opens the optional mobile-entry dialog. Every monitoring session therefore downloaded, parsed and retained the encoder. The production `MonitorPage` chunk was 50.26 kB raw / 17.97 kB gzip, and QR generation failures also left the dialog on an endless spinner because the Promise had no rejection state.
|
The monitor route statically imported the QR encoder even though it is used only after an operator opens the optional mobile-entry dialog. Every monitoring session therefore downloaded, parsed and retained the encoder. The production `MonitorPage` chunk was 50.26 kB raw / 17.97 kB gzip, and QR generation failures also left the dialog on an endless spinner because the Promise had no rejection state.
|
||||||
|
|||||||
Reference in New Issue
Block a user