diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 48fe062..3df6da3 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -1725,6 +1725,30 @@ test('rgthree dynamic lora widgets bypass the generic full workflow refresh', () ]); }); +test('removed workflow nodes notify the live workflow refresh mechanism', () => { + const configureWorkflowNodeLifecycle = eval( + `(${extractMethod(modelResolverSource, 'configureWorkflowNodeLifecycle')})` + ); + const calls = []; + const node = { + onRemoved(value) { + calls.push(`native:${value}`); + return 'removed'; + }, + }; + const resolver = { + dialog: { + scheduleActiveWorkflowRefresh: reason => calls.push(reason), + }, + }; + + configureWorkflowNodeLifecycle.call(resolver, node); + + assert.equal(node.onRemoved('node'), 'removed'); + assert.deepEqual(calls, ['native:node', 'node-removed']); + assert.equal(node.__modelResolverWorkflowLifecyclePatched, true); +}); + test('LoRA Manager text onWidgetChanged waits for a confirmed model-list change', () => { const configureNodeContextMenu = eval(`(${extractMethod(modelResolverSource, 'configureNodeContextMenu')})`); const calls = []; @@ -2928,6 +2952,44 @@ test('node widget changes request a content-preserving Missing Models refresh', assert.equal(preserveSearchCacheAtSync, true); }); +test('metadata workflow changes refresh transfer targets without showing a loading replacement', async () => { + const log = { debug() {} }; + const refreshForActiveWorkflowChange = eval( + `(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})` + ); + const workflow = { nodes: [{ id: 3, type: 'CLIPLoader' }] }; + let openArguments = null; + const dialog = { + _workflowRefreshGeneration: 1, + activeWorkflowRouteKey: 'workflow-a', + activeWorkflowSignature: 'old-signature', + activeMissingWorkflowSignature: 'old-missing-signature', + activeTab: 'metadata', + contentElement: { style: {} }, + isVisible: () => true, + getActiveWorkflowRouteKey: () => 'workflow-a', + getCurrentWorkflow: () => workflow, + getWorkflowSignature: () => 'new-signature', + getMissingWorkflowSignature: () => 'new-missing-signature', + syncWorkflowScopedQueue() {}, + async openMetadataTransfer(...args) { + openArguments = args; + }, + }; + + await refreshForActiveWorkflowChange.call(dialog, { + reason: 'node-created', + expectedRoute: 'workflow-a', + previousSignature: 'old-signature', + attempt: 8, + generation: 1, + candidateRoute: 'workflow-a', + candidateSignature: 'new-signature', + }); + + assert.deepEqual(openArguments, [{ preserveContent: true }]); +}); + test('node widget refresh retries while ComfyUI still serializes the previous workflow', async () => { const log = { debug() {} }; const refreshForActiveWorkflowChange = eval( diff --git a/tests/test_frontend_entrypoint.mjs b/tests/test_frontend_entrypoint.mjs index 5dc95ca..28e22cd 100644 --- a/tests/test_frontend_entrypoint.mjs +++ b/tests/test_frontend_entrypoint.mjs @@ -61,4 +61,14 @@ test('frontend entrypoint registers the ComfyUI extension contract', () => { 2, 'dynamically created and loaded node types must receive the widget-change hook' ); + assert.match( + resolverSource, + /modelResolver\.dialog\?\.scheduleActiveWorkflowRefresh\?\.\('node-created'\)/, + 'new nodes must notify the live workflow refresh mechanism' + ); + assert.match( + modelResolverSource, + /configureWorkflowNodeLifecycle\(node\)/, + 'workflow nodes must receive removal tracking' + ); }); diff --git a/tests/test_image_metadata_transfer.mjs b/tests/test_image_metadata_transfer.mjs index be43916..c23cbcd 100644 --- a/tests/test_image_metadata_transfer.mjs +++ b/tests/test_image_metadata_transfer.mjs @@ -250,6 +250,90 @@ test('metadata transfer stores a fresh target scan in the shared Loaded Models c assert.equal(saveCount, 1); }); +test('metadata transfer refresh keeps the current panel visible while scanning a changed workflow', async () => { + const oldTargetGroup = { + category: 'text_encoders', + nodes: [{ + nodeKey: 'top:1', + nodeId: 1, + nodeType: 'CLIPLoader', + refs: [], + }], + }; + const sourceModel = { + category: 'text_encoders', + original_path: 'imported.safetensors', + }; + let resolveFetch; + let renderCount = 0; + const context = { + ...imageMetadataMethods, + imageInspectorState: { + requestToken: 'source-request', + loadedModels: { loaded_models: [sourceModel] }, + transfer: { + open: true, + workflowSignature: 'old-workflow', + targetModels: { loaded_models: [] }, + targetGroups: [oldTargetGroup], + selectedNodeKeys: new Set(['top:1']), + collapsedCategories: new Set(['text_encoders']), + sourceSelections: {}, + }, + }, + getCurrentWorkflow: () => ({ signature: 'new-workflow' }), + getWorkflowSignature: workflow => workflow.signature, + syncWorkflowScopedQueue() {}, + getCachedLoadedModelsForSignature() { + return null; + }, + getMetadataTransferAcceptedFileTypes() { + return []; + }, + fetchJson: () => new Promise(resolve => { + resolveFetch = resolve; + }), + saveLoadedModelsCacheForActiveWorkflow() {}, + renderImageMetadataResult() { + renderCount += 1; + }, + }; + + const refreshPromise = context.openMetadataTransfer({ preserveContent: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.equal(renderCount, 0); + assert.equal(context.imageInspectorState.transfer.refreshing, true); + assert.deepEqual(context.imageInspectorState.transfer.targetGroups, [oldTargetGroup]); + assert.deepEqual( + Array.from(context.imageInspectorState.transfer.selectedNodeKeys), + ['top:1'] + ); + + resolveFetch({ + loaded_models: [{ + category: 'text_encoders', + node_id: 1, + node_type: 'CLIPLoader', + widget_index: 0, + original_path: 'current.safetensors', + active: true, + connected: true, + is_top_level: true, + }], + total: 1, + }); + await refreshPromise; + + assert.equal(renderCount, 1); + assert.equal(context.imageInspectorState.transfer.refreshing, false); + assert.equal(context.imageInspectorState.transfer.targetGroups[0].nodes[0].nodeKey, 'top:1'); + assert.deepEqual( + Array.from(context.imageInspectorState.transfer.selectedNodeKeys), + ['top:1'] + ); +}); + test('metadata transfer uses the shared canonical category aliases', () => { const context = createTransferContext(); const state = createTransferState([ diff --git a/web/resolver.js b/web/resolver.js index 89bf4d9..457d57e 100644 --- a/web/resolver.js +++ b/web/resolver.js @@ -39,6 +39,8 @@ app.registerExtension({ queueMicrotask(() => modelResolver.configureNodeContextMenu(node)); modelResolver.configureWorkflowDependencyMarkerNode(node); modelResolver.configureCustomNodeModelAdapter(node); + modelResolver.configureWorkflowNodeLifecycle(node); + modelResolver.dialog?.scheduleActiveWorkflowRefresh?.('node-created'); }, loadedGraphNode(node) { modelResolver.configureNodeContextMenu(node?.constructor); @@ -46,6 +48,7 @@ app.registerExtension({ queueMicrotask(() => modelResolver.configureNodeContextMenu(node)); modelResolver.configureWorkflowDependencyMarkerNode(node); modelResolver.configureCustomNodeModelAdapter(node); + modelResolver.configureWorkflowNodeLifecycle(node); }, afterConfigureGraph() { modelResolver.handleGraphConfigured(); diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 1f1c916..1cadb8d 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -667,6 +667,32 @@ export class ModelResolver { adapter.observe(node, notifyIfModelsChanged); } + configureWorkflowNodeLifecycle(node) { + if ( + !node + || typeof node !== 'object' + || node.__modelResolverWorkflowLifecyclePatched + || typeof node.onRemoved !== 'function' + ) return; + + const owner = this; + const originalOnRemoved = node.onRemoved; + try { + node.onRemoved = function(...args) { + let result; + try { + result = originalOnRemoved.apply(this, args); + } finally { + owner.dialog?.scheduleActiveWorkflowRefresh?.('node-removed'); + } + return result; + }; + node.__modelResolverWorkflowLifecyclePatched = true; + } catch (_error) { + log.debug('Model Resolver: could not attach workflow lifecycle tracking to a node.'); + } + } + waitForResolverDialogReady(timeoutMs = 2500) { const startedAt = Date.now(); return new Promise((resolve) => { diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index bda04a3..9a3f134 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -872,7 +872,7 @@ export const workflowStateMethods = { && missingSignature !== this.activeMissingWorkflowSignature; const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; const workflowChangeStillPending = ( - reason === 'node-widget-change' + String(reason || '').startsWith('node-') && !routeChanged && !signatureChanged ); @@ -940,7 +940,9 @@ export const workflowStateMethods = { }); } else if (this.activeTab === 'metadata') { if (this.contentElement) this.contentElement.style.overflowY = 'auto'; - await this.openMetadataTransfer?.(); + await this.openMetadataTransfer?.({ + preserveContent: !routeChanged && String(reason || '').startsWith('node-'), + }); } } }; diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 23b341a..3e3565d 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -1346,6 +1346,7 @@ export const imageMetadataMethods = { const hasTransferableTarget = previewRows.length > 0; const applyDisabled = ( transfer.applying + || transfer.refreshing || !sourceEntries.length || !selectedTargetCount || !hasTransferableTarget @@ -1400,7 +1401,7 @@ export const imageMetadataMethods = { `; }, - async openMetadataTransfer({ force = false } = {}) { + async openMetadataTransfer({ force = false, preserveContent = false } = {}) { const state = getImageInspectorState(this); const imageRequestToken = state.requestToken; const workflow = this.getCurrentWorkflow?.(); @@ -1409,10 +1410,17 @@ export const imageMetadataMethods = { : ''; if (workflow) this.syncWorkflowScopedQueue?.(workflow); const previousTransfer = state.transfer; + const keepCurrentTransferContent = Boolean(preserveContent && previousTransfer); const preserveSelection = Boolean( - workflowSignature - && previousTransfer?.workflowSignature - && previousTransfer.workflowSignature === workflowSignature + previousTransfer + && ( + preserveContent + || ( + workflowSignature + && previousTransfer.workflowSignature + && previousTransfer.workflowSignature === workflowSignature + ) + ) ); const previousSourceSelections = previousTransfer?.sourceSelections; const savedPreferences = this.getMetadataTransferPreferences(); @@ -1426,7 +1434,8 @@ export const imageMetadataMethods = { const token = `transfer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; state.transfer = { open: true, - loading: Boolean(workflow), + loading: Boolean(workflow) && !keepCurrentTransferContent, + refreshing: Boolean(workflow) && keepCurrentTransferContent, applying: false, error: '', mode: normalizeTransferMode(previousTransfer?.mode ?? savedPreferences.mode), @@ -1444,7 +1453,14 @@ export const imageMetadataMethods = { : new Set(), sourceSelections, }; - this.renderImageMetadataResult(); + if (keepCurrentTransferContent) { + state.transfer.targetModels = previousTransfer.targetModels || null; + state.transfer.targetGroups = Array.isArray(previousTransfer.targetGroups) + ? previousTransfer.targetGroups + : []; + } else { + this.renderImageMetadataResult(); + } if (!workflow) { state.transfer.loading = false; @@ -1479,6 +1495,7 @@ export const imageMetadataMethods = { this.saveLoadedModelsCacheForActiveWorkflow?.(); } state.transfer.loading = false; + state.transfer.refreshing = false; state.transfer.targetModels = targetModels; state.transfer.targetGroups = this.getMetadataTransferTargetGroups(targetModels); const sourceEntries = this.getMetadataTransferSourceModels(state.loadedModels); @@ -1505,6 +1522,7 @@ export const imageMetadataMethods = { || state.transfer?.requestToken !== token ) return null; state.transfer.loading = false; + state.transfer.refreshing = false; state.transfer.error = error?.message || 'Could not scan the current workflow.'; this.renderImageMetadataResult(); return null; @@ -1562,7 +1580,7 @@ export const imageMetadataMethods = { async applyMetadataTransfer() { const state = getImageInspectorState(this); const transfer = state.transfer; - if (!transfer || transfer.loading || transfer.applying) return null; + if (!transfer || transfer.loading || transfer.refreshing || transfer.applying) return null; const mode = normalizeTransferMode(transfer.mode); const activityScope = normalizeTransferActivityScope(transfer.activityScope);