From 6d3d5f1362a4d566eb1e0541c37ab1b2c9fb301b Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Tue, 9 Dec 2025 17:49:14 +0900 Subject: [PATCH 1/6] fix: update dependency array in CodeMirrorEditor to include filePath and editable - Ensure the editor re-renders correctly when the document's filePath or editable state changes --- app/components/editor/codemirror/CodeMirrorEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 3409e2be60..8b22db63f7 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -773,7 +773,7 @@ export const CodeMirrorEditor = memo( doc as TextEditorDocument, recreateEditorView, ); - }); + }, [doc?.filePath, editable]); // Render From 2490f6acee82700d9fe24edd8e1989ea430a2115 Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Tue, 9 Dec 2025 18:09:02 +0900 Subject: [PATCH 2/6] fix: update dependency array in CodeMirrorEditor to include document value - Ensure the editor re-renders correctly when the document's value changes, in addition to filePath and editable state --- app/components/editor/codemirror/CodeMirrorEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 8b22db63f7..6210a17e9d 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -773,7 +773,7 @@ export const CodeMirrorEditor = memo( doc as TextEditorDocument, recreateEditorView, ); - }, [doc?.filePath, editable]); + }, [doc?.value, doc?.filePath, editable]); // Render From 08a7cd2a6f2e09083e64dc1a983c425effc93f2a Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Tue, 9 Dec 2025 18:15:07 +0900 Subject: [PATCH 3/6] fix: enhance file change detection in CodeMirrorEditor - Introduce a check to detect changes in the file path before applying the editor state - Ensure the editor state is only updated when the file path has changed, improving performance and reducing unnecessary renders --- app/components/editor/codemirror/CodeMirrorEditor.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 6210a17e9d..ab94f9b3f9 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -749,7 +749,8 @@ export const CodeMirrorEditor = memo( logger.warn(EDITOR_MESSAGES.EMPTY_FILE_PATH); } - prevFilePathRef.current = doc.filePath; + // Detect file change + const isFileChanged = prevFilePathRef.current !== doc.filePath; // Get or create editor state for this file let state = editorStates.get(doc.filePath); @@ -761,8 +762,11 @@ export const CodeMirrorEditor = memo( editorStates.set(doc.filePath, state); } - // Apply state and load document - view.setState(state); + if (isFileChanged) { + view.setState(state); + } + + prevFilePathRef.current = doc.filePath; setEditorDocument( view, From 747a0f01cf8dca3667cf69919bc95aa697b419a8 Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Wed, 10 Dec 2025 16:43:00 +0900 Subject: [PATCH 4/6] refactor: streamline CodeMirrorEditor logic and improve document handling - Remove unnecessary comments and consolidate type definitions for clarity. - Update transaction handling to use a more consistent dispatch method. - Simplify document update logic and state management, ensuring proper handling of editor states and selections. - Enhance error handling during document and theme updates to improve robustness. --- .../editor/codemirror/CodeMirrorEditor.tsx | 402 ++++++++---------- 1 file changed, 169 insertions(+), 233 deletions(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index ab94f9b3f9..861efbb3a5 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -1,9 +1,3 @@ -/* - * ============================================================================ - * 1. IMPORTS & EXTERNAL DEPENDENCIES - * ============================================================================ - */ - import { acceptCompletion, autocompletion, closeBrackets } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemirror/language'; @@ -16,6 +10,7 @@ import { StateField, Transaction, type Extension, + type TransactionSpec, } from '@codemirror/state'; import { drawSelection, @@ -29,7 +24,7 @@ import { tooltips, type Tooltip, } from '@codemirror/view'; -import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react'; +import { memo, useEffect, useRef, useState, type RefObject } from 'react'; import type { Theme } from '~/types/theme'; import { classNames } from '~/utils/classNames'; import { debounce } from '~/utils/debounce'; @@ -39,12 +34,6 @@ import { getTheme, reconfigureTheme } from './cm-theme'; import { indentKeyBinding } from './indent'; import { getLanguage } from './languages'; -/* - * ============================================================================ - * 2. CONSTANTS & CONFIGURATION - * ============================================================================ - */ - const logger = createScopedLogger('CodeMirrorEditor'); // Editor default values to eliminate magic numbers @@ -72,16 +61,11 @@ const EDITOR_MESSAGES = { FOCUS_FAILED: 'Failed to set focus', SCROLL_FAILED: 'Failed to set scroll position', SCROLL_RESET_FAILED: 'Failed to reset scroll position', + DISPATCH_FAILED: 'Error in dispatching transaction', AI_COMPLETION_SYNC: 'Final state synchronization', AI_COMPLETION_FAILED: 'Final state synchronization failed', } as const; -/* - * ============================================================================ - * 3. TYPE DEFINITIONS - * ============================================================================ - */ - export interface EditorDocument { value: string; isBinary: boolean; @@ -112,7 +96,6 @@ export type OnSaveCallback = () => void; interface CodeMirrorEditorProps { theme: Theme; - id?: unknown; doc?: EditorDocument; editable?: boolean; debounceChange?: number; @@ -128,12 +111,6 @@ interface CodeMirrorEditorProps { type TextEditorDocument = EditorDocument & { value: string }; type EditorStates = Map; -/* - * ============================================================================ - * 4. CODEMIRROR STATE & EFFECTS - * ============================================================================ - */ - const readOnlyTooltipStateEffect = StateEffect.define(); const editableStateEffect = StateEffect.define(); @@ -204,42 +181,25 @@ function getReadOnlyTooltip(state: EditorState): Tooltip[] { })); } -/* - * ============================================================================ - * 5. UTILITY FUNCTIONS - * ============================================================================ - */ - // Check if editor view is available function isViewAvailable(view: EditorView): boolean { - return !!(view && view.dom && view.dom.isConnected); + return Boolean(view?.dom?.isConnected); } -// Safe dispatch execution -function safeDispatch(view: EditorView, spec: any, operation: string, recreateViewFn?: () => void): boolean { +// Dispatch transaction to view (throws on failure) +function dispatchToView(view: EditorView, transaction: TransactionSpec): void { if (!isViewAvailable(view)) { - logger.warn(`${EDITOR_MESSAGES.VIEW_NOT_AVAILABLE}: ${operation}`); - recreateViewFn?.(); - - return false; + throw new Error(EDITOR_MESSAGES.VIEW_NOT_AVAILABLE); } - try { - view.dispatch(spec); - return true; - } catch (error) { - logger.error(`Error in ${operation}, recreating view:`, error); - recreateViewFn?.(); - - return false; - } + view.dispatch(transaction); } // Create common dispatchTransactions logic function createDispatchTransactions( onUpdate: (update: EditorUpdate) => void, - editorStatesRef: MutableRefObject, - docRef: MutableRefObject, + editorStatesRef: RefObject, + doc: EditorDocument | undefined, isRecreating = false, ) { return function dispatchTransactions(this: EditorView, transactions: readonly Transaction[]) { @@ -257,7 +217,7 @@ function createDispatchTransactions( (newSelection === undefined || previousSelection === undefined || !newSelection.eq(previousSelection)); // Only notify of changes if we have a document and something actually changed - if (docRef.current && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) { + if (doc && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) { const documentChanged = transactions.some((transaction) => transaction.docChanged); const shouldNotify = documentChanged || !isRecreating; @@ -265,13 +225,13 @@ function createDispatchTransactions( onUpdate({ selection: view.state.selection, content: view.state.doc.toString(), - filePath: docRef.current.filePath, + filePath: doc.filePath, }); } // Save the current state for this file path - if (editorStatesRef.current && docRef.current.filePath) { - editorStatesRef.current.set(docRef.current.filePath, view.state); + if (editorStatesRef.current && doc.filePath) { + editorStatesRef.current.set(doc.filePath, view.state); } } @@ -279,102 +239,100 @@ function createDispatchTransactions( }; } -// Set empty document -function setNoDocument(view: EditorView, recreateViewFn?: () => void) { - // Clear all content and reset cursor to start - safeDispatch( - view, - { - selection: { anchor: 0 }, - changes: { - from: 0, - to: view.state.doc.length, - insert: '', - }, - }, - 'clear document', - recreateViewFn, - ); - - // Reset scroll position to top-left - try { - view.scrollDOM.scrollTo(0, 0); - } catch (error) { - logger.warn(EDITOR_MESSAGES.SCROLL_RESET_FAILED, error); - } -} - -// Set editor document +// Set editor document (returns false if view recreation is needed) function setEditorDocument( - view: EditorView, + viewRef: RefObject, theme: Theme, editable: boolean, languageCompartment: Compartment, autoFocus: boolean, doc: TextEditorDocument, - recreateViewFn?: () => void, -) { - const needsContentUpdate = doc.value !== view.state.doc.toString(); - const newEditableState = editable && !doc.isBinary; +): boolean { + const editorView = viewRef.current; - if (needsContentUpdate) { - safeDispatch( - view, - { - selection: { anchor: 0 }, - changes: { from: 0, to: view.state.doc.length, insert: doc.value }, - effects: [editableStateEffect.of(newEditableState)], - }, - 'document and state update', - recreateViewFn, - ); - } else { - safeDispatch( - view, - { effects: [editableStateEffect.of(newEditableState)] }, - 'editable state update', - recreateViewFn, - ); + if (!editorView) { + return false; } - const scheduleScrollAndFocus = () => { - if (!isViewAvailable(view)) { - logger.warn(EDITOR_MESSAGES.VIEW_UNAVAILABLE_LAYOUT); - recreateViewFn?.(); + const needsContentUpdate = doc.value !== editorView.state.doc.toString(); + const newEditableState = editable && !doc.isBinary; + let transaction; - return; + if (needsContentUpdate) { + transaction = { + selection: { anchor: 0 }, + changes: { from: 0, to: editorView.state.doc.length, insert: doc.value }, + effects: [editableStateEffect.of(newEditableState)], + }; + + try { + dispatchToView(editorView, transaction); + } catch (error) { + logger.warn('setEditorDocument: document and state update failed', error); + return false; } + } else { + transaction = { effects: [editableStateEffect.of(newEditableState)] }; - // requestAnimationFrame to ensure the scroll and focus are performed after the layout is completed - requestAnimationFrame(() => handleScrollAndFocus(view, autoFocus, editable, doc)); - }; + try { + dispatchToView(editorView, transaction); + } catch (error) { + logger.warn('setEditorDocument: editable state update failed', error); + return false; + } + } getLanguage(doc.filePath) .then((languageSupport) => { + const editorView = viewRef.current; + + if (!editorView) { + return; + } + if (!languageSupport) { - scheduleScrollAndFocus(); + scheduleScrollAndFocus(editorView, autoFocus, editable, doc); return; } - const success = safeDispatch( - view, - { + try { + dispatchToView(editorView, { effects: [languageCompartment.reconfigure([languageSupport]), reconfigureTheme(theme)], - }, - 'language configuration', - recreateViewFn, - ); - - if (!success) { - logger.warn('Language configuration failed'); + }); + } catch (error) { + logger.warn('setEditorDocument: language configuration failed', error); + return; } - scheduleScrollAndFocus(); + scheduleScrollAndFocus(editorView, autoFocus, editable, doc); }) .catch((error) => { - logger.error(EDITOR_MESSAGES.LANGUAGE_LOAD_ERROR, error); - scheduleScrollAndFocus(); + logger.warn(EDITOR_MESSAGES.LANGUAGE_LOAD_ERROR, error); + + const editorView = viewRef.current; + + if (!editorView) { + return false; + } + + scheduleScrollAndFocus(editorView, autoFocus, editable, doc); + + return true; }); + + return true; +} + +// Schedule scroll and focus after layout +function scheduleScrollAndFocus(view: EditorView, autoFocus: boolean, editable: boolean, doc: TextEditorDocument) { + if (!isViewAvailable(view)) { + logger.warn(EDITOR_MESSAGES.VIEW_UNAVAILABLE_LAYOUT); + + return; + } + + // requestAnimationFrame to ensure the scroll and focus are performed after the layout is completed + requestAnimationFrame(() => handleScrollAndFocus(view, autoFocus, editable, doc)); } function handleScrollAndFocus(view: EditorView, autoFocus: boolean, editable: boolean, doc: TextEditorDocument) { @@ -417,20 +375,14 @@ function handleScrollAndFocus(view: EditorView, autoFocus: boolean, editable: bo } } -/* - * ============================================================================ - * 6. EDITOR STATE CREATION - * ============================================================================ - */ - // Create new editor state with extensions function newEditorState( content: string, theme: Theme, settings: EditorSettings | undefined, - onScrollRef: MutableRefObject, + onScroll: OnScrollCallback | undefined, debounceScroll: number, - onFileSaveRef: MutableRefObject, + onSave: OnSaveCallback | undefined, extensions: Extension[], ) { return EditorState.create({ @@ -442,7 +394,7 @@ function newEditorState( return; } - onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop }); + onScroll?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop }); }, debounceScroll), keydown: (event, view) => { if (view.state.readOnly) { @@ -467,7 +419,7 @@ function newEditorState( key: 'Mod-s', preventDefault: true, run: () => { - onFileSaveRef.current?.(); + onSave?.(); return true; }, }, @@ -517,15 +469,8 @@ function newEditorState( }); } -/* - * ============================================================================ - * 7. MAIN COMPONENT - * ============================================================================ - */ - export const CodeMirrorEditor = memo( ({ - id, doc, debounceScroll = EDITOR_DEFAULTS.DEBOUNCE_SCROLL, debounceChange = EDITOR_DEFAULTS.DEBOUNCE_CHANGE, @@ -544,11 +489,9 @@ export const CodeMirrorEditor = memo( const [languageCompartment] = useState(new Compartment()); const containerRef = useRef(null); const viewRef = useRef(); - const docRef = useRef(); - const editorStatesRef = useRef(); - const onScrollRef = useRef(onScroll); - const onChangeRef = useRef(onChange); - const onSaveRef = useRef(onSave); + const editorStatesRef = useRef(new Map()); + const docValueRef = useRef(); + const docFilePathRef = useRef(); // Track previous editable state for AI completion detection const prevEditableRef = useRef(editable); @@ -583,17 +526,17 @@ export const CodeMirrorEditor = memo( // Create new view const onUpdate = debounce((update: EditorUpdate) => { - onChangeRef.current?.(update); + onChange?.(update); }, debounceChange); const newView = new EditorView({ parent: containerRef.current, - dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, docRef, true), + dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc, true), }); // Restore state if (currentDoc) { - const state = newEditorState(currentDoc, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ + const state = newEditorState(currentDoc, theme, settings, onScroll, debounceScroll, onSave, [ languageCompartment.of([]), ]); newView.setState(state); @@ -617,13 +560,13 @@ export const CodeMirrorEditor = memo( // Fallback: create minimal working view if (containerRef.current) { - const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ + const state = newEditorState('', theme, settings, onScroll, debounceScroll, onSave, [ languageCompartment.of([]), ]); const fallbackView = new EditorView({ parent: containerRef.current, state, - dispatchTransactions: createDispatchTransactions(() => undefined, editorStatesRef, docRef, true), + dispatchTransactions: createDispatchTransactions(() => undefined, editorStatesRef, doc, true), }); viewRef.current = fallbackView; } @@ -633,54 +576,15 @@ export const CodeMirrorEditor = memo( }; })(); - // Update callback references on every render - useEffect(() => { - onScrollRef.current = onScroll; - onChangeRef.current = onChange; - onSaveRef.current = onSave; - docRef.current = doc; - }); - - // AI completion detection and final state synchronization - useEffect(() => { - // Detect AI streaming completion (editable: false → true) - if (!prevEditableRef.current && editable && doc?.value && viewRef.current) { - // Use existing safeDispatch for safety and consistency - const success = safeDispatch( - viewRef.current, - { - changes: { - from: 0, - to: viewRef.current.state.doc.length, - insert: doc.value, - }, - selection: { anchor: doc.value.length }, // Move cursor to end of file - annotations: [Transaction.addToHistory.of(false)], // Don't add to undo history - }, - EDITOR_MESSAGES.AI_COMPLETION_SYNC, - recreateEditorView, - ); - - if (success) { - logger.info(EDITOR_MESSAGES.AI_COMPLETION_SYNC); - } else { - logger.warn(EDITOR_MESSAGES.AI_COMPLETION_FAILED); - } - } - - // Update previous editable state - prevEditableRef.current = editable; - }, [editable]); - // Initialize CodeMirror editor view (mount only) useEffect(() => { const onUpdate = debounce((update: EditorUpdate) => { - onChangeRef.current?.(update); + onChange?.(update); }, debounceChange); const view = new EditorView({ parent: containerRef.current!, - dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, docRef, false), + dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc, false), }); viewRef.current = view; @@ -692,48 +596,83 @@ export const CodeMirrorEditor = memo( }; }, []); + // AI completion detection and final state synchronization + useEffect(() => { + // Detect AI streaming completion (editable: false → true) + if (!prevEditableRef.current && editable && doc?.value && viewRef.current) { + const transaction: TransactionSpec = { + changes: { + from: 0, + to: viewRef.current.state.doc.length, + insert: doc.value, + }, + selection: { anchor: doc.value.length }, // Move cursor to end of file + annotations: [Transaction.addToHistory.of(false)], // Don't add to undo history. + effects: [editableStateEffect.of(true)], // Make editor editable. + }; + + try { + dispatchToView(viewRef.current, transaction); + logger.info(EDITOR_MESSAGES.AI_COMPLETION_SYNC); + } catch (error) { + logger.warn(EDITOR_MESSAGES.AI_COMPLETION_FAILED, error); + recreateEditorView(); + + return; + } + } + + // Update previous editable state + prevEditableRef.current = editable; + }, [editable]); + // Handle theme changes useEffect(() => { if (!viewRef.current) { return; } - safeDispatch( - viewRef.current, - { - effects: [reconfigureTheme(theme)], - }, - 'theme reconfiguration', - recreateEditorView, - ); + try { + dispatchToView(viewRef.current, { effects: [reconfigureTheme(theme)] }); + } catch (error) { + logger.warn('theme change failed', error); + recreateEditorView(); + } }, [theme]); - // Reset editor states on ID change - useEffect(() => { - editorStatesRef.current = new Map(); - }, [id]); - // Handle document changes and loading useEffect(() => { const editorStates = editorStatesRef.current; - const view = viewRef.current; + const editorView = viewRef.current; - if (!view || !editorStates) { + if (!editorView || !editorStates) { return; } // Skip during IME composition (CJK languages) - if (view.composing) { + if (editorView.composing) { return; } // Handle no document case if (!doc) { - const state = newEditorState('', theme, settings, onScrollRef, debounceScroll, onSaveRef, [ + const state = newEditorState('', theme, settings, onScroll, debounceScroll, onSave, [ languageCompartment.of([]), ]); - view.setState(state); - setNoDocument(view, recreateEditorView); + editorView.setState(state); + + // Clear document and reset scroll + try { + dispatchToView(editorView, { + selection: { anchor: 0 }, + changes: { from: 0, to: editorView.state.doc.length, insert: '' }, + }); + editorView.scrollDOM.scrollTo(0, 0); + } catch (error) { + logger.warn('Clear document and scroll reset failed', error); + recreateEditorView(); + } + prevFilePathRef.current = undefined; return; @@ -744,43 +683,48 @@ export const CodeMirrorEditor = memo( return; } + if (docValueRef.current === doc.value || docFilePathRef.current === doc.filePath) { + return; + } + + docValueRef.current = doc.value; + docFilePathRef.current = doc.filePath; + // Warn about empty file paths (affects language detection) if (doc.filePath === '') { logger.warn(EDITOR_MESSAGES.EMPTY_FILE_PATH); } - // Detect file change - const isFileChanged = prevFilePathRef.current !== doc.filePath; + prevFilePathRef.current = doc.filePath; // Get or create editor state for this file let state = editorStates.get(doc.filePath); if (!state) { - state = newEditorState(doc.value, theme, settings, onScrollRef, debounceScroll, onSaveRef, [ + state = newEditorState(doc.value, theme, settings, onScroll, debounceScroll, onSave, [ languageCompartment.of([]), ]); editorStates.set(doc.filePath, state); } - if (isFileChanged) { - view.setState(state); - } + // Apply state and load document + editorView.setState(state); - prevFilePathRef.current = doc.filePath; - - setEditorDocument( - view, + const success = setEditorDocument( + viewRef, theme, editable, languageCompartment, autoFocusOnDocumentChange, doc as TextEditorDocument, - recreateEditorView, ); - }, [doc?.value, doc?.filePath, editable]); - // Render + if (!success) { + recreateEditorView(); + } + }, [doc, autoFocusOnDocumentChange]); + // Render return (
{doc?.isBinary && } @@ -790,12 +734,4 @@ export const CodeMirrorEditor = memo( }, ); -/* - * ============================================================================ - * 8. COMPONENT EXPORT & DISPLAY NAME - * ============================================================================ - */ - export default CodeMirrorEditor; - -CodeMirrorEditor.displayName = 'CodeMirrorEditor'; From 72d16f9169ff8d25aae1cd76873dcd5c3e595cfa Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Wed, 10 Dec 2025 16:56:02 +0900 Subject: [PATCH 5/6] refactor: clean up editor messages in CodeMirrorEditor - Remove outdated and redundant messages related to view availability during layout and language loading. - Streamline the error messages to enhance clarity and maintainability. --- app/components/editor/codemirror/CodeMirrorEditor.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 861efbb3a5..1d73cef0c0 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -52,16 +52,11 @@ const EDITOR_MESSAGES = { VIEW_NOT_AVAILABLE: 'View not available for operation, recreating view', RECREATION_SKIPPED: 'Skipping recreation: already in progress or view not available', VIEW_UNAVAILABLE_LAYOUT: 'View not available for layout operation, recreating view', - VIEW_UNAVAILABLE_DURING_LAYOUT: 'View became unavailable during layout operation', RECREATION_SUCCESS: 'EditorView successfully recreated', RECREATION_FAILED: 'Failed to recreate EditorView', LANGUAGE_LOAD_ERROR: 'Error loading language support', - VIEW_UNAVAILABLE_AFTER_LANGUAGE: 'View no longer available after language loading', - SCROLL_FOCUS_UNAVAILABLE: 'View not available for scroll/focus operations', FOCUS_FAILED: 'Failed to set focus', SCROLL_FAILED: 'Failed to set scroll position', - SCROLL_RESET_FAILED: 'Failed to reset scroll position', - DISPATCH_FAILED: 'Error in dispatching transaction', AI_COMPLETION_SYNC: 'Final state synchronization', AI_COMPLETION_FAILED: 'Final state synchronization failed', } as const; From efaf1e411dd0ea15bed77f5439506e4da06b00f0 Mon Sep 17 00:00:00 2001 From: "sb.kim" Date: Wed, 10 Dec 2025 17:45:53 +0900 Subject: [PATCH 6/6] refactor: improve CodeMirrorEditor state management and view handling - Simplify the logic for checking view availability and update the method of handling editor view references. - Consolidate the editor view recreation process to enhance clarity and maintainability. - Streamline the document update logic by removing unnecessary checks and ensuring proper state restoration. - Enhance error handling during view recreation to provide fallback mechanisms when needed. --- .../editor/codemirror/CodeMirrorEditor.tsx | 176 +++++++++--------- 1 file changed, 84 insertions(+), 92 deletions(-) diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index 1d73cef0c0..0bbb755f25 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -178,7 +178,7 @@ function getReadOnlyTooltip(state: EditorState): Tooltip[] { // Check if editor view is available function isViewAvailable(view: EditorView): boolean { - return Boolean(view?.dom?.isConnected); + return !!(view && view.dom && view.dom.isConnected); } // Dispatch transaction to view (throws on failure) @@ -195,7 +195,6 @@ function createDispatchTransactions( onUpdate: (update: EditorUpdate) => void, editorStatesRef: RefObject, doc: EditorDocument | undefined, - isRecreating = false, ) { return function dispatchTransactions(this: EditorView, transactions: readonly Transaction[]) { const view = this; @@ -213,16 +212,11 @@ function createDispatchTransactions( // Only notify of changes if we have a document and something actually changed if (doc && (transactions.some((transaction) => transaction.docChanged) || selectionChanged)) { - const documentChanged = transactions.some((transaction) => transaction.docChanged); - const shouldNotify = documentChanged || !isRecreating; - - if (shouldNotify) { - onUpdate({ - selection: view.state.selection, - content: view.state.doc.toString(), - filePath: doc.filePath, - }); - } + onUpdate({ + selection: view.state.selection, + content: view.state.doc.toString(), + filePath: doc.filePath, + }); // Save the current state for this file path if (editorStatesRef.current && doc.filePath) { @@ -236,14 +230,14 @@ function createDispatchTransactions( // Set editor document (returns false if view recreation is needed) function setEditorDocument( - viewRef: RefObject, + editorViewRef: RefObject, theme: Theme, editable: boolean, languageCompartment: Compartment, autoFocus: boolean, doc: TextEditorDocument, ): boolean { - const editorView = viewRef.current; + const editorView = editorViewRef.current; if (!editorView) { return false; @@ -279,7 +273,7 @@ function setEditorDocument( getLanguage(doc.filePath) .then((languageSupport) => { - const editorView = viewRef.current; + const editorView = editorViewRef.current; if (!editorView) { return; @@ -304,7 +298,7 @@ function setEditorDocument( .catch((error) => { logger.warn(EDITOR_MESSAGES.LANGUAGE_LOAD_ERROR, error); - const editorView = viewRef.current; + const editorView = editorViewRef.current; if (!editorView) { return false; @@ -337,6 +331,9 @@ function handleScrollAndFocus(view: EditorView, autoFocus: boolean, editable: bo const newTop = doc.scroll?.top ?? 0; const needsScrolling = currentLeft !== newLeft || currentTop !== newTop; + // Force viewport recalculation to ensure all visible lines are rendered + view.requestMeasure(); + // Handle focus management for editable editors if (autoFocus && editable) { try { @@ -483,7 +480,7 @@ export const CodeMirrorEditor = memo( // State and references const [languageCompartment] = useState(new Compartment()); const containerRef = useRef(null); - const viewRef = useRef(); + const editorViewRef = useRef(); const editorStatesRef = useRef(new Map()); const docValueRef = useRef(); const docFilePathRef = useRef(); @@ -494,82 +491,75 @@ export const CodeMirrorEditor = memo( // Track previous file path to detect file changes const prevFilePathRef = useRef(); - // EditorView recreation function (infinite recursion prevention) - const recreateEditorView = (() => { - let isRecreating = false; + // EditorView recreation function + const recreateEditorView = () => { + if (!editorViewRef.current || !containerRef.current) { + logger.warn(EDITOR_MESSAGES.RECREATION_SKIPPED); + return; + } - return () => { - if (isRecreating || !viewRef.current || !containerRef.current) { - logger.warn(EDITOR_MESSAGES.RECREATION_SKIPPED); - return; - } + logger.info('Starting EditorView recreation'); - isRecreating = true; - logger.info('Starting EditorView recreation'); + try { + // Backup current state + const currentDoc = editorViewRef.current.state.doc.toString(); + const scrollPos = { + left: editorViewRef.current.scrollDOM?.scrollLeft || 0, + top: editorViewRef.current.scrollDOM?.scrollTop || 0, + }; + const selection = editorViewRef.current.state.selection; - try { - // Backup current state - const currentDoc = viewRef.current.state.doc.toString(); - const scrollPos = { - left: viewRef.current.scrollDOM?.scrollLeft || 0, - top: viewRef.current.scrollDOM?.scrollTop || 0, - }; - const selection = viewRef.current.state.selection; + // Clean up existing view + editorViewRef.current.destroy(); - // Clean up existing view - viewRef.current.destroy(); + // Create new view + const onUpdate = debounce((update: EditorUpdate) => { + onChange?.(update); + }, debounceChange); - // Create new view - const onUpdate = debounce((update: EditorUpdate) => { - onChange?.(update); - }, debounceChange); + const newView = new EditorView({ + parent: containerRef.current, + dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc), + }); - const newView = new EditorView({ - parent: containerRef.current, - dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc, true), - }); + // Restore state + if (currentDoc) { + const state = newEditorState(currentDoc, theme, settings, onScroll, debounceScroll, onSave, [ + languageCompartment.of([]), + ]); + newView.setState(state); - // Restore state - if (currentDoc) { - const state = newEditorState(currentDoc, theme, settings, onScroll, debounceScroll, onSave, [ - languageCompartment.of([]), - ]); - newView.setState(state); + if (selection) { + newView.dispatch({ selection }); + } + } - if (selection) { - newView.dispatch({ selection }); - } + // Restore scroll position + requestAnimationFrame(() => { + if (newView.scrollDOM) { + newView.scrollDOM.scrollTo(scrollPos.left, scrollPos.top); } + }); - // Restore scroll position - requestAnimationFrame(() => { - if (newView.scrollDOM) { - newView.scrollDOM.scrollTo(scrollPos.left, scrollPos.top); - } + editorViewRef.current = newView; + logger.info(EDITOR_MESSAGES.RECREATION_SUCCESS); + } catch (error) { + logger.error(EDITOR_MESSAGES.RECREATION_FAILED, error); + + // Fallback: create minimal working view + if (containerRef.current) { + const state = newEditorState('', theme, settings, onScroll, debounceScroll, onSave, [ + languageCompartment.of([]), + ]); + const fallbackView = new EditorView({ + parent: containerRef.current, + state, + dispatchTransactions: createDispatchTransactions(() => undefined, editorStatesRef, doc), }); - - viewRef.current = newView; - logger.info(EDITOR_MESSAGES.RECREATION_SUCCESS); - } catch (error) { - logger.error(EDITOR_MESSAGES.RECREATION_FAILED, error); - - // Fallback: create minimal working view - if (containerRef.current) { - const state = newEditorState('', theme, settings, onScroll, debounceScroll, onSave, [ - languageCompartment.of([]), - ]); - const fallbackView = new EditorView({ - parent: containerRef.current, - state, - dispatchTransactions: createDispatchTransactions(() => undefined, editorStatesRef, doc, true), - }); - viewRef.current = fallbackView; - } - } finally { - isRecreating = false; + editorViewRef.current = fallbackView; } - }; - })(); + } + }; // Initialize CodeMirror editor view (mount only) useEffect(() => { @@ -579,26 +569,28 @@ export const CodeMirrorEditor = memo( const view = new EditorView({ parent: containerRef.current!, - dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc, false), + dispatchTransactions: createDispatchTransactions(onUpdate, editorStatesRef, doc), }); - viewRef.current = view; + editorViewRef.current = view; + docValueRef.current = undefined; + docFilePathRef.current = undefined; // Cleanup on unmount return () => { - viewRef.current?.destroy(); - viewRef.current = undefined; + editorViewRef.current?.destroy(); + editorViewRef.current = undefined; }; }, []); // AI completion detection and final state synchronization useEffect(() => { // Detect AI streaming completion (editable: false → true) - if (!prevEditableRef.current && editable && doc?.value && viewRef.current) { + if (!prevEditableRef.current && editable && doc?.value && editorViewRef.current) { const transaction: TransactionSpec = { changes: { from: 0, - to: viewRef.current.state.doc.length, + to: editorViewRef.current.state.doc.length, insert: doc.value, }, selection: { anchor: doc.value.length }, // Move cursor to end of file @@ -607,7 +599,7 @@ export const CodeMirrorEditor = memo( }; try { - dispatchToView(viewRef.current, transaction); + dispatchToView(editorViewRef.current, transaction); logger.info(EDITOR_MESSAGES.AI_COMPLETION_SYNC); } catch (error) { logger.warn(EDITOR_MESSAGES.AI_COMPLETION_FAILED, error); @@ -623,12 +615,12 @@ export const CodeMirrorEditor = memo( // Handle theme changes useEffect(() => { - if (!viewRef.current) { + if (!editorViewRef.current) { return; } try { - dispatchToView(viewRef.current, { effects: [reconfigureTheme(theme)] }); + dispatchToView(editorViewRef.current, { effects: [reconfigureTheme(theme)] }); } catch (error) { logger.warn('theme change failed', error); recreateEditorView(); @@ -638,7 +630,7 @@ export const CodeMirrorEditor = memo( // Handle document changes and loading useEffect(() => { const editorStates = editorStatesRef.current; - const editorView = viewRef.current; + const editorView = editorViewRef.current; if (!editorView || !editorStates) { return; @@ -706,7 +698,7 @@ export const CodeMirrorEditor = memo( editorView.setState(state); const success = setEditorDocument( - viewRef, + editorViewRef, theme, editable, languageCompartment,