Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Brown
953374b9de refactor: unify widget promotion to use store as single source of truth
SubgraphInput.connect() dispatches widget-promotion-requested event
for widget-backed slots instead of creating input slots and links.
EmptySubgraphInput.connect() checks for widgets before creating
input slots. All promotions now flow through promotionStore.

Removed ~200 lines of linked-promotion infrastructure from
SubgraphNode (LinkedPromotionEntry, _resolveLinkedPromotionByInputName,
_syncPromotions, _buildPromotionReconcileState, _setWidget, etc.).
Removed _widget property from SubgraphInput, NodeInputSlot,
INodeInputSlot, and LGraphNode.removeWidget().

Amp-Thread-ID: https://ampcode.com/threads/T-019ccb06-9ebd-7161-a547-ab84d6ac2afd
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 17:21:22 -08:00
14 changed files with 412 additions and 769 deletions

View File

@@ -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<string, unknown>)
.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]

View File

@@ -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

View File

@@ -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<string, unknown>).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 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
// 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)
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,30 +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)
test('nested promotion shows button fallback when intermediate view is unavailable', () => {
const { subgraphNodeA, subgraphNodeB } = createTwoLevelNestedSubgraph()
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.
// Force subgraphNodeA to report no promoted widgets
Object.defineProperty(subgraphNodeA, 'widgets', {
get: () => [],
configurable: true
@@ -1158,30 +1145,10 @@ describe('promoted combo rendering', () => {
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)
test('nested promotion shows button fallback when intermediate promotions are cleared', () => {
const { subgraphNodeA, subgraphNodeB } = createTwoLevelNestedSubgraph()
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 a transient where intermediate promotions are unavailable but
// input _widget binding is already updated.
// Clear intermediate promotions
usePromotionStore().setPromotions(
subgraphNodeA.rootGraph.id,
subgraphNodeA.id,
@@ -1195,105 +1162,88 @@ 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 })
usePromotionStore().setPromotions(
subgraphNodeA.rootGraph.id,
subgraphNodeA.id,
[]
)
Object.defineProperty(subgraphNodeA, 'widgets', {
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 })
const subgraphB = createTestSubgraph({
inputs: [
{ name: 'lora_name', type: '*' },
{ name: 'strength_model', type: '*' }
]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA)
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)
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 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'lora_name', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const store = usePromotionStore()
store.promote(
subgraphNodeA.rootGraph.id,
subgraphNodeA.id,
String(innerNode.id),
'lora_name'
)
const subgraphB = createTestSubgraph()
subgraphB.add(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
)

View File

@@ -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}`)

View File

@@ -52,9 +52,14 @@ function addLinkedNestedSubgraphNode(
const input = innerSubgraphNode.addInput(linkedInputName, '*')
if (options.widget) {
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
}
// 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 }
}
inputSlot.connect(input, innerSubgraphNode)
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')

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -15,4 +15,9 @@ export interface SubgraphInputEventMap extends LGraphEventMap {
'input-disconnected': {
input: SubgraphInput
}
'widget-promotion-requested': {
node: LGraphNode
widget: IBaseWidget
}
}

View File

@@ -14,7 +14,6 @@ import type {
LinkDirection,
RenderShape
} from './types/globalEnums'
import type { IBaseWidget } from './types/widgets'
export type Dictionary<T> = { [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 {

View File

@@ -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<IBaseWidget> | 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<Point> {
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
}

View File

@@ -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)

View File

@@ -34,17 +34,6 @@ export class SubgraphInput extends SubgraphSlot {
events = new CustomEventTarget<SubgraphInputEventMap>()
/** The linked widget that this slot is connected to. */
private _widgetRef?: WeakRef<IBaseWidget>
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 })
}

View File

@@ -117,11 +117,9 @@ describe.skip('SubgraphNode Memory Management', () => {
})
} as Partial<IWidget> 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)

View File

@@ -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<PromotedWidgetView>()
/**
* 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<string>()
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
)
return this._promotedViewManager.reconcile(entries, (entry) =>
createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName)
)
}
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<string, string>
reconcileEntries: Array<{
interiorNodeId: string
widgetName: string
viewKey?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
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<string, string> {
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<SubgraphInputEventMap['widget-promotion-requested']>
) {
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<ISubgraphInput>
@@ -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<SubgraphInput>,
input: INodeInputSlot,
interiorWidget: Readonly<IBaseWidget>,
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,