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
+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'));
});
});