mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
6 Commits
ext-api/i-
...
fix/fe-233
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7aa71733 | ||
|
|
37e87d44f3 | ||
|
|
5de7935928 | ||
|
|
aa5ba10ad6 | ||
|
|
ad6d0f08f6 | ||
|
|
340a0b7d23 |
@@ -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']))
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -7,6 +7,7 @@
|
||||
:placeholder="placeholder"
|
||||
:max-selected-labels="3"
|
||||
:display="display"
|
||||
:aria-label="widget.name"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
dropdownIcon: 'text-button-icon'
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user