From 64df629bacac0ba230b1105f19bc91a79523de9d Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 26 Aug 2026 10:32:08 +0700 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 | 111 +++++++++++++ .../SyntaxHighlighter/SyntaxHighlighter.tsx | 146 ++++++++++++------ 2 files changed, 211 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 00000000000..8740b3af492 --- /dev/null +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx @@ -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 = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + 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 ({ 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), + ); + } + }, +}; diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index 68d9e140ce5..37e76006575 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 ( + + ); +};