fix(site): dispose Monaco diff models on unmount - #28503
Conversation
5bd8c67 to
9edb60a
Compare
| // 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(); | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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(); | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
34890f9 to
5b2310a
Compare
5b2310a to
b215971
Compare
b215971 to
eab9c3f
Compare
There was a problem hiding this comment.
💡 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. |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
why is this not a cleanup on the effect creating the resources? does this leak when onMount changes and this effect doesn't refire?
There was a problem hiding this comment.
🤖 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.

Fixes DEVEX-736.
Problem
Opening template versions in the template editor leaks Monaco text models. Each version renders one
DiffEditorper changed file, andSyntaxHighlightersetskeepCurrentOriginalModel/keepCurrentModifiedModel, which tells@monaco-editor/reactnot 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
DiffEditorWidgetis torn down), which throws.Fix
Keep the
keepCurrent*Modelflags, but move the diff editor into its ownDiffFilecomponent that owns the model cleanup:DiffFilecaptures theoriginal/modifiedmodels inonMountand 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 whileSyntaxHighlighterstays mounted (thekey={filename}case raised in review), not only on full unmount.queueMicrotaskso it runs after React's commit and after@monaco-editor/reactdisposes the widget. Freeing the models in the same synchronous teardown throwsTextModel 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
.tffiles each), driving 192 in-app version navigations (single SPA session, no page reloads) and readingmonaco.editor.getModels().lengthvia temporary instrumentation.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.tsxwith aplayregression test (DisposesModelsOnDiffToggle) that reproduces the exact leak: it toggles a single survivingSyntaxHighlighterbetween diff and plain three times and assertsmonaco.editor.getModels().lengthdrops 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 check(biome) — passpnpm lint:types(tsc --noEmit) — passManual 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.tsx→TemplateFilesrenders every template file at once.SyntaxHighlighter→ a full MonacoDiffEditor.@monaco-editor/react@4.7.0unmount cleanup does, in effect:keepCurrent*flags set, neither model is disposed, so they leak.Options considered
originalModelPath/modifiedModelPathso 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.Why the cleanup is scoped to a child component
The first iteration kept the effect in
SyntaxHighlighterwith 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 theDiffFilechild ties it to the diff editor's own lifecycle, closing that gap.Why
queueMicrotaskDisposing 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"througheditorProps. 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.