chore: split web route bundles (#1410)

This commit is contained in:
Alfred
2026-05-22 22:22:15 +08:00
committed by GitHub
parent 41dc521af8
commit 964d4ca2e2
5 changed files with 369 additions and 17 deletions

View File

@@ -0,0 +1,161 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import * as AuthContext from './contexts/AuthContext';
type AuthState = ReturnType<typeof AuthContext.useAuth>;
const { chatPageShouldThrow, setCurrentRoute, useAgentChatStoreMock } = vi.hoisted(() => {
const setCurrentRoute = vi.fn();
const chatPageShouldThrow = { value: false };
const state = { completionBadge: false };
const useAgentChatStoreMock = Object.assign(
vi.fn((selector?: (value: typeof state) => unknown) => (selector ? selector(state) : state)),
{ getState: () => ({ setCurrentRoute }) },
);
return { chatPageShouldThrow, setCurrentRoute, useAgentChatStoreMock };
});
vi.mock('./contexts/AuthContext', () => ({
AuthProvider: ({ children }: { children: ReactNode }) => children,
useAuth: vi.fn(),
}));
vi.mock('./stores/agentChatStore', () => ({
useAgentChatStore: useAgentChatStoreMock,
}));
vi.mock('./pages/HomePage', () => ({
default: () => <div data-testid="home-page">Home</div>,
}));
vi.mock('./pages/ChatPage', () => ({
default: () => {
if (chatPageShouldThrow.value) {
throw new Error('chunk load failed');
}
return <div data-testid="chat-page">Chat</div>;
},
}));
vi.mock('./pages/PortfolioPage', () => ({
default: () => <div data-testid="portfolio-page">Portfolio</div>,
}));
vi.mock('./pages/BacktestPage', () => ({
default: () => <div data-testid="backtest-page">Backtest</div>,
}));
vi.mock('./pages/AlertsPage', () => ({
default: () => <div data-testid="alerts-page">Alerts</div>,
}));
vi.mock('./pages/SettingsPage', () => ({
default: () => <div data-testid="settings-page">Settings</div>,
}));
vi.mock('./pages/NotFoundPage', () => ({
default: () => <div data-testid="not-found-page">Not Found</div>,
}));
vi.mock('./pages/LoginPage', () => ({
default: () => <div data-testid="login-page">Login</div>,
}));
function makeAuthState(overrides: Partial<AuthState> = {}): AuthState {
return {
authEnabled: false,
loggedIn: false,
passwordSet: false,
passwordChangeable: false,
setupState: 'no_password',
isLoading: false,
loadError: null,
login: vi.fn().mockResolvedValue({ success: true }),
changePassword: vi.fn().mockResolvedValue({ success: true }),
logout: vi.fn().mockResolvedValue(undefined),
refreshStatus: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
chatPageShouldThrow.value = false;
window.history.pushState({}, '', '/');
vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState());
});
describe('App routing behavior', () => {
it('shows loading fallback while auth status is initializing', () => {
vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({ isLoading: true }));
const { container } = render(<App />);
expect(container.querySelector('.border-t-cyan')).toBeInTheDocument();
});
it('redirects protected routes to login when auth is enabled but user is not logged in', async () => {
vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({
authEnabled: true,
loggedIn: false,
setupState: 'enabled',
}));
window.history.pushState({}, '', '/portfolio');
render(<App />);
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
expect(window.location.pathname).toBe('/login');
expect(window.location.search).toBe('?redirect=%2Fportfolio');
});
it('renders the current route page after auth is ready', async () => {
window.history.pushState({}, '', '/chat');
render(<App />);
expect(await screen.findByTestId('chat-page')).toBeInTheDocument();
expect(setCurrentRoute).toHaveBeenCalledWith('/chat');
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
expect(screen.queryByTestId('home-page')).not.toBeInTheDocument();
});
it('redirects authenticated login visits back to the home page', async () => {
vi.mocked(AuthContext.useAuth).mockReturnValue(makeAuthState({
authEnabled: true,
loggedIn: true,
setupState: 'enabled',
}));
window.history.pushState({}, '', '/login');
render(<App />);
expect(await screen.findByTestId('home-page')).toBeInTheDocument();
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
});
it('keeps the shell mounted and resets the route boundary after page render errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
chatPageShouldThrow.value = true;
window.history.pushState({}, '', '/chat');
try {
render(<App />);
expect(await screen.findByRole('heading', { name: '页面加载失败' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '主导航' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新加载页面' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '返回首页' })).toBeInTheDocument();
chatPageShouldThrow.value = false;
fireEvent.click(screen.getByRole('link', { name: '持仓' }));
expect(await screen.findByTestId('portfolio-page')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '页面加载失败' })).not.toBeInTheDocument();
} finally {
consoleError.mockRestore();
}
});
});

View File

@@ -1,19 +1,25 @@
import type React from 'react';
import { useEffect } from 'react';
import { lazy, useEffect } from 'react';
import { BrowserRouter as Router, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import HomePage from './pages/HomePage';
import BacktestPage from './pages/BacktestPage';
import SettingsPage from './pages/SettingsPage';
import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage';
import ChatPage from './pages/ChatPage';
import PortfolioPage from './pages/PortfolioPage';
import AlertsPage from './pages/AlertsPage';
import { ApiErrorAlert, Shell } from './components/common';
import {
PageLoadingFallback,
RouteOutletBoundary,
StandaloneRouteBoundary,
} from './components/layout/RouteBoundary';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { useAgentChatStore } from './stores/agentChatStore';
import './App.css';
const HomePage = lazy(() => import('./pages/HomePage'));
const BacktestPage = lazy(() => import('./pages/BacktestPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ChatPage = lazy(() => import('./pages/ChatPage'));
const PortfolioPage = lazy(() => import('./pages/PortfolioPage'));
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
const AppContent: React.FC = () => {
const location = useLocation();
const { authEnabled, loggedIn, isLoading, loadError, refreshStatus } = useAuth();
@@ -23,11 +29,7 @@ const AppContent: React.FC = () => {
}, [location.pathname]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-base">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan/20 border-t-cyan" />
</div>
);
return <PageLoadingFallback />;
}
if (loadError) {
@@ -49,7 +51,11 @@ const AppContent: React.FC = () => {
if (authEnabled && !loggedIn) {
if (location.pathname === '/login') {
return <LoginPage />;
return (
<StandaloneRouteBoundary>
<LoginPage />
</StandaloneRouteBoundary>
);
}
const redirect = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirect}`} replace />;
@@ -61,7 +67,13 @@ const AppContent: React.FC = () => {
return (
<Routes>
<Route element={<Shell />}>
<Route
element={(
<Shell>
<RouteOutletBoundary />
</Shell>
)}
>
<Route path="/" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
@@ -70,7 +82,6 @@ const AppContent: React.FC = () => {
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
);
};

View File

@@ -0,0 +1,115 @@
import type React from 'react';
import { Component, Suspense } from 'react';
import type { ErrorInfo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
type PageLoadingFallbackProps = {
fullPage?: boolean;
};
export const PageLoadingFallback: React.FC<PageLoadingFallbackProps> = ({ fullPage = true }) => (
<div
className={
fullPage
? 'flex min-h-screen items-center justify-center bg-base'
: 'flex min-h-[60vh] items-center justify-center'
}
>
<div className="h-8 w-8 animate-spin rounded-full border-2 border-cyan/20 border-t-cyan" />
</div>
);
type RouteErrorBoundaryProps = {
children: React.ReactNode;
resetKey: string;
fullPage: boolean;
};
type RouteErrorBoundaryState = {
hasError: boolean;
};
class RouteErrorBoundary extends Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
override state: RouteErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError(): RouteErrorBoundaryState {
return { hasError: true };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Route page failed to render or load', error, errorInfo);
}
override componentDidUpdate(prevProps: RouteErrorBoundaryProps) {
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
this.setState({ hasError: false });
}
}
override render() {
if (!this.state.hasError) {
return this.props.children;
}
return (
<div
className={
this.props.fullPage
? 'flex min-h-screen items-center justify-center bg-base px-4'
: 'flex min-h-[60vh] items-center justify-center px-2 py-8'
}
>
<div className="w-full max-w-md rounded-2xl border border-border bg-card/94 p-6 text-center shadow-soft-card">
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-3 text-sm leading-6 text-secondary-text">
</p>
<div className="mt-5 flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
type="button"
className="btn-primary"
onClick={() => window.location.reload()}
>
</button>
<button
type="button"
className="rounded-xl border border-border/70 bg-card px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-hover"
onClick={() => window.location.assign('/')}
>
</button>
</div>
</div>
</div>
);
}
}
export const RouteBoundary: React.FC<{ children: React.ReactNode; fullPage?: boolean }> = ({
children,
fullPage = true,
}) => {
const location = useLocation();
const resetKey = `${location.pathname}${location.search}`;
return (
<RouteErrorBoundary resetKey={resetKey} fullPage={fullPage}>
<Suspense fallback={<PageLoadingFallback fullPage={fullPage} />}>{children}</Suspense>
</RouteErrorBoundary>
);
};
export const RouteOutletBoundary: React.FC = () => (
<RouteBoundary fullPage={false}>
<Outlet />
</RouteBoundary>
);
export const StandaloneRouteBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<RouteBoundary fullPage>
{children}
</RouteBoundary>
);

View File

@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { lazy } from 'react';
import type React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { RouteOutletBoundary } from '../RouteBoundary';
import { Shell } from '../Shell';
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
authEnabled: false,
logout: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('../../../stores/agentChatStore', () => {
const state = { completionBadge: false };
return {
useAgentChatStore: (selector?: (value: typeof state) => unknown) => (
selector ? selector(state) : state
),
};
});
describe('RouteOutletBoundary', () => {
it('catches rejected lazy route imports inside the shell and resets on navigation', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const BrokenLazyRoute = lazy(() => (
Promise.reject(new Error('chunk load failed')) as Promise<{ default: React.ComponentType }>
));
try {
render(
<MemoryRouter initialEntries={['/chat']}>
<Routes>
<Route
element={(
<Shell>
<RouteOutletBoundary />
</Shell>
)}
>
<Route path="/chat" element={<BrokenLazyRoute />} />
<Route path="/portfolio" element={<div data-testid="portfolio-page">Portfolio</div>} />
</Route>
</Routes>
</MemoryRouter>,
);
expect(screen.getByRole('navigation', { name: '主导航' })).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '页面加载失败' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新加载页面' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '返回首页' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('link', { name: '持仓' }));
expect(await screen.findByTestId('portfolio-page')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '页面加载失败' })).not.toBeInTheDocument();
} finally {
consoleError.mockRestore();
}
});
});

View File

@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [改进] Web 路由页面改为按需加载,降低首包体积并增加路由加载失败恢复提示。
## [3.18.0] - 2026-05-21