fix(web): bound lazy route recovery
This commit is contained in:
@@ -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>);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user