Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 046189d

Browse files
jakehwllmtojek
andauthored
fix(site): dispose Monaco diff models on unmount (#28503) (#28809)
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Backport of #28503 to `release/2.37`. Refs [DEVEX-736](https://linear.app/codercom/issue/DEVEX-736/template-editor-crashes-becomes-unusable-when-opening-versions). This carries the Monaco diff-model lifecycle cleanup and Storybook regression coverage into 2.37, preventing template-version navigation from retaining models until the editor becomes unusable. The original patch applies cleanly to this release branch. Co-authored-by: Marcin Tojek <[email protected]>
1 parent 8a148a9 commit 046189d

2 files changed

Lines changed: 211 additions & 46 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import type * as Monaco from "monaco-editor";
3+
import * as monaco from "monaco-editor";
4+
import { useState } from "react";
5+
import { expect, userEvent, waitFor } from "storybook/test";
6+
import { withDashboardProvider } from "#/testHelpers/storybook";
7+
import { SyntaxHighlighter } from "./SyntaxHighlighter";
8+
9+
const original = `resource "coder_agent" "main" {
10+
os = "linux"
11+
arch = "amd64"
12+
}
13+
`;
14+
15+
const modified = `resource "coder_agent" "main" {
16+
os = "linux"
17+
arch = "arm64"
18+
}
19+
`;
20+
21+
// The diff editor's gutter menu and occurrence highlighter register delayed
22+
// disposables whose teardown throws when editors unmount in Storybook tests.
23+
// They are irrelevant to model disposal, so we turn them off in stories to keep
24+
// the test runner clean without changing production behavior.
25+
const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions =
26+
{
27+
minimap: { enabled: false },
28+
renderSideBySide: true,
29+
readOnly: true,
30+
renderGutterMenu: false,
31+
occurrencesHighlight: "off",
32+
};
33+
34+
const meta: Meta<typeof SyntaxHighlighter> = {
35+
title: "components/SyntaxHighlighter",
36+
component: SyntaxHighlighter,
37+
decorators: [withDashboardProvider],
38+
args: {
39+
language: "hcl",
40+
editorProps: { options: stableTeardownOptions },
41+
},
42+
};
43+
44+
export default meta;
45+
type Story = StoryObj<typeof SyntaxHighlighter>;
46+
47+
export const Plain: Story = {
48+
args: {
49+
value: original,
50+
},
51+
};
52+
53+
export const Diff: Story = {
54+
args: {
55+
value: modified,
56+
compareWith: original,
57+
},
58+
};
59+
60+
// Reproduces the leak from DEVEX-736: a single SyntaxHighlighter instance that
61+
// stays mounted while a file switches between diff and plain across template
62+
// versions. Each diff editor owns two Monaco models, and they must be disposed
63+
// when the diff goes away. Before the fix the models were only disposed on full
64+
// unmount, so toggling diff -> plain -> diff leaked two models per cycle.
65+
const DiffToggle = () => {
66+
const [showDiff, setShowDiff] = useState(true);
67+
return (
68+
<div>
69+
<button type="button" onClick={() => setShowDiff((show) => !show)}>
70+
Toggle diff
71+
</button>
72+
<SyntaxHighlighter
73+
language="hcl"
74+
value={showDiff ? modified : original}
75+
compareWith={original}
76+
editorProps={{ options: stableTeardownOptions }}
77+
/>
78+
</div>
79+
);
80+
};
81+
82+
export const DisposesModelsOnDiffToggle: Story = {
83+
render: () => <DiffToggle />,
84+
play: async ({ canvas }) => {
85+
const toggle = canvas.getByRole("button", { name: "Toggle diff" });
86+
87+
// Wait for the diff editor to mount its original + modified models, then
88+
// record the total as a baseline. Every full toggle cycle must return to
89+
// this number; growth would mean abandoned models are being retained.
90+
let baseline = 0;
91+
await waitFor(() => {
92+
baseline = monaco.editor.getModels().length;
93+
expect(baseline).toBeGreaterThanOrEqual(2);
94+
});
95+
96+
for (let cycle = 0; cycle < 3; cycle++) {
97+
// Switch to plain: the diff editor unmounts and must dispose its models.
98+
await userEvent.click(toggle);
99+
await waitFor(() =>
100+
expect(monaco.editor.getModels().length).toBeLessThan(baseline),
101+
);
102+
103+
// Switch back to diff: a new diff editor mounts and the total must land
104+
// back on the baseline rather than climbing.
105+
await userEvent.click(toggle);
106+
await waitFor(() =>
107+
expect(monaco.editor.getModels().length).toBe(baseline),
108+
);
109+
}
110+
},
111+
};

site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx

Lines changed: 100 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import Editor, { DiffEditor, loader } from "@monaco-editor/react";
22
import type * as Monaco from "monaco-editor";
33
import * as monaco from "monaco-editor";
4-
import { type ComponentProps, type FC, useCallback } from "react";
4+
import {
5+
type ComponentProps,
6+
type FC,
7+
useCallback,
8+
useEffect,
9+
useRef,
10+
} from "react";
511
import { useTheme } from "#/theme/context";
612
import { useCoderTheme } from "./coderTheme";
713

@@ -38,40 +44,6 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
3844
const theme = useTheme();
3945
const coderTheme = useCoderTheme();
4046

41-
// Auto-scroll to first diff when the diff editor mounts and diffs are computed.
42-
const handleDiffEditorMount = useCallback(
43-
(
44-
editor: Monaco.editor.IStandaloneDiffEditor,
45-
monacoInstance: typeof Monaco,
46-
) => {
47-
// Call any existing onMount handler from editorProps.
48-
editorProps?.onMount?.(editor, monacoInstance);
49-
50-
// Diffs may already be computed by the time onMount fires,
51-
// so check immediately first. If not ready yet, fall back
52-
// to waiting for the onDidUpdateDiff event.
53-
const scrollToFirstDiff = () => {
54-
editor.goToDiff("next");
55-
};
56-
57-
const changes = editor.getLineChanges();
58-
if (changes && changes.length > 0) {
59-
scrollToFirstDiff();
60-
return;
61-
}
62-
63-
const disposable = editor.onDidUpdateDiff(() => {
64-
const updatedChanges = editor.getLineChanges();
65-
if (!updatedChanges || updatedChanges.length === 0) {
66-
return;
67-
}
68-
disposable.dispose();
69-
scrollToFirstDiff();
70-
});
71-
},
72-
[editorProps],
73-
);
74-
7547
const commonProps = {
7648
language,
7749
theme: coderTheme.name,
@@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
9971
}}
10072
>
10173
{hasDiff ? (
102-
<DiffEditor
103-
original={compareWith}
104-
modified={value}
105-
{...commonProps}
106-
// Let the editor handle model cleanup. Without this,
107-
// @monaco-editor/react disposes models before the
108-
// DiffEditorWidget and throws an error.
109-
keepCurrentOriginalModel
110-
keepCurrentModifiedModel
111-
onMount={handleDiffEditorMount}
112-
/>
74+
<DiffFile original={compareWith} modified={value} {...commonProps} />
11375
) : (
11476
<Editor value={value} {...commonProps} />
11577
)}
11678
</div>
11779
);
11880
};
81+
82+
type DiffFileProps = CommonEditorProps & {
83+
original: string;
84+
modified: string;
85+
};
86+
87+
// Renders the diff editor and owns its model cleanup. Scoping this to its own
88+
// component means the cleanup effect runs whenever the diff editor unmounts,
89+
// including when SyntaxHighlighter stays mounted but switches diff -> plain for
90+
// a file that stopped changing between versions.
91+
//
92+
// keepCurrent{Original,Modified}Model stops @monaco-editor/react from disposing
93+
// the models mid-teardown (which throws), so we dispose them ourselves after
94+
// React has torn the editor down. Without this the models accumulate unbounded
95+
// as users open template versions until the tab runs out of memory.
96+
const DiffFile: FC<DiffFileProps> = ({
97+
original,
98+
modified,
99+
onMount,
100+
...editorProps
101+
}) => {
102+
const diffModelsRef = useRef<{
103+
original: Monaco.editor.ITextModel;
104+
modified: Monaco.editor.ITextModel;
105+
} | null>(null);
106+
107+
const handleMount = useCallback(
108+
(
109+
editor: Monaco.editor.IStandaloneDiffEditor,
110+
monacoInstance: typeof Monaco,
111+
) => {
112+
onMount?.(editor, monacoInstance);
113+
114+
const diffModel = editor.getModel();
115+
diffModelsRef.current = diffModel
116+
? { original: diffModel.original, modified: diffModel.modified }
117+
: null;
118+
119+
// Auto-scroll to the first diff. Diffs may already be computed by the
120+
// time onMount fires, so check immediately and otherwise wait for the
121+
// onDidUpdateDiff event.
122+
const scrollToFirstDiff = () => {
123+
editor.goToDiff("next");
124+
};
125+
126+
const changes = editor.getLineChanges();
127+
if (changes && changes.length > 0) {
128+
scrollToFirstDiff();
129+
return;
130+
}
131+
132+
const disposable = editor.onDidUpdateDiff(() => {
133+
const updatedChanges = editor.getLineChanges();
134+
if (!updatedChanges || updatedChanges.length === 0) {
135+
return;
136+
}
137+
disposable.dispose();
138+
scrollToFirstDiff();
139+
});
140+
},
141+
[onMount],
142+
);
143+
144+
useEffect(() => {
145+
return () => {
146+
const models = diffModelsRef.current;
147+
if (!models) {
148+
return;
149+
}
150+
diffModelsRef.current = null;
151+
// Defer disposal until after React's commit finishes. @monaco-editor/
152+
// react disposes the diff widget in its own unmount cleanup; freeing
153+
// the models in the same synchronous teardown makes the widget throw
154+
// "TextModel got disposed before DiffEditorWidget model got reset".
155+
queueMicrotask(() => {
156+
models.original.dispose();
157+
models.modified.dispose();
158+
});
159+
};
160+
}, []);
161+
162+
return (
163+
<DiffEditor
164+
original={original}
165+
modified={modified}
166+
{...editorProps}
167+
keepCurrentOriginalModel
168+
keepCurrentModifiedModel
169+
onMount={handleMount}
170+
/>
171+
);
172+
};

0 commit comments

Comments
 (0)