mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
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:
@@ -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';
|
type Theme = 'light' | 'dark' | 'auto';
|
||||||
import Editor from '@monaco-editor/react';
|
import { Editor } from '@/lib/monacoLoader';
|
||||||
import TerminalComponent from './Terminal';
|
import TerminalComponent from './Terminal';
|
||||||
import ErrorBoundary from './ErrorBoundary';
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
import HomeDashboard from './HomeDashboard';
|
import HomeDashboard from './HomeDashboard';
|
||||||
@@ -2841,29 +2841,31 @@ export default function EditorLayout() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{!isFileLoading && (
|
{!isFileLoading && (
|
||||||
<Editor
|
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
|
||||||
height="100%"
|
<Editor
|
||||||
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
|
height="100%"
|
||||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
|
||||||
value={activeTab === 'compose' ? safeContent : safeEnvContent}
|
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||||
onMount={(editor) => { monacoEditorRef.current = editor; }}
|
value={activeTab === 'compose' ? safeContent : safeEnvContent}
|
||||||
onChange={(value) => {
|
onMount={(editor) => { monacoEditorRef.current = editor; }}
|
||||||
if (!isEditing) return; // Prevent changes in view mode
|
onChange={(value) => {
|
||||||
if (activeTab === 'compose') {
|
if (!isEditing) return; // Prevent changes in view mode
|
||||||
setContent(value || '');
|
if (activeTab === 'compose') {
|
||||||
} else {
|
setContent(value || '');
|
||||||
setEnvContent(value || '');
|
} else {
|
||||||
}
|
setEnvContent(value || '');
|
||||||
}}
|
}
|
||||||
options={{
|
}}
|
||||||
minimap: { enabled: false },
|
options={{
|
||||||
fontFamily: "'Geist Mono', monospace",
|
minimap: { enabled: false },
|
||||||
fontSize: 14,
|
fontFamily: "'Geist Mono', monospace",
|
||||||
padding: { top: 10 },
|
fontSize: 14,
|
||||||
scrollBeyondLastLine: false,
|
padding: { top: 10 },
|
||||||
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
|
scrollBeyondLastLine: false,
|
||||||
}}
|
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{isFileLoading && (
|
{isFileLoading && (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||||
import Editor from '@monaco-editor/react';
|
import { Editor } from '@/lib/monacoLoader';
|
||||||
import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react';
|
import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -276,16 +276,18 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<Editor
|
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
|
||||||
height="100%"
|
<Editor
|
||||||
language={language}
|
height="100%"
|
||||||
value={content}
|
language={language}
|
||||||
onChange={(val) => {
|
value={content}
|
||||||
if (!readOnly) setContent(val ?? '');
|
onChange={(val) => {
|
||||||
}}
|
if (!readOnly) setContent(val ?? '');
|
||||||
theme={isDarkMode ? 'vs-dark' : 'light'}
|
}}
|
||||||
options={editorOptions}
|
theme={isDarkMode ? 'vs-dark' : 'light'}
|
||||||
/>
|
options={editorOptions}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import type { FileContentResult } from '@/lib/stackFilesApi';
|
import type { FileContentResult } from '@/lib/stackFilesApi';
|
||||||
|
|
||||||
vi.mock('@monaco-editor/react', () => ({
|
// FileViewer now imports `Editor` from the lazy loader, not directly from
|
||||||
default: () => <div data-testid="monaco-editor" />,
|
// @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', () => ({
|
vi.mock('@/lib/stackFilesApi', () => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import { DiffEditor } from '@monaco-editor/react';
|
import { DiffEditor } from '@/lib/monacoLoader';
|
||||||
import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react';
|
import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
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';
|
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="px-6 pb-4 pt-3">
|
||||||
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
|
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
|
||||||
<DiffEditor
|
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
|
||||||
height="100%"
|
<DiffEditor
|
||||||
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
|
height="100%"
|
||||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
|
||||||
original={currentValue}
|
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||||
modified={incomingValue}
|
original={currentValue}
|
||||||
options={{
|
modified={incomingValue}
|
||||||
readOnly: true,
|
options={{
|
||||||
renderSideBySide: true,
|
readOnly: true,
|
||||||
minimap: { enabled: false },
|
renderSideBySide: true,
|
||||||
scrollBeyondLastLine: false,
|
minimap: { enabled: false },
|
||||||
fontFamily: "'Geist Mono', monospace",
|
scrollBeyondLastLine: false,
|
||||||
fontSize: 12,
|
fontFamily: "'Geist Mono', monospace",
|
||||||
}}
|
fontSize: 12,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> | null = null;
|
||||||
|
|
||||||
|
function setupMonaco(): Promise<void> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
@@ -4,20 +4,6 @@ import './index.css'
|
|||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import ErrorBoundary from './components/ErrorBoundary.tsx'
|
import ErrorBoundary from './components/ErrorBoundary.tsx'
|
||||||
import { initializeDensity } from './hooks/use-density'
|
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()
|
initializeDensity()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user