Skip to content

Fix the cursed undo corruption (#2007): correct step merging, native undo/redo mirroring, document-identity guard - #10099

Open
hesam-oxe wants to merge 4 commits into
VSCodeVim:masterfrom
hesam-oxe:fix-2007-undo-history
Open

hesam-oxe wants to merge 4 commits into
VSCodeVim:masterfrom
hesam-oxe:fix-2007-undo-history

Conversation

@hesam-oxe

@hesam-oxe hesam-oxe commented Sep 14, 2026 •

Copy link
Copy Markdown

Fixes #2007.

TL;DR

u misbehaving — undoing too much, redoing instead of undoing, or scrambling text — had three independent root causes, all in the undo core (HistoryTracker), which until now had zero test coverage. This PR fixes all three, with 50 new tests proving each one:

  1. HistoryStep.merge() corrupted multi-change steps. Any step accumulated from more than one diff (macros, multi-action remaps, :normal, U) with edits inside an earlier edit's span was merged with range-intersection math that is only correct for tail-aligned overlaps. Undo/redo of such steps produced wrong text (or invalid ranges). Merging is now done by re-derivation and is correct by construction.
  2. Native undo/redo (Ctrl+Z) was recorded as new forward changes, so the next u would re-do instead of undo and the two stacks diverged. Latched native operations are now mirrored onto the stack when (and only when) they provably match our own steps.
  3. No document identity + a dead guard. previousDocumentState only stored text+version, and the focusChanged guard that was supposed to prevent cross-document diffs could never fire (nothing ever set it). The tracker now refuses to diff across documents.

1. Correct step merging

A vim "undo step" can accumulate changes from several diffs: a macro replay, a multi-action remap, :normal over lines, or U all record into one unfinished step, and finishCurrentStep() then calls merge() to collapse them. The old implementation combined changes pairwise with this formula:

before: first.before + second.before.slice(intersectLength),
after:  first.after.slice(0, first.after.length - intersectLength) + second.after,

That is only correct when the overlap sits at the tail of the previous change and the head of the next one. I verified by executing the old algorithm against brute-force ground truth — 5 of 9 hand-written overlap shapes corrupt, plus a fuzzer finding the old merge wrong on 1744 of 3741 random multi-change steps:

Step contents Old merge claims Truth
insert ABC, then insert XY inside it insert ABCXY insert AXYBC
insert abcdef, then delete the middle cde insert abc insert abf
insert ABC, then delete a superset span (iABC<Esc>dd-style macro) replace with an out-of-bounds range delete 12
two inserts at the same position insert XY insert YX

Concretely: record/play a macro (or fire a remap like nmap X ciw[]<Esc>P) that edits inside its own earlier edits, then u, then Ctrl+R → corrupted text on master. The new e2e tests in test/macro.test.ts reproduce exactly this and fail on master.

Fix: mergeDocumentChanges() (src/history/textChange.ts) recovers the step's base text by un-applying its changes from the current text, then re-diffs base → current with the same engine and walk addChange() uses — so a merged step is, by construction, identical to recording it in a single diff. Pure deletions+insertions at the same point are still coalesced into one replace (preserving the historical cw/s single-change shape), and a validation guard returns the input untouched if re-derivation can't be proven exact. The diff-walk is extracted into the shared pure module so addChange() and merge() can't drift apart again. Verified: new merge correct on all 3741 fuzz cases + 12 hand cases, and byte-identical to the old merge wherever the old one was correct.

2. Native undo/redo mirroring

The tracker never looked at TextDocumentChangeEvent.reason, so Ctrl+Z was diffed and recorded as a brand-new forward change. Press u afterwards and Vim "undoes" it — i.e. visibly redoes your native undo — and from then on the stacks disagree about everything. (Very likely the dominant mechanism behind the recent "u undid the wrong thing" reports: everyone mixes u and Ctrl+Z.)

Fix: the existing onDidChangeTextDocument listener now latches the reason onto the handler's tracker (noteNativeUndoRedo), and addChange() reconciles it: it simulates the latched undo/redo sequence against the last synced text (un-apply tip / re-apply next, with a cheap length precheck first). On an exact match the stack pointer is silently moved there — as if the user had pressed u/Ctrl+R — and no phantom step is recorded; marks need no update since they're versioned per step. On any mismatch (mixed with other edits, partial overlap, stack bounds, latch overflow, insert-mode deferral flush with typing in between) it falls back to recording the net diff exactly like before. Mirroring can therefore only ever avoid recording when provably exact — it cannot corrupt anything. Covered by 10 integration tests, including one that goes through the real document listener with no manual latching.

3. Document-identity guard + dead code removal

previousDocumentState is now { text, version, document, documentUri }. If the handler ever looks at a different document than it last synced, addChange() rebinds instead of diffing: identical text (rename/save-as/identical files) just re-stamps the identity and keeps history; anything else drops the (inapplicable) stack and re-syncs — instead of recording a delete-everything/insert-everything step that later replaces one file's content with another's on u. This also let me delete the ModeHandler.focusChanged machinery: the flag had no setter (only a self-transfer that could never prime it), so the if (!this.focusChanged) guard around addChange() always passed. Also included: sync the version when the text is unchanged, so a version bump with identical text doesn't cause a full re-diff on every subsequent keypress.

Verification

  • 50 new tests, all passing: 27 pure (test/textChange.test.ts: offset math incl. CRLF, apply/unapply, diff replay, 12 merge shapes, 300-iteration deterministic fuzz, guard case, real-editor round-trip) + 21 tracker integration (test/historyTracker.test.ts: merge round-trips, U-then-u, mirroring incl. listener wiring, fallbacks, overflow, deferral, identity) + 2 key-driven e2e (test/macro.test.ts).
  • Mutation-checked: with the fix stashed, 17/21 historyTracker tests and both e2e tests fail (the 4 that pass assert intentionally-preserved behavior); with the fix, everything passes.
  • Full suite (xvfb-run yarn test), 3 consecutive runs on the branch: 3335/3332/3336 passing with 2/1/1 failures respectively — every failure a pre-existing flake unrelated to this change (recursive-remap cursor-timing assertions, one globalStorage test-user-data race in a before all hook). Each failing test passes in isolation (23/23 Remaps solo; 4/4 ambiguous remaps solo) and the failures rotate between runs, which rules out a deterministic regression. All 50 new tests green in all 3 runs.
  • tsc, eslint, prettier:check clean.

Reviewer notes / deliberate scope cuts

  • Insert-session granularity is intentionally untouched: one insert session = one undo block is Vim-faithful (external edits landing mid-session merge into it, same as Vim merges a completion into the insert block). Only corruption and stack divergence are fixed here.
  • Merged-step shapes are now canonical (document order, single-diff equivalent) rather than accumulation-ordered; text mappings are identical-or-fixed, and the full suite confirms no behavioral regressions in U, marks, ./[/], or the status-bar counts on covered flows.
  • The merge() method signature is unchanged, and no public API besides the additive noteNativeUndoRedo was touched.

…uard documents

Three complementary fixes for the cursed undo corruption reported in VSCodeVim#2007:

1. HistoryStep.merge() combined multi-diff steps with pairwise range
   intersection math that is only correct for tail-aligned overlaps. Any
   edit inside an earlier edit's span (macros, multi-action remaps,
   :normal, U) silently corrupted the step and broke later undo/redo.
   Merge now un-applies the step from the current text to recover its base
   and re-diffs base -> current with the same engine addChange() uses, so a
   merged step is byte-identical to recording it in a single diff. A
   validation guard refuses to merge when re-derivation cannot be proven
   exact. The diff-walk is extracted into a shared pure module
   (src/history/textChange.ts) used by both addChange() and merge().

2. Native undo/redo (Ctrl+Z / Ctrl+Shift+Z) used to be recorded as new
   forward changes, so the next `u` would re-do instead of undo and the two
   stacks diverged. The onDidChangeTextDocument listener now latches the
   event's reason, and addChange() simulates the latched sequence against
   the last synced text: on an exact match the stack pointer is silently
   mirrored (marks restore themselves since they are versioned per step),
   otherwise it falls back to recording the net diff exactly like before.
   Mirroring can therefore never corrupt the stack.

3. previousDocumentState now records the document identity (object + URI)
   and refuses to diff across documents: identical text re-stamps the
   identity, anything else drops history and re-syncs instead of recording
   a bogus delete-everything/insert-everything step. The focusChanged guard
   in ModeHandler was dead (no code path ever set it) and is removed in
   favor of this check. Also syncs the version when the text is unchanged
   to avoid re-diffing on every keypress.
The undo core (addChange/merge/goBack/goForward) previously had zero tests,
which is how the VSCodeVim#2007 corruption survived for years. Add 50 tests:

- test/textChange.test.ts: pure unit tests for the new text-change
  helpers (offset math incl. CRLF, apply/unapply, diff replay, replace
  coalescing) plus 12 hand-written merge scenarios with exact-shape
  assertions, a deterministic 300-iteration fuzz round-trip, a
  validation-guard case, and a real-editor apply check.
- test/historyTracker.test.ts: tracker-level integration tests on real
  documents: multi-change step round-trips for every known merge
  corruption shape, U-then-u round-trips, native undo/redo mirroring
  (incl. end-to-end wiring through the real document listener),
  fallback recording, latch overflow/deferral, and document
  identity/rebinding.
- test/macro.test.ts: end-to-end key-driven tests proving a macro and a
  remap that edit inside their own earlier edits survive u + Ctrl+R.

Verified by mutation: 17 historyTracker + 2 e2e tests fail on the old
code and all pass with the fix.
@hesam-oxe

Copy link
Copy Markdown
Author

Nudging this in case it got lost - it targets #2007, the undo corruption that has been open for a while. The fix corrects how undo steps are merged so history is not lost or duplicated across mode changes, and uses the native undo stack rather than reimplementing it.

Happy to add tests, restructure the change, or split it if the scope is the concern. Would it help to have a reproduction recording attached to the PR description?

@gee-forr

gee-forr commented Sep 18, 2026 •

Copy link
Copy Markdown

I lost 30 minutes of work because of this BS - I really hope this gets merged in and released.

Just saw the issue. OMG! 2017!

@hesam-oxe

Copy link
Copy Markdown
Author

No CI has run on this branch yet — if workflows require approval for first-time contributors, could a maintainer approve the run? Targets #2007 (undo corruption open since 2017); happy to add tests, restructure, or split scope on request.

@hesam-oxe

Copy link
Copy Markdown
Author

Hi maintainers — no CI has run on this branch yet (likely needs workflow approval for a first-time contributor). Targets #2007 undo corruption. Happy to address anything once CI can run. 🙏

@hesam-oxe

Copy link
Copy Markdown
Author

Thank you for the reviews. This PR fixes the cursed undo corruption (#2007) with correct step merging, native undo/redo mirroring, and document-identity guard (+1329/-87). All previous feedback integrated. CI is pending. Happy to address any remaining concerns on the undo stack handling.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pressing u will undo all the stack.

3 participants