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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as Monaco from "monaco-editor";
import * as monaco from "monaco-editor";
import { useState } from "react";
import { expect, userEvent, waitFor } from "storybook/test";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { SyntaxHighlighter } from "./SyntaxHighlighter";

const original = `resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
}
`;

const modified = `resource "coder_agent" "main" {
os = "linux"
arch = "arm64"
}
`;

// The diff editor's gutter menu and occurrence highlighter register delayed
// disposables whose teardown throws under jsdom (no real timers/layout) when
// editors unmount. They are irrelevant to model disposal, so we turn them off
// in stories to keep the test runner clean without changing production behavior.
const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions =
{
minimap: { enabled: false },
renderSideBySide: true,
readOnly: true,
renderGutterMenu: false,
occurrencesHighlight: "off",
};

const meta: Meta<typeof SyntaxHighlighter> = {
title: "components/SyntaxHighlighter",
component: SyntaxHighlighter,
decorators: [withDashboardProvider],
args: {
language: "hcl",
editorProps: { options: stableTeardownOptions },
},
};

export default meta;
type Story = StoryObj<typeof SyntaxHighlighter>;

export const Plain: Story = {
args: {
value: original,
},
};

export const Diff: Story = {
args: {
value: modified,
compareWith: original,
},
};

// Reproduces the leak from DEVEX-736: a single SyntaxHighlighter instance that
// stays mounted while a file switches between diff and plain across template
// versions. Each diff editor owns two Monaco models, and they must be disposed
// when the diff goes away. Before the fix the models were only disposed on full
// unmount, so toggling diff -> plain -> diff leaked two models per cycle.
const DiffToggle = () => {
const [showDiff, setShowDiff] = useState(true);
return (
<div>
<button type="button" onClick={() => setShowDiff((show) => !show)}>
Toggle diff
</button>
<SyntaxHighlighter
language="hcl"
value={showDiff ? modified : original}
compareWith={original}
editorProps={{ options: stableTeardownOptions }}
/>
</div>
);
};

export const DisposesModelsOnDiffToggle: Story = {
render: () => <DiffToggle />,
play: async ({ canvas }) => {
const toggle = canvas.getByRole("button", { name: "Toggle diff" });

// Wait for the diff editor to mount its original + modified models, then
// record the total as a baseline. Every full toggle cycle must return to
// this number; growth would mean abandoned models are being retained.
let baseline = 0;
await waitFor(() => {
baseline = monaco.editor.getModels().length;
expect(baseline).toBeGreaterThanOrEqual(2);
});

for (let cycle = 0; cycle < 3; cycle++) {
// Switch to plain: the diff editor unmounts and must dispose its models.
await userEvent.click(toggle);
await waitFor(() =>
expect(monaco.editor.getModels().length).toBeLessThan(baseline),
);

// Switch back to diff: a new diff editor mounts and the total must land
// back on the baseline rather than climbing.
await userEvent.click(toggle);
await waitFor(() =>
expect(monaco.editor.getModels().length).toBe(baseline),
);
}
},
};
146 changes: 100 additions & 46 deletions site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Editor, { DiffEditor, loader } from "@monaco-editor/react";
import type * as Monaco from "monaco-editor";
import * as monaco from "monaco-editor";
import { type ComponentProps, type FC, useCallback } from "react";
import {
type ComponentProps,
type FC,
useCallback,
useEffect,
useRef,
} from "react";
import { useTheme } from "#/theme/context";
import { useCoderTheme } from "./coderTheme";

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

// Auto-scroll to first diff when the diff editor mounts and diffs are computed.
const handleDiffEditorMount = useCallback(
(
editor: Monaco.editor.IStandaloneDiffEditor,
monacoInstance: typeof Monaco,
) => {
// Call any existing onMount handler from editorProps.
editorProps?.onMount?.(editor, monacoInstance);

// Diffs may already be computed by the time onMount fires,
// so check immediately first. If not ready yet, fall back
// to waiting for the onDidUpdateDiff event.
const scrollToFirstDiff = () => {
editor.goToDiff("next");
};

const changes = editor.getLineChanges();
if (changes && changes.length > 0) {
scrollToFirstDiff();
return;
}

const disposable = editor.onDidUpdateDiff(() => {
const updatedChanges = editor.getLineChanges();
if (!updatedChanges || updatedChanges.length === 0) {
return;
}
disposable.dispose();
scrollToFirstDiff();
});
},
[editorProps],
);

const commonProps = {
language,
theme: coderTheme.name,
Expand Down Expand Up @@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
}}
>
{hasDiff ? (
<DiffEditor
original={compareWith}
modified={value}
{...commonProps}
// Let the editor handle model cleanup. Without this,
// @monaco-editor/react disposes models before the
// DiffEditorWidget and throws an error.
keepCurrentOriginalModel
keepCurrentModifiedModel
onMount={handleDiffEditorMount}
/>
<DiffFile original={compareWith} modified={value} {...commonProps} />
) : (
<Editor value={value} {...commonProps} />
)}
</div>
);
};

type DiffFileProps = CommonEditorProps & {
original: string;
modified: string;
};

// Renders the diff editor and owns its model cleanup. Scoping this to its own
// component means the cleanup effect runs whenever the diff editor unmounts,
// including when SyntaxHighlighter stays mounted but switches diff -> plain for
// a file that stopped changing between versions.
//
// keepCurrent{Original,Modified}Model stops @monaco-editor/react from disposing
// the models mid-teardown (which throws), so we dispose them ourselves after
// React has torn the editor down. Without this the models accumulate unbounded
// as users open template versions until the tab runs out of memory.
const DiffFile: FC<DiffFileProps> = ({
original,
modified,
onMount,
...editorProps
}) => {
const diffModelsRef = useRef<{
original: Monaco.editor.ITextModel;
modified: Monaco.editor.ITextModel;
} | null>(null);

const handleMount = useCallback(
(
editor: Monaco.editor.IStandaloneDiffEditor,
monacoInstance: typeof Monaco,
) => {
onMount?.(editor, monacoInstance);

const diffModel = editor.getModel();
diffModelsRef.current = diffModel
? { original: diffModel.original, modified: diffModel.modified }
: null;

// Auto-scroll to the first diff. Diffs may already be computed by the
// time onMount fires, so check immediately and otherwise wait for the
// onDidUpdateDiff event.
const scrollToFirstDiff = () => {
editor.goToDiff("next");
};

const changes = editor.getLineChanges();
if (changes && changes.length > 0) {
scrollToFirstDiff();
return;
}

const disposable = editor.onDidUpdateDiff(() => {
const updatedChanges = editor.getLineChanges();
if (!updatedChanges || updatedChanges.length === 0) {
return;
}
disposable.dispose();
scrollToFirstDiff();
});
},
[onMount],
);

useEffect(() => {
return () => {
Comment thread
jakehwll marked this conversation as resolved.
Comment on lines +144 to +145

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this not a cleanup on the effect creating the resources? does this leak when onMount changes and this effect doesn't refire?

@jakehwll jakehwll Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– Posted by Coder Agents on behalf of Jake Howell.

Good instinct β€” I dug into @monaco-editor/react. onMount is stored in a ref and fired only once (its effect keys on the editor-ready flag, not on onMount), and the diff editor + its models are created a single time β€” later prop changes only setModel/setValue in place. So a changing onMount never re-fires or recreates models; diffModelsRef keeps pointing at the one live pair.

It isn't a creation-effect cleanup because we don't create the models in an effect β€” Monaco makes them imperatively and we just capture refs in onMount. Their lifetime is exactly DiffFile's mounted lifetime (one editor per instance), so the unmount cleanup reads the current ref and frees the right pair. No leak.

const models = diffModelsRef.current;
if (!models) {
return;
}
diffModelsRef.current = null;
// Defer disposal until after React's commit finishes. @monaco-editor/
// react disposes the diff widget in its own unmount cleanup; freeing
// the models in the same synchronous teardown makes the widget throw
// "TextModel got disposed before DiffEditorWidget model got reset".
queueMicrotask(() => {
models.original.dispose();
models.modified.dispose();
});
};
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Dispose models when the DiffEditor unmounts

When a mounted SyntaxHighlighter changes from a diff to a plain editor, such as navigating between cached template versions where the same filename changes in one version but matches the active version in another, only the DiffEditor unmounts, so this parent-only cleanup does not run. Returning to a diff then overwrites diffModelsRef.current, permanently losing the previous model pair and allowing repeated navigation to recreate the memory growth and eventual OOM this change is intended to fix. Tie disposal to the DiffEditor lifetime rather than only the SyntaxHighlighter lifetime.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh. Fair 'enough.

Reworking this to SyntaxHighlighter. This isn't a path we actually take advantage of today, but we should do it right in the first place.


return (
<DiffEditor
original={original}
modified={modified}
{...editorProps}
keepCurrentOriginalModel
keepCurrentModifiedModel
onMount={handleMount}
/>
);
};
Loading