fix(frontend): ignore stale search and URN UI responses

This commit is contained in:
Dariusz L
2026-08-04 20:08:53 +02:00
parent c8155d955c
commit 746f7d3ec4
3 changed files with 108 additions and 8 deletions
+59
View File
@@ -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;
}
});
@@ -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')})`);
@@ -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 = `<div class="mr-download-info">Unable to resolve direct download for this URN.</div>`;
}
} else if (data) {
return;
} else {
const loadingEl = document.getElementById(loadingElementId);
if (loadingEl) {
@@ -2895,6 +2916,7 @@ export const resolveDownloadMethods = {
}
} catch (error) {
console.error('Model Resolver: URN resolve error:', error);
if (!isCurrentUrnUi()) return;
const loadingEl = document.getElementById(loadingElementId);
if (loadingEl) {
loadingEl.textContent = 'Error';
@@ -2906,6 +2928,10 @@ export const resolveDownloadMethods = {
if (downloadEl) {
downloadEl.innerHTML = `<div class="mr-download-info">Failed to resolve URN download.</div>`;
}
} finally {
if (isCurrentUrnUi()) {
this.urnResolveUiTokens.delete(uiTokenKey);
}
}
},