From e74b4db44dd327b263886440c2a54a243e2d28e8 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 28 Apr 2026 08:31:03 -0400 Subject: [PATCH] perf(frontend): lazy-load xterm chunk + addons (#825) xterm-the-terminal-emulator and its three addons (fit, search, serialize) used to be imported at module scope by Terminal.tsx, BashExecModal.tsx, and HostConsole.tsx along with xterm's CSS. Even though only Terminal.tsx is rendered eagerly inside the editor layout, the static imports forced the ~660 KB xterm chunk plus the xterm.css bytes into every cold app start regardless of whether a user ever opened a terminal. Move the bootstrap into a new frontend/src/lib/xtermLoader.ts module. loadXtermModules() Promise.alls the four addon imports plus the CSS, caches the result on a shared promise, and returns the constructors. On rejection the cache is cleared so the next mount can retry instead of rethrowing the same failed promise. Three consumers (Terminal, BashExecModal, HostConsole) swap their value imports for type-only InstanceType aliases from the loader, then call loadXtermModules() inside their existing useEffect. A mounted/cancelled flag in each effect closure prevents initialisation if the component unmounts during the load. The vite.config.ts manualChunks group from #823 already groups all @xterm/* packages into the xterm chunk, so it now loads on demand instead of being bundled into the entry chunk. --- frontend/src/components/BashExecModal.tsx | 27 +-- frontend/src/components/HostConsole.tsx | 210 +++++++++++----------- frontend/src/components/Terminal.tsx | 24 +-- frontend/src/lib/xtermLoader.ts | 53 ++++++ 4 files changed, 190 insertions(+), 124 deletions(-) create mode 100644 frontend/src/lib/xtermLoader.ts diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 13e104cb..4fd3f0df 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -1,9 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Terminal as TerminalIcon } from 'lucide-react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type XtermModules } from '@/lib/xtermLoader'; type TerminalContainer = HTMLDivElement & { __resizeObserver?: ResizeObserver }; @@ -45,10 +43,13 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN return; } + let cancelled = false; let attempts = 0; const maxAttempts = 15; // up to 1.5 seconds + let modules: XtermModules | null = null; const checkAndInit = () => { + if (cancelled || !modules) return; // If already initialized, stop. if (xtermRef.current) return; @@ -72,23 +73,28 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN initTimeoutRef.current = setTimeout(checkAndInit, 100); } else { console.warn('BashExecModal: terminal container has zero dimensions after 1.5s, forcing init anyway.'); - initTerminal(container); + initTerminal(container, modules); } return; } // Node exists and has layout - safe to initialize xterm! - initTerminal(container); + initTerminal(container, modules); }; - // Start polling - initTimeoutRef.current = setTimeout(checkAndInit, 50); + void loadXtermModules().then((mods) => { + if (cancelled) return; + modules = mods; + initTimeoutRef.current = setTimeout(checkAndInit, 50); + }).catch((err) => { + console.error('BashExecModal: failed to load xterm:', err); + }); - function initTerminal(containerEl: HTMLDivElement) { + function initTerminal(containerEl: HTMLDivElement, mods: XtermModules) { // xterm.js requires literal color strings in its theme config; CSS variables // and oklch() are not supported by the canvas renderer. These values are // intentionally hardcoded to match the terminal well aesthetic. - const term = new Terminal({ + const term = new mods.Terminal({ theme: { background: '#0a0a0a', foreground: '#d4d4d4', @@ -101,7 +107,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN cursorBlink: true, }); - const fitAddon = new FitAddon(); + const fitAddon = new mods.FitAddon(); term.loadAddon(fitAddon); term.open(containerEl); @@ -200,6 +206,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN } return () => { + cancelled = true; // Clean up ResizeObserver const el = terminalRef.current as TerminalContainer | null; if (el?.__resizeObserver) { diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index 578c5e36..4ded27f1 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -1,11 +1,8 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { SerializeAddon } from '@xterm/addon-serialize'; import { ArrowLeft, Copy, Trash2, Download, RefreshCw } from 'lucide-react'; import { Button } from './ui/button'; import { PageMasthead, type MastheadTone } from './ui/PageMasthead'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type SerializeAddon } from '@/lib/xtermLoader'; import { useNodes } from '@/context/NodeContext'; import { copyToClipboard } from '@/lib/clipboard'; @@ -71,124 +68,131 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { if (!container) return; let mounted = true; + let resizeObserver: ResizeObserver | null = null; + let resizeTimeout: ReturnType | undefined; - const term = new Terminal({ - theme: getTerminalTheme(), - fontFamily: "'Geist Mono', monospace", - fontSize: 14, - cursorBlink: true, - }); - - const fitAddon = new FitAddon(); - const serializeAddon = new SerializeAddon(); - term.loadAddon(fitAddon); - term.loadAddon(serializeAddon); - term.open(container); - - xtermRef.current = term; - fitAddonRef.current = fitAddon; - serializeRef.current = serializeAddon; - - requestAnimationFrame(() => { - try { - if (mounted) { - fitAddon.fit(); - setDims({ cols: term.cols, rows: term.rows }); - } - } catch { - // Ignore fit errors during initial render - } - }); - - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const activeNodeId = localStorage.getItem('sencho-active-node') || ''; - const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : ''; - const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : ''; - const queryString = [nodeParam, stackParam].filter(Boolean).join('&'); - const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { + void loadXtermModules().then((mods) => { if (!mounted) return; - setConnState('connected'); - setLastActivityAt(Date.now()); - term.focus(); - setTimeout(() => { + const term = new mods.Terminal({ + theme: getTerminalTheme(), + fontFamily: "'Geist Mono', monospace", + fontSize: 14, + cursorBlink: true, + }); + + const fitAddon = new mods.FitAddon(); + const serializeAddon = new mods.SerializeAddon(); + term.loadAddon(fitAddon); + term.loadAddon(serializeAddon); + term.open(container); + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + serializeRef.current = serializeAddon; + + requestAnimationFrame(() => { try { if (mounted) { fitAddon.fit(); setDims({ cols: term.cols, rows: term.rows }); } } catch { - // Ignore + // Ignore fit errors during initial render } - if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + }); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const activeNodeId = localStorage.getItem('sencho-active-node') || ''; + const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : ''; + const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : ''; + const queryString = [nodeParam, stackParam].filter(Boolean).join('&'); + const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mounted) return; + setConnState('connected'); + setLastActivityAt(Date.now()); + term.focus(); + + setTimeout(() => { + try { + if (mounted) { + fitAddon.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } + } catch { + // Ignore + } + if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + ws.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + })); + } + }, 100); + }; + + ws.onmessage = (event) => { + if (!mounted) return; + const text = typeof event.data === 'string' ? event.data : event.data.toString(); + term.write(text); + setLastActivityAt(Date.now()); + }; + + ws.onerror = () => { + if (!mounted) return; + term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); + setConnState('disconnected'); + }; + + ws.onclose = () => { + if (!mounted) return; + term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); + setConnState('disconnected'); + }; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, + type: 'input', + payload: data, })); } - }, 100); - }; + }); - ws.onmessage = (event) => { - if (!mounted) return; - const text = typeof event.data === 'string' ? event.data : event.data.toString(); - term.write(text); - setLastActivityAt(Date.now()); - }; + resizeObserver = new ResizeObserver(() => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (!mounted || !fitAddonRef.current || !wsRef.current) return; + try { + fitAddonRef.current.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } catch { + return; + } + if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + wsRef.current.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + })); + } + }, 50); + }); - ws.onerror = () => { - if (!mounted) return; - term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); - setConnState('disconnected'); - }; - - ws.onclose = () => { - if (!mounted) return; - term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); - setConnState('disconnected'); - }; - - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'input', - payload: data, - })); - } + resizeObserver.observe(container); + }).catch((err) => { + console.error('HostConsole: failed to load xterm:', err); }); - let resizeTimeout: ReturnType; - const resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - if (!mounted || !fitAddonRef.current || !wsRef.current) return; - try { - fitAddonRef.current.fit(); - setDims({ cols: term.cols, rows: term.rows }); - } catch { - return; - } - if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { - wsRef.current.send(JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, - })); - } - }, 50); - }); - - resizeObserver.observe(container); - return () => { mounted = false; - resizeObserver.disconnect(); - clearTimeout(resizeTimeout); + if (resizeObserver) resizeObserver.disconnect(); + if (resizeTimeout) clearTimeout(resizeTimeout); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 063fcb24..5a25ee05 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -1,12 +1,8 @@ -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { SearchAddon } from '@xterm/addon-search'; -import { SerializeAddon } from '@xterm/addon-serialize'; import { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Download, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type SearchAddon, type SerializeAddon } from '@/lib/xtermLoader'; interface TerminalComponentProps { stackName?: string; @@ -50,11 +46,17 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter let mounted = true; - const initTerminal = () => { + const initTerminal = async () => { if (!mounted || !terminalRef.current) return; + const mods = await loadXtermModules().catch((err) => { + console.error('Terminal: failed to load xterm:', err); + return null; + }); + if (!mods || !mounted || !terminalRef.current) return; + try { - const term = new Terminal({ + const term = new mods.Terminal({ cursorBlink: true, convertEol: true, allowProposedApi: true, @@ -84,9 +86,9 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter scrollback: 10000, }); - const fitAddon = new FitAddon(); - const searchAddon = new SearchAddon(); - const serializeAddon = new SerializeAddon(); + const fitAddon = new mods.FitAddon(); + const searchAddon = new mods.SearchAddon(); + const serializeAddon = new mods.SerializeAddon(); term.loadAddon(fitAddon); term.loadAddon(searchAddon); @@ -167,7 +169,7 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter }; // Initialize terminal after a small delay to ensure container is rendered - const timeoutId = setTimeout(initTerminal, 50); + const timeoutId = setTimeout(() => { void initTerminal(); }, 50); // Attach ResizeObserver to the terminal's parent container let resizeTimeout: number | undefined; diff --git a/frontend/src/lib/xtermLoader.ts b/frontend/src/lib/xtermLoader.ts new file mode 100644 index 00000000..b5cc4f2a --- /dev/null +++ b/frontend/src/lib/xtermLoader.ts @@ -0,0 +1,53 @@ +/** + * Lazy-loaded xterm. The terminal is only mounted by surfaces the user + * explicitly opens (TerminalComponent, BashExecModal, HostConsole), but + * the eager top-level imports used to pull the ~660 KB @xterm chunk plus + * xterm.css into every cold app start. Defer the chunk until the first + * terminal mount; the loader caches the imported modules so subsequent + * mounts hit a warm cache. + * + * All four addons live in the same `xterm` manual chunk (see + * vite.config.ts), so loading the namespace is the same network cost + * whether one or four classes are needed downstream. + */ + +export type Terminal = InstanceType; +export type FitAddon = InstanceType; +export type SearchAddon = InstanceType; +export type SerializeAddon = InstanceType; + +export interface XtermModules { + Terminal: typeof import('@xterm/xterm')['Terminal']; + FitAddon: typeof import('@xterm/addon-fit')['FitAddon']; + SearchAddon: typeof import('@xterm/addon-search')['SearchAddon']; + SerializeAddon: typeof import('@xterm/addon-serialize')['SerializeAddon']; +} + +let cachedPromise: Promise | null = null; + +export function loadXtermModules(): Promise { + if (!cachedPromise) { + cachedPromise = (async () => { + const [xtermMod, fitMod, searchMod, serializeMod] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + import('@xterm/addon-search'), + import('@xterm/addon-serialize'), + import('@xterm/xterm/css/xterm.css'), + ]); + return { + Terminal: xtermMod.Terminal, + FitAddon: fitMod.FitAddon, + SearchAddon: searchMod.SearchAddon, + SerializeAddon: serializeMod.SerializeAddon, + }; + })().catch((err) => { + // On a failed chunk fetch (transient network blip), drop the cached + // promise so the next terminal mount retries the load instead of + // permanently rethrowing. + cachedPromise = null; + throw err; + }); + } + return cachedPromise; +}