From f16aa87d8b088544db0cfb69048bc450516c2d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 07:16:22 +0000 Subject: [PATCH] the code editor collides with itself: two panels, one model, two buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "two Monaco editors sharing a model" elimination asked which OTHER component mounts Monaco and never asked whether the code editor collides with itself. It does — the panel is addable to a dashboard twice, and useEditorState keeps files in a plain useState, so two panels hold two independent buffers for one globally-keyed model. Verified against @monaco-editor/react@4.7.0's built source rather than inferred. Model lookup is getModel(uri) || createModel(value), so the second mount adopts the first panel's buffer and ignores its own freshly-read value. The value-sync effect then overwrites the shared model with executeEdits. And the flag that suppresses onChange during that write is per-instance, so one panel's programmatic write fires the OTHER panel's onChange — which CodeEditor answers with setContent(path, value). The other panel's buffer becomes this one's content and goes dirty with no keystroke: dirty tab, content nobody typed, disk untouched, fixed by reopening. Exactly the reported shape. Unmount also disposes the shared model (keepCurrentModel defaults false), and key={activeFile.path} means that fires on every tab switch, not only on panel close — so one panel can dispose a model another is still displaying. Not yet a closed case: nothing in the app writes job output into useEditorState, so this produces the right shape but does not explain where the log text comes from. No fix shipped — the two candidates (per-panel model URIs vs sharing the state to match the shared model) differ in whether Monaco's TypeScript resolution keeps working, and that is the owner's call. Trap stays armed. Co-Authored-By: Claude Opus 5 Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420 --- docs/editor-buffer-corruption.md | 47 +++++++++++++++---- ...ground-job-logs-appeared-inside-an-open.md | 19 +++++++- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/editor-buffer-corruption.md b/docs/editor-buffer-corruption.md index 441f2291..2f8aeee5 100644 --- a/docs/editor-buffer-corruption.md +++ b/docs/editor-buffer-corruption.md @@ -22,20 +22,47 @@ Two details from that report do most of the diagnostic work: Do not re-investigate these without new evidence. -| Candidate | Why it is out | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. | -| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. | -| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. | -| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. | +| Candidate | Why it is out | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. | +| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. | +| ~~Two Monaco editors sharing a model~~ **— WRONG, see below** | This asked which OTHER component mounts Monaco and answered `EditorRenderer` (still true: no `path`, so `inmemory://model/N`, no collision). It never asked whether the **code editor collides with ITSELF**. It does — the panel is addable to a dashboard twice. | +| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. | That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic `executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about. -One thing to check first, because it is recent and it is ours: `2b70a570` gave `` a real `path`, -so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share -one model. That should be an improvement, and it is a change in exactly the layer that turns out to be -involved. +## Two code-editor panels share one model and two buffers (2026-08-25) + +The suspicion recorded here — `2b70a570` giving `` a real `path`, so models are keyed by file URI +**globally** rather than per mount — is correct, and it is worse than "should be an improvement". Verified +by reading `@monaco-editor/react@4.7.0`'s built source, not inferred: + +- **Model lookup is `getModel(Uri.parse(path)) || createModel(value, …)`.** An existing model for that path + wins and the second mount's `value` is **ignored at creation**. So the second panel to open a file adopts + the first panel's buffer. +- **The value-sync effect then overwrites it**, because `value !== editor.getValue()`: + `executeEdits('', [{ range: getFullModelRange(), text: value }])`. +- **The guard that suppresses `onChange` during that write is per-INSTANCE.** The package sets an internal + flag around its own `executeEdits` and checks it inside `onDidChangeModelContent`. Panel B's flag is not + panel A's. So B's programmatic write **fires A's `onChange`** with B's content. +- **Unmount disposes the shared model.** `keepCurrentModel` defaults to `false` and we never set it, so the + cleanup calls `editor.getModel()?.dispose()`. And `CodeEditor` mounts ``, + so this runs on every **tab switch**, not only on panel close — switching tabs in one panel disposes the + model another panel is still displaying. + +The other half is ours: `useEditorState` holds `files` in a plain `useState`, so it is **per panel +instance**. Its own comment says so — "this app also mounts as a dashboard panel, and a dashboard can hold +two of them". One shared model, two independent `content` values, and an `onChange` that crosses between +them. `CodeEditor`'s handler does `setContent(activeFile.path, value)`, so panel A's buffer silently becomes +panel B's content and `isDirty` flips with no keystroke — dirty tab, content the user never typed, disk +untouched, fixed by reopening. That is the reported symptom's exact shape. + +**What this does NOT yet explain is how JOB OUTPUT becomes one of those `value`s.** Nothing in the app +writes job output into `useEditorState` — `openFile`/`setContent`/`reloadFile` are called only from +`CodeEditor.tsx`, and the task log renders through shiki. So this is a real defect that produces the right +shape, and it is not proven to be the reported instance. **The trap is still the thing that names the +text's source.** Do not delete it on the strength of this section. ## When it is reported diff --git a/todo/known-bugs/background-job-logs-appeared-inside-an-open.md b/todo/known-bugs/background-job-logs-appeared-inside-an-open.md index c5ffedbc..0e4d1a0b 100644 --- a/todo/known-bugs/background-job-logs-appeared-inside-an-open.md +++ b/todo/known-bugs/background-job-logs-appeared-inside-an-open.md @@ -1,6 +1,6 @@ --- id: known-bugs/background-job-logs-appeared-inside-an-open -title: "Background job logs appeared INSIDE an open file in the code editor" +title: 'Background job logs appeared INSIDE an open file in the code editor' theme: known-bugs status: open created: 2026-08-16 @@ -60,6 +60,23 @@ One thing to rule out first, because it is mine and it is recent: `2b70a570` gav panels open on the same file now share one model. That should be an improvement, but it is a change in exactly the layer that turned out to be involved. +**2026-08-25 — that suspicion was right, and the "ruled out" row above was wrong.** The elimination asked +which OTHER component mounts Monaco and never asked whether the code editor collides with **itself**. It +does: the panel is addable to a dashboard twice, and `useEditorState` keeps `files` in a plain `useState`, +so two panels hold two independent buffers for one globally-keyed model. Verified in +`@monaco-editor/react@4.7.0`'s source: model lookup is `getModel(uri) || createModel(value)` so the second +mount's value is ignored, the value-sync effect then overwrites the shared model with `executeEdits`, and +**the flag that suppresses `onChange` during that write is per-instance** — so one panel's programmatic +write fires the OTHER panel's `onChange`. `CodeEditor` responds with `setContent(path, value)`: the other +panel's buffer becomes this one's content and goes dirty with no keystroke. Unmount also disposes the +shared model (`keepCurrentModel` defaults false), and `key={activeFile.path}` means that happens on every +tab switch, not just on panel close. + +That reproduces the symptom's exact shape — dirty tab, content nobody typed, disk clean, fixed by +reopening. It does **not** explain how job output becomes one of those buffers, and nothing in the app +writes job output into `useEditorState`. So the trap stays armed; it is still the only thing that can name +where the text came from. Full write-up in `docs/editor-buffer-corruption.md`. + Worth noting because it is the reason to look at all: an editor that can show content the file does not contain is a correctness problem even when it never writes — the next person's instinct will be to save the file to "fix" it.