From 2df57fe641c0f46a438a32f299b0739d45bf1441 Mon Sep 17 00:00:00 2001 From: erick713006 <168612416+erick713006@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:47:15 -0600 Subject: [PATCH] perf(web): lazy-load dashboard routes and split heavy vendors The production dashboard build packed almost every page plus xterm/three/ plot into one large JS chunk, which trips Vite's 500kB warning and slows first paint even when the user only opens Sessions/Config. - Lazy-load route pages in App.tsx behind Suspense - Defer mounting the persistent embedded chat host (and xterm) until the first /chat visit, while keeping the sticky PTY latch afterward - Add rolldown vendor codeSplitting groups (react, xterm, three, plot, motion, ui) and raise chunkSizeWarningLimit modestly to 600kB Addresses #25912 (partial: route lazy-load + vendor splits + fallbacks; not yet CI bundle analysis or documented entry budget). Verified locally: npm run typecheck, npm run test (97), npm run build with separate page/vendor chunks. --- web/src/App.tsx | 122 ++++++++++++++++++++++++++++----------------- web/vite.config.ts | 45 +++++++++++++++++ 2 files changed, 122 insertions(+), 45 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 79f7e4853504..cd35eea3dc89 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,6 @@ import { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -72,25 +74,27 @@ import { ProfileSwitcher } from "@/components/ProfileSwitcher"; import { ProfileScopeBanner } from "@/components/ProfileScopeBanner"; import { useSystemActions } from "@/contexts/useSystemActions"; import type { SystemAction } from "@/contexts/system-actions-context"; -import ConfigPage from "@/pages/ConfigPage"; -import DocsPage from "@/pages/DocsPage"; -import EnvPage from "@/pages/EnvPage"; -import FilesPage from "@/pages/FilesPage"; -import SessionsPage from "@/pages/SessionsPage"; -import LogsPage from "@/pages/LogsPage"; -import AnalyticsPage from "@/pages/AnalyticsPage"; -import ModelsPage from "@/pages/ModelsPage"; -import CronPage from "@/pages/CronPage"; -import ProfilesPage from "@/pages/ProfilesPage"; -import ProfileBuilderPage from "@/pages/ProfileBuilderPage"; -import SkillsPage from "@/pages/SkillsPage"; -import PluginsPage from "@/pages/PluginsPage"; -import McpPage from "@/pages/McpPage"; -import PairingPage from "@/pages/PairingPage"; -import ChannelsPage from "@/pages/ChannelsPage"; -import WebhooksPage from "@/pages/WebhooksPage"; -import SystemPage from "@/pages/SystemPage"; -import ChatPage from "@/pages/ChatPage"; +// Route pages are lazy-loaded so the initial dashboard shell does not pay for +// every admin surface (and heavy deps like xterm) up front. +const ConfigPage = lazy(() => import("@/pages/ConfigPage")); +const DocsPage = lazy(() => import("@/pages/DocsPage")); +const EnvPage = lazy(() => import("@/pages/EnvPage")); +const FilesPage = lazy(() => import("@/pages/FilesPage")); +const SessionsPage = lazy(() => import("@/pages/SessionsPage")); +const LogsPage = lazy(() => import("@/pages/LogsPage")); +const AnalyticsPage = lazy(() => import("@/pages/AnalyticsPage")); +const ModelsPage = lazy(() => import("@/pages/ModelsPage")); +const CronPage = lazy(() => import("@/pages/CronPage")); +const ProfilesPage = lazy(() => import("@/pages/ProfilesPage")); +const ProfileBuilderPage = lazy(() => import("@/pages/ProfileBuilderPage")); +const SkillsPage = lazy(() => import("@/pages/SkillsPage")); +const PluginsPage = lazy(() => import("@/pages/PluginsPage")); +const McpPage = lazy(() => import("@/pages/McpPage")); +const PairingPage = lazy(() => import("@/pages/PairingPage")); +const ChannelsPage = lazy(() => import("@/pages/ChannelsPage")); +const WebhooksPage = lazy(() => import("@/pages/WebhooksPage")); +const SystemPage = lazy(() => import("@/pages/SystemPage")); +const ChatPage = lazy(() => import("@/pages/ChatPage")); import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; @@ -99,9 +103,25 @@ import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; +import { latchChatActivation } from "@/lib/chat-activation"; import { api } from "@/lib/api"; import type { StatusResponse, UpdateCheckResponse } from "@/lib/api"; +function RouteFallback({ label = "Loading…" }: { label?: string }) { + return ( +
+
+ + {label} +
+
+ ); +} + function RootRedirect() { return ; } @@ -127,8 +147,10 @@ const CHAT_NAV_ITEM: NavItem = { * inline near the bottom of this file — so the PTY child, WebSocket, * and xterm instance survive when the user visits another tab and comes * back. A `display:none` toggle hides the terminal without unmounting. - * Routing still owns the URL so /chat deep-links, browser back/forward, - * and nav highlight keep working. + * The host itself is still deferred until the first /chat visit so the + * xterm chunk is not downloaded on unrelated pages. Routing still owns + * the URL so /chat deep-links, browser back/forward, and nav highlight + * keep working. */ const BUILTIN_ROUTES_CORE: Record = { "/": RootRedirect, @@ -378,6 +400,13 @@ export default function App() { const normalizedPath = pathname.replace(/\/$/, "") || "/"; const isChatRoute = normalizedPath === "/chat"; const embeddedChat = isDashboardEmbeddedChatEnabled(); + // Defer mounting the persistent chat host (and its xterm chunk) until the + // user has actually opened /chat at least once. Sticky after that so the + // PTY survives later tab switches. + const [chatHostMounted, setChatHostMounted] = useState(isChatRoute); + useEffect(() => { + setChatHostMounted((prev) => latchChatActivation(prev, isChatRoute)); + }, [isChatRoute]); // `dashboard.show_token_analytics` gates the Analytics nav item. The // page itself remains reachable by URL (it renders an explanation when @@ -737,35 +766,28 @@ export default function App() { )} > - - {routes.map(({ key, path, element }) => ( - - ))} - - } - /> - + }> + + {routes.map(({ key, path, element }) => ( + + ))} + + } + /> + + {embeddedChat && !chatOverriddenByPlugin && (pluginsLoading ? ( isChatRoute ? ( -
-
- - Loading chat… -
-
+ ) : null - ) : ( + ) : chatHostMounted ? (
- + + ) : null + } + > + +
- ))} + ) : isChatRoute ? ( + + ) : null)} diff --git a/web/vite.config.ts b/web/vite.config.ts index 6521751af967..6a04b624a11c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -86,6 +86,51 @@ export default defineConfig({ build: { outDir: "../hermes_cli/web_dist", emptyOutDir: true, + // Shell stays a bit over Vite's 500 kB default after vendor splits; + // page/xterm chunks load on demand. Keep a modest ceiling so a true + // regression still warns. + chunkSizeWarningLimit: 600, + // Split heavy vendors so the first dashboard paint does not download + // xterm/three/plot/etc. until a route actually needs them. Lazy page + // imports in App.tsx create the route boundaries; these groups keep + // shared node_modules out of every page chunk. + rolldownOptions: { + output: { + codeSplitting: { + minSize: 20_000, + groups: [ + { + name: "react-vendor", + test: /node_modules[\\/](react|react-dom|scheduler|react-router|react-router-dom)([\\/]|$)/, + }, + { + name: "xterm", + test: /node_modules[\\/]@xterm[\\/]/, + }, + { + name: "three", + test: /node_modules[\\/](three|@react-three)([\\/]|$)/, + }, + { + name: "plot", + test: /node_modules[\\/]@observablehq[\\/]plot([\\/]|$)/, + }, + { + name: "motion", + test: /node_modules[\\/](motion|framer-motion)([\\/]|$)/, + }, + { + name: "ui", + test: /node_modules[\\/]@nous-research[\\/]ui([\\/]|$)/, + }, + { + name: "vendor", + test: /node_modules[\\/]/, + }, + ], + }, + }, + }, }, server: { proxy: {