Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions .changeset/remove-nodeid-from-handles.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,5 @@
---
'@workflowbuilder/sdk': major
'@workflowbuilder/sdk': patch
---

refactor!: drop `nodeId` from handle IDs. xyflow scopes handle IDs by their
owning node, so embedding the node id in the string was redundant.

Breaking changes:

- `getHandleId({ nodeId, handleType, innerId? })` is now `getHandleId({ handleType, innerId? })`.
The returned ID is `<handleType>` for outer handles and `<handleType>:inner:<innerId>` for
inner handles. Update every call site to drop the `nodeId` argument.
- The `HandleId` type narrowed accordingly: `OuterHandleId = 'source' | 'target'`,
`InnerHandleId = 'source:inner:${string}' | 'target:inner:${string}'`.
- `ConnectableItem` no longer accepts `{ nodeId, innerId, handleType }`. Pass the
pre-built `handleId` directly (use `getHandleId` to construct it).

Persisted diagrams: edges saved with the previous format
(`<nodeId>:<handleType>[:inner:<innerId>]`) will no longer resolve their
endpoints after upgrading. No automatic migration is provided. Re-save affected
diagrams in a build of the previous SDK, transform them externally, or rebuild
them in the new format before upgrading.
fix: drop `nodeId` from handle IDs. Compound nodes (decision, AI agent, conditional) can now declare default ports statically (e.g. in JSON-defined `defaultProperties`) and copy/paste no longer requires custom handle rewriting after a node ID change. `getHandleId({ nodeId })` still compiles — the argument is optional, marked `@deprecated`, and ignored at runtime. Diagrams saved with the 2.0.0 `<nodeId>:<handleType>[:inner:<innerId>]` format are auto-migrated to the new `<handleType>[:inner:<innerId>]` form on `setDiagramModel` and `setStoreDataFromIntegration`.
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ describe('getHandleId', () => {
expect(id.startsWith('source')).toBe(true);
expect(id).not.toContain('node-');
});

it('accepts the deprecated 2.0.0 `nodeId` arg and ignores it at runtime', () => {
expect(getHandleId({ nodeId: 'node-1', handleType: 'source' })).toBe('source');
expect(getHandleId({ nodeId: 'node-1', handleType: 'target', innerId: 'tool-7' })).toBe('target:inner:tool-7');
});
});
7 changes: 7 additions & 0 deletions packages/sdk/src/features/diagram/handles/get-handle-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ export function getHandleId({ handleType, innerId }: GetHandleIdOptions): Handle
type GetHandleIdOptions = {
handleType: HandleType;
innerId?: string;
/**
* @deprecated Handle IDs are scoped to the owning node by xyflow, so
* `nodeId` is no longer part of the returned string. Accepted to keep
* 2.0.0 call sites compiling and ignored at runtime. Will be removed in
* the next major (3.0).
*/
nodeId?: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
migrateLegacyHandleId,
migrateLegacyHandleIdsOnEdges,
migrateLegacyHandleIdsOnNodes,
} from './migrate-legacy-handle-id';

describe('migrateLegacyHandleId', () => {
it('passes through new-format outer handle ids unchanged', () => {
expect(migrateLegacyHandleId('source')).toBe('source');
expect(migrateLegacyHandleId('target')).toBe('target');
});

it('passes through new-format inner handle ids unchanged', () => {
expect(migrateLegacyHandleId('source:inner:branch-1')).toBe('source:inner:branch-1');
expect(migrateLegacyHandleId('target:inner:tool-42')).toBe('target:inner:tool-42');
});

it('strips uuid prefix from legacy outer handle ids', () => {
expect(migrateLegacyHandleId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:source')).toBe('source');
expect(migrateLegacyHandleId('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:target')).toBe('target');
});

it('strips uuid prefix from legacy inner handle ids', () => {
expect(migrateLegacyHandleId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:source:inner:branch-1')).toBe(
'source:inner:branch-1',
);
expect(migrateLegacyHandleId('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:target:inner:tool-42')).toBe(
'target:inner:tool-42',
);
});

it('preserves null and undefined', () => {
expect(migrateLegacyHandleId(null)).toBeNull();
let value: string | undefined;
expect(migrateLegacyHandleId(value)).toBeUndefined();
});

it('leaves unknown shapes untouched', () => {
expect(migrateLegacyHandleId('something-weird')).toBe('something-weird');
});

it('does not migrate strings ending in :source/:target without a uuid prefix (no false positive)', () => {
expect(migrateLegacyHandleId('node-1:source')).toBe('node-1:source');
expect(migrateLegacyHandleId('myCustom:target')).toBe('myCustom:target');
expect(migrateLegacyHandleId('tenant:org:source')).toBe('tenant:org:source');
});
});

describe('migrateLegacyHandleIdsOnEdges', () => {
it('rewrites legacy handle ids on a mixed batch of edges', () => {
const uuidA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const uuidB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const uuidC = 'cccccccc-cccc-cccc-cccc-cccccccccccc';

const edges = [
{ id: 'e1', source: uuidA, target: uuidB, sourceHandle: `${uuidA}:source`, targetHandle: `${uuidB}:target` },
{
id: 'e2',
source: uuidC,
target: uuidB,
sourceHandle: `${uuidC}:source:inner:branch-1`,
targetHandle: 'target',
},
{ id: 'e3', source: uuidA, target: uuidB, sourceHandle: 'source', targetHandle: 'target' },
];

const result = migrateLegacyHandleIdsOnEdges(edges);

expect(result[0].sourceHandle).toBe('source');
expect(result[0].targetHandle).toBe('target');
expect(result[1].sourceHandle).toBe('source:inner:branch-1');
expect(result[1].targetHandle).toBe('target');
expect(result[2]).toBe(edges[2]);
});
});

describe('migrateLegacyHandleIdsOnNodes', () => {
it('rewrites legacy sourceHandle inside nested property arrays (ai-agent tools)', () => {
const agentUuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const nodes = [
{
id: agentUuid,
data: {
properties: {
tools: [
{ id: 'tool-1', sourceHandle: `${agentUuid}:source:inner:tool-1` },
{ id: 'tool-2', sourceHandle: `${agentUuid}:source:inner:tool-2` },
],
},
},
},
];

const [migrated] = migrateLegacyHandleIdsOnNodes(nodes);
const properties = migrated.data.properties as { tools: { sourceHandle: string }[] };

expect(properties.tools[0].sourceHandle).toBe('source:inner:tool-1');
expect(properties.tools[1].sourceHandle).toBe('source:inner:tool-2');
});

it('rewrites legacy sourceHandle inside decisionBranches', () => {
const decisionUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const nodes = [
{
id: decisionUuid,
data: {
properties: {
decisionBranches: [{ id: 'b-1', sourceHandle: `${decisionUuid}:source:inner:b-1` }],
},
},
},
];

const [migrated] = migrateLegacyHandleIdsOnNodes(nodes);
const properties = migrated.data.properties as { decisionBranches: { sourceHandle: string }[] };

expect(properties.decisionBranches[0].sourceHandle).toBe('source:inner:b-1');
});

it('returns the same node reference when no handle ids change', () => {
const node = {
id: 'agent-1',
data: { properties: { tools: [{ id: 'tool-1', sourceHandle: 'source:inner:tool-1' }] } },
};

const [result] = migrateLegacyHandleIdsOnNodes([node]);

expect(result).toBe(node);
});

it('handles nodes without properties gracefully', () => {
const node = {
id: 'plain',
data: { type: 'foo', icon: 'bar' } as Record<string, unknown>,
};

const [result] = migrateLegacyHandleIdsOnNodes([node]);

expect(result).toBe(node);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Edge, Node } from '@xyflow/react';

import { INNER_HANDLE_MARKER } from './types';

const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
const NEW_FORMAT_INNER_PREFIX = new RegExp(`^(source|target):${INNER_HANDLE_MARKER}:`);
const LEGACY_HANDLE_PATTERN = new RegExp(`^${UUID_PATTERN}:(source|target)(:${INNER_HANDLE_MARKER}:.+)?$`);

/**
* SDK 2.0.0 persisted handle IDs as `<uuid>:<handleType>[:inner:<innerId>]`.
* The patch dropped the `<uuid>:` prefix because xyflow already scopes handle
* IDs by their owning node. Strip the prefix on load so edges saved by 2.0.0
* resolve their endpoints after upgrade. New-format IDs pass through unchanged.
* Anything not matching the SDK-produced legacy shape (UUID prefix) is left
* alone, so user-supplied custom handle IDs are never mis-migrated.
*/
export function migrateLegacyHandleId<T extends string | null | undefined>(handleId: T): T {
if (!handleId) return handleId;

if (handleId === 'source' || handleId === 'target') return handleId;
if (NEW_FORMAT_INNER_PREFIX.test(handleId)) return handleId;

const match = handleId.match(LEGACY_HANDLE_PATTERN);
if (match) {
return `${match[1]}${match[2] ?? ''}` as T;
}

return handleId;
}

export function migrateLegacyHandleIdsOnEdges<T extends Pick<Edge, 'sourceHandle' | 'targetHandle'>>(edges: T[]): T[] {
return edges.map((edge) => {
const sourceHandle = migrateLegacyHandleId(edge.sourceHandle);
const targetHandle = migrateLegacyHandleId(edge.targetHandle);

if (sourceHandle === edge.sourceHandle && targetHandle === edge.targetHandle) {
return edge;
}

return { ...edge, sourceHandle, targetHandle };
});
}

/**
* Compound nodes (AI agent tools, decision branches) persist handle IDs
* inside `node.data.properties` (e.g. `tools[].sourceHandle`). Those
* strings are passed straight to `<Handle id={...}>` at render time, so
* if they keep the legacy `<nodeId>:` prefix while edges get migrated,
* xyflow can't match edges to handles. Walk the properties tree and
* rewrite any `sourceHandle` / `targetHandle` string the same way edges
* are rewritten.
*/
export function migrateLegacyHandleIdsOnNodes<T extends Pick<Node, 'data'>>(nodes: T[]): T[] {
return nodes.map((node) => {
const properties = (node.data as { properties?: unknown } | undefined)?.properties;
const migrated = migrateHandleIdsInTree(properties);
if (migrated === properties) return node;

return {
...node,
data: { ...(node.data as object), properties: migrated },
} as T;
});
}

function migrateHandleIdsInTree(value: unknown): unknown {
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item) => {
const migrated = migrateHandleIdsInTree(item);
if (migrated !== item) changed = true;
return migrated;
});
return changed ? next : value;
}

if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
let changed = false;
const next: Record<string, unknown> = {};
for (const key in record) {
const original = record[key];
const migrated =
(key === 'sourceHandle' || key === 'targetHandle') && typeof original === 'string'
? migrateLegacyHandleId(original)
: migrateHandleIdsInTree(original);
if (migrated !== original) changed = true;
next[key] = migrated;
}
return changed ? next : value;
}

return value;
}
8 changes: 6 additions & 2 deletions packages/sdk/src/store/slices/diagram-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { type Connection, type Node, type OnConnect, addEdge } from '@xyflow/rea

import { trackFutureChange } from '../../features/changes-tracker/stores/use-changes-tracker-store';
import { getEdgeZIndex } from '../../features/diagram/edges/get-edge-z-index';
import {
migrateLegacyHandleIdsOnEdges,
migrateLegacyHandleIdsOnNodes,
} from '../../features/diagram/handles/migrate-legacy-handle-id';
import type { VariablesIndex } from '../../features/variables/types';
import {
type ConnectionBeingDragged,
Expand Down Expand Up @@ -72,8 +76,8 @@ export function useDiagramSlice(set: SetDiagramState, get: GetDiagramState) {
}
}

const nodes = model?.diagram.nodes.map(getNodeWithErrors) || [];
const edges = model?.diagram.edges || [];
const nodes = migrateLegacyHandleIdsOnNodes(model?.diagram.nodes.map(getNodeWithErrors) || []);
const edges = migrateLegacyHandleIdsOnEdges(model?.diagram.edges || []);
const documentName = model?.name || 'Untitled';
const layoutDirection = model?.layoutDirection || 'RIGHT';

Expand Down
8 changes: 6 additions & 2 deletions packages/sdk/src/store/slices/diagram-slice/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
// About actions: apps/demo/src/app/store/README.md
import {
migrateLegacyHandleIdsOnEdges,
migrateLegacyHandleIdsOnNodes,
} from '../../../features/diagram/handles/migrate-legacy-handle-id';
import { selectSingleSelectedElement } from '../../../features/properties-bar/use-single-selected-element';
import type { VariableDefinition } from '../../../features/variables/types';
import type { LayoutDirection } from '../../../node/common';
Expand Down Expand Up @@ -106,8 +110,8 @@ export function setStoreDataFromIntegration(loadData: Partial<IntegrationDataFor
useStore.setState((state) => ({
documentName: loadData.name ?? state.documentName,
globalVariables: loadData.globalVariables || state.globalVariables,
nodes: (loadData.nodes ?? state.nodes).map(getNodeWithErrors),
edges: loadData.edges ?? state.edges,
nodes: (loadData.nodes ? migrateLegacyHandleIdsOnNodes(loadData.nodes) : state.nodes).map(getNodeWithErrors),
edges: loadData.edges ? migrateLegacyHandleIdsOnEdges(loadData.edges) : state.edges,
layoutDirection: loadData.layoutDirection ?? state.layoutDirection,
}));
}
Expand Down
Loading