diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 2c1a688d..8e32bb09 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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 = () => ( +
+ LOADING… +
+); + /* Unified MazeNET entrypoint: no ?topology → topology selector, * ?topology= → editor bound to that topology. */ function MazeNETRoute() { @@ -75,22 +100,24 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue return ( <> setCmdOpen(true)}> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + = ({ 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 && {label}} diff --git a/decnet_web/src/routePrefetch.ts b/decnet_web/src/routePrefetch.ts new file mode 100644 index 00000000..3e7cb388 --- /dev/null +++ b/decnet_web/src/routePrefetch.ts @@ -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; + +const loaders: Record = { + '/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(); + +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); + }); +}