fix: repair widget widths on new nodes

This commit is contained in:
Ethanfel
2026-08-07 19:32:41 +02:00
parent 732699d4cd
commit 4a07e1dffc
4 changed files with 198 additions and 22 deletions
+14 -7
View File
@@ -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
+3 -2
View File
@@ -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):
+119
View File
@@ -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
View File
@@ -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();
};