Skip to content

Commit e027e59

Browse files
fix: keep the link the input references when deduplicating duplicate connections
1 parent 907ca2b commit e027e59

4 files changed

Lines changed: 252 additions & 31 deletions

File tree

src/lib/litegraph/src/LGraph.inputSlotRealign.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ function shiftedNodesAndLinks(sourceId: number, targetId: number) {
109109
/**
110110
* As {@link shiftedNodesAndLinks}, but `in_a` carries a duplicate link: the
111111
* link registered first (id 3) is not the id the serialized input references
112-
* (id 4). Registration rejects 4, so realignment must follow
113-
* the rejected alias through to the registered link to correct its slot.
112+
* (id 4). The input reference is authoritative, so registration keeps 4 and
113+
* rejects 3; realignment must still correct the survivor's slot.
114114
*/
115115
function duplicateDriftedNodesAndLinks(sourceId: number, targetId: number) {
116116
const base = shiftedNodesAndLinks(sourceId, targetId)
@@ -288,12 +288,18 @@ const LINK_BY_INPUT_NAME: Record<string, number> = {
288288
in_c: 2
289289
}
290290

291-
function assertLinksRealigned(graph: LGraph, targetNodeId: NodeId) {
291+
function assertLinksRealigned(
292+
graph: LGraph,
293+
targetNodeId: NodeId,
294+
overrides: Record<string, number> = {}
295+
) {
292296
const target = graph.getNodeById(targetNodeId)!
293297
const linkStore = useLinkStore()
294298

295299
for (const [slot, input] of target.inputs.entries()) {
296-
const expectedLinkId = toLinkId(LINK_BY_INPUT_NAME[input.name])
300+
const expectedLinkId = toLinkId(
301+
overrides[input.name] ?? LINK_BY_INPUT_NAME[input.name]
302+
)
297303
const link = graph.links.get(expectedLinkId)!
298304

299305
expect(link.target_slot, `link.target_slot for input ${input.name}`).toBe(
@@ -339,8 +345,8 @@ describe('LGraph.configure input slot realignment (#3348)', () => {
339345
const graph = new LGraph()
340346
graph.configure(savedWorkflow({ duplicate: true }))
341347

342-
expect(graph.links.has(toLinkId(4))).toBe(false)
343-
assertLinksRealigned(graph, toNodeId(2))
348+
expect(graph.links.has(toLinkId(3))).toBe(false)
349+
assertLinksRealigned(graph, toNodeId(2), { in_a: 4 })
344350
})
345351

346352
it('maps a rejected subgraph input fanout branch to its exact survivor', () => {
@@ -355,7 +361,7 @@ describe('LGraph.configure input slot realignment (#3348)', () => {
355361
graph.configure(workflow)
356362

357363
const subgraph = graph.subgraphs.get(SUBGRAPH_ID)!
358-
expect(subgraph.inputs[0].linkIds).toEqual([toLinkId(2)])
364+
expect(subgraph.inputs[0].linkIds).toEqual([toLinkId(3)])
359365
})
360366

361367
it('uses the first slot when one link is referenced by multiple inputs', () => {

src/lib/litegraph/src/__fixtures__/duplicateLinks.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,107 @@ export const conflictingOriginLinksRoot: SerialisableGraph = {
141141
]
142142
}
143143

144+
/**
145+
* Root graph containing a SubgraphNode whose subgraph definition has two
146+
* links from different origins into the same subgraph output boundary slot.
147+
* The boundary slot's `linkIds` array lists both candidates, so the target
148+
* side cannot name a single survivor and document order must win, with the
149+
* dropped id pruned from `linkIds` rather than left dangling.
150+
*/
151+
export const conflictingOriginLinksSubgraphBoundary: SerialisableGraph = {
152+
id: 'dd000000-0000-4000-8000-000000000005',
153+
version: 1,
154+
revision: 0,
155+
state: {
156+
lastNodeId: 1,
157+
lastLinkId: 0,
158+
lastGroupId: 0,
159+
lastRerouteId: 0
160+
},
161+
nodes: [
162+
{
163+
id: 1,
164+
type: 'dd222222-2222-4222-8222-222222222222',
165+
pos: [0, 0],
166+
size: [200, 100],
167+
flags: {},
168+
order: 0,
169+
mode: 0,
170+
properties: {}
171+
}
172+
],
173+
definitions: {
174+
subgraphs: [
175+
{
176+
id: 'dd222222-2222-4222-8222-222222222222',
177+
version: 1,
178+
revision: 0,
179+
state: {
180+
lastNodeId: 2,
181+
lastLinkId: 2,
182+
lastGroupId: 0,
183+
lastRerouteId: 0
184+
},
185+
name: 'Subgraph With Conflicting Boundary Links',
186+
config: {},
187+
inputNode: { id: SUBGRAPH_INPUT_ID, bounding: [0, 100, 120, 60] },
188+
outputNode: { id: SUBGRAPH_OUTPUT_ID, bounding: [500, 100, 120, 60] },
189+
inputs: [],
190+
outputs: [
191+
{ id: 'output', name: 'output', type: 'number', linkIds: [1, 2] }
192+
],
193+
widgets: [],
194+
nodes: [
195+
{
196+
id: 1,
197+
type: 'test/DupTestNode',
198+
pos: [100, 100],
199+
size: [200, 100],
200+
flags: {},
201+
order: 0,
202+
mode: 0,
203+
inputs: [{ name: 'input_0', type: 'number', link: null }],
204+
outputs: [{ name: 'output_0', type: 'number', links: [1] }],
205+
properties: {}
206+
},
207+
{
208+
id: 2,
209+
type: 'test/DupTestNode',
210+
pos: [100, 300],
211+
size: [200, 100],
212+
flags: {},
213+
order: 1,
214+
mode: 0,
215+
inputs: [{ name: 'input_0', type: 'number', link: null }],
216+
outputs: [{ name: 'output_0', type: 'number', links: [2] }],
217+
properties: {}
218+
}
219+
],
220+
groups: [],
221+
links: [
222+
{
223+
id: 1,
224+
origin_id: 1,
225+
origin_slot: 0,
226+
target_id: SUBGRAPH_OUTPUT_ID,
227+
target_slot: 0,
228+
type: 'number'
229+
},
230+
{
231+
id: 2,
232+
origin_id: 2,
233+
origin_slot: 0,
234+
target_id: SUBGRAPH_OUTPUT_ID,
235+
target_slot: 0,
236+
type: 'number'
237+
}
238+
],
239+
extra: {}
240+
}
241+
]
242+
}
243+
}
244+
144245
/**
145246
* Root graph with slot-shifted duplicates. Target node has an extra input
146247
* (simulating widget-to-input conversion) that shifts the connected input

src/lib/litegraph/src/linkDeduplication.conflictingOrigins.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
22
import { setActivePinia } from 'pinia'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5+
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
56
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
67
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
78
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
@@ -11,7 +12,10 @@ import { toLinkId } from '@/types/linkId'
1112
import { toNodeId } from '@/types/nodeId'
1213
import type { NodeId } from '@/types/nodeId'
1314

14-
import { conflictingOriginLinksRoot } from './__fixtures__/duplicateLinks'
15+
import {
16+
conflictingOriginLinksRoot,
17+
conflictingOriginLinksSubgraphBoundary
18+
} from './__fixtures__/duplicateLinks'
1519

1620
class DupTestNode extends LGraphNode {
1721
constructor(title?: string) {
@@ -71,15 +75,15 @@ describe('normalizeConfiguredTopology with conflicting origins (#15577)', () =>
7175
LiteGraph.registerNodeType('test/DupTestNode', DupTestNode)
7276
})
7377

74-
it.fails('keeps the link that input.link references', () => {
78+
it('keeps the link that input.link references', () => {
7579
const graph = configureConflictingOrigins()
7680

7781
expect(graph.getNodeById(toNodeId(3))?.getInputLink(0)?.origin_id).toBe(
7882
toNodeId(2)
7983
)
8084
})
8185

82-
it.fails('warns when a link is dropped in favour of a different origin', () => {
86+
it('warns when a link is dropped in favour of a different origin', () => {
8387
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
8488

8589
configureConflictingOrigins()
@@ -97,7 +101,7 @@ describe('normalizeConfiguredTopology with conflicting origins (#15577)', () =>
97101
expect(graph.getNodeById(toNodeId(3))?.getInputLink(0)).toBeDefined()
98102
})
99103

100-
it.fails('re-saves the workflow without changing the upstream node', () => {
104+
it('re-saves the workflow without changing the upstream node', () => {
101105
const graph = configureConflictingOrigins()
102106

103107
const [survivor] = linksIntoTargetSlot(
@@ -110,6 +114,29 @@ describe('normalizeConfiguredTopology with conflicting origins (#15577)', () =>
110114
})
111115
})
112116

117+
describe('conflicting origins at a subgraph output boundary (#15577)', () => {
118+
beforeEach(() => {
119+
setActivePinia(createTestingPinia({ stubActions: false }))
120+
LiteGraph.registerNodeType('test/DupTestNode', DupTestNode)
121+
})
122+
123+
it('keeps exactly one link and prunes the dropped id from linkIds', () => {
124+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
125+
126+
const graph = new LGraph()
127+
graph.configure(structuredClone(conflictingOriginLinksSubgraphBoundary))
128+
const subgraph = graph.subgraphs.get(
129+
'dd222222-2222-4222-8222-222222222222'
130+
)!
131+
132+
expect(subgraph.links.size).toBe(1)
133+
expect(subgraph.outputs[0].linkIds).toEqual([toLinkId(1)])
134+
expect(warn.mock.calls.flat().join(' ')).toContain(
135+
`${SUBGRAPH_OUTPUT_ID}:0`
136+
)
137+
})
138+
})
139+
113140
describe('legacy mirror link creation (#15577 reachability)', () => {
114141
beforeEach(() => {
115142
setActivePinia(createTestingPinia({ stubActions: false }))

src/lib/litegraph/src/linkDeduplication.ts

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,36 +54,123 @@ export function remapLinkReferences(
5454
}
5555
}
5656

57+
/**
58+
* Removes serialized link ids from every list and scalar that names them.
59+
* Used for links dropped because a *different* connection already owns the
60+
* target slot: remapping would hand the loser's origin a link it does not own,
61+
* so the reference must be deleted rather than repointed.
62+
*/
63+
export function pruneLinkReferences(
64+
data: ConfiguredGraph,
65+
dropped: ReadonlySet<number>
66+
): void {
67+
if (!dropped.size) return
68+
const nodes = data.nodes ?? []
69+
70+
for (const input of nodes.flatMap((node) => node.inputs ?? [])) {
71+
if (input.link != null && dropped.has(input.link)) input.link = null
72+
}
73+
74+
const linkIdLists = [
75+
...nodes.flatMap((node) =>
76+
(node.outputs ?? []).map((output) => output.links)
77+
),
78+
...(data.inputs ?? []).map((slot) => slot.linkIds),
79+
...(data.outputs ?? []).map((slot) => slot.linkIds),
80+
...(data.reroutes ?? []).map((reroute) => reroute.linkIds)
81+
]
82+
for (const ids of linkIdLists) {
83+
if (!ids) continue
84+
const kept = ids.filter((id) => !dropped.has(id))
85+
if (kept.length !== ids.length) ids.splice(0, ids.length, ...kept)
86+
}
87+
88+
if (data.extra?.linkExtensions) {
89+
data.extra.linkExtensions = data.extra.linkExtensions.filter(
90+
(extension) => !dropped.has(extension.id)
91+
)
92+
}
93+
}
94+
95+
/**
96+
* Maps each `target_id:target_slot` to the link id the *target side* of the
97+
* serialized data names, when that side can name exactly one.
98+
*
99+
* Positional, not membership. A node input holds a single scalar at a known
100+
* index, so it is authoritative. A subgraph boundary slot holds an *array*
101+
* (`SubgraphIO.linkIds`), and a file with two links into one boundary slot
102+
* lists both ids, so containment cannot discriminate between them - such
103+
* slots deliberately produce no entry here and fall back to document order.
104+
*/
105+
export function authoritativeSurvivorByTarget(
106+
data: ConfiguredGraph
107+
): Map<string, number> {
108+
const authoritative = new Map<string, number>()
109+
for (const node of data.nodes ?? []) {
110+
const inputs = node.inputs ?? []
111+
for (const [slot, input] of inputs.entries()) {
112+
if (input?.link == null) continue
113+
authoritative.set(`${toNodeId(node.id)}:${slot}`, input.link)
114+
}
115+
}
116+
return authoritative
117+
}
118+
57119
export function normalizeConfiguredTopology<T extends ConfiguredGraph>(
58120
data: T
59121
): T {
60122
if (!data.links?.length) return data
61123

62-
const survivorByTarget = new Map<string, ReturnType<typeof linkFields>>()
63-
const survivorByDuplicateId = new Map<number, number>()
64-
const links = data.links.filter((link) => {
124+
const authoritative = authoritativeSurvivorByTarget(data)
125+
126+
// Pass 1: group every link by the slot it targets, in document order.
127+
const byTarget = new Map<string, ReturnType<typeof linkFields>[]>()
128+
for (const link of data.links) {
65129
const fields = linkFields(link)
66130
const key = `${toNodeId(fields.target_id)}:${fields.target_slot}`
67-
const survivor = survivorByTarget.get(key)
68-
if (!survivor) {
69-
survivorByTarget.set(key, fields)
70-
return true
71-
}
72-
if (
73-
toNodeId(survivor.origin_id) === toNodeId(fields.origin_id) &&
74-
survivor.origin_slot === fields.origin_slot
75-
) {
76-
survivorByDuplicateId.set(fields.id, survivor.id)
131+
const group = byTarget.get(key)
132+
if (group) group.push(fields)
133+
else byTarget.set(key, [fields])
134+
}
135+
136+
// Pass 2: pick the survivor per slot. The target side wins when it names a
137+
// link that is actually in the group; otherwise keep document order.
138+
const remapped = new Map<number, number>()
139+
const dropped = new Set<number>()
140+
for (const [key, group] of byTarget) {
141+
if (group.length === 1) continue
142+
const named = authoritative.get(key)
143+
const survivor =
144+
(named == null
145+
? undefined
146+
: group.find((fields) => fields.id === named)) ?? group[0]
147+
for (const fields of group) {
148+
if (fields.id === survivor.id) continue
149+
if (
150+
toNodeId(survivor.origin_id) === toNodeId(fields.origin_id) &&
151+
survivor.origin_slot === fields.origin_slot
152+
) {
153+
remapped.set(fields.id, survivor.id)
154+
} else {
155+
dropped.add(fields.id)
156+
console.warn(
157+
`LiteGraph: link ${fields.id} (origin ${String(fields.origin_id)}:${fields.origin_slot}) dropped; ` +
158+
`${key} is already connected by link ${survivor.id} (origin ${String(survivor.origin_id)}:${survivor.origin_slot})`
159+
)
160+
}
77161
}
78-
return false
79-
})
80-
if (links.length === data.links.length) return data
162+
}
163+
164+
if (!remapped.size && !dropped.size) return data
81165

82-
const normalized = Object.assign({}, data, { links })
83-
if (!survivorByDuplicateId.size) return normalized
166+
const links = data.links.filter((link) => {
167+
const { id } = linkFields(link)
168+
return !remapped.has(id) && !dropped.has(id)
169+
})
84170

85-
const cloned = cloneDeep(normalized)
86-
remapLinkReferences(cloned, survivorByDuplicateId)
171+
const cloned = cloneDeep(Object.assign({}, data, { links }))
172+
if (remapped.size) remapLinkReferences(cloned, remapped)
173+
pruneLinkReferences(cloned, dropped)
87174
return cloned
88175
}
89176

0 commit comments

Comments
 (0)