diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 39a25b23520..515f56bc080 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -279,6 +279,9 @@ describe('Nested promoted widget mapping', () => { }) it('maps store identity to deepest concrete widget for two-layer promotions', () => { + const store = usePromotionStore() + + // Inner layer: subgraphA contains innerNode with a combo widget const subgraphA = createTestSubgraph({ inputs: [{ name: 'a_input', type: '*' }] }) @@ -287,23 +290,48 @@ describe('Nested promoted widget mapping', () => { innerNode.addWidget('combo', 'picker', 'a', () => undefined, { values: ['a', 'b'] }) - innerInput.widget = { name: 'picker' } subgraphA.add(innerNode) + // Connect without .widget so SubgraphInput creates a real link subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + innerInput.widget = { name: 'picker' } - const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 }) - + // Outer layer: subgraphB contains subgraphNodeA const subgraphB = createTestSubgraph({ inputs: [{ name: 'b_input', type: '*' }] }) + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 }) subgraphB.add(subgraphNodeA) subgraphNodeA._internalConfigureAfterSlots() + // Connect without .widget so SubgraphInput creates a real link + const savedWidget = subgraphNodeA.inputs[0].widget + delete (subgraphNodeA.inputs[0] as unknown as Record) + .widget subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + subgraphNodeA.inputs[0].widget = savedWidget + // Root: graph contains subgraphNodeB const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 }) const graph = subgraphNodeB.graph as LGraph graph.add(subgraphNodeB) + // Register promotions using rootGraph IDs as seen at lookup time: + // - subgraphNodeA.rootGraph resolves through subgraphB to its rootGraph + // - subgraphNodeB.rootGraph is the top-level graph + store.promote( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + String(innerNode.id), + 'picker' + ) + // Outer promotion references the inner promoted view's widget name ('picker'), + // not the subgraph input name ('a_input') + store.promote( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id, + String(subgraphNodeA.id), + 'picker' + ) + const { vueNodeData } = useGraphNodeManager(graph) const nodeData = vueNodeData.get(String(subgraphNodeB.id)) const mappedWidget = nodeData?.widgets?.[0] diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 7a7a5900f7a..a86b707225b 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -236,7 +236,6 @@ function safeWidgetMapper( const promotedInputName = node.inputs?.find((input) => { if (input.name === widget.name) return true - if (input._widget === widget) return true return false })?.name const displayName = promotedInputName ?? widget.name diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 463c7747f3c..39834a5f18c 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -405,10 +405,12 @@ describe('SubgraphNode.widgets getter', () => { innerInput.widget = { name: 'picker' } subgraph.add(innerNode) - subgraph.inputNode.slots[0].connect(innerInput, innerNode) + // Wire up listeners before connecting so the event is handled subgraphNode._internalConfigureAfterSlots() + subgraph.inputNode.slots[0].connect(innerInput, innerNode) const store = usePromotionStore() + // While id is -1, promotions are buffered — not in the store expect(store.getPromotions(subgraphNode.rootGraph.id, -1)).toStrictEqual([]) subgraphNode.graph?.add(subgraphNode) @@ -431,29 +433,40 @@ describe('SubgraphNode.widgets getter', () => { subgraphNode.graph?.add(subgraphNode) const firstNode = new LGraphNode('FirstNode') - const firstInput = firstNode.addInput('picker_input', '*') firstNode.addWidget('combo', 'picker', 'a', () => {}, { values: ['a', 'b'] }) - firstInput.widget = { name: 'picker' } subgraph.add(firstNode) - const subgraphInputSlot = subgraph.inputNode.slots[0] - subgraphInputSlot.connect(firstInput, firstNode) - - // Mirror user-driven rebind behavior: move the slot connection from first - // source to second source, rather than keeping both links connected. - subgraphInputSlot.disconnect() const secondNode = new LGraphNode('SecondNode') - const secondInput = secondNode.addInput('picker_input', '*') secondNode.addWidget('combo', 'picker', 'b', () => {}, { values: ['a', 'b'] }) - secondInput.widget = { name: 'picker' } subgraph.add(secondNode) - subgraphInputSlot.connect(secondInput, secondNode) - const promotions = usePromotionStore().getPromotions( + const store = usePromotionStore() + + // Promote first source, then demote and promote second source + store.promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(firstNode.id), + 'picker' + ) + store.demote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(firstNode.id), + 'picker' + ) + store.promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(secondNode.id), + 'picker' + ) + + const promotions = store.getPromotions( subgraphNode.rootGraph.id, subgraphNode.id ) @@ -467,31 +480,21 @@ describe('SubgraphNode.widgets getter', () => { expect(subgraphNode.widgets[0].value).toBe('b') }) - test('preserves distinct promoted display names when two inputs share one concrete widget name', () => { - const subgraph = createTestSubgraph({ - inputs: [ - { name: 'strength_model', type: '*' }, - { name: 'strength_model_1', type: '*' } - ] - }) - const subgraphNode = createTestSubgraphNode(subgraph, { id: 90 }) - subgraphNode.graph?.add(subgraphNode) - - const innerNode = new LGraphNode('InnerNumberNode') - const firstInput = innerNode.addInput('strength_model', '*') - const secondInput = innerNode.addInput('strength_model_1', '*') + test('two store entries for different widgets on the same node produce two views', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + const innerNode = firstInnerNode(innerNodes) innerNode.addWidget('number', 'strength_model', 1, () => {}) - firstInput.widget = { name: 'strength_model' } - secondInput.widget = { name: 'strength_model' } - subgraph.add(innerNode) + innerNode.addWidget('number', 'strength_clip', 0.5, () => {}) - subgraph.inputNode.slots[0].connect(firstInput, innerNode) - subgraph.inputNode.slots[1].connect(secondInput, innerNode) + setPromotions(subgraphNode, [ + [String(innerNode.id), 'strength_model'], + [String(innerNode.id), 'strength_clip'] + ]) expect(subgraphNode.widgets).toHaveLength(2) - expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([ + expect(subgraphNode.widgets.map((w) => w.name)).toStrictEqual([ 'strength_model', - 'strength_model_1' + 'strength_clip' ]) }) @@ -500,52 +503,32 @@ describe('SubgraphNode.widgets getter', () => { expect(subgraphNode.widgets).toEqual([]) }) - test('widgets getter prefers live linked entries over stale store entries', () => { - const subgraph = createTestSubgraph({ - inputs: [{ name: 'widgetA', type: '*' }] - }) - const subgraphNode = createTestSubgraphNode(subgraph, { id: 91 }) - subgraphNode.graph?.add(subgraphNode) - - const liveNode = new LGraphNode('LiveNode') - const liveInput = liveNode.addInput('widgetA', '*') - liveNode.addWidget('text', 'widgetA', 'a', () => {}) - liveInput.widget = { name: 'widgetA' } - subgraph.add(liveNode) - subgraph.inputNode.slots[0].connect(liveInput, liveNode) + test('store entries for missing interior nodes produce disconnected fallback views', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) setPromotions(subgraphNode, [ - [String(liveNode.id), 'widgetA'], + [String(innerNodes[0].id), 'widgetA'], ['9999', 'missingWidget'] ]) - expect(subgraphNode.widgets).toHaveLength(1) + expect(subgraphNode.widgets).toHaveLength(2) expect(subgraphNode.widgets[0].name).toBe('widgetA') + expect(subgraphNode.widgets[0].type).toBe('text') + expect(subgraphNode.widgets[1].name).toBe('missingWidget') + expect(subgraphNode.widgets[1].type).toBe('button') }) - test('partial linked coverage does not destructively prune unresolved store promotions', () => { - const subgraph = createTestSubgraph({ - inputs: [ - { name: 'widgetA', type: '*' }, - { name: 'widgetB', type: '*' } - ] - }) - const subgraphNode = createTestSubgraphNode(subgraph, { id: 92 }) - subgraphNode.graph?.add(subgraphNode) - - const liveNode = new LGraphNode('LiveNode') - const liveInput = liveNode.addInput('widgetA', '*') - liveNode.addWidget('text', 'widgetA', 'a', () => {}) - liveInput.widget = { name: 'widgetA' } - subgraph.add(liveNode) - subgraph.inputNode.slots[0].connect(liveInput, liveNode) + test('widgets getter does not prune unresolved store promotions', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) setPromotions(subgraphNode, [ - [String(liveNode.id), 'widgetA'], + [String(innerNodes[0].id), 'widgetA'], ['9999', 'widgetB'] ]) - // Trigger widgets getter reconciliation in partial-linked state. + // Trigger widgets getter — should not prune missing entries void subgraphNode.widgets const promotions = usePromotionStore().getPromotions( @@ -553,7 +536,7 @@ describe('SubgraphNode.widgets getter', () => { subgraphNode.id ) expect(promotions).toStrictEqual([ - { interiorNodeId: String(liveNode.id), widgetName: 'widgetA' }, + { interiorNodeId: String(innerNodes[0].id), widgetName: 'widgetA' }, { interiorNodeId: '9999', widgetName: 'widgetB' } ]) }) @@ -634,39 +617,52 @@ describe('SubgraphNode.widgets getter', () => { expect(subgraphNode.widgets).toHaveLength(1) }) - test('migrates legacy -1 entries via _resolveLegacyEntry', () => { - const [subgraphNode, innerNodes] = setupSubgraph(1) - innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) + test('gracefully drops unresolvable legacy -1 entries', () => { + const [subgraphNode] = setupSubgraph() - // Simulate a slot-connected widget so legacy resolution works - const subgraph = subgraphNode.subgraph - subgraph.addInput('stringWidget', '*') + // Legacy -1 format with no link to resolve against + subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']] subgraphNode._internalConfigureAfterSlots() - // The _internalConfigureAfterSlots would have set up the slot-connected - // widget via _setWidget if there's a link. For unit testing legacy - // migration, we need to set up the input._widget manually. - const input = subgraphNode.inputs.find((i) => i.name === 'stringWidget') - if (input) { - input._widget = createPromotedWidgetView( - subgraphNode, - String(innerNodes[0].id), - 'stringWidget' - ) - } + const entries = usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + expect(entries).toStrictEqual([]) + }) + + test('migrates legacy -1 entries via _resolveLegacyEntry when link exists', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'stringWidget', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 }) + subgraphNode.graph?.add(subgraphNode) + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('stringWidget', '*') + innerNode.addWidget('text', 'stringWidget', 'value', () => {}) + innerInput.widget = { name: 'stringWidget' } + subgraph.add(innerNode) + + // Create a non-widget link manually so legacy resolution has something to find + const subgraphInputSlot = subgraph.inputNode.slots[0] + // Temporarily remove .widget so connect creates a link + const savedWidget = innerInput.widget + delete (innerInput as unknown as Record).widget + subgraphInputSlot.connect(innerInput, innerNode) + innerInput.widget = savedWidget // Set legacy -1 format via properties and re-run hydration subgraphNode.properties.proxyWidgets = [['-1', 'stringWidget']] subgraphNode._internalConfigureAfterSlots() - // Migration should have rewritten the store with resolved IDs const entries = usePromotionStore().getPromotions( subgraphNode.rootGraph.id, subgraphNode.id ) expect(entries).toStrictEqual([ { - interiorNodeId: String(innerNodes[0].id), + interiorNodeId: String(innerNode.id), widgetName: 'stringWidget' } ]) @@ -984,29 +980,40 @@ function createInspectableCanvasContext(fillText = vi.fn()) { } function createTwoLevelNestedSubgraph() { - const subgraphA = createTestSubgraph({ - inputs: [{ name: 'a_input', type: '*' }] - }) + const subgraphA = createTestSubgraph() const innerNode = new LGraphNode('InnerComboNode') - const innerInput = innerNode.addInput('picker_input', '*') const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, { values: ['a', 'b'] }) - innerInput.widget = { name: 'picker' } subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(innerInput, innerNode) const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 }) + subgraphNodeA._internalConfigureAfterSlots() - const subgraphB = createTestSubgraph({ - inputs: [{ name: 'b_input', type: '*' }] - }) + // Level A: promote innerNode.picker on subgraphNodeA + const store = usePromotionStore() + store.promote( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + String(innerNode.id), + 'picker' + ) + + const subgraphB = createTestSubgraph() subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 }) - return { innerNode, comboWidget, subgraphNodeB } + subgraphNodeB._internalConfigureAfterSlots() + + // Level B: promote subgraphNodeA.picker on subgraphNodeB + store.promote( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id, + String(subgraphNodeA.id), + 'picker' + ) + + return { innerNode, comboWidget, subgraphNodeA, subgraphNodeB } } describe('promoted combo rendering', () => { @@ -1126,67 +1133,10 @@ describe('promoted combo rendering', () => { expect(promotedWidget.value).toBe('b') }) - test('state lookup does not use promotion store fallback when intermediate view is unavailable', () => { - const subgraphA = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - const innerNode = new LGraphNode('InnerNumberNode') - const innerInput = innerNode.addInput('strength_model', '*') - innerNode.addWidget('number', 'strength_model', 1, () => {}) - innerInput.widget = { name: 'strength_model' } - subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(innerInput, innerNode) - - const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) - - const subgraphB = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) - - const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) - - // Simulate transient stale intermediate view state by forcing host 47 - // to report no promoted widgets while promotionStore still has entries. - Object.defineProperty(subgraphNodeA, 'widgets', { - get: () => [], - configurable: true - }) - - expect(subgraphNodeB.widgets[0].type).toBe('button') - }) - - test('state lookup does not use input-widget fallback when intermediate promotions are absent', () => { - const subgraphA = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - const innerNode = new LGraphNode('InnerNumberNode') - const innerInput = innerNode.addInput('strength_model', '*') - innerNode.addWidget('number', 'strength_model', 1, () => {}) - innerInput.widget = { name: 'strength_model' } - subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(innerInput, innerNode) - - const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) - - const subgraphB = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + test('nested promotion shows button fallback when intermediate view is unavailable', () => { + const { subgraphNodeA, subgraphNodeB } = createTwoLevelNestedSubgraph() - const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) - - // Simulate a transient where intermediate promotions are unavailable but - // input _widget binding is already updated. - usePromotionStore().setPromotions( - subgraphNodeA.rootGraph.id, - subgraphNodeA.id, - [] - ) + // Force subgraphNodeA to report no promoted widgets Object.defineProperty(subgraphNodeA, 'widgets', { get: () => [], configurable: true @@ -1195,28 +1145,10 @@ describe('promoted combo rendering', () => { expect(subgraphNodeB.widgets[0].type).toBe('button') }) - test('state lookup does not use subgraph-link fallback when intermediate bindings are unavailable', () => { - const subgraphA = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - const innerNode = new LGraphNode('InnerNumberNode') - const innerInput = innerNode.addInput('strength_model', '*') - innerNode.addWidget('number', 'strength_model', 1, () => {}) - innerInput.widget = { name: 'strength_model' } - subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(innerInput, innerNode) - - const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) - - const subgraphB = createTestSubgraph({ - inputs: [{ name: 'strength_model', type: '*' }] - }) - subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) - - const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) + test('nested promotion shows button fallback when intermediate promotions are cleared', () => { + const { subgraphNodeA, subgraphNodeB } = createTwoLevelNestedSubgraph() + // Clear intermediate promotions usePromotionStore().setPromotions( subgraphNodeA.rootGraph.id, subgraphNodeA.id, @@ -1226,74 +1158,92 @@ describe('promoted combo rendering', () => { get: () => [], configurable: true }) - subgraphNodeA.inputs[0]._widget = undefined expect(subgraphNodeB.widgets[0].type).toBe('button') }) test('nested promotion keeps concrete widget types at top level', () => { - const subgraphA = createTestSubgraph({ - inputs: [ - { name: 'lora_name', type: '*' }, - { name: 'strength_model', type: '*' } - ] - }) + const subgraphA = createTestSubgraph() const innerNode = new LGraphNode('InnerLoraNode') - const comboInput = innerNode.addInput('lora_name', '*') - const numberInput = innerNode.addInput('strength_model', '*') innerNode.addWidget('combo', 'lora_name', 'a', () => {}, { values: ['a', 'b'] }) innerNode.addWidget('number', 'strength_model', 1, () => {}) - comboInput.widget = { name: 'lora_name' } - numberInput.widget = { name: 'strength_model' } subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(comboInput, innerNode) - subgraphA.inputNode.slots[1].connect(numberInput, innerNode) const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 60 }) + subgraphNodeA._internalConfigureAfterSlots() - const subgraphB = createTestSubgraph({ - inputs: [ - { name: 'lora_name', type: '*' }, - { name: 'strength_model', type: '*' } - ] - }) + const store = usePromotionStore() + store.promote( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + String(innerNode.id), + 'lora_name' + ) + store.promote( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + String(innerNode.id), + 'strength_model' + ) + + const subgraphB = createTestSubgraph() subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) - subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA) const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 61 }) + subgraphNodeB._internalConfigureAfterSlots() + + store.promote( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id, + String(subgraphNodeA.id), + 'lora_name' + ) + store.promote( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id, + String(subgraphNodeA.id), + 'strength_model' + ) expect(subgraphNodeB.widgets[0].type).toBe('combo') expect(subgraphNodeB.widgets[1].type).toBe('number') }) - test('input promotion from promoted view stores immediate source node id', () => { - const subgraphA = createTestSubgraph({ - inputs: [{ name: 'lora_name', type: '*' }] - }) + test('store-based promotion stores immediate source node id, not deep inner node', () => { + const subgraphA = createTestSubgraph() const innerNode = new LGraphNode('InnerNode') - const innerInput = innerNode.addInput('lora_name', '*') innerNode.addWidget('combo', 'lora_name', 'a', () => {}, { values: ['a', 'b'] }) - innerInput.widget = { name: 'lora_name' } subgraphA.add(innerNode) - subgraphA.inputNode.slots[0].connect(innerInput, innerNode) const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 70 }) + subgraphNodeA._internalConfigureAfterSlots() - const subgraphB = createTestSubgraph({ - inputs: [{ name: 'lora_name', type: '*' }] - }) + const store = usePromotionStore() + store.promote( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + String(innerNode.id), + 'lora_name' + ) + + const subgraphB = createTestSubgraph() subgraphB.add(subgraphNodeA) - subgraphNodeA._internalConfigureAfterSlots() - subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 71 }) - const promotions = usePromotionStore().getPromotions( + subgraphNodeB._internalConfigureAfterSlots() + + store.promote( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id, + String(subgraphNodeA.id), + 'lora_name' + ) + + const promotions = store.getPromotions( subgraphNodeB.rootGraph.id, subgraphNodeB.id ) diff --git a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts index 450af13ac1b..5e3660ecdf0 100644 --- a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts +++ b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts @@ -49,9 +49,12 @@ function addLinkedInteriorInput( const node = new LGraphNode(`Interior-${linkedInputName}`) const input = node.addInput(linkedInputName, '*') node.addWidget('text', widgetName, '', () => undefined) - input.widget = { name: widgetName } subgraph.add(node) + + // Connect without .widget set so SubgraphInput creates a real link + // (widget-backed inputs now dispatch an event instead of creating links) inputSlot.connect(input, node) + input.widget = { name: widgetName } if (input.link == null) throw new Error(`Expected link to be created for input ${linkedInputName}`) diff --git a/src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts b/src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts index 77f8542ff50..1cebb0c5937 100644 --- a/src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts +++ b/src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts @@ -52,9 +52,14 @@ function addLinkedNestedSubgraphNode( const input = innerSubgraphNode.addInput(linkedInputName, '*') if (options.widget) { innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined) - input.widget = { name: options.widget } } + + // Connect without .widget set so SubgraphInput creates a real link + // (widget-backed inputs now dispatch an event instead of creating links) inputSlot.connect(input, innerSubgraphNode) + if (options.widget) { + input.widget = { name: options.widget } + } if (input.link == null) { throw new Error(`Expected link to be created for input ${linkedInputName}`) @@ -129,9 +134,10 @@ describe('resolveSubgraphInputTarget', () => { node.id = 42 const input = node.addInput('seed_input', '*') node.addWidget('number', 'seed', 0, () => undefined) - input.widget = { name: 'seed' } outerSubgraph.add(node) + // Connect without .widget set so SubgraphInput creates a real link inputSlot.connect(input, node) + input.widget = { name: 'seed' } const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed') diff --git a/src/core/graph/subgraph/subgraphNodePromotion.test.ts b/src/core/graph/subgraph/subgraphNodePromotion.test.ts index f0826b9eb23..a9a11645245 100644 --- a/src/core/graph/subgraph/subgraphNodePromotion.test.ts +++ b/src/core/graph/subgraph/subgraphNodePromotion.test.ts @@ -226,7 +226,7 @@ describe('Subgraph proxyWidgets', () => { expect(subgraphNode.widgets[0].name).toBe('widgetB') }) - test('removeWidget cleans up input references', () => { + test('removeWidget removes from store and view cache', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) usePromotionStore().setPromotions( @@ -236,15 +236,15 @@ describe('Subgraph proxyWidgets', () => { ) const view = subgraphNode.widgets[0] - // Simulate an input referencing the widget - subgraphNode.addInput('stringWidget', '*') - const input = subgraphNode.inputs[subgraphNode.inputs.length - 1] - input._widget = view - subgraphNode.removeWidget(view) - expect(input._widget).toBeUndefined() expect(subgraphNode.widgets).toHaveLength(0) + expect( + usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + ).toHaveLength(0) }) test('serialize does not produce widgets_values for promoted views', () => { @@ -285,3 +285,139 @@ describe('Subgraph proxyWidgets', () => { ]) }) }) + +describe('SubgraphInput widget-slot connection behavior', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + test('connecting a widget slot to SubgraphInput promotes via store, not link', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'widget_input', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph) + subgraphNode._internalConfigureAfterSlots() + subgraphNode.graph!.add(subgraphNode) + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('myWidget', '*') + innerNode.addWidget('text', 'myWidget', 'val', () => {}) + innerInput.widget = { name: 'myWidget' } + subgraph.add(innerNode) + + const subgraphInputSlot = subgraph.inputNode.slots[0] + const link = subgraphInputSlot.connect(innerInput, innerNode) + + // Should NOT create a link for widget-slot connections + expect(link).toBeUndefined() + + // Should promote via the store + const store = usePromotionStore() + expect( + store.isPromoted( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(innerNode.id), + 'myWidget' + ) + ).toBe(true) + }) + + test('connecting an already-promoted widget to SubgraphInput unpromotes it', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'widget_input', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph) + subgraphNode._internalConfigureAfterSlots() + subgraphNode.graph!.add(subgraphNode) + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('myWidget', '*') + innerNode.addWidget('text', 'myWidget', 'val', () => {}) + innerInput.widget = { name: 'myWidget' } + subgraph.add(innerNode) + + const store = usePromotionStore() + // Pre-promote the widget + store.promote( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(innerNode.id), + 'myWidget' + ) + expect( + store.isPromoted( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(innerNode.id), + 'myWidget' + ) + ).toBe(true) + + // Connect again — should toggle (unpromote) + const subgraphInputSlot = subgraph.inputNode.slots[0] + const link = subgraphInputSlot.connect(innerInput, innerNode) + + expect(link).toBeUndefined() + expect( + store.isPromoted( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(innerNode.id), + 'myWidget' + ) + ).toBe(false) + }) + + test('connecting a non-widget slot to SubgraphInput still creates a link', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'data_input', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph) + subgraphNode._internalConfigureAfterSlots() + subgraphNode.graph!.add(subgraphNode) + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('data', '*') + // No widget on this input + subgraph.add(innerNode) + + const subgraphInputSlot = subgraph.inputNode.slots[0] + const link = subgraphInputSlot.connect(innerInput, innerNode) + + // Non-widget connections should still create a link + expect(link).toBeDefined() + }) + + test('EmptySubgraphInput promotes widget without creating input slot', () => { + const [subgraphNode] = setupSubgraph(0) + const subgraph = subgraphNode.subgraph + const store = usePromotionStore() + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('picker_input', '*') + innerNode.addWidget('combo', 'picker', 'a', () => undefined, { + values: ['a', 'b'] + }) + innerInput.widget = { name: 'picker' } + subgraph.add(innerNode) + + const inputCountBefore = subgraph.inputs.length + const emptySlot = subgraph.inputNode.emptySlot + emptySlot.connect(innerInput, innerNode) + + // No new subgraph input slot should be created + expect(subgraph.inputs).toHaveLength(inputCountBefore) + // No new input slot on the SubgraphNode + expect(subgraphNode.inputs).toHaveLength(inputCountBefore) + // Widget should be promoted in the store + expect( + store.isPromoted( + subgraphNode.rootGraph.id, + subgraphNode.id, + String(innerNode.id), + 'picker' + ) + ).toBe(true) + }) +}) diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 99eb1c14d55..0dd4675a723 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -2021,16 +2021,6 @@ export class LGraphNode const widgetIndex = this.widgets.indexOf(widget) if (widgetIndex === -1) throw new Error('Widget not found on this node') - // Clean up slot references to prevent memory leaks - if (this.inputs) { - for (const input of this.inputs) { - if (input._widget === widget) { - input._widget = undefined - input.widget = undefined - } - } - } - widget.onRemove?.() this.widgets.splice(widgetIndex, 1) } diff --git a/src/lib/litegraph/src/infrastructure/SubgraphInputEventMap.ts b/src/lib/litegraph/src/infrastructure/SubgraphInputEventMap.ts index 97bf6542159..9d412854775 100644 --- a/src/lib/litegraph/src/infrastructure/SubgraphInputEventMap.ts +++ b/src/lib/litegraph/src/infrastructure/SubgraphInputEventMap.ts @@ -15,4 +15,9 @@ export interface SubgraphInputEventMap extends LGraphEventMap { 'input-disconnected': { input: SubgraphInput } + + 'widget-promotion-requested': { + node: LGraphNode + widget: IBaseWidget + } } diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index b0bbc884fc3..8ca45f8c4a8 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -14,7 +14,6 @@ import type { LinkDirection, RenderShape } from './types/globalEnums' -import type { IBaseWidget } from './types/widgets' export type Dictionary = { [key: string]: T } @@ -360,11 +359,6 @@ export interface INodeInputSlot extends INodeSlot { link: LinkId | null widget?: IWidgetLocator alwaysVisible?: boolean - - /** - * Internal use only; API is not finalised and may change at any time. - */ - _widget?: IBaseWidget } export interface IWidgetInputSlot extends INodeInputSlot { diff --git a/src/lib/litegraph/src/node/NodeInputSlot.ts b/src/lib/litegraph/src/node/NodeInputSlot.ts index 60aa4ebf42b..1c62e2583a8 100644 --- a/src/lib/litegraph/src/node/NodeInputSlot.ts +++ b/src/lib/litegraph/src/node/NodeInputSlot.ts @@ -13,7 +13,6 @@ import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' export class NodeInputSlot extends NodeSlot implements INodeInputSlot { link: LinkId | null @@ -23,17 +22,6 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot { return !!this.widget } - private _widgetRef: WeakRef | undefined - - /** Internal use only; API is not finalised and may change at any time. */ - get _widget(): IBaseWidget | undefined { - return this._widgetRef?.deref() - } - - set _widget(widget: IBaseWidget | undefined) { - this._widgetRef = widget ? new WeakRef(widget) : undefined - } - get collapsedPos(): Readonly { return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5] } diff --git a/src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts b/src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts index 50fde9c5214..0ca4947aaa4 100644 --- a/src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/EmptySubgraphInput.ts @@ -31,8 +31,19 @@ export class EmptySubgraphInput extends SubgraphInput { afterRerouteId?: RerouteId ): LLink | undefined { const { subgraph } = this.parent - const existingNames = subgraph.inputs.map((x) => x.name) + // Widget-backed slots trigger store-based promotion without + // creating a subgraph input slot or link. + const inputWidget = node.getWidgetFromSlot(slot) + if (inputWidget) { + this.events.dispatch('widget-promotion-requested', { + node, + widget: inputWidget + }) + return + } + + const existingNames = subgraph.inputs.map((x) => x.name) const name = nextUniqueName(slot.name, existingNames) const input = subgraph.addInput(name, String(slot.type)) return input.connect(slot, node, afterRerouteId) diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index ee3d0f96d13..4627453bc24 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -34,17 +34,6 @@ export class SubgraphInput extends SubgraphSlot { events = new CustomEventTarget() - /** The linked widget that this slot is connected to. */ - private _widgetRef?: WeakRef - - get _widget() { - return this._widgetRef?.deref() - } - - set _widget(widget) { - this._widgetRef = widget ? new WeakRef(widget) : undefined - } - override connect( slot: INodeInputSlot, node: LGraphNode, @@ -82,19 +71,11 @@ export class SubgraphInput extends SubgraphSlot { const inputWidget = node.getWidgetFromSlot(slot) if (inputWidget) { - if (!this.matchesWidget(inputWidget)) { - console.warn('Target input has invalid widget.', slot, node) - return - } - - // Keep the widget reference in sync with the active upstream widget. - // Stale references can appear across nested promotion rebinds. - this._widget = inputWidget - this.events.dispatch('input-connected', { - input: slot, - widget: inputWidget, - node + this.events.dispatch('widget-promotion-requested', { + node, + widget: inputWidget }) + return } const link = new LLink( @@ -183,35 +164,9 @@ export class SubgraphInput extends SubgraphSlot { return widgets } - /** - * Validates that the connection between the new slot and the existing widget is valid. - * Used to prevent connections between widgets that are not of the same type. - * @param otherWidget The widget to compare to. - * @returns `true` if the connection is valid, otherwise `false`. - */ - matchesWidget(otherWidget: IBaseWidget): boolean { - const widget = this._widgetRef?.deref() - if (!widget) return true - - if ( - otherWidget.type !== widget.type || - otherWidget.options.min !== widget.options.min || - otherWidget.options.max !== widget.options.max || - otherWidget.options.step !== widget.options.step || - otherWidget.options.step2 !== widget.options.step2 || - otherWidget.options.precision !== widget.options.precision - ) { - return false - } - - return true - } - override disconnect(): void { super.disconnect() - this._widget = undefined - this.events.dispatch('input-disconnected', { input: this }) } diff --git a/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts b/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts index 92699283e9e..d6a72a7f70b 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphMemory.test.ts @@ -117,11 +117,9 @@ describe.skip('SubgraphNode Memory Management', () => { }) } as Partial as IWidget - input._widget = mockWidget input.widget = { name: 'promoted_widget' } subgraphNode.widgets.push(mockWidget) - expect(input._widget).toBe(mockWidget) expect(input.widget).toBeDefined() expect(subgraphNode.widgets).toContain(mockWidget) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 735a0f27c7e..1fe54445c76 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -6,11 +6,9 @@ import type { DrawTitleBoxOptions } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' import type { ResolvedConnection } from '@/lib/litegraph/src/LLink' import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError' +import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap' import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError' -import type { - ISubgraphInput, - IWidgetLocator -} from '@/lib/litegraph/src/interfaces' +import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { INodeInputSlot, @@ -49,11 +47,6 @@ const workflowSvg = new Image() workflowSvg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E" -type LinkedPromotionEntry = { - inputName: string - interiorNodeId: string - widgetName: string -} // Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing // the SVG's internal stylesheet on every ctx.drawImage() call per frame. const workflowBitmapCache = createBitmapCache(workflowSvg, 32) @@ -86,9 +79,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { new PromotedWidgetViewManager() /** * Promotions buffered before this node is attached to a graph (`id === -1`). - * They are flushed in `_flushPendingPromotions()` from `_setWidget()` and - * `onAdded()`, so construction-time promotions require normal add-to-graph - * lifecycle to persist. + * They are flushed in `_flushPendingPromotions()` from the + * `widget-promotion-requested` handler and `onAdded()`, so construction-time + * promotions require normal add-to-graph lifecycle to persist. */ private _pendingPromotions: Array<{ interiorNodeId: string @@ -100,241 +93,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { // so we use declare + defineProperty instead. declare widgets: IBaseWidget[] - private _resolveLinkedPromotionByInputName( - inputName: string - ): { interiorNodeId: string; widgetName: string } | undefined { - const resolvedTarget = resolveSubgraphInputTarget(this, inputName) - if (!resolvedTarget) return undefined - - return { - interiorNodeId: resolvedTarget.nodeId, - widgetName: resolvedTarget.widgetName - } - } - - private _getLinkedPromotionEntries(): LinkedPromotionEntry[] { - const linkedEntries: LinkedPromotionEntry[] = [] - - // TODO(pr9282): Optimization target. This path runs on widgets getter reads - // and resolves each input link chain eagerly. - for (const input of this.inputs) { - const resolved = this._resolveLinkedPromotionByInputName(input.name) - if (!resolved) continue - - linkedEntries.push({ inputName: input.name, ...resolved }) - } - - const seenEntryKeys = new Set() - const deduplicatedEntries = linkedEntries.filter((entry) => { - const entryKey = this._makePromotionViewKey( - entry.inputName, - entry.interiorNodeId, - entry.widgetName - ) - if (seenEntryKeys.has(entryKey)) return false - - seenEntryKeys.add(entryKey) - return true - }) - - return deduplicatedEntries - } - private _getPromotedViews(): PromotedWidgetView[] { const store = usePromotionStore() const entries = store.getPromotionsRef(this.rootGraph.id, this.id) - const linkedEntries = this._getLinkedPromotionEntries() - const { displayNameByViewKey, reconcileEntries } = - this._buildPromotionReconcileState(entries, linkedEntries) - - return this._promotedViewManager.reconcile(reconcileEntries, (entry) => - createPromotedWidgetView( - this, - entry.interiorNodeId, - entry.widgetName, - entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined - ) - ) - } - - private _syncPromotions(): void { - if (this.id === -1) return - - const store = usePromotionStore() - const entries = store.getPromotionsRef(this.rootGraph.id, this.id) - const linkedEntries = this._getLinkedPromotionEntries() - const { mergedEntries, shouldPersistLinkedOnly } = - this._buildPromotionPersistenceState(entries, linkedEntries) - if (!shouldPersistLinkedOnly) return - - const hasChanged = - mergedEntries.length !== entries.length || - mergedEntries.some( - (entry, index) => - entry.interiorNodeId !== entries[index]?.interiorNodeId || - entry.widgetName !== entries[index]?.widgetName - ) - if (!hasChanged) return - - store.setPromotions(this.rootGraph.id, this.id, mergedEntries) - } - private _buildPromotionReconcileState( - entries: Array<{ interiorNodeId: string; widgetName: string }>, - linkedEntries: LinkedPromotionEntry[] - ): { - displayNameByViewKey: Map - reconcileEntries: Array<{ - interiorNodeId: string - widgetName: string - viewKey?: string - }> - } { - const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries( - entries, - linkedEntries + return this._promotedViewManager.reconcile(entries, (entry) => + createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName) ) - const linkedReconcileEntries = - this._buildLinkedReconcileEntries(linkedEntries) - const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) - - return { - displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries), - reconcileEntries: shouldPersistLinkedOnly - ? linkedReconcileEntries - : [...linkedReconcileEntries, ...fallbackStoredEntries] - } - } - - private _buildPromotionPersistenceState( - entries: Array<{ interiorNodeId: string; widgetName: string }>, - linkedEntries: LinkedPromotionEntry[] - ): { - mergedEntries: Array<{ interiorNodeId: string; widgetName: string }> - shouldPersistLinkedOnly: boolean - } { - const { linkedPromotionEntries, fallbackStoredEntries } = - this._collectLinkedAndFallbackEntries(entries, linkedEntries) - const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) - - return { - mergedEntries: shouldPersistLinkedOnly - ? linkedPromotionEntries - : [...linkedPromotionEntries, ...fallbackStoredEntries], - shouldPersistLinkedOnly - } - } - - private _collectLinkedAndFallbackEntries( - entries: Array<{ interiorNodeId: string; widgetName: string }>, - linkedEntries: LinkedPromotionEntry[] - ): { - linkedPromotionEntries: Array<{ - interiorNodeId: string - widgetName: string - }> - fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }> - } { - const linkedPromotionEntries = this._toPromotionEntries(linkedEntries) - const fallbackStoredEntries = this._getFallbackStoredEntries( - entries, - linkedPromotionEntries - ) - - return { - linkedPromotionEntries, - fallbackStoredEntries - } - } - - private _shouldPersistLinkedOnly( - linkedEntries: LinkedPromotionEntry[] - ): boolean { - return this.inputs.length > 0 && linkedEntries.length === this.inputs.length - } - - private _toPromotionEntries( - linkedEntries: LinkedPromotionEntry[] - ): Array<{ interiorNodeId: string; widgetName: string }> { - return linkedEntries.map(({ interiorNodeId, widgetName }) => ({ - interiorNodeId, - widgetName - })) - } - - private _getFallbackStoredEntries( - entries: Array<{ interiorNodeId: string; widgetName: string }>, - linkedPromotionEntries: Array<{ - interiorNodeId: string - widgetName: string - }> - ): Array<{ interiorNodeId: string; widgetName: string }> { - const linkedKeys = new Set( - linkedPromotionEntries.map((entry) => - this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) - ) - ) - return entries.filter( - (entry) => - !linkedKeys.has( - this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) - ) - ) - } - - private _buildLinkedReconcileEntries( - linkedEntries: LinkedPromotionEntry[] - ): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> { - return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({ - interiorNodeId, - widgetName, - viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName) - })) - } - - private _buildDisplayNameByViewKey( - linkedEntries: LinkedPromotionEntry[] - ): Map { - return new Map( - linkedEntries.map((entry) => [ - this._makePromotionViewKey( - entry.inputName, - entry.interiorNodeId, - entry.widgetName - ), - entry.inputName - ]) - ) - } - - private _makePromotionEntryKey( - interiorNodeId: string, - widgetName: string - ): string { - return `${interiorNodeId}:${widgetName}` - } - - private _makePromotionViewKey( - inputName: string, - interiorNodeId: string, - widgetName: string - ): string { - return `${inputName}:${interiorNodeId}:${widgetName}` } private _resolveLegacyEntry( widgetName: string ): [string, string] | undefined { // Legacy -1 entries use the slot name as the widget name. - // Find the input with that name, then trace to the connected interior widget. - const input = this.inputs.find((i) => i.name === widgetName) - if (!input?._widget) return undefined - - const widget = input._widget - if (isPromotedWidgetView(widget)) { - return [widget.sourceNodeId, widget.sourceWidgetName] - } - // Fallback: find via subgraph input slot connection const resolvedTarget = resolveSubgraphInputTarget(this, widgetName) if (!resolvedTarget) return undefined @@ -372,28 +143,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const subgraphEvents = this.subgraph.events const { signal } = this._eventAbortController + // Listen for widget promotions from the empty "+" slot + this.subgraph.inputNode.emptySlot.events.addEventListener( + 'widget-promotion-requested', + (e) => this._handleWidgetPromotionRequested(e), + { signal } + ) + subgraphEvents.addEventListener( 'input-added', (e) => { const subgraphInput = e.detail.input const { name, type } = subgraphInput const existingInput = this.inputs.find((i) => i.name === name) - if (existingInput) { - const linkId = subgraphInput.linkIds[0] - const { inputNode, input } = subgraph.links[linkId].resolve(subgraph) - const widget = inputNode?.widgets?.find?.((w) => w.name === name) - if (widget && inputNode) - this._setWidget( - subgraphInput, - existingInput, - widget, - input?.widget, - inputNode - ) - return - } - const input = this.addInput(name, type) + if (existingInput) return + const input = this.addInput(name, type) this._addSubgraphInputListeners(subgraphInput, input) }, { signal } @@ -402,11 +167,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { subgraphEvents.addEventListener( 'removing-input', (e) => { - const widget = e.detail.input._widget - if (widget) this.ensureWidgetRemoved(widget) - this.removeInput(e.detail.index) - this._syncPromotions() this.setDirtyCanvas(true, true) }, { signal } @@ -438,9 +199,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (!input) throw new Error('Subgraph input not found') input.label = newName - if (input._widget) { - input._widget.label = newName - } }, { signal } ) @@ -480,6 +238,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } + private _handleWidgetPromotionRequested( + e: CustomEvent + ) { + const nodeId = String(e.detail.node.id) + const widgetName = e.detail.widget.name + + if (this.id === -1) { + this._pendingPromotions.push({ interiorNodeId: nodeId, widgetName }) + return + } + + const store = usePromotionStore() + if (store.isPromoted(this.rootGraph.id, this.id, nodeId, widgetName)) { + store.demote(this.rootGraph.id, this.id, nodeId, widgetName) + } else { + store.promote(this.rootGraph.id, this.id, nodeId, widgetName) + } + } + private _addSubgraphInputListeners( subgraphInput: SubgraphInput, input: INodeInputSlot & Partial @@ -494,56 +271,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const { signal } = input._listenerController subgraphInput.events.addEventListener( - 'input-connected', - (e) => { - const widget = subgraphInput._widget - if (!widget) return - - // If this widget is already promoted, demote it first - // so it transitions cleanly to being linked via SubgraphInput. - const nodeId = String(e.detail.node.id) - if ( - usePromotionStore().isPromoted( - this.rootGraph.id, - this.id, - nodeId, - widget.name - ) - ) { - usePromotionStore().demote( - this.rootGraph.id, - this.id, - nodeId, - widget.name - ) - } - - const widgetLocator = e.detail.input.widget - this._setWidget( - subgraphInput, - input, - widget, - widgetLocator, - e.detail.node - ) - this._syncPromotions() - }, + 'widget-promotion-requested', + (e) => this._handleWidgetPromotionRequested(e), { signal } ) subgraphInput.events.addEventListener( 'input-disconnected', () => { - // If the input is connected to more than one widget, don't remove the widget - const connectedWidgets = subgraphInput.getConnectedWidgets() - if (connectedWidgets.length > 0) return - - if (input._widget) this.ensureWidgetRemoved(input._widget) - delete input.pos delete input.widget - input._widget = undefined - this._syncPromotions() }, { signal } ) @@ -634,14 +371,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ]) } - // Check all inputs for connected widgets + // Register event listeners for each input for (const input of this.inputs) { const subgraphInput = this.subgraph.inputNode.slots.find( (slot) => slot.name === input.name ) if (!subgraphInput) { - // Skip inputs that don't exist in the subgraph definition - // This can happen when loading workflows with dynamically added inputs console.warn( `[SubgraphNode.configure] No subgraph input found for input ${input.name}, skipping` ) @@ -649,126 +384,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } this._addSubgraphInputListeners(subgraphInput, input) - this._resolveInputWidget(subgraphInput, input) - } - - this._syncPromotions() - } - - private _resolveInputWidget( - subgraphInput: SubgraphInput, - input: INodeInputSlot - ) { - for (const linkId of subgraphInput.linkIds) { - const link = this.subgraph.getLink(linkId) - if (!link) { - console.warn( - `[SubgraphNode.configure] No link found for link ID ${linkId}`, - this - ) - continue - } - - const { inputNode } = link.resolve(this.subgraph) - if (!inputNode) { - console.warn('Failed to resolve inputNode', link, this) - continue - } - - const targetInput = inputNode.inputs.find((inp) => inp.link === linkId) - if (!targetInput) { - console.warn('Failed to find corresponding input', link, inputNode) - continue - } - - const widget = inputNode.getWidgetFromSlot(targetInput) - if (!widget) continue - - this._setWidget( - subgraphInput, - input, - widget, - targetInput.widget, - inputNode - ) - break - } - } - - private _setWidget( - subgraphInput: Readonly, - input: INodeInputSlot, - interiorWidget: Readonly, - inputWidget: IWidgetLocator | undefined, - interiorNode: LGraphNode - ) { - this._flushPendingPromotions() - - const nodeId = String(interiorNode.id) - const widgetName = interiorWidget.name - - const previousView = input._widget - - if ( - previousView && - isPromotedWidgetView(previousView) && - (previousView.sourceNodeId !== nodeId || - previousView.sourceWidgetName !== widgetName) - ) { - usePromotionStore().demote( - this.rootGraph.id, - this.id, - previousView.sourceNodeId, - previousView.sourceWidgetName - ) - this._removePromotedView(previousView) } - - if (this.id === -1) { - if ( - !this._pendingPromotions.some( - (entry) => - entry.interiorNodeId === nodeId && entry.widgetName === widgetName - ) - ) { - this._pendingPromotions.push({ - interiorNodeId: nodeId, - widgetName - }) - } - } else { - // Add to promotion store - usePromotionStore().promote( - this.rootGraph.id, - this.id, - nodeId, - widgetName - ) - } - - // Create/retrieve the view from cache - const view = this._promotedViewManager.getOrCreate( - nodeId, - widgetName, - () => - createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name), - this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName) - ) - - // NOTE: This code creates linked chains of prototypes for passing across - // multiple levels of subgraphs. As part of this, it intentionally avoids - // creating new objects. Have care when making changes. - input.widget ??= { name: subgraphInput.name } - input.widget.name = subgraphInput.name - if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget) - - input._widget = view - - // Dispatch widget-promoted event - this.subgraph.events.dispatch('widget-promoted', { - widget: view, - subgraphNode: this - }) } private _flushPendingPromotions() { @@ -788,7 +404,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { override onAdded(_graph: LGraph): void { this._flushPendingPromotions() - this._syncPromotions() } /** @@ -938,17 +553,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { private _removePromotedView(view: PromotedWidgetView): void { this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName) - // Reconciled views can also be keyed by inputName-scoped view keys. - // Remove both key shapes to avoid stale cache entries across promote/rebind flows. - this._promotedViewManager.removeByViewKey( - view.sourceNodeId, - view.sourceWidgetName, - this._makePromotionViewKey( - view.name, - view.sourceNodeId, - view.sourceWidgetName - ) - ) } override removeWidget(widget: IBaseWidget): void { @@ -971,18 +575,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) this._removePromotedView(widget) } - for (const input of this.inputs) { - if (input._widget === widget) { - input._widget = undefined - input.widget = undefined - } - } this.subgraph.events.dispatch('widget-demoted', { widget, subgraphNode: this }) - - this._syncPromotions() } override onRemoved(): void { @@ -1048,22 +644,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { * This ensures nested subgraph widget values are preserved when saving. */ override serialize(): ISerialisedNode { - // Sync widget values to subgraph definition before serialization. - // Only sync for inputs that are linked to a promoted widget via _widget. - for (const input of this.inputs) { - if (!input._widget) continue - - const subgraphInput = this.subgraph.inputNode.slots.find( - (slot) => slot.name === input.name - ) - if (!subgraphInput) continue - - const connectedWidgets = subgraphInput.getConnectedWidgets() - for (const connectedWidget of connectedWidgets) { - connectedWidget.value = input._widget.value - } - } - // Write promotion store state back to properties for serialization const entries = usePromotionStore().getPromotions( this.rootGraph.id,