refactor: replace widget copy with lightweight stub in SubgraphNode._setWidget

Replace the heavy createCopyForNode/Object.defineProperties pattern with
a minimal stub that only carries metadata (sourceNodeId, sourceWidgetName)
and delegates value/type/options to the interior widget.

- Remove BaseWidget and AssetWidget imports from SubgraphNode
- Trigger syncPromotedWidgets for live connections via proxyWidgets setter
- Patch input._widget references after stub-to-PromotedWidgetSlot replacement
- Resolve legacy -1 entries via subgraph input wiring instead of copy metadata
- Add optional slotName parameter to PromotedWidgetSlot constructor

Amp-Thread-ID: https://ampcode.com/threads/T-019c5551-e9c9-754a-afdd-94537f2542b3
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-12 21:23:51 -08:00
parent 5de9eaccf4
commit 6049332d4e
4 changed files with 84 additions and 65 deletions

View File

@@ -45,9 +45,10 @@ export class PromotedWidgetSlot extends BaseWidget<IBaseWidget> {
constructor(
subgraphNode: SubgraphNode,
sourceNodeId: NodeId,
sourceWidgetName: string
sourceWidgetName: string,
slotName?: string
) {
const name = `${sourceNodeId}: ${sourceWidgetName}`
const name = slotName ?? `${sourceNodeId}: ${sourceWidgetName}`
super(
{
name,

View File

@@ -34,6 +34,7 @@ function createMockSubgraphNode(
return {
isSubgraphNode: () => true,
widgets,
inputs: [],
properties: { proxyWidgets: [] },
_setConcreteSlots: vi.fn(),
arrange: vi.fn()

View File

@@ -63,9 +63,8 @@ function syncPromotedWidgets(
if (w instanceof PromotedWidgetSlot) w.disposeDomAdapter()
}
// Collect slot-promoted copies created by _setWidget() during configure.
// These have sourceNodeId/sourceWidgetName set via Object.defineProperties.
const copies = widgets.filter(
// Collect stubs created by _setWidget() during configure.
const stubs = widgets.filter(
(
w
): w is IBaseWidget & { sourceNodeId: string; sourceWidgetName: string } =>
@@ -74,55 +73,84 @@ function syncPromotedWidgets(
'sourceWidgetName' in w
)
// Remove all promoted widgets (both PromotedWidgetSlots and copies)
// Remove all promoted widgets (both PromotedWidgetSlots and stubs)
node.widgets = widgets.filter(
(w) =>
!(w instanceof PromotedWidgetSlot) &&
!(copies as IBaseWidget[]).includes(w)
!(stubs as IBaseWidget[]).includes(w)
)
// Track which source widgets are covered by the parsed list
const subgraphNode = node as SubgraphNode
const covered = new Set<string>()
// Create PromotedWidgetSlots for all parsed entries.
// Legacy `-1` entries are resolved to real IDs via the copies.
const newSlots: IBaseWidget[] = parsed.flatMap(([nodeId, widgetName]) => {
let resolvedNodeId = nodeId
let resolvedWidgetName = widgetName
// Legacy `-1` entries: resolve via subgraph input wiring
if (nodeId === '-1') {
const copy = copies.find((w) => w.name === widgetName)
if (!copy) return []
resolvedNodeId = copy.sourceNodeId
resolvedWidgetName = copy.sourceWidgetName
const subgraph = subgraphNode.subgraph
const inputSlot = subgraph?.inputNode?.slots.find(
(s) => s.name === widgetName
)
if (!inputSlot || !subgraph) return []
const linkId = inputSlot.linkIds[0]
const link = linkId != null ? subgraph.getLink(linkId) : undefined
if (!link) return []
const resolved = link.resolve(subgraph)
const inputWidgetName = resolved.input?.widget?.name
if (!resolved.inputNode || !inputWidgetName) return []
resolvedNodeId = String(resolved.inputNode.id)
resolvedWidgetName = inputWidgetName
}
covered.add(`${resolvedNodeId}:${resolvedWidgetName}`)
return [
new PromotedWidgetSlot(
node as SubgraphNode,
resolvedNodeId,
resolvedWidgetName
)
new PromotedWidgetSlot(subgraphNode, resolvedNodeId, resolvedWidgetName)
]
})
// Add PromotedWidgetSlots for any copies not in the parsed list
// Add PromotedWidgetSlots for stubs not in the parsed list
// (e.g. old workflows that didn't serialize slot-promoted entries)
for (const copy of copies) {
const key = `${copy.sourceNodeId}:${copy.sourceWidgetName}`
for (const stub of stubs) {
const key = `${stub.sourceNodeId}:${stub.sourceWidgetName}`
if (covered.has(key)) continue
newSlots.unshift(
new PromotedWidgetSlot(
node as SubgraphNode,
copy.sourceNodeId,
copy.sourceWidgetName
subgraphNode,
stub.sourceNodeId,
stub.sourceWidgetName
)
)
}
node.widgets.push(...newSlots)
// Update input._widget references to point to the new PromotedWidgetSlots
// instead of the stubs they replaced.
for (const input of subgraphNode.inputs) {
const oldWidget = input._widget
if (
!oldWidget ||
!('sourceNodeId' in oldWidget) ||
!('sourceWidgetName' in oldWidget)
)
continue
const sid = String(oldWidget.sourceNodeId)
const swn = String(oldWidget.sourceWidgetName)
const replacement = newSlots.find(
(w) =>
w instanceof PromotedWidgetSlot &&
w.sourceNodeId === sid &&
w.sourceWidgetName === swn
)
if (replacement) input._widget = replacement
}
canvasStore.canvas?.setDirty(true, true)
node._setConcreteSlots()
node.arrange()

View File

@@ -29,8 +29,6 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -354,40 +352,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
// Use the first matching widget
const promotedWidget =
widget instanceof BaseWidget
? widget.createCopyForNode(this)
: { ...widget, node: this }
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
// Delegate value to the interior widget so both the SubgraphNode copy
// and the interior widget share the same store entry.
// Object.defineProperty is required — Object.assign invokes getters
// and copies the result as a data property.
const sourceWidget = widget as IBaseWidget
Object.defineProperties(promotedWidget, {
sourceNodeId: {
value: String(interiorNode.id),
configurable: true,
enumerable: true
},
sourceWidgetName: {
value: sourceWidget.name,
configurable: true,
enumerable: true
},
const stub: IBaseWidget = Object.create(null)
Object.defineProperties(stub, {
sourceNodeId: { value: String(interiorNode.id), enumerable: true },
sourceWidgetName: { value: sourceWidget.name, enumerable: true },
node: { value: this, enumerable: true },
name: {
get: () => subgraphInput.name,
set() {},
configurable: true,
enumerable: true
},
localized_name: {
get: () => subgraphInput.localized_name,
set() {},
configurable: true,
type: {
get: () => sourceWidget.type,
enumerable: true
},
value: {
@@ -395,29 +372,29 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
set: (v) => {
sourceWidget.value = v
},
configurable: true,
enumerable: true
},
options: {
get: () => sourceWidget.options,
enumerable: true
},
label: {
get: () => subgraphInput.label,
set() {},
configurable: true,
enumerable: true
},
tooltip: {
get: () => widget.tooltip,
set() {},
configurable: true,
enumerable: true
}
})
const widgetCount = this.inputs.filter((i) => i.widget).length
this.widgets.splice(widgetCount, 0, promotedWidget)
this.widgets.splice(widgetCount, 0, stub)
// Dispatch widget-promoted event
this.subgraph.events.dispatch('widget-promoted', {
widget: promotedWidget,
widget: stub,
subgraphNode: this
})
@@ -428,7 +405,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = promotedWidget
input._widget = stub
// Trigger promoted widget slot sync for live connections.
// During configure, the proxyWidgets setter isn't defined yet (no-op).
// After configure, re-assigning triggers syncPromotedWidgets which
// replaces this stub with a proper PromotedWidgetSlot and patches
// input._widget references.
const desc = Object.getOwnPropertyDescriptor(
this.properties,
'proxyWidgets'
)
if (desc?.set) {
this.properties.proxyWidgets = this.properties.proxyWidgets
}
}
/**
@@ -585,9 +575,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Clean up all subgraph event listeners
this._eventAbortController.abort()
// Clean up all promoted widgets (skip PromotedWidgetSlot instances)
// Dispatch widget-demoted for all widgets so listeners can clean up
for (const widget of this.widgets) {
if (widget.isPromotedSlot) continue
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this