Skip to content

Fix EditContext desynchronization when extensions consume type commands - #348384

Open
Sabyasachi Panda (SabyaXSachi) wants to merge 2 commits into
microsoft:mainfrom
SabyaXSachi:fix/editcontext-type-command-desync
Open

Sabyasachi Panda (SabyaXSachi) wants to merge 2 commits into
microsoft:mainfrom
SabyaXSachi:fix/editcontext-type-command-desync

Conversation

@SabyaXSachi

Copy link
Copy Markdown

Summary

When an extension overrides the editor's type command and consumes it without applying a text edit (a common pattern for modal editing extensions), the native EditContext becomes desynchronized from the editor model. The next keystroke is then dispatched as a replacePreviousChar command computed from the desynchronized state, which replaces parts of the document with the typed characters instead of typing them. No composition is involved.

Root cause

NativeEditContext keeps the browser's EditContext in sync with the model in _updateEditContext (src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts). The browser modifies its EditContext text natively before firing a textupdate event, and that change was only mirrored back when the triggered type command applied a text edit, because the model change triggered a resynchronization (onDidChangeContent → _updateEditContext).

_updateEditContext decided whether to rewrite the EditContext by comparing the model text against _previousEditContextText, the text previously written by VS Code, which is never updated when the browser mutates its own EditContext. When the type command is consumed without an edit, the only resynchronization attempt happens on the selection change caused by the extension (for example cancelSelection), and that comparison wrongly concludes that both are in sync, leaving the EditContext containing the typed character.

On the next keystroke, _emitTypeEvent diffs against the desynchronized state and computes a spurious replacePrevCharCnt, which the command delegate routes to the replacePreviousChar command — the corruption reported in the issue.

Solution

Base the resynchronization decision on the live EditContext text (this._editContext.text) instead of the previously written mirror, and use its actual length for the updateText range. The stale mirror is removed.

  • In all healthy states the mirror was equal to the live text, so the behavior is unchanged there (one identical-content rewrite per selection-replacing keystroke is even avoided).
  • In desynchronized states the comparison now detects the difference and heals the EditContext at the next synchronization point (cursor change or model change).
  • Selection bookkeeping is untouched. Because the text is rewritten before updateSelection (unchanged order), the browser no longer clamps the EditContext selection in the desynchronized case.

Testing

New regression test issue #348222: EditContext stays in sync when an extension consumes the type command without applying a text edit in src/vs/editor/test/browser/widget/codeEditorWidget.test.ts:

  • Registers the extension override from the issue (type is consumed, cancelSelection runs, no text edit is applied).
  • Simulates the browser contract against a real EditContext: programmatic updateText/updateSelection (a native mutation fires no events) plus synthetic TextUpdateEvent dispatch, for two keystrokes over a full-line selection.
  • Asserts that the first keystroke is dispatched as type with 'x', that the EditContext is resynchronized to the model text, that the second keystroke is dispatched as type with 'y' and not as replacePreviousChar, and that the model is never modified.

Note: the test stubs ICommandService with a wired-up TestCommandService because the instance registered by createCodeEditorServices cannot dispatch commands (its constructor argument is not a decorated service and is therefore never provided). The same pattern exists in sessionsWorkbenchFixtureUtils.ts.

Executed checks (Chromium via test/unit/browser/index.js, Node 24.22.1 as required by .nvmrc):

  • Regression test against the unfixed code (commit 96bdcf26): failed with AssertionError: "x" === "hello world", i.e. the EditContext still contained the first keystroke — the reported desynchronization.
  • Regression test against the fixed code (commit af419a7b): passed.
  • Full editor test tree vs/editor/**/*.test.js: 6606 passed, 0 failed (39 pending are environment-dependent skips).
  • npm run typecheck-client: passed.
  • npm run oxlint (full repository): passed.
  • git diff --check: clean.
  • gulp hygiene could not run on this Windows machine: build/gulpfile.hygiene.ts computes dirName via new URL(import.meta.url).pathname, which produces a C:\C:\... path on Windows and fails in check-package-json before any file is checked. This is a pre-existing tooling issue independent of this change.

Risk

  • IME/composition: composition textupdates flow through the same synchronization path, and the comparison is strictly more accurate than the previous mirror (which could also go stale mid-composition). Composition event dispatch and compositionType behavior are unchanged.
  • Extensions that apply real edits: unchanged — the model edit already resynchronized the EditContext, and the new comparison produces the same or fewer writes.
  • Undo/redo and cursor positioning: untouched; the selection bookkeeping (_previousEditContextSelection) is unchanged.

Issue reference

Fixes #348222

Registers the extension command override from microsoft#348222
(type is consumed without applying a text edit) and simulates two
keystrokes against a real EditContext: the first one replaces the
selected EditContext text, the second one inserts a character.

Without the fix, the EditContext is not resynchronized to the model
after the consumed type command, so the second keystroke is dispatched
as replacePreviousChar and corrupts the document. The test fails with
the EditContext still containing the first keystroke.
The browser modifies its EditContext natively before firing
'textupdate', and that change was only mirrored back to the EditContext
when the triggered 'type' command applied a text edit, because the model
change triggered a resynchronization. When an extension consumes the
'type' command without applying an edit, _updateEditContext compared the
model text against the previously written text instead of the actual
EditContext text, wrongly concluded that both were in sync and left the
EditContext containing the typed character.

The next keystroke was then diffed against the desynchronized state and
dispatched as a 'replacePreviousChar' command, replacing parts of the
document with the typed characters.

Base the resynchronization decision on the live EditContext text so the
desynchronization is detected and healed, and drop the stale mirror.

Fixes microsoft#348222
Copilot AI balanced review requested due to automatic review settings November 15, 2026 08:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues remain for the EditContext synchronization change.

No open issues 🎉

What changed in this PR

Fixes #348222 by synchronizing the browser’s native EditContext with the editor model when an extension consumes a type command without editing text, preventing subsequent keystrokes from incorrectly replacing document content.

Changes:

  • Compares model text with live EditContext text and uses its actual length when resynchronizing, removing the stale text mirror.
  • Adds a two-keystroke regression test covering command routing, resynchronization, and preservation of model content.
File Description
src/​vs/​editor/​test/​browser/​widget/​codeEditorWidget.test.ts Adds regression coverage for an extension consuming type and cancelling selection.
src/​vs/​editor/​browser/​controller/​editContext/​native/​nativeEditContext.ts Uses live EditContext text to detect and repair desynchronization.

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EditContext becomes desynchronized when an extension consumes "type"-command without applying a text edit

3 participants