How to safely ignore some changes while using the UndoManager?

Hello,

I’m using GoJS version 1.7.5. My diagram has two kinds of user interactions:

  1. ephemeral, non-important changes: on mouve-over, some links are highlighted, also sometimes nodes are decorated/highlighted… various stuff
  2. important changes: on click, some nodes/groups are expanded, nodes and links are added and removed, all in a transaction.

My goal is to allow users to undo/redo the important changes, without bothering about ephemeral changes. Until now I have tried two strategies unsuccessfully:

  1. Wrap all ephemeral changes in skipsUndoManager = true; ...; skipsUndoManager = false, but that led to the same kinds of problems that are mentioned in How can I skip the default transaction in undoManager? - #4 by walter

    In my case, after an undo, links where left floating in the void, not connected to any nodes. Also, sometimes, during an undo, I had warnings about link points being changed out a transaction.

  2. Just today, I saw the previous issue where Walter recommends to not use skipsUndoManager so I tried another strategy:

    • always have a top-level transaction open, to record all the ephemeral changes
    • just before an important change, rollback that transaction
    • go on with the important change
    • then open again the ephemeral-event-transaction.

    That didn’t work, because it seems to me that GoJS kills my “ephemeral transaction” pretty quickly, so that ephemeral changes trigger warnings about “not in a transaction”. Then I saw that other issue which says that transactions should be short: How to set start transaction commit time in Gojs and I gave up.

What should I do? Is there a way to get one of my two strategies to work, or is there a third way to accomplish that? Thanks in advance.

Jany Belluz

skipsUndoManager should be what you want, but you have to be very careful in the book-keeping to make sure you are not leaving the value true for indeterminite periods of time.

I suggest commenting out all uses of skipsUndoManager in your code and slowly reintroducing them while testing to see what might be the culprit.

In fact I had all my ephemeral modifications wrapped in this helper:

  function skipsUndoManager (callback) {
    var oldValue = globalDiagram.skipsUndoManager;
    globalDiagram.skipsUndoManager = true;
    callback();
    globalDiagram.skipsUndoManager = oldValue;
  }

like this

skipsUndoManager(function () { globalDiagram.doStuff() });

So I guess that wasn’t my problem?

I had a new idea that I will explore: I will also commit small transactions for ephemeral transactions, and when the user asks for an undo, I will undo all the the ephemeral transactions and then one important transaction.

In case its not clear, when you have the skipsUndomanager flag set to true, you should still be using startTransaction and commitTransaction, because without doing so, you cannot guarantee that the updates will happen right away. They may happen at a “later” time which may be after the skipsUndoManager flag is reset.

Thanks for the clarification, I had indeed not understood that part. I tried just now to modify my helper like that:

  function skipsUndoManager (callback) {
    var oldValue = globalDiagram.skipsUndoManager;
    globalDiagram.skipsUndoManager = true;
    globalDiagram.startTransaction('ephemeral');
    callback();
    globalDiagram.commitTransaction('ephemeral');
    globalDiagram.skipsUndoManager = oldValue;
  }

However, I observe an undesired behaviour: the commitTransaction('ephemeral') seems to add empty transactions to the undo stack, and/or erase the redo stack. I haven’t investigated much, but the results are certainly not what I would expect, i.e. that using transactions while skipsUndoManager == true should be a no-op (except for the synchronization that commitTransaction enforces, according to your previous explanation).

Am I missing something else? Thanks in advance.

I don’t understand why you need to do anything special. Perhaps we need to talk about precisely the situations that you want to be handled differently.

For example, mouseOver events happen with skipsUndoManager temporarily set to true: GraphObject | GoJS API, so you should already be getting the behavior you want with no effort on your part.

Furthermore all of the expand and collapse commands, such as the click event handler for “SubGraphExpanderButton”, defined in http://gojs.net/latest/extensions/Buttons.js, call CommandHandler methods such as CommandHandler | GoJS API. Such methods are defined to execute the changes within a transaction, and they do not try to skip any recording by the UndoManager.

It is true that in some event handlers, such as GraphObject | GoJS API, if you are going to make changes to the model or to the diagram you may need to execute a transaction.

So I don’t understand why what you want to do doesn’t just work correctly without much effort on your part.

Here is a simplified version of what I do to highlight nodes on mouse over. It’s not my actual code, just a summary. Maybe the problem comes from the async calls? Is there a better way to accomplish the same thing?

globalDiagram.nodeTemplateMap.add('...',
      $(go.Node, go.Panel.Auto, { mouseEnter: handler, ...}, ...));

function handler(e, node) {
  setTimeout(function () {
    jQuery.ajax('/backend/nodeKeysToHighlight?from=' + node.data.key).then(function (keys) {
      skipsUndoManager(function () {
        keys.forEach(function (key) {
          var n = globalDiagram.findNodeForKey(key);
          if (n) n.isHighlighted = true;
        });
      });
    });
  }, 500);
}

Yes, that’s very much the problem.

Your app really cannot depend on long waits and then doing round-trips to the server to decide what to do while the user is dragging the mouse – that’s way too slow to provide responsive behavior.

Even if you execute the AJAX call immediately rather than waiting half-a-second, it would still be bad behavior.

You may have already seen behavior similar to what I think you want in the Friend Wheel sample, Friend Wheel.

Even if you execute the AJAX call immediately rather than waiting half-a-second, it would still be bad behavior.

Do you mean that it is impossible to do correctly with GoJS’ undo manager? I ask because the spec that I need to implement certainly requires a round-trip to the server (which is actually fast enough not to be noticeable) and is not going to change. If I need to, I will implement my own undo

No, it is quite possible to have your responses make asynchronous changes to the diagram. I’m just saying that it’s a bad idea unless you want those updates to “lag behind” where the mouse currently is.

Particularly if the server’s answers aren’t going to change dynamically, it would make sense to have the results saved along with the diagram on the client.

But if you really want to persist with that strategy, you could do something like:

function skipUndoManager(callback) {
  var old = globalDiagram.skipsUndoManager;
  globalDiagram.skipsUndoManager = true;
  callback();
  globalDiagram.requestUpdate();
  globalDiagram.skipsUndoManager = old;
}

Thanks for your latest update, I will try that and check whether I still have problems.

Best regards

Just as a follow-up, we never managed to get the UndoManager to work for our async use-cases, so we solved the problem by managing diagram state and history out of GoJS (using Redux).

I’m glad that you got it working, but I am curious what we need to do to make it easy for your kind of app organization. But if you do not have time for such a discussion, I understand. Thanks for having the patience to try various things before.

This discussion gives a good overview of the problems I had. Also:

  • The UndoManager keeps track of model changes as well as go.Node/go.Group property changes (I think), so there’s this mix of data/presentation in the transactions and I think some of the bugs came from that, like reverting some data updated some bindings and changed the link routes, but also maybe those same routes were reverted from the transaction so in the end I had warnings in the console. I’m not sure I understood that problem correctly, but that’s part of the problem: it’s not easy to understand.
  • On the other hand, our new Redux setup only keeps track of model changes, so it’s much clearer what is being reverted or not, and also instead of keeping “deltas” (= “transactions”) it keeps snapshots of the whole model’s state, so there’s no need of “skipsUndoManager” to include stuff in the delta or not.

Yes, that’s right – the UndoManager holds ChangedEvents for both GraphObjects and for model data.

I agree that you just want to keep track of model changes. I suppose saving copies of the whole model works for your app, but for many apps that would be way too much to save, so using incremental changes (Transactions) is required. But if you have decided on immutable models, that’s all you can do.

There are two methods that are used for updating a Diagram when the model has changed but one has no idea of what may have changed: Diagram.updateAllRelationshipsFromData and Diagram.updateAllTargetBindings. But incrementally updating the diagram depends on the reference identity of the node data objects (and link data objects if it’s a GraphLinksModel). So that won’t help you when you are reconstructing a completely new model composed of new data objects.

w.r.t. your last point, we sorted that out quite easily with an array diff on nodeDataArray/linkDataArray and an object diff on each nodeData/linkData. It’s quite nice because in the ends it calls a minimal amount of model.setDataProperty and it gives GoJS animations also on undo/redo!

Ah, very good – I was going to suggest doing that as a possibility, but you already figured that out.

I should state this for posterity: the job of merging in changes into an existing model is what Model.applyIncrementalJson does.