What's wrong
JsonUndoRedoSerializer falls back to a PlaceholderCommand whenever it can't rebuild a saved command. That happens when Data is null, or when Type.GetType can't resolve the type, for example because a plugin assembly isn't loaded yet. PlaceholderCommand (JsonUndoRedoSerializer.cs ~line 262) keeps only the description, navigation context and metadata. It throws away the original type string and data.
On the next SaveStateAsync, ConvertToSerializableCommand (~line 123) serialises the placeholder like any other command:
Type is written as ktsu.UndoRedo.Core.Services.PlaceholderCommand, ...
Data is written as null, because a placeholder isn't an ISerializableCommand
Description is written as the already-prefixed "[Placeholder] …"
This has two consequences:
- Data loss. Suppose a file is loaded while a plugin is missing and then saved, for instance by an autosave. The commands that could have been rebuilt once the plugin was back are gone for good.
- Growing prefix. Every save/load cycle adds another
"[Placeholder] ".
Repro
var svc = new UndoRedoService(new StackManager(), new SaveBoundaryManager(), new CommandMerger());
svc.SetSerializer(new JsonUndoRedoSerializer());
// Prefix growth
svc.Execute(new DelegateCommand("Type hello", () => { }, () => { }));
for (int i = 0; i < 3; i++)
{
await svc.LoadStateAsync(await svc.SaveStateAsync());
Console.WriteLine(svc.Commands[0].Description);
}
// [Placeholder] Type hello
// [Placeholder] [Placeholder] Type hello
// [Placeholder] [Placeholder] [Placeholder] Type hello
// Data loss
string json = """{"commands":[{"type":"My.Plugin.SetTextCommand","description":"Set text","data":"hello","metadata":{"changeType":"Modify","affectedItems":[],"timestamp":"2026-01-01T00:00:00+00:00","size":1}}],"currentPosition":0,"saveBoundaries":[],"formatVersion":"json-v1.0","timestamp":"2026-01-01T00:00:00Z"}""";
await svc.LoadStateAsync(Encoding.UTF8.GetBytes(json)); // true; Commands[0] is a PlaceholderCommand
Console.WriteLine(Encoding.UTF8.GetString(await svc.SaveStateAsync()));
// "type":"ktsu.UndoRedo.Core.Services.PlaceholderCommand, …","description":"[Placeholder] Set text" and no "data":
// the original type "My.Plugin.SetTextCommand" and data "hello" are gone
Expected: a placeholder round-trips the original type, description and data unchanged. It should also keep a single [Placeholder] prefix on the description it displays.
Suggested fix
- Give
PlaceholderCommand OriginalType, OriginalDescription and OriginalData. Set them from the SerializableCommand in both placeholder branches of ConvertFromSerializableCommand, the null-data branch and the unresolvable-type branch.
- In
ConvertToSerializableCommand, when command is PlaceholderCommand p, write Type = p.OriginalType, Description = p.OriginalDescription and Data = p.OriginalData.
- Apply the
"[Placeholder] " prefix only to the displayed Description.
Related gap in the same file (a smaller variant, can be fixed alongside)
ValidateShape (~line 92) rejects metadata that is null but not a missing affectedItems. A saved file whose command metadata leaves out affectedItems loads successfully, and LoadStateAsync returns true, but Metadata.AffectedItems is null even though the type is non-nullable. Any later .Count read throws NullReferenceException, and so does new CompositeCommand("c", [loadedCmd]) via SelectMany(c => c.Metadata.AffectedItems). To stay consistent with the #81 contract, reject it in ValidateShape, or normalise it to [].
Acceptance criteria
- A save → load → save → load cycle keeps the description stable.
- Loading a command with an unresolvable type and saving again reproduces the original
type and data byte-for-byte.
- Metadata with no
affectedItems makes LoadStateAsync return false, or loads with an empty list.
What's wrong
JsonUndoRedoSerializerfalls back to aPlaceholderCommandwhenever it can't rebuild a saved command. That happens whenDatais null, or whenType.GetTypecan't resolve the type, for example because a plugin assembly isn't loaded yet.PlaceholderCommand(JsonUndoRedoSerializer.cs~line 262) keeps only the description, navigation context and metadata. It throws away the originaltypestring anddata.On the next
SaveStateAsync,ConvertToSerializableCommand(~line 123) serialises the placeholder like any other command:Typeis written asktsu.UndoRedo.Core.Services.PlaceholderCommand, ...Datais written asnull, because a placeholder isn't anISerializableCommandDescriptionis written as the already-prefixed"[Placeholder] …"This has two consequences:
"[Placeholder] ".Repro
Expected: a placeholder round-trips the original
type,descriptionanddataunchanged. It should also keep a single[Placeholder]prefix on the description it displays.Suggested fix
PlaceholderCommandOriginalType,OriginalDescriptionandOriginalData. Set them from theSerializableCommandin both placeholder branches ofConvertFromSerializableCommand, the null-data branch and the unresolvable-type branch.ConvertToSerializableCommand, whencommand is PlaceholderCommand p, writeType = p.OriginalType,Description = p.OriginalDescriptionandData = p.OriginalData."[Placeholder] "prefix only to the displayedDescription.Related gap in the same file (a smaller variant, can be fixed alongside)
ValidateShape(~line 92) rejects metadata that is null but not a missingaffectedItems. A saved file whose command metadata leaves outaffectedItemsloads successfully, andLoadStateAsyncreturnstrue, butMetadata.AffectedItemsisnulleven though the type is non-nullable. Any later.Countread throwsNullReferenceException, and so doesnew CompositeCommand("c", [loadedCmd])viaSelectMany(c => c.Metadata.AffectedItems). To stay consistent with the #81 contract, reject it inValidateShape, or normalise it to[].Acceptance criteria
typeanddatabyte-for-byte.affectedItemsmakesLoadStateAsyncreturnfalse, or loads with an empty list.