fix: keep bypass/mute state on frontend 1.53+ node shell state (#1123)

ComfyUI frontend 1.53 turned LGraphNode.mode into a prototype accessor
backed by node._state, and serialize() now reads that state directly.
Redefining mode on the instance shadowed the setter, so bypass/mute
never reached the serialized workflow and silently reverted to Always
on save/reload or workflow tab switch.

Add interceptModeChange() in web/comfyui/utils.js: it delegates to the
prototype accessor when one exists (observing changes only), and falls
back to the legacy closure accessor on older frontends. Use it in
lora_loader.js and in the Vue widgets' setupModeChangeHandler, which
covers the LoRA provider/aggregator nodes with the same latent bug.
This commit is contained in:
Will Miao
2026-09-25 18:12:01 +08:00
parent dae18b3d1d
commit 067e605e75
8 changed files with 360 additions and 120 deletions
+68
View File
@@ -188,6 +188,74 @@ export function chainCallback(object, property, callback) {
}
}
/**
* Find a `mode` accessor (getter/setter) on the node's prototype chain.
* Since ComfyUI frontend 1.53, `LGraphNode.mode` is a prototype accessor
* backed by the node's shell state (`node._state.mode`); on legacy frontends
* `mode` is a plain data property on the instance and no accessor exists.
*/
function findModeAccessor(node) {
let proto = Object.getPrototypeOf(node);
while (proto && proto !== Object.prototype) {
const descriptor = Object.getOwnPropertyDescriptor(proto, "mode");
if (descriptor && (descriptor.get || descriptor.set)) {
return descriptor;
}
proto = Object.getPrototypeOf(proto);
}
return null;
}
/**
* Observe node mode changes (Always/Never/On Trigger/Bypass) without
* breaking the frontend's own `mode` property.
*
* Since ComfyUI frontend 1.53, `mode` is a prototype accessor backed by
* shell state and serialization reads that state directly. Redefining `mode`
* on the instance would shadow the prototype setter, so bypass/mute never
* reaches the serialized workflow and silently reverts to Always on reload.
* When a prototype accessor exists we delegate to it and only observe the
* change; on legacy frontends we keep the value in a closure as before.
*
* @param {Object} node - The litegraph node instance
* @param {(newMode: number, oldMode: number) => void} onModeChange - Called when the mode actually changes
*/
export function interceptModeChange(node, onModeChange) {
const delegate = findModeAccessor(node);
if (delegate && typeof delegate.get === "function" && typeof delegate.set === "function") {
Object.defineProperty(node, "mode", {
configurable: true,
get() {
return delegate.get.call(this);
},
set(value) {
const oldValue = delegate.get.call(this);
delegate.set.call(this, value);
if (oldValue !== value) {
onModeChange(value, oldValue);
}
},
});
return;
}
let currentMode = node.mode;
Object.defineProperty(node, "mode", {
configurable: true,
get() {
return currentMode;
},
set(value) {
const oldValue = currentMode;
currentMode = value;
if (oldValue !== value) {
onModeChange(value, oldValue);
}
},
});
}
/**
* Show a toast notification
* @param {Object|string} options - Toast options object or message string for backward compatibility