diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs index 02f2ea9..adea495 100644 --- a/tests/test_dom_patchers.mjs +++ b/tests/test_dom_patchers.mjs @@ -48,6 +48,7 @@ const syncSearchElementAttributes = eval(`(${extractMethod(resolveDownloadMethod const getSearchResultsPatchKind = eval(`(${extractMethod(resolveDownloadMethodsSource, 'getSearchResultsPatchKind')})`); const wireSearchDownloadButtons = eval(`(${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')})`); const patchLocalMatchesContainer = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchLocalMatchesContainer')})`); +const resolveUrnAsync = eval(`(${extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync')})`); const patchQueuedSelections = eval(`(${extractMethod(queueMethodsSource, 'patchQueuedSelections')})`); const patchDownloadsPanelElement = eval(`(${extractMethod(queueMethodsSource, 'patchDownloadsPanelElement')})`); const patchLoadedModelsProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'patchLoadedModelsProgress')})`); @@ -226,3 +227,61 @@ test('search download button wiring remains idempotent on the same node', () => assert.equal(calls, 1); }); + +test('stale URN responses cannot overwrite the latest UI request', async () => { + const window = new Window(); + const previousDocument = globalThis.document; + const previousLog = globalThis.log; + const previousConsoleError = console.error; + const urnErrors = []; + globalThis.document = window.document; + globalThis.log = { debug() {} }; + console.error = (...args) => urnErrors.push(args); + + try { + const loading = window.document.createElement('div'); + loading.id = 'urn-loading-1'; + const download = window.document.createElement('div'); + download.id = 'urn-download-1'; + window.document.body.append(loading, download); + + const resolvers = []; + const dialog = { + missingModels: [], + urnResolveUiTokens: new Map(), + getStoredTokens() { + return {}; + }, + fetchJson() { + return new Promise(resolve => resolvers.push(resolve)); + }, + renderVersionedModelNameHtml(name, version) { + return `${name} ${version}`; + }, + escapeHtml(value) { + return String(value); + }, + }; + + const first = resolveUrnAsync.call(dialog, '1', '1', loading.id, 'https://example.test/one'); + await Promise.resolve(); + const second = resolveUrnAsync.call(dialog, '1', '2', loading.id, 'https://example.test/two'); + await Promise.resolve(); + + resolvers[1]({ civitai: { name: 'Latest', version_name: 'v2', download_url: 'latest' } }); + await second; + assert.equal(urnErrors.length, 0, urnErrors.map(args => args.map(String).join(' ')).join('\n')); + assert.match(loading.textContent, /Latest v2/); + + resolvers[0]({ civitai: { name: 'Stale', version_name: 'v1', download_url: 'stale' } }); + await first; + assert.match(loading.textContent, /Latest v2/); + assert.doesNotMatch(loading.textContent, /Stale v1/); + } finally { + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + if (previousLog === undefined) delete globalThis.log; + else globalThis.log = previousLog; + console.error = previousConsoleError; + } +}); diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 7cff334..27991b2 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -5245,6 +5245,21 @@ test('URN resolution preserves the download panel container', () => { assert.match(resolveUrnAsync, /wireDownloadSearchPanel\(downloadEl, missing\)/); }); +test('search and URN completion ignore stale UI runs', () => { + const searchStart = resolveDownloadMethodsSource.indexOf('async searchOnline'); + const urnStart = resolveDownloadMethodsSource.indexOf('async resolveUrnAsync'); + const searchOnline = resolveDownloadMethodsSource.slice(searchStart, urnStart); + const resolveUrnAsync = extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync'); + + assert.match(searchOnline, /isCurrentSearchRun/); + assert.match(searchOnline, /currentSearchRun/); + assert.match(searchOnline, /if \(searchRunId && !isCurrentSearchRun\(\)\) return/); + assert.match(searchOnline, /currentSearchRun && searchBtn\?\.isConnected !== false/); + assert.match(resolveUrnAsync, /urnResolveUiTokens/); + assert.match(resolveUrnAsync, /isCurrentUrnUi/); + assert.match(resolveUrnAsync, /else if \(data\) \{\s*return;/); +}); + test('local match status group warns when match folder is unsupported by node', () => { const renderLocalMatchStatus = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchStatus')})`); const getLocalMatchCategory = eval(`(${extractMethod(searchPanelMethodsSource, 'getLocalMatchCategory')})`); diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 1ea5b04..0a4740f 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -2456,6 +2456,10 @@ export const resolveDownloadMethods = { : null; let searchRunId = null; let completedSearchRun = false; + const isCurrentSearchRun = () => ( + !searchRunId + || this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId) + ); try { if (!sourceIds.length) { @@ -2776,13 +2780,18 @@ export const resolveDownloadMethods = { console.error('Model Resolver: Search error:', error); this.clearSearchProgressTimers(searchRunId); this.clearBackendSearchProgressTimers?.(searchRunId); + if (searchRunId && !isCurrentSearchRun()) return; state.lastAttemptError = error.message; this.persistSearchStateForWorkflow(workflowKey, missing, state); - if (resultsDiv) { - resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error'); + if (resultsDiv?.isConnected !== false) { + this.patchSearchResultsContainer?.( + resultsDiv, + this.renderStatusMessage(`Search failed: ${error.message}`, 'error') + ); } } finally { - if (!searchRunId || this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) { + const currentSearchRun = isCurrentSearchRun(); + if (!searchRunId || currentSearchRun) { this.clearSearchProgressTimers(searchRunId); this.clearBackendSearchProgressTimers?.(searchRunId); state.activeSearchRunId = null; @@ -2796,7 +2805,7 @@ export const resolveDownloadMethods = { this.backgroundSearchJobs.delete(jobKey); } } - if (!completedSearchRun) { + if (!completedSearchRun && currentSearchRun) { this.settleInactiveSearchProgress?.(missing, state, { workflowKey, message: state.lastAttemptError || 'Search interrupted', @@ -2804,12 +2813,14 @@ export const resolveDownloadMethods = { refresh: false }); } - if (searchBtn) { + if (currentSearchRun && searchBtn?.isConnected !== false) { searchBtn.disabled = false; searchBtn.innerHTML = this.renderSearchButtonContent('Search Again'); } - this.persistSearchStateForWorkflow(workflowKey, missing, state); - this.refreshSearchUiForMissing(missing, state, { workflowKey }); + if (currentSearchRun) { + this.persistSearchStateForWorkflow(workflowKey, missing, state); + this.refreshSearchUiForMissing(missing, state, { workflowKey }); + } } }, @@ -2823,6 +2834,14 @@ export const resolveDownloadMethods = { return; } + if (!(this.urnResolveUiTokens instanceof Map)) { + this.urnResolveUiTokens = new Map(); + } + const uiTokenKey = String(loadingElementId || `urn:${modelId}:${versionId}`); + const uiToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + this.urnResolveUiTokens.set(uiTokenKey, uiToken); + const isCurrentUrnUi = () => this.urnResolveUiTokens.get(uiTokenKey) === uiToken; + try { const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); const missing = this.missingModels.find(m => @@ -2853,7 +2872,7 @@ export const resolveDownloadMethods = { log.debug('resolveUrnAsync search completed'); } - if (data) { + if (data && isCurrentUrnUi()) { const loadingEl = document.getElementById(loadingElementId); if (loadingEl && data.civitai) { loadingEl.classList.remove('mr-is-muted', 'mr-is-error'); @@ -2880,6 +2899,8 @@ export const resolveDownloadMethods = { } else if (downloadEl) { downloadEl.innerHTML = `