Compare commits

..

1 Commits

Author SHA1 Message Date
AustinMroz
13b42d9b59 Ensure dynamic combo children cleanup state (#13073)
#12617 introduced a regression in Dynamic Combos. If two options have
child widgets of the same name (such as `bit_depth` on `Save Image
(Advanced)`), then widget state would be incorrectly shared between the
two widgets.

This is resolved by having removed widgets also delete their state.

There was previous interest in having widgets of this type keep state
when valid. This interest remains, but will require a more controlled
intentional implementation in the future.

Since the bit depth options on `Save Image (Advanced)` could potentially
be expanded in the future, this PR specifically adds a new devtools node
for testing with.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 01:08:06 +00:00
3 changed files with 43 additions and 1 deletions

View File

@@ -73,4 +73,16 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
test('Dynamic children have separate state', async ({ comfyPage }) => {
const nodeName = 'Node With Dynamic Combo'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 150 }
})
const child = comfyPage.vueNodes.getWidgetByName(nodeName, 'suboption')
await expect(child, 'initial state').toHaveText('1x')
await comfyPage.vueNodes.selectComboOption(nodeName, 'combo', 'option2')
await expect(child, 'child of same name has new state').toHaveText('2x')
})
})

View File

@@ -77,6 +77,7 @@ function dynamicComboWidget(
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()
const { deleteWidget } = useWidgetValueStore()
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
const inputData = parseResult.data
@@ -99,7 +100,10 @@ function dynamicComboWidget(
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
for (const widget of remove(node.widgets, isInGroup)) {
widget.onRemove?.()
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return

View File

@@ -343,6 +343,30 @@ class NodeWithPriceBadge(IO.ComfyNode):
async def execute(cls, price):
return IO.NodeOutput()
class NodeWithDynamicCombo(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="DevToolsNodeWithDynamicCombo",
display_name="Node With Dynamic Combo",
description="A node with a Dynamic combo",
inputs=[IO.DynamicCombo.Input("combo", options=[
IO.DynamicCombo.Option("option1", [IO.Combo.Input("suboption", options=["1x"])]),
IO.DynamicCombo.Option("option2", [IO.Combo.Input("suboption", options=["2x"])]),
IO.DynamicCombo.Option("option3", [IO.Image.Input("image")]),
IO.DynamicCombo.Option("option4", [
IO.DynamicCombo.Input("subcombo", options=[
IO.DynamicCombo.Option("opt1", [IO.Float.Input("float_x"), IO.Float.Input("float_y")]),
IO.DynamicCombo.Option("opt2", [IO.Mask.Input("mask1", optional=True)]),
])
])]
)],
)
@classmethod
async def execute(cls):
return IO.NodeOutput()
NODE_CLASS_MAPPINGS = {
"DevToolsLongComboDropdown": LongComboDropdown,
@@ -361,6 +385,7 @@ NODE_CLASS_MAPPINGS = {
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
"DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget,
"DevToolsNodeWithPriceBadge": NodeWithPriceBadge,
"DevToolsNodeWithDynamicCombo": NodeWithDynamicCombo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -380,6 +405,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
"DevToolsNodeWithLegacyWidget": "Node With Legacy Widget",
"DevToolsNodeWithPriceBadge": "Node With Price Badge",
"DevToolsNodeWithDynamicCombo": "Node With Dynamic Combo",
}
__all__ = [