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.
|
// metadata results when they clicked the Soulseek icon.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (src === 'soulseek') {
|
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');
|
const basicInput = document.getElementById('downloads-search-input');
|
||||||
if (basicInput && query) basicInput.value = query;
|
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"]');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const input = document.getElementById('enhanced-search-input');
|
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
|
// 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.
|
// the user returns to /search we need to re-render whatever was cached.
|
||||||
let _searchPageRestoreOnEnter = null;
|
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() {
|
function initializeSearchModeToggle() {
|
||||||
// Subsequent invocations: just re-display cached results so they don't
|
// Subsequent invocations: just re-display cached results so they don't
|
||||||
|
|
@ -207,6 +212,7 @@ function initializeSearchModeToggle() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
searchController.init();
|
searchController.init();
|
||||||
|
_searchPageController = searchController;
|
||||||
|
|
||||||
// Expose a re-render hook so navigate-back to /search restores cached
|
// Expose a re-render hook so navigate-back to /search restores cached
|
||||||
// results instead of leaving the dropdown hidden. Deferred to the next
|
// 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;
|
for (const src of SOURCE_ORDER) state.configuredSources[src] = true;
|
||||||
|
|
||||||
let abortCtrl = null;
|
let abortCtrl = null;
|
||||||
// Monotonic request token. Each _fetchSource call captures the next
|
// Per-source request tokens. Each _fetchSource call increments the
|
||||||
// value; settle/error blocks bail before mutating shared state if a
|
// monotonic _requestSeq and stamps it into _sourceRequestIds[src].
|
||||||
// newer request has superseded them. Without this, a fast retype lets
|
// Settle/error blocks bail before mutating shared state if their
|
||||||
// the in-flight fetch's catch (or settle) clear loadingSources / write
|
// requestId no longer matches the latest id for THAT source —
|
||||||
// stale data into state.sources, causing a flash of empty/error UI
|
// protecting against the fast-retype race (same-source supersession)
|
||||||
// while the new query's fetch is still running.
|
// 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;
|
let _requestSeq = 0;
|
||||||
|
const _sourceRequestIds = Object.create(null);
|
||||||
|
|
||||||
function _notify() { if (onStateChange) onStateChange(state); }
|
function _notify() { if (onStateChange) onStateChange(state); }
|
||||||
|
|
||||||
|
|
@ -316,6 +323,7 @@ function createSearchController({
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
const requestId = ++_requestSeq;
|
const requestId = ++_requestSeq;
|
||||||
|
_sourceRequestIds[src] = requestId;
|
||||||
|
|
||||||
state.loadingSources.add(src);
|
state.loadingSources.add(src);
|
||||||
renderSourceRow();
|
renderSourceRow();
|
||||||
|
|
@ -332,8 +340,11 @@ function createSearchController({
|
||||||
source: src,
|
source: src,
|
||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
});
|
});
|
||||||
// Bail without writing if a newer query has superseded us.
|
// Bail without writing if a newer request for THIS source
|
||||||
if (requestId !== _requestSeq) return;
|
// 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] = {
|
state.sources[src] = {
|
||||||
artists: data.spotify_artists || [],
|
artists: data.spotify_artists || [],
|
||||||
albums: data.spotify_albums || [],
|
albums: data.spotify_albums || [],
|
||||||
|
|
@ -345,18 +356,20 @@ function createSearchController({
|
||||||
if (served && served !== src) state.fallbacks[src] = served;
|
if (served && served !== src) state.fallbacks[src] = served;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the latest request gets to clear loadingSources + notify.
|
if (_sourceRequestIds[src] !== requestId) return;
|
||||||
// A stale completion would otherwise wipe the spinner the new
|
|
||||||
// request just set.
|
|
||||||
if (requestId !== _requestSeq) return;
|
|
||||||
state.loadingSources.delete(src);
|
state.loadingSources.delete(src);
|
||||||
renderSourceRow();
|
renderSourceRow();
|
||||||
_notify();
|
_notify();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== _requestSeq) return;
|
// Only clear loadingSources if no newer request for THIS source
|
||||||
state.loadingSources.delete(src);
|
// is in flight. Cross-source supersession (e.g. user switched
|
||||||
renderSourceRow();
|
// Spotify → Deezer) still falls through here so Spotify's
|
||||||
_notify();
|
// spinner gets cleared on its own AbortError.
|
||||||
|
if (_sourceRequestIds[src] === requestId) {
|
||||||
|
state.loadingSources.delete(src);
|
||||||
|
renderSourceRow();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
console.debug(`Source fetch failed for ${src}:`, err);
|
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}`);
|
if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`);
|
||||||
|
|
||||||
// Bail before allocating cache entry if superseded by a newer request.
|
// Bail before allocating cache entry if a newer YouTube request
|
||||||
if (requestId !== _requestSeq) return;
|
// has superseded us.
|
||||||
|
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
||||||
|
|
||||||
state.sources['youtube_videos'] = {
|
state.sources['youtube_videos'] = {
|
||||||
artists: [], albums: [], tracks: [], videos: [], db_artists: [],
|
artists: [], albums: [], tracks: [], videos: [], db_artists: [],
|
||||||
|
|
@ -386,9 +400,7 @@ function createSearchController({
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
// Mid-stream supersession check — abort cleanly without writing
|
if (_sourceRequestIds['youtube_videos'] !== requestId) return;
|
||||||
// additional chunks into stale cache.
|
|
||||||
if (requestId !== _requestSeq) return;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
let idx;
|
let idx;
|
||||||
while ((idx = buffer.indexOf('\n')) !== -1) {
|
while ((idx = buffer.indexOf('\n')) !== -1) {
|
||||||
|
|
@ -399,7 +411,6 @@ function createSearchController({
|
||||||
const chunk = JSON.parse(line);
|
const chunk = JSON.parse(line);
|
||||||
if (chunk.type === 'videos') {
|
if (chunk.type === 'videos') {
|
||||||
cache.videos = chunk.data;
|
cache.videos = chunk.data;
|
||||||
// Live-render if still the active source.
|
|
||||||
if (state.activeSource === 'youtube_videos') _notify();
|
if (state.activeSource === 'youtube_videos') _notify();
|
||||||
}
|
}
|
||||||
} catch (_) { /* best-effort NDJSON parse */ }
|
} catch (_) { /* best-effort NDJSON parse */ }
|
||||||
|
|
@ -413,6 +424,15 @@ function createSearchController({
|
||||||
state.sources = {};
|
state.sources = {};
|
||||||
state.fallbacks = {};
|
state.fallbacks = {};
|
||||||
state.loadingSources = new Set();
|
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();
|
renderSourceRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue