Skip to content

RestoreFromState wipes the whole history and returns true when the state is built from service.Commands / service.SaveBoundaries #118

Description

@matt-edmondson

What's wrong

UndoRedoService.RestoreFromState (UndoRedo/Services/UndoRedoService.cs, ~lines 386-430) validates the state, then clears the live stack and save boundaries, and only afterwards enumerates state.Commands and state.SaveBoundaries:

_stackManager.Clear();
_saveBoundaryManager.Clear();

foreach (ICommand command in state.Commands) { ... }          // already empty
...
foreach (SaveBoundary boundary in state.SaveBoundaries) { ... } // already empty
return true;

But the public IUndoRedoService.Commands and IUndoRedoService.SaveBoundaries are live views, not copies:

  • StackManager.cs:15 — Commands => _commands.AsReadOnly()
  • SaveBoundaryManager.cs:20 — SaveBoundaries => _saveBoundaries.AsReadOnly()

UndoRedoStackState is a plain record that stores whatever IReadOnlyList it is given, so a state built from those properties aliases the lists that Clear() empties.

Failure scenario

// 3 commands executed, then MarkAsSaved()
var state = new UndoRedoStackState(service.Commands, 0, service.SaveBoundaries, "1.0", DateTime.UtcNow);
bool ok = service.RestoreFromState(state);
  • Actual: ok == true, CommandCount == 0, CurrentPosition == -1, no save boundaries. The entire history is silently destroyed while the call reports success.
  • Expected: 3 commands, position 0, 1 save boundary.

Variant: copying the commands but passing the live service.SaveBoundaries keeps the commands but drops every save boundary, so HasUnsavedChanges reports true at the saved position.

GetCurrentState() copies its lists, so it is unaffected; the trap is only for callers who assemble a state from the public properties (e.g. "jump to position N, keep the history"), which nothing in the API warns against. Related to, but distinct from, #95 (lazy query results over the live stack).

Reproduced with a scratch MSTest probe on .NET 10: the live-collections case prints ok=True count=0 pos=-1 boundaries=0; the live-boundaries-only case prints ok=True count=3 pos=2 boundaries=0 unsaved=True.

Suggested fix

Snapshot the collections at the top of RestoreFromState, before validation and clearing, and use only the snapshots afterwards:

List<ICommand> commands = [.. state.Commands];
List<SaveBoundary> boundaries = [.. state.SaveBoundaries];

(Optionally also make UndoRedoStackState / the Commands and SaveBoundaries properties return copies, though the local snapshot alone fixes the bug.)

Acceptance criteria

  • Restoring a state built from service.Commands and service.SaveBoundaries keeps all commands, lands at the requested position, and keeps the boundaries; HasUnsavedChanges is false at the saved position.
  • Regression tests for both scenarios above; existing tests still pass.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreadyFully specified; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions