From f81f6eda82a32f11fe819d0f82b099a76907f432 Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 25 Feb 2026 08:23:18 +0000 Subject: [PATCH] fix: textarea stays disabled after link disconnect on promoted widgets refreshNodeSlots used SafeWidgetData.name for slot metadata lookups. For promoted widgets, name is sourceWidgetName (interior name) which differs from the subgraph node's input slot widget name. Add slotName field to SafeWidgetData to track the original LiteGraph widget name, and use it in refreshNodeSlots for correct matching. Amp-Thread-ID: https://ampcode.com/threads/T-019c93e3-1cef-72cc-b7cc-a2f067070ff6 --- .../graph/useGraphNodeManager.test.ts | 158 ++++++++++++++++++ src/composables/graph/useGraphNodeManager.ts | 11 +- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 2a405880a8..681d3ee1a5 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, nextTick, watch } from 'vue' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' +import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph, @@ -82,6 +83,163 @@ describe('Node Reactivity', () => { }) }) +describe('Widget slotMetadata reactivity on link disconnect', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + function createWidgetInputGraph() { + const graph = new LGraph() + const node = new LGraphNode('test') + + // Add a widget and an associated input slot (simulates "widget converted to input") + node.addWidget('string', 'prompt', 'hello', () => undefined, {}) + 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 } + } + + it('sets slotMetadata.linked to true when input has a link', () => { + const { graph, node } = createWidgetInputGraph() + const { vueNodeData } = useGraphNodeManager(graph) + + const nodeData = vueNodeData.get(String(node.id)) + const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt') + + expect(widgetData?.slotMetadata).toBeDefined() + expect(widgetData?.slotMetadata?.linked).toBe(true) + }) + + it('updates slotMetadata.linked to false after link disconnect event', async () => { + const { graph, node } = createWidgetInputGraph() + const { vueNodeData } = useGraphNodeManager(graph) + + const nodeData = vueNodeData.get(String(node.id)) + const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt') + + // Verify initially linked + expect(widgetData?.slotMetadata?.linked).toBe(true) + + // Simulate link disconnection (as LiteGraph does before firing the event) + node.inputs[0].link = null + + // Fire the trigger event that LiteGraph fires on disconnect + graph.trigger('node:slot-links:changed', { + nodeId: node.id, + slotType: NodeSlotType.INPUT, + slotIndex: 0, + connected: false, + linkId: 42 + }) + + await nextTick() + + // slotMetadata.linked should now be false + expect(widgetData?.slotMetadata?.linked).toBe(false) + }) + + it('reactively updates disabled state in a derived computed after disconnect', async () => { + const { graph, node } = createWidgetInputGraph() + const { vueNodeData } = useGraphNodeManager(graph) + + const nodeData = vueNodeData.get(String(node.id))! + + // Mimic what processedWidgets does in NodeWidgets.vue: + // derive disabled from slotMetadata.linked + const derivedDisabled = computed(() => { + const widgets = nodeData.widgets ?? [] + const widget = widgets.find((w) => w.name === 'prompt') + return widget?.slotMetadata?.linked ? true : false + }) + + // Initially linked → disabled + expect(derivedDisabled.value).toBe(true) + + // Track changes + const onChange = vi.fn() + watch(derivedDisabled, onChange) + + // Simulate disconnect + node.inputs[0].link = null + graph.trigger('node:slot-links:changed', { + nodeId: node.id, + slotType: NodeSlotType.INPUT, + slotIndex: 0, + connected: false, + linkId: 42 + }) + + await nextTick() + + // The derived computed should now return false + expect(derivedDisabled.value).toBe(false) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => { + // Set up a subgraph with an interior node that has a "prompt" widget. + // createPromotedWidgetView resolves against this interior node. + const subgraph = createTestSubgraph() + const interiorNode = new LGraphNode('interior') + interiorNode.id = 10 + interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {}) + subgraph.add(interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 }) + + // Create a PromotedWidgetView with displayName="value" (subgraph input + // slot name) and sourceWidgetName="prompt" (interior widget name). + // PromotedWidgetView.name returns "value", but safeWidgetMapper sets + // SafeWidgetData.name to sourceWidgetName ("prompt"). + const promotedView = createPromotedWidgetView( + subgraphNode, + '10', + 'prompt', + 'value' + ) + + // Host the promoted view on a regular node so we can control widgets + // directly (SubgraphNode.widgets is a synthetic getter). + const graph = new LGraph() + const hostNode = new LGraphNode('host') + hostNode.widgets = [promotedView] + const input = hostNode.addInput('value', 'STRING') + input.widget = { name: 'value' } + input.link = 42 + graph.add(hostNode) + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeData = vueNodeData.get(String(hostNode.id)) + + // SafeWidgetData.name is "prompt" (sourceWidgetName), but the + // input slot widget name is "value" — slotName bridges this gap. + 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) + }) +}) + describe('Subgraph Promoted Pseudo Widgets', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 475df92243..be3d8bd8e0 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -69,6 +69,12 @@ export interface SafeWidgetData { spec?: InputSpec /** Input slot metadata (index and link status) */ slotMetadata?: WidgetSlotMetadata + /** + * Original LiteGraph widget name used for slot metadata matching. + * For promoted widgets, `name` is `sourceWidgetName` (interior widget name) + * which differs from the subgraph node's input slot widget name. + */ + slotName?: string } export interface VueNodeData { @@ -238,7 +244,8 @@ function safeWidgetMapper( options: isPromotedPseudoWidget ? { ...options, canvasOnly: true } : options, - slotMetadata: slotInfo + slotMetadata: slotInfo, + slotName: name !== widget.name ? widget.name : undefined } } catch (error) { return { @@ -376,7 +383,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Update only widgets with new slot metadata, keeping other widget data intact for (const widget of currentData.widgets ?? []) { - const slotInfo = slotMetadata.get(widget.name) + const slotInfo = slotMetadata.get(widget.slotName ?? widget.name) if (slotInfo) widget.slotMetadata = slotInfo } }