diff --git a/README.md b/README.md index 5a7628f..3662b22 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nodes.py b/nodes.py index 6e8e09c..354b491 100644 --- a/nodes.py +++ b/nodes.py @@ -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): diff --git a/tests/bugfix.test.mjs b/tests/bugfix.test.mjs new file mode 100644 index 0000000..dde3180 --- /dev/null +++ b/tests/bugfix.test.mjs @@ -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"); +}); diff --git a/web/bugfix.js b/web/bugfix.js index 73415d4..8d3202d 100644 --- a/web/bugfix.js +++ b/web/bugfix.js @@ -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(); };