fix: scope custom combo copy-paste restore to extension

This commit is contained in:
dante01yoon
2026-04-07 13:56:45 +09:00
parent 22ca0bbb7f
commit 81ac6d2ce4
4 changed files with 108 additions and 77 deletions

View File

@@ -0,0 +1,102 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
class TestCustomComboNode extends LGraphNode {
static override title = 'CustomCombo'
constructor() {
super('CustomCombo')
this.serialize_widgets = true
this.addOutput('value', '*')
this.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
}
}
function findWidget(node: LGraphNode, name: string) {
return node.widgets?.find((widget) => widget.name === name)
}
function getCustomWidgetsExtension(): ComfyExtension {
const extension = useExtensionStore().extensions.find(
(candidate) => candidate.name === 'Comfy.CustomWidgets'
)
if (!extension) {
throw new Error('Comfy.CustomWidgets extension was not registered')
}
return extension
}
describe('CustomCombo copy/paste', () => {
beforeAll(async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
await import('./customWidgets')
const extension = getCustomWidgetsExtension()
await extension.beforeRegisterNodeDef?.(
TestCustomComboNode,
{ name: 'CustomCombo' } as ComfyNodeDef,
app
)
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
})
afterAll(() => {
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
})
it('preserves combo options and selected value through clone and paste', () => {
const graph = new LGraph()
const appWithRootGraph = app as typeof app & { rootGraphInternal?: LGraph }
const previousRootGraph = appWithRootGraph.rootGraphInternal
appWithRootGraph.rootGraphInternal = graph
try {
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
graph.add(original)
findWidget(original, 'option1')!.value = 'alpha'
findWidget(original, 'option2')!.value = 'beta'
findWidget(original, 'option3')!.value = 'gamma'
findWidget(original, 'value')!.value = 'beta'
const clonedSerialised = original.clone()?.serialize()
expect(clonedSerialised).toBeDefined()
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
pasted.configure(clonedSerialised!)
graph.add(pasted)
expect(findWidget(pasted, 'value')!.value).toBe('beta')
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
expect(findWidget(pasted, 'value')!.options.values).toEqual([
'alpha',
'beta',
'gamma'
])
} finally {
appWithRootGraph.rootGraphInternal = previousRootGraph
}
})
})

View File

@@ -78,16 +78,17 @@ function onCustomComboCreated(this: LGraphNode) {
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,

View File

@@ -1,53 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('Widget options.values serialization', () => {
it('preserves combo options.values through serialize/configure round-trip', () => {
const graph = new LGraph()
// Create node with combo + option widgets manually
const node = new LGraphNode('TestCombo')
node.serialize_widgets = true
node.addWidget('combo', 'value', 'alpha', () => {}, {
values: ['alpha', 'beta', 'gamma']
})
node.addWidget('string', 'option1', 'alpha', () => {})
node.addWidget('string', 'option2', 'beta', () => {})
node.addWidget('string', 'option3', 'gamma', () => {})
graph.add(node)
// Serialize
const serialized = node.serialize()
// Create fresh node and configure
const restored = new LGraphNode('TestCombo')
restored.serialize_widgets = true
const restoredCombo = restored.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
restored.addWidget('string', 'option1', '', () => {})
restored.addWidget('string', 'option2', '', () => {})
restored.addWidget('string', 'option3', '', () => {})
graph.add(restored)
restored.configure(serialized)
// Widget values should be restored
expect(restored.widgets![0].value).toBe('alpha')
expect(restored.widgets![1].value).toBe('alpha')
expect(restored.widgets![2].value).toBe('beta')
expect(restored.widgets![3].value).toBe('gamma')
const restoredValues = restoredCombo.options.values as string[]
expect(restoredValues).toContain('alpha')
expect(restoredValues).toContain('beta')
expect(restoredValues).toContain('gamma')
})
})

View File

@@ -920,25 +920,6 @@ export class LGraphNode
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
// Rebuild combo options.values from sibling option widgets.
// widget.options.values is not serialized, so combo widgets
// lose their option list after serialize/configure (e.g. copy-paste).
for (const widget of this.widgets ?? []) {
if (widget.type !== 'combo') continue
const values = widget.options.values
if (!Array.isArray(values) || values.length > 0) continue
const optionValues = (this.widgets ?? [])
.filter(
(w) =>
w.name.startsWith('option') &&
w.value !== undefined &&
w.value !== ''
)
.map((w) => `${w.value}`)
if (optionValues.length > 0) values.push(...optionValues)
}
}
}