diff --git a/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts b/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts index 0f35bfb6e1..11c8b646ec 100644 --- a/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts @@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => { await comfyPage.page.evaluate(() => { const graph = window.graph as TestGraphAccess const node = graph._nodes_by_id['4'] - node.widgets!.push(node.widgets![0]) + node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' }) }) await expect(loadCheckpointNode).toHaveCount(2) await comfyPage.page.evaluate(() => { const graph = window.graph as TestGraphAccess const node = graph._nodes_by_id['4'] - node.widgets![2] = node.widgets![0] + node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' } }) await expect(loadCheckpointNode).toHaveCount(3) await comfyPage.page.evaluate(() => { const graph = window.graph as TestGraphAccess const node = graph._nodes_by_id['4'] - node.widgets!.splice(0, 0, node.widgets![0]) + node.widgets!.splice(0, 0, { + ...node.widgets![0], + name: 'added_widget_3' + }) }) await expect(loadCheckpointNode).toHaveCount(4) }) diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts index 0716570855..ed4ac4b64a 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts @@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => { expect(renderKey).toBe(dedupeIdentity) }) - it('returns transient renderKey for widgets without stable identity', () => { + it('falls back to host nodeId so duplicate normal widgets dedupe', () => { const widget = createMockWidget({ nodeId: undefined, storeNodeId: undefined, sourceExecutionId: undefined }) const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3) + expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo') + expect(renderKey).toBe(dedupeIdentity) + }) + + it('returns transient renderKey when no nodeId is available at all', () => { + const widget = createMockWidget({ + nodeId: undefined, + storeNodeId: undefined, + sourceExecutionId: undefined + }) + const { dedupeIdentity, renderKey } = getWidgetIdentity( + widget, + undefined, + 3 + ) expect(dedupeIdentity).toBeUndefined() - expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3') + expect(renderKey).toBe('transient::test_widget:test_widget:combo:3') }) it('uses sourceExecutionId for identity when no nodeId', () => { @@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => { expect(result).toHaveLength(1) expect(result[0].hidden).toBe(false) }) + + it('collapses duplicate normal widgets on the same node to one render', () => { + const colorA = createMockWidget({ + name: 'color', + type: 'color', + nodeId: undefined, + storeNodeId: undefined, + sourceExecutionId: undefined + }) + const colorB = createMockWidget({ + name: 'color', + type: 'color', + nodeId: undefined, + storeNodeId: undefined, + sourceExecutionId: undefined + }) + + const result = computeProcessedWidgets({ + nodeData: { + id: '1', + type: 'ColorToRGBInt', + widgets: [colorA, colorB], + title: 'Color to RGB Int', + mode: 0, + selected: false, + executing: false, + inputs: [], + outputs: [] + }, + graphId: 'graph-test', + showAdvanced: false, + isGraphReady: false, + rootGraph: null, + ui: noopUi + }) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('color') + expect(result[0].renderKey).toBe('node:1:color:color:color') + }) }) describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => { diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts index c1b11ed571..a51382aede 100644 --- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts +++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts @@ -129,11 +129,15 @@ export function getWidgetIdentity( const rawWidgetId = widget.storeNodeId ?? widget.nodeId const storeWidgetName = widget.storeName ?? widget.name const slotNameForIdentity = widget.slotName ?? widget.name + const hostNodeIdRoot = + nodeId !== undefined && nodeId !== '' + ? `node:${String(stripGraphPrefix(nodeId))}` + : undefined const stableIdentityRoot = rawWidgetId ? `node:${String(stripGraphPrefix(rawWidgetId))}` : widget.sourceExecutionId ? `exec:${widget.sourceExecutionId}` - : undefined + : hostNodeIdRoot const dedupeIdentity = stableIdentityRoot ? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}` diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.test.ts new file mode 100644 index 0000000000..93d06d2382 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + IColorWidget, + IWidgetOptions +} from '@/lib/litegraph/src/types/widgets' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget' + +function createMockNode(): LGraphNode { + const widgets: IColorWidget[] = [] + const addWidget = vi.fn( + ( + type: string, + name: string, + value: string, + _callback: () => void, + options: IWidgetOptions + ) => { + const widget = { + type, + name, + value, + options, + callback: _callback + } as unknown as IColorWidget + widgets.push(widget) + return widget + } + ) + + return { widgets, addWidget } as unknown as LGraphNode +} + +const colorSpec: InputSpec = { + type: 'COLOR', + name: 'color', + default: '#ffffff', + socketless: true +} + +describe('useColorWidget', () => { + it('reads the top-level default from the V2 spec', () => { + const node = createMockNode() + const widget = useColorWidget()(node, colorSpec) + expect(widget.value).toBe('#ffffff') + }) + + it('falls back to nested options.default when top-level default is absent', () => { + const node = createMockNode() + const widget = useColorWidget()(node, { + type: 'COLOR', + name: 'color', + options: { default: '#abcdef' } + } as InputSpec) + expect(widget.value).toBe('#abcdef') + }) + + it('falls back to #000000 when no default is declared', () => { + const node = createMockNode() + const widget = useColorWidget()(node, { + type: 'COLOR', + name: 'color' + } as InputSpec) + expect(widget.value).toBe('#000000') + }) + + it('returns the existing widget instead of creating a duplicate', () => { + const node = createMockNode() + const first = useColorWidget()(node, colorSpec) + const second = useColorWidget()(node, colorSpec) + expect(second).toBe(first) + expect(node.widgets).toHaveLength(1) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts index 420e31ce05..c4c45a562f 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts @@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' export const useColorWidget = (): ComfyWidgetConstructorV2 => { return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => { - const { name, options } = inputSpec as ColorInputSpec - const defaultValue = options?.default || '#000000' + const colorSpec = inputSpec as ColorInputSpec + const { name, options } = colorSpec + const defaultValue = colorSpec.default ?? options?.default ?? '#000000' + + const existing = node.widgets?.find( + (w): w is IColorWidget => w.name === name && w.type === 'color' + ) + if (existing) return existing const widget = node.addWidget('color', name, defaultValue, () => {}, { serialize: true