Files
coder/site
Jake Howell 5008603d08 fix(site): dispose Monaco diff models on unmount (#28503)
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Fixes
[DEVEX-736](https://linear.app/codercom/issue/DEVEX-736/template-editor-crashes-becomes-unusable-when-opening-versions).

## 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).

<details>
<summary>Investigation & decision log</summary>

**Root cause trace**
- `site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx` →
`TemplateFiles` 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:
  ```js
  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.
</details>
2026-08-26 10:32:08 +07:00
..
…
…
…