Link shape looks messy when we change the port

Hi Walter,
We are facing a issue related to the links connected to node.
As you can see in the first image the middle node (Fetch node ) has two links (two outcomes ) both are connected to the end node

Now if i move the no result to a different port the link appears curved (image 2 )

I tried to invalidate the link after changing its position however that did not fix the issue.

link.invalidateRoute();

but still the issue persisted. what could be the reason for it ? is there a way solve it, any way to get around this so the constructed link reshapes / appears correct (expected:- see 3rd image )

Do the links have AvoidsNodes routing?

@walter yes

Hmmm, I cannot reproduce the problem. Can you tell me how the following code is meaningfully different from your situation? Select the bottom link and click the HTML button.

<!DOCTYPE html>
<html>
<head>
  <title>Minimal GoJS Sample</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Change fromSpot of selected Link</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      layout: new go.TreeLayout({ layerSpacing: 100, setsPortSpot: false }),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto", { width: 120, height: 80 })
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock()
        .bind("text")
    );

myDiagram.linkTemplate =
  new go.Link({ routing: go.Link.AvoidsNodes, corner: 10 })
    .bind("fromSpot", "fromSpot", go.Spot.parse)
    .add(
      new go.Shape(),
      new go.Shape({ toArrow: "OpenTriangle" })
    );

myDiagram.model = new go.GraphLinksModel(
[
  { key: 1, text: "Alpha", color: "lightblue" },
  { key: 2, text: "Beta", color: "orange" },
  { key: 3, text: "Gamma", color: "lightgreen" },
],
[
  { from: 1, to: 2 },
  { from: 2, to: 3, fromSpot: "1 0" },
  { from: 2, to: 3, fromSpot: "1 1" },
]);

document.getElementById("myTestButton").addEventListener("click", e => {
  const link = myDiagram.selection.first();
  if (link instanceof go.Link) {
    myDiagram.model.commit(m => {
      m.set(link.data, "fromSpot", go.Spot.stringify(go.Spot.Bottom));
    });
  }
});
  </script>
</body>
</html>

@walter I did my best to create a reproducible example outside of our project, I didn’t manage to do that.

However, the code below looks very similar to our code.

And what I’ve managed to nail down — If I remove line this.temporaryFromPort = createTemporaryPort(); — issue disappears. So, most probably the problem is in overriding temporaryFromPort, probably we are doing it incorrectly. What do you think?

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>GoJS Flow Diagram – Multiple Ports</title>

  <!-- GoJS from CDN -->
  <script src="https://unpkg.com/gojs/release/go.js"></script>

  <style>
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      background: #f5f7fa;
      font-family: Arial, sans-serif;
      color: #333;
    }

    .page {
      min-height: 100%;
      padding: 32px;
      box-sizing: border-box;
    }

    .container {
      max-width: 1100px;
      margin: 0 auto;
    }

    h1 {
      margin-bottom: 24px;
      font-size: 32px;
    }

    .card {
      background: #ffffff;
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 16px;
      margin-bottom: 24px;
    }

    .card h2 {
      margin-top: 0;
      margin-bottom: 8px;
      font-size: 18px;
    }

    .card ul {
      margin: 0;
      padding-left: 18px;
      font-size: 14px;
    }

    .card li {
      margin-bottom: 6px;
    }

    #diagramDiv {
      width: 100%;
      height: 600px;
      background: #ffffff;
      border: 2px solid #ddd;
      border-radius: 8px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
    }
  </style>
</head>

<body>
  <div class="page">
    <div class="container">
      <h1>GoJS Flow Diagram with Multiple Ports</h1>

      <div class="card">
        <h2>Features</h2>
        <ul>
          <li>8 connection ports per node (edges + corners)</li>
          <li>Edge ports span full side minus padding</li>
          <li>Grid snapping while dragging nodes</li>
          <li>Relinkable connections with custom temporary ports</li>
          <li>Each port has a distinct color</li>
          <li>Custom relinking tool with live port visualization</li>
        </ul>
      </div>

      <div id="diagramDiv"></div>
    </div>
  </div>

  <script>
    const $ = go.GraphObject.make;
    const PORT_SIZE = 10;
    const NODE_WIDTH = 140;
    const NODE_HEIGHT = 100;

    function createStepPort(
      alignment,
      id,
      connectionSpot,
      fromLinkable = true,
      toLinkable = true,
      visualShape
    ) {
      return $(
        go.Panel,
        "Spot",
        {
          alignmentFocus: alignment instanceof go.Spot ? alignment : go.Spot.Center,
          portId: id,
          fromLinkable,
          toLinkable,
          toLinkableDuplicates: toLinkable,
          cursor: "pointer",
          ...(alignment instanceof go.Spot
            ? {
                alignment,
                toSpot: connectionSpot || alignment,
                fromSpot: connectionSpot || alignment
              }
            : {
                toSpot: connectionSpot || go.Spot.Center,
                fromSpot: connectionSpot || go.Spot.Center
              })
        },
        ...(alignment instanceof go.Binding ? [alignment] : []),
        visualShape ||
          $(go.Shape, {
            width: PORT_SIZE,
            height: PORT_SIZE,
            stroke: null,
            fill: "transparent"
          })
      );
    }

    function createGoTemporaryLinkTemplate() {
      return $(
        go.Link,
        {
          routing: go.Routing.Normal,
          curve: go.Curve.JumpOver,
          corner: 5,
          toShortLength: 4
        },
        $(go.Shape, {
          isPanelMain: true,
          stroke: "#FF8000",
          strokeWidth: 2,
          strokeDashArray: [4, 4]
        }),
        $(go.Shape, {
          toArrow: "Standard",
          stroke: null,
          fill: "#FF8000",
          scale: 1.5
        }),
        $(go.Shape, "Circle", {
          width: 9,
          height: 9,
          fill: "#FF8000",
          segmentIndex: 0,
          segmentFraction: 0
        }),
        $(
          go.TextBlock,
          {
            alignment: go.Spot.Center,
            segmentFraction: 0.5,
            segmentOrientation: go.Orientation.Upright,
            font: "bold 13px sans-serif",
            stroke: "black"
          },
          new go.Binding("text", "label")
        )
      );
    }

    function createLinkHandle(options) {
      return $(go.Shape, {
        desiredSize: new go.Size(8, 8),
        fill: "lightblue",
        stroke: "dodgerblue",
        cursor: options.cursor,
        segmentIndex: options.segmentIndex
      });
    }

    function createTemporaryPort() {
      const port = $(
        go.Panel,
        "Spot",
        $(go.Shape, "RoundedRectangle", { name: "TO_PORT_SHAPE" })
      );
      resetTemporaryPort(port);
      return port;
    }

    function resetTemporaryPort(port) {
      const shape = port.findObject("TO_PORT_SHAPE");
      if (!shape) return;

      shape.margin = 1;
      shape.figure = "RoundedRectangle";
      shape.stroke = "white";
      shape.strokeWidth = 2;
      shape.fill = "rgba(255,172,76,0.7)";
    }

    function customizeTemporaryPort(port, targetPort) {
      const shape = port.findObject("TO_PORT_SHAPE");
      if (!shape || !targetPort) return;

      shape.strokeWidth = 1;
      shape.margin = 0;
      shape.figure = targetPort.width === targetPort.height ? "Circle" : "RoundedRectangle";

      const b = targetPort.getDocumentBounds();
      port.width = b.width;
      port.height = b.height;
    }

    class FlowDesignerGoJSRelinkingTool extends go.RelinkingTool {
      constructor() {
        super();
        this.portGravity = 50;
        this.fromHandleArchetype = createLinkHandle({ cursor: "move", segmentIndex: 0 });
        this.toHandleArchetype = createLinkHandle({ cursor: "move", segmentIndex: -1 });
        this.temporaryFromPort = createTemporaryPort();
        this.temporaryToPort = createTemporaryPort();
        this.temporaryLink = createGoTemporaryLinkTemplate();
      }

      isReconnectingStart() {
        return this.handle && this.handle.segmentIndex === 0;
      }

      doMouseMove() {
        super.doMouseMove();
        if (!this.isActive) return;

        const staticPort = this.isReconnectingStart() ? this.temporaryToPort : this.temporaryFromPort;
        const originalPort = this.isReconnectingStart() ? this.originalToPort : this.originalFromPort;
        customizeTemporaryPort(staticPort, originalPort);

        const target = this.findTargetPort(true);
        if (target) {
          customizeTemporaryPort(
            this.isReconnectingStart() ? this.temporaryFromPort : this.temporaryToPort,
            target
          );
        } else {
          resetTemporaryPort(
            this.isReconnectingStart() ? this.temporaryFromPort : this.temporaryToPort
          );
        }
      }
    }

    const diagram = $(go.Diagram, "diagramDiv", {
      "undoManager.isEnabled": true,
      initialContentAlignment: go.Spot.Center,
      "grid.visible": true,
      "grid.gridCellSize": new go.Size(20, 20),
      "draggingTool.isGridSnapEnabled": true
    });

    diagram.toolManager.relinkingTool = new FlowDesignerGoJSRelinkingTool();

    diagram.nodeTemplate = $(
      go.Node,
      "Spot",
      new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
      $(go.Shape, "Rectangle", {
        width: NODE_WIDTH,
        height: NODE_HEIGHT,
        fill: "lightblue",
        stroke: "#4a90e2",
        strokeWidth: 2
      }, new go.Binding("fill", "color")),
      $(go.TextBlock, { font: "bold 14px Arial" }, new go.Binding("text", "text")),

      createStepPort(go.Spot.Left, "left", go.Spot.Left, true, true,
        $(go.Shape, "Rectangle", { width: PORT_SIZE, height: NODE_HEIGHT - 20, fill: "#ff6b6b" })
      ),
      createStepPort(go.Spot.Top, "top", go.Spot.Top, true, true,
        $(go.Shape, "Rectangle", { width: NODE_WIDTH - 20, height: PORT_SIZE, fill: "#4ecdc4" })
      ),
      createStepPort(go.Spot.Right, "right", go.Spot.Right, true, true,
        $(go.Shape, "Rectangle", { width: PORT_SIZE, height: NODE_HEIGHT - 20, fill: "#95e1d3" })
      ),
      createStepPort(go.Spot.Bottom, "bottom", go.Spot.Bottom, true, true,
        $(go.Shape, "Rectangle", { width: NODE_WIDTH - 20, height: PORT_SIZE, fill: "#f38181" })
      ),

      createStepPort(go.Spot.TopLeft, "topleft", go.Spot.TopLeft, true, true,
        $(go.Shape, "Circle", { width: PORT_SIZE, height: PORT_SIZE, fill: "#aa96da" })
      ),
      createStepPort(go.Spot.TopRight, "topright", go.Spot.TopRight, true, true,
        $(go.Shape, "Circle", { width: PORT_SIZE, height: PORT_SIZE, fill: "#fcbad3" })
      ),
      createStepPort(go.Spot.BottomLeft, "bottomleft", go.Spot.BottomLeft, true, true,
        $(go.Shape, "Circle", { width: PORT_SIZE, height: PORT_SIZE, fill: "#ffffd2" })
      ),
      createStepPort(go.Spot.BottomRight, "bottomright", go.Spot.BottomRight, true, true,
        $(go.Shape, "Circle", { width: PORT_SIZE, height: PORT_SIZE, fill: "#a8d8ea" })
      )
    );

    diagram.linkTemplate = $(
      go.Link,
      {
        relinkableFrom: true,
        relinkableTo: true,
        reshapable: true,
        routing: go.Routing.AvoidsNodes,
        curve: go.Curve.JumpOver,
        corner: 20
      },
      $(go.Shape, { stroke: "#4a90e2", strokeWidth: 3 }),
      $(go.Shape, { toArrow: "Standard", fill: "#4a90e2", scale: 1.5 }),
      $(go.TextBlock, {
        margin: 4,
        background: "white",
        font: "10pt Arial"
      }, new go.Binding("text", "text"))
    );

    const model = new go.GraphLinksModel(
      [
        { key: 1, text: "Step 1", color: "lightblue", loc: "100 150" },
        { key: 2, text: "Step 2", color: "lightgreen", loc: "350 150" },
        { key: 3, text: "Step 3", color: "lightyellow", loc: "225 300" }
      ],
      [
        { from: 1, to: 2, fromPort: "right", toPort: "left" },
        { from: 1, to: 3, fromPort: "bottom", toPort: "top" },
        { from: 2, to: 3, fromPort: "bottomleft", toPort: "topright" }
      ]
    );

    model.linkFromPortIdProperty = "fromPort";
    model.linkToPortIdProperty = "toPort";
    diagram.model = model;
  </script>
</body>
</html>

The only way currently to solve the problem I’ve found:

diagram.addDiagramEventListener("LinkRelinked", () => {
    diagram.rebuildParts();
});

I can run your code, but I don’t know what I should do to cause the problem.

I’ll explore your code more when I have some free time.

@walter yes, I still didn’t manage to nail down the problem. However, the attached code looks exactly as ours. Currently when we relink start point of the connection from the right side to the bottom in our code it looks in the following way:

And I managed to figure out that it is related to termporaryFromPort as when I remove this.temporaryFromPort = createTemporaryPort(); The issue disappears.

So, probably, you could review whether we are doing fromPort customization incorrectly.

Instead of overriding doMouseMove, your code should override:

@walter do you have an example of the correct override?

The reason why I want to override the temporary port is that I have “go.Shape” inside the “go.Panel”. I would like to highlight the port in the following manner.

    function createTemporaryPort() {
        const $GO = go.GraphObject.make;

        const tempPort = $GO(go.Panel, "Spot",
            $GO(go.Shape, "RoundedRectangle", {
                name: "TO_PORT_SHAPE"
            })
        );

        resetTemporaryPort(tempPort);

        return tempPort;
    }

This is probably unrelated to the actual issue that you are dealing with, but I notice that your createTemporaryPort function is returning a “Spot” Panel that only has one element in it. The point of “Spot” (and “Auto” and some other Panel types) is to position and/or size elements of a panel relative to each other. There’s no legitimate reason to use a “Spot” Panel with only one element in it. Try using go-debug.js and you will see a bunch of warning messages about this.

I find that if I remove all of your code dealing with temporary ports, by defining your custom RelinkingTool as follows, the behavior seems pretty reasonable:

    class FlowDesignerGoJSRelinkingTool extends go.RelinkingTool {
      constructor() {
        super();
        this.portGravity = 50;
        this.fromHandleArchetype = createLinkHandle({ cursor: "move", segmentIndex: 0 });
        this.toHandleArchetype = createLinkHandle({ cursor: "move", segmentIndex: -1 });
        // this.temporaryFromPort = createTemporaryPort();
        // this.temporaryToPort = createTemporaryPort();
        this.temporaryLink = createGoTemporaryLinkTemplate();
      }
// no override of doMouseMove
    }