12 KiB
Converting widget-array mutation and the converted-widget protocol
Accessor style. The published handles follow
src/types/extensionV2.ts(PR #11251): reads and writes are methods, not properties —widget.getValue()/setValue(v),isHidden()/setHidden(b),getOptions()/setOption(key, value),setLabel(s), andwidgetTypefor the type. Node handles keepgetTitle()/setTitle()style likewise.
The largest cohort by pack count, and the one where naive conversions are most likely to be silently wrong.
| Surface | Packs | Installs |
|---|---|---|
widgets.splice |
286 | 21.6% |
widget.type overwrite |
270 | 18.6% |
| converted-widget protocol | 238 | 25.1% |
widgets = [...] |
226 | 10.9% |
widgets.push |
142 | — |
getCustomWidgets POJO |
91 | 16.8% |
widgets.length = 0 |
31 | — |
These overlap heavily; the same pack usually hits several.
Classify before converting — splice is often not a reorder
// ComfyUI-KJNodes setgetnodes.js:988
// Fresh options object (live getter preserved) + remove/re-add to force
// Vue re-extraction.
w.options = newOpts
const idx = this.widgets.indexOf(w)
if (idx >= 0) {
this.widgets.splice(idx, 1)
this.widgets.splice(idx, 0, w)
}
Removed and reinserted at the same index — the array is unchanged. This is cache invalidation, not reordering. It converts to:
node.widgets.get(name).setOption('values', newValues)
The hack disappears: invalidation is the API's problem now. A converter that assumes "splice means reorder" produces nonsense here.
Read the indices before choosing a rule. Same index in and out → invalidation.
Different index → a real move (widgets.move). A full rewrite of the array →
widgets.reorder.
The converted-widget protocol — ~20 lines become one property
The full pattern, from ComfyUI-Easy-Use easyExtraMenu.js:339:
const CONVERTED_TYPE = 'converted-widget'
function hideWidget(node, widget, suffix = '') {
widget.origType = widget.type
widget.origComputeSize = widget.computeSize
widget.origSerializeValue = widget.serializeValue
widget.computeSize = () => [0, -4] // -4 offsets litegraph's inter-widget gap
widget.type = CONVERTED_TYPE + suffix
widget.serializeValue = () => {
if (!node.inputs) return undefined
const input = node.inputs.find((i) => i.widget?.name === widget.name)
if (!input || !input.link) return undefined // unlinked → do not serialize
return widget.origSerializeValue
? widget.origSerializeValue()
: widget.value
}
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) hideWidget(node, w, ':' + widget.name)
}
}
Everything above exists to emulate a property that did not exist:
node.widgets.get(name).hidden = true
Gone with it: the origType/origComputeSize/origSerializeValue save-and-
restore dance, the [0, -4] magic number, and the type-string mangling.
⚠️ The trap: hidden does not imply "do not serialize"
The old hack coupled two things. Hiding a widget also installed a
serializeValue that returned undefined unless the matching input was linked.
In the published API those are orthogonal — hidden is presentation,
serialize is persistence. That is the better design, but it means a literal
one-line conversion changes behaviour: a hidden widget now serializes where
it previously did not.
So check what the pack relied on:
- Hiding purely for presentation →
hidden = trueis complete. - Hiding and suppressing serialization → set
hidden, and handle serialization explicitly. If the value should persist only when the socket is connected, that condition belongs in the pack's ownonSerialize.
This is a wire-format change if you get it wrong, so it will be caught by the gate — but understand it rather than letting the gate find it.
Linked widgets
The recursion over widget.linkedWidgets (seed + seed-control being the classic
pair) has no published equivalent. Hide each widget explicitly by name, or
escalate if the linkage is computed rather than fixed.
widget.type overwrite — 270 packs
Almost always the converted-widget hack above. If it is genuinely trying to change a widget's kind, there is no replacement: type is identity. Remove the widget and add the intended one.
// before
widget.type = 'converted-widget' // → widget.setHidden(true)
// before — genuinely changing kind
widget.type = 'combo' // → remove + add, or escalate
Array assignment and truncation
// before
this.widgets = this.widgets.filter((w) => w.name !== 'seed')
this.widgets.length = 0
this.widgets.push(w)
// after
node.widgets.remove('seed')
for (const name of node.widgets.names()) node.widgets.remove(name)
node.widgets.add(def)
Two reasons not to translate these literally:
- Assigning a new array drops the renderer's tracking. The array identity is
what the renderer watches;
widgets.reordersplices in place for exactly this reason. - Assigning
lengthskips teardown. Packs that do it correctly callwidget.onRemove?.()first — see Custom-ScriptsshowText.js.remove()runs teardown for you, so the manual loop goes away.
Creating a widget
ComfyWidgets.* is an unpublished internal. node.widgets.add(def) replaces it,
and the def is plain data — no node, no app, no return-value unwrapping:
// before
const w = ComfyWidgets.STRING(
this,
'text',
['STRING', { multiline: true }],
app
).widget
w.inputEl.readOnly = true
w.inputEl.style.opacity = 0.6
// after
const widget = node.widgets.add({
type: 'textarea',
name: 'text',
value: '',
disabled: true
})
disabled: true replaces the readOnly + opacity pair — the two lines packs
use to fake a read-only widget. It is a real property, so the styling stays
consistent with every other disabled widget instead of being hand-rolled.
| Old factory | type |
|---|---|
ComfyWidgets.STRING(..., { multiline: true }) |
'textarea' |
ComfyWidgets.STRING(...) |
'string' |
ComfyWidgets.INT / FLOAT |
'number' (or 'slider' with min/max) |
ComfyWidgets.BOOLEAN |
'toggle' |
ComfyWidgets.COMBO |
'combo', values via options.values |
ComfyWidgets.MARKDOWN |
'markdown' |
ComfyWidgets.COLOR |
'color' |
Keeping a widget out of the saved workflow
// before
widget.serializeValue = async () => {} // per widget
this.serialize_widgets = false // whole node
// after
node.widgets.add({ type: 'textarea', name: 'text', serialize: false })
node.serializesWidgets = false
Both are wire-format switches, so a conversion that drops them changes what the
saved workflow contains. serialize is orthogonal to hidden and disabled:
a widget can be visible and unsaved, or hidden and saved.
add throws if the name is already taken — rebuild is remove-then-add, and the
throw catches the common bug of appending a duplicate every execution.
Do not convert a remove-and-recreate into remove plus a bare
ComfyWidgets.* call. That leaves the pack on the unpublished surface, which
is the entire thing the conversion exists to end. If the widget it needs has no
add equivalent, that is an api-gap punt, not a partial conversion.
Never let a write vanish
A handle lookup can fail. Reached through ?., a write behind it does
nothing at all and the pack cannot tell:
// ✗ silently does nothing when the node has not joined a graph yet — which is
// exactly when a helper like this is usually called
comfy.graph.node(String(node.id))?.widgets.get(name)?.setHidden(true)
// ✓ use the handle you were given
function hideForGood(node, name) {
node.widgets.get(name)?.setHidden(true) // reads may be optional
}
comfy.graph.node(id) resolves through the graph, so it returns nothing for a
node that has not been added yet. If you find yourself looking a node up by id
inside a helper, the helper should be taking a NodeHandle instead — its
caller has one.
Optional chaining on a read is fine: ?.getValue() returning undefined is
visible. On a write it is a bug that never reports itself.
Reordering
node.widgets.reorder(['prompt', 'seed', 'steps']) // full permutation
node.widgets.move('prompt', 0) // single move
reorder throws on a partial list rather than dropping the widgets you
omitted — which is precisely how the splice idiom lost them. The error names
what is missing.
setOption preserves accessors — do not hand-merge
// kjnodes builds dynamic combos with a live getter
Object.defineProperty(
newOpts,
'values',
Object.getOwnPropertyDescriptor(comboOptions, 'values')
)
setOption merges by property descriptor, so getters stay getters. If you
hand-roll { ...widget.options(), ...patch } you will invoke the getter and
freeze its result — pinning a dynamic combo to a one-time snapshot, silently.
options() returns a frozen snapshot by design; use setOption to write.
getCustomWidgets — 91 packs
Returning plain objects the store never sees. The widget is mounted on the node that needs it instead of registered as a global type:
node.widgets.mount({
name: 'slider',
height: 40,
render(container) {
/* build DOM; call widget.setValue on change */
},
destroy() {
/* release listeners, timers, observers */
}
})
render receives the container and gets a plain DOM element, deliberately:
packs bundle their own Vue since ADR 0005, and a component from a foreign Vue
instance cannot be mounted. A render function is framework-agnostic and
sidesteps the dual-instance problem.
There is no global widget-type registry. If a pack genuinely needs one type reused across many node types, mount it from a shared helper — that is the supported shape, not a workaround.
Traps summary
| Trap | Consequence |
|---|---|
| Treating same-index splice as a reorder | Nonsense conversion of a cache-invalidation hack |
hidden = true alone, where the old code also suppressed serialization |
Wire-format change |
| Hand-merging options | Live getters flattened; dynamic combos freeze |
Assigning a new widgets array |
Renderer stops tracking |
Assigning widgets.length |
Widget teardown skipped |
Partial reorder list |
Throws — by design; supply every name |
Source data
Counts from the registry census at ~/comfy/nodes-compat-study/
(results/registry_scan.json, 4,969 packs). Grep-derived with a known
false-positive rate — sample-verify before citing an individual number.