13 KiB
Converting beforeRegisterNodeDef and prototype patching
1,265 packs / 50.4M downloads / 47.4% of registry installs register a
beforeRegisterNodeDef hook, and 1,191 of them use it to patch the generated
class's prototype. This is the largest surface in the ecosystem — bigger than
the widget and painting cohorts combined — and the one that couples packs to our
internals most tightly.
What they patch — measured
Prototype assignments across those 1,265 packs, litegraph methods only
(bundled-library noise like _next, dispose, toString filtered out):
| Patched method | Sites | Packs | Destination |
|---|---|---|---|
onNodeCreated |
3,161 | 943 | b.onCreated(cb) |
onExecuted |
964 | 497 | b.onExecuted(cb) |
onConfigure |
1,272 | 429 | b.onConfigured(cb) |
onConnectionsChange |
454 | 223 | b.onConnectionsChanged(cb) |
onDrawForeground |
446 | 199 | references/draw-callbacks.md |
onRemoved |
353 | 158 | b.onRemoved(cb) |
constructor |
473 | 49 | b.onCreated(cb) |
getDefaultShape |
335 | 3 | no replacement — escalate |
The selector is already written — it's the guard clause
Every one of these hooks runs for every registered node type, so essentially all of them open with a filter and return. That filter is the selector:
// before — ComfyUI-KJNodes jsnodes.js:37
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!nodeData?.category?.startsWith('KJNodes')) return
switch (nodeData.name) {
case 'ImageBatchMulti':
case 'ImageAddMulti':
nodeType.prototype.onNodeCreated = function () {
setupDynamicInputs(this, { type: 'IMAGE', prefix: 'image_' })
}
break
...
}
}
// after
comfy.defs.extend(['ImageBatchMulti', 'ImageAddMulti'], (b) => {
b.onCreated((node) => setupDynamicInputs(node, { type: 'IMAGE', prefix: 'image_' }))
})
This is mechanical: lift the if/switch condition into the selector, one
extend call per case group.
It is also a real performance fix, not just tidiness. With 1,265 packs hooking and a few thousand node types, boot currently runs millions of callbacks that immediately return. A declarative predicate can be indexed.
Selector forms: exact type, array of types, RegExp over the type, or
{ category } where category is a string or a RegExp. The regex form covers
the prefix filter 53 packs open with —
nodeData.category.startsWith('KJNodes') becomes { category: /^KJNodes/ }.
onCreated fires when the node joins a graph, not inside the constructor.
Litegraph's onNodeCreated runs before the node has an id, a graph, or store
registration, so widget writes made there are lost on insert. If a pack relied
on running before insertion, escalate — that ordering is not reproducible.
Chaining boilerplate disappears
// before — capture-and-chain, because prototype patching has no composition
const onExecuted = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments)
populate.call(this, message.text)
}
// after — registered callbacks compose by construction
b.onExecuted((node, result) => populate(node, result.text))
Whether the old form worked at all depended on load order and on every pack remembering to call through. Two packs patching the same method with one forgetting silently broke the other.
onExecuted — the second-largest, and easy to get wrong
497 packs. The result shape is now explicit rather than a raw backend payload:
// before
nodeType.prototype.onExecuted = function (message) {
populate.call(this, message.text)
}
// after
b.onExecuted((node, result) => populate(node, result.text))
ExecutionResult exposes images, text, and raw for everything else.
Custom output keys survive in raw — ADR 0007's passthrough schema guarantees
it — so a pack reading a bespoke key keeps working.
A worked example touching four surfaces at once
ComfyUI-Custom-Scripts showText.js:10 is representative of the harder cases:
// before
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'ShowText|pysssss') {
function populate(text) {
if (this.widgets) {
// On older frontend versions there is a hidden converted-widget
const isConvertedWidget = +!!this.inputs?.[0].widget
for (let i = isConvertedWidget; i < this.widgets.length; i++) {
this.widgets[i].onRemove?.()
}
this.widgets.length = isConvertedWidget
}
for (const l of text) {
const w = ComfyWidgets.STRING(this, 'text_' + this.widgets?.length, ...).widget
w.inputEl.readOnly = true
w.inputEl.style.opacity = 0.6
}
}
...
}
}
Four separate conversions:
| Old | New |
|---|---|
nodeData.name === 'ShowText|pysssss' guard |
the defs.extend selector |
+!!this.inputs?.[0].widget converted-widget sniffing |
input.isWidgetInput |
this.widgets.length = n truncation |
remove by name (below) |
w.inputEl DOM access |
widgets.mount({ render }) |
Truncation has no single-call replacement, deliberately — assigning length
skips each widget's teardown, which is why the pack has to call onRemove() by
hand first:
// after — removal runs teardown for you
for (const name of node.widgets.names().slice(keep)) {
node.widgets.remove(name)
}
Note isConvertedWidget exists only to skip a widget the old frontend hid via
the converted-widget protocol. With setHidden() as a real property, that
whole line of reasoning goes away — check input.isWidgetInput if you actually
care whether an input is a widget's socket form.
Pack-owned node types — registerCustomNodes / extends LGraphNode
86 packs / 18.2% of installs define their own types by subclassing. The
replacement is comfy.defs.define(...): the definition is plain data, and no
class is ever yours.
First identify the intent. "Virtual node" is four different things, and the conversion differs for each:
| The node exists to… | Examples | Convert to |
|---|---|---|
| annotate — never executes | Note nodes | execution: 'frontend', no resolve |
| be a wire — indirection | Reroute, SetNode/GetNode |
resolve returning forwardTo |
| be a value — UI-held literal | Primitive, constants | resolve returning literal |
| act on other nodes — a remote control | rgthree Fast Muter | not resolve at all — handle calls in widget callbacks |
// before
class GetNode extends LGraphNode {
constructor() {
super()
this.addOutput('value', '*')
this.isVirtualNode = true
}
applyToGraph() {
/* rewrites links on the LIVE graph mid-serialize */
}
}
LiteGraph.registerNodeType('GetNode', GetNode)
// after — declared, and resolution is a pure answer over a read-only view
comfy.defs.define({
type: 'GetNode',
execution: 'frontend',
outputs: [{ name: 'value', type: '*' }],
widgets: [{ type: 'string', name: 'key', value: '' }],
resolve: ({ self, nodesOfType }) => {
const setter = nodesOfType('SetNode').find(
(n) => n.widgetValue('key') === self.widgetValue('key')
)
return {
value: setter ? { forwardTo: setter.input('value') } : { omit: true }
}
}
})
resolve answers, per output: { forwardTo: inputRef } ("whatever feeds that
input"), { literal: value }, or { omit: true }. Our pass follows chains
(Get → Set → Reroute → …) with cycle detection. You never see or touch the
prompt being built — a resolver that throws poisons one prompt build and the
graph is untouched, which is exactly what applyToGraph could not guarantee.
A simple reroute is one line: resolve: ({ self }) => ({ out: { forwardTo: self.input('in') } }).
Nodes that act on other nodes are ordinary nodes. A Fast Muter is a remote control: a row of toggles, one per wired-in node. A Fast Muter is defs.define with
execution: 'frontend', buttons via widgets, and callbacks that call
comfy.graph.nodesOfType(...) + node.setMode('bypass') — edit-time changes
the user can undo, not serialization behaviour. Do not put neighbour mutation
in resolve; resolve cannot write anything, by design.
Do not carry over isVirtualNode, applyToGraph, or the subclass itself.
If the node's applyToGraph does something none of the three resolution shapes
can express, that is an api-gap punt — name what it rewrites.
Node handles are accessors, not properties
Every read and write on a NodeHandle is a method — the contract in
src/types/extensionV2.ts, so a read can be a store query and a write can
become a command. Property syntax silently does nothing or throws.
| Old (on the raw node) | Published API |
|---|---|
node.title = t |
node.setTitle(t) / getTitle() |
node.color = c |
node.setColor(c) / getColor() |
node.bgcolor = c |
node.setBgColor(c) / getBgColor() |
node.mode = 4 |
node.setMode('bypass') / getMode() |
node.flags.collapsed |
node.isCollapsed() / setCollapsed(b) |
node.flags.pinned |
node.isPinned() / setPinned(b) |
node.shape = s |
node.setShape(s) / getShape() |
node.properties[k] = v |
node.setProperty(k, v) / getProperty(k) |
node.size / node.pos |
node.getSize() / getPosition() |
Both setters take one tuple, not two numbers — the legacy setSize(w, h)
and setPos(x, y) arities are gone:
// before
node.setSize([200, 58]) // litegraph took an array here
node.color = '#1b4669'
node.bgcolor = '#29699c'
// after
node.setSize([200, 58]) // unchanged — still one tuple
node.setColor('#1b4669')
node.setBgColor('#29699c')
Widget handles follow the same rule: getValue()/setValue(),
isHidden()/setHidden(), getOptions()/setOption(), and widgetType
rather than type. See widgets.md.
Per-instance state
Handles hold no arbitrary properties, so node._myState = x has no target. Keep
the state yourself, keyed by node id:
// before — a property stashed on the node instance
nodeType.prototype.onExecuted = function (output) {
if (this._lastHash === hash) return
this._lastHash = hash
}
// after — the pack owns its own state
const stateByNode = new Map()
const stateFor = (id) => {
let s = stateByNode.get(id)
if (!s) stateByNode.set(id, (s = {}))
return s
}
comfy.defs.extend('MyNode', (b) => {
b.onExecuted((node, result) => {
const state = stateFor(node.id)
if (state.lastHash === hash) return
state.lastHash = hash
})
b.onRemoved((node) => stateByNode.delete(node.id))
})
This is a supported conversion, not a workaround. Clean up in onRemoved — the
old form was collected with the node, and a Map is not.
Traps
The constructor self-assignment. this.type = this.type ?? undefined is a
defensive no-op that now throws, killing construction entirely. Handled
mechanically by the type-write-noop rule — 8 packs, 3.86M downloads, including
rgthree's whole 12-type virtual-node family.
nodeData is a definition, not a node. Reading nodeData.input.required.x
is fine; it becomes b.def.inputs. But the shape differs — do not assume a
field-by-field rename.
Async hooks. async beforeRegisterNodeDef is common and usually gratuitous.
defs.extend is synchronous; if a pack genuinely needs async setup, do it in
onCreated and guard for the node being removed before it resolves.
Order-dependent patches. A pack that reads nodeType.prototype.onNodeCreated
expecting another pack's patch to already be installed has no equivalent, by
design. Escalate — it needs the author.
Source data
Prototype-assignment counts derived from the corpus at
~/comfy/nodes-compat-study/corpus/registry_js (4,969 packs, 2,562 files
scanned). Grep-derived; sample-verify before citing an individual number.