From 5640eb7d927feed42526f0f751b840c752ce37c4 Mon Sep 17 00:00:00 2001 From: Arthur R Longbottom Date: Fri, 13 Mar 2026 09:58:13 -0700 Subject: [PATCH] fix: subgraph output slot labels not updating in v2 renderer (#9266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Custom names set on subgraph output nodes are ignored in the v2 renderer — it always shows the data type name (e.g. "texts") instead of the user-defined label. Works correctly in v1. ## Changes - **What**: Made `outputs` in `extractVueNodeData` reactive via `shallowReactive` + `defineProperty` (matching the existing `inputs` pattern). Added a `node:slot-label:changed` graph trigger that `SubgraphNode` fires when input/output labels are renamed, so the Vue layer picks up the change. ## Review Focus - The `outputs` reactivity mirrors `inputs` exactly — same `shallowReactive` + setter pattern. The new trigger event forces `shallowReactive` to detect the deep property change by re-assigning the array. - Also handles input label renames for consistency, even though the current bug report is output-specific. ## Screenshots **v1 — output correctly shows custom label "output_text":** Screenshot 2026-02-26 at 4 43 00 PM **v2 before fix — output shows type name "texts" instead of custom label:** Screenshot 2026-02-26 at 4 43 30 PM **v2 after fix — output correctly shows "output_text":** Screenshot 2026-02-26 at 5 14 44 PM ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9266-fix-subgraph-output-slot-labels-not-updating-in-v2-renderer-3146d73d365081979327fd775a6ef62b) by [Unito](https://www.unito.io) --- .../graph/useGraphNodeManager.test.ts | 72 +++++++++++++++++++ src/composables/graph/useGraphNodeManager.ts | 38 +++++++++- src/lib/litegraph/src/LGraph.ts | 3 +- .../litegraph/src/subgraph/SubgraphNode.ts | 9 +++ src/lib/litegraph/src/types/graphTriggers.ts | 7 ++ 5 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 4aadcf57da..f0c4c3d66e 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -243,6 +243,78 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { }) }) +describe('Subgraph output slot label reactivity', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('updates output slot labels when node:slot-label:changed is triggered', async () => { + const graph = new LGraph() + const node = new LGraphNode('test') + node.addOutput('original_name', 'STRING') + node.addOutput('other_name', 'STRING') + graph.add(node) + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeId = String(node.id) + const nodeData = vueNodeData.get(nodeId) + if (!nodeData?.outputs) throw new Error('Expected output data to exist') + + expect(nodeData.outputs[0].label).toBeUndefined() + expect(nodeData.outputs[1].label).toBeUndefined() + + // Simulate what SubgraphNode does: set the label, then fire the trigger + node.outputs[0].label = 'custom_label' + graph.trigger('node:slot-label:changed', { + nodeId: node.id, + slotType: NodeSlotType.OUTPUT + }) + + await nextTick() + + const updatedData = vueNodeData.get(nodeId) + expect(updatedData?.outputs?.[0]?.label).toBe('custom_label') + expect(updatedData?.outputs?.[1]?.label).toBeUndefined() + }) + + it('updates input slot labels when node:slot-label:changed is triggered', async () => { + const graph = new LGraph() + const node = new LGraphNode('test') + node.addInput('original_name', 'STRING') + graph.add(node) + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeId = String(node.id) + const nodeData = vueNodeData.get(nodeId) + if (!nodeData?.inputs) throw new Error('Expected input data to exist') + + expect(nodeData.inputs[0].label).toBeUndefined() + + node.inputs[0].label = 'custom_label' + graph.trigger('node:slot-label:changed', { + nodeId: node.id, + slotType: NodeSlotType.INPUT + }) + + await nextTick() + + const updatedData = vueNodeData.get(nodeId) + expect(updatedData?.inputs?.[0]?.label).toBe('custom_label') + }) + + it('ignores node:slot-label:changed for unknown node ids', () => { + const graph = new LGraph() + useGraphNodeManager(graph) + + expect(() => + graph.trigger('node:slot-label:changed', { + nodeId: 'missing-node', + slotType: NodeSlotType.OUTPUT + }) + ).not.toThrow() + }) +}) + 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 a0685e66c9..c40fe95e49 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -396,7 +396,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData { }, set(v) { reactiveWidgets.splice(0, reactiveWidgets.length, ...v) - } + }, + configurable: true, + enumerable: true }) } const reactiveInputs = shallowReactive(node.inputs ?? []) @@ -406,7 +408,20 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData { }, set(v) { reactiveInputs.splice(0, reactiveInputs.length, ...v) - } + }, + configurable: true, + enumerable: true + }) + const reactiveOutputs = shallowReactive(node.outputs ?? []) + Object.defineProperty(node, 'outputs', { + get() { + return reactiveOutputs + }, + set(v) { + reactiveOutputs.splice(0, reactiveOutputs.length, ...v) + }, + configurable: true, + enumerable: true }) const safeWidgets = reactiveComputed(() => { @@ -448,7 +463,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData { hasErrors: !!node.has_errors, widgets: safeWidgets, inputs: reactiveInputs, - outputs: node.outputs ? [...node.outputs] : undefined, + outputs: reactiveOutputs, flags: node.flags ? { ...node.flags } : undefined, color: node.color || undefined, bgcolor: node.bgcolor || undefined, @@ -746,6 +761,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { if (slotLinksEvent.slotType === NodeSlotType.INPUT) { refreshNodeSlots(String(slotLinksEvent.nodeId)) } + }, + 'node:slot-label:changed': (slotLabelEvent) => { + const nodeId = String(slotLabelEvent.nodeId) + const nodeRef = nodeRefs.get(nodeId) + if (!nodeRef) return + + // Force shallowReactive to detect the deep property change + // by re-assigning the affected array through the defineProperty setter. + if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) { + nodeRef.inputs = [...nodeRef.inputs] + } + if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) { + nodeRef.outputs = [...nodeRef.outputs] + } } } @@ -760,6 +789,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { case 'node:slot-links:changed': triggerHandlers['node:slot-links:changed'](event) break + case 'node:slot-label:changed': + triggerHandlers['node:slot-label:changed'](event) + break } // Chain to original handler diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index c8b7e3c080..eafefbeba5 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1336,7 +1336,8 @@ export class LGraph const validEventTypes = new Set([ 'node:slot-links:changed', 'node:slot-errors:changed', - 'node:property:changed' + 'node:property:changed', + 'node:slot-label:changed' ]) if (validEventTypes.has(action) && param && typeof param === 'object') { diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 735a0f27c7..e7b31bdb9c 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -27,6 +27,7 @@ import type { ExportedSubgraphInstance, ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' +import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { @@ -441,6 +442,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (input._widget) { input._widget.label = newName } + this.graph?.trigger('node:slot-label:changed', { + nodeId: this.id, + slotType: NodeSlotType.INPUT + }) }, { signal } ) @@ -453,6 +458,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (!output) throw new Error('Subgraph output not found') output.label = newName + this.graph?.trigger('node:slot-label:changed', { + nodeId: this.id, + slotType: NodeSlotType.OUTPUT + }) }, { signal } ) diff --git a/src/lib/litegraph/src/types/graphTriggers.ts b/src/lib/litegraph/src/types/graphTriggers.ts index 6b6492f16c..ae53053f5c 100644 --- a/src/lib/litegraph/src/types/graphTriggers.ts +++ b/src/lib/litegraph/src/types/graphTriggers.ts @@ -23,10 +23,17 @@ interface NodeSlotLinksChangedEvent { linkId: number } +interface NodeSlotLabelChangedEvent { + type: 'node:slot-label:changed' + nodeId: NodeId + slotType?: NodeSlotType +} + export type LGraphTriggerEvent = | NodePropertyChangedEvent | NodeSlotErrorsChangedEvent | NodeSlotLinksChangedEvent + | NodeSlotLabelChangedEvent export type LGraphTriggerAction = LGraphTriggerEvent['type']