refactor(useGraphNodeManager): derive widget slot linked from upstream resolvability

Replace the SUBGRAPH_INPUT_ID sentinel check in buildSlotMetadata with a
check that the upstream node actually resolves via getNodeById. Promoted
widgets (link origin = SUBGRAPH_INPUT sentinel) naturally render as
editable instead of disabled with a phantom upstream chip, and the
renderer no longer imports a litegraph sentinel constant.

Test setup now uses a real upstream node + upstream.connect() instead of
an unreferenced input.link id. Added regression test for the
non-resolvable-upstream case.

Amp-Thread-ID: https://ampcode.com/threads/T-019e3d8c-b6b4-731e-b3fb-9bdb6fefb1ae
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-18 18:21:05 -07:00
parent efc0a21fd4
commit 331afcecab
2 changed files with 39 additions and 33 deletions

View File

@@ -102,12 +102,17 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
return { graph, node }
// Real upstream node + real link — the renderer treats a slot as
// `linked` only when the upstream resolves to an actual node.
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'STRING')
graph.add(upstream)
const link = upstream.connect(0, node, 0)
if (!link) throw new Error('Expected upstream.connect to produce a link')
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
@@ -187,7 +192,28 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
it('does not mark a slot as linked when the link references a non-resolvable upstream (e.g. SubgraphInput sentinel)', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
// Reference a link that doesn't resolve to a real upstream node — mirrors
// the subgraph promotion case where origin_id === SUBGRAPH_INPUT_ID and
// getNodeById returns null. The renderer must treat the slot as editable
// rather than disabled-with-phantom-upstream.
input.link = 9999
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(widgetData?.slotMetadata?.originNodeId).toBeUndefined()
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -217,7 +243,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
@@ -228,21 +253,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {

View File

@@ -14,7 +14,6 @@ import {
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -387,25 +386,21 @@ function buildSlotMetadata(
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
// Promotion via SubgraphInput materialises a real link from the
// SUBGRAPH_INPUT sentinel into the interior widget's input slot.
// That link is internal plumbing — not an external connection — so
// exclude it from `linked` (which downstream renders as disabled).
let isPromotionLink = false
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
// Resolvability gate: SubgraphInput-sentinel origins return null here,
// so promoted widgets stay editable instead of disabled with no chip.
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
isPromotionLink = link.origin_id === SUBGRAPH_INPUT_ID
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null && !isPromotionLink,
linked: originNodeId != null,
originNodeId,
originOutputName,
type: String(input.type)