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 <Suspense>
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.
This commit is contained in:
Anso
2026-04-28 08:09:15 -04:00
committed by GitHub
parent f5dd8af7db
commit b5d038f395
6 changed files with 118 additions and 70 deletions
+27 -25
View File
@@ -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() {
)}
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { 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),
}}
/>
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { 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),
}}
/>
</Suspense>
)}
{isFileLoading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
+14 -12
View File
@@ -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({
</div>
<div className="flex-1 min-h-0">
<Editor
height="100%"
language={language}
value={content}
onChange={(val) => {
if (!readOnly) setContent(val ?? '');
}}
theme={isDarkMode ? 'vs-dark' : 'light'}
options={editorOptions}
/>
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<Editor
height="100%"
language={language}
value={content}
onChange={(val) => {
if (!readOnly) setContent(val ?? '');
}}
theme={isDarkMode ? 'vs-dark' : 'light'}
options={editorOptions}
/>
</Suspense>
</div>
</div>
);
@@ -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: () => <div data-testid="monaco-editor" />,
// 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: () => <div data-testid="monaco-editor" />,
DiffEditor: () => <div data-testid="monaco-diff-editor" />,
}));
vi.mock('@/lib/stackFilesApi', () => ({
@@ -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({
<div className="px-6 pb-4 pt-3">
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
<DiffEditor
height="100%"
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
original={currentValue}
modified={incomingValue}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontFamily: "'Geist Mono', monospace",
fontSize: 12,
}}
/>
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<DiffEditor
height="100%"
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
original={currentValue}
modified={incomingValue}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontFamily: "'Geist Mono', monospace",
fontSize: 12,
}}
/>
</Suspense>
</div>
</div>