feat: deduplicate subgraph node IDs on workflow load (experimental) (#8762)

## Summary

Add `ensureGlobalIdUniqueness` to reassign duplicate node IDs across
subgraphs when loading workflows, gated behind an experimental setting.

## Changes

- **What**: Shared `LGraphState` between root graph and subgraphs so ID
counters are global. Added `ensureGlobalIdUniqueness()` method that
detects and remaps colliding node IDs in subgraphs, preserving root
graph IDs as canonical and patching link references. Gated behind
`Comfy.Graph.DeduplicateSubgraphNodeIds` (experimental, default
`false`).
- **Dependencies**: None

## Review Focus

- Shared state override on `Subgraph` (getter delegates to root, setter
is no-op) — verify no existing code sets `subgraph.state` directly.
- `Math.max` state merging in `configure()` prevents ID counter
regression when loading subgraph definitions.
- Feature flag wiring: static property on `LGraph`, synced from settings
via `useLitegraphSettings`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8762-feat-deduplicate-subgraph-node-IDs-on-workflow-load-experimental-3036d73d36508184b6cee5876dc4d935)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-09 18:01:58 -08:00
committed by GitHub
parent a6620a4ddc
commit ff9642d0cb
7 changed files with 1002 additions and 7 deletions

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
LLink
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraphData,
createTestSubgraphNode
@@ -288,3 +294,182 @@ describe('Legacy LGraph Compatibility Layer', () => {
expect(LiteGraph.LGraph).toBe(LGraph)
})
})
describe('Shared LGraphState', () => {
function createSubgraphOnGraph(rootGraph: LGraph): Subgraph {
const data = createTestSubgraphData()
return rootGraph.createSubgraph(data)
}
it('subgraph state is the same object as rootGraph state', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
expect(subgraph.state).toBe(rootGraph.state)
})
it('adding a node in a subgraph increments the root counter', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
rootGraph.add(new DummyNode())
const rootNodeId = rootGraph.state.lastNodeId
subgraph.add(new DummyNode())
expect(rootGraph.state.lastNodeId).toBe(rootNodeId + 1)
})
it('node IDs never collide between root and subgraph', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNode = new DummyNode()
subgraph.add(subNode)
expect(rootNode.id).not.toBe(subNode.id)
})
it('configure merges state using max', () => {
const rootGraph = new LGraph()
rootGraph.state.lastNodeId = 10
const data = createTestSubgraphData()
data.state = {
lastNodeId: 5,
lastLinkId: 20,
lastGroupId: 0,
lastRerouteId: 0
}
const subgraph = rootGraph.createSubgraph(data)
subgraph.configure(data)
expect(rootGraph.state.lastNodeId).toBe(10)
expect(rootGraph.state.lastLinkId).toBe(20)
})
})
describe('ensureGlobalIdUniqueness', () => {
function createSubgraphOnGraph(rootGraph: LGraph): Subgraph {
const data = createTestSubgraphData()
return rootGraph.createSubgraph(data)
}
it('reassigns duplicate node IDs in subgraphs', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNode = new DummyNode()
subNode.id = rootNode.id
subgraph._nodes.push(subNode)
subgraph._nodes_by_id[subNode.id] = subNode
rootGraph.ensureGlobalIdUniqueness()
expect(subNode.id).not.toBe(rootNode.id)
expect(subgraph._nodes_by_id[subNode.id]).toBe(subNode)
expect(subgraph._nodes_by_id[rootNode.id as number]).toBeUndefined()
})
it('preserves root graph node IDs as canonical', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const originalRootId = rootNode.id
const subNode = new DummyNode()
subNode.id = rootNode.id
subgraph._nodes.push(subNode)
subgraph._nodes_by_id[subNode.id] = subNode
rootGraph.ensureGlobalIdUniqueness()
expect(rootNode.id).toBe(originalRootId)
})
it('updates lastNodeId to reflect reassigned IDs', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNode = new DummyNode()
subNode.id = rootNode.id
subgraph._nodes.push(subNode)
subgraph._nodes_by_id[subNode.id] = subNode
rootGraph.ensureGlobalIdUniqueness()
expect(rootGraph.state.lastNodeId).toBeGreaterThanOrEqual(
subNode.id as number
)
})
it('patches link origin_id and target_id after reassignment', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNodeA = new DummyNode()
subNodeA.id = rootNode.id
subgraph._nodes.push(subNodeA)
subgraph._nodes_by_id[subNodeA.id] = subNodeA
const subNodeB = new DummyNode()
subNodeB.id = 999
subgraph._nodes.push(subNodeB)
subgraph._nodes_by_id[subNodeB.id] = subNodeB
const link = new LLink(1, 'number', subNodeA.id, 0, subNodeB.id, 0)
subgraph._links.set(link.id, link)
rootGraph.ensureGlobalIdUniqueness()
expect(link.origin_id).toBe(subNodeA.id)
expect(link.target_id).toBe(subNodeB.id)
expect(link.origin_id).not.toBe(rootNode.id)
})
it('detects collisions with reserved (not-yet-created) node IDs', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const subNode = new DummyNode()
subNode.id = 42
subgraph._nodes.push(subNode)
subgraph._nodes_by_id[subNode.id] = subNode
rootGraph.ensureGlobalIdUniqueness([42])
expect(subNode.id).not.toBe(42)
expect(subgraph._nodes_by_id[subNode.id]).toBe(subNode)
})
it('is a no-op when there are no collisions', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNode = new DummyNode()
subgraph.add(subNode)
const rootId = rootNode.id
const subId = subNode.id
rootGraph.ensureGlobalIdUniqueness()
expect(rootNode.id).toBe(rootId)
expect(subNode.id).toBe(subId)
})
})

View File

@@ -158,6 +158,7 @@ export class LGraph
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
static deduplicateSubgraphIds = false
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
@@ -199,13 +200,21 @@ export class LGraph
list_of_graphcanvas: LGraphCanvas[] | null
status: number = LGraph.STATUS_STOPPED
state: LGraphState = {
private _state: LGraphState = {
lastGroupId: 0,
lastNodeId: 0,
lastLinkId: 0,
lastRerouteId: 0
}
get state(): LGraphState {
return this._state
}
set state(value: LGraphState) {
this._state = value
}
readonly events = new CustomEventTarget<LGraphEventMap>()
readonly _subgraphs: Map<UUID, Subgraph> = new Map()
@@ -2377,15 +2386,19 @@ export class LGraph
} else {
// New schema - one version so far, no check required.
// State
// State - use max to prevent ID collisions across root and subgraphs
if (data.state) {
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } =
data.state
const { state } = this
if (lastGroupId != null) state.lastGroupId = lastGroupId
if (lastLinkId != null) state.lastLinkId = lastLinkId
if (lastNodeId != null) state.lastNodeId = lastNodeId
if (lastRerouteId != null) state.lastRerouteId = lastRerouteId
if (lastGroupId != null)
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
if (lastLinkId != null)
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
if (lastNodeId != null)
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
if (lastRerouteId != null)
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
}
// Links
@@ -2424,6 +2437,13 @@ export class LGraph
this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
if (this.isRootGraph && LGraph.deduplicateSubgraphIds) {
const reservedNodeIds = nodesData
?.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
this.ensureGlobalIdUniqueness(reservedNodeIds)
}
let error = false
const nodeDataMap = new Map<NodeId, ISerialisedNode>()
@@ -2516,6 +2536,50 @@ export class LGraph
}
}
/**
* Ensures all node IDs are globally unique across the root graph and all
* subgraphs. Reassigns any colliding IDs found in subgraphs, preserving
* root graph IDs as canonical. Updates link references (`origin_id`,
* `target_id`) within the affected graph to match the new node IDs.
*/
ensureGlobalIdUniqueness(reservedNodeIds?: Iterable<number>): void {
const { state } = this
const allGraphs: LGraph[] = [this, ...this._subgraphs.values()]
const usedNodeIds = new Set<number>(reservedNodeIds)
for (const graph of allGraphs) {
const remappedIds = new Map<NodeId, NodeId>()
for (const node of graph._nodes) {
if (typeof node.id !== 'number') continue
if (usedNodeIds.has(node.id)) {
const oldId = node.id
while (usedNodeIds.has(++state.lastNodeId));
const newId = state.lastNodeId
delete graph._nodes_by_id[oldId]
node.id = newId
graph._nodes_by_id[newId] = node
usedNodeIds.add(newId)
remappedIds.set(oldId, newId)
console.warn(
`LiteGraph: duplicate node ID ${oldId} reassigned to ${newId} in graph ${graph.id}`
)
} else {
usedNodeIds.add(node.id as number)
if ((node.id as number) > state.lastNodeId)
state.lastNodeId = node.id as number
}
}
if (remappedIds.size > 0) {
patchLinkNodeIds(graph._links, remappedIds)
patchLinkNodeIds(graph.floatingLinksInternal, remappedIds)
}
}
}
private _canvas?: LGraphCanvas
get primaryCanvas(): LGraphCanvas | undefined {
return this.rootGraph._canvas
@@ -2596,6 +2660,14 @@ export class Subgraph
return this._rootGraph
}
override get state(): LGraphState {
return this._rootGraph.state
}
override set state(_value: LGraphState) {
// No-op: subgraphs share the root graph's state.
}
constructor(rootGraph: LGraph, data: ExportedSubgraph) {
if (!rootGraph) throw new Error('Root graph is required')
@@ -2850,3 +2922,16 @@ export class Subgraph
}
}
}
function patchLinkNodeIds(
links: Map<LinkId, LLink>,
remappedIds: Map<NodeId, NodeId>
): void {
for (const link of links.values()) {
const newOrigin = remappedIds.get(link.origin_id)
if (newOrigin !== undefined) link.origin_id = newOrigin
const newTarget = remappedIds.get(link.target_id)
if (newTarget !== undefined) link.target_id = newTarget
}
}

View File

@@ -2,6 +2,7 @@ import { watchEffect } from 'vue'
import {
CanvasPointer,
LGraph,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
@@ -162,4 +163,10 @@ export const useLitegraphSettings = () => {
'Comfy.EnableWorkflowViewRestore'
)
})
watchEffect(() => {
LGraph.deduplicateSubgraphIds = settingStore.get(
'Comfy.Graph.DeduplicateSubgraphNodeIds'
)
})
}

View File

@@ -1201,5 +1201,16 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
},
{
id: 'Comfy.Graph.DeduplicateSubgraphNodeIds',
category: ['Comfy', 'Graph', 'Subgraph'],
name: 'Deduplicate subgraph node IDs',
tooltip:
'Automatically reassign duplicate node IDs in subgraphs when loading a workflow.',
type: 'boolean',
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
}
]

View File

@@ -288,6 +288,7 @@ const zSettings = z.object({
'Comfy.Graph.CanvasInfo': z.boolean(),
'Comfy.Graph.CanvasMenu': z.boolean(),
'Comfy.Graph.CtrlShiftZoom': z.boolean(),
'Comfy.Graph.DeduplicateSubgraphNodeIds': z.boolean(),
'Comfy.Graph.LiveSelection': z.boolean(),
'Comfy.Graph.LinkMarkers': z.nativeEnum(LinkMarkerShape),
'Comfy.Graph.ZoomSpeed': z.number(),