Skip to content

Commit d7b5fb8

Browse files
fix(sdk): make nodeId optional in getHandleId and migrate 2.0.0 handles on load (#22)
1 parent 8410a99 commit d7b5fb8

7 files changed

Lines changed: 261 additions & 23 deletions

File tree

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,5 @@
11
---
2-
'@workflowbuilder/sdk': major
2+
'@workflowbuilder/sdk': patch
33
---
44

5-
refactor!: drop `nodeId` from handle IDs. xyflow scopes handle IDs by their
6-
owning node, so embedding the node id in the string was redundant.
7-
8-
Breaking changes:
9-
10-
- `getHandleId({ nodeId, handleType, innerId? })` is now `getHandleId({ handleType, innerId? })`.
11-
The returned ID is `<handleType>` for outer handles and `<handleType>:inner:<innerId>` for
12-
inner handles. Update every call site to drop the `nodeId` argument.
13-
- The `HandleId` type narrowed accordingly: `OuterHandleId = 'source' | 'target'`,
14-
`InnerHandleId = 'source:inner:${string}' | 'target:inner:${string}'`.
15-
- `ConnectableItem` no longer accepts `{ nodeId, innerId, handleType }`. Pass the
16-
pre-built `handleId` directly (use `getHandleId` to construct it).
17-
18-
Persisted diagrams: edges saved with the previous format
19-
(`<nodeId>:<handleType>[:inner:<innerId>]`) will no longer resolve their
20-
endpoints after upgrading. No automatic migration is provided. Re-save affected
21-
diagrams in a build of the previous SDK, transform them externally, or rebuild
22-
them in the new format before upgrading.
5+
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`.

packages/sdk/src/features/diagram/handles/get-handle-id.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,9 @@ describe('getHandleId', () => {
2222
expect(id.startsWith('source')).toBe(true);
2323
expect(id).not.toContain('node-');
2424
});
25+
26+
it('accepts the deprecated 2.0.0 `nodeId` arg and ignores it at runtime', () => {
27+
expect(getHandleId({ nodeId: 'node-1', handleType: 'source' })).toBe('source');
28+
expect(getHandleId({ nodeId: 'node-1', handleType: 'target', innerId: 'tool-7' })).toBe('target:inner:tool-7');
29+
});
2530
});

packages/sdk/src/features/diagram/handles/get-handle-id.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,11 @@ export function getHandleId({ handleType, innerId }: GetHandleIdOptions): Handle
2626
type GetHandleIdOptions = {
2727
handleType: HandleType;
2828
innerId?: string;
29+
/**
30+
* @deprecated Handle IDs are scoped to the owning node by xyflow, so
31+
* `nodeId` is no longer part of the returned string. Accepted to keep
32+
* 2.0.0 call sites compiling and ignored at runtime. Will be removed in
33+
* the next major (3.0).
34+
*/
35+
nodeId?: string;
2936
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
migrateLegacyHandleId,
3+
migrateLegacyHandleIdsOnEdges,
4+
migrateLegacyHandleIdsOnNodes,
5+
} from './migrate-legacy-handle-id';
6+
7+
describe('migrateLegacyHandleId', () => {
8+
it('passes through new-format outer handle ids unchanged', () => {
9+
expect(migrateLegacyHandleId('source')).toBe('source');
10+
expect(migrateLegacyHandleId('target')).toBe('target');
11+
});
12+
13+
it('passes through new-format inner handle ids unchanged', () => {
14+
expect(migrateLegacyHandleId('source:inner:branch-1')).toBe('source:inner:branch-1');
15+
expect(migrateLegacyHandleId('target:inner:tool-42')).toBe('target:inner:tool-42');
16+
});
17+
18+
it('strips uuid prefix from legacy outer handle ids', () => {
19+
expect(migrateLegacyHandleId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:source')).toBe('source');
20+
expect(migrateLegacyHandleId('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:target')).toBe('target');
21+
});
22+
23+
it('strips uuid prefix from legacy inner handle ids', () => {
24+
expect(migrateLegacyHandleId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:source:inner:branch-1')).toBe(
25+
'source:inner:branch-1',
26+
);
27+
expect(migrateLegacyHandleId('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb:target:inner:tool-42')).toBe(
28+
'target:inner:tool-42',
29+
);
30+
});
31+
32+
it('preserves null and undefined', () => {
33+
expect(migrateLegacyHandleId(null)).toBeNull();
34+
let value: string | undefined;
35+
expect(migrateLegacyHandleId(value)).toBeUndefined();
36+
});
37+
38+
it('leaves unknown shapes untouched', () => {
39+
expect(migrateLegacyHandleId('something-weird')).toBe('something-weird');
40+
});
41+
42+
it('does not migrate strings ending in :source/:target without a uuid prefix (no false positive)', () => {
43+
expect(migrateLegacyHandleId('node-1:source')).toBe('node-1:source');
44+
expect(migrateLegacyHandleId('myCustom:target')).toBe('myCustom:target');
45+
expect(migrateLegacyHandleId('tenant:org:source')).toBe('tenant:org:source');
46+
});
47+
});
48+
49+
describe('migrateLegacyHandleIdsOnEdges', () => {
50+
it('rewrites legacy handle ids on a mixed batch of edges', () => {
51+
const uuidA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
52+
const uuidB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
53+
const uuidC = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
54+
55+
const edges = [
56+
{ id: 'e1', source: uuidA, target: uuidB, sourceHandle: `${uuidA}:source`, targetHandle: `${uuidB}:target` },
57+
{
58+
id: 'e2',
59+
source: uuidC,
60+
target: uuidB,
61+
sourceHandle: `${uuidC}:source:inner:branch-1`,
62+
targetHandle: 'target',
63+
},
64+
{ id: 'e3', source: uuidA, target: uuidB, sourceHandle: 'source', targetHandle: 'target' },
65+
];
66+
67+
const result = migrateLegacyHandleIdsOnEdges(edges);
68+
69+
expect(result[0].sourceHandle).toBe('source');
70+
expect(result[0].targetHandle).toBe('target');
71+
expect(result[1].sourceHandle).toBe('source:inner:branch-1');
72+
expect(result[1].targetHandle).toBe('target');
73+
expect(result[2]).toBe(edges[2]);
74+
});
75+
});
76+
77+
describe('migrateLegacyHandleIdsOnNodes', () => {
78+
it('rewrites legacy sourceHandle inside nested property arrays (ai-agent tools)', () => {
79+
const agentUuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
80+
const nodes = [
81+
{
82+
id: agentUuid,
83+
data: {
84+
properties: {
85+
tools: [
86+
{ id: 'tool-1', sourceHandle: `${agentUuid}:source:inner:tool-1` },
87+
{ id: 'tool-2', sourceHandle: `${agentUuid}:source:inner:tool-2` },
88+
],
89+
},
90+
},
91+
},
92+
];
93+
94+
const [migrated] = migrateLegacyHandleIdsOnNodes(nodes);
95+
const properties = migrated.data.properties as { tools: { sourceHandle: string }[] };
96+
97+
expect(properties.tools[0].sourceHandle).toBe('source:inner:tool-1');
98+
expect(properties.tools[1].sourceHandle).toBe('source:inner:tool-2');
99+
});
100+
101+
it('rewrites legacy sourceHandle inside decisionBranches', () => {
102+
const decisionUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
103+
const nodes = [
104+
{
105+
id: decisionUuid,
106+
data: {
107+
properties: {
108+
decisionBranches: [{ id: 'b-1', sourceHandle: `${decisionUuid}:source:inner:b-1` }],
109+
},
110+
},
111+
},
112+
];
113+
114+
const [migrated] = migrateLegacyHandleIdsOnNodes(nodes);
115+
const properties = migrated.data.properties as { decisionBranches: { sourceHandle: string }[] };
116+
117+
expect(properties.decisionBranches[0].sourceHandle).toBe('source:inner:b-1');
118+
});
119+
120+
it('returns the same node reference when no handle ids change', () => {
121+
const node = {
122+
id: 'agent-1',
123+
data: { properties: { tools: [{ id: 'tool-1', sourceHandle: 'source:inner:tool-1' }] } },
124+
};
125+
126+
const [result] = migrateLegacyHandleIdsOnNodes([node]);
127+
128+
expect(result).toBe(node);
129+
});
130+
131+
it('handles nodes without properties gracefully', () => {
132+
const node = {
133+
id: 'plain',
134+
data: { type: 'foo', icon: 'bar' } as Record<string, unknown>,
135+
};
136+
137+
const [result] = migrateLegacyHandleIdsOnNodes([node]);
138+
139+
expect(result).toBe(node);
140+
});
141+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Edge, Node } from '@xyflow/react';
2+
3+
import { INNER_HANDLE_MARKER } from './types';
4+
5+
const UUID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
6+
const NEW_FORMAT_INNER_PREFIX = new RegExp(`^(source|target):${INNER_HANDLE_MARKER}:`);
7+
const LEGACY_HANDLE_PATTERN = new RegExp(`^${UUID_PATTERN}:(source|target)(:${INNER_HANDLE_MARKER}:.+)?$`);
8+
9+
/**
10+
* SDK 2.0.0 persisted handle IDs as `<uuid>:<handleType>[:inner:<innerId>]`.
11+
* The patch dropped the `<uuid>:` prefix because xyflow already scopes handle
12+
* IDs by their owning node. Strip the prefix on load so edges saved by 2.0.0
13+
* resolve their endpoints after upgrade. New-format IDs pass through unchanged.
14+
* Anything not matching the SDK-produced legacy shape (UUID prefix) is left
15+
* alone, so user-supplied custom handle IDs are never mis-migrated.
16+
*/
17+
export function migrateLegacyHandleId<T extends string | null | undefined>(handleId: T): T {
18+
if (!handleId) return handleId;
19+
20+
if (handleId === 'source' || handleId === 'target') return handleId;
21+
if (NEW_FORMAT_INNER_PREFIX.test(handleId)) return handleId;
22+
23+
const match = handleId.match(LEGACY_HANDLE_PATTERN);
24+
if (match) {
25+
return `${match[1]}${match[2] ?? ''}` as T;
26+
}
27+
28+
return handleId;
29+
}
30+
31+
export function migrateLegacyHandleIdsOnEdges<T extends Pick<Edge, 'sourceHandle' | 'targetHandle'>>(edges: T[]): T[] {
32+
return edges.map((edge) => {
33+
const sourceHandle = migrateLegacyHandleId(edge.sourceHandle);
34+
const targetHandle = migrateLegacyHandleId(edge.targetHandle);
35+
36+
if (sourceHandle === edge.sourceHandle && targetHandle === edge.targetHandle) {
37+
return edge;
38+
}
39+
40+
return { ...edge, sourceHandle, targetHandle };
41+
});
42+
}
43+
44+
/**
45+
* Compound nodes (AI agent tools, decision branches) persist handle IDs
46+
* inside `node.data.properties` (e.g. `tools[].sourceHandle`). Those
47+
* strings are passed straight to `<Handle id={...}>` at render time, so
48+
* if they keep the legacy `<nodeId>:` prefix while edges get migrated,
49+
* xyflow can't match edges to handles. Walk the properties tree and
50+
* rewrite any `sourceHandle` / `targetHandle` string the same way edges
51+
* are rewritten.
52+
*/
53+
export function migrateLegacyHandleIdsOnNodes<T extends Pick<Node, 'data'>>(nodes: T[]): T[] {
54+
return nodes.map((node) => {
55+
const properties = (node.data as { properties?: unknown } | undefined)?.properties;
56+
const migrated = migrateHandleIdsInTree(properties);
57+
if (migrated === properties) return node;
58+
59+
return {
60+
...node,
61+
data: { ...(node.data as object), properties: migrated },
62+
} as T;
63+
});
64+
}
65+
66+
function migrateHandleIdsInTree(value: unknown): unknown {
67+
if (Array.isArray(value)) {
68+
let changed = false;
69+
const next = value.map((item) => {
70+
const migrated = migrateHandleIdsInTree(item);
71+
if (migrated !== item) changed = true;
72+
return migrated;
73+
});
74+
return changed ? next : value;
75+
}
76+
77+
if (value !== null && typeof value === 'object') {
78+
const record = value as Record<string, unknown>;
79+
let changed = false;
80+
const next: Record<string, unknown> = {};
81+
for (const key in record) {
82+
const original = record[key];
83+
const migrated =
84+
(key === 'sourceHandle' || key === 'targetHandle') && typeof original === 'string'
85+
? migrateLegacyHandleId(original)
86+
: migrateHandleIdsInTree(original);
87+
if (migrated !== original) changed = true;
88+
next[key] = migrated;
89+
}
90+
return changed ? next : value;
91+
}
92+
93+
return value;
94+
}

packages/sdk/src/store/slices/diagram-slice.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { type Connection, type Node, type OnConnect, addEdge } from '@xyflow/rea
22

33
import { trackFutureChange } from '../../features/changes-tracker/stores/use-changes-tracker-store';
44
import { getEdgeZIndex } from '../../features/diagram/edges/get-edge-z-index';
5+
import {
6+
migrateLegacyHandleIdsOnEdges,
7+
migrateLegacyHandleIdsOnNodes,
8+
} from '../../features/diagram/handles/migrate-legacy-handle-id';
59
import type { VariablesIndex } from '../../features/variables/types';
610
import {
711
type ConnectionBeingDragged,
@@ -72,8 +76,8 @@ export function useDiagramSlice(set: SetDiagramState, get: GetDiagramState) {
7276
}
7377
}
7478

75-
const nodes = model?.diagram.nodes.map(getNodeWithErrors) || [];
76-
const edges = model?.diagram.edges || [];
79+
const nodes = migrateLegacyHandleIdsOnNodes(model?.diagram.nodes.map(getNodeWithErrors) || []);
80+
const edges = migrateLegacyHandleIdsOnEdges(model?.diagram.edges || []);
7781
const documentName = model?.name || 'Untitled';
7882
const layoutDirection = model?.layoutDirection || 'RIGHT';
7983

packages/sdk/src/store/slices/diagram-slice/actions.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
// About actions: apps/demo/src/app/store/README.md
2+
import {
3+
migrateLegacyHandleIdsOnEdges,
4+
migrateLegacyHandleIdsOnNodes,
5+
} from '../../../features/diagram/handles/migrate-legacy-handle-id';
26
import { selectSingleSelectedElement } from '../../../features/properties-bar/use-single-selected-element';
37
import type { VariableDefinition } from '../../../features/variables/types';
48
import type { LayoutDirection } from '../../../node/common';
@@ -106,8 +110,8 @@ export function setStoreDataFromIntegration(loadData: Partial<IntegrationDataFor
106110
useStore.setState((state) => ({
107111
documentName: loadData.name ?? state.documentName,
108112
globalVariables: loadData.globalVariables || state.globalVariables,
109-
nodes: (loadData.nodes ?? state.nodes).map(getNodeWithErrors),
110-
edges: loadData.edges ?? state.edges,
113+
nodes: (loadData.nodes ? migrateLegacyHandleIdsOnNodes(loadData.nodes) : state.nodes).map(getNodeWithErrors),
114+
edges: loadData.edges ? migrateLegacyHandleIdsOnEdges(loadData.edges) : state.edges,
111115
layoutDirection: loadData.layoutDirection ?? state.layoutDirection,
112116
}));
113117
}

0 commit comments

Comments
 (0)