fix(frontend): ignore stale search and URN UI responses
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user