# Validate port connections

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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`PortSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-portspec) vocabulary rather than constructing live ports yourself. [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-engine) 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 title="ports.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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-ports) 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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions) 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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/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.

:::code-group
```ts title="JavaScript"
// 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.
```
```tsx title="React"
// App.tsx
import { useCallback, useEffect, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, configurePorts } from './ports';

export default function App() {
  const instanceRef = useRef<DiagramInstance | null>(null);
  useEffect(() => {
    const instance = instanceRef.current;
    if (!instance) return;
    return configurePorts(instance);
  }, []);
  const onInit = useCallback((instance: DiagramInstance) => {
    instanceRef.current = instance;
  }, []);
  return (
    <div style={{ height: '460px' }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
        interaction={{ portVisibility: 'always' }} onInit={onInit} />
    </div>
  );
}
```
```vue title="Vue"
<!-- App.vue -->
<script setup lang="ts">
import { onBeforeUnmount } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, configurePorts } from './ports';

let disposeRules: (() => void) | undefined;
function onInit(instance: DiagramInstance) {
  disposeRules?.();
  disposeRules = configurePorts(instance);
}
onBeforeUnmount(() => disposeRules?.());
</script>

<template>
  <div style="height:460px">
    <GrafloriaFlow :default-nodes="nodes" :default-edges="edges"
      :interaction="{ portVisibility: 'always' }" @init="onInit" />
  </div>
</template>
```
```tsx title="Qwik"
// App.tsx
import {
  component$, noSerialize, useSignal, useVisibleTask$,
  type NoSerialize,
} from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, configurePorts } from './ports';

export default component$(() => {
  const instance = useSignal<NoSerialize<DiagramInstance>>();
  useVisibleTask$(({ track, cleanup }) => {
    const mounted = track(() => instance.value);
    if (!mounted) return;
    const disposeRules = configurePorts(mounted);
    cleanup(disposeRules);
  });
  return (
    <div style={{ height: '460px' }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
        interaction={{ portVisibility: 'always' }}
        onInit$={(mounted: DiagramInstance) => {
          instance.value = noSerialize(mounted);
        }} />
    </div>
  );
});
```
```ts title="Angular"
// app.component.ts
import { Component, viewChild, type AfterViewInit, type OnDestroy } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { nodes, edges, installPortRules } from './ports';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
      style="display:block; height:460px" />
  `,
})
export class AppComponent implements AfterViewInit, OnDestroy {
  readonly canvas = viewChild.required(DiagramCanvasComponent);
  nodes: NodeSpec[] = structuredClone(nodes);
  edges: EdgeSpec[] = structuredClone(edges);
  private disposeRules: (() => void) | undefined;

  ngAfterViewInit(): void {
    const engine = this.canvas().activeEngine();
    if (!engine) return;
    this.disposeRules = installPortRules(engine);
    this.canvas().scheduleRender();
  }

  ngOnDestroy(): void {
    this.disposeRules?.();
  }
}
```
:::

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.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/13c8320171e2a24f4374dbd673631522.png)

The React canvas displays the same initial pipeline.

![React: A → B → C appears above the separate String input node.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/f2c8f04406e795cb82f9511e74dca53a.png)

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

![Qwik: the number pipeline has two wires, while String input remains unconnected.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/4a50e70d7e77ad1e8127d9a5c735fb07.png)

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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/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.

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `shape.shape` | `'circle' \| 'square' \| 'diamond' \| 'triangle' \| 'path'` | `'circle'` | Selects the glyph; `path` needs SVG path data in `shape.path`. |
| `shape.size` | `number` | Twice `portDefaultRadius` | Sets 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.offset` | `number` | `6` | Sets the gap from the glyph edge in pixels. |
| `layout.strategy` | `'shape' \| 'absolute' \| 'line' \| 'sideLinear' \| 'ellipse' \| 'ellipseSpread'` | Shape anchor | Uses the shape silhouette, a fixed point, a segment, an edge column, or an ellipse arrangement. |
| `group` | `string` | Unset | Inherits configuration from the named group in `metadata.portGroups`; port-level layout, shape and label fields override it. |
| `dataType` | `string` | Unset | Names the data-flow type used for color and compatibility. |
| `gating.isConnectableStart`, `gating.isConnectableEnd` | `boolean` | `true` | Permit or veto the corresponding end of a proposed wire. |
| `gating.fromMaxLinks`, `gating.toMaxLinks` | `number \| null` | Unlimited | Cap outgoing or incoming links separately. |
| `maxConnections` | `number` | Unlimited | Caps all connections on this port. |
| `gating.allowedTypes` | `string[]` | No restriction | Requires the opposite port's data type or system type to appear in the list. |
| `gating.allowSelfLink` | `boolean` | `false` | Permits a same-node link only when both endpoints allow it. |
| `gating.allowDuplicateLinks` | `boolean` | `true` | When 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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-ext-functions) 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.

## Live demos and related pages

- [Typed ports](https://grafloria.com/demos/ports/typed-ports.html): compare matching and mismatched types.
- [Port groups and layouts](https://grafloria.com/demos/ports/port-groups-and-layouts.html): compare columns, segments and rings.
- [Connection limit](https://grafloria.com/demos/nodes/connection-limit.html): try a second wire on a full port.
- [Preventing cycles](https://grafloria.com/demos/interaction/preventing-cycles.html) and its [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/interaction/preventing-cycles.html): follow directed reachability through live links.
- [Specs and live models](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/specs-and-live-models): understand the data queried by the cycle callback.
- [Instance and lifecycle](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/instance-and-lifecycle): own a mounted instance and its teardown.
