fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing

When nodes are packed into a nested subgraph, the outer SubgraphNode's
proxyWidgets retained stale entries referencing grandchild node IDs that
no longer exist in its direct subgraph. These entries rendered as
'Disconnected' placeholder widgets on the canvas.

Two fixes:
- Filter entries during hydration when source node doesn't exist in subgraph
- Skip missing-node entries in _pruneStaleAliasFallbackEntries as defense

Amp-Thread-ID: https://ampcode.com/threads/T-019d1335-0090-74bc-8615-0f07354f5ce4
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-03-21 20:47:28 -07:00
parent aa407e7cd4
commit 9c0ddeeb8f
2 changed files with 62 additions and 2 deletions

View File

@@ -509,6 +509,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
@@ -1049,6 +1051,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
return null
}
if (!this.subgraph.getNodeById(nodeId)) return null
const entry: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
@@ -1060,8 +1063,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy -1 format doesn't persist
if (raw.some(([id]) => id === '-1')) {
// Write back resolved entries so legacy or stale entries don't persist
if (entries.length !== raw.length) {
this.properties.proxyWidgets = this._serializeEntries(entries)
}

View File

@@ -8,6 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
createEventCapture,
@@ -263,6 +264,62 @@ describe('SubgraphWidgetPromotion', () => {
})
})
describe('Nested Subgraph Widget Promotion', () => {
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
// Reproduces the bug where packing nodes into a nested subgraph leaves
// stale proxyWidgets on the outer subgraph node referencing grandchild
// node IDs that no longer exist directly in the outer subgraph.
// Uses 3 inputs with only 1 having a linked widget entry, matching the
// real workflow structure where model/vae inputs don't resolve widgets.
const subgraph = createTestSubgraph({
inputs: [
{ name: 'clip', type: 'CLIP' },
{ name: 'model', type: 'MODEL' },
{ name: 'vae', type: 'VAE' }
]
})
const { node: samplerNode } = createNodeWithWidget(
'Sampler',
'number',
42,
'number'
)
subgraph.add(samplerNode)
subgraph.inputNode.slots[1].connect(samplerNode.inputs[0], samplerNode)
// Add nodes without widget-connected inputs for the other slots
const modelNode = new LGraphNode('ModelNode')
modelNode.addInput('model', 'MODEL')
subgraph.add(modelNode)
const vaeNode = new LGraphNode('VAENode')
vaeNode.addInput('vae', 'VAE')
subgraph.add(vaeNode)
const outerNode = createTestSubgraphNode(subgraph)
// Inject stale proxyWidgets referencing nodes that don't exist in
// this subgraph (they were packed into a nested subgraph)
outerNode.properties.proxyWidgets = [
['999', 'text'],
['998', 'text'],
[String(samplerNode.id), 'widget']
]
outerNode.configure(outerNode.serialize())
// Check widgets getter — stale entries should not produce views
const widgetSourceIds = outerNode.widgets
.filter(isPromotedWidgetView)
.filter((w) => !w.name.startsWith('$$'))
.map((w) => w.sourceNodeId)
expect(widgetSourceIds).not.toContain('999')
expect(widgetSourceIds).not.toContain('998')
})
})
describe('Tooltip Promotion', () => {
it('should preserve widget tooltip when promoting', () => {
const subgraph = createTestSubgraph({