From fa886abff9daee1103f433ee68240cdd65486339 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 07:01:34 +0800 Subject: [PATCH] fix(web): retry rejected route modules --- .../apps/web/src/v2/AppV2.tsx | 18 +++++------ .../web/src/v2/routing/RouteBoundary.test.tsx | 19 +++++++++++- .../apps/web/src/v2/routing/RouteBoundary.tsx | 18 ++++++----- .../apps/web/src/v2/routing/routeModules.ts | 31 ++++++++++++++----- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx index 38527861..c60f6cfd 100644 --- a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx +++ b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx @@ -4,7 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppShell } from './layout/AppShell'; import { AuthGate } from './auth/AuthGate'; import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary'; -import { RoutePages } from './routing/routeModules'; +import { RoutePageFactories, RoutePages } from './routing/routeModules'; import { ROUTER_FUTURE } from './routing/routerConfig'; import { QUERY_MEMORY } from './queryPolicy'; @@ -44,14 +44,14 @@ export function AppV2() { }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx index 7f4fe86f..03c25e67 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx @@ -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 { lazy, useEffect, useState } from 'react'; import { MemoryRouter, useSearchParams } from 'react-router-dom'; @@ -46,6 +46,23 @@ describe('route recovery boundary', () => { render(); expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新'); 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: () => 历史模块已恢复 }))); + + render(); + + 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', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx index 64d10dfe..9a8b9bef 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx @@ -1,5 +1,5 @@ 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 { 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 } = {}; static getDerivedStateFromError(error: Error) { @@ -46,8 +46,8 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey } } - componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string }>) { - if (this.state.error && previous.routeKey !== this.props.routeKey) { + componentDidUpdate(previous: Readonly<{ children: ReactNode; routeKey: string; retryKey: number; onRetry: () => void }>) { + if (this.state.error && (previous.routeKey !== this.props.routeKey || previous.retryKey !== this.props.retryKey)) { this.setState({ error: undefined }); } } @@ -65,6 +65,7 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey

{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}

{error.message || error.name} @@ -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 routeKey = `${location.pathname}${location.search}`; - return - }> + const [retryKey, setRetryKey] = useState(0); + const Page = useMemo(() => retryKey && recreatePage ? recreatePage() : page, [page, recreatePage, retryKey]); + const retry = useCallback(() => setRetryKey((value) => value + 1), []); + return + }> ; } diff --git a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts index 865a9bf1..d1780520 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts +++ b/vehicle-data-platform/apps/web/src/v2/routing/routeModules.ts @@ -82,6 +82,10 @@ function loadRoute(section: RouteSection) { return next; } +export function createRouteComponent(section: RouteSection) { + return lazy(() => loadRoute(section)); +} + export function routeSectionForPath(pathname: string): RouteSection | undefined { const section = pathname.split('?')[0].split('#')[0].split('/').filter(Boolean)[0] as RouteSection | undefined; return section && section in importers ? section : undefined; @@ -184,12 +188,23 @@ export function scheduleIdleRoutePreloads({ } export const RoutePages = { - Monitor: lazy(() => loadRoute('monitor')), - Vehicles: lazy(() => loadRoute('vehicles')), - Tracks: lazy(() => loadRoute('tracks')), - History: lazy(() => loadRoute('history')), - Statistics: lazy(() => loadRoute('statistics')), - Access: lazy(() => loadRoute('access')), - Alerts: lazy(() => loadRoute('alerts')), - Operations: lazy(() => loadRoute('operations')) + 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; + +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;