Compare commits

...

6 Commits

Author SHA1 Message Date
dante01yoon
0e7aa71733 test: type graph access in multi_select e2e 2026-04-24 18:52:55 +09:00
GitHub Action
37e87d44f3 [automated] Apply ESLint and Oxfmt fixes 2026-04-24 09:31:25 +00:00
dante01yoon
5de7935928 fix: stabilize multi_select component widgets 2026-04-24 18:28:35 +09:00
dante01yoon
aa5ba10ad6 docs: FE-233 attach before/after screenshots for the multi_select fix
Captured against `DevToolsMultiSelectNode` in ComfyUI_devtools with Vue
Nodes enabled (`Comfy.VueNodes.Enabled: true`) on:

  * `main` — widget renders as an invisible `WidgetLegacy` canvas fallback.
  * `fix/fe-233-multiselect-combo-vue-nodes` — `WidgetComponent` resolves
    the ComponentWidget and mounts `MultiSelectWidget.vue` (PrimeVue
    MultiSelect) with the "Choose foos" placeholder from the node's
    `multi_select.placeholder`.

Screenshots live under `temp/fe-233/` for PR review and can be removed
before merge. Kept force-added so the branch carries visual proof
alongside the red-green CI evidence.
2026-04-21 11:58:35 +09:00
dante01yoon
ad6d0f08f6 fix: FE-233 render multi_select combo widgets in Nodes 2.0
Vue Nodes routed ComponentWidget instances through the widget registry (no
entry for type='custom') and DOMWidget fallback (no .element), so
multi_select combos landed on WidgetLegacy — a canvas widget that expects
a .draw() method ComponentWidgets don't provide. Add a WidgetComponent
wrapper that resolves the host-node ComponentWidget and mounts its own
Vue component, and branch on a new isComponentWidget flag in the widget
resolver ahead of the DOM/legacy fallback.

Fixes FE-233
Source: Slack https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776737678080949
2026-04-21 11:52:38 +09:00
dante01yoon
340a0b7d23 test: FE-233 add failing spec for multi_select combo widgets in Nodes 2.0
Reproduces the #bug-dump report from Kosinkadink using ComfyUI_devtools'
`DevToolsMultiSelectNode`: Vue Nodes routes ComponentWidget instances
(type=custom, no DOM element) through WidgetLegacy, which renders a blank
canvas instead of the PrimeVue MultiSelect component. The new e2e asserts
an interactive multi-select trigger and that picking two options yields an
array value. The test is expected to fail on main until the fix lands.

Fixes FE-233
Source: Slack https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776737678080949
2026-04-21 11:47:11 +09:00
6 changed files with 216 additions and 8 deletions

View File

@@ -0,0 +1,108 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
// Repro lifted from #bug-dump (Jedrzej Kosinski, 2026-04-21): `multi_select`
// combo widgets declared with `ComfyUI_devtools`'s `DevToolsMultiSelectNode`
// never render as a multi-select control in Nodes 2.0. Vue Nodes routes
// ComponentWidget instances (widget.type === 'custom', no DOM element) to
// `WidgetLegacy`, which expects a canvas `draw` callback the component widget
// does not provide — so the node renders a blank canvas widget instead of
// PrimeVue's MultiSelect.
const multiSelectWorkflow: ComfyWorkflowJSON = {
id: 'fe-233-multi-select-regression',
revision: 0,
last_node_id: 1,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'DevToolsMultiSelectNode',
pos: [200, 200],
size: [360, 140],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'STRING',
type: 'STRING',
links: null
}
],
properties: { 'Node name for S&R': 'DevToolsMultiSelectNode' },
widgets_values: [[]]
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
test.describe(
'FE-233 multi_select combo regression',
{ tag: ['@vue-nodes', '@regression'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadGraphData(multiSelectWorkflow)
await comfyPage.vueNodes.waitForNodes(1)
})
test('renders an interactive multi-select widget and accepts multiple values', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Multi Select Node')
// The multi_select widget must surface as an interactive control with
// an accessible label. In the broken state Vue Nodes renders this as a
// legacy canvas widget (no accessible control, no selectable options),
// so the trigger never appears.
await expect(
node.getByRole('combobox', { name: 'foo', exact: true })
).toHaveCount(1)
const trigger = node.getByText('Choose foos', { exact: true })
await expect(trigger.first()).toBeVisible({ timeout: 3000 })
await trigger.first().click()
await comfyPage.page
.getByRole('option', { name: 'A', exact: true })
.click()
await expect(
comfyPage.page.getByRole('option', { name: 'B', exact: true })
).toBeVisible()
await comfyPage.page
.getByRole('option', { name: 'B', exact: true })
.click()
await comfyPage.page.keyboard.press('Escape')
// Underlying widget value must be an array holding both selections.
// In the broken state the value is either untouched (empty array / no
// widget) or collapses to a single string under single-select semantics.
const widgetValue = await comfyPage.page.evaluate(() => {
function hasTestGraphAccess(graph: unknown): graph is TestGraphAccess {
return (
typeof graph === 'object' &&
graph !== null &&
'_nodes_by_id' in graph
)
}
if (!hasTestGraphAccess(window.graph)) return null
const multiSelectNode = Object.values(window.graph._nodes_by_id).find(
(n) => n.type === 'DevToolsMultiSelectNode'
)
return multiSelectNode?.widgets?.[0]?.value ?? null
})
expect(Array.isArray(widgetValue)).toBe(true)
expect(widgetValue).toEqual(expect.arrayContaining(['A', 'B']))
})
}
)

View File

@@ -7,6 +7,7 @@
:placeholder="placeholder"
:max-selected-labels="3"
:display="display"
:aria-label="widget.name"
class="w-full"
:pt="{
dropdownIcon: 'text-button-icon'

View File

@@ -246,6 +246,46 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('marks promoted component widgets as component-backed instead of DOM-backed', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
const componentWidget = interiorNode.addWidget(
'custom',
'foo',
[],
() => undefined,
{}
)
Object.defineProperties(componentWidget, {
component: { value: {}, enumerable: true },
id: { value: 'comp-widget-foo', enumerable: true }
})
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'foo',
'foo',
undefined,
'foo'
)
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'foo')
expect(widgetData?.isComponentWidget).toBe(true)
expect(widgetData?.isDOMWidget).toBe(false)
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [

View File

@@ -22,7 +22,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
@@ -66,6 +66,12 @@ export interface SafeWidgetData {
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Whether widget is a Vue component-backed widget (e.g. `multi_select`
* combos). Rendered through `WidgetComponent` so the widget's own Vue
* component is mounted inside the Nodes 2.0 tree.
*/
isComponentWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
@@ -142,11 +148,15 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
return isDOMWidget(sourceWidget.widget)
}
function isPromotedComponentWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
return isComponentWidget(sourceWidget.widget)
}
export function getControlWidget(
@@ -318,6 +328,8 @@ function safeWidgetMapper(
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const isComponentBackedWidget =
isComponentWidget(effectiveWidget) || isPromotedComponentWidget(widget)
const localId = isPromotedWidgetView(widget)
? String(
@@ -342,7 +354,10 @@ function safeWidgetMapper(
...sharedEnhancements,
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
isDOMWidget:
!isComponentBackedWidget &&
(isDOMWidget(effectiveWidget) || isPromotedDOMWidget(widget)),
isComponentWidget: isComponentBackedWidget,
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),

View File

@@ -16,6 +16,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetComponent from '@/renderer/extensions/vueNodes/widgets/components/WidgetComponent.vue'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
@@ -259,7 +260,11 @@ export function computeProcessedWidgets({
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
(widget.isComponentWidget
? WidgetComponent
: widget.isDOMWidget
? WidgetDOM
: WidgetLegacy)
const { slotMetadata } = widget

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
import { isComponentWidget } from '@/scripts/domWidget'
import type { ComponentWidget } from '@/scripts/domWidget'
import { app } from '@/scripts/app'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
const props = defineProps<{
widget: SimplifiedWidget<WidgetValue>
nodeId: string
}>()
const modelValue = defineModel<WidgetValue>()
const componentWidget = computed<ComponentWidget<object | string> | undefined>(
() => {
const locatorId = props.widget.nodeLocatorId ?? props.nodeId
const hostNode = app.rootGraph
? (getNodeByLocatorId(app.rootGraph, locatorId) ?? undefined)
: undefined
const resolved = resolveWidgetFromHostNode(hostNode, props.widget.name)
if (resolved && isComponentWidget(resolved.widget)) return resolved.widget
return undefined
}
)
</script>
<template>
<component
:is="componentWidget.component"
v-if="componentWidget"
v-model="modelValue"
:widget="componentWidget"
v-bind="componentWidget.props"
/>
</template>