fix(web): harden route transitions and monitor memory

This commit is contained in:
lingniu
2026-07-15 23:54:21 +08:00
parent 3fabcf181a
commit c29ccdf2da
17 changed files with 332 additions and 36 deletions

View File

@@ -0,0 +1,36 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { isRouteChunkError, RoutePage } from './RouteBoundary';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
describe('route recovery boundary', () => {
it('recognizes deployment-related lazy chunk failures', () => {
expect(isRouteChunkError(new TypeError('Failed to fetch dynamically imported module: /assets/Monitor-old.js'))).toBe(true);
expect(isRouteChunkError(new Error('Loading chunk 18 failed'))).toBe(true);
expect(isRouteChunkError(new Error('Cannot read properties of undefined'))).toBe(false);
});
it('isolates a page render failure and keeps a visible recovery action', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const BrokenPage = () => { throw new Error('render exploded'); };
render(<MemoryRouter initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
});
it('shows a deployment recovery page when an automatic chunk reload already ran', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/statistics', at: Date.now() }));
const StaleChunkPage = () => { throw new TypeError('Failed to fetch dynamically imported module: /assets/Statistics-old.js'); };
render(<MemoryRouter initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新');
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
import { IconAlertTriangle, IconHome, IconRefresh } from '@douyinfe/semi-icons';
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense } from 'react';
import { useLocation } from 'react-router-dom';
import { PageLoading } from '../shared/AsyncState';
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
const CHUNK_RELOAD_COOLDOWN_MS = 2 * 60_000;
export function isRouteChunkError(error: unknown) {
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
return /ChunkLoadError|Loading chunk .+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
}
function canAutoReload(routeKey: string) {
try {
const marker = JSON.parse(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) ?? 'null') as { routeKey?: string; at?: number } | null;
return !marker || marker.routeKey !== routeKey || Date.now() - (marker.at ?? 0) > CHUNK_RELOAD_COOLDOWN_MS;
} catch {
return true;
}
}
function rememberAutoReload(routeKey: string) {
try {
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, JSON.stringify({ routeKey, at: Date.now() }));
} catch {
// Recovery must still work when storage is unavailable.
}
}
class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey: string }, { error?: Error }> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, _info: ErrorInfo) {
if (isRouteChunkError(error) && canAutoReload(this.props.routeKey)) {
rememberAutoReload(this.props.routeKey);
window.location.reload();
}
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
const chunkError = isRouteChunkError(error);
return <section className="v2-route-error" role="alert">
<span><IconAlertTriangle /></span>
<div>
<small>{chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
<h2>{chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
<p>{chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。可刷新当前页面重试。'}</p>
<code>{error.message || error.name}</code>
<footer>
<button type="button" onClick={() => window.location.reload()}><IconRefresh /></button>
<a href="/monitor"><IconHome /></a>
</footer>
</div>
</section>;
}
}
export function RoutePage({ page: Page, label }: { page: ElementType; label: string }) {
const location = useLocation();
const routeKey = `${location.pathname}${location.search}`;
return <RecoverableRouteBoundary key={routeKey} routeKey={routeKey}>
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page /></Suspense>
</RecoverableRouteBoundary>;
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { routeSectionForPath } from './routeModules';
describe('route module preloading', () => {
it('maps nested and parameterized URLs to the correct lazy module', () => {
expect(routeSectionForPath('/monitor')).toBe('monitor');
expect(routeSectionForPath('/vehicles/LTEST001?tab=telemetry')).toBe('vehicles');
expect(routeSectionForPath('/alerts/rules#editor')).toBe('alerts');
expect(routeSectionForPath('/not-a-platform-route')).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations';
type RouteModule = { default: ComponentType };
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
monitor: () => import('../pages/MonitorPage'),
vehicles: () => import('../pages/VehiclePage'),
tracks: () => import('../pages/TrackPage'),
history: () => import('../pages/HistoryPage'),
statistics: () => import('../pages/StatisticsPage'),
access: () => import('../pages/AccessPage'),
alerts: () => import('../pages/AlertsPage'),
operations: () => import('../pages/OperationsPage')
};
const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
function loadRoute(section: RouteSection) {
const pending = pendingModules.get(section);
if (pending) return pending;
const next = importers[section]();
pendingModules.set(section, next);
void next.catch(() => pendingModules.delete(section));
return next;
}
export function routeSectionForPath(pathname: string): RouteSection | undefined {
const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined;
return section && section in importers ? section : undefined;
}
export function preloadRoute(pathname: string) {
const section = routeSectionForPath(pathname);
return section ? loadRoute(section) : undefined;
}
export const RoutePages = {
Monitor: lazy(() => loadRoute('monitor')),
Vehicles: lazy(() => loadRoute('vehicles')),
Tracks: lazy(() => loadRoute('tracks')),
History: lazy(() => loadRoute('history')),
Statistics: lazy(() => loadRoute('statistics')),
Access: lazy(() => loadRoute('access')),
Alerts: lazy(() => loadRoute('alerts')),
Operations: lazy(() => loadRoute('operations'))
} as const;