fix: repair widget widths on new nodes
This commit is contained in:
@@ -31,8 +31,12 @@ Adds a single node, **Legacy Widget Width Fix (temporary)**, under
|
||||
`utils/bugfix` in the Add Node menu.
|
||||
|
||||
- **Drop it into a workflow** → the frontend patch installs.
|
||||
Existing widgets have their stale widths cleared, and future writes
|
||||
from the Vue draw loop are dropped while LiteGraph mode is active.
|
||||
Existing widgets have their stale widths cleared, future writes from
|
||||
the Vue draw loop are dropped while LiteGraph mode is active, and new
|
||||
nodes are repaired automatically as you add them.
|
||||
- **Repair button** → click **Repair widget widths now** on the fix node
|
||||
to rescan the workflow manually. This is a fallback for unusual custom
|
||||
nodes that create widgets outside ComfyUI's normal widget APIs.
|
||||
- **Remove it and refresh** → the patch deactivates and widgets revert
|
||||
to stock behavior on node removal, with complete removal on refresh
|
||||
(this two-step approach keeps your active session intact if
|
||||
@@ -42,19 +46,22 @@ Adds a single node, **Legacy Widget Width Fix (temporary)**, under
|
||||
until the last instance is removed.
|
||||
|
||||
The node has no inputs, no outputs, and does nothing on execution. Its
|
||||
only purpose is to be a presence in the graph that the frontend
|
||||
extension can detect.
|
||||
presence activates the frontend patch; its button only triggers an
|
||||
immediate rescan and does not affect workflow execution.
|
||||
|
||||
## How the patch works
|
||||
|
||||
`web/bugfix.js`:
|
||||
|
||||
1. Hooks `LGraphNode.prototype.addWidget` so every newly-created widget
|
||||
has its `width` property replaced with a getter/setter.
|
||||
has its `width` property replaced with a getter/setter and its stale
|
||||
initial width cleared while the patch is active.
|
||||
2. Replaces the `width` property on all existing widgets the same way.
|
||||
3. The setter drops writes when `LiteGraph.vueNodesMode` is false (i.e.
|
||||
3. Rescans every node through ComfyUI's `nodeCreated` extension hook,
|
||||
including a deferred pass for widgets added by other extensions.
|
||||
4. The setter drops writes when `LiteGraph.vueNodesMode` is false (i.e.
|
||||
in LiteGraph mode, where the bug manifests).
|
||||
4. Reverses everything when the last instance of the node is removed.
|
||||
5. Reverses everything when the last instance of the node is removed.
|
||||
|
||||
If `LiteGraph.vueNodesMode` isn't exposed on the build you're running,
|
||||
the patch assumes LiteGraph mode and drops the writes — matching the
|
||||
|
||||
@@ -20,8 +20,9 @@ class LegacyWidgetWidthFix:
|
||||
CATEGORY = "utils/bugfix"
|
||||
DESCRIPTION = (
|
||||
"Workaround for LiteGraph-mode widget width bug "
|
||||
"(ComfyUI_frontend #12443). Only needed if you have the bug "
|
||||
"until ComfyOrg patches the bug."
|
||||
"(ComfyUI_frontend #12443). New nodes are repaired automatically; "
|
||||
"use the button to rescan manually. Only needed until ComfyOrg "
|
||||
"patches the bug."
|
||||
)
|
||||
|
||||
def noop(self):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const NODE_NAME = "LegacyWidgetWidthFix";
|
||||
|
||||
async function loadExtension() {
|
||||
let extension;
|
||||
const animationFrames = [];
|
||||
|
||||
class LGraphNode {
|
||||
constructor() {
|
||||
this.widgets = [];
|
||||
this.size = [200, 100];
|
||||
}
|
||||
|
||||
addWidget(type, name, value, callback, options) {
|
||||
const widget = { type, name, value, callback, options, width: 240 };
|
||||
this.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
addDOMWidget(name, type, element, options) {
|
||||
const widget = { name, type, element, options, width: 250 };
|
||||
this.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
addCustomWidget(widget) {
|
||||
this.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
|
||||
const graph = {
|
||||
_nodes: [],
|
||||
dirtyCalls: 0,
|
||||
setDirtyCanvas() {
|
||||
this.dirtyCalls += 1;
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
graph,
|
||||
registerExtension(value) {
|
||||
extension = value;
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.LGraphNode = LGraphNode;
|
||||
globalThis.LiteGraph = { vueNodesMode: false };
|
||||
globalThis.__lwwfTestApp = app;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
animationFrames.push(callback);
|
||||
};
|
||||
|
||||
const sourceUrl = new URL("../web/bugfix.js", import.meta.url);
|
||||
const source = (await readFile(sourceUrl, "utf8")).replace(
|
||||
'import { app } from "../../scripts/app.js";',
|
||||
"const app = globalThis.__lwwfTestApp;",
|
||||
);
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
await import(moduleUrl);
|
||||
|
||||
return { animationFrames, app, extension, graph, LGraphNode };
|
||||
}
|
||||
|
||||
test("repairs existing and newly-created nodes without re-adding the fix node", async () => {
|
||||
const { animationFrames, extension, graph, LGraphNode } = await loadExtension();
|
||||
|
||||
class FixNode extends LGraphNode {}
|
||||
FixNode.comfyClass = NODE_NAME;
|
||||
extension.beforeRegisterNodeDef(FixNode, { name: NODE_NAME });
|
||||
|
||||
const existingNode = new LGraphNode();
|
||||
existingNode.widgets.push({ name: "existing", width: 180 });
|
||||
graph._nodes.push(existingNode);
|
||||
|
||||
const fixNode = new FixNode();
|
||||
extension.nodeCreated(fixNode);
|
||||
graph._nodes.push(fixNode);
|
||||
fixNode.onAdded();
|
||||
|
||||
assert.equal(existingNode.widgets[0].width, undefined);
|
||||
const repairButton = fixNode.widgets.find(
|
||||
(widget) => widget.name === "Repair widget widths now",
|
||||
);
|
||||
assert.ok(repairButton, "the fix node should expose a manual repair button");
|
||||
|
||||
const newNode = new LGraphNode();
|
||||
const normalWidget = newNode.addWidget("number", "new", 0, null, {});
|
||||
assert.equal(normalWidget.width, undefined);
|
||||
|
||||
const directWidget = { name: "direct", width: 300 };
|
||||
newNode.widgets.push(directWidget);
|
||||
extension.nodeCreated(newNode);
|
||||
assert.equal(directWidget.width, undefined);
|
||||
|
||||
const deferredWidget = { name: "deferred", width: 320 };
|
||||
newNode.widgets.push(deferredWidget);
|
||||
graph._nodes.push(newNode);
|
||||
animationFrames.splice(0).forEach((callback) => callback());
|
||||
assert.equal(deferredWidget.width, undefined);
|
||||
|
||||
const unusualWidget = { name: "unusual", width: 500 };
|
||||
existingNode.widgets.push(unusualWidget);
|
||||
repairButton.callback();
|
||||
assert.equal(unusualWidget.width, undefined);
|
||||
|
||||
directWidget.width = 410;
|
||||
assert.equal(directWidget.width, undefined, "LiteGraph writes remain blocked");
|
||||
|
||||
globalThis.LiteGraph.vueNodesMode = true;
|
||||
directWidget.width = 410;
|
||||
assert.equal(directWidget.width, 410, "Vue mode writes remain untouched");
|
||||
|
||||
fixNode.onRemoved();
|
||||
directWidget.width = 430;
|
||||
assert.equal(directWidget.width, 430, "removal restores stock width writes");
|
||||
});
|
||||
+62
-13
@@ -11,14 +11,15 @@
|
||||
// the patch is active and LiteGraph mode is in control.
|
||||
//
|
||||
// Patch is installed when a LegacyWidgetWidthFix node is added to the
|
||||
// graph and removal requires deleting the node and refreshing the page
|
||||
// — deleting alone deactivates the patch but doesn't unload it.
|
||||
// graph. New nodes are repaired automatically while it is present, and
|
||||
// the fix node also exposes a manual repair button as a fallback.
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "LegacyWidgetWidthFix";
|
||||
const WIDTH_KEY = "__lwwf_width";
|
||||
const GUARDED = "__lwwf_guarded";
|
||||
const REPAIR_BUTTON = "__lwwf_repair_button";
|
||||
|
||||
let activeCount = 0;
|
||||
let hooksInstalled = false;
|
||||
@@ -59,6 +60,18 @@ function guardWidget(w) {
|
||||
});
|
||||
}
|
||||
|
||||
function repairWidget(w) {
|
||||
if (!w) return;
|
||||
guardWidget(w);
|
||||
|
||||
// Clear the stale persisted value so renderers fall back to node.size[0].
|
||||
// Must be `undefined`, not 0 — DomWidgets.vue uses `width ?? nodeWidth`
|
||||
// (nullish), which would not fall back from 0. Canvas renderer uses
|
||||
// `width || nodeWidth` (falsy), which falls back from either. undefined
|
||||
// works for both.
|
||||
if (!inVueMode()) w[WIDTH_KEY] = undefined;
|
||||
}
|
||||
|
||||
function unguardWidget(w) {
|
||||
if (!w || !w[GUARDED]) return;
|
||||
const v = w[WIDTH_KEY];
|
||||
@@ -82,7 +95,10 @@ function hookWidgetMethods() {
|
||||
origMethods[name] = fn;
|
||||
proto[name] = function patched(...args) {
|
||||
const w = fn.apply(this, args);
|
||||
if (w) guardWidget(w);
|
||||
if (w) {
|
||||
guardWidget(w);
|
||||
if (patchActive()) repairWidget(w);
|
||||
}
|
||||
return w;
|
||||
};
|
||||
}
|
||||
@@ -98,18 +114,37 @@ function forEachWidget(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function repairNode(node) {
|
||||
for (const w of node?.widgets ?? []) repairWidget(w);
|
||||
}
|
||||
|
||||
function repairAll() {
|
||||
if (!patchActive()) return;
|
||||
forEachWidget((w) => repairWidget(w));
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function addRepairButton(node) {
|
||||
if (!node || node[REPAIR_BUTTON]) return;
|
||||
const button = node.addWidget?.(
|
||||
"button",
|
||||
"Repair widget widths now",
|
||||
null,
|
||||
() => repairAll(),
|
||||
{ serialize: false },
|
||||
);
|
||||
if (button) node[REPAIR_BUTTON] = button;
|
||||
}
|
||||
|
||||
function isFixNode(node) {
|
||||
return node?.comfyClass === NODE_NAME
|
||||
|| node?.constructor?.comfyClass === NODE_NAME
|
||||
|| node?.type === NODE_NAME;
|
||||
}
|
||||
|
||||
function install() {
|
||||
hookWidgetMethods();
|
||||
forEachWidget((w, n) => {
|
||||
guardWidget(w);
|
||||
// Clear the stale persisted value so renderers fall back to node.size[0].
|
||||
// Must be `undefined`, not 0 — DomWidgets.vue uses `width ?? nodeWidth`
|
||||
// (nullish), which would not fall back from 0. Canvas renderer uses
|
||||
// `width || nodeWidth` (falsy), which falls back from either. undefined
|
||||
// works for both.
|
||||
if (!inVueMode()) w[WIDTH_KEY] = undefined;
|
||||
});
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
repairAll();
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
@@ -120,12 +155,26 @@ function uninstall() {
|
||||
app.registerExtension({
|
||||
name: "LegacyWidgetWidthFix",
|
||||
|
||||
nodeCreated(node) {
|
||||
if (isFixNode(node)) addRepairButton(node);
|
||||
if (!patchActive()) return;
|
||||
|
||||
repairNode(node);
|
||||
|
||||
// Other extensions can append widgets in their own nodeCreated hooks.
|
||||
// Re-scan once those synchronous hooks and the current render pass finish.
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
if (patchActive() && app.graph?._nodes?.includes(node)) repairNode(node);
|
||||
});
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
|
||||
const origOnAdded = nodeType.prototype.onAdded;
|
||||
nodeType.prototype.onAdded = function (...args) {
|
||||
origOnAdded?.apply(this, args);
|
||||
addRepairButton(this);
|
||||
activeCount += 1;
|
||||
if (activeCount === 1) install();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user