From e7ee26cad8dbcc9e3f48bdf0ded0014d3f330d71 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 19 Jul 2026 15:10:12 +0200 Subject: [PATCH] refactor: preserve downloads panel DOM state during active refresh Patch the panel element instead of replacing innerHTML, maintaining hover and tooltip state for active download cards. --- tests/test_downloads_tab_workflow_route.mjs | 40 +++++---- web/resolver/actions/queue_methods.js | 92 ++++++++++++++++++++- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index fc4d044..78267e5 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -608,14 +608,8 @@ test('downloads tab shows active downloads from all workflow tabs', () => { }); test('downloads tab renders workflow label for active downloads', () => { - const renderQueueDownloads = eval(`(${extractMethod(queueMethodsSource, 'renderQueueDownloads')})`); + const renderQueueDownloadsHtml = eval(`(${extractMethod(queueMethodsSource, 'renderQueueDownloadsHtml')})`); const dialog = { - queueList: { - innerHTML: '', - querySelectorAll() { - return []; - }, - }, escapeHtml(value) { return String(value).replace(/[&<>\"]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[char])); }, @@ -631,15 +625,23 @@ test('downloads tab renders workflow label for active downloads', () => { getDownloadFolderContext() { return null; }, - renderQueueDownloadsHtml(downloads) { - const info = downloads[0]?.info || {}; - const workflowLabel = this.getDownloadWorkflowLabel(info); - return `Workflow ${workflowLabel}`; + getDownloadDisplayProgress(progress) { + return { + percent: progress.progress || 0, + downloaded: progress.downloaded || 0, + totalSize: progress.total_size || 0, + isFinalizing: false, + }; + }, + formatDownloadProgressMeta() { + return ''; + }, + getDownloadQueueContext() { + return null; }, - wireDownloadsPanelControls() {}, }; - renderQueueDownloads.call(dialog, [{ + const html = renderQueueDownloadsHtml.call(dialog, [{ downloadId: 'download-1', info: { workflowLabel: 'Workflow B', @@ -648,8 +650,16 @@ test('downloads tab renders workflow label for active downloads', () => { }, }]); - assert.match(dialog.queueList.innerHTML, /Workflow<\/span>/); - assert.match(dialog.queueList.innerHTML, /Workflow B/); + assert.match(html, /Workflow<\/span>/); + assert.match(html, /Workflow B/); +}); + +test('active download refresh preserves existing cards for hover and tooltip state', () => { + const renderQueueDownloadsHtml = extractMethod(queueMethodsSource, 'renderQueueDownloadsHtml'); + + assert.match(queueMethodsSource, /patchDownloadsPanelElement\(currentPanel, nextPanel\)/); + assert.match(renderQueueDownloadsHtml, /data-download-id=/); + assert.match(queueMethodsSource, /patchDownloadsPanelElement\(currentElement, nextElement\)/); }); test('active workflow label prefers selected ComfyUI workflow tab name over route hash', () => { diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index 2ce9982..1fd0575 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -1161,7 +1161,7 @@ export const queueMethods = { renderDownloadsPanel(downloads = [], history = this.getDownloadHistory()) { const activeSubTab = this.queueDownloadsActiveTab === 'history' ? 'history' : 'active'; const activeSelected = activeSubTab === 'active'; - let html = '
'; + let html = `
`; html += '
'; html += ``; html += ``; @@ -1171,10 +1171,98 @@ export const queueMethods = { : this.renderDownloadHistoryHtml(history); html += '
'; + const currentPanel = this.queueList.querySelector?.(`.mr-downloads-panel[data-downloads-view="${activeSubTab}"]`); + if (currentPanel && typeof document !== 'undefined') { + const template = document.createElement('template'); + template.innerHTML = html; + const nextPanel = template.content.firstElementChild; + if (nextPanel) { + this.patchDownloadsPanelElement(currentPanel, nextPanel); + this.wireDownloadsPanelControls(); + return; + } + } + this.queueList.innerHTML = html; this.wireDownloadsPanelControls(); }, + patchDownloadsPanelElement(currentElement, nextElement) { + const syncAttributes = (current, next) => { + const nextAttributeNames = new Set(Array.from(next.attributes || [], attribute => attribute.name)); + for (const attribute of Array.from(next.attributes || [])) { + if (current.getAttribute(attribute.name) !== attribute.value) { + current.setAttribute(attribute.name, attribute.value); + } + } + for (const attribute of Array.from(current.attributes || [])) { + if (!nextAttributeNames.has(attribute.name)) { + current.removeAttribute(attribute.name); + } + } + }; + const getNodeKey = (node) => { + if (node?.nodeType !== 1) return ''; + return node.getAttribute?.('data-download-id') || ''; + }; + const canPatchNode = (current, next) => { + if (!current || current.nodeType !== next.nodeType) return false; + if (current.nodeType !== 1) return true; + if (current.tagName !== next.tagName) return false; + if ((current.getAttribute('class') || '') !== (next.getAttribute('class') || '')) return false; + const currentKey = getNodeKey(current); + const nextKey = getNodeKey(next); + return !currentKey && !nextKey ? true : currentKey === nextKey; + }; + const patchNode = (current, next) => { + if (!canPatchNode(current, next)) { + const replacement = next.cloneNode(true); + current.replaceWith(replacement); + return replacement; + } + if (current.nodeType !== 1) { + if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue; + return current; + } + + syncAttributes(current, next); + patchChildren(current, next); + return current; + }; + const patchChildren = (current, next) => { + const nextChildren = Array.from(next.childNodes || []); + let cursor = current.firstChild; + + for (const nextChild of nextChildren) { + const nextKey = getNodeKey(nextChild); + let currentChild = cursor; + if (nextKey) { + currentChild = Array.from(current.childNodes || []).find(child => getNodeKey(child) === nextKey) || null; + if (currentChild && currentChild !== cursor) { + current.insertBefore(currentChild, cursor); + } + } + + if (!currentChild) { + current.appendChild(nextChild.cloneNode(true)); + cursor = null; + continue; + } + + const patchedChild = patchNode(currentChild, nextChild); + cursor = patchedChild.nextSibling; + } + + while (cursor) { + const nextSibling = cursor.nextSibling; + cursor.remove(); + cursor = nextSibling; + } + }; + + patchNode(currentElement, nextElement); + }, + wireDownloadsPanelControls() { const bindInstantAction = (button, handler) => { if (!button || button._hasListener) return; @@ -1285,7 +1373,7 @@ export const queueMethods = { ? this.getContextMenuAttrs(contextModel, contextTooltip) : ''; - html += `
`; + html += `
`; html += `
`; html += `${this.escapeHtml(filename)}`; html += `${this.escapeHtml(statusLabel)}`;