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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user