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.
This commit is contained in:
erick713006 2026-07-26 11:47:15 -06:00 committed by Teknium
parent aff48958d3
commit 2df57fe641
2 changed files with 122 additions and 45 deletions

View file

@ -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 (
<div
className="flex min-h-[12rem] flex-1 items-center justify-center"
aria-busy="true"
aria-live="polite"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner />
<span>{label}</span>
</div>
</div>
);
}
function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
@ -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<string, ComponentType> = {
"/": 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() {
)}
>
<ProfileKeyedRoutes>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>
</Suspense>
</ProfileKeyedRoutes>
{embeddedChat &&
!chatOverriddenByPlugin &&
(pluginsLoading ? (
isChatRoute ? (
<div
className="flex min-h-0 min-w-0 flex-1 items-center justify-center"
aria-busy="true"
aria-live="polite"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner />
<span>Loading chat</span>
</div>
</div>
<RouteFallback label="Loading chat…" />
) : null
) : (
) : chatHostMounted ? (
<div
data-chat-active={isChatRoute ? "true" : "false"}
className={cn(
@ -774,9 +796,19 @@ export default function App() {
)}
aria-hidden={!isChatRoute}
>
<ChatPage isActive={isChatRoute} />
<Suspense
fallback={
isChatRoute ? (
<RouteFallback label="Loading chat…" />
) : null
}
>
<ChatPage isActive={isChatRoute} />
</Suspense>
</div>
))}
) : isChatRoute ? (
<RouteFallback label="Loading chat…" />
) : null)}
</div>
<PluginSlot name="post-main" />
</div>

View file

@ -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: {