From 2f94ae603687c5c8a60e3c515a2492a3c6ed1a3e Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 31 Aug 2026 17:26:01 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix(site/src/components/SyntaxHi?= =?UTF-8?q?ghlighter):=20dispose=20Monaco=20diff=20models=20on=20unmount?= =?UTF-8?q?=20(#28503)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening template versions leaked Monaco text models: SyntaxHighlighter sets keepCurrent{Original,Modified}Model so @monaco-editor/react does not dispose the models, and no stable model paths are supplied, so every visit created anonymous models that were never freed. Move the diff editor into a DiffFile component that captures the original and modified models in onMount and disposes them in its own unmount effect, so the cleanup also runs when a file switches diff to plain while SyntaxHighlighter stays mounted. Disposal is deferred with queueMicrotask because freeing the models in the same synchronous teardown throws "TextModel got disposed before DiffEditorWidget model got reset". Adds a Storybook story that toggles diff to plain repeatedly and asserts the model count returns to its baseline. (cherry picked from commit 5008603d08fefbf1187b0ba18fe1c2de4a202e43) --- .../SyntaxHighlighter.stories.tsx | 110 +++++++++++++ .../SyntaxHighlighter/SyntaxHighlighter.tsx | 146 ++++++++++++------ 2 files changed, 210 insertions(+), 46 deletions(-) create mode 100644 site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 0000000000000..3b5199c4346ad --- /dev/null +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx @@ -0,0 +1,110 @@ +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, within } from "storybook/test"; +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 that throw "AbstractContextKeyService has been disposed" when the +// editor unmounts during a story run. They are irrelevant to model disposal, so +// stories turn them off to keep the runner clean; production keeps the defaults. +const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions = + { + minimap: { enabled: false }, + renderSideBySide: true, + readOnly: true, + renderGutterMenu: false, + occurrencesHighlight: "off", + }; + +const meta: Meta = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj; + +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 ( +
+ + +
+ ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + 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), + ); + } + }, +}; diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index 68d9e140ce528..37e76006575be 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -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 }); @@ -38,40 +44,6 @@ export const SyntaxHighlighter: FC = ({ 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, @@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC = ({ }} > {hasDiff ? ( - + ) : ( )} ); }; + +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 = ({ + 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 ( + + ); +};