fix(web): bound lazy route recovery

This commit is contained in:
lingniu
2026-07-16 05:29:08 +08:00
parent b21f9a60cd
commit d669531128
5 changed files with 97 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ 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(Object.assign(new Error('Route chunk load timed out after 12000ms: history'), { name: 'RouteChunkLoadTimeoutError' }))).toBe(true);
expect(isRouteChunkError(new Error('Cannot read properties of undefined'))).toBe(false);
});
@@ -36,6 +37,20 @@ describe('route recovery boundary', () => {
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
});
it('explains a bounded route timeout instead of leaving the loading skeleton forever', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() }));
const TimeoutPage = () => {
throw Object.assign(new Error('Route chunk load timed out after 10000ms: history'), { name: 'RouteChunkLoadTimeoutError' });
};
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={TimeoutPage} label="历史数据" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载超时');
expect(screen.getByText(/网络暂时不稳定/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /刷新当前页面/ })).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>);

View File

@@ -8,7 +8,11 @@ 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);
return /ChunkLoadError|RouteChunkLoadTimeoutError|Route chunk load timed out|Loading chunk .+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
}
function isRouteChunkTimeout(error: unknown) {
return error instanceof Error && (error.name === 'RouteChunkLoadTimeoutError' || /Route chunk load timed out/i.test(error.message));
}
function canAutoReload(routeKey: string) {
@@ -52,12 +56,13 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
const { error } = this.state;
if (!error) return this.props.children;
const chunkError = isRouteChunkError(error);
const chunkTimeout = isRouteChunkTimeout(error);
return <section className="v2-route-error" role="alert">
<span><IconAlertTriangle /></span>
<div>
<small>{chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
<h2>{chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
<p>{chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
<small>{chunkTimeout ? '页面资源加载超时' : chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
<h2>{chunkTimeout ? '当前模块没有在预期时间内加载完成' : chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
<p>{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
<code>{error.message || error.name}</code>
<footer>
<button type="button" onClick={() => window.location.reload()}><IconRefresh /></button>

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import {
loadRouteModule,
routePreloadBudget,
routePreloadCandidates,
routeSectionForPath,
@@ -20,6 +21,25 @@ describe('route module preloading', () => {
expect(routeSectionForPath('/not-a-platform-route')).toBeUndefined();
});
it('retries one transient route import failure before exposing an error boundary', async () => {
const loader = vi.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch dynamically imported module'))
.mockResolvedValue({ default: () => null });
await expect(loadRouteModule(loader, 'history', { timeoutMs: 100, retryDelayMs: 0 })).resolves.toBeDefined();
expect(loader).toHaveBeenCalledTimes(2);
});
it('turns a permanently pending route import into a bounded chunk error', async () => {
const loader = vi.fn(() => new Promise<never>(() => undefined));
await expect(loadRouteModule(loader, 'statistics', { timeoutMs: 5 })).rejects.toMatchObject({
name: 'RouteChunkLoadTimeoutError',
message: expect.stringContaining('statistics')
});
expect(loader).toHaveBeenCalledTimes(1);
});
it('skips background route preloads on save-data and 2G connections', () => {
const visible = { visibilityState: 'visible' as const, deviceMemory: 8 };
expect(shouldPreloadRoutes({ saveData: true, effectiveType: '4g' }, visible)).toBe(false);

View File

@@ -5,6 +5,10 @@ type RouteModule = { default: ComponentType };
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
type RoutePreloadScheduler = (callback: () => void) => () => void;
type RouteLoadOptions = { timeoutMs?: number; retryDelayMs?: number; retries?: number };
const ROUTE_LOAD_TIMEOUT_MS = 10_000;
const ROUTE_RETRY_DELAY_MS = 180;
const importers: Record<RouteSection, () => Promise<RouteModule>> = {
monitor: () => import('../pages/MonitorPage'),
@@ -29,10 +33,50 @@ const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
operations: ['access', 'monitor']
};
function routeLoadTimeout<T>(promise: Promise<T>, section: string, timeoutMs: number) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
const error = new Error(`Route chunk load timed out after ${timeoutMs}ms: ${section}`);
error.name = 'RouteChunkLoadTimeoutError';
reject(error);
}, timeoutMs);
promise.then(
(value) => { window.clearTimeout(timer); resolve(value); },
(error) => { window.clearTimeout(timer); reject(error); }
);
});
}
function retryDelay(delayMs: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, delayMs));
}
function isRouteLoadTimeout(error: unknown) {
return error instanceof Error && error.name === 'RouteChunkLoadTimeoutError';
}
export async function loadRouteModule<T>(
loader: () => Promise<T>,
section: string,
{ timeoutMs = ROUTE_LOAD_TIMEOUT_MS, retryDelayMs = ROUTE_RETRY_DELAY_MS, retries = 1 }: RouteLoadOptions = {}
) {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await routeLoadTimeout(Promise.resolve().then(loader), section, timeoutMs);
} catch (error) {
lastError = error;
if (attempt >= retries || isRouteLoadTimeout(error)) break;
await retryDelay(retryDelayMs);
}
}
throw lastError;
}
function loadRoute(section: RouteSection) {
const pending = pendingModules.get(section);
if (pending) return pending;
const next = importers[section]();
const next = loadRouteModule(importers[section], section);
pendingModules.set(section, next);
void next.catch(() => pendingModules.delete(section));
return next;

View File

@@ -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.
## 2026-07-16: bounded route-module recovery
The route shell already displayed a meaningful Suspense skeleton and recovered rejected lazy chunks, but a dynamic import that remained pending forever never reached the error boundary. Operators could therefore stay on a loading workspace indefinitely during a stalled resource request. React documents that `lazy` caches its Promise and that Suspense continues showing the nearest fallback until that Promise settles; a rejected Promise is then delivered to the nearest error boundary: <https://react.dev/reference/react/lazy>, <https://react.dev/reference/react/Suspense>.
Every route import now has a ten-second settlement boundary. An ordinary transient rejection is retried once after 180 ms before it reaches React, while a true timeout is not repeated and immediately enters the existing controlled reload path. This preserves fast recovery from a single network hiccup without turning one hung request into two consecutive waits. If the reload cooldown prevents another automatic reload, the visible recovery page now identifies a network/resource timeout instead of incorrectly describing every chunk failure as a version update.
Regression tests prove that a first failed import resolves on its single retry, a permanently pending import rejects once with `RouteChunkLoadTimeoutError`, the error classifier accepts that failure, and the route boundary renders an explicit timeout explanation plus refresh action. Authenticated production navigation across history, mileage and access was also checked from a retained older tab to exercise compatibility assets; all three routes remained non-blank and no route recovery error appeared.
## 2026-07-16: render-scoped fleet refresh
The incremental map update already avoided AMap mutations when a 15-second response changed only its `asOf` timestamp, but the effects still depended on the complete response object. Each metadata-only refresh therefore traversed all visible points to rebuild signatures, cluster counts and label diffs before discovering that there was nothing to draw. The cost was hidden from mutation-count tests and could still create a periodic main-thread spike in a dense viewport.