GraphLinksModel.linkKeyProperty must not be an empty string for .toIncrementalData() to succeed

I’m having an issue with code that works fine on 2.1.14, but breaks when i upgrade to 2.1.15 or higher. The error i get is in the title, and i’m just trying to find out what changed and how to fix the issue. See my code below

import { scaleLinear } from "d3";
import go from "gojs";

const noop = (event: any) => {};

function initializeDiagram({ diagramMount, paletteMount, onChange = noop, data = {}, translations }) {
  const {
    StartTitle = "Start",
    StepText = "Step",
    ConditionalText = "???",
    EndTitle = "End",
    CommentText = "Comment",
    YesTitle = "Yes",
  } = translations;

  function init() {
    const $ = go.GraphObject.make; // for conciseness in defining templates

    go.Diagram.licenseKey =
      "XXX";
    const diagram = $(
      go.Diagram,
      diagramMount, // must name or refer to the DIV HTML element
      {
        LinkDrawn: showLinkLabel, // this DiagramEvent listener is defined below
        LinkRelinked: showLinkLabel,
        "undoManager.isEnabled": true, // enable undo & redo
        initialAutoScale: go.Diagram.Uniform,
      },
    );
    // helper definitions for node templates

    function nodeStyle() {
      return [
        // The Node.location comes from the "loc" property of the node data,
        // converted by the Point.parse static method.
        // If the Node.location is changed, it updates the "loc" property of the node data,
        // converting back using the Point.stringify static method.
        new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
        {
          // the Node.location is at the center of each node
          locationSpot: go.Spot.Center,
        },
      ];
    }

    // Define a function for creating a "port" that is normally transparent.
    // The "name" is used as the GraphObject.portId,
    // the "align" is used to determine where to position the port relative to the body of the node,
    // the "spot" is used to control how links connect with the port and whether the port
    // stretches along the side of the node,
    // and the boolean "output" and "input" arguments control whether the user can draw links from or to the port.
    function makePort(name, align, spot, output, input) {
      const horizontal = align.equals(go.Spot.Top) || align.equals(go.Spot.Bottom);
      // the port is basically just a transparent rectangle that stretches along the side of the node,
      // and becomes colored when the mouse passes over it
      return $(go.Shape, {
        fill: "transparent", // changed to a color in the mouseEnter event handler
        strokeWidth: 0, // no stroke
        width: horizontal ? NaN : 8, // if not stretching horizontally, just 8 wide
        height: !horizontal ? NaN : 8, // if not stretching vertically, just 8 tall
        alignment: align, // align the port on the main Shape
        stretch: horizontal ? go.GraphObject.Horizontal : go.GraphObject.Vertical,
        portId: name, // declare this object to be a "port"
        fromSpot: spot, // declare where links may connect at this port
        fromLinkable: output, // declare whether the user may draw links from here
        toSpot: spot, // declare where links may connect at this port
        toLinkable: input, // declare whether the user may draw links to here
        cursor: "pointer", // show a different cursor to indicate potential link point
        mouseEnter: function (e, port) {
          // the PORT argument will be this Shape
          if (!e.diagram.isReadOnly) port.fill = "rgba(255,0,255,0.5)";
        },
        mouseLeave: function (e, port) {
          port.fill = "transparent";
        },
      });
    }

    function textStyle() {
      return {
        font: "bold 11pt Lato, Helvetica, Arial, sans-serif",
        stroke: "#2f2f2f",
      };
    }

    // define the Node templates for regular nodes

    diagram.nodeTemplateMap.add(
      "", // the default category
      $(
        go.Node,
        "Table",
        nodeStyle(),
        // the main object is a Panel that surrounds a TextBlock with a rectangular Shape
        $(
          go.Panel,
          "Auto",
          $(
            go.Shape,
            "Rectangle",
            { fill: "#ccc", stroke: "#00A9C9", strokeWidth: 3.5 },
            new go.Binding("figure", "figure"),
          ),
          $(
            go.TextBlock,
            textStyle(),
            {
              margin: 8,
              maxSize: new go.Size(160, NaN),
              wrap: go.TextBlock.WrapFit,
              editable: true,
            },
            new go.Binding("text").makeTwoWay(),
          ),
        ),
        // four named ports, one on each side:
        makePort("T", go.Spot.Top, go.Spot.TopSide, false, true),
        makePort("L", go.Spot.Left, go.Spot.LeftSide, true, true),
        makePort("R", go.Spot.Right, go.Spot.RightSide, true, true),
        makePort("B", go.Spot.Bottom, go.Spot.BottomSide, true, false),
      ),
    );

    diagram.nodeTemplateMap.add(
      "Conditional",
      $(
        go.Node,
        "Table",
        nodeStyle(),
        // the main object is a Panel that surrounds a TextBlock with a rectangular Shape
        $(
          go.Panel,
          "Auto",
          $(
            go.Shape,
            "Diamond",
            { fill: "#ccc", stroke: "#00A9C9", strokeWidth: 3.5 },
            new go.Binding("figure", "figure"),
          ),
          $(
            go.TextBlock,
            textStyle(),
            {
              margin: 8,
              maxSize: new go.Size(160, NaN),
              wrap: go.TextBlock.WrapFit,
              editable: true,
            },
            new go.Binding("text").makeTwoWay(),
          ),
        ),
        // four named ports, one on each side:
        makePort("T", go.Spot.Top, go.Spot.Top, false, true),
        makePort("L", go.Spot.Left, go.Spot.Left, true, true),
        makePort("R", go.Spot.Right, go.Spot.Right, true, true),
        makePort("B", go.Spot.Bottom, go.Spot.Bottom, true, false),
      ),
    );

    diagram.nodeTemplateMap.add(
      "Start",
      $(
        go.Node,
        "Table",
        nodeStyle(),
        $(
          go.Panel,
          "Spot",
          $(go.Shape, "Circle", {
            desiredSize: new go.Size(70, 70),
            fill: "#ccc",
            stroke: "#22b530",
            strokeWidth: 3.5,
          }),
          $(go.TextBlock, "Start", textStyle(), { editable: true }, new go.Binding("text")),
        ),
        // three named ports, one on each side except the top, all output only:
        makePort("L", go.Spot.Left, go.Spot.Left, true, false),
        makePort("R", go.Spot.Right, go.Spot.Right, true, false),
        makePort("B", go.Spot.Bottom, go.Spot.Bottom, true, false),
      ),
    );

    diagram.nodeTemplateMap.add(
      "End",
      $(
        go.Node,
        "Table",
        nodeStyle(),
        $(
          go.Panel,
          "Spot",
          $(go.Shape, "Circle", {
            desiredSize: new go.Size(60, 60),
            fill: "#ccc",
            stroke: "#DC3C00",
            strokeWidth: 3.5,
          }),
          $(go.TextBlock, "End", textStyle(), { editable: true }, new go.Binding("text")),
        ),
        // three named ports, one on each side except the bottom, all input only:
        makePort("T", go.Spot.Top, go.Spot.Top, false, true),
        makePort("L", go.Spot.Left, go.Spot.Left, false, true),
        makePort("R", go.Spot.Right, go.Spot.Right, false, true),
      ),
    );

    // taken from ../extensions/Figures.js:
    go.Shape.defineFigureGenerator("File", function (shape, w, h) {
      const geo = new go.Geometry();
      const fig = new go.PathFigure(0, 0, true); // starting point
      geo.add(fig);
      fig.add(new go.PathSegment(go.PathSegment.Line, 0.75 * w, 0));
      fig.add(new go.PathSegment(go.PathSegment.Line, w, 0.25 * h));
      fig.add(new go.PathSegment(go.PathSegment.Line, w, h));
      fig.add(new go.PathSegment(go.PathSegment.Line, 0, h).close());
      const fig2 = new go.PathFigure(0.75 * w, 0, false);
      geo.add(fig2);
      // The Fold
      fig2.add(new go.PathSegment(go.PathSegment.Line, 0.75 * w, 0.25 * h));
      fig2.add(new go.PathSegment(go.PathSegment.Line, w, 0.25 * h));
      geo.spot1 = new go.Spot(0, 0.25);
      geo.spot2 = go.Spot.BottomRight;
      return geo;
    });

    diagram.nodeTemplateMap.add(
      "Comment",
      $(
        go.Node,
        "Auto",
        nodeStyle(),
        $(go.Shape, "File", { fill: "#ccc", stroke: "#DEE0A3", strokeWidth: 3 }),
        $(
          go.TextBlock,
          textStyle(),
          {
            margin: 8,
            maxSize: new go.Size(200, NaN),
            wrap: go.TextBlock.WrapFit,
            textAlign: "center",
            editable: true,
          },
          new go.Binding("text").makeTwoWay(),
        ),
        // no ports, because no links are allowed to connect with a comment
      ),
    );

    // replace the default Link template in the linkTemplateMap
    diagram.linkTemplate = $(
      go.Link, // the whole link panel
      {
        routing: go.Link.AvoidsNodes,
        curve: go.Link.JumpOver,
        corner: 5,
        toShortLength: 4,
        relinkableFrom: true,
        relinkableTo: true,
        reshapable: true,
        resegmentable: true,
        // mouse-overs subtly highlight links:
        mouseEnter: function (e, link: any) {
          link.findObject("HIGHLIGHT").stroke = "rgba(30,144,255,0.2)";
        },
        mouseLeave: function (e, link: any) {
          link.findObject("HIGHLIGHT").stroke = "transparent";
        },
        selectionAdorned: false,
      },
      new go.Binding("points").makeTwoWay(),
      $(
        go.Shape, // the highlight shape, normally transparent
        { isPanelMain: true, strokeWidth: 8, stroke: "transparent", name: "HIGHLIGHT" },
      ),
      $(
        go.Shape, // the link path shape
        { isPanelMain: true, stroke: "gray", strokeWidth: 2 },
        new go.Binding("stroke", "isSelected", function (sel) {
          return sel ? "dodgerblue" : "gray";
        }).ofObject(),
      ),
      $(
        go.Shape, // the arrowhead
        { toArrow: "standard", strokeWidth: 0, fill: "gray" },
      ),
      $(
        go.Panel,
        "Auto", // the link label, normally not visible
        { visible: false, name: "LABEL", segmentIndex: 2, segmentFraction: 0.5 },
        new go.Binding("visible", "visible").makeTwoWay(),
        $(
          go.Shape,
          "RoundedRectangle", // the label shape
          { fill: "#ccc", strokeWidth: 0 },
        ),
        $(
          go.TextBlock,
          YesTitle, // the label
          {
            textAlign: "center",
            font: "10pt helvetica, arial, sans-serif",
            stroke: "#333333",
            editable: true,
          },
          new go.Binding("text").makeTwoWay(),
        ),
      ),
    );

    // Make link labels visible if coming out of a "conditional" node.
    // This listener is called by the "LinkDrawn" and "LinkRelinked" DiagramEvents.
    function showLinkLabel(e) {
      const label = e.subject.findObject("LABEL");
      if (label !== null) label.visible = e.subject.fromNode.data.category === "Conditional";
    }

    // temporary links used by LinkingTool and RelinkingTool are also orthogonal:
    diagram.toolManager.linkingTool.temporaryLink.routing = go.Link.Orthogonal;
    diagram.toolManager.relinkingTool.temporaryLink.routing = go.Link.Orthogonal;

    diagram.model = load(); // load an initial diagram from some JSON text

    // initialize the Palette that is on the left side of the page
    const myPalette = $(
      go.Palette,
      paletteMount, // must name or refer to the DIV HTML element
      {
        // Instead of the default animation, use a custom fade-down
        "animationManager.initialAnimationStyle": go.AnimationManager.None,
        InitialAnimationStarting: animateFadeDown, // Instead, animate with this function

        nodeTemplateMap: diagram.nodeTemplateMap, // share the templates used by diagram
        model: new go.GraphLinksModel([
          // specify the contents of the Palette
          { category: "Start", text: StartTitle },
          { text: StepText },
          { category: "Conditional", text: ConditionalText },
          { category: "End", text: EndTitle },
          { category: "Comment", text: CommentText },
        ]),
      },
    );

    // This is a re-implementation of the default animation, except it fades in from downwards, instead of upwards.
    function animateFadeDown(e) {
      const diagram = e.diagram;
      const animation = new go.Animation();
      animation.isViewportUnconstrained = true; // So Diagram positioning rules let the animation start off-screen
      animation.easing = go.Animation.EaseOutExpo;
      animation.duration = 900;
      // Fade "down", in other words, fade in from above
      animation.add(diagram, "position", diagram.position.copy().offset(0, 200), diagram.position);
      animation.add(diagram, "opacity", 0, 1);
      animation.start();
    }

    const scale = scaleLinear().domain([0, 100]).range([diagram.minScale, diagram.maxScale]).clamp(true);

    // @ts-ignore
    diagram.zoomIn = zoomIn.bind(diagram);

    function zoomIn() {
      this.scale = scale(scale.invert(this.scale) + 0.25);
    }

    // @ts-ignore
    diagram.zoomOut = zoomOut.bind(diagram);

    function zoomOut() {
      this.scale = scale(scale.invert(this.scale) - 0.25);
    }

    diagram.zoomToFit = diagram.zoomToFit.bind(diagram);

    diagram.addModelChangedListener((e) => {
      if (e.isTransactionFinished) {
        //getting the below error when i upgrade gojs to 2.1.15 or higher
        //GraphLinksModel.linkKeyProperty must not be an empty string for .toIncrementalData() to succeed
        const dataChanges = e.model.toIncrementalData(e);
        if (dataChanges !== null) {
          onChange(dataChanges);
        }
      }
    });

    return diagram;
  } // end init

  // Show the diagram's model in JSON format that the user may edit
  // function save() {
  //   savedModel = diagram.model.toJson();
  //   diagram.isModified = false;
  // }
  function load() {
    return go.Model.fromJson(data);
  }

  // print the diagram by opening a new window holding SVG images of the diagram contents for each page
  return init();
}
export default initializeDiagram;

Yes, we had that error message only being raised in the go-debug.js library, but after some people had problems that they would have figured out on their own if they had seen it (or if they had used the Debug library), we made it so that error message is raised in the go.js library too.

So nothing has really changed other than explicitly making it clear what the problem is. I’m surprised that it worked for you before. All you need to do, as the error message implies, is set GraphLinksModel.linkKeyProperty to the name of the property that should hold the key for link data objects.

actually it has a visual difference as well, not just a console message. i should have brought that up but i assumed that fixing the console error would fix the UX issue. with the code i gave above, we have a pretty simple flowchart like Flowchart, but when i drag ‘start’ over into the workspace area, it stays selected and cilcking in the workspace again just adds multiple start circles.

if i downgrade to 2.1.14, then this doesn’t happen anymore and it works fine (like the link above). no other changes other than gojs versions are made.

If there are exceptions during the operation of a tool, the tool will not work in the normal manner. What you describe sounds like that is indeed the cause.

We do recommend using go-debug.js during development, and checking the console frequently for warnings or error messages.

ok, so i should be able to put in my code above, in the section

model: new go.GraphLinksModel([
          // specify the contents of the Palette
          { category: "Start", text: StartTitle },
          { text: StepText },
          { category: "Conditional", text: ConditionalText },
          { category: "End", text: EndTitle },
          { category: "Comment", text: CommentText },
        ]),

i just put a ‘linkKeyProperty’ value in there, and set it to something random, and that would be valid?

Well, you can’t do that in the constructor.

model: $(go.GraphLinksModel,
  {
    linkKeyProperty: "id",  // or whatever name it is that you are using
    nodeDataArray: . . .,
    linkDataArray: . . .
  }),