mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-29 01:18:22 -05:00
309 lines
12 KiB
Markdown
309 lines
12 KiB
Markdown
# 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)`, and `widgetType` for
|
|
> the type. Node handles keep `getTitle()`/`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
|
|
|
|
```js
|
|
// 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:
|
|
|
|
```js
|
|
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`:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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 = true` is 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 own `onSerialize`.
|
|
|
|
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.
|
|
|
|
```js
|
|
// before
|
|
widget.type = 'converted-widget' // → widget.setHidden(true)
|
|
|
|
// before — genuinely changing kind
|
|
widget.type = 'combo' // → remove + add, or escalate
|
|
```
|
|
|
|
## Array assignment and truncation
|
|
|
|
```js
|
|
// 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.reorder` splices in place for exactly this
|
|
reason.
|
|
- **Assigning `length` skips teardown.** Packs that do it correctly call
|
|
`widget.onRemove?.()` first — see Custom-Scripts `showText.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:
|
|
|
|
```js
|
|
// 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
|
|
|
|
```js
|
|
// 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:
|
|
|
|
```js
|
|
// ✗ 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
|
|
|
|
```js
|
|
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
|
|
|
|
```js
|
|
// 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:
|
|
|
|
```js
|
|
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.
|