From a83e75a79f222313f61e1f080f3af4578a911abc Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 05:47:58 +0200 Subject: [PATCH] Code-split feature pages and admin shell with React.lazy The dashboard (recharts, ~120 kB gzip), tracing, troubleshoot, actions and bootstrap wizard now load on demand, and the admin panel is split out of the entry chunk so anonymous visitors only download the login page (~134 kB gzip instead of ~557 kB). --- src/App.tsx | 6 ++++- src/components/common/LoadingFallback.tsx | 24 +++++++++++++++++ src/components/layout/MainContent.tsx | 32 ++++++++++++++++++----- src/main.tsx | 2 +- src/pages/AdminPanel.lazy.tsx | 12 +++++++++ src/pages/AdminPanel.tsx | 13 ++++++--- 6 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/components/common/LoadingFallback.tsx create mode 100644 src/pages/AdminPanel.lazy.tsx diff --git a/src/App.tsx b/src/App.tsx index 57fc917..1c815e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,14 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; import { Toaster } from '@/components/ui/toaster'; export default function App() { return ( - + }> + + ); diff --git a/src/components/common/LoadingFallback.tsx b/src/components/common/LoadingFallback.tsx new file mode 100644 index 0000000..e7b107e --- /dev/null +++ b/src/components/common/LoadingFallback.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface LoadingFallbackProps { + fullScreen?: boolean; +} + +export function LoadingFallback({ fullScreen }: LoadingFallbackProps) { + const { t } = useTranslation(); + return ( +
+
+ +

{t('common.loading')}

+
+
+ ); +} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 4b04c83..7706f2d 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useSchemaStore } from '@/stores/schemaStore'; import { useCacheStore } from '@/stores/cacheStore'; import { useAccountStore } from '@/stores/accountStore'; @@ -12,11 +12,23 @@ import { resolveObject } from '@/lib/schemaResolver'; import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; -import { DashboardView } from '@/features/dashboard/components/DashboardView'; -import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage'; -import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage'; -import { TraceDetailView } from '@/features/tracing/components/TraceDetailView'; -import { ActionPage } from '@/features/actions/ActionPage'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; + +// Heavy or rarely used feature pages are code-split so the initial bundle +// stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own). +const DashboardView = lazy(() => + import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })), +); +const DeliveryTracePage = lazy(() => + import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })), +); +const LiveTracingPage = lazy(() => + import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })), +); +const TraceDetailView = lazy(() => + import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })), +); +const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage }))); interface MainContentProps { viewName?: string; @@ -25,6 +37,14 @@ interface MainContentProps { } export function MainContent({ viewName, id, section }: MainContentProps) { + return ( + }> + + + ); +} + +function MainContentView({ viewName, id, section }: MainContentProps) { const schema = useSchemaStore((s) => s.schema); const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists); diff --git a/src/main.tsx b/src/main.tsx index 5cfa5ce..06656fa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,8 +12,8 @@ import './index.css'; import App from './App'; import LoginPage from './pages/LoginPage'; import OAuthCallback from './pages/OAuthCallback'; -import AdminPanel from './pages/AdminPanel'; import NotFound from './pages/NotFound'; +import { AdminPanel } from './pages/AdminPanel.lazy'; import { ProtectedRoute } from './components/layout/ProtectedRoute'; import { getBasePath } from './lib/basePath'; diff --git a/src/pages/AdminPanel.lazy.tsx b/src/pages/AdminPanel.lazy.tsx new file mode 100644 index 0000000..03a6a3d --- /dev/null +++ b/src/pages/AdminPanel.lazy.tsx @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { lazy } from 'react'; + +// The admin panel (and everything reachable from it) is split out of the +// entry chunk so anonymous visitors only download the login page. +// Kept in a dedicated file so main.tsx stays component-free for fast refresh. +export const AdminPanel = lazy(() => import('./AdminPanel')); diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 76e80ac..7959abd 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/stores/authStore'; @@ -17,7 +17,7 @@ import { TopBar } from '@/components/layout/TopBar'; import { Sidebar } from '@/components/layout/Sidebar'; import { MainContent } from '@/components/layout/MainContent'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; -import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, @@ -27,6 +27,11 @@ import { import { usePermissions } from '@/hooks/usePermissions'; import { Loader2 } from 'lucide-react'; +// Bootstrap mode is a rare first-run flow; keep it out of the main chunk. +const BootstrapWizard = lazy(() => + import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })), +); + export default function AdminPanel() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -237,7 +242,9 @@ export default function AdminPanel() { if (isBootstrapMode) { return ( - + }> + + ); }