-
Notifications
You must be signed in to change notification settings - Fork 410
Add support for dynamic widgets #6661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
🎨 Storybook Build Status✅ Build completed successfully! ⏰ Completed at: 11/20/2025, 03:59:18 AM UTC 🔗 Links🎉 Your Storybook is ready for review! |
🎭 Playwright Test Results⏰ Completed at: 11/20/2025, 05:18:07 AM UTC 📈 Summary
📊 Test Reports by Browser
🎉 Click on the links above to view detailed test results for each browser configuration. |
Bundle Size ReportSummary
Category Glance Per-category breakdownApp Entry Points — 3.12 MB (baseline 3.12 MB) • 🟢 -2.26 kBMain entry bundles and manifests
Status: 3 added / 3 removed Graph Workspace — 925 kB (baseline 925 kB) • ⚪ 0 BGraph editor runtime, canvas, workflow orchestration
Status: 1 added / 1 removed Views & Navigation — 7.97 kB (baseline 7.97 kB) • ⚪ 0 BTop-level views, pages, and routed surfaces
Status: 1 added / 1 removed Panels & Settings — 306 kB (baseline 306 kB) • ⚪ 0 BConfiguration panels, inspectors, and settings screens
Status: 6 added / 6 removed UI Components — 141 kB (baseline 141 kB) • ⚪ 0 BReusable component library chunks
Status: 6 added / 6 removed Data & Services — 12.5 kB (baseline 12.5 kB) • ⚪ 0 BStores, services, APIs, and repositories
Status: 2 added / 2 removed Utilities & Hooks — 2.94 kB (baseline 2.94 kB) • ⚪ 0 BHelpers, composables, and utility bundles
Status: 1 added / 1 removed Vendor & Third-Party — 5.32 MB (baseline 5.32 MB) • ⚪ 0 BExternal libraries and shared vendor chunks
Other — 3.87 MB (baseline 3.87 MB) • ⚪ 0 BBundles that do not match a named category
Status: 18 added / 18 removed |
doesn't correctly apply on initial load
Still includes some tempoary implementations and seems to have an issue with flawed logic for removing inputs
When a node is freshly added to a workflow, there's not a configure event and the widgets absed on the initial state were not applied
Callback is usually, but not always triggered when the value changes. This had two downsides: - The dynamic Combo code would usually apply twice on value change - If there was any code that also added callbacks to the widget, it would be applied multiple times. Both of these are fixed by not setting the widget change code as a callback.
litegraphService had a very large amount of duplicated code that was private methods. Private methods are awful and prevent reuse. These methods have all been moved to anonymous functions with no duplication. The primary purpose of this is to expose a single addNodeInput function for use in dynamic widget code. The battle to end duck violence is long and hard fought.
The functionality is dumb and wrong, but required to keep expected sizing behaviour for nodes like Clip Text Encode
a395bd1 to
756e391
Compare
| widget.value = info.widgets_values[i] | ||
| const widgetsWithValue = this.widgets | ||
| .values() | ||
| .filter((w) => w.serialize !== false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So if serialize is undefined we want to treat it as true?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, but we need exact equals here. We can't use !w.serialize
christian-byrne
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I were to try to refactor the dynamicWidgets.ts file with the goal of making it as expressive as possible, it would look something like this:
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
/**
* Validates and normalizes the dynamic combo input spec.
*/
function parseDynamicComboInputSpec(untypedInputData: InputSpec) {
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
return parseResult.data
}
/**
* Builds:
* - optionKeys: the list of keys shown in the base COMBO widget
* - optionInputsByKey: key → structured input spec for that option
*
* Key point: unlike a normal combo, each option maps to an object of input specs.
*/
function buildDynamicOptionIndex(dynamicComboSpec: unknown) {
const [, dynamicOptions] = dynamicComboSpec as {
1: { options: Array<{ key: string; inputs: unknown }> }
}
const optionInputsByKey = Object.fromEntries(
dynamicOptions.options.map((option) => [option.key, option.inputs])
)
const optionKeys = dynamicOptions.options.map((option) => option.key)
return { optionInputsByKey, optionKeys }
}
/**
* Removes all inputs and widgets previously added for dynamic options.
*/
function clearDynamicInputsAndWidgets(
node: LGraphNode,
dynamicNames: string[]
) {
for (const name of dynamicNames) {
const inputIndex = node.inputs.findIndex((input) => input.name === name)
if (inputIndex !== -1) node.removeInput(inputIndex)
if (!node.widgets) continue
const widgetIndex = node.widgets.findIndex((widget) => widget.name === name)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
return [] as string[]
}
/**
* Adds inputs and widgets for the selected option and keeps them adjacent
* to the base combo widget, preserving input order as much as possible.
*/
function addDynamicInputsAndWidgets(
node: LGraphNode,
baseWidget: any,
newSpec: any,
addNodeInput: (node: LGraphNode, spec: InputSpec) => void,
currentDynamicNames: string[]
) {
if (!node.widgets) throw new Error('Not Reachable')
const insertionPoint = node.widgets.findIndex((w) => w === baseWidget) + 1
const startingWidgetCount = node.widgets.length
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === baseWidget.name) + 1
const startingInputCount = node.inputs.length
if (insertionPoint === 0) {
throw new Error("Dynamic widget doesn't exist on node")
}
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
]
for (const [inputType, isOptional] of inputTypes) {
for (const name in inputType ?? {}) {
addNodeInput(
node,
transformInputSpecV1ToV2(inputType![name], {
name,
isOptional
})
)
currentDynamicNames.push(name)
}
}
const addedWidgets = node.widgets.splice(startingWidgetCount)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
if (inputInsertionPoint === 0) {
if (addedWidgets.length === 0 && node.inputs.length !== startingInputCount) {
// input is inputOnly, but lacks an insertion point
throw new Error('Failed to find input socket for ' + baseWidget.name)
}
return currentDynamicNames
}
const addedInputs = node
.spliceInputs(startingInputCount)
.map((addedInput) => {
const existingIndex = node.inputs.findIndex(
(existingInput) => addedInput.name === existingInput.name
)
return existingIndex === -1
? addedInput
: node.spliceInputs(existingIndex, 1)[0]
})
// assume existing inputs are in correct order
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
node.size[1] = node.computeSize([...node.size])[1]
return currentDynamicNames
}
/**
* Wraps a combo widget so that updating its value runs the provided updater.
*/
function attachDynamicUpdateToWidget(
widget: any,
updateWidgets: (value?: string) => void
) {
let widgetValue = widget.value
Object.defineProperty(widget, 'value', {
get() {
return widgetValue
},
set(value) {
widgetValue = value
updateWidgets(value)
}
})
// trigger once with initial value
updateWidgets(widgetValue)
}
function dynamicComboWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
appArg: ComfyApp,
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()
const dynamicComboSpec = parseDynamicComboInputSpec(untypedInputData)
const { optionInputsByKey, optionKeys } =
buildDynamicOptionIndex(dynamicComboSpec)
// The base COMBO only needs the list of option keys.
const comboSpecForBaseWidget: ComboInputSpec = [optionKeys, {}]
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
node,
inputName,
comboSpecForBaseWidget,
appArg,
widgetName
)
let currentDynamicNames: string[] = []
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const selectedSpec = value ? optionInputsByKey[value] : undefined
currentDynamicNames = clearDynamicInputsAndWidgets(
node,
currentDynamicNames
)
if (!selectedSpec) return
currentDynamicNames = addDynamicInputsAndWidgets(
node,
widget,
selectedSpec,
addNodeInput,
currentDynamicNames
)
}
attachDynamicUpdateToWidget(widget, updateWidgets)
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }| Object.defineProperty(widget, 'value', { | ||
| get() { | ||
| return widgetValue | ||
| }, | ||
| set(value) { | ||
| widgetValue = value | ||
| updateWidgets(value) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the callback property doesn't work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep. Callback also isn't triggered after loading widgets_values, which means dynamic widgets wouldn't get created when loading a saved workflow.
| widgetName | ||
| ) | ||
| let currentDynamicNames: string[] = [] | ||
| const updateWidgets = (value?: string) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The semantics of updateWidget(value?: string): void and const newSpec = value ? options[value] : undefined are somewhat confusing. So this function is called when a widget is updated with a new value, but also updates other widgets. And the new spec is derived from an options object which is addressed by the value?
Maybe what the options and value objects represent could be clearer somehow .
| const options = Object.fromEntries( | ||
| inputData[1].options.map(({ key, inputs }) => [key, inputs]) | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we could communicate more about the data structure and semantics of the system via variable names, helper functions, code flow, etc.
|
This code looks good, is there a comfyanonymous/ComfyUI branch I can test with? |
|
Core branch is v3-dynamic-combo. |
|
The test seems to be failing. |
|
Got bamboozled by the green checkmark on the last code commit and assumed flake. Should be fixed now. |
Adds support for "dynamic combo" widgets where selecting a value on a combo widget can cause other widgets or inputs to be created.
Includes a fairly large refactoring in litegraphService to remove
#privatemethods and cleanup some duplication in constructors for subgraphNodes.┆Issue is synchronized with this Notion page by Unito