-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix(site): dispose Monaco diff models on unmount #28503
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eab9c3f
ba321a2
caa7231
03d17db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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), | ||
| ); | ||
| } | ||
| }, | ||
| }; |
| 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"; | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 on lines
+144
to
+145
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Good instinct β I dug into 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 |
||
| 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(); | ||
| }); | ||
| }; | ||
| }, []); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a mounted Useful? React with πΒ / π.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Huh. Fair 'enough. Reworking this to |
||
|
|
||
| return ( | ||
| <DiffEditor | ||
| original={original} | ||
| modified={modified} | ||
| {...editorProps} | ||
| keepCurrentOriginalModel | ||
| keepCurrentModifiedModel | ||
| onMount={handleMount} | ||
| /> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.