From dcb49963335bdc1ed50e63ce7295949b7ac5b032 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 3 Aug 2026 19:31:29 +0200 Subject: [PATCH] fix: improve rgthree Power Lora Loader removal detection and workflow refresh retry behavior - Wrap node.widgets.splice in rgthree Power Lora Loader adapter so removals made through the context menu trigger model resolution updates. - Delay node context menu analysis when a workflow refresh is already pending to avoid stale analysis. - Extend workflow refresh retry logic to also retry when ComfyUI is still serializing the previous workflow after a node widget change. - Add tests for context menu removal detection, refresh retry behavior, and deferred context analysis. --- tests/test_downloads_tab_workflow_route.mjs | 124 ++++++++++++++++++ .../custom_nodes/rgthree_power_lora_loader.js | 53 +++++--- web/resolver/model_resolver.js | 9 ++ web/resolver/shell/workflow_state_methods.js | 7 +- 4 files changed, 175 insertions(+), 18 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 805e3a4..7ff0346 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -1393,6 +1393,52 @@ test('rgthree Power Lora Loader updates strength without reloading workflow anal ]); }); +test('rgthree Power Lora Loader detects removals made through its context menu', async () => { + const configureCustomNodeModelAdapter = eval( + `(${extractMethod(modelResolverSource, 'configureCustomNodeModelAdapter')})` + ); + const getCustomNodeModelListSignature = eval( + `(${extractMethod(modelResolverSource, 'getCustomNodeModelListSignature')})` + ); + const getCustomNodeModelStrengthSignature = eval( + `(${extractMethod(modelResolverSource, 'getCustomNodeModelStrengthSignature')})` + ); + const calls = []; + const node = { + comfyClass: 'Power Lora Loader (rgthree)', + widgets: [ + { + name: 'lora_1', + value: { on: true, lora: 'first.safetensors', strength: 1 }, + }, + { + name: 'lora_2', + value: { on: true, lora: 'second.safetensors', strength: 1 }, + }, + ], + setDirtyCanvas() {}, + }; + const resolver = { + customNodeModelAdapterStates: new WeakMap(), + getCustomNodeModelListSignature, + getCustomNodeModelStrengthSignature, + scheduleNodeContextMenuAnalysis: () => calls.push('context-analysis'), + dialog: { + isWorkflowRefreshSuppressed: () => false, + scheduleActiveWorkflowRefresh: reason => calls.push(`dialog-refresh:${reason}`), + }, + }; + + configureCustomNodeModelAdapter.call(resolver, node); + node.widgets.splice(1, 1); + await Promise.resolve(); + + assert.deepEqual(calls, [ + 'context-analysis', + 'dialog-refresh:node-widget-change', + ]); +}); + test('rgthree dynamic lora widgets bypass the generic full workflow refresh', () => { const configureNodeContextMenu = eval( `(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})` @@ -2112,6 +2158,84 @@ test('node widget changes request a content-preserving Missing Models refresh', assert.equal(preserveSearchCacheAtSync, true); }); +test('node widget refresh retries while ComfyUI still serializes the previous workflow', async () => { + const log = { debug() {} }; + const refreshForActiveWorkflowChange = eval( + `(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})` + ); + const workflow = { version: 'old' }; + let loadArguments = null; + let retryCallback = null; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = callback => { + retryCallback = callback; + return 1; + }; + + const dialog = { + _workflowRefreshGeneration: 1, + activeWorkflowRouteKey: 'workflow-a', + activeWorkflowSignature: 'old-signature', + activeMissingWorkflowSignature: 'old-missing-signature', + activeTab: 'missing', + contentElement: { style: {} }, + isVisible: () => true, + getActiveWorkflowRouteKey: () => 'workflow-a', + getCurrentWorkflow: () => workflow, + getWorkflowSignature: currentWorkflow => `${currentWorkflow.version}-signature`, + getMissingWorkflowSignature: currentWorkflow => `${currentWorkflow.version}-missing-signature`, + refreshForActiveWorkflowChange, + syncWorkflowScopedQueue() {}, + async loadWorkflowData(...args) { + loadArguments = args; + }, + }; + + try { + await refreshForActiveWorkflowChange.call(dialog, { + reason: 'node-widget-change', + expectedRoute: 'workflow-a', + previousSignature: 'old-signature', + attempt: 0, + generation: 1, + candidateRoute: 'workflow-a', + candidateSignature: 'new-signature', + }); + + assert.equal(loadArguments, null); + assert.equal(typeof retryCallback, 'function'); + + workflow.version = 'new'; + retryCallback(); + await Promise.resolve(); + + assert.equal(loadArguments[0], workflow); + assert.deepEqual(loadArguments[1], { preserveContent: true }); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test('node context analysis waits for a visible workflow refresh to finish', async () => { + const refreshNodeContextMenuAnalysis = eval( + `(${extractMethod(modelResolverSource, 'refreshNodeContextMenuAnalysis')})` + ); + const calls = []; + const dialog = { + isVisible: () => true, + _workflowRefreshRetryTimer: 1, + }; + const resolver = { + dialog, + scheduleNodeContextMenuAnalysis: delay => calls.push(delay), + }; + + const result = await refreshNodeContextMenuAnalysis.call(resolver); + + assert.equal(result, null); + assert.deepEqual(calls, [180]); +}); + test('strength-only widget changes synchronize workflow state without refreshing Missing Models', async () => { const log = { debug() {} }; const refreshForActiveWorkflowChange = eval( diff --git a/web/resolver/custom_nodes/rgthree_power_lora_loader.js b/web/resolver/custom_nodes/rgthree_power_lora_loader.js index 764c2c4..c3c3f6e 100644 --- a/web/resolver/custom_nodes/rgthree_power_lora_loader.js +++ b/web/resolver/custom_nodes/rgthree_power_lora_loader.js @@ -16,28 +16,47 @@ function getPowerLoraEntries(node) { } function observePowerLoraLoader(node, notify) { - if ( - typeof node.setDirtyCanvas !== 'function' - || node.setDirtyCanvas.__modelResolverCustomNodeAdapter - ) { - return; - } - - const originalSetDirtyCanvas = node.setDirtyCanvas; - let inspectionQueued = false; - const wrappedSetDirtyCanvas = function() { - const result = originalSetDirtyCanvas.apply(this, arguments); - if (!inspectionQueued) { + const queueInspection = (() => { + let inspectionQueued = false; + return () => { + if (inspectionQueued) return; inspectionQueued = true; queueMicrotask(() => { inspectionQueued = false; notify(); }); - } - return result; - }; - wrappedSetDirtyCanvas.__modelResolverCustomNodeAdapter = true; - node.setDirtyCanvas = wrappedSetDirtyCanvas; + }; + })(); + + if ( + typeof node.setDirtyCanvas !== 'function' + || node.setDirtyCanvas.__modelResolverCustomNodeAdapter + ) { + if (!Array.isArray(node.widgets)) return; + } else { + const originalSetDirtyCanvas = node.setDirtyCanvas; + const wrappedSetDirtyCanvas = function() { + const result = originalSetDirtyCanvas.apply(this, arguments); + queueInspection(); + return result; + }; + wrappedSetDirtyCanvas.__modelResolverCustomNodeAdapter = true; + node.setDirtyCanvas = wrappedSetDirtyCanvas; + } + + if ( + typeof node.widgets.splice === 'function' + && !node.widgets.splice.__modelResolverCustomNodeAdapter + ) { + const originalSplice = node.widgets.splice; + const wrappedSplice = function() { + const result = originalSplice.apply(this, arguments); + queueInspection(); + return result; + }; + wrappedSplice.__modelResolverCustomNodeAdapter = true; + node.widgets.splice = wrappedSplice; + } } export const rgthreePowerLoraLoaderAdapter = Object.freeze({ diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 038cd13..cf1783e 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -353,6 +353,15 @@ export class ModelResolver { async refreshNodeContextMenuAnalysis({ force = false } = {}) { if (!this.dialog) return null; + const workflowRefreshPending = ( + this.dialog.isVisible?.() + && (this.dialog._workflowRefreshTimer || this.dialog._workflowRefreshRetryTimer) + ); + if (!force && workflowRefreshPending) { + this.scheduleNodeContextMenuAnalysis(180); + return null; + } + const { workflow, signature } = this.getNodeContextWorkflowState(); if (!workflow || !signature) return null; diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index cc32f62..3bd0a60 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -823,8 +823,13 @@ export const workflowStateMethods = { const missingSignatureChanged = missingSignature && missingSignature !== this.activeMissingWorkflowSignature; const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; + const workflowChangeStillPending = ( + reason === 'node-widget-change' + && !routeChanged + && !signatureChanged + ); - if ((!signature || graphStillLooksOld) && attempt < 8) { + if ((!signature || graphStillLooksOld || workflowChangeStillPending) && attempt < 8) { this._workflowRefreshRetryTimer = setTimeout(() => { if (generation !== this._workflowRefreshGeneration) return; this._workflowRefreshRetryTimer = null;