Skip to content
D
Documentation

Validate port connections

how-to
5 min readUpdated

Use declared ports when your editor needs named inputs and outputs rather than interchangeable attachment points. The example renders a number pipeline, A → B → C, plus a string input. Number ports paint blue; the string port paints purple. Matching types connect, full inputs refuse another wire, and a cycle validator prevents C → A.

Ports decide where links attach and which connections are legal: declare them in specs, let the engine enforce the rules, and let the renderer draw the glyphs.

1. Declare the ports and their rules

In your browser application's source directory, create ports.ts. All five bindings below import this file. Use the library's NodeSpec and PortSpec vocabulary rather than constructing live ports yourself. EdgeSpec names the ports used by the initial pipeline through sourceHandle and targetHandle.

The two inputs on B inherit a square glyph, inside labels, and an evenly spaced left-edge column from metadata.portGroups.inputs. Each member supplies its own id and label text. A and C use diamond outputs. The setup function receives the mounted DiagramEngine so the cycle rule queries its current diagram.

Known issue: Declaring type: 'input' or type: 'output' alone does not enforce start-only/end-only direction: the connection rule rejects equal non-bi types, but does not reject an input-to-output wire. Until it is fixed, also set gating.isConnectableStart: false on inputs and gating.isConnectableEnd: false on outputs, as below.

ts
import { portTypeRegistry, type DiagramEngine } from '@grafloria/engine';
import {
  registerConnectionValidator,
  type NodeSpec,
  type PortSpec,
  type EdgeSpec,
  type DiagramInstance,
} from '@grafloria/renderer';

function input(id: string, dataType: string): PortSpec {
  return {
    id, side: 'left', type: 'input', dataType,
    shape: { shape: 'square', size: 12 },
    label: { text: dataType, layout: 'inside' },
    gating: { isConnectableStart: false, toMaxLinks: 1 },
  };
}

function output(id: string): PortSpec {
  return {
    id, side: 'right', type: 'output', dataType: 'number',
    shape: { shape: 'diamond', size: 14 },
    label: { text: 'out', layout: 'inside' },
    gating: { isConnectableEnd: false },
  };
}

export const nodes: NodeSpec[] = [
  {
    id: 'a', label: 'A', position: { x: 60, y: 100 },
    size: { width: 150, height: 100 },
    ports: [input('a-in', 'number'), output('a-out')],
  },
  {
    id: 'b', label: 'B', position: { x: 290, y: 100 },
    size: { width: 150, height: 100 },
    metadata: {
      portGroups: {
        inputs: {
          id: 'inputs', side: 'left',
          shape: { shape: 'square', size: 12 },
          label: { layout: 'inside' },
          layout: { strategy: 'sideLinear', args: { padding: 10 } },
        },
      },
    },
    ports: [
      {
        id: 'b-x', group: 'inputs', type: 'input', dataType: 'number',
        label: { text: 'x' },
        gating: { isConnectableStart: false, toMaxLinks: 1 },
      },
      {
        id: 'b-y', group: 'inputs', type: 'input', dataType: 'number',
        label: { text: 'y' },
        gating: { isConnectableStart: false, toMaxLinks: 1 },
      },
      output('b-out'),
    ],
  },
  {
    id: 'c', label: 'C', position: { x: 520, y: 100 },
    size: { width: 150, height: 100 },
    ports: [input('c-in', 'number'), output('c-out')],
  },
  {
    id: 's', label: 'String input', position: { x: 520, y: 290 },
    size: { width: 150, height: 100 },
    ports: [input('s-in', 'string')],
  },
];

export const edges: EdgeSpec[] = [
  {
    id: 'ab', source: 'a', target: 'b',
    sourceHandle: 'a-out', targetHandle: 'b-x',
  },
  {
    id: 'bc', source: 'b', target: 'c',
    sourceHandle: 'b-out', targetHandle: 'c-in',
  },
];

export function installPortRules(engine: DiagramEngine): () => void {
  engine.setInteractionConfig({ enableLinkReconnection: false });
  portTypeRegistry.registerAll([
    { name: 'number', color: '#2563eb', compatibleWith: ['number'] },
    { name: 'string', color: '#9333ea', compatibleWith: ['string'] },
  ]);

  const model = engine.getDiagram();
  if (!model) throw new Error('Mount the canvas before installing port rules.');

  return registerConnectionValidator(({ sourceNode, targetNode }) => {
    // The registry is global. Apply this rule only to this mounted model.
    if (model.getNode(sourceNode.id) !== sourceNode ||
        model.getNode(targetNode.id) !== targetNode) return true;

    const seen = new Set<string>();
    const stack = [targetNode.id];
    while (stack.length > 0) {
      const current = stack.pop()!;
      if (current === sourceNode.id) return 'Refused: would create a cycle';
      if (seen.has(current)) continue;
      seen.add(current);

      for (const edge of model.getLinks()) {
        const from = model.getNodeByPortId(edge.sourcePortId)?.id;
        const to = model.getNodeByPortId(edge.targetPortId)?.id;
        if (from === current && to !== undefined) stack.push(to);
      }
    }
    return true;
  });
}

export function configurePorts(instance: DiagramInstance): () => void {
  const dispose = installPortRules(instance.getEngine());
  instance.renderNow();
  return dispose;
}

portTypeRegistry supplies both the type palette and compatibility rules. Identical type names match; an untyped endpoint imposes no type restriction. compatibleWith permits additional target types in the source-to-target direction—it does not automatically permit the reverse conversion.

registerConnectionValidator returns a disposer. Its callback receives both nodes and both ports for new connections. Return true to allow the candidate, or false or a reason string to veto it. Every registered validator must pass for a new connection.

Known issue: Endpoint reconnection does not invoke registered validators, despite the callback type's optional link field intended for that task. Until it is fixed, disable endpoint reconnection with engine.setInteractionConfig({ enableLinkReconnection: false }), as installPortRules() does, so reconnection cannot bypass the cycle rule.

The cycle rule traverses the mounted instance's live links, not the initial edges array. It rejects a proposed edge when its target already reaches its source. Moving the boxes does not change that graph rule.

2. Mount the same editor in your framework

Choose one installation command for your existing project:

bash
# JavaScript
npm install @grafloria/element @grafloria/engine @grafloria/renderer
# React
npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
# Vue
npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue
# Qwik
npm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik
# Angular
npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs

Add configurePorts() (or installPortRules() in Angular) to apply the type colors and cycle rule to this editor; see Instance and lifecycle for mounting and ready callbacks.

Each wrapper has a resolved height. Ports stay visible through interaction.portVisibility in JavaScript, React, Vue and Qwik. The Angular sample keeps the default hover visibility. Keep the returned validator disposer until unmount; the framework binding owns its canvas teardown.

ts
// main.ts
import { render } from '@grafloria/element';
import { nodes, edges, configurePorts } from './ports';

export function mountPorts(container: HTMLElement): () => void {
  container.style.height = '460px';
  const instance = render(
    { nodes, edges }, container,
    { interaction: { portVisibility: 'always' } },
  );
  const disposeRules = configurePorts(instance);
  return () => {
    disposeRules();
    instance.dispose();
  };
}

const container = document.createElement('div');
document.body.append(container);
export const unmountPorts = mountPorts(container);
// Call unmountPorts() when your host removes this view.

The JavaScript canvas starts with A → B → C and a separate purple string input.

JavaScript: two wires connect A, B and C; blue square inputs and diamond outputs stay visible.

The React canvas displays the same initial pipeline.

React: A → B → C appears above the separate String input node.

The Qwik canvas displays the blue number ports and purple string port.

Qwik: the number pipeline has two wires, while String input remains unconnected.

Qwik registers the types and validator in a browser visible task, after the ready callback supplies the instance. The live instance stays in noSerialize() state rather than entering the server's serialized state.

3. Try the connection rules

The initial canvas contains two wires. Test each layer by dragging from the diamond output on A:

  1. Drop on B's y input. A number-to-number wire appears, and that input becomes full.
  2. Try that same input again. No second wire appears; the one incoming-link cap is per port, not per node.
  3. Drop on the purple input of String input. No wire appears because number cannot flow into string.
  4. Drag from C's output to A's input. No wire appears: A already reaches C, so the proposed edge closes a cycle.

To use hover ports instead, omit the interaction prop/options in JavaScript, React, Vue and Qwik. Angular already uses hover ports in this sample. Hovering a node then reveals its ports. For container sizing, see Theme a canvas.

Options that matter

Use the built-in glyphs and port layouts before registering custom geometry. These options belong to each port unless noted otherwise.

OptionTypeDefaultWhat it does
shape.shape'circle' | 'square' | 'diamond' | 'triangle' | 'path''circle'Selects the glyph; path needs SVG path data in shape.path.
shape.sizenumberTwice portDefaultRadiusSets the glyph box width and height; for circles, this is the diameter.
label.layout'inside' | 'outside' | 'orthogonal' | 'radial''outside'Places text toward the body, away from it, across the normal, or radially from the node center.
label.offsetnumber6Sets the gap from the glyph edge in pixels.
layout.strategy'shape' | 'absolute' | 'line' | 'sideLinear' | 'ellipse' | 'ellipseSpread'Shape anchorUses the shape silhouette, a fixed point, a segment, an edge column, or an ellipse arrangement.
groupstringUnsetInherits configuration from the named group in metadata.portGroups; port-level layout, shape and label fields override it.
dataTypestringUnsetNames the data-flow type used for color and compatibility.
gating.isConnectableStart, gating.isConnectableEndbooleantruePermit or veto the corresponding end of a proposed wire.
gating.fromMaxLinks, gating.toMaxLinksnumber | nullUnlimitedCap outgoing or incoming links separately.
maxConnectionsnumberUnlimitedCaps all connections on this port.
gating.allowedTypesstring[]No restrictionRequires the opposite port's data type or system type to appear in the list.
gating.allowSelfLinkbooleanfalsePermits a same-node link only when both endpoints allow it.
gating.allowDuplicateLinksbooleantrueWhen false on either endpoint, rejects another link between the same two ports, including the reverse direction.
interaction.portVisibility'always' | 'on-hover' | 'hidden''on-hover'Controls canvas-wide port visibility; hidden ports disable drag-to-connect.

For many-port nodes, sideLinear spaces ports along an edge, line spaces them along args.start → args.end in node-local pixels, and ellipseSpread fans them around the inscribed ellipse. The port arrangement travels with the node. Route and label edges covers the wires attached to those ports.

Clean up global validators

Connection validators are process-global, not per canvas. Keep each registration's disposer and call it on unmount, as the samples do. The identity check in the cycle callback limits its rule to the intended live model; the disposer removes the registration itself.

Use clearConnectionValidators only when you intend a clean slate for the whole process. It removes every registered validator, including registrations owned by other canvases. Do not substitute it for disposing one view's rule.

Was this page helpful?

Validate port connections — Grafloria