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);
});
};