Files

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.