From 54be6aa46813c12e9e4fd6f2381e80b2e6e57c41 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Thu, 22 Jan 2026 18:36:44 +0200 Subject: [PATCH 1/4] fix(editor): create EditCodeWidget container imperatively to prevent DOM conflicts Monaco moves contentWidget DOM nodes to its overlay container. When React tries to unmount these nodes, they're no longer in their expected location, causing 'removeChild' errors. This fix creates the widget container via document.createElement() and uses React portals to render content into it. Fixes #193 --- .../project/ai-edit/edit-code-widget.tsx | 81 ++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/web/components/project/ai-edit/edit-code-widget.tsx b/web/components/project/ai-edit/edit-code-widget.tsx index 51344361..cfd615d2 100644 --- a/web/components/project/ai-edit/edit-code-widget.tsx +++ b/web/components/project/ai-edit/edit-code-widget.tsx @@ -2,18 +2,24 @@ import { AnimatePresence, motion } from "framer-motion" import { Sparkles } from "lucide-react" +import { useEffect, useState } from "react" +import { createPortal } from "react-dom" import { Button } from "../../ui/button" export interface EditCodeWidgetProps { isSelected: boolean showSuggestion: boolean onAiEdit: () => void - suggestionRef: React.RefObject + suggestionRef: React.MutableRefObject } /** * Edit Code Widget that appears when text is selected * Shows an animated "Edit Code" button with AI capabilities + * + * IMPORTANT: The widget container is created imperatively (not via JSX) because + * Monaco moves the DOM node to its overlay. If React managed this node, + * it would crash when trying to unmount a node that's no longer in its expected location. */ export default function EditCodeWidget({ isSelected, @@ -21,32 +27,53 @@ export default function EditCodeWidget({ onAiEdit, suggestionRef, }: EditCodeWidgetProps) { - return ( -
- - {isSelected && showSuggestion && ( - (null) + + // Create the container div imperatively on mount + useEffect(() => { + const div = document.createElement("div") + div.className = "relative" + suggestionRef.current = div + setContainer(div) + + // Cleanup: remove from DOM if it's still attached somewhere + return () => { + suggestionRef.current = null + if (div.parentNode) { + div.parentNode.removeChild(div) + } + } + }, [suggestionRef]) + + // Don't render anything if container doesn't exist yet + if (!container) return null + + // Use portal to render content into the imperatively created container + return createPortal( + + {isSelected && showSuggestion && ( + + - - )} - -
+ + Edit Code + + + )} + , + container, ) } From bcccf055bca766379c97544e6fcf7671c1cda9f4 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Thu, 22 Jan 2026 18:36:50 +0200 Subject: [PATCH 2/4] fix(editor): create GenerateWidget containers imperatively to prevent DOM conflicts Apply the same fix pattern to GenerateWidget - create anchor and widget containers via document.createElement() and use React portals to render content. This prevents Monaco from causing DOM node conflicts when the editor tab is closed. Related to #193 --- .../project/ai-edit/generate-widget.tsx | 82 ++++++++++++++----- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/web/components/project/ai-edit/generate-widget.tsx b/web/components/project/ai-edit/generate-widget.tsx index 9b71558d..258cc0b4 100644 --- a/web/components/project/ai-edit/generate-widget.tsx +++ b/web/components/project/ai-edit/generate-widget.tsx @@ -1,12 +1,12 @@ "use client" import { processEdit } from "@/app/actions/ai" -import { cn } from "@/lib/utils" import { useRouter } from "@bprogress/next/app" import { Editor } from "@monaco-editor/react" import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react" import { useTheme } from "next-themes" import { useCallback, useEffect, useRef, useState } from "react" +import { createPortal } from "react-dom" import { toast } from "sonner" import { Button } from "../../ui/button" @@ -27,10 +27,18 @@ interface GenerateInputProps { onClose: () => void } interface GenerateWidgetProps extends GenerateInputProps { - generateRef: React.RefObject - generateWidgetRef: React.RefObject + generateRef: React.MutableRefObject + generateWidgetRef: React.MutableRefObject show: boolean } + +/** + * Generate Widget container + * + * IMPORTANT: The widget containers are created imperatively (not via JSX) because + * Monaco moves the DOM nodes to its overlay. If React managed these nodes, + * it would crash when trying to unmount nodes that are no longer in their expected location. + */ export function GenerateWidget({ generateRef, generateWidgetRef, @@ -39,21 +47,53 @@ export function GenerateWidget({ projectName, ...inputProps }: GenerateWidgetProps) { - return ( - <> - {/* Generate DOM anchor point */} -
- {/* Generate Widget */} -
- {show ? ( - - ) : null} -
- + const [containers, setContainers] = useState<{ + anchor: HTMLDivElement | null + widget: HTMLDivElement | null + }>({ anchor: null, widget: null }) + + // Create the container divs imperatively on mount + useEffect(() => { + const anchorDiv = document.createElement("div") + const widgetDiv = document.createElement("div") + + generateRef.current = anchorDiv + generateWidgetRef.current = widgetDiv + setContainers({ anchor: anchorDiv, widget: widgetDiv }) + + // Cleanup: remove from DOM if still attached somewhere + return () => { + generateRef.current = null + generateWidgetRef.current = null + if (anchorDiv.parentNode) { + anchorDiv.parentNode.removeChild(anchorDiv) + } + if (widgetDiv.parentNode) { + widgetDiv.parentNode.removeChild(widgetDiv) + } + } + }, [generateRef, generateWidgetRef]) + + // Update widget container class when show changes + useEffect(() => { + if (containers.widget) { + containers.widget.className = show ? "z-50 p-1" : "" + } + }, [show, containers.widget]) + + // Don't render anything if containers don't exist yet + if (!containers.widget) return null + + // Use portal to render content into the imperatively created widget container + return createPortal( + show ? ( + + ) : null, + containers.widget, ) } @@ -106,7 +146,7 @@ function GenerateInput({ fileName: data.fileName, projectId: projectId, projectName: projectName, - } + }, ) // Clean up any potential markdown or explanation text @@ -120,7 +160,7 @@ function GenerateInput({ router.refresh() } catch (error) { toast.error( - error instanceof Error ? error.message : "Failed to generate code" + error instanceof Error ? error.message : "Failed to generate code", ) } finally { setLoading({ generate: false, regenerate: false }) @@ -131,7 +171,7 @@ function GenerateInput({ e.preventDefault() handleGenerate({ regenerate: false }) }, - [input, currentPrompt] + [input, currentPrompt], ) useEffect(() => { From 2ce2a273016a202687c22363186ffef8174cb654 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Thu, 22 Jan 2026 18:37:00 +0200 Subject: [PATCH 3/4] fix(editor): add widget cleanup and proper useEffect cleanup returns - Add suggestionWidgetRef to store widget instance for proper cleanup - Add cleanup return to suggestion widget useEffect - Add cleanup return to generate widget useEffect - Add cleanupWidgets function for manual widget cleanup - Update refs to MutableRefObject for imperative container assignment Related to #193 --- .../project/hooks/useMonacoEditor.ts | 128 ++++++++++++++---- 1 file changed, 102 insertions(+), 26 deletions(-) diff --git a/web/components/project/hooks/useMonacoEditor.ts b/web/components/project/hooks/useMonacoEditor.ts index eb96d3aa..38144f0c 100644 --- a/web/components/project/hooks/useMonacoEditor.ts +++ b/web/components/project/hooks/useMonacoEditor.ts @@ -59,6 +59,7 @@ export interface UseMonacoEditorReturn { handleEditorWillMount: BeforeMount handleEditorMount: OnMount handleAiEdit: (editor?: monaco.editor.ICodeEditor) => void + cleanupWidgets: () => void // Internal setters setEditorRef: (editor: monaco.editor.IStandaloneCodeEditor) => void @@ -101,9 +102,10 @@ export const useMonacoEditor = ({ // Refs const monacoRef = useRef(null) - const generateRef = useRef(null) - const suggestionRef = useRef(null) - const generateWidgetRef = useRef(null) + const generateRef = useRef(null) + const suggestionRef = useRef(null) + const generateWidgetRef = useRef(null) + const suggestionWidgetRef = useRef(null) const lastCopiedRangeRef = useRef<{ startLine: number endLine: number @@ -113,7 +115,7 @@ export const useMonacoEditor = ({ const debouncedSetIsSelected = useRef( debounce((value: boolean) => { setIsSelected(value) - }, 800) + }, 800), ).current // Helper function to fetch file content @@ -125,7 +127,7 @@ export const useMonacoEditor = ({ }) }) }, - [socket] + [socket], ) // Load and merge TSConfig @@ -133,10 +135,10 @@ export const useMonacoEditor = ({ async ( files: (TFolder | TFile)[], editor: monaco.editor.IStandaloneCodeEditor, - monaco: typeof import("monaco-editor") + monaco: typeof import("monaco-editor"), ) => { const tsconfigFiles = files.filter((file) => - file.name.endsWith("tsconfig.json") + file.name.endsWith("tsconfig.json"), ) let mergedConfig: any = { compilerOptions: {} } @@ -172,10 +174,10 @@ export const useMonacoEditor = ({ ...mergedConfig.compilerOptions, }) monaco.languages.typescript.typescriptDefaults.setCompilerOptions( - updatedOptions + updatedOptions, ) monaco.languages.typescript.javascriptDefaults.setCompilerOptions( - updatedOptions + updatedOptions, ) } @@ -189,7 +191,7 @@ export const useMonacoEditor = ({ } }) }, - [fetchFileContent] + [fetchFileContent], ) // Pre-mount editor keybindings @@ -243,7 +245,7 @@ export const useMonacoEditor = ({ } }) }, - [editorRef, generateRef, setGenerate] + [editorRef, generateRef, setGenerate], ) // Post-mount editor keybindings and actions @@ -260,10 +262,10 @@ export const useMonacoEditor = ({ monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true) monaco.languages.typescript.typescriptDefaults.setCompilerOptions( - defaultCompilerOptions + defaultCompilerOptions, ) monaco.languages.typescript.javascriptDefaults.setCompilerOptions( - defaultCompilerOptions + defaultCompilerOptions, ) // Load TSConfig @@ -294,7 +296,7 @@ export const useMonacoEditor = ({ lineNumber, column, lineNumber, - endColumn + endColumn, ), options: { afterContentClassName: "inline-decoration", @@ -336,7 +338,7 @@ export const useMonacoEditor = ({ handleAiEdit, setIsAIChatOpen, debouncedSetIsSelected, - ] + ], ) // Generate widget effect @@ -415,6 +417,26 @@ export const useMonacoEditor = ({ } }) } + + // Cleanup function for generate widget + return () => { + if (generate.widget && editorRef) { + try { + editorRef.removeContentWidget(generate.widget) + } catch (e) { + // Widget may already be removed + } + } + if (generate.id && editorRef) { + try { + editorRef.changeViewZones((changeAccessor) => { + changeAccessor.removeZone(generate.id) + }) + } catch (e) { + // Zone may already be removed + } + } + } }, [ generate.show, generate.id, @@ -428,14 +450,23 @@ export const useMonacoEditor = ({ // Suggestion widget effect useEffect(() => { if (!suggestionRef.current || !editorRef) return + + // Remove any existing widget first + if (suggestionWidgetRef.current) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Widget may already be removed + } + suggestionWidgetRef.current = null + } + + if (!isSelected) return + const widgetElement = suggestionRef.current const suggestionWidget: monaco.editor.IContentWidget = { - getDomNode: () => { - return widgetElement - }, - getId: () => { - return "suggestion.widget" - }, + getDomNode: () => widgetElement, + getId: () => "suggestion.widget", getPosition: () => { const selection = editorRef?.getSelection() const column = Math.max(3, selection?.positionColumn ?? 1) @@ -453,11 +484,21 @@ export const useMonacoEditor = ({ } }, } - if (isSelected) { - editorRef?.addContentWidget(suggestionWidget) - editorRef?.applyFontInfo(suggestionRef.current) - } else { - editorRef?.removeContentWidget(suggestionWidget) + + suggestionWidgetRef.current = suggestionWidget + editorRef.addContentWidget(suggestionWidget) + editorRef.applyFontInfo(suggestionRef.current) + + // Cleanup function - remove widget when effect re-runs or unmounts + return () => { + if (suggestionWidgetRef.current && editorRef) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Widget may already be removed or editor disposed + } + suggestionWidgetRef.current = null + } } }, [isSelected, editorRef]) @@ -500,6 +541,40 @@ export const useMonacoEditor = ({ } }, [decorations.options, cursorLine, editorRef]) + // Cleanup function to remove all contentWidgets before tab close + const cleanupWidgets = useCallback(() => { + if (!editorRef) return + try { + // Remove suggestion widget using stored reference + if (suggestionWidgetRef.current) { + try { + editorRef.removeContentWidget(suggestionWidgetRef.current) + } catch (e) { + // Already removed + } + suggestionWidgetRef.current = null + } + // Remove generate widget + if (generate.widget) { + try { + editorRef.removeContentWidget(generate.widget) + } catch (e) { + // Already removed + } + } + // Clear any view zones + if (generate.id) { + editorRef.changeViewZones((changeAccessor) => { + changeAccessor.removeZone(generate.id) + }) + } + // Clear decorations + decorations.instance?.clear() + } catch (err) { + console.warn("Error cleaning up Monaco widgets:", err) + } + }, [editorRef, generate.widget, generate.id, decorations.instance]) + // useEffect(() => {}, [tabs]) return { // Editor state @@ -525,6 +600,7 @@ export const useMonacoEditor = ({ handleEditorWillMount, handleEditorMount, handleAiEdit, + cleanupWidgets, // Internal setters setEditorRef, From 5680fd868958908cfd38d3a71b14f8bb67de18c0 Mon Sep 17 00:00:00 2001 From: Hamzat Victor Date: Thu, 22 Jan 2026 18:37:06 +0200 Subject: [PATCH 4/4] fix(editor): cleanup widgets before closing editor tab Call cleanupWidgets and reset widget states (isSelected, generate.show) before removing the tab to ensure Monaco widgets are properly disposed before React unmounts the component. Related to #193 --- web/components/project/project-layout.tsx | 66 +++++++++++++++-------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/web/components/project/project-layout.tsx b/web/components/project/project-layout.tsx index a10661c3..62bdbe0a 100644 --- a/web/components/project/project-layout.tsx +++ b/web/components/project/project-layout.tsx @@ -134,6 +134,8 @@ export default function ProjectLayout({ handleEditorWillMount, handleEditorMount, handleAiEdit, + cleanupWidgets, + setIsSelected, } = useMonacoEditor({ editorPanelRef, setIsAIChatOpen, @@ -184,7 +186,7 @@ export default function ProjectLayout({ getUnresolvedSnapshot, restoreFromSnapshot, clearVisuals, - forceClearAllDecorations + forceClearAllDecorations, ) // Store diff functions so sidebar can use them @@ -214,14 +216,14 @@ export default function ProjectLayout({ setMergeDecorationsCollection(decorationsCollection) } }, - [handleApplyCode] + [handleApplyCode], ) const updateFileDraft = useCallback( (fileId: string, content?: string) => { setDraft(fileId, content ?? "") }, - [setDraft] + [setDraft], ) const handleEditorChange = useCallback( @@ -231,7 +233,7 @@ export default function ProjectLayout({ } updateFileDraft(activeTab.id, value) }, - [activeTab?.id, updateFileDraft] + [activeTab?.id, updateFileDraft], ) const waitForEditorModel = useCallback(async () => { @@ -330,7 +332,7 @@ export default function ProjectLayout({ activeTab?.id, hasActiveWidgets, acceptAll, - ] + ], ) const restoreOriginalFile = useCallback( @@ -352,7 +354,7 @@ export default function ProjectLayout({ activeTab?.id, hasActiveWidgets, rejectAll, - ] + ], ) // Handler for rejecting code from chat @@ -368,20 +370,35 @@ export default function ProjectLayout({ const handleCloseTab = useCallback( (tab: TTab) => { const isClosingActive = activeTab?.id === tab.id - if (isClosingActive && hasActiveWidgets()) { + if (isClosingActive) { + // CRITICAL: Clear selection and generate states first to trigger widget removal + // This must happen before we remove the tab to prevent React/Monaco DOM conflicts + setIsSelected(false) + setGenerate((prev) => ({ ...prev, show: false })) + + // Remove all contentWidgets synchronously to prevent DOM errors + // Monaco moves widget DOM nodes out of React's tree, so we must remove them + // before React tries to unmount the component try { - const session = getUnresolvedSnapshot(tab.id) - if (session) { - saveDiffSession(tab.id, session) - } - } catch (error) { - console.warn("Failed to snapshot unresolved diffs on close:", error) + cleanupWidgets() + } catch (err) { + console.warn("Error cleaning up Monaco widgets:", err) } - // Clear widgets before closing the tab - try { - clearVisuals() - } catch (error) { - forceClearAllDecorations() + if (hasActiveWidgets()) { + try { + const session = getUnresolvedSnapshot(tab.id) + if (session) { + saveDiffSession(tab.id, session) + } + } catch (error) { + console.warn("Failed to snapshot unresolved diffs on close:", error) + } + // Clear widgets before closing the tab + try { + clearVisuals() + } catch (error) { + forceClearAllDecorations() + } } } removeTab(tab) @@ -394,7 +411,10 @@ export default function ProjectLayout({ clearVisuals, forceClearAllDecorations, removeTab, - ] + cleanupWidgets, + setIsSelected, + setGenerate, + ], ) // Use the session manager for tab switching @@ -523,10 +543,10 @@ export default function ProjectLayout({ isAIChatOpen && isHorizontalLayout ? "horizontal" : isAIChatOpen - ? "vertical" - : isHorizontalLayout - ? "horizontal" - : "vertical" + ? "vertical" + : isHorizontalLayout + ? "horizontal" + : "vertical" } > {/* Preview Panel */}