Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadOnlyCodeBlock } from './ReadOnlyCodeBlock';

const mocks = vi.hoisted(() => ({
initialize: vi.fn(),
createModel: vi.fn(),
create: vi.fn(),
}));

vi.mock('../../services/MonacoInitManager', () => ({
monacoInitManager: { initialize: mocks.initialize },
}));

vi.mock('../../services/monacoRuntime', () => ({
monacoApi: {
editor: {
create: mocks.create,
setModelLanguage: vi.fn(),
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { KeyS: 49 },
},
}));

vi.mock('../../services/MonacoModelManager', () => ({
monacoModelManager: {
getOrCreateModel: mocks.createModel,
releaseModel: vi.fn(),
},
}));

vi.mock('../../services/EditorExtensionManager', () => ({
editorExtensionManager: {
notifyEditorCreated: () => 'editor-1',
notifyEditorWillDispose: vi.fn(),
notifyContentChanged: vi.fn(),
},
}));

vi.mock('../../services/ActiveEditTargetService', () => ({
activeEditTargetService: {
bindTarget: () => () => {},
setActiveTarget: vi.fn(),
clearActiveTarget: vi.fn(),
},
createMonacoEditTarget: () => ({ id: 'mock-target' }),
}));

vi.mock('@/infrastructure/appearance/adapters/MonacoAppearanceAdapter', () => ({
monacoAppearanceAdapter: {
attachMonaco: () => 'test-theme',
getCurrentThemeId: () => 'test-theme',
},
}));

vi.mock('../../services/EditorOptionsBuilder', () => ({
buildEditorOptions: () => ({}),
}));

describe('ReadOnlyCodeBlock lazy loading', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let intersectionCallbacks: IntersectionObserverCallback[] = [];

beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
intersectionCallbacks = [];

class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null;
readonly rootMargin: string = '';
readonly thresholds: ReadonlyArray<number> = [];
constructor(private cb: IntersectionObserverCallback) {
intersectionCallbacks.push(cb);
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] { return []; }
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);

const mockModel = {
getValue: () => 'const x = 1;',
getLanguageId: () => 'typescript',
onDidChangeContent: () => ({ dispose() {} }),
dispose: vi.fn(),
};
const mockEditor = {
onDidChangeCursorPosition: () => ({ dispose() {} }),
onDidChangeCursorSelection: () => ({ dispose() {} }),
onDidFocusEditorText: () => ({ dispose() {} }),
onDidBlurEditorText: () => ({ dispose() {} }),
addCommand: vi.fn(),
updateOptions: vi.fn(),
dispose: vi.fn(),
};

mocks.initialize.mockResolvedValue({});
mocks.createModel.mockReturnValue(mockModel);
mocks.create.mockReturnValue(mockEditor);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it('defers Monaco initialization when lazy=true until intersecting', async () => {
await act(async () => {
root.render(
<ReadOnlyCodeBlock content="const x = 1;" language="typescript" lazy={true} />
);
});

// Not intersecting yet, Monaco should not be initialized
expect(mocks.initialize).not.toHaveBeenCalled();
expect(mocks.create).not.toHaveBeenCalled();

// Trigger viewport intersection
expect(intersectionCallbacks.length).toBe(1);
await act(async () => {
intersectionCallbacks[0](
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver
);
});

expect(mocks.initialize).toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalled();
});

it('initializes immediately when lazy=false', async () => {
await act(async () => {
root.render(
<ReadOnlyCodeBlock content="const x = 1;" language="typescript" lazy={false} />
);
});

expect(mocks.initialize).toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface ReadOnlyCodeBlockProps {
style?: React.CSSProperties;
/** Config overrides */
config?: EditorConfigPartial;
/** Whether to defer loading Monaco until visible in viewport (defaults to true) */
lazy?: boolean;
/** Click handler */
onClick?: () => void;
/** Editor ready callback */
Expand All @@ -54,6 +56,7 @@ export const ReadOnlyCodeBlock: React.FC<ReadOnlyCodeBlockProps> = ({
className = '',
style,
config,
lazy = true,
onClick,
onReady,
}) => {
Expand Down Expand Up @@ -125,6 +128,7 @@ export const ReadOnlyCodeBlock: React.FC<ReadOnlyCodeBlockProps> = ({
readOnly={true}
showLineNumbers={showLineNumbers}
showMinimap={false}
lazy={lazy}
onEditorReady={handleEditorReady}
/>
</div>
Expand Down
23 changes: 21 additions & 2 deletions src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ export const MonacoEditorCore = forwardRef<MonacoEditorCoreRef, MonacoEditorCore
onSave,
className = '',
style,
lazy = false,
jumpToLine,
jumpToColumn,
jumpToRange,
} = props;

const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(!lazy);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const modelRef = useRef<monaco.editor.ITextModel | null>(null);
const editorIdRef = useRef<string>('');
Expand Down Expand Up @@ -160,7 +162,24 @@ export const MonacoEditorCore = forwardRef<MonacoEditorCoreRef, MonacoEditorCore
}, [createExtensionContext]);

useEffect(() => {
if (!containerRef.current) return;
if (!lazy || isVisible || !containerRef.current) return;
const el = containerRef.current;
if (typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
}, { rootMargin: '200px' });
observer.observe(el);
return () => observer.disconnect();
}, [lazy, isVisible]);

useEffect(() => {
if (!containerRef.current || !isVisible) return;

isUnmountedRef.current = false;
hasJumpedRef.current = false;
Expand Down Expand Up @@ -278,7 +297,7 @@ export const MonacoEditorCore = forwardRef<MonacoEditorCoreRef, MonacoEditorCore

setIsReady(false);
};
}, [filePath, createExtensionContext, registerEventListeners]);
}, [filePath, isVisible, createExtensionContext, registerEventListeners]);

useEffect(() => {
if (!isReady || !editorRef.current || hasJumpedRef.current) return;
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/tools/editor/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface MonacoEditorCoreProps {
// Other
className?: string;
style?: React.CSSProperties;
/** Defer editor instantiation until the container intersects the viewport */
lazy?: boolean;
/** @deprecated Use jumpToRange */
jumpToLine?: number;
/** @deprecated Use jumpToRange */
Expand Down
Loading