From 2cb4c5eff337c294fc44bab64e1e74d00ba9253f Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 25 Feb 2026 20:50:11 -0800 Subject: [PATCH] fix: textarea stays disabled after link disconnect on promoted widgets (#9199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix textarea widgets staying disabled after disconnecting a link on promoted widgets in subgraphs. ## Changes - **What**: `refreshNodeSlots` used `SafeWidgetData.name` for slot metadata lookups, but for promoted widgets this is `sourceWidgetName` (the interior widget name), which doesn't match the subgraph node's input slot widget name. Added `slotName` field to `SafeWidgetData` to track the original LiteGraph widget name, and updated `refreshNodeSlots` to use `slotName ?? name` for correct matching. ## Review Focus The key change is the `slotName` field on `SafeWidgetData` — it's only populated when `name !== widget.name` (i.e., for promoted widgets). The `refreshNodeSlots` function now uses `widget.slotName ?? widget.name` to look up slot metadata, ensuring promoted widgets correctly update their `linked` state on disconnect. Fixes #8818 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9199-fix-textarea-stays-disabled-after-link-disconnect-on-promoted-widgets-3126d73d3650813db499c227e6587aca) by [Unito](https://www.unito.io) --- .../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 } }