fix(web): recover pre-render boot failures
This commit is contained in:
@@ -4,9 +4,55 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>车辆数据管理中台</title>
|
||||
<style>
|
||||
#platform-boot{display:grid;min-height:100vh;place-items:center;background:#f3f6fa;color:#1d2a3d;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}#platform-boot>div{display:grid;width:min(360px,calc(100vw - 40px));justify-items:center;border:1px solid #e1e8f1;border-radius:16px;background:#fff;padding:34px 28px;box-shadow:0 18px 55px rgba(22,43,75,.1);text-align:center}.platform-boot-mark{display:grid;width:44px;height:44px;place-items:center;border-radius:13px;background:#1268f3;color:#fff;font-size:20px;font-weight:800;box-shadow:0 9px 22px rgba(18,104,243,.22)}#platform-boot strong{margin-top:15px;font-size:18px}#platform-boot small{margin-top:8px;color:#758399;font-size:12px;line-height:1.6}#platform-boot button{height:40px;margin-top:20px;border:0;border-radius:8px;background:#1268f3;padding:0 18px;color:#fff;font-weight:700;cursor:pointer}#platform-boot.is-error .platform-boot-mark{background:#e5484d;box-shadow:0 9px 22px rgba(229,72,77,.2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="root"><div id="platform-boot" role="status" aria-live="polite"><div><span class="platform-boot-mark">车</span><strong>车辆数据中台</strong><small>正在加载工作台…</small></div></div></div>
|
||||
<script>
|
||||
(() => {
|
||||
const readyEvent = 'vehicle-platform:ready';
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('error', fail, true);
|
||||
window.removeEventListener('unhandledrejection', fail);
|
||||
window.removeEventListener(readyEvent, ready);
|
||||
};
|
||||
const ready = () => { if (!settled) { settled = true; cleanup(); } };
|
||||
const fail = () => {
|
||||
if (settled) return;
|
||||
const boot = document.getElementById('platform-boot');
|
||||
if (!boot) { ready(); return; }
|
||||
settled = true;
|
||||
cleanup();
|
||||
boot.classList.add('is-error');
|
||||
boot.setAttribute('role', 'alert');
|
||||
const title = boot.querySelector('strong');
|
||||
const detail = boot.querySelector('small');
|
||||
const card = boot.querySelector('div');
|
||||
if (title) title.textContent = '平台启动失败';
|
||||
if (detail) detail.textContent = '页面资源未能正常加载,请检查网络后重新获取最新版本。';
|
||||
if (card) {
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.textContent = '重新加载最新版本';
|
||||
retry.addEventListener('click', () => {
|
||||
const target = new URL(window.location.href);
|
||||
target.searchParams.set('__reload', String(Date.now()));
|
||||
window.location.replace(target);
|
||||
}, { once: true });
|
||||
card.appendChild(retry);
|
||||
}
|
||||
};
|
||||
window.addEventListener('error', fail, true);
|
||||
window.addEventListener('unhandledrejection', fail);
|
||||
window.addEventListener(readyEvent, ready, { once: true });
|
||||
const timer = window.setTimeout(fail, 10_000);
|
||||
})();
|
||||
</script>
|
||||
<noscript><div style="padding:24px;font-family:sans-serif">车辆数据中台需要启用 JavaScript 后访问。</div></noscript>
|
||||
<script src="/app-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -8,6 +8,7 @@ const mileageWorkers = assets.filter((name) => /^mileageExport\.worker-[\w-]+\.j
|
||||
const monitorAssets = assets.filter((name) => /^MonitorPage-[\w-]+\.js$/.test(name));
|
||||
const runtimeConfig = readFileSync(resolve(process.cwd(), 'dist/app-config.js'), 'utf8');
|
||||
const safeRuntimeTemplate = readFileSync(resolve(process.cwd(), 'public/app-config.example.js'), 'utf8');
|
||||
const builtIndex = readFileSync(resolve(process.cwd(), 'dist/index.html'), 'utf8');
|
||||
const releaseManifest = readFileSync(resolve(process.cwd(), 'dist/.release-assets'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||
|
||||
if (excelAssets.length !== 1) {
|
||||
@@ -22,6 +23,16 @@ if (monitorAssets.length !== 1) {
|
||||
if (runtimeConfig !== safeRuntimeTemplate) {
|
||||
throw new Error('production dist/app-config.js must match the credential-free runtime template');
|
||||
}
|
||||
if (!builtIndex.includes('id="platform-boot"')
|
||||
|| !builtIndex.includes('vehicle-platform:ready')
|
||||
|| !builtIndex.includes('window.setTimeout(fail, 10_000)')) {
|
||||
throw new Error('production index must retain the static boot recovery boundary');
|
||||
}
|
||||
const mainAsset = builtIndex.match(/<script[^>]+type="module"[^>]+src="\/assets\/([^"/]+\.js)"/)?.[1];
|
||||
if (!mainAsset || !assets.includes(mainAsset)
|
||||
|| !readFileSync(resolve(assetsDirectory, mainAsset), 'utf8').includes('vehicle-platform:ready')) {
|
||||
throw new Error('production entry must retain the React boot-ready handshake');
|
||||
}
|
||||
if (releaseManifest.length !== assets.length || releaseManifest.some((name, index) => name !== [...assets].sort()[index])) {
|
||||
throw new Error('production dist/.release-assets must list every generated asset exactly once in sorted order');
|
||||
}
|
||||
@@ -50,4 +61,4 @@ if (monitorBytes > 35_000) {
|
||||
throw new Error(`monitor critical-path asset exceeds 35000 bytes: ${monitorBytes}`);
|
||||
}
|
||||
const qrBytes = statSync(resolve(assetsDirectory, qrAssets[0])).size;
|
||||
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1\n`);
|
||||
process.stdout.write(`web_build_gate=ok release_assets=${releaseManifest.length} exceljs_assets=1 exceljs_bytes=${excelBytes} mileage_workers=1 worker_bytes=${workerBytes} monitor_bytes=${monitorBytes} qr_deferred=1 qr_bytes=${qrBytes} runtime_config_sanitized=1 boot_recovery=1\n`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
49
vehicle-data-platform/apps/web/src/v2/bootRecovery.test.ts
Normal file
49
vehicle-data-platform/apps/web/src/v2/bootRecovery.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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} {`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user