SubGraphExpander button - links not re-routed

I have an issue with links between groups that aren’t re-routing after collapsing/expanding. When that happens AvoidLinkSouter.routeLinks receives an empty list of Links. That happens the most after my app is reloaded (first interaction). What should I look for in order to solve that issue?

  1. Initial state,

  1. First SubGraphExpanderButton click / collapse (routeLinks received the empty list),

3. Expand,

  1. Second collapse (routeLinks received a list with the link),

When I try using the following Router, I do not observe any problems with the number of links passed to routeLinks:

class CustomRouter extends go.Router {
  constructor(init) {
    super(init);
  }

  routeLinks(links, container) {
    super.routeLinks(links, container);
    console.log("routeLinks", links.count, container instanceof go.Group ? container.key : "Diagram");
  }
}

To be clear, in your example neither lane/Group contains any Links because the one existing Link connects Nodes in different lanes/Groups. That Link is only contained by the pool/Group that contains both lanes/Groups.

I’m aware that it is not reproducible in a simple example but it is in the project that I work on. I also know that the link is a member of the pool. Due to project’s complexity I cannot identify the root cause so I’m asking for an advice what should I look for in order to figure it out. Why the links list could be empty, when are they added/removed. Could it be some NaN/isReal issue? I don’t know how GoJS works inside. Maybe it’s a race condition - something uninitialized until the first interaction.

I added that CustomRouter to a Swim Lanes sample, so the sample wasn’t that simple.

The collection of Links passed to routeLinks is the set of route-invalidated Links that the Diagram has collected so far. Of course any implementation doesn’t have to limit itself in which Links it examines or reroutes.

Does the problem that you encounter depend on any groups starting off collapsed? I didn’t try that.

No, it is the same for both collapsed and expanded groups. The first interaction doesn’t update the link.

I’m not sure what to suggest. Have you tried disabling the AnimationManager to see if that matters?

Try setting myDiagram.animationManager.isEnabled to false when initializing your Diagram, before assigning its model.

I disabled the AnimationManager but it didn’t help.

When I call link.invalidateRoute manually from the console it gets fixed. But if I do that inside of a transaction it doesn’t. What I was able to do so far was to split it in two transactions:

diagram.commit(() => {
  ...
  group.expandSubGraph();
  ...
  const links = findLinks(...);
  for(const link of links){
    link.invalidateRoute(); // this doesn't work, links stay misaligned as if a group was still collapsed
  }
}, 'SubgraphStateChanged');

setTimeout(() => {
  diagram.commit(() => {
  const links = findLinks(...);
  for(const link of links){
    link.invalidateRoute(); // this works, but link points updates aren't recorded by the undo manager - undo/redo leaves misaligned links
  }
  }, null); // null - I don't want to undo twice to restore group to previous state
})

What could cause that? Or how can I force routeInvalidation? Could that be that there is a race condition between layouts and collapse/expand leading to outdated positions of nodes when a route is invalidated?

OK, the guess about animation wasn’t it.

Here’s another guess. Is that commit code (without the setTimeout call) what is executed when the user clicks the “expand” button for the group? If so, try calling CommandHandler.expandSubGraph instead of Group.expandSubGraph.

The result is the same - links are misaligned - they look like they’re pointing to node locations before expanding. It looks like link routes are always one step behind the actual diagram state.

How can I reproduce the problem?

I still don’t know how to make it easy to reproduce - the issue is within a complex project. But we found one step that seems to help. At some point we set fromPort and toPort properties to go.Spot.AllSides. After commenting this out it seems like the issue isn’t reproducible anymore. Though I have no idea how it could affect this. Where could I look further? Maybe I’ll be finally able to create a small example with reproducible issue but I still don’t know all the moving parts. I’d appreciate some more suggestions.

And you can’t leave the spots at Spot.AllSides? What about Spot.TopBottomSides? Although I cannot guess why either one would work for you but not specific spots like Spot.Top or Spot.Bottom. Or were you not setting the spots at all, which would effectively mean Spot.None?

What are your Node and Link templates? If you want, you can take out all of the contents for the Nodes, since that shouldn’t matter. You just need to include the port definition.

We narrowed it down to link.adjusting. When it’s set to None it works fine. The issue becomes reproducible when it’s set to End. (Orthogonal).

We’ve found a reproducible example. It’s a mix of node.fromPort/toPort === AllSides and link.adjusting === End. It is already broken when you open it.

<!DOCTYPE html>
<html lang="en">
  <body>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/release/go-debug.js"></script>
    <div id="sample" style="display: flex">
      <div
        id="diagramDiv"
        style="border: solid 1px black; width: 600px; height: 600px"
      ></div>
    </div>

    <script id="code">
      const diagram = new go.Diagram("diagramDiv", {
        "animationManager.isEnabled": false,
      });

      const nodeTemplate = new go.Node("Auto")
        .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
        .bind("", "", (_, part) => {
          if (part.findAdornment("Adornment")) {
            return;
          }
          const adornment = makeAdornment();
          adornment.adornedObject = part;
          part.addAdornment("Adornment", adornment);
        })
        .add(
          new go.Shape("RoundedRectangle", {
            strokeWidth: 0,
            fill: "white",
            fromSpot: go.Spot.AllSides,
            toSpot: go.Spot.AllSides,
            portId: '',
          })
            .bind("fill", "color")
            .bind(
              new go.Binding("fromSpot", "fromSpot", go.Spot.parse).makeTwoWay(
                go.Spot.stringify
              )
            )
            .bind(
              new go.Binding("toSpot", "toSpot", go.Spot.parse).makeTwoWay(
                go.Spot.stringify
              )
            ),
          new go.TextBlock({
            margin: 8,
            font: "bold 14px sans-serif",
            stroke: "#333",
          }).bind("text")
        );

      const makeAdornment = () =>
        new go.Adornment("Spot")
          .bind("visible", "", (_, targetObj) => {
            const ad = targetObj.part;
            if (!(ad instanceof go.Adornment)) return true;
            const part = ad.adornedObject;
            if (!(part instanceof go.Part)) return true;
            return part.isVisible();
          })
          .add(
            new go.Panel("Vertical").add(
              new go.Placeholder(),
              new go.TextBlock({ text: "Adornment" })
            )
          );

      const poolTemplate = new go.Group("Auto", {
        layout: new go.GridLayout({
          alignment: go.GridAlignment.Position,
          comparer: (a, b) => a.position.x - b.position.x,
          spacing: new go.Size(0, 0),
          cellSize: new go.Size(0, 0),
          wrappingColumn: Infinity,
          wrappingWidth: Infinity,
          isRealtime: true,
        }),
      })
        .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
        .add(
          new go.Shape("RoundedRectangle", {
            strokeWidth: 1,
            fill: "white",
            width: 400,
            height: 400,
          })
        );

      const laneTemplate = new go.Group("Vertical", {
        isSubGraphExpanded: true,
      })
        .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
        .bindTwoWay("isSubGraphExpanded")
        .add(
          go.GraphObject.make("SubGraphExpanderButton", {
            click: (event, thisObj) => {
              thisObj.part.diagram.startTransaction("Toggle");
              if (thisObj.part.isSubGraphExpanded) {
                thisObj.part.collapseSubGraph();
              } else {
                thisObj.part.expandSubGraph();
                // for (const part of thisObj.part.memberParts) {
                //   part.updateTargetBindings();
                // }
              }
              thisObj.part.diagram.commitTransaction("Toggle");
            },
          }),
          new go.Shape("RoundedRectangle", {
            strokeWidth: 1,
            fill: "white",
            width: 150,
            height: 350,
          }).bindObject(
            "desiredSize",
            "isSubGraphExpanded",
            (isSubGraphExpanded) =>
              isSubGraphExpanded ? new go.Size(150, 350) : new go.Size(30, 350)
          )
        );

      const linkTemplate = new go.Link({
        reshapable: true,
        resegmentable: true,
        routing: go.Routing.Orthogonal,
        adjusting: go.LinkAdjusting.End
      })
        .bindTwoWay(
          "points",
          "points",
          (points) => points.map(go.Point.parse),
          (points) => {
            const result = points.map(go.Point.stringify).toArray();
            console.trace(result);
            return result;
          }
        )
        .add(new go.Shape({ strokeWidth: 1.5, stroke: "red" }))
        .add(
          new go.Shape({
            strokeWidth: 0,
            fill: "red",
            scale: 0.7,
            fromArrow: "circle",
          })
        )
        .add(
          new go.Shape({
            strokeWidth: 0,
            fill: "red",
            scale: 0.7,
            toArrow: "circle",
          })
        );

      diagram.nodeTemplate = nodeTemplate;
      diagram.groupTemplateMap.add("Pool", poolTemplate);
      diagram.groupTemplateMap.add("Lane", laneTemplate);
      diagram.linkTemplate = linkTemplate;

      const nodes = [
        {
          key: "P",
          isGroup: true,
          category: "Pool",
          location: "31.24609375 -0.3515625",
        },
        {
          key: "L1",
          group: "P",
          isGroup: true,
          category: "Lane",
          location: "31.24609375 -0.3515625",
          isSubGraphExpanded: true,
        },
        {
          key: "L2",
          group: "P",
          isGroup: true,
          category: "Lane",
          location: "62.24609375 -0.3515625",
          isSubGraphExpanded: true,
        },
        {
          key: "L3",
          group: "P",
          isGroup: true,
          category: "Lane",
          location: "62.24609375 -0.3515625",
          isSubGraphExpanded: true,
        },
        {
          key: "N1",
          group: "L1",
          text: "Alpha",
          color: "lightblue",
          location: "74.30859375 157.82421875",
        },
        {
          key: "N2",
          group: "L2",
          text: "Beta",
          color: "orange",
          location: "150.8359375 88.375",
        },
        {
          key: "N3",
          group: "L3",
          text: "Gamma",
          color: "lightgreen",
          location: "100.37538656080795 203.03515625",
        },
      ];

      const links = [
        {
          from: "N1",
          to: "N3",
        },
        {
          from: "N1",
          to: "N2",
        },
      ];

      diagram.model = new go.GraphLinksModel([], []);

      setTimeout(() => {
        diagram.model.addNodeDataCollection(nodes);
        diagram.model.addLinkDataCollection(links);
      });
    </script>
  </body>
</html>

Ah, setting Link.adjusting to End would cause a problem, since when computing a new route it will always keep all but the ultimate and penultimate points of the route.

First, if you don’t set Link.adjusting, can you reproduce the problem in your app?

Second, if you really want Link.adjusting to be End so that it maintains the general route except at the end(s), can you temporarily set Link.adjusting to Nonewhen expanding or collapsing lanes/groups?

Sounds like a workaround to me. Could you fix it on your end? Now that we have found an example with a reproducible issue, it seems to be clearly a GoJS issue.

Well, why are you setting Link.adjusting? The purpose of that property is to control how Link.computePoints determines a new route when the route has been invalidated, typically because a Node has been moved. One wouldn’t set that unless one wanted the current behavior, which apparently is not what you want.

Still, there’s some odd initialization behavior there. We’ll investigate.

It’s odd to replace the Diagram.model with an empty model and then, soon, adding node data and link data. Could you try passing a second argument to setTimeout such as 100 (milliseconds)?

It may be clarifying to know what problem the setTimeout was intended to solve in the first place

Have you tried running the example I shared? If you remove setTimeout, the result is the same: the link isn’t aligned with the to node.

How should I temporarily change the linkAdjusting? Inside of the collapse/expand transaction, outside of the transaction, should there be two transactions wrapped within a single parent transaction?