Tighten Soulseek handoff + per-source request tokens after self-audit
Two bugs in the previous review-fix commits, found during a Cin-standard re-audit: A) Soulseek handoff stale state.query overrode the global widget's query The previous fix pre-set basicInput.value before clicking the Search page's Soulseek icon. But the click triggers onSoulseekSelected with the controller's CURRENT state.query — which is whatever the user last typed on /search, not the global widget's query. The Search page callback then ran `if (query) basicInput.value = query;` and overwrote the just-set value with the stale one before firing performDownloadsSearch. Fix: expose searchController as `_searchPageController` (mirrors `_searchPageRestoreOnEnter` already at module scope). Global widget's _gsNavigateToSearchPage syncs `_searchPageController.state.query` to its own query before clicking the icon. Also added a fallback for the case where the icon doesn't exist yet (controller still mid-init): swap sections + run performDownloadsSearch directly. B) Single _requestSeq token leaked loadingSources across sources The earlier "stale request" fix used one global _requestSeq. But when the user switched Spotify → Deezer mid-fetch, the Spotify abort's catch block bailed (1 !== 2), leaving 'spotify' in loadingSources forever — permanent spinner on the Spotify icon even though no fetch was running for it. Fix: per-source `_sourceRequestIds[src]` map. Same-source supersession bails (correct), cross-source supersession still clears the old source's loadingSources entry (correct). Bonus defensive: submitQuery now invalidates every per-source token and aborts the in-flight fetch when the query string changes. Catches the residual edge case where user clears the input — the in-flight fetch's settle would otherwise write stale data into the just-cleared state.sources.
This commit is contained in:
parent
325292ce5a
commit
527b51d69b
3 changed files with 74 additions and 28 deletions
|
|
@ -5188,15 +5188,35 @@ function _gsNavigateToSearchPage(query, src) {
|
|||
// metadata results when they clicked the Soulseek icon.
|
||||
setTimeout(() => {
|
||||
if (src === 'soulseek') {
|
||||
// Pre-fill the basic input first, then click the Search page's
|
||||
// Soulseek icon. The icon's click triggers the controller's
|
||||
// onSoulseekSelected callback, which owns the section swap and
|
||||
// re-runs performDownloadsSearch with whatever's in the basic
|
||||
// input (i.e., the value we just wrote).
|
||||
const basicInput = document.getElementById('downloads-search-input');
|
||||
if (basicInput && query) basicInput.value = query;
|
||||
|
||||
// Sync the Search page controller's state.query to the widget's
|
||||
// query BEFORE clicking the Soulseek icon. Otherwise the icon
|
||||
// click fires onSoulseekSelected(state.query) where state.query
|
||||
// is whatever the user last typed on /search (often stale), and
|
||||
// the callback would overwrite basicInput.value with that stale
|
||||
// value before running performDownloadsSearch.
|
||||
if (typeof _searchPageController !== 'undefined' && _searchPageController) {
|
||||
_searchPageController.state.query = query || '';
|
||||
}
|
||||
|
||||
const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]');
|
||||
if (soulseekIcon) soulseekIcon.click();
|
||||
if (soulseekIcon) {
|
||||
soulseekIcon.click();
|
||||
return;
|
||||
}
|
||||
// Fallback: controller hasn't initialized yet (slow /api/settings
|
||||
// fetches at first /search visit). Run the search directly + swap
|
||||
// sections so the user still gets results. Icon row will catch up
|
||||
// visually on the next render.
|
||||
const basicSection = document.getElementById('basic-search-section');
|
||||
const enhancedSection = document.getElementById('enhanced-search-section');
|
||||
if (basicSection) basicSection.classList.add('active');
|
||||
if (enhancedSection) enhancedSection.classList.remove('active');
|
||||
if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') {
|
||||
performDownloadsSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('enhanced-search-input');
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ let searchModeToggleInitialized = false;
|
|||
// click is treated as outside-click and dismisses the dropdown, so when
|
||||
// the user returns to /search we need to re-render whatever was cached.
|
||||
let _searchPageRestoreOnEnter = null;
|
||||
// Exposed so the global-search widget's Soulseek handoff can sync the
|
||||
// controller's state.query to the widget's query before clicking the
|
||||
// Soulseek icon — otherwise onSoulseekSelected fires with whatever the
|
||||
// user last typed on /search and overwrites the basic input.
|
||||
let _searchPageController = null;
|
||||
|
||||
function initializeSearchModeToggle() {
|
||||
// Subsequent invocations: just re-display cached results so they don't
|
||||
|
|
@ -207,6 +212,7 @@ function initializeSearchModeToggle() {
|
|||
},
|
||||
});
|
||||
searchController.init();
|
||||
_searchPageController = searchController;
|
||||
|
||||
// Expose a re-render hook so navigate-back to /search restores cached
|
||||
// results instead of leaving the dropdown hidden. Deferred to the next
|
||||
|
|
|
|||
|
|
@ -175,13 +175,20 @@ function createSearchController({
|
|||
for (const src of SOURCE_ORDER) state.configuredSources[src] = true;
|
||||
|
||||
let abortCtrl = null;
|
||||
// Monotonic request token. Each _fetchSource call captures the next
|
||||
// value; settle/error blocks bail before mutating shared state if a
|
||||
// newer request has superseded them. Without this, a fast retype lets
|
||||
// the in-flight fetch's catch (or settle) clear loadingSources / write
|
||||
// stale data into state.sources, causing a flash of empty/error UI
|
||||
// while the new query's fetch is still running.
|
||||
// Per-source request tokens. Each _fetchSource call increments the
|
||||
// monotonic _requestSeq and stamps it into _sourceRequestIds[src].
|
||||
// Settle/error blocks bail before mutating shared state if their
|
||||
// requestId no longer matches the latest id for THAT source —
|
||||
// protecting against the fast-retype race (same-source supersession)
|
||||
// without dropping cleanup for cross-source supersession.
|
||||
//
|
||||
// A single global token would mishandle cross-source: switching
|
||||
// Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs
|
||||
// to clear 'spotify' from loadingSources (Deezer's request hasn't
|
||||
// touched it). Per-source tracking lets each source's catch own its
|
||||
// own loadingSources entry.
|
||||
let _requestSeq = 0;
|
||||
const _sourceRequestIds = Object.create(null);
|
||||
|
||||
function _notify() { if (onStateChange) onStateChange(state); }
|
||||
|
||||
|
|
@ -316,6 +323,7 @@ function createSearchController({
|
|||
if (!query) return;
|
||||
|
||||
const requestId = ++_requestSeq;
|
||||
_sourceRequestIds[src] = requestId;
|
||||
|
||||
state.loadingSources.add(src);
|
||||
renderSourceRow();
|
||||
|
|
@ -332,8 +340,11 @@ function createSearchController({
|
|||
source: src,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
// Bail without writing if a newer query has superseded us.
|
||||
if (requestId !== _requestSeq) return;
|
||||
// Bail without writing if a newer request for THIS source
|
||||
// has superseded us. Cross-source supersession (different
|
||||
// src entirely) is handled by the loadingSources cleanup
|
||||
// below — each source's catch owns its own entry.
|
||||
if (_sourceRequestIds[src] !== requestId) return;
|
||||
state.sources[src] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
|
|
@ -345,18 +356,20 @@ function createSearchController({
|
|||
if (served && served !== src) state.fallbacks[src] = served;
|
||||
}
|
||||
|
||||
// Only the latest request gets to clear loadingSources + notify.
|
||||
// A stale completion would otherwise wipe the spinner the new
|
||||
// request just set.
|
||||
if (requestId !== _requestSeq) return;
|
||||
if (_sourceRequestIds[src] !== requestId) return;
|
||||
state.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
} catch (err) {
|
||||
if (requestId !== _requestSeq) return;
|
||||
state.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
// Only clear loadingSources if no newer request for THIS source
|
||||
// is in flight. Cross-source supersession (e.g. user switched
|
||||
// Spotify → Deezer) still falls through here so Spotify's
|
||||
// spinner gets cleared on its own AbortError.
|
||||
if (_sourceRequestIds[src] === requestId) {
|
||||
state.loadingSources.delete(src);
|
||||
renderSourceRow();
|
||||
_notify();
|
||||
}
|
||||
if (err.name !== 'AbortError') {
|
||||
console.debug(`Source fetch failed for ${src}:`, err);
|
||||
}
|
||||
|
|
@ -372,8 +385,9 @@ function createSearchController({
|
|||
});
|
||||
if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`);
|
||||
|
||||
// Bail before allocating cache entry if superseded by a newer request.
|
||||
if (requestId !== _requestSeq) return;
|
||||
// Bail before allocating cache entry if a newer YouTube request
|
||||
// has superseded us.
|
||||
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
||||
|
||||
state.sources['youtube_videos'] = {
|
||||
artists: [], albums: [], tracks: [], videos: [], db_artists: [],
|
||||
|
|
@ -386,9 +400,7 @@ function createSearchController({
|
|||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Mid-stream supersession check — abort cleanly without writing
|
||||
// additional chunks into stale cache.
|
||||
if (requestId !== _requestSeq) return;
|
||||
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n')) !== -1) {
|
||||
|
|
@ -399,7 +411,6 @@ function createSearchController({
|
|||
const chunk = JSON.parse(line);
|
||||
if (chunk.type === 'videos') {
|
||||
cache.videos = chunk.data;
|
||||
// Live-render if still the active source.
|
||||
if (state.activeSource === 'youtube_videos') _notify();
|
||||
}
|
||||
} catch (_) { /* best-effort NDJSON parse */ }
|
||||
|
|
@ -413,6 +424,15 @@ function createSearchController({
|
|||
state.sources = {};
|
||||
state.fallbacks = {};
|
||||
state.loadingSources = new Set();
|
||||
// Invalidate every in-flight per-source token. Without this, a
|
||||
// settle that arrives AFTER a query reset (e.g. user typed 'a',
|
||||
// fetch started, then user cleared the input) would still
|
||||
// pass the per-source token check and write stale data back
|
||||
// into the just-cleared state.sources. Setting fresh tokens
|
||||
// when each new _fetchSource fires re-stamps as needed.
|
||||
for (const k in _sourceRequestIds) delete _sourceRequestIds[k];
|
||||
// Abort the active fetch — its results are useless now.
|
||||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
||||
renderSourceRow();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue