fix(site): dispose Monaco diff models on unmount by jakehwll · Pull Request #28503 · coder/coder · GitHub
Skip to content

fix(site): dispose Monaco diff models on unmount - #28503

Merged
jakehwll merged 4 commits into
mainfrom
jakehwll/DEVEX-736-template-editor-monaco-model-leak
Aug 26, 2026
Merged

fix(site): dispose Monaco diff models on unmount#28503
jakehwll merged 4 commits into
mainfrom
jakehwll/DEVEX-736-template-editor-monaco-model-leak

Conversation

@jakehwll

@jakehwll jakehwll commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Fixes DEVEX-736.

Problem

Opening template versions in the template editor leaks Monaco text models. Each version renders one DiffEditor per changed file, and SyntaxHighlighter sets keepCurrentOriginalModel / keepCurrentModifiedModel, which tells @monaco-editor/react not to dispose the underlying text models when the editor unmounts. Because no stable model paths are supplied, every visit creates fresh anonymous models that are never freed. Navigating between versions accumulates models without bound until the tab lags, spins, and eventually OOMs — matching the customer reports on 2.34.5.

The flags themselves are still required: removing them makes the library dispose the models mid-teardown (before the DiffEditorWidget is torn down), which throws.

Fix

Keep the keepCurrent*Model flags, but move the diff editor into its own DiffFile component that owns the model cleanup:

  • DiffFile captures the original/modified models in onMount and disposes them in its own unmount effect. Because the effect lives with the diff editor, it runs whenever the diff editor unmounts — including when a file switches diff → plain across versions while SyntaxHighlighter stays mounted (the key={filename} case raised in review), not only on full unmount.
  • Disposal is deferred with queueMicrotask so it runs after React's commit and after @monaco-editor/react disposes the widget. Freeing the models in the same synchronous teardown throws TextModel got disposed before DiffEditorWidget model got reset.

Non-diff files use the plain Editor, which already disposes its own model, so they are unaffected. No editor options or UI behavior change.

Evidence

Reproduced locally on a template with 32 versions (9 .tf files each), driving 192 in-app version navigations (single SPA session, no page reloads) and reading monaco.editor.getModels().length via temporary instrumentation.

before (pass 1 → pass 6) after (pass 1 → pass 6)
retained Monaco models 532 → 3,142 (unbounded) ~10–19 (flat)
per-navigation render latency 206ms → 611ms ~120–270ms (flat)

Before: models climb ~520 per full pass and never release (a sample taken with zero editors on screen still showed 522 live models). After: model count and render latency stay flat across the same workload.

Testing

Added SyntaxHighlighter.stories.tsx with a play regression test (DisposesModelsOnDiffToggle) that reproduces the exact leak: it toggles a single surviving SyntaxHighlighter between diff and plain three times and asserts monaco.editor.getModels().length drops when the diff is removed and returns to the baseline each cycle (no accumulation).

  • The test fails on the unfixed behavior (disposal removed): expected 5 to be less than 4 — models grow instead of being freed.

  • With the fix it passes with zero Monaco teardown errors:

    $ pnpm test:storybook src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx
     Test Files  1 passed (1)
          Tests  3 passed (3)
         Errors  0
    
  • pnpm check (biome) — pass

  • pnpm lint:types (tsc --noEmit) — pass

  • Manual stress test: model count and render latency stay flat across 192 navigations (see Evidence).

Investigation & decision log

Root cause trace

  • site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsxTemplateFiles renders every template file at once.
  • Each changed file → SyntaxHighlighter → a full Monaco DiffEditor.
  • @monaco-editor/react@4.7.0 unmount cleanup does, in effect:
    const model = editor.getModel();
    keepCurrentOriginalModel || model.original.dispose();
    keepCurrentModifiedModel || model.modified.dispose();
    editor.dispose();
    With both keepCurrent* flags set, neither model is disposed, so they leak.
  • The flags exist to stop a storybook hang caused by that same disposal-order error, so they cannot simply be removed.

Options considered

  1. Remove the flags — fixes the leak but re-introduces the mid-teardown throw. Rejected.
  2. Supply stable originalModelPath/modifiedModelPath so models are reused — the library reuses by URI, but its value-sync effect is skipped on the first render, so a reused model renders stale content for the new version. Rejected.
  3. Keep the flags and dispose the captured models when the diff editor unmounts — targeted and correct for the diff → plain → diff case. Chosen.

Why the cleanup is scoped to a child component
The first iteration kept the effect in SyntaxHighlighter with an empty dependency array, so it only ran on full unmount. Review correctly flagged that a surviving instance switching diff → plain would then orphan the previous model pair. Scoping the effect to the DiffFile child ties it to the diff editor's own lifecycle, closing that gap.

Why queueMicrotask
Disposing synchronously in the effect cleanup races Monaco's own widget teardown and throws TextModel got disposed before DiffEditorWidget model got reset. Deferring one microtask lets the widget finish tearing down first.

Test-only editor options
The stories pass renderGutterMenu: false / occurrencesHighlight: "off" through editorProps. Those Monaco features register delayed disposables whose teardown throws under jsdom on unmount; disabling them keeps the test runner clean. They do not affect model count, and production keeps Monaco's default options unchanged.

@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

DEVEX-736

@jakehwll jakehwll changed the title 🤖 fix(site/src/components/SyntaxHighlighter): dispose Monaco diff models on unmount fix(site): dispose Monaco diff models on unmount Aug 24, 2026
@jakehwll
jakehwll force-pushed the jakehwll/DEVEX-736-template-editor-monaco-model-leak branch from 5bd8c67 to 9edb60a Compare August 24, 2026 18:42
Comment on lines +97 to +110
// Dispose the captured diff models on unmount. React runs this after the
// DiffEditor's own cleanup, so the editor is already gone and the models are
// safe to free.
useEffect(() => {
return () => {
const models = diffModelsRef.current;
if (!models) {
return;
}
diffModelsRef.current = null;
models.original.dispose();
models.modified.dispose();
};
}, []);

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.

I don't love that this is an empty dependency useEffect(() => {}) and I toyed with using onMount + onDidDispose variant. Unfortunately this doesn't work.

This is probably a fine enough solution to the problem.

@jakehwll
jakehwll marked this pull request as ready for review August 24, 2026 18:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9edb60a326

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

Comment thread site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx
@jakehwll
jakehwll marked this pull request as draft August 24, 2026 19:09
@jakehwll
jakehwll force-pushed the jakehwll/DEVEX-736-template-editor-monaco-model-leak branch 2 times, most recently from 34890f9 to 5b2310a Compare August 24, 2026 19:13
Comment thread site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx Outdated
@jakehwll
jakehwll force-pushed the jakehwll/DEVEX-736-template-editor-monaco-model-leak branch from 5b2310a to b215971 Compare August 24, 2026 19:29
@jakehwll
jakehwll force-pushed the jakehwll/DEVEX-736-template-editor-monaco-model-leak branch from b215971 to eab9c3f Compare August 25, 2026 02:21
@jakehwll
jakehwll marked this pull request as ready for review August 25, 2026 02:26
@jakehwll
jakehwll requested a review from jeremyruppel August 25, 2026 02:26

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: caa7231bc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

) => {
onMount?.(editor, monacoInstance);

// Capture the models so the cleanup effect can dispose them.

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 Remove the model-capture restatement

The comment only paraphrases the assignment immediately below it and adds no invariant, constraint, or rationale. Remove it to comply with FE4, which prohibits comments that restate identifiers or control flow.

AGENTS.md reference: site/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

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.

@jakehwll
jakehwll merged commit 5008603 into main Aug 26, 2026
26 checks passed
@jakehwll
jakehwll deleted the jakehwll/DEVEX-736-template-editor-monaco-model-leak branch August 26, 2026 03:32
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants