perf(web): bound speculative route warming

This commit is contained in:
lingniu
2026-07-16 03:39:47 +08:00
parent cad39d6326
commit 2f26fd25b9
5 changed files with 135 additions and 37 deletions

View File

@@ -14,7 +14,7 @@ import {
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { usePlatformSession } from '../auth/AuthGate';
import { preloadRoute, scheduleIdleRoutePreloads } from '../routing/routeModules';
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
const navigation = [
{ to: '/monitor', label: '全局监控', icon: IconHome },
@@ -65,7 +65,7 @@ export function AppShell() {
function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
const warmRoute = (path: string) => { void preloadRoute(path); };
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
return (
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>

View File

@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useEffect, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { MemoryRouter, useSearchParams } from 'react-router-dom';
import { isRouteChunkError, RoutePage } from './RouteBoundary';
import { ROUTER_FUTURE } from './routerConfig';
@@ -36,6 +36,14 @@ describe('route recovery boundary', () => {
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
});
it('keeps a meaningful content skeleton visible while a route chunk is pending', () => {
const PendingPage = lazy(() => new Promise<never>(() => undefined));
const { container } = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><RoutePage page={PendingPage} label="接入管理" /></MemoryRouter>);
expect(screen.getByRole('status')).toHaveTextContent('正在加载接入管理');
expect(screen.getByText('导航与筛选状态已保留')).toBeInTheDocument();
expect(container.querySelectorAll('.v2-page-skeleton > i')).toHaveLength(4);
});
it('keeps page state mounted when only URL search parameters change', () => {
let mounts = 0;
function FilterablePage() {

View File

@@ -1,5 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { routeSectionForPath, scheduleIdleRoutePreloads, shouldPreloadRoutes } from './routeModules';
import {
routePreloadBudget,
routePreloadCandidates,
routeSectionForPath,
scheduleIdleRoutePreloads,
shouldPreloadRouteOnIntent,
shouldPreloadRoutes
} from './routeModules';
describe('route module preloading', () => {
async function flushPreloadQueue() {
@@ -14,14 +21,28 @@ describe('route module preloading', () => {
});
it('skips background route preloads on save-data and 2G connections', () => {
expect(shouldPreloadRoutes({ saveData: true, effectiveType: '4g' })).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: 'slow-2g' })).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: '2g' })).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: '3g' })).toBe(true);
expect(shouldPreloadRoutes({ effectiveType: '4g' })).toBe(true);
const visible = { visibilityState: 'visible' as const, deviceMemory: 8 };
expect(shouldPreloadRoutes({ saveData: true, effectiveType: '4g' }, visible)).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: 'slow-2g' }, visible)).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: '2g' }, visible)).toBe(false);
expect(shouldPreloadRoutes({ effectiveType: '3g' }, visible)).toBe(true);
expect(shouldPreloadRoutes({ effectiveType: '4g' }, visible)).toBe(true);
expect(shouldPreloadRoutes({ effectiveType: '4g' }, { ...visible, visibilityState: 'hidden' })).toBe(false);
expect(shouldPreloadRouteOnIntent({ saveData: true, effectiveType: '4g' })).toBe(false);
expect(shouldPreloadRouteOnIntent({ effectiveType: '2g' })).toBe(false);
});
it('warms every inactive primary route in bounded idle batches and stops after cleanup', async () => {
it('limits speculative modules by memory/network budget and route likelihood', () => {
const visible = { visibilityState: 'visible' as const };
expect(routePreloadBudget({ effectiveType: '4g' }, { ...visible, deviceMemory: 8 })).toBe(2);
expect(routePreloadBudget({ effectiveType: '3g' }, { ...visible, deviceMemory: 8 })).toBe(1);
expect(routePreloadBudget({ effectiveType: '4g' }, { ...visible, deviceMemory: 4 })).toBe(1);
expect(routePreloadBudget({ effectiveType: '4g' }, { ...visible, deviceMemory: 2 })).toBe(0);
expect(routePreloadCandidates('/monitor')).toEqual(['vehicles', 'tracks']);
expect(routePreloadCandidates('/statistics?dateFrom=2026-07-01')).toEqual(['history', 'vehicles']);
});
it('warms only likely routes in sequential idle slots and stops after cleanup', async () => {
const callbacks: Array<() => void> = [];
const cancelled: number[] = [];
const schedule = vi.fn((callback: () => void) => {
@@ -32,29 +53,51 @@ describe('route module preloading', () => {
const cleanup = scheduleIdleRoutePreloads({
activePathname: '/monitor',
connection: { effectiveType: '4g' },
runtime: { visibilityState: 'visible', deviceMemory: 8 },
preload,
schedule
schedule,
isVisible: () => true
});
expect(schedule).toHaveBeenCalledTimes(1);
callbacks[0]();
await flushPreloadQueue();
expect(preload.mock.calls.map(([path]) => path)).toEqual(['/vehicles', '/tracks']);
expect(preload.mock.calls.map(([path]) => path)).toEqual(['/vehicles']);
expect(schedule).toHaveBeenCalledTimes(2);
callbacks[1]();
await flushPreloadQueue();
expect(preload.mock.calls.map(([path]) => path)).toEqual(['/vehicles', '/tracks', '/history', '/statistics']);
callbacks[2]();
await flushPreloadQueue();
callbacks[3]();
await flushPreloadQueue();
expect(preload.mock.calls.map(([path]) => path)).toEqual([
'/vehicles', '/tracks', '/history', '/statistics', '/alerts', '/access', '/operations'
]);
expect(preload.mock.calls.map(([path]) => path)).toEqual(['/vehicles', '/tracks']);
expect(schedule).toHaveBeenCalledTimes(2);
cleanup();
expect(cancelled).toContain(4);
expect(cancelled).toContain(1);
});
it('does not start or continue speculative work in a background tab', async () => {
const schedule = vi.fn((_callback: () => void) => () => undefined);
scheduleIdleRoutePreloads({
activePathname: '/monitor',
connection: { effectiveType: '4g' },
runtime: { visibilityState: 'hidden', deviceMemory: 8 },
schedule
});
expect(schedule).not.toHaveBeenCalled();
const callbacks: Array<() => void> = [];
const preload = vi.fn(async () => undefined);
let visible = true;
scheduleIdleRoutePreloads({
activePathname: '/monitor',
connection: { effectiveType: '4g' },
runtime: { visibilityState: 'visible', deviceMemory: 8 },
preload,
schedule: (callback) => { callbacks.push(callback); return () => undefined; },
isVisible: () => visible
});
visible = false;
callbacks[0]();
await flushPreloadQueue();
expect(preload).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
type RouteModule = { default: ComponentType };
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
type RoutePreloadScheduler = (callback: () => void) => () => void;
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
@@ -17,10 +18,16 @@ const importers: Record<RouteSection, () => Promise<RouteModule>> = {
};
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
const backgroundPreloadOrder: RouteSection[] = [
'monitor', 'vehicles', 'tracks', 'history', 'statistics', 'alerts', 'access', 'operations'
];
const backgroundPreloadBatchSize = 2;
const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
monitor: ['vehicles', 'tracks'],
vehicles: ['monitor', 'tracks'],
tracks: ['monitor', 'history'],
history: ['statistics', 'tracks'],
statistics: ['history', 'vehicles'],
access: ['monitor', 'operations'],
alerts: ['monitor', 'access'],
operations: ['access', 'monitor']
};
function loadRoute(section: RouteSection) {
const pending = pendingModules.get(section);
@@ -52,7 +59,35 @@ function browserConnection(): RoutePreloadConnection | undefined {
return (navigator as Navigator & { connection?: RoutePreloadConnection }).connection;
}
export function shouldPreloadRoutes(connection = browserConnection()) {
function browserRuntime(): RoutePreloadRuntime {
return {
visibilityState: document.visibilityState,
deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory
};
}
export function routePreloadBudget(connection = browserConnection(), runtime = browserRuntime()) {
if (runtime.visibilityState === 'hidden' || connection?.saveData) return 0;
const effectiveType = String(connection?.effectiveType ?? '').toLowerCase();
if (effectiveType.includes('2g') || (runtime.deviceMemory != null && runtime.deviceMemory <= 2)) return 0;
if (effectiveType.includes('3g') || (runtime.deviceMemory != null && runtime.deviceMemory <= 4)) return 1;
return 2;
}
export function shouldPreloadRoutes(connection = browserConnection(), runtime = browserRuntime()) {
return routePreloadBudget(connection, runtime) > 0;
}
export function routePreloadCandidates(pathname: string) {
const activeSection = routeSectionForPath(pathname);
return activeSection ? likelyNextRoutes[activeSection] : [];
}
function shouldContinuePreloading() {
return document.visibilityState !== 'hidden';
}
export function shouldPreloadRouteOnIntent(connection = browserConnection()) {
if (connection?.saveData) return false;
return !String(connection?.effectiveType ?? '').toLowerCase().includes('2g');
}
@@ -69,27 +104,31 @@ function scheduleWhenIdle(callback: () => void) {
export function scheduleIdleRoutePreloads({
activePathname,
connection = browserConnection(),
runtime = browserRuntime(),
preload = preloadRoute,
schedule = scheduleWhenIdle
schedule = scheduleWhenIdle,
isVisible = shouldContinuePreloading
}: {
activePathname: string;
connection?: RoutePreloadConnection;
runtime?: RoutePreloadRuntime;
preload?: (pathname: string) => Promise<unknown> | undefined;
schedule?: RoutePreloadScheduler;
isVisible?: () => boolean;
}) {
if (!shouldPreloadRoutes(connection)) return () => undefined;
const activeSection = routeSectionForPath(activePathname);
const queue = backgroundPreloadOrder.filter((section) => section !== activeSection);
const budget = routePreloadBudget(connection, runtime);
if (!budget) return () => undefined;
const queue = routePreloadCandidates(activePathname).slice(0, budget);
let cancelled = false;
let cancelScheduled: () => void = () => undefined;
const warmNext = () => {
if (cancelled) return;
const batch = queue.splice(0, backgroundPreloadBatchSize);
if (!batch.length) return;
void Promise.allSettled(batch.map((section) => Promise.resolve().then(() => preload(`/${section}`))))
if (cancelled || !isVisible()) return;
const section = queue.shift();
if (!section) return;
void Promise.resolve().then(() => preload(`/${section}`))
.finally(() => {
if (!cancelled) cancelScheduled = schedule(warmNext);
if (!cancelled && queue.length && isVisible()) cancelScheduled = schedule(warmNext);
});
};

View File

@@ -8,6 +8,14 @@ The 15-second monitor refresh previously treated every new response object as a
FleetMap now fingerprints only fields that affect map rendering. An unchanged refresh performs no AMap data/style/label mutation. A moving vehicle still updates MassMarks once, but the LabelsLayer removes and replaces only that vehicle's changed marker; full label-layer replacement is reserved for crossing the normal/dense zoom boundary. The selected ripple marker also skips redundant position writes. This uses the official AMap JS API 2.0 [`LabelsLayer.remove`](https://lbs.amap.com/api/javascript-api-v2/guide/amap-massmarker/label-marker) and `LabelMarker` update model instead of reconstructing all overlays. Unit tests assert zero map mutations for an `asOf`-only refresh and exactly one label replacement for one moved vehicle.
## 2026-07-16: bounded predictive route warming
The route shell split every primary page into a lazy chunk, but its idle queue then imported every inactive page during the first session. Successful ES-module imports cannot be unloaded, so this gradually made all route code resident even when an operator stayed on the monitor. It also let speculative parsing overlap live map work and defeated much of the memory benefit of route splitting.
Background warming is now predictive and bounded. Each active page has two likely next destinations; a fast visible session warms at most those two, one idle task at a time. A 3G or 4 GB device warms one, while `saveData`, 2G, hidden tabs and devices reporting 2 GB or less warm none. Pointer, focus and pointer-down intent still warm the exact destination immediately on capable connections, so deliberate navigation keeps the short transition while unused pages stay lazy. This follows web.dev's guidance to prefetch only high-confidence future resources, avoid slow or data-saving connections and prefer likely links over every link: <https://web.dev/articles/link-prefetch>, <https://web.dev/learn/performance/prefetching-prerendering-precaching>.
Unit tests protect route predictions, memory/network budgets, sequential scheduling, background-tab cancellation and cleanup. The production build remains guarded by a visible Suspense skeleton and the route chunk recovery boundary, so a page transition cannot become an empty content area while a non-warmed chunk loads.
## 2026-07-16: high-cardinality address cache lifecycle
The monitor list previously retained every manually resolved coordinate for 24 hours, and track replay inherited the global five-minute cache for every paused point. Long-running dispatch sessions could therefore accumulate address payloads across pages and arbitrary playback coordinates even after their components disappeared.
@@ -86,7 +94,7 @@ The default `test` command is now the production V2 suite. The old `App.tsx` int
React documents that `lazy` caches the import promise and propagates a rejected module load to the nearest Error Boundary; this is why a route-level boundary is required rather than a loading fallback alone: <https://react.dev/reference/react/lazy>. React's Suspense documentation also distinguishes a loading fallback from error handling: <https://react.dev/reference/react/Suspense>.
The first continuity pass only warmed Monitor, Tracks, History and Statistics in the background. A production cold-cache audit with 450 ms simulated latency still measured roughly 1.66 seconds for `/monitor` to `/access`, leaving Vehicles, Alerts, Access and Operations dependent on click-time loading. The idle queue now covers all eight route sections in bounded batches of two. It still skips `saveData`, `slow-2g` and `2g` connections, while pointer/focus/down intent preloading remains the fastest path for an immediately selected destination. Unit coverage proves every inactive route is queued and that cleanup cancels the next idle batch.
The first continuity pass only warmed Monitor, Tracks, History and Statistics in the background. A later pass expanded that queue to every route to reduce a 450 ms simulated cold transition, but doing so made all successful ES-module imports resident for every session. The current policy keeps pointer/focus/down intent warming for the exact destination and limits idle warming to one or two likely routes according to visibility, memory and connection constraints. This preserves the no-white-screen loading boundary without paying the memory cost of importing every page speculatively.
A production route-memory loop did not prove a retained application leak: after natural collection, JS heap returned to roughly its initial 31 MB range and old map documents/frames were released. It did expose an ineffective rendering hint: `content-visibility: auto` was attached to the always-visible vehicle scroll viewport instead of the 200 independently off-screen rows. The containment boundary now lives on every desktop rail row and mobile list card, paired with `contain-intrinsic-size` so skipped content keeps stable scroll geometry. This follows the CSS Containment specification's long-scroll-list use case while avoiding the documented scrollbar jump caused by missing intrinsic size: <https://www.w3.org/TR/css-contain-2/#content-visibility>.