fix(editor): correct URI predicate + relocate close cleanup to App level#1476
Open
brennanb2025 wants to merge 4 commits intomainfrom
Open
fix(editor): correct URI predicate + relocate close cleanup to App level#1476brennanb2025 wants to merge 4 commits intomainfrom
brennanb2025 wants to merge 4 commits intomainfrom
Conversation
…ane diff tabs Two leaks surfaced in PR #1353 review (see docs/changes-mode-followups.md): 1. ChangesModeView rotates the original-side model URI on each HEAD content change, but @monaco-editor/react's `keepCurrentOriginalModel` flag prevents the wrapper from disposing the rotated-out model. Track the previous URI in a ref and dispose it explicitly on rotation and on unmount. 2. EditorPanel's tab-close cleanup disposed only the plain edit model for `case 'edit'`, leaving the kept Changes-mode diff models in the registry. The `case 'diff'` branch had the analogous bug for split-pane URIs. Replace exact-URI lookups with a prefix scan that covers single-pane, split-pane (`::scope`), and rotated-original (`:original:hash`) URI shapes. Extract the predicate to editor-model-leak.ts and unit-test both modes. Also tightens two comments (SourceControl Changes-mode carve-out and EditorPanel inFlightDiffKey IPC sharing scope) to match actual behavior. Co-authored-by: Orca <help@stably.ai>
Two follow-up bugs from PR #1353: 1. findLeakedDiffModelUris compared monaco.Uri.toString() against literal `:` prefixes, but Monaco percent-encodes `:` to `%3A` in toString(). Real-world tab ids are absolute file paths whose `:` characters got encoded, so the predicate matched zero models in production. Fixed by matching against uri.path (which preserves literal `:`) and renamed the helper to findLeakedDiffModelPaths to reflect the new shape. 2. The tab-close cleanup useEffect lived inside EditorPanel, but EditorPanel is conditionally mounted on the active tab — so when the active tab is closed, the panel unmounts before its effect can observe the change. Extracted the cleanup into useEditorTabCloseCleanup and mount it at App level so it observes openFiles regardless of which panel is active. Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Orca <help@stably.ai>
… drop unmount-time disposal The unmount useEffect fired on every tab switch (not just tab close), defeating keepCurrentOriginalModel and duplicating the App-level useEditorTabCloseCleanup. The remaining rotation effect now also disposes the previously-retained model when originalModelUri transitions to null (dc flips from text to binary/undefined while the tab stays open), so it doesn't linger until tab close. Co-authored-by: Orca <help@stably.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two follow-up bugs from the merged fix in #1353 (commit
ffaca13b):Bug 1 — predicate compared against the wrong URI form
findLeakedDiffModelUris(ineditor-model-leak.ts) comparedmonaco.Uri.toString()against literal:prefixes. Monaco'sURI.toString()percent-encodes:to%3Ain the path segment, so real-world URIs likediff:modified%3A/Users/me/file.md%3A%3Ascopenever matched the predicate'sdiff:modified:/Users/me/file.md::prefix. The unit test passed because it used synthetic tab ids liketab-abcthat contained no encoded characters — the regression class real production tabs (file paths with:) silently fell into.Fix: the helper now operates on the URI's
schemeandpath(which preserve literal:) instead oftoString(). RenamedfindLeakedDiffModelUris(uris: string[], …)→findLeakedDiffModelPaths(models: { scheme; path }[], …). Predicate prefixes drop thediff:scheme (now checked separately viascheme === 'diff') and match againstpath. Added an'absolute file path'test that exercises the exact path shape Monaco produces for file-path tab ids.Bug 2 — cleanup effect lived on a component that unmounts at tab close
The close-cleanup
useEffectwas insideEditorPanel. ButEditorPanelis conditionally mounted on the active tab — when the user closes the active tab, the panel unmounts before its effect can observe the change. Confirmed empirically: aftercloseFile, no[debug-effect-fire]log printed because the panel had already unmounted (debug-unmount-firefired first). This means the existingcase 'edit',case 'diff',case 'markdown-preview'cleanup branches all silently failed for the active tab. Other panels could pick up the slack in multi-pane setups, but a single-pane close leaked everything.Fix: extracted the entire cleanup into
useEditorTabCloseCleanupand mount it at App level alongsideuseGitStatusPolling— App is always mounted, so the effect always observesopenFileschanges regardless of which panel is active.Files changed
src/renderer/src/App.tsx— import + calluseEditorTabCloseCleanup(). Comment cross-references the hook file for the architectural why.src/renderer/src/components/editor/EditorPanel.tsx— removed the entire close-cleanup block (~85 lines) and the now-unusedprevOpenFilesRef,deleteCacheEntriesByPrefix. Dropped imports that were only used by the removed code:monaco,scrollTopCache/cursorPositionCache/diffViewStateCache,findLeakedDiffModelPaths. Left a short comment pointing readers to the hook.src/renderer/src/components/editor/editor-model-leak.ts— predicate now keyed on{scheme, path}; function renamed.src/renderer/src/components/editor/editor-model-leak.test.ts— tests updated to the new shape; added an absolute-file-path regression test that locks in the encoding-mismatch fix.src/renderer/src/components/editor/use-editor-tab-close-cleanup.ts— new file; ownsprevOpenFilesRef, the switch overprevFile.mode, and a module-leveldeleteCacheEntriesByPrefixhelper. Cleanup logic is byte-identical to what was inEditorPanel— same switch, same disposal calls, same cache evictions; only the location changed.The cleanup effect was reviewed by parallel Claude
/review-code, Claude/review-algorithm-architecture, Claude deletion-impact, and Codex/review-codeagents — all four reported zero must-fix issues.Test plan
pnpm exec vitest run src/renderer/src/components/editor/editor-model-leak.test.ts— 10/10 passpnpm exec tsc --noEmit -p config/tsconfig.web.json— clean (the two pre-existingworktree-logic/wsl.tserrors are unrelated)Manual verification in running Electron build (port 9333) via
playwright-cli eval:Bug 1 — encoding fix: Drove a real
monaco.Uri.parse()against a real-world Changes-mode path (/Users/me/repo/file.md::scope:original:hash):uri.toString()→modified%3A/Users/me/repo/file.md%3A%3Ascope-id-1(percent-encoded). OldtoString()-based predicate matched 0 of 2 paths (reproduces the production bug).uri.path→modified:/Users/me/repo/file.md::scope-id-1(literal:). New predicate matched 2 of 2 paths.Bug 2 — App-level hook fires for non-active tabs: Created 4 kept-alive Monaco diff models (single-pane modified, split-pane modified, rotated original, split-pane rotated original) for a synthetic edit-mode tab whose id is a file path with embedded
:. Inserted intoopenFiles, removed without ever activating it (so noEditorPanelwas ever mounted for it — the exact failure mode the old code had). Result: all 4 models disposed (isDisposed() === true) and no leftover diff models for the tab id.Diff-mode + collision sanity: Repeated with a production-shape diff tab id (
fake-wt::diff::unstaged::synthetic.md). All 4 target models disposed. A near-prefix unrelated tab (fake-wt::diff::unstaged::synthetic.md.txt) was correctly not disposed — confirms the::boundary in the predicate prevents prefix-collision false-positives.User session integrity: 8 user-open tabs unchanged before/after every test step.
Made with Orca 🐋