mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
1 Commits
fix/codera
...
drjkl/one-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
953374b9de |
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@ export interface SubgraphInputEventMap extends LGraphEventMap {
|
||||
'input-disconnected': {
|
||||
input: SubgraphInput
|
||||
}
|
||||
|
||||
'widget-promotion-requested': {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user