perf(web): bound speculative route warming
This commit is contained in:
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user