From ff9b0601c3209e1fcffe329ef15794db68dc46b9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 29 Jun 2026 23:03:25 +0200 Subject: [PATCH] fix(search): make the YouTube-search view its own history entry The live-search results view had no browser-history entry of its own, so a player opened over the results sat directly on the feed page entry. Pressing Back (e.g. the mouse back button over the player) could pop past both the player and the search in one step, bouncing from the search results to the normal feed instead of just closing the player. The search is now a feed sub-view that owns a history entry (history.state._yt): entering a search pushes it, the popstate handler derives ytSearch from it, and "Back to feed" pops it. Back now steps player -> search -> feed: the first Back closes only the player (results stay), the second returns to the normal feed. A reload drops any stale _yt so the first Back can't resurrect a gone search. --- frontend/src/App.tsx | 31 +++++++++++++++++++++++++------ frontend/src/components/Feed.tsx | 16 ++++++++++------ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36f4f8a..b777c3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -124,7 +124,22 @@ export default function App() { const [page, setPageState] = useState(loadInitialPage); // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL, // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it). + // The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the + // 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 enterYtSearch = useCallback((q: string) => { + setYtSearch(q); + const 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 + }, []); + const exitYtSearch = useCallback(() => { + // Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one. + if (window.history.state?._yt) window.history.back(); + else setYtSearch(null); + }, []); const [wizardOpen, setWizardOpen] = useState(false); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); @@ -230,10 +245,10 @@ export default function App() { // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // survives a later query-string strip.) useEffect(() => { - window.history.replaceState( - { ...window.history.state, sfPage: page }, - "" - ); + // 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. + const { _yt: _staleYt, ...rest } = window.history.state || {}; + 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 @@ -255,6 +270,9 @@ export default function App() { } setPageState(p); localStorage.setItem(PAGE_KEY, p); + // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or + // clears it to match the entry we landed on (and a player popped first leaves it intact). + setYtSearch((e.state?._yt as string) ?? null); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); @@ -505,7 +523,7 @@ export default function App() { filters={filters} setFilters={setFilters} page={page} - onYtSearch={setYtSearch} + onYtSearch={enterYtSearch} onGoToFullHistory={() => { setChannelFilter("needs_full"); setChannelsView("subscribed"); // the status filter applies to the subscriptions tab @@ -579,7 +597,8 @@ export default function App() { isDemo={meQuery.data!.is_demo} onOpenWizard={() => setWizardOpen(true)} ytSearch={ytSearch} - setYtSearch={setYtSearch} + onYtSearch={enterYtSearch} + onExitYtSearch={exitYtSearch} /> )} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 6f94efd..0223735 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -72,7 +72,8 @@ export default function Feed({ isDemo = false, onOpenWizard, ytSearch, - setYtSearch, + onYtSearch, + onExitYtSearch, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -80,10 +81,13 @@ export default function Feed({ canRead: boolean; isDemo?: boolean; onOpenWizard: () => void; - // Live YouTube search: the active search term (null = normal feed), and its setter so the - // empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo. + // Live YouTube search: the active search term (null = normal feed). Entering a search and + // leaving it both step browser history (the search is a feed sub-view with its own history + // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via + // onExitYtSearch. Search affordances are hidden for demo. ytSearch: string | null; - setYtSearch: (q: string | null) => void; + onYtSearch: (q: string) => void; + onExitYtSearch: () => void; }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); @@ -292,7 +296,7 @@ export default function Feed({ // The search just ingested new catalog videos, so the normal feed (which was // disabled and still holds its pre-search cache) is stale. Drop those caches so // it re-fetches fresh on return instead of showing the old (often empty) result. - setYtSearch(null); + onExitYtSearch(); qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["facets"] }); @@ -529,7 +533,7 @@ export default function Feed({

{t("feed.noMatches")}

{filters.q.trim() && !isDemo && (