fix(editor): dispose compose DiffEditor models after widget reset (#1828)

Closing the compose or git diff dialog threw a Monaco console error
because the library disposed TextModels while DiffEditorWidget still
held them.
This commit is contained in:
Anso
2026-08-13 10:32:26 -04:00
committed by GitHub
parent f5178889eb
commit 93a5ba9f21
5 changed files with 132 additions and 6 deletions
@@ -1,5 +1,5 @@
import { Suspense } from 'react';
import { DiffEditor } from '@/lib/monacoLoader';
import { SafeDiffEditor } from '@/lib/SafeDiffEditor';
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import { BusyButton } from '@/components/ui/busy-button';
@@ -43,7 +43,7 @@ export function ComposeDiffPreviewDialog({
<div className="px-6 pb-4 pt-3">
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<DiffEditor
<SafeDiffEditor
height="100%"
language={language}
theme={isDarkMode ? 'vs-dark' : 'vs'}
@@ -3,8 +3,8 @@ import { render, screen } from '@testing-library/react';
import { ComposeDiffPreviewDialog } from '../ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '../resolveComposeDiffActionLabel';
vi.mock('@/lib/monacoLoader', () => ({
DiffEditor: () => <div data-testid="diff-editor" />,
vi.mock('@/lib/SafeDiffEditor', () => ({
SafeDiffEditor: () => <div data-testid="diff-editor" />,
}));
describe('resolveComposeDiffActionLabel', () => {
@@ -45,4 +45,24 @@ describe('ComposeDiffPreviewDialog', () => {
);
expect(screen.getByRole('button', { name: 'Save & reapply' })).toBeInTheDocument();
});
it('unmounts the diff editor without throwing', () => {
const { unmount } = render(
<ComposeDiffPreviewDialog
open
onOpenChange={vi.fn()}
stackName="sencho"
fileName="docker-compose.yml"
language="yaml"
original="a"
modified="b"
actionLabel="Save"
confirming={false}
isDarkMode={false}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByTestId('diff-editor')).toBeInTheDocument();
expect(() => unmount()).not.toThrow();
});
});
@@ -1,5 +1,5 @@
import { useState, Suspense } from 'react';
import { DiffEditor } from '@/lib/monacoLoader';
import { SafeDiffEditor } from '@/lib/SafeDiffEditor';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
@@ -120,7 +120,7 @@ export function GitSourceDiffDialog({
<div className="px-6 pb-4 pt-3">
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<DiffEditor
<SafeDiffEditor
height="100%"
language={effectiveTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react';
import type { DiffEditorProps } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { DiffEditor } from './monacoLoader';
/**
* DiffEditor wrapper that owns model disposal. keepCurrentOriginalModel and
* keepCurrentModifiedModel stop @monaco-editor/react from disposing first.
* On unmount this resets the widget (setModel(null)) then disposes the models.
* Monaco 0.56 throws "TextModel got disposed before DiffEditorWidget model
* got reset" if the library disposes models while the widget still holds them.
*/
export function SafeDiffEditor({ onMount, ...props }: DiffEditorProps) {
const editorRef = useRef<editor.IStandaloneDiffEditor | null>(null);
const modelsRef = useRef<editor.IDiffEditorModel | null>(null);
useEffect(() => {
return () => {
const diffEditor = editorRef.current;
const models = modelsRef.current;
modelsRef.current = null;
try {
diffEditor?.setModel(null);
diffEditor?.dispose();
} catch {
// Widget already torn down by monaco-react.
}
try {
models?.original.dispose();
models?.modified.dispose();
} catch {
// Models already disposed.
}
};
}, []);
return (
<DiffEditor
{...props}
keepCurrentOriginalModel
keepCurrentModifiedModel
onMount={(ed, monaco) => {
editorRef.current = ed;
modelsRef.current = ed.getModel();
onMount?.(ed, monaco);
}}
/>
);
}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import type { DiffEditorProps } from '@monaco-editor/react';
let lastDiffProps: DiffEditorProps | null = null;
let disposeCalls: string[] = [];
vi.mock('@/lib/monacoLoader', () => ({
DiffEditor: (props: DiffEditorProps) => {
lastDiffProps = props;
if (props.onMount) {
const original = { dispose: () => disposeCalls.push('original.dispose') };
const modified = { dispose: () => disposeCalls.push('modified.dispose') };
let attached: { original: typeof original; modified: typeof modified } | null = {
original,
modified,
};
props.onMount({
getModel: () => attached,
setModel: (next: typeof attached) => {
disposeCalls.push('setModel');
attached = next;
},
dispose: () => {
if (attached) {
throw new Error('TextModel got disposed before DiffEditorWidget model got reset');
}
disposeCalls.push('editor.dispose');
},
} as never, {} as never);
}
return <div data-testid="diff-editor" />;
},
}));
import { SafeDiffEditor } from '../SafeDiffEditor';
describe('SafeDiffEditor', () => {
it('keeps current models and resets the widget before disposing them', () => {
disposeCalls = [];
const { unmount } = render(
<SafeDiffEditor
height="100%"
language="yaml"
original="a"
modified="b"
/>,
);
expect(lastDiffProps?.keepCurrentOriginalModel).toBe(true);
expect(lastDiffProps?.keepCurrentModifiedModel).toBe(true);
unmount();
expect(disposeCalls.indexOf('setModel')).toBeGreaterThanOrEqual(0);
expect(disposeCalls.indexOf('setModel')).toBeLessThan(disposeCalls.indexOf('original.dispose'));
expect(disposeCalls.indexOf('setModel')).toBeLessThan(disposeCalls.indexOf('modified.dispose'));
expect(disposeCalls.indexOf('editor.dispose')).toBeGreaterThan(disposeCalls.indexOf('setModel'));
});
});