fix(web): recover pre-render boot failures

This commit is contained in:
lingniu
2026-07-16 06:04:30 +08:00
parent 4302fc8d45
commit 97fe1704a2
9 changed files with 196 additions and 20 deletions

View File

@@ -1,8 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell';
import { AuthGate } from './auth/AuthGate';
import { RoutePage } from './routing/RouteBoundary';
import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary';
import { RoutePages } from './routing/routeModules';
import { ROUTER_FUTURE } from './routing/routerConfig';
import { QUERY_MEMORY } from './queryPolicy';
@@ -25,26 +26,37 @@ export function createPlatformQueryClient() {
}
const queryClient = createPlatformQueryClient();
export const PLATFORM_READY_EVENT = 'vehicle-platform:ready';
function PlatformReadySignal() {
useEffect(() => {
window.dispatchEvent(new Event(PLATFORM_READY_EVENT));
}, []);
return null;
}
export function AppV2() {
return (
<QueryClientProvider client={queryClient}>
<AuthGate><BrowserRouter future={ROUTER_FUTURE}>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} />
<Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} label="全局监控" />} />
<Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} label="车辆查询" />} />
<Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} label="轨迹回放" />} />
<Route path="/history" element={<RoutePage page={RoutePages.History} label="历史数据" />} />
<Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} label="里程查询" />} />
<Route path="/access" element={<RoutePage page={RoutePages.Access} label="接入管理" />} />
<Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} label="告警中心" />} />
<Route path="/operations" element={<RoutePage page={RoutePages.Operations} label="运维质量" />} />
<Route path="*" element={<Navigate to="/monitor" replace />} />
</Route>
</Routes>
</BrowserRouter></AuthGate>
<PlatformReadySignal />
<PlatformErrorBoundary>
<AuthGate><BrowserRouter future={ROUTER_FUTURE}>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} />
<Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} label="全局监控" />} />
<Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} label="车辆查询" />} />
<Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} label="轨迹回放" />} />
<Route path="/history" element={<RoutePage page={RoutePages.History} label="历史数据" />} />
<Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} label="里程查询" />} />
<Route path="/access" element={<RoutePage page={RoutePages.Access} label="接入管理" />} />
<Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} label="告警中心" />} />
<Route path="/operations" element={<RoutePage page={RoutePages.Operations} label="运维质量" />} />
<Route path="*" element={<Navigate to="/monitor" replace />} />
</Route>
</Routes>
</BrowserRouter></AuthGate>
</PlatformErrorBoundary>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,49 @@
import { afterEach, expect, test, vi } from 'vitest';
import indexSource from '../../index.html?raw';
const bootScript = indexSource.match(/<script>\s*([\s\S]*?vehicle-platform:ready[\s\S]*?)<\/script>/)?.[1];
function mountBootShell() {
document.body.innerHTML = '<div id="root"><div id="platform-boot" role="status"><div><span class="platform-boot-mark">车</span><strong>车辆数据中台</strong><small>正在加载工作台…</small></div></div></div>';
expect(bootScript).toBeTruthy();
Function(bootScript!)();
}
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
test('turns an entry resource error into an actionable static recovery screen', () => {
vi.useFakeTimers();
mountBootShell();
window.dispatchEvent(new Event('error'));
expect(document.getElementById('platform-boot')).toHaveClass('is-error');
expect(document.getElementById('platform-boot')).toHaveAttribute('role', 'alert');
expect(document.querySelector('#platform-boot strong')).toHaveTextContent('平台启动失败');
expect(document.querySelector('#platform-boot button')).toHaveTextContent('重新加载最新版本');
});
test('uses the timeout only before React readiness and never replaces a committed app', () => {
vi.useFakeTimers();
mountBootShell();
document.getElementById('root')!.innerHTML = '<main data-testid="committed-app">已启动</main>';
vi.advanceTimersByTime(10_000);
expect(document.querySelector('[data-testid="committed-app"]')).toHaveTextContent('已启动');
expect(document.querySelector('#platform-boot')).not.toBeInTheDocument();
});
test('cancels the watchdog after the React ready handshake', () => {
vi.useFakeTimers();
mountBootShell();
window.dispatchEvent(new Event('vehicle-platform:ready'));
vi.advanceTimersByTime(10_000);
expect(document.getElementById('platform-boot')).not.toHaveClass('is-error');
expect(document.querySelector('#platform-boot button')).not.toBeInTheDocument();
});

View File

@@ -1,6 +1,8 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, test } from 'vitest';
import appV2Source from './AppV2.tsx?raw';
import indexSource from '../../index.html?raw';
import mainSource from '../main.tsx?raw';
const v2Styles = readFileSync(resolve(process.cwd(), 'src/v2/styles/v2.css'), 'utf8');
@@ -11,6 +13,16 @@ describe('V2 production entry', () => {
expect(mainSource).toContain("./v2/styles/v2.css");
});
test('renders a static boot shell and recovers when the React entry never becomes ready', () => {
expect(indexSource).toContain('id="platform-boot"');
expect(indexSource).toContain("window.addEventListener('error', fail, true)");
expect(indexSource).toContain("window.addEventListener('unhandledrejection', fail)");
expect(indexSource).toContain('window.setTimeout(fail, 10_000)');
expect(indexSource).toContain("target.searchParams.set('__reload', String(Date.now()))");
expect(appV2Source).toContain("export const PLATFORM_READY_EVENT = 'vehicle-platform:ready'");
expect(appV2Source).toContain('window.dispatchEvent(new Event(PLATFORM_READY_EVENT))');
});
test('applies rendering containment to off-screen vehicle items instead of the visible scroller', () => {
const ruleBody = (selector: string) => {
const start = v2Styles.indexOf(`${selector} {`);

View File

@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { lazy, useEffect, useState } from 'react';
import { MemoryRouter, useSearchParams } from 'react-router-dom';
import { isRouteChunkError, RoutePage } from './RouteBoundary';
import { isRouteChunkError, PlatformErrorBoundary, RoutePage } from './RouteBoundary';
import { ROUTER_FUTURE } from './routerConfig';
afterEach(() => {
@@ -12,6 +12,17 @@ afterEach(() => {
});
describe('route recovery boundary', () => {
it('keeps a recovery screen when the platform shell fails outside page routes', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const BrokenShell = () => { throw new Error('shell exploded'); };
render(<PlatformErrorBoundary><BrokenShell /></PlatformErrorBoundary>);
expect(screen.getByRole('alert')).toHaveTextContent('平台外壳运行异常');
expect(screen.getByRole('button', { name: /重新加载平台/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
});
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);

View File

@@ -73,6 +73,32 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
}
}
export class PlatformErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return <section className="v2-route-error v2-root-error" role="alert">
<span><IconAlertTriangle /></span>
<div>
<small></small>
<h2></h2>
<p></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}`;

View File

@@ -254,6 +254,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-page-skeleton i { min-height: 72px; border: 1px solid #edf1f6; border-radius: 10px; background: linear-gradient(100deg,#f4f7fb 25%,#edf3fa 38%,#f4f7fb 55%); background-size: 300% 100%; animation: v2-skeleton 1.4s ease infinite; }
.v2-page-skeleton i:last-child { min-height: 260px; grid-column: 1 / -1; }
.v2-route-error { display: grid; min-height: 100%; place-items: center; padding: 28px; background: #f6f8fb; }
.v2-root-error { min-height: 100vh; }
.v2-route-error > span { display: grid; width: 52px; height: 52px; place-items: center; border-radius: 16px; background: #fff1f2; color: #dc2626; font-size: 24px; }
.v2-route-error > div { width: min(560px,100%); text-align: center; }
.v2-route-error small { color: #b45309; font-size: 10px; font-weight: 700; }