fix(web): retry rejected route modules

This commit is contained in:
lingniu
2026-07-16 07:01:34 +08:00
parent ec3071d59b
commit fa886abff9
4 changed files with 61 additions and 25 deletions

View File

@@ -4,7 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell'; import { AppShell } from './layout/AppShell';
import { AuthGate } from './auth/AuthGate'; import { AuthGate } from './auth/AuthGate';
import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary'; import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary';
import { RoutePages } from './routing/routeModules'; import { RoutePageFactories, RoutePages } from './routing/routeModules';
import { ROUTER_FUTURE } from './routing/routerConfig'; import { ROUTER_FUTURE } from './routing/routerConfig';
import { QUERY_MEMORY } from './queryPolicy'; import { QUERY_MEMORY } from './queryPolicy';
@@ -44,14 +44,14 @@ export function AppV2() {
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} /> <Route index element={<Navigate to="/monitor" replace />} />
<Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} label="全局监控" />} /> <Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" />} />
<Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} label="车辆查询" />} /> <Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" />} />
<Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} label="轨迹回放" />} /> <Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" />} />
<Route path="/history" element={<RoutePage page={RoutePages.History} label="历史数据" />} /> <Route path="/history" element={<RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" />} />
<Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} label="里程查询" />} /> <Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" />} />
<Route path="/access" element={<RoutePage page={RoutePages.Access} label="接入管理" />} /> <Route path="/access" element={<RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" />} />
<Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} label="告警中心" />} /> <Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" />} />
<Route path="/operations" element={<RoutePage page={RoutePages.Operations} label="运维质量" />} /> <Route path="/operations" element={<RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" />} />
<Route path="*" element={<Navigate to="/monitor" replace />} /> <Route path="*" element={<Navigate to="/monitor" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { lazy, useEffect, useState } from 'react'; import { lazy, useEffect, useState } from 'react';
import { MemoryRouter, useSearchParams } from 'react-router-dom'; import { MemoryRouter, useSearchParams } from 'react-router-dom';
@@ -46,6 +46,23 @@ describe('route recovery boundary', () => {
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>); render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新'); expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新');
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument(); expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /重试加载模块/ })).toBeInTheDocument();
});
it('recreates a rejected lazy route when the operator retries without a full reload', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/history', at: Date.now() }));
const failedPage = lazy(() => Promise.reject(new TypeError('Failed to fetch dynamically imported module: /assets/History-old.js')));
const recreatePage = vi.fn(() => lazy(() => Promise.resolve({ default: () => <strong></strong> })));
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={failedPage} recreatePage={recreatePage} label="历史数据" /></MemoryRouter>);
expect(await screen.findByRole('alert')).toHaveTextContent('检测到页面版本更新');
fireEvent.click(screen.getByRole('button', { name: /重试加载模块/ }));
await waitFor(() => expect(screen.getByText('历史模块已恢复')).toBeInTheDocument());
expect(recreatePage).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
}); });
it('explains a bounded route timeout instead of leaving the loading skeleton forever', () => { it('explains a bounded route timeout instead of leaving the loading skeleton forever', () => {

View File

@@ -1,5 +1,5 @@
import { IconAlertTriangle, IconHome, IconRefresh } from '@douyinfe/semi-icons'; import { IconAlertTriangle, IconHome, IconRefresh } from '@douyinfe/semi-icons';
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense } from 'react'; import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { PageLoading } from '../shared/AsyncState'; import { PageLoading } from '../shared/AsyncState';
@@ -32,7 +32,7 @@ function rememberAutoReload(routeKey: string) {
} }
} }
class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey: string }, { error?: Error }> { class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey: string; retryKey: number; onRetry: () => void }, { error?: Error }> {
state: { error?: Error } = {}; state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) { static getDerivedStateFromError(error: Error) {
@@ -46,8 +46,8 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
} }
} }
componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string }>) { componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string; retryKey: number; onRetry: () => void }>) {
if (this.state.error && previous.routeKey !== this.props.routeKey) { if (this.state.error && (previous.routeKey !== this.props.routeKey || previous.retryKey !== this.props.retryKey)) {
this.setState({ error: undefined }); this.setState({ error: undefined });
} }
} }
@@ -65,6 +65,7 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
<p>{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p> <p>{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
<code>{error.message || error.name}</code> <code>{error.message || error.name}</code>
<footer> <footer>
<button type="button" onClick={this.props.onRetry}><IconRefresh /></button>
<button type="button" onClick={() => window.location.reload()}><IconRefresh /></button> <button type="button" onClick={() => window.location.reload()}><IconRefresh /></button>
<a href="/monitor"><IconHome /></a> <a href="/monitor"><IconHome /></a>
</footer> </footer>
@@ -99,10 +100,13 @@ export class PlatformErrorBoundary extends Component<{ children: ReactNode }, {
} }
} }
export function RoutePage({ page: Page, label }: { page: ElementType; label: string }) { export function RoutePage({ page, recreatePage, label }: { page: ElementType; recreatePage?: () => ElementType; label: string }) {
const location = useLocation(); const location = useLocation();
const routeKey = `${location.pathname}${location.search}`; const routeKey = `${location.pathname}${location.search}`;
return <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey}> const [retryKey, setRetryKey] = useState(0);
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page /></Suspense> const Page = useMemo(() => retryKey && recreatePage ? recreatePage() : page, [page, recreatePage, retryKey]);
const retry = useCallback(() => setRetryKey((value) => value + 1), []);
return <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey} retryKey={retryKey} onRetry={retry}>
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page key={retryKey} /></Suspense>
</RecoverableRouteBoundary>; </RecoverableRouteBoundary>;
} }

View File

@@ -82,6 +82,10 @@ function loadRoute(section: RouteSection) {
return next; return next;
} }
export function createRouteComponent(section: RouteSection) {
return lazy(() => loadRoute(section));
}
export function routeSectionForPath(pathname: string): RouteSection | undefined { export function routeSectionForPath(pathname: string): RouteSection | undefined {
const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined; const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined;
return section && section in importers ? section : undefined; return section && section in importers ? section : undefined;
@@ -184,12 +188,23 @@ export function scheduleIdleRoutePreloads({
} }
export const RoutePages = { export const RoutePages = {
Monitor: lazy(() => loadRoute('monitor')), Monitor: createRouteComponent('monitor'),
Vehicles: lazy(() => loadRoute('vehicles')), Vehicles: createRouteComponent('vehicles'),
Tracks: lazy(() => loadRoute('tracks')), Tracks: createRouteComponent('tracks'),
History: lazy(() => loadRoute('history')), History: createRouteComponent('history'),
Statistics: lazy(() => loadRoute('statistics')), Statistics: createRouteComponent('statistics'),
Access: lazy(() => loadRoute('access')), Access: createRouteComponent('access'),
Alerts: lazy(() => loadRoute('alerts')), Alerts: createRouteComponent('alerts'),
Operations: lazy(() => loadRoute('operations')) Operations: createRouteComponent('operations')
} as const;
export const RoutePageFactories = {
Monitor: () => createRouteComponent('monitor'),
Vehicles: () => createRouteComponent('vehicles'),
Tracks: () => createRouteComponent('tracks'),
History: () => createRouteComponent('history'),
Statistics: () => createRouteComponent('statistics'),
Access: () => createRouteComponent('access'),
Alerts: () => createRouteComponent('alerts'),
Operations: () => createRouteComponent('operations')
} as const; } as const;