From 0b73285ca11d8014b6e753f4007042e53c28119f Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Fri, 6 Mar 2026 13:56:56 -0800 Subject: [PATCH] fix: extract and harden subgraph node ID deduplication (#9510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extract and harden subgraph node ID deduplication to prevent widget store key collisions when multiple subgraph copies share identical node IDs. ## Changes - **What**: Extract `deduplicateSubgraphNodeIds` from `LGraph.ts` into `utils/subgraphDeduplication.ts`, decomposed into focused helpers (`remapNodeIds`, `findNextAvailableId`, `patchSerialisedLinks`, `patchPromotedWidgets`, `patchProxyWidgets`). Clone inputs internally so caller data is never mutated. Add safety limit on ID search to prevent unbounded loops. Add `console.warn` on remapped IDs matching existing `ensureGlobalIdUniqueness` behavior. Add test fixture and 5 behavioral tests covering ID remapping, link patching, promoted widget patching, proxyWidget patching, and no-op when IDs are unique. ## Review Focus - The cloning strategy in `deduplicateSubgraphNodeIds` — it `structuredClone`s subgraphs and rootNodes, returning the clones. The caller uses `effectiveNodesData` to thread the patched root nodes through to node creation. - The `MAX_NODE_ID` safety limit (100M) — is this a reasonable ceiling? ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9510-fix-extract-and-harden-subgraph-node-ID-deduplication-31b6d73d365081f48c7de75e2bfc48b3) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- src/lib/litegraph/src/LGraph.test.ts | 123 ++++++++++++ src/lib/litegraph/src/LGraph.ts | 46 +++-- .../__fixtures__/duplicateSubgraphNodeIds.ts | 163 ++++++++++++++++ .../nestedSubgraphProxyWidgets.ts | 177 ++++++++++++++++++ .../src/__fixtures__/nodeIdSpaceExhausted.ts | 172 +++++++++++++++++ .../src/__fixtures__/uniqueSubgraphNodeIds.ts | 163 ++++++++++++++++ .../src/utils/subgraphDeduplication.ts | 164 ++++++++++++++++ 7 files changed, 996 insertions(+), 12 deletions(-) create mode 100644 src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts create mode 100644 src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts create mode 100644 src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts create mode 100644 src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts create mode 100644 src/lib/litegraph/src/utils/subgraphDeduplication.ts diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 59780fc74a..ee37d72ee8 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -9,6 +9,7 @@ import { LiteGraph, LLink } from '@/lib/litegraph/src/litegraph' +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -17,6 +18,10 @@ import { createTestSubgraphNode } from './subgraph/__fixtures__/subgraphHelpers' +import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds' +import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets' +import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted' +import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds' import { test } from './__fixtures__/testExtensions' function swapNodes(nodes: LGraphNode[]) { @@ -656,3 +661,121 @@ describe('Subgraph Unpacking', () => { expect(definitionIds).toContain(subgraph.id) }) }) + +describe('deduplicateSubgraphNodeIds (via configure)', () => { + const SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' as UUID + const SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' as UUID + const SHARED_NODE_IDS = [3, 8, 37] + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + LiteGraph.registerNodeType('dummy', DummyNode) + }) + + function loadFixture(): SerialisableGraph { + return structuredClone(duplicateSubgraphNodeIds) + } + + function configureFromFixture() { + const graphData = loadFixture() + const graph = new LGraph() + graph.configure(graphData) + return { graph, graphData } + } + + function nodeIdSet(graph: LGraph, subgraphId: UUID) { + return new Set(graph.subgraphs.get(subgraphId)!.nodes.map((n) => n.id)) + } + + it('remaps duplicate node IDs so subgraphs have no overlap', () => { + const { graph } = configureFromFixture() + + const idsA = nodeIdSet(graph, SUBGRAPH_A) + const idsB = nodeIdSet(graph, SUBGRAPH_B) + + for (const id of SHARED_NODE_IDS) { + expect(idsA.has(id as NodeId)).toBe(true) + } + for (const id of idsA) { + expect(idsB.has(id)).toBe(false) + } + }) + + it('patches link references in remapped subgraph', () => { + const { graph } = configureFromFixture() + const idsB = nodeIdSet(graph, SUBGRAPH_B) + + for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) { + expect(idsB.has(link.origin_id)).toBe(true) + expect(idsB.has(link.target_id)).toBe(true) + } + }) + + it('patches promoted widget references in remapped subgraph', () => { + const { graph } = configureFromFixture() + const idsB = nodeIdSet(graph, SUBGRAPH_B) + + for (const widget of graph.subgraphs.get(SUBGRAPH_B)!.widgets) { + expect(idsB.has(widget.id)).toBe(true) + } + }) + + it('patches proxyWidgets in root-level nodes referencing remapped IDs', () => { + const { graph } = configureFromFixture() + + const idsA = new Set( + graph.subgraphs.get(SUBGRAPH_A)!.nodes.map((n) => String(n.id)) + ) + const idsB = new Set( + graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id)) + ) + + const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets + expect(Array.isArray(pw102)).toBe(true) + for (const entry of pw102 as unknown[][]) { + expect(Array.isArray(entry)).toBe(true) + expect(idsA.has(String(entry[0]))).toBe(true) + } + + const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets + expect(Array.isArray(pw103)).toBe(true) + for (const entry of pw103 as unknown[][]) { + expect(Array.isArray(entry)).toBe(true) + expect(idsB.has(String(entry[0]))).toBe(true) + } + }) + + it('patches proxyWidgets inside nested subgraph nodes', () => { + const graph = new LGraph() + graph.configure(structuredClone(nestedSubgraphProxyWidgets)) + + const idsB = new Set( + graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id)) + ) + + const innerNode = graph.subgraphs + .get(SUBGRAPH_A)! + .nodes.find((n) => n.id === (50 as NodeId)) + const pw = innerNode?.properties?.proxyWidgets + expect(Array.isArray(pw)).toBe(true) + for (const entry of pw as unknown[][]) { + expect(Array.isArray(entry)).toBe(true) + expect(idsB.has(String(entry[0]))).toBe(true) + } + }) + + it('throws when node ID space is exhausted', () => { + expect(() => { + const graph = new LGraph() + graph.configure(structuredClone(nodeIdSpaceExhausted)) + }).toThrow('Node ID space exhausted') + }) + + it('is a no-op when subgraph node IDs are already unique', () => { + const graph = new LGraph() + graph.configure(structuredClone(uniqueSubgraphNodeIds)) + + expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12])) + expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22])) + }) +}) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index d0cc890a07..dcf5e0c776 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -77,6 +77,7 @@ import type { SerialisableReroute } from './types/serialisation' import { getAllNestedItems } from './utils/collections' +import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication' export type { LGraphTriggerAction, @@ -2475,19 +2476,40 @@ export class LGraph this[i] = data[i] } - // Subgraph definitions + // Subgraph definitions — deduplicate node IDs before configuring. + // deduplicateSubgraphNodeIds clones internally to avoid mutating + // the caller's data (e.g. reactive Pinia state). const subgraphs = data.definitions?.subgraphs + let effectiveNodesData = nodesData if (subgraphs) { - for (const subgraph of subgraphs) this.createSubgraph(subgraph) - for (const subgraph of subgraphs) - this.subgraphs.get(subgraph.id)?.configure(subgraph) - } + const reservedNodeIds = new Set() + for (const node of this._nodes) { + if (typeof node.id === 'number') reservedNodeIds.add(node.id) + } + for (const sg of this.subgraphs.values()) { + for (const node of sg.nodes) { + if (typeof node.id === 'number') reservedNodeIds.add(node.id) + } + } + for (const n of nodesData ?? []) { + if (typeof n.id === 'number') reservedNodeIds.add(n.id) + } - if (this.isRootGraph) { - const reservedNodeIds = nodesData - ?.map((n) => n.id) - .filter((id): id is number => typeof id === 'number') - this.ensureGlobalIdUniqueness(reservedNodeIds) + const deduplicated = this.isRootGraph + ? deduplicateSubgraphNodeIds( + subgraphs, + reservedNodeIds, + this.state, + nodesData + ) + : undefined + + const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs + effectiveNodesData = deduplicated?.rootNodes ?? nodesData + + for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph) + for (const subgraph of finalSubgraphs) + this.subgraphs.get(subgraph.id)?.configure(subgraph) } let error = false @@ -2495,8 +2517,8 @@ export class LGraph // create nodes this._nodes = [] - if (nodesData) { - for (const n_info of nodesData) { + if (effectiveNodesData) { + for (const n_info of effectiveNodesData) { // stored info let node = LiteGraph.createNode(String(n_info.type), n_info.title) if (!node) { diff --git a/src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts b/src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts new file mode 100644 index 0000000000..3641e952ae --- /dev/null +++ b/src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts @@ -0,0 +1,163 @@ +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' + +/** + * Workflow with two subgraph definitions whose internal nodes share + * identical IDs [3, 8, 37]. Reproduces the widget-state collision bug + * where copied subgraphs overwrote each other's widget store entries. + * + * SubgraphA (node 102): widgets reference node 3, link 3→8 + * SubgraphB (node 103): widgets reference node 8, link 3→37 + */ +export const duplicateSubgraphNodeIds = { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + version: 1, + revision: 0, + state: { + lastNodeId: 100, + lastLinkId: 10, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 102, + type: '11111111-1111-4111-8111-111111111111', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + properties: { proxyWidgets: [['3', 'seed']] } + }, + { + id: 103, + type: '22222222-2222-4222-8222-222222222222', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + properties: { proxyWidgets: [['8', 'prompt']] } + } + ], + definitions: { + subgraphs: [ + { + id: '11111111-1111-4111-8111-111111111111', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphA', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 3, name: 'seed' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 1, + origin_id: 3, + origin_slot: 0, + target_id: 8, + target_slot: 0, + type: 'number' + } + ], + groups: [] + }, + { + id: '22222222-2222-4222-8222-222222222222', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphB', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 8, name: 'prompt' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 2, + origin_id: 3, + origin_slot: 0, + target_id: 37, + target_slot: 0, + type: 'string' + } + ], + groups: [] + } + ] + } +} as const satisfies SerialisableGraph diff --git a/src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts b/src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts new file mode 100644 index 0000000000..b71962880d --- /dev/null +++ b/src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts @@ -0,0 +1,177 @@ +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' + +/** + * Workflow where SubgraphA contains a nested SubgraphNode referencing + * SubgraphB. Both subgraph definitions share internal node IDs [3, 8, 37]. + * + * The nested SubgraphNode (id 50, inside SubgraphA) has proxyWidgets + * pointing at SubgraphB's node 8. After deduplication remaps SubgraphB's + * nodes, the nested proxyWidgets must also be patched. + * + * SubgraphA (node 102): widgets reference node 3, link 3→8, + * contains nested SubgraphNode(50) → SubgraphB with proxyWidget ['8'] + * SubgraphB (node 103): widgets reference node 8, link 3→37 + */ +export const nestedSubgraphProxyWidgets = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + version: 1, + revision: 0, + state: { + lastNodeId: 100, + lastLinkId: 10, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 102, + type: '11111111-1111-4111-8111-111111111111', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + properties: { proxyWidgets: [['3', 'seed']] } + }, + { + id: 103, + type: '22222222-2222-4222-8222-222222222222', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + properties: { proxyWidgets: [['8', 'prompt']] } + } + ], + definitions: { + subgraphs: [ + { + id: '11111111-1111-4111-8111-111111111111', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphA', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 3, name: 'seed' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + }, + { + id: 50, + type: '22222222-2222-4222-8222-222222222222', + pos: [200, 0], + size: [100, 50], + flags: {}, + order: 3, + mode: 0, + properties: { proxyWidgets: [['8', 'prompt']] } + } + ], + links: [ + { + id: 1, + origin_id: 3, + origin_slot: 0, + target_id: 8, + target_slot: 0, + type: 'number' + } + ], + groups: [] + }, + { + id: '22222222-2222-4222-8222-222222222222', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphB', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 8, name: 'prompt' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 2, + origin_id: 3, + origin_slot: 0, + target_id: 37, + target_slot: 0, + type: 'string' + } + ], + groups: [] + } + ] + } +} as const satisfies SerialisableGraph diff --git a/src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts b/src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts new file mode 100644 index 0000000000..ba0008e887 --- /dev/null +++ b/src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts @@ -0,0 +1,172 @@ +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' + +/** + * Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000) + * and root node 100_000_000 reserves the only remaining candidate ID. + * + * Both subgraph definitions share node IDs [3, 8, 37]. When SubgraphB's + * duplicates need remapping, candidate 100_000_000 is already reserved, + * so the next candidate (100_000_001) exceeds MAX_NODE_ID and must throw. + */ +export const nodeIdSpaceExhausted = { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + version: 1, + revision: 0, + state: { + lastNodeId: 99_999_999, + lastLinkId: 10, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 102, + type: '11111111-1111-4111-8111-111111111111', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + properties: { proxyWidgets: [['3', 'seed']] } + }, + { + id: 103, + type: '22222222-2222-4222-8222-222222222222', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + properties: { proxyWidgets: [['8', 'prompt']] } + }, + { + id: 100_000_000, + type: 'dummy', + pos: [600, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + definitions: { + subgraphs: [ + { + id: '11111111-1111-4111-8111-111111111111', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphA', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 3, name: 'seed' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 1, + origin_id: 3, + origin_slot: 0, + target_id: 8, + target_slot: 0, + type: 'number' + } + ], + groups: [] + }, + { + id: '22222222-2222-4222-8222-222222222222', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphB', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 8, name: 'prompt' }], + nodes: [ + { + id: 3, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 8, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 37, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 2, + origin_id: 3, + origin_slot: 0, + target_id: 37, + target_slot: 0, + type: 'string' + } + ], + groups: [] + } + ] + } +} as const satisfies SerialisableGraph diff --git a/src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts b/src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts new file mode 100644 index 0000000000..549f1f7ab4 --- /dev/null +++ b/src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts @@ -0,0 +1,163 @@ +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' + +/** + * Workflow with two subgraph definitions whose internal nodes already + * have unique IDs. Deduplication should be a no-op — all IDs, links, + * widgets, and proxyWidgets pass through unchanged. + * + * SubgraphA (node 102): nodes [10, 11, 12], link 10→11, widget ref 10 + * SubgraphB (node 103): nodes [20, 21, 22], link 20→22, widget ref 21 + */ +export const uniqueSubgraphNodeIds = { + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + version: 1, + revision: 0, + state: { + lastNodeId: 100, + lastLinkId: 10, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 102, + type: '11111111-1111-4111-8111-111111111111', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + properties: { proxyWidgets: [['10', 'seed']] } + }, + { + id: 103, + type: '22222222-2222-4222-8222-222222222222', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + properties: { proxyWidgets: [['21', 'prompt']] } + } + ], + definitions: { + subgraphs: [ + { + id: '11111111-1111-4111-8111-111111111111', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphA', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 10, name: 'seed' }], + nodes: [ + { + id: 10, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 11, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 12, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 1, + origin_id: 10, + origin_slot: 0, + target_id: 11, + target_slot: 0, + type: 'number' + } + ], + groups: [] + }, + { + id: '22222222-2222-4222-8222-222222222222', + version: 1, + revision: 0, + state: { + lastNodeId: 0, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'SubgraphB', + config: {}, + inputNode: { id: -10, bounding: [10, 100, 150, 126] }, + outputNode: { id: -20, bounding: [400, 100, 140, 126] }, + inputs: [], + outputs: [], + widgets: [{ id: 21, name: 'prompt' }], + nodes: [ + { + id: 20, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 0, + mode: 0 + }, + { + id: 21, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 1, + mode: 0 + }, + { + id: 22, + type: 'dummy', + pos: [0, 0], + size: [100, 50], + flags: {}, + order: 2, + mode: 0 + } + ], + links: [ + { + id: 2, + origin_id: 20, + origin_slot: 0, + target_id: 22, + target_slot: 0, + type: 'string' + } + ], + groups: [] + } + ] + } +} as const satisfies SerialisableGraph diff --git a/src/lib/litegraph/src/utils/subgraphDeduplication.ts b/src/lib/litegraph/src/utils/subgraphDeduplication.ts new file mode 100644 index 0000000000..fef48112f8 --- /dev/null +++ b/src/lib/litegraph/src/utils/subgraphDeduplication.ts @@ -0,0 +1,164 @@ +import type { LGraphState } from '../LGraph' +import type { NodeId } from '../LGraphNode' +import type { + ExportedSubgraph, + ExposedWidget, + ISerialisedNode, + SerialisableLLink +} from '../types/serialisation' + +const MAX_NODE_ID = 100_000_000 + +interface DeduplicationResult { + subgraphs: ExportedSubgraph[] + rootNodes: ISerialisedNode[] | undefined +} + +/** + * Pre-deduplicates node IDs across serialized subgraph definitions before + * they are configured. This prevents widget store key collisions when + * multiple subgraph copies contain nodes with the same IDs. + * + * Also patches proxyWidgets in root-level nodes that reference the + * remapped inner node IDs. + * + * Returns deep clones of the inputs — the originals are never mutated. + * + * @param subgraphs - Serialized subgraph definitions to deduplicate + * @param reservedNodeIds - Node IDs already in use by root-level nodes + * @param state - Graph state containing the `lastNodeId` counter (mutated) + * @param rootNodes - Optional root-level nodes with proxyWidgets to patch + */ +export function deduplicateSubgraphNodeIds( + subgraphs: ExportedSubgraph[], + reservedNodeIds: Set, + state: LGraphState, + rootNodes?: ISerialisedNode[] +): DeduplicationResult { + const clonedSubgraphs = structuredClone(subgraphs) + const clonedRootNodes = rootNodes ? structuredClone(rootNodes) : undefined + + const usedNodeIds = new Set(reservedNodeIds) + const subgraphIdSet = new Set(clonedSubgraphs.map((sg) => sg.id)) + const remapBySubgraph = new Map>() + + for (const subgraph of clonedSubgraphs) { + const remappedIds = remapNodeIds(subgraph.nodes ?? [], usedNodeIds, state) + + if (remappedIds.size === 0) continue + remapBySubgraph.set(subgraph.id, remappedIds) + + patchSerialisedLinks(subgraph.links ?? [], remappedIds) + patchPromotedWidgets(subgraph.widgets ?? [], remappedIds) + } + + for (const subgraph of clonedSubgraphs) { + patchProxyWidgets(subgraph.nodes ?? [], subgraphIdSet, remapBySubgraph) + } + + if (clonedRootNodes) { + patchProxyWidgets(clonedRootNodes, subgraphIdSet, remapBySubgraph) + } + + return { subgraphs: clonedSubgraphs, rootNodes: clonedRootNodes } +} + +/** + * Remaps duplicate node IDs to unique values, updating `usedNodeIds` + * and `state.lastNodeId` as new IDs are allocated. + * + * @returns A map of old ID → new ID for nodes that were remapped. + */ +function remapNodeIds( + nodes: ISerialisedNode[], + usedNodeIds: Set, + state: LGraphState +): Map { + const remappedIds = new Map() + + for (const node of nodes) { + const id = node.id + if (typeof id !== 'number') continue + + if (usedNodeIds.has(id)) { + const newId = findNextAvailableId(usedNodeIds, state) + remappedIds.set(id, newId) + node.id = newId + usedNodeIds.add(newId as number) + console.warn( + `LiteGraph: duplicate subgraph node ID ${id} remapped to ${newId}` + ) + } else { + usedNodeIds.add(id) + if (id > state.lastNodeId) state.lastNodeId = id + } + } + + return remappedIds +} + +/** + * Finds the next unused node ID by incrementing `state.lastNodeId`. + * Throws if the ID space is exhausted. + */ +function findNextAvailableId( + usedNodeIds: Set, + state: LGraphState +): NodeId { + while (true) { + const nextId = state.lastNodeId + 1 + if (nextId > MAX_NODE_ID) { + throw new Error('Node ID space exhausted') + } + state.lastNodeId = nextId + if (!usedNodeIds.has(nextId)) return nextId as NodeId + } +} + +/** Patches origin_id / target_id in serialized links. */ +function patchSerialisedLinks( + links: SerialisableLLink[], + remappedIds: Map +): void { + for (const link of links) { + 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 + } +} + +/** Patches promoted widget node references. */ +function patchPromotedWidgets( + widgets: ExposedWidget[], + remappedIds: Map +): void { + for (const widget of widgets) { + const newId = remappedIds.get(widget.id) + if (newId !== undefined) widget.id = newId + } +} + +/** Patches proxyWidgets in root-level SubgraphNode instances. */ +function patchProxyWidgets( + rootNodes: ISerialisedNode[], + subgraphIdSet: Set, + remapBySubgraph: Map> +): void { + for (const node of rootNodes) { + if (!subgraphIdSet.has(node.type)) continue + const remappedIds = remapBySubgraph.get(node.type) + if (!remappedIds) continue + + const proxyWidgets = node.properties?.proxyWidgets + if (!Array.isArray(proxyWidgets)) continue + + for (const entry of proxyWidgets) { + if (!Array.isArray(entry)) continue + const oldId = Number(entry[0]) as NodeId + const newId = remappedIds.get(oldId) + if (newId !== undefined) entry[0] = String(newId) + } + } +}