From b498755067200d3f8d788f4ec28c8f6019f8bbfb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 01:55:05 +0200 Subject: [PATCH] feat(search): keep scrape-sourced YouTube results across a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live YouTube search was always dropped on reload so it could never re-spend quota. But the default scrape source costs no quota, so there's no reason to bounce the user back to the feed — they can stay on their results. Feed now stamps history.state._ytScrape=true once a search resolves via the scrape source; App restores _yt on reload only when that flag is set (and re-runs the search, which is free). An api-source search is left unmarked and still drops to the feed on reload, since re-fetching it would cost ~100 units. The flag is cleared when a new search starts (Feed re-stamps it once the new results' source is known). --- frontend/src/App.tsx | 29 +++++++++++++++++++++-------- frontend/src/components/Feed.tsx | 12 ++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73d36d3..18aa039 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -130,10 +130,18 @@ export default function App() { // popstate handler below derives ytSearch from it, so Back steps player → search → feed in // that order (a player opened over the results pops first, then the search, then the feed) — // instead of the search vanishing because it had no history entry of its own. - const [ytSearch, setYtSearch] = useState(null); + const [ytSearch, setYtSearch] = useState(() => { + // Restore a live search across a reload ONLY when it was served by the zero-quota scrape + // source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's + // dropped to the normal feed as before. The scrape flag rides in history.state (survives F5). + const st = window.history.state; + return st?._yt && st?._ytScrape ? (st._yt as string) : null; + }); const enterYtSearch = useCallback((q: string) => { setYtSearch(q); - const st = window.history.state || {}; + // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps + // it once the results resolve, so a reload only restores a confirmed scrape-sourced search. + const { _ytScrape: _drop, ...st } = window.history.state || {}; if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry }, []); @@ -271,12 +279,17 @@ export default function App() { // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // survives a later query-string strip.) useEffect(() => { - // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch - // begins null), so the first Back doesn't resurrect a search we're no longer showing. - // Drop a stale _yt (a reload starts on the normal feed; a search would re-spend quota), but - // KEEP _chan/_chanName so the open channel page survives F5 (channelView restores from it above). - const { _yt: _staleYt, ...rest } = window.history.state || {}; - window.history.replaceState({ ...rest, sfPage: page }, ""); + // A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was + // scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for + // free (ytSearch above already initialised from it). An api-source search is still dropped (it + // would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5. + const st = window.history.state || {}; + if (st._yt && st._ytScrape) { + window.history.replaceState({ ...st, sfPage: page }, ""); + } else { + const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; + window.history.replaceState({ ...rest, sfPage: page }, ""); + } function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 45a84be..3288e26 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -151,6 +151,18 @@ export default function Feed({ retry: false, }); + // Remember (in history.state) that this live search was served by the zero-quota scrape source, + // so a reload restores the results instead of dropping to the feed — re-fetching scrape pages + // costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units). + useEffect(() => { + if (!ytActive) return; + const st = window.history.state; + if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry + if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) { + window.history.replaceState({ ...st, _ytScrape: true }, ""); + } + }, [ytActive, ytSearch, ytQuery.data]); + // Switching to the relevance sort when a search starts happens atomically in the header's // input onChange (race-free with the query update). Here we only handle the reverse: when // the term is cleared, fall back to the default sort — relevance has no dropdown option and