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.
This commit is contained in:
Dariusz L
2026-08-03 19:31:29 +02:00
parent db76a45b1b
commit dcb4996333
4 changed files with 175 additions and 18 deletions
+124
View File
@@ -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(
@@ -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({
+9
View File
@@ -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;
+6 -1
View File
@@ -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;