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 when editors unmount in Storybook tests.
// 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
Expand Up @@ -2,7 +2,13 @@ import { useTheme } from "@emotion/react";
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 { useCoderTheme } from "./coderTheme";

loader.config({ monaco });
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 () => {
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();
});
};
}, []);

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