From b5d038f395d4909ecd1a35ad7feca65262f3fb97 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 28 Apr 2026 08:09:15 -0400 Subject: [PATCH] perf(frontend): lazy-load Monaco editor + diff editor (#824) Monaco-editor and @monaco-editor/react were imported eagerly from main.tsx so that the locally-bundled Monaco was registered with @monaco-editor/react (CSP blocks the default CDN load). This pulled the ~3 MB monaco chunk into every cold app start regardless of whether a user ever opened the editor. Move the Monaco setup into a new frontend/src/lib/monacoLoader.tsx module that exports React.lazy-wrapped Editor and DiffEditor components. The lazy factory awaits a one-shot setupMonaco() that dynamic-imports monaco-editor, @monaco-editor/react, and the editor worker, then calls loader.config({ monaco }) and sets window.MonacoEnvironment before resolving the underlying component. Concurrent first mounts share a single setup promise so the work runs at most once per process. The three consumers (EditorLayout, FileViewer, GitSourceDiffDialog) wrap their editor in with a transparent fallback that preserves layout while the chunk loads. main.tsx loses three eager imports plus the MonacoEnvironment + loader.config bootstrap. The vite.config.ts manualChunks group from PR #823 was already prepared for this; the monaco chunk now loads on demand instead of being bundled into the entry chunk. --- frontend/src/components/EditorLayout.tsx | 52 ++++++++++--------- frontend/src/components/files/FileViewer.tsx | 26 +++++----- .../files/__tests__/FileViewer.test.tsx | 8 ++- .../components/stack/GitSourceDiffDialog.tsx | 36 +++++++------ frontend/src/lib/monacoLoader.tsx | 52 +++++++++++++++++++ frontend/src/main.tsx | 14 ----- 6 files changed, 118 insertions(+), 70 deletions(-) create mode 100644 frontend/src/lib/monacoLoader.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7585c1c9..4fbb1da2 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback, Suspense } from 'react'; type Theme = 'light' | 'dark' | 'auto'; -import Editor from '@monaco-editor/react'; +import { Editor } from '@/lib/monacoLoader'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; @@ -2841,29 +2841,31 @@ export default function EditorLayout() { )}
{!isFileLoading && ( - { monacoEditorRef.current = editor; }} - onChange={(value) => { - if (!isEditing) return; // Prevent changes in view mode - if (activeTab === 'compose') { - setContent(value || ''); - } else { - setEnvContent(value || ''); - } - }} - options={{ - minimap: { enabled: false }, - fontFamily: "'Geist Mono', monospace", - fontSize: 14, - padding: { top: 10 }, - scrollBeyondLastLine: false, - readOnly: !isEditing || !can('stack:edit', 'stack', stackName), - }} - /> + }> + { monacoEditorRef.current = editor; }} + onChange={(value) => { + if (!isEditing) return; // Prevent changes in view mode + if (activeTab === 'compose') { + setContent(value || ''); + } else { + setEnvContent(value || ''); + } + }} + options={{ + minimap: { enabled: false }, + fontFamily: "'Geist Mono', monospace", + fontSize: 14, + padding: { top: 10 }, + scrollBeyondLastLine: false, + readOnly: !isEditing || !can('stack:edit', 'stack', stackName), + }} + /> + )} {isFileLoading && (
diff --git a/frontend/src/components/files/FileViewer.tsx b/frontend/src/components/files/FileViewer.tsx index c3c4461b..91e123d2 100644 --- a/frontend/src/components/files/FileViewer.tsx +++ b/frontend/src/components/files/FileViewer.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useMemo } from 'react'; -import Editor from '@monaco-editor/react'; +import { useState, useEffect, useMemo, Suspense } from 'react'; +import { Editor } from '@/lib/monacoLoader'; import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; @@ -276,16 +276,18 @@ export function FileViewer({
- { - if (!readOnly) setContent(val ?? ''); - }} - theme={isDarkMode ? 'vs-dark' : 'light'} - options={editorOptions} - /> + }> + { + if (!readOnly) setContent(val ?? ''); + }} + theme={isDarkMode ? 'vs-dark' : 'light'} + options={editorOptions} + /> +
); diff --git a/frontend/src/components/files/__tests__/FileViewer.test.tsx b/frontend/src/components/files/__tests__/FileViewer.test.tsx index 2d7b1e36..f669d7ed 100644 --- a/frontend/src/components/files/__tests__/FileViewer.test.tsx +++ b/frontend/src/components/files/__tests__/FileViewer.test.tsx @@ -9,8 +9,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import type { FileContentResult } from '@/lib/stackFilesApi'; -vi.mock('@monaco-editor/react', () => ({ - default: () =>
, +// FileViewer now imports `Editor` from the lazy loader, not directly from +// @monaco-editor/react. Mock the loader so tests skip Monaco's setup path +// and the editor renders synchronously. +vi.mock('@/lib/monacoLoader', () => ({ + Editor: () =>
, + DiffEditor: () =>
, })); vi.mock('@/lib/stackFilesApi', () => ({ diff --git a/frontend/src/components/stack/GitSourceDiffDialog.tsx b/frontend/src/components/stack/GitSourceDiffDialog.tsx index 27030a7b..5188c462 100644 --- a/frontend/src/components/stack/GitSourceDiffDialog.tsx +++ b/frontend/src/components/stack/GitSourceDiffDialog.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { DiffEditor } from '@monaco-editor/react'; +import { useState, Suspense } from 'react'; +import { DiffEditor } from '@/lib/monacoLoader'; import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; @@ -124,21 +124,23 @@ export function GitSourceDiffDialog({
- + }> + +
diff --git a/frontend/src/lib/monacoLoader.tsx b/frontend/src/lib/monacoLoader.tsx new file mode 100644 index 00000000..72ac30a4 --- /dev/null +++ b/frontend/src/lib/monacoLoader.tsx @@ -0,0 +1,52 @@ +import { lazy } from 'react'; + +/** + * Lazy-loaded Monaco. Replaces the eager imports that used to live in + * `main.tsx`. The 3 MB monaco-editor + @monaco-editor/react chunk no longer + * loads on cold app start; it loads the first time any consumer renders an + * editor, and subsequent editor mounts reuse the already-loaded module. + * + * `setupMonaco` registers the locally bundled Monaco with @monaco-editor/react + * (so it does not fetch from the CDN, which the CSP `script-src 'self'` blocks) + * and wires the editor worker. The setup runs at most once per process; the + * shared promise dedupes concurrent first mounts. + */ + +declare global { + interface Window { + MonacoEnvironment?: { getWorker: (workerId: string, label: string) => Worker }; + } +} + +let setupPromise: Promise | null = null; + +function setupMonaco(): Promise { + if (!setupPromise) { + setupPromise = (async () => { + const [monacoMod, reactMonaco, editorWorkerMod] = await Promise.all([ + import('monaco-editor'), + import('@monaco-editor/react'), + import('monaco-editor/esm/vs/editor/editor.worker?worker'), + ]); + window.MonacoEnvironment = { + getWorker(): Worker { + return new editorWorkerMod.default(); + }, + }; + reactMonaco.loader.config({ monaco: monacoMod }); + })(); + } + return setupPromise; +} + +export const Editor = lazy(async () => { + await setupMonaco(); + const mod = await import('@monaco-editor/react'); + return { default: mod.default }; +}); + +export const DiffEditor = lazy(async () => { + await setupMonaco(); + const mod = await import('@monaco-editor/react'); + return { default: mod.DiffEditor }; +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e648c8dc..21bb4e6d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,20 +4,6 @@ import './index.css' import App from './App.tsx' import ErrorBoundary from './components/ErrorBoundary.tsx' import { initializeDensity } from './hooks/use-density' -import * as monaco from 'monaco-editor' -import { loader } from '@monaco-editor/react' -import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' - -// Use the locally bundled Monaco instead of fetching from cdn.jsdelivr.net. -// The CSP (scriptSrc: 'self') blocks external CDN scripts; bundling avoids -// that entirely. Sencho only needs YAML/plaintext so the base editorWorker -// covers all language modes - no additional language workers required. -window.MonacoEnvironment = { - getWorker(): Worker { - return new editorWorker() - }, -} -loader.config({ monaco }) initializeDensity()