perf(web): lazy-load page routes + prefetch-on-hover
Switch all navigable route components to React.lazy() and wrap <Routes> in <Suspense>. Dashboard/Login/Layout stay eager since they're the shell. Initial index bundle drops 246kB -> 34.67kB (gzip 10.5kB). Each route becomes its own 8-51kB chunk, loaded on demand. Nav hover/focus triggers prefetchRoute(path) which fires the same dynamic import() specifier the bundler dedups against React.lazy, so the chunk is warm by the time the user clicks. Avoids the Suspense flicker that would otherwise show on every first nav.
This commit is contained in:
@@ -1,25 +1,50 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lazy, Suspense, useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
||||
import Login from './components/Login';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import DeckyFleet from './components/DeckyFleet';
|
||||
import LiveLogs from './components/LiveLogs';
|
||||
import Webhooks from './components/Webhooks';
|
||||
import Attackers from './components/Attackers';
|
||||
import AttackerDetail from './components/AttackerDetail';
|
||||
import Config from './components/Config';
|
||||
import Bounty from './components/Bounty';
|
||||
import RemoteUpdates from './components/RemoteUpdates';
|
||||
import SwarmHosts from './components/SwarmHosts';
|
||||
import MazeNET from './components/MazeNET/MazeNET';
|
||||
import TopologyList from './components/TopologyList/TopologyList';
|
||||
import CommandPalette from './components/CommandPalette/CommandPalette';
|
||||
import ShortcutsHelp from './components/ShortcutsHelp/ShortcutsHelp';
|
||||
import { ToastProvider } from './components/Toasts/ToastProvider';
|
||||
import { useToast } from './components/Toasts/useToast';
|
||||
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
|
||||
|
||||
// Page components are code-split per route. Each lands as its own
|
||||
// chunk and only downloads when the user navigates to that path —
|
||||
// initial page-load stays slim. Dashboard stays eager because it's
|
||||
// the landing page: lazy-loading it would Suspense-flicker on every
|
||||
// login for zero gain.
|
||||
const DeckyFleet = lazy(() => import('./components/DeckyFleet'));
|
||||
const LiveLogs = lazy(() => import('./components/LiveLogs'));
|
||||
const Webhooks = lazy(() => import('./components/Webhooks'));
|
||||
const Attackers = lazy(() => import('./components/Attackers'));
|
||||
const AttackerDetail = lazy(() => import('./components/AttackerDetail'));
|
||||
const Config = lazy(() => import('./components/Config'));
|
||||
const Bounty = lazy(() => import('./components/Bounty'));
|
||||
const RemoteUpdates = lazy(() => import('./components/RemoteUpdates'));
|
||||
const SwarmHosts = lazy(() => import('./components/SwarmHosts'));
|
||||
const MazeNET = lazy(() => import('./components/MazeNET/MazeNET'));
|
||||
const TopologyList = lazy(() => import('./components/TopologyList/TopologyList'));
|
||||
|
||||
/* Minimal fallback rendered while a lazy-loaded route chunk is in
|
||||
* flight. Matches the house "dim mono" voice — no spinner library,
|
||||
* no new CSS. Visible for a few frames on first navigation to a
|
||||
* route; cached thereafter. */
|
||||
const RouteFallback: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
opacity: 0.5,
|
||||
fontSize: '0.82rem',
|
||||
letterSpacing: '1.5px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
LOADING…
|
||||
</div>
|
||||
);
|
||||
|
||||
/* Unified MazeNET entrypoint: no ?topology → topology selector,
|
||||
* ?topology=<id> → editor bound to that topology. */
|
||||
function MazeNETRoute() {
|
||||
@@ -75,22 +100,24 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
||||
return (
|
||||
<>
|
||||
<Layout onLogout={onLogout} onSearch={onSearch} onOpenCmd={() => setCmdOpen(true)}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
|
||||
<Route path="/fleet" element={<DeckyFleet searchQuery={searchQuery} />} />
|
||||
<Route path="/topologies" element={<Navigate to="/mazenet" replace />} />
|
||||
<Route path="/mazenet" element={<MazeNETRoute />} />
|
||||
<Route path="/live-logs" element={<LiveLogs />} />
|
||||
<Route path="/webhooks" element={<Webhooks />} />
|
||||
<Route path="/bounty" element={<Bounty />} />
|
||||
<Route path="/attackers" element={<Attackers />} />
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||
<Route path="/swarm/hosts" element={<SwarmHosts />} />
|
||||
<Route path="/swarm/enroll" element={<Navigate to="/swarm/hosts" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
|
||||
<Route path="/fleet" element={<DeckyFleet searchQuery={searchQuery} />} />
|
||||
<Route path="/topologies" element={<Navigate to="/mazenet" replace />} />
|
||||
<Route path="/mazenet" element={<MazeNETRoute />} />
|
||||
<Route path="/live-logs" element={<LiveLogs />} />
|
||||
<Route path="/webhooks" element={<Webhooks />} />
|
||||
<Route path="/bounty" element={<Bounty />} />
|
||||
<Route path="/attackers" element={<Attackers />} />
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||
<Route path="/swarm/hosts" element={<SwarmHosts />} />
|
||||
<Route path="/swarm/enroll" element={<Navigate to="/swarm/hosts" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
<CommandPalette
|
||||
open={cmdOpen}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
|
||||
ShieldAlert, Bell, Webhook,
|
||||
} from 'lucide-react';
|
||||
import { prefetchRoute } from '../routePrefetch';
|
||||
import './Layout.css';
|
||||
|
||||
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
|
||||
@@ -212,6 +213,8 @@ const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent, badge
|
||||
to={to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
|
||||
end={to === '/'}
|
||||
onMouseEnter={() => prefetchRoute(to)}
|
||||
onFocus={() => prefetchRoute(to)}
|
||||
>
|
||||
{icon}
|
||||
{open && <span className="nav-label">{label}</span>}
|
||||
|
||||
39
decnet_web/src/routePrefetch.ts
Normal file
39
decnet_web/src/routePrefetch.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/* Prefetch-on-intent for lazy-loaded routes.
|
||||
*
|
||||
* Each key is a route path; each value is the same dynamic import()
|
||||
* used by React.lazy() in App.tsx. The bundler dedups by specifier
|
||||
* string, so a hover-triggered import here warms the exact chunk
|
||||
* React.lazy resolves on click — no double fetch, no separate chunk.
|
||||
*
|
||||
* A Set of already-fired paths prevents redundant imports on repeat
|
||||
* hovers; the module cache would short-circuit anyway, but skipping
|
||||
* the call avoids a microtask and makes intent obvious in devtools. */
|
||||
|
||||
type Loader = () => Promise<unknown>;
|
||||
|
||||
const loaders: Record<string, Loader> = {
|
||||
'/fleet': () => import('./components/DeckyFleet'),
|
||||
'/mazenet': () => import('./components/MazeNET/MazeNET'),
|
||||
'/topologies': () => import('./components/TopologyList/TopologyList'),
|
||||
'/live-logs': () => import('./components/LiveLogs'),
|
||||
'/webhooks': () => import('./components/Webhooks'),
|
||||
'/bounty': () => import('./components/Bounty'),
|
||||
'/attackers': () => import('./components/Attackers'),
|
||||
'/config': () => import('./components/Config'),
|
||||
'/swarm-updates': () => import('./components/RemoteUpdates'),
|
||||
'/swarm/hosts': () => import('./components/SwarmHosts'),
|
||||
};
|
||||
|
||||
const fired = new Set<string>();
|
||||
|
||||
export function prefetchRoute(path: string): void {
|
||||
const loader = loaders[path];
|
||||
if (!loader || fired.has(path)) return;
|
||||
fired.add(path);
|
||||
loader().catch(() => {
|
||||
// Network hiccup on a speculative prefetch is a non-event —
|
||||
// React.lazy will re-try on actual navigation and surface the
|
||||
// real error there if it persists.
|
||||
fired.delete(path);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user