From ea118ae4bc0427b2e127321ccdf605e0fbb9c201 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Sat, 7 Nov 2026 22:12:25 +0800 Subject: [PATCH] perf(editor): add lazy viewport loading for MonacoEditorCore and ReadOnlyCodeBlock --- .../ReadOnlyCodeBlock.test.tsx | 151 ++++++++++++++++++ .../ReadOnlyCodeBlock/ReadOnlyCodeBlock.tsx | 4 + .../tools/editor/core/MonacoEditorCore.tsx | 23 ++- src/web-ui/src/tools/editor/core/types.ts | 2 + 4 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.test.tsx diff --git a/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.test.tsx b/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.test.tsx new file mode 100644 index 0000000000..864f43aeac --- /dev/null +++ b/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.test.tsx @@ -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; + 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 = []; + 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( + + ); + }); + + // 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( + + ); + }); + + expect(mocks.initialize).toHaveBeenCalled(); + expect(mocks.create).toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.tsx b/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.tsx index 7bbafc9b7b..846cf72c67 100644 --- a/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.tsx +++ b/src/web-ui/src/tools/editor/components/ReadOnlyCodeBlock/ReadOnlyCodeBlock.tsx @@ -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 */ @@ -54,6 +56,7 @@ export const ReadOnlyCodeBlock: React.FC = ({ className = '', style, config, + lazy = true, onClick, onReady, }) => { @@ -125,6 +128,7 @@ export const ReadOnlyCodeBlock: React.FC = ({ readOnly={true} showLineNumbers={showLineNumbers} showMinimap={false} + lazy={lazy} onEditorReady={handleEditorReady} /> diff --git a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx index a021cabea3..ceb9ba8258 100644 --- a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx +++ b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx @@ -54,12 +54,14 @@ export const MonacoEditorCore = forwardRef(null); + const [isVisible, setIsVisible] = useState(!lazy); const editorRef = useRef(null); const modelRef = useRef(null); const editorIdRef = useRef(''); @@ -160,7 +162,24 @@ export const MonacoEditorCore = forwardRef { - 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; @@ -278,7 +297,7 @@ export const MonacoEditorCore = forwardRef { if (!isReady || !editorRef.current || hasJumpedRef.current) return; diff --git a/src/web-ui/src/tools/editor/core/types.ts b/src/web-ui/src/tools/editor/core/types.ts index fb4f24455e..439b495cf9 100644 --- a/src/web-ui/src/tools/editor/core/types.ts +++ b/src/web-ui/src/tools/editor/core/types.ts @@ -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 */