fix Vue node widgets should be in disabled state if their slots are connected with a link (#5834)

## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5692 by
making widget link connection status trigger on change so Vue widgets
with connected links could properly switch to the `disabled` state when
they are implicitly converted to inputs.

## Changes

- **What**: Added `node:slot-links:changed` event tracking and reactive
slot data synchronization for Vue widgets

```mermaid
graph TD
    A[Widget Link Change] --> B[NodeInputSlot.link setter]
    B --> C{Is Widget Input?}
    C -->|Yes| D[Trigger slot-links:changed]
    C -->|No| E[End]
    D --> F[Graph Event Handler]
    F --> G[syncNodeSlotData]
    G --> H[Update Vue Reactive Data]
    H --> I[Widget Re-render]
    
    style A fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#f9f9f9,stroke:#333,color:#000
```

## Review Focus

Widget reactivity performance with frequent link changes and event
handler memory management in graph operations.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5834-fix-Vue-node-widgets-should-be-in-disabled-state-if-their-slots-are-connected-with-a-link-27c6d73d365081f6a6c3c1ddc3905c5e)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2025-10-09 10:30:12 -07:00
committed by GitHub
parent b222cae56e
commit 06b0eecfe4
12 changed files with 388 additions and 82 deletions

View File

@@ -0,0 +1,90 @@
{
"id": "95ea19ba-456c-46e8-aa40-dc3ff135b746",
"revision": 0,
"last_node_id": 11,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [494.3333740234375, 142.3333282470703],
"size": [444, 399],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveInt",
"pos": [24.333343505859375, 149.6666717529297],
"size": [444, 125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [67, "randomize"]
}
],
"links": [[10, 11, 0, 10, 4, "INT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.28.6"
},
"version": 0.4
}

View File

@@ -119,4 +119,24 @@ export class VueNodeHelpers {
await this.page.waitForSelector('[data-node-id]')
}
}
/**
* Get a specific widget by node title and widget name
*/
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
return this.getNodeByTitle(nodeTitle).locator(
`_vue=[widget.name="${widgetName}"]`
)
}
/**
* Get controls for input number widgets (increment/decrement buttons and input)
*/
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').last()
}
}
}

View File

@@ -0,0 +1,42 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Integer Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should be disabled and not allow changing value when link connected to slot', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
await comfyPage.vueNodes.waitForNodes()
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
const initialValue = Number(await controls.input.inputValue())
// Verify widget is disabled when linked
await controls.incrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await controls.decrementButton.click({ force: true })
await expect(controls.input).toHaveValue(initialValue.toString())
await expect(seedWidget).toBeVisible()
// Delete the node that is linked to the slot (freeing up the widget)
await comfyPage.vueNodes.getNodeByTitle('Int').click()
await comfyPage.vueNodes.deleteSelected()
// Test widget works when unlinked
await controls.incrementButton.click()
await expect(controls.input).toHaveValue((initialValue + 1).toString())
await controls.decrementButton.click()
await expect(controls.input).toHaveValue(initialValue.toString())
})
})

View File

@@ -15,7 +15,19 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '../../lib/litegraph/src/litegraph'
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
export interface WidgetSlotMetadata {
index: number
linked: boolean
}
export interface SafeWidgetData {
name: string
@@ -25,6 +37,7 @@ export interface SafeWidgetData {
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
}
export interface VueNodeData {
@@ -68,6 +81,37 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
const refreshNodeSlots = (nodeId: string) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef || !currentData) return
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
// Update only widgets with new slot metadata, keeping other widget data intact
const updatedWidgets = currentData.widgets?.map((widget) => {
const slotInfo = slotMetadata.get(widget.name)
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
})
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets,
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -76,6 +120,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
const safeWidgets = node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
@@ -92,6 +146,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
@@ -100,7 +155,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec
spec,
slotMetadata: slotInfo
}
} catch (error) {
return {
@@ -375,7 +431,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
) => {
return () => {
// Restore original callbacks
@@ -407,29 +463,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
// Listen for property change events from instrumented nodes
graph.onTrigger = (action: string, param: unknown) => {
if (
action === 'node:property:changed' &&
param &&
typeof param === 'object'
) {
const event = param as {
nodeId: string | number
property: string
oldValue: unknown
newValue: unknown
}
const nodeId = String(event.nodeId)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
switch (event.property) {
switch (propertyEvent.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(event.newValue)
title: String(propertyEvent.newValue)
})
break
case 'flags.collapsed':
@@ -437,7 +483,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
collapsed: Boolean(propertyEvent.newValue)
}
})
break
@@ -446,22 +492,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
flags: {
...currentData.flags,
pinned: Boolean(event.newValue)
pinned: Boolean(propertyEvent.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode: typeof event.newValue === 'number' ? event.newValue : 0
mode:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof event.newValue === 'string'
? event.newValue
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
@@ -469,40 +518,38 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof event.newValue === 'string'
? event.newValue
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
}
}
} else if (
action === 'node:slot-errors:changed' &&
param &&
typeof param === 'object'
) {
const event = param as { nodeId: string | number }
const nodeId = String(event.nodeId)
const litegraphNode = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (litegraphNode && currentData) {
// Re-extract slot data with updated hasErrors properties
vueNodeData.set(nodeId, {
...currentData,
inputs: litegraphNode.inputs
? [...litegraphNode.inputs]
: undefined,
outputs: litegraphNode.outputs
? [...litegraphNode.outputs]
: undefined
})
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
}
}
// Call original trigger handler if it exists
if (originalOnTrigger) {
originalOnTrigger(action, param)
graph.onTrigger = (event: LGraphTriggerEvent) => {
switch (event.type) {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state

View File

@@ -57,6 +57,12 @@ import {
splitPositionables
} from './subgraph/subgraphUtils'
import { Alignment, LGraphEventMode } from './types/globalEnums'
import type {
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerHandler,
LGraphTriggerParam
} from './types/graphTriggers'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -68,6 +74,11 @@ import type {
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
export type {
LGraphTriggerAction,
LGraphTriggerParam
} from './types/graphTriggers'
export interface LGraphState {
lastGroupId: number
lastNodeId: number
@@ -257,7 +268,7 @@ export class LGraph
onExecuteStep?(): void
onNodeAdded?(node: LGraphNode): void
onNodeRemoved?(node: LGraphNode): void
onTrigger?(action: string, param: unknown): void
onTrigger?: LGraphTriggerHandler
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
onConnectionChange?(node: LGraphNode): void
@@ -1183,8 +1194,23 @@ export class LGraph
}
// ********** GLOBALS *****************
trigger<A extends LGraphTriggerAction>(
action: A,
param: LGraphTriggerParam<A>
): void
trigger(action: string, param: unknown): void
trigger(action: string, param: unknown) {
this.onTrigger?.(action, param)
// Convert to discriminated union format for typed handlers
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed'
])
if (validEventTypes.has(action) && param && typeof param === 'object') {
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
}
// Don't handle unknown events - just ignore them
}
/** @todo Clean up - never implemented. */

View File

@@ -2851,7 +2851,17 @@ export class LGraphNode
output.links ??= []
output.links.push(link.id)
// connect in input
inputNode.inputs[inputIndex].link = link.id
const targetInput = inputNode.inputs[inputIndex]
targetInput.link = link.id
if (targetInput.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: inputNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
@@ -3008,6 +3018,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3044,6 +3063,15 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
@@ -3113,6 +3141,15 @@ export class LGraphNode
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
// remove other side
const link_info = graph._links.get(link_id)

View File

@@ -102,7 +102,12 @@ export type {
Positionable,
Size
} from './interfaces'
export { LGraph } from './LGraph'
export {
LGraph,
type LGraphTriggerAction,
type LGraphTriggerParam
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'
export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'

View File

@@ -0,0 +1,38 @@
import type { NodeId } from '../LGraphNode'
import type { NodeSlotType } from './globalEnums'
interface NodePropertyChangedEvent {
type: 'node:property:changed'
nodeId: NodeId
property: string
oldValue: unknown
newValue: unknown
}
interface NodeSlotErrorsChangedEvent {
type: 'node:slot-errors:changed'
nodeId: NodeId
}
interface NodeSlotLinksChangedEvent {
type: 'node:slot-links:changed'
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
export type LGraphTriggerEvent =
| NodePropertyChangedEvent
| NodeSlotErrorsChangedEvent
| NodeSlotLinksChangedEvent
export type LGraphTriggerAction = LGraphTriggerEvent['type']
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
LGraphTriggerEvent,
{ type: A }
>
export type LGraphTriggerHandler = (event: LGraphTriggerEvent) => void

View File

@@ -95,19 +95,19 @@ export function useSlotLayoutSync() {
}
}
graph.onTrigger = (action: string, param: any) => {
graph.onTrigger = (event) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
event.type === 'node:property:changed' &&
event.property === 'flags.collapsed'
) {
const node = graph.getNodeById(parseInt(String(param.nodeId)))
const node = graph.getNodeById(parseInt(String(event.nodeId)))
if (node) {
computeAndRegisterSlots(node)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
// Chain to original handler
origTrigger?.(event)
}
graph.onAfterChange = (graph: any, node?: any) => {

View File

@@ -33,7 +33,7 @@
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="getWidgetInputIndex(widget)"
:index="widget.slotMetadata?.index ?? 0"
:dot-only="true"
/>
</div>
@@ -56,12 +56,12 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
@@ -110,6 +110,7 @@ interface ProcessedWidget {
value: WidgetValue
updateHandler: (value: unknown) => void
tooltipConfig: any
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
@@ -126,12 +127,24 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const vueComponent = getComponent(widget.type) || WidgetInputText
const slotMetadata = widget.slotMetadata
let widgetOptions = widget.options
// Core feature: Disable Vue widgets when their input slots are connected
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) {
widgetOptions = widget.options
? { ...widget.options, disabled: true }
: { disabled: true }
}
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value: widget.value,
label: widget.label,
options: widget.options,
options: widgetOptions,
callback: widget.callback,
spec: widget.spec
}
@@ -152,25 +165,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
simplified,
value: widget.value,
updateHandler,
tooltipConfig
tooltipConfig,
slotMetadata
})
}
return result
})
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
// or restructuring data model to unify widgets and inputs
// Map a widget to its corresponding input slot index
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
const inputs = nodeData?.inputs
if (!inputs) return 0
const idx = inputs.findIndex((input: any) => {
if (!input || typeof input !== 'object') return false
if (!('name' in input && 'type' in input)) return false
return 'widget' in input && input.widget?.name === widget.name
})
return idx >= 0 ? idx : 0
}
</script>

View File

@@ -90,6 +90,7 @@ const buttonTooltip = computed(() => {
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
:pt="{
incrementButton:
'!rounded-r-lg bg-transparent border-none hover:bg-zinc-500/30 active:bg-zinc-500/40',

View File

@@ -283,6 +283,7 @@ LGraph {
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
"revision": 0,
"runningtime": 0,
"starttime": 0,