diff --git a/CLAUDE.md b/CLAUDE.md
index 223ce247..0534ad64 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `
` |
+| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view |
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
diff --git a/docs/development.md b/docs/development.md
index a81a6349..994a3d87 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -48,6 +48,12 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside
its codec; the shell clears them on a page switch and opens a page without another page's keys.
+ Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec
+ in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out),
+ written with `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through
+ `useBackToClose`'s), read back first on mount (a link wins over anything remembered). Add its round trip to
+ `e2e/bookmark.spec.ts`. Only what an address cannot promise to reopen stays out, said in its codec (Home's
+ draw, `lib/homeUrlState`).
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts
new file mode 100644
index 00000000..85815399
--- /dev/null
+++ b/frontend/e2e/bookmark.spec.ts
@@ -0,0 +1,146 @@
+import { test, expect, type Browser, type Page } from "@playwright/test";
+import {
+ clickVisibleChannelLink,
+ expectContentType,
+ gotoPage,
+ openApp,
+ openFirstVideo,
+ toggleContentType,
+} from "./helpers";
+
+// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
+// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
+// storage it wrote) can stand in for what the address says. One round trip per page, plus the
+// overlays the shell opens over any page (the channel page, the player) and the live search.
+
+const STORAGE_STATE = "e2e/.auth/state.json";
+
+/** The page's address, opened as a bookmark in a fresh context of the same account. */
+async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise) {
+ const url = page.url();
+ const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" });
+ const fresh = await ctx.newPage();
+ await prepare?.(fresh);
+ await openApp(fresh, url);
+ return fresh;
+}
+
+const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
+
+test.describe("a bookmark reopens the view it was taken of", () => {
+ test("the feed's filters", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await toggleContentType(page, "includeLive");
+ await expect(page).toHaveURL(/[?&]live=/);
+ const fresh = await bookmarked(browser, page);
+ await expectContentType(fresh, "includeLive", true);
+ await fresh.context().close();
+ });
+
+ test("Home's time budget", async ({ page, browser }) => {
+ await openApp(page, "/?page=home");
+ await page.getByRole("button", { name: "An evening", exact: true }).click();
+ await expect(page).toHaveURL(/[?&]hb=long/);
+ const fresh = await bookmarked(browser, page);
+ await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await fresh.context().close();
+ });
+
+ test("a Settings tab", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await gotoPage(page, "settings");
+ await tab(page, "Notifications").click();
+ const fresh = await bookmarked(browser, page);
+ await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true");
+ await fresh.context().close();
+ });
+
+ test("an admin page's tab", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await page.getByTestId("nav-admin").click();
+ await page.getByTestId("nav-admin-users").click();
+ await tab(page, "Access requests").click();
+ const fresh = await bookmarked(browser, page);
+ await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
+ await fresh.context().close();
+ });
+
+ test("the Channels manager's tab and name search", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await gotoPage(page, "channels");
+ await tab(page, "Hidden").click();
+ await page.getByPlaceholder("Search channels…").fill("zz");
+ await expect(page).toHaveURL(/zz/); // written once the typing settles
+ const fresh = await bookmarked(browser, page);
+ await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true");
+ await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz");
+ await fresh.context().close();
+ });
+
+ test("the Files search", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await gotoPage(page, "files");
+ await page.getByPlaceholder("Search files…").fill("zz");
+ await expect(page).toHaveURL(/zz/);
+ const fresh = await bookmarked(browser, page);
+ await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz");
+ await fresh.context().close();
+ });
+
+ test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await expect(page.getByTestId("video-card").first()).toBeVisible();
+ expect(await clickVisibleChannelLink(page)).toBe(true);
+ const title = await page.getByTestId("channel-title").textContent();
+ await tab(page, "About").click();
+ const fresh = await bookmarked(browser, page);
+ await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? "");
+ await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true");
+ await fresh.context().close();
+ });
+
+ test("the player and the video it plays", async ({ page, browser }) => {
+ await openApp(page, "/?page=feed");
+ await openFirstVideo(page);
+ await expect(page).toHaveURL(/[?&]watch=/);
+ const watch = new URL(page.url()).searchParams.get("watch");
+ const fresh = await bookmarked(browser, page);
+ await expect(fresh.getByTestId("player-modal")).toBeVisible();
+ expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch);
+ await fresh.context().close();
+ });
+
+ // The search itself never reaches YouTube here: the answer is the server's shape, served by the
+ // test, and the request it answers is what the address named.
+ test("a live YouTube search, its term and its count", async ({ page, browser }) => {
+ const answer = async (p: Page, seen: string[]) => {
+ await p.route("**/api/search/youtube?**", (route) => {
+ seen.push(route.request().url());
+ return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
+ });
+ };
+ await answer(page, []);
+ await openApp(page, "/?page=feed");
+ await page.getByPlaceholder("Search your subscriptions…").fill("lofi");
+ await page.getByRole("button", { name: "Search YouTube", exact: true }).click();
+ await expect(page.getByText("YouTube results for “lofi”")).toBeVisible();
+ await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set
+ await page.getByRole("button", { name: "40", exact: true }).click();
+ await expect(page).toHaveURL(/[?&]ytn=40/);
+
+ const seen: string[] = [];
+ const fresh = await bookmarked(browser, page, (p) => answer(p, seen));
+ await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible();
+ await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect.poll(() => seen.length).toBeGreaterThan(0);
+ const asked = new URL(seen[0]!).searchParams;
+ expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]);
+ await fresh.context().close();
+ });
+});
diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx
index 78ff10fa..a8eded41 100644
--- a/frontend/src/features/navigation/NavigationProvider.tsx
+++ b/frontend/src/features/navigation/NavigationProvider.tsx
@@ -21,6 +21,12 @@ import {
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import { PLAYER_KEYS } from "../../lib/playerUrlState";
+import {
+ DEFAULT_YT_COUNT,
+ ytCountFrom,
+ ytSearchFrom,
+ ytSearchParams,
+} from "../../lib/ytSearchUrlState";
import {
channelViewFrom,
loadInitialPage,
@@ -77,14 +83,19 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// and the address bar (stamped from it below) agree.
const me = useMe();
const page = reachablePage(chosenPage, me);
- // 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) — UNLESS it was zero-quota scrape-sourced, which rides
- // in history.state and is free to re-fetch. The popstate handler derives it from history.state so
- // Back steps player → search → feed instead of the search vanishing without a history entry.
- const [ytSearch, setYtSearch] = useState(() => {
- const st = window.history.state;
- return st?._yt && st?._ytScrape ? (st._yt as string) : null;
- });
+ // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
+ // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
+ // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
+ // asked to search for (sent from the search box, or "Search YouTube again" pressed). Anything else
+ // — a reload, a link, a Back onto an older search — runs only when it costs no quota (Feed decides).
+ const [ytSearch, setYtSearch] = useState(() =>
+ page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
+ );
+ const [ytCount, setYtCountState] = useState(() =>
+ ytCountFrom(new URLSearchParams(window.location.search)),
+ );
+ const [ytAsked, setYtAsked] = useState(null);
+ const ytSearchRef = useLatest(ytSearch);
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads) AND is mirrored to the address bar as ?chan=, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
@@ -176,26 +187,52 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
- // 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 {
- // New sub-view entry.
- const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
- window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
+ setYtAsked(q);
+ const st = window.history.state ?? {};
+ const here = new URLSearchParams(window.location.search);
+ if (st._yt || ytSearchFrom(here) != null) {
+ // Refine the search on screen, in place, at the count it shows (one a link opened too: it
+ // is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
+ window.history.replaceState(
+ { ...st, _yt: q },
+ "",
+ urlWithParams(ytSearchParams(q, ytCountFrom(here))),
+ );
+ } else {
+ // New sub-view entry, at the default count.
+ setYtCountState(DEFAULT_YT_COUNT);
+ const left = rememberNavPosition();
+ window.history.pushState(
+ { ...left, sfPage: "feed", _yt: q },
+ "",
+ urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)),
+ );
}
}, []);
+ // The press that runs the search on screen, where it did not run by itself.
+ const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]);
+
+ const setYtCount = useCallback(
+ (n: number) => {
+ setYtCountState(n);
+ window.history.replaceState(
+ window.history.state,
+ "",
+ urlWithParams(ytSearchParams(ytSearchRef.current, n)),
+ );
+ },
+ [ytSearchRef],
+ );
+
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
- const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
- window.history.replaceState(st, "");
+ const { _yt: _q, ...st } = window.history.state ?? {};
+ window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
}
}, []);
@@ -244,9 +281,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
- // A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
- // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
- // are always kept so a channel page survives F5.
+ // The entry's `_yt` mark stays while the address still names its search (a reload of it); a mark
+ // left without one (an older build's entry) goes. _chan/_chanName are always kept so a channel
+ // page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
@@ -264,7 +301,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage,
});
- if (st._yt && st._ytScrape) {
+ if (st._yt && ytSearchFrom(new URLSearchParams(window.location.search)) != null) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
@@ -287,9 +324,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
);
setPageState(p);
setAccountRaw(PAGE_KEY, p);
- // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
- // restores or clears them to match the entry we landed on.
- setYtSearch((e.state?._yt as string) ?? null);
+ // The YouTube search is read from the address the browser restored, so Back/Forward opens or
+ // clears it to match the entry we landed on — not asked for: an API search waits for a press.
+ const here = new URLSearchParams(window.location.search);
+ setYtSearch(p === "feed" ? ytSearchFrom(here) : null);
+ setYtCountState(ytCountFrom(here));
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
@@ -301,12 +340,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, [mountPage]);
const state = useMemo(
- () => ({ page, channelView, ytSearch }),
- [page, channelView, ytSearch],
+ () => ({
+ page,
+ channelView,
+ ytSearch,
+ ytCount,
+ ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
+ }),
+ [page, channelView, ytSearch, ytCount, ytAsked],
);
const actions = useMemo(
- () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
- [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
+ () => ({
+ setPage,
+ openChannel,
+ closeChannel,
+ enterYtSearch,
+ exitYtSearch,
+ askYtSearch,
+ setYtCount,
+ }),
+ [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount],
);
return (
diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx
new file mode 100644
index 00000000..a610ed4a
--- /dev/null
+++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx
@@ -0,0 +1,198 @@
+// @vitest-environment jsdom
+//
+// The live YouTube search in the address: sent from the search box it gets its own entry naming the
+// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the
+// browser restored — and only a search asked for in this document counts as asked (an API search
+// reopened any other way waits for a press, Feed's part).
+import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
+import { renderHook, act, cleanup } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
+ };
+});
+
+import { NavigationProvider } from "./NavigationProvider";
+import { useNavigation, useNavigationActions } from "./navigationContext";
+import { qk } from "../../lib/queryKeys";
+import { claimLocalData } from "../../lib/storage";
+
+let qc = new QueryClient();
+const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+const render = () =>
+ renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
+const search = () => new URLSearchParams(window.location.search);
+
+/** No entry of this document under the current one (a bookmark opened in a new tab). */
+function stubNoEntryBelow() {
+ Object.defineProperty(window, "navigation", {
+ configurable: true,
+ value: {
+ currentEntry: { index: 1 },
+ entries: () => [{ sameDocument: false }, { sameDocument: true }],
+ },
+ });
+}
+
+beforeEach(() => {
+ qc = new QueryClient();
+ qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
+ localStorage.clear();
+ claimLocalData(1);
+ window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(window, "navigation");
+});
+
+describe("NavigationProvider · the YouTube search in the address", () => {
+ it("names a search sent from the box on an entry of its own, over the feed's", () => {
+ const { result } = render();
+ const depth = window.history.length;
+ act(() => result.current.enterYtSearch("lofi"));
+ expect(window.history.length).toBe(depth + 1);
+ expect(search().get("yt")).toBe("lofi");
+ expect(search().get("q")).toBe("lofi"); // the feed's own keys stay
+ expect(search().get("ytn")).toBeNull(); // the default count is left out
+ expect(result.current.ytSearch).toBe("lofi");
+ expect(result.current.ytSearchAsked).toBe(true);
+ });
+
+ it("refines the search on screen in place, at the count it shows", () => {
+ const { result } = render();
+ act(() => result.current.enterYtSearch("lofi"));
+ act(() => result.current.setYtCount(60));
+ expect(search().get("ytn")).toBe("60");
+ const depth = window.history.length;
+ act(() => result.current.enterYtSearch("lofi beats"));
+ expect(window.history.length).toBe(depth);
+ expect(search().get("yt")).toBe("lofi beats");
+ expect(search().get("ytn")).toBe("60");
+ expect(result.current.ytCount).toBe(60);
+ });
+
+ it("a new search starts at the default count", () => {
+ const { result } = render();
+ act(() => result.current.enterYtSearch("lofi"));
+ act(() => result.current.setYtCount(40));
+ stubNoEntryBelow(); // left in place, so the next search is a new entry
+ vi.spyOn(window.history, "back").mockImplementation(() => {});
+ act(() => result.current.exitYtSearch());
+ act(() => result.current.enterYtSearch("jazz"));
+ expect(result.current.ytCount).toBe(20);
+ expect(search().get("ytn")).toBeNull();
+ });
+
+ it("writes the count, and clears it back at the default", () => {
+ const { result } = render();
+ act(() => result.current.enterYtSearch("lofi"));
+ act(() => result.current.setYtCount(100));
+ expect(search().get("ytn")).toBe("100");
+ act(() => result.current.setYtCount(20));
+ expect(search().get("ytn")).toBeNull();
+ });
+
+ it("reopens the search a reload or a link names, not as asked", () => {
+ window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
+ const { result } = render();
+ expect(result.current.ytSearch).toBe("lofi");
+ expect(result.current.ytCount).toBe(40);
+ expect(result.current.ytSearchAsked).toBe(false);
+ expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it
+ act(() => result.current.askYtSearch());
+ expect(result.current.ytSearchAsked).toBe(true);
+ });
+
+ it("refines a search a link opened in place too, at the count it shows", () => {
+ window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
+ const { result } = render();
+ const depth = window.history.length;
+ act(() => result.current.enterYtSearch("jazz"));
+ expect(window.history.length).toBe(depth);
+ expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]);
+ });
+
+ it("Back onto an entry whose address alone names the search reopens it", () => {
+ const { result } = render();
+ window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
+ });
+ expect(result.current.ytSearch).toBe("lofi");
+ });
+
+ it("keeps the search mark of an entry the reload started from", () => {
+ window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
+ render();
+ expect(window.history.state?._yt).toBe("lofi");
+ });
+
+ it("drops an older build's search mark the address does not name", () => {
+ window.history.replaceState(
+ { sfPage: "feed", _yt: "lofi", _ytScrape: true },
+ "",
+ "/?page=feed",
+ );
+ const { result } = render();
+ expect(result.current.ytSearch).toBeNull();
+ expect(window.history.state?._yt).toBeUndefined();
+ expect(window.history.state?._ytScrape).toBeUndefined();
+ });
+
+ it("names no search under another page", () => {
+ window.history.replaceState(null, "", "/?page=plex&yt=lofi");
+ const { result } = render();
+ expect(result.current.page).toBe("plex");
+ expect(result.current.ytSearch).toBeNull();
+ expect(search().get("yt")).toBeNull();
+ });
+
+ it("Back/Forward read the search from the address the browser restored, not as asked", () => {
+ const { result } = render();
+ act(() => result.current.enterYtSearch("lofi"));
+ window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60");
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
+ });
+ expect(result.current.ytSearch).toBe("jazz");
+ expect(result.current.ytCount).toBe(60);
+ expect(result.current.ytSearchAsked).toBe(false);
+
+ window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
+ });
+ expect(result.current.ytSearch).toBeNull();
+ });
+
+ it("leaving a search with no entry of this document below clears it from the address", () => {
+ window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
+ const { result } = render();
+ stubNoEntryBelow();
+ const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
+ act(() => result.current.exitYtSearch());
+ expect(back).not.toHaveBeenCalled();
+ expect(result.current.ytSearch).toBeNull();
+ expect(search().get("yt")).toBeNull();
+ expect(search().get("ytn")).toBeNull();
+ });
+
+ it("a page switch takes the search off the address", () => {
+ const { result } = render();
+ act(() => result.current.enterYtSearch("lofi"));
+ act(() => result.current.setPage("home"));
+ expect(result.current.ytSearch).toBeNull();
+ expect(search().get("yt")).toBeNull();
+ });
+});
diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts
index b033ae96..ab173013 100644
--- a/frontend/src/features/navigation/navigationContext.ts
+++ b/frontend/src/features/navigation/navigationContext.ts
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState";
+import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -15,8 +16,13 @@ export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null;
- /** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
+ /** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */
ytSearch: string | null;
+ /** How many results the search gathers (`ytn`). */
+ ytCount: number;
+ /** The search on screen was asked for in this document (sent from the search box, or run again by
+ * a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */
+ ytSearchAsked: boolean;
}
export interface NavigationActions {
@@ -33,12 +39,17 @@ export interface NavigationActions {
closeChannel: () => void;
enterYtSearch: (q: string) => void;
exitYtSearch: () => void;
+ /** Run the search on screen: the "Search YouTube again" press. */
+ askYtSearch: () => void;
+ setYtCount: (n: number) => void;
}
export const NavigationStateContext = createContext({
page: "feed",
channelView: null,
ytSearch: null,
+ ytCount: DEFAULT_YT_COUNT,
+ ytSearchAsked: false,
});
export const NavigationActionsContext = createContext({
setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext({
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
+ askYtSearch: () => {},
+ setYtCount: () => {},
});
export function useNavigation(): NavigationState {
diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json
index 64b53f9f..ce0941ad 100644
--- a/frontend/src/i18n/locales/en/feed.json
+++ b/frontend/src/i18n/locales/en/feed.json
@@ -75,6 +75,9 @@
"clearNow": "Clear now",
"cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results",
- "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size."
+ "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
+ "notRunTitle": "This search hasn't run again",
+ "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload or a link doesn't start one by itself.",
+ "runAgain": "Search YouTube again"
}
}
diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json
index e2fea925..111fe5b2 100644
--- a/frontend/src/i18n/locales/hu/feed.json
+++ b/frontend/src/i18n/locales/hu/feed.json
@@ -75,6 +75,9 @@
"clearNow": "Törlés most",
"cleared_one": "{{count}} találat törölve",
"cleared_other": "{{count}} találat törölve",
- "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább."
+ "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.",
+ "notRunTitle": "Ez a keresés nem futott újra",
+ "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltés vagy link nem indít el egyet magától.",
+ "runAgain": "Keresés újra a YouTube-on"
}
}
diff --git a/frontend/src/lib/homeUrlState.ts b/frontend/src/lib/homeUrlState.ts
new file mode 100644
index 00000000..bf3efdbf
--- /dev/null
+++ b/frontend/src/lib/homeUrlState.ts
@@ -0,0 +1,20 @@
+// Home's time budget in the address bar (`?page=home&hb=short`): a page SETTING, like a feed filter,
+// so it is a link and survives a reload. What Home draws under it is not: the question's seed, the
+// press and the cards already offered stay on the history entry (Home.tsx), because the same question
+// draws other cards once what you have watched moves on — an address cannot promise "that view".
+import { DISCOVER_BUDGETS, type DiscoverBudget } from "./api";
+
+const HOME_BUDGET_KEY = "hb";
+export const HOME_KEYS = [HOME_BUDGET_KEY] as const;
+export const DEFAULT_BUDGET: DiscoverBudget = "any";
+
+/** The budget a URL names, or null when it names none (or none Home offers). */
+export function budgetFrom(params: URLSearchParams): DiscoverBudget | null {
+ const raw = params.get(HOME_BUDGET_KEY);
+ return DISCOVER_BUDGETS.includes(raw as DiscoverBudget) ? (raw as DiscoverBudget) : null;
+}
+
+/** The URL update for a budget: the default clears its key. */
+export function budgetParams(budget: DiscoverBudget): Record {
+ return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget };
+}
diff --git a/frontend/src/lib/ytSearchUrlState.test.ts b/frontend/src/lib/ytSearchUrlState.test.ts
new file mode 100644
index 00000000..e23e31c6
--- /dev/null
+++ b/frontend/src/lib/ytSearchUrlState.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { ytCountFrom, ytSearchFrom, ytSearchParams } from "./ytSearchUrlState";
+
+const p = (q: string) => new URLSearchParams(q);
+
+describe("the live YouTube search's address", () => {
+ it("reads the term, trimmed; a blank one names no search", () => {
+ expect(ytSearchFrom(p("yt=%20lofi%20"))).toBe("lofi");
+ expect(ytSearchFrom(p("yt=%20%20"))).toBeNull();
+ expect(ytSearchFrom(p("q=lofi"))).toBeNull();
+ });
+
+ it("reads one of the offered counts, else the default", () => {
+ expect(ytCountFrom(p("ytn=60"))).toBe(60);
+ expect(ytCountFrom(p("ytn=61"))).toBe(20);
+ expect(ytCountFrom(p("ytn=x"))).toBe(20);
+ expect(ytCountFrom(p(""))).toBe(20);
+ });
+
+ it("writes the count only off its default, and nothing with no search", () => {
+ expect(ytSearchParams("lofi", 40)).toEqual({ yt: "lofi", ytn: "40" });
+ expect(ytSearchParams("lofi", 20)).toEqual({ yt: "lofi", ytn: null });
+ expect(ytSearchParams(null, 40)).toEqual({ yt: null, ytn: null });
+ });
+
+ it("round-trips", () => {
+ const q = new URLSearchParams();
+ for (const [k, v] of Object.entries(ytSearchParams("lofi beats", 100))) if (v) q.set(k, v);
+ expect([ytSearchFrom(q), ytCountFrom(q)]).toEqual(["lofi beats", 100]);
+ });
+});
diff --git a/frontend/src/lib/ytSearchUrlState.ts b/frontend/src/lib/ytSearchUrlState.ts
new file mode 100644
index 00000000..6c8985ec
--- /dev/null
+++ b/frontend/src/lib/ytSearchUrlState.ts
@@ -0,0 +1,32 @@
+// The feed's live YouTube search in the address bar (`?page=feed&yt=lofi&ytn=40`), so a search is a
+// link and survives a reload. Only the term and how many results to gather: which source answers is
+// the instance's setting at the moment the link opens (an admin may have switched it since), and that
+// decides whether the search runs by itself (the free page scrape) or waits for a press (an API
+// search spends 100 quota units, so nobody's reload or bookmark spends them unasked).
+
+const YT_SEARCH_KEY = "yt";
+const YT_COUNT_KEY = "ytn";
+export const YT_SEARCH_KEYS = [YT_SEARCH_KEY, YT_COUNT_KEY] as const;
+
+/** How many live-search results one fetch gathers; the first is the default, left out of the URL. */
+export const YT_COUNTS = [20, 40, 60, 100] as const;
+export const DEFAULT_YT_COUNT = YT_COUNTS[0];
+
+/** The search a URL names, or null: a blank term names none (the search box never sends one). */
+export function ytSearchFrom(params: URLSearchParams): string | null {
+ return params.get(YT_SEARCH_KEY)?.trim() || null;
+}
+
+/** The result count a URL names; anything but one of the offered counts is the default. */
+export function ytCountFrom(params: URLSearchParams): number {
+ const n = Number(params.get(YT_COUNT_KEY));
+ return (YT_COUNTS as readonly number[]).includes(n) ? n : DEFAULT_YT_COUNT;
+}
+
+/** The URL updates for a search (null = none): a default count clears its key. */
+export function ytSearchParams(term: string | null, count: number): Record {
+ return {
+ [YT_SEARCH_KEY]: term,
+ [YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null,
+ };
+}
diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts
index 54050448..8ee94f78 100644
--- a/frontend/src/moduleUrl.contract.test.ts
+++ b/frontend/src/moduleUrl.contract.test.ts
@@ -16,6 +16,8 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState";
+import { ytSearchParams } from "./lib/ytSearchUrlState";
+import { budgetParams } from "./lib/homeUrlState";
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
import { playlistViewParamUpdates } from "./lib/playlistsUrlState";
import { SETTINGS_TAB_KEY } from "./lib/settingsTab";
@@ -43,7 +45,8 @@ const FEED: FeedFilters = {
// What each module's own encoders write, from the encoders themselves. Every module that declares a
// key has a row, so a new one cannot declare keys its codec does not write, or the other way round.
const WRITES: Partial string[]>> = {
- feed: () => Object.keys(filterParamUpdates(FEED)),
+ feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))],
+ home: () => Object.keys(budgetParams("short")),
channels: () =>
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })),
plex: () => [
diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx
index 87d9c0cf..771b7134 100644
--- a/frontend/src/modules/channels/ChannelsProvider.tsx
+++ b/frontend/src/modules/channels/ChannelsProvider.tsx
@@ -185,7 +185,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
setFilters({ sync: "needsFull" });
setView("subscribed"); // the sync filter applies to the subscriptions tab
- setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
+ setFiltersResetToken((n) => n + 1); // also clear the name search + tag chips
setPage("channels");
}, [setFilters, setView, setPage]);
diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx
index db2ea7a9..baf69e7a 100644
--- a/frontend/src/modules/feed/Feed.tsx
+++ b/frontend/src/modules/feed/Feed.tsx
@@ -40,6 +40,8 @@ import {
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
+import { YT_COUNTS } from "../../lib/ytSearchUrlState";
+import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useDebounced } from "../../lib/useDebounced";
import {
markedRows,
@@ -148,9 +150,6 @@ const withoutWrite = (o: Overrides, id: string, seq: number): Overrides api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
- enabled: ytActive,
+ enabled: ytRuns,
staleTime: 5 * 60_000,
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
@@ -556,7 +552,8 @@ export default function Feed({
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
- const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
+ // Before an answer (a search waiting for its press), the instance's own source says it.
+ const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape";
return (
@@ -638,7 +635,16 @@ export default function Feed({
)}
- {ytQuery.isLoading ? (
+ {!ytRuns && ytQuery.data === undefined ? (
+ // Reopened, not asked for: the term is on screen, the search waits for a press.
+
) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can
diff --git a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx
new file mode 100644
index 00000000..676a13d0
--- /dev/null
+++ b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx
@@ -0,0 +1,218 @@
+// @vitest-environment jsdom
+//
+// A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it
+// costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search
+// YouTube again" — unless its results are still cached, or it was asked for in this document.
+import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+vi.mock("react-i18next", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+}));
+vi.mock("../../features/wizard/wizardContext", () => ({
+ useWizard: () => ({ openWizard: vi.fn() }),
+}));
+vi.mock("./VirtualFeed", () => ({
+ default: ({ items }: { items: { id: string; title: string }[] }) => (
+
+ {items.map((v) => (
+
{v.title}
+ ))}
+
+ ),
+}));
+
+import Feed from "./Feed";
+import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
+import { qk } from "../../lib/queryKeys";
+import {
+ NavigationActionsContext,
+ NavigationStateContext,
+ type NavigationActions,
+} from "../../features/navigation/navigationContext";
+
+beforeAll(() => {
+ class Stub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords() {
+ return [];
+ }
+ }
+ globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
+ globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const FILTERS: FeedFilters = {
+ tags: [],
+ tagModes: DEFAULT_TAG_MODES,
+ channelIds: [],
+ q: "",
+ sort: "newest",
+ scope: "my",
+ includeNormal: true,
+ includeShorts: false,
+ includeLive: false,
+ show: "unwatched",
+};
+
+const hit = (id: string): Video =>
+ ({
+ id,
+ title: `hit ${id}`,
+ channel_id: "UCx",
+ channel_title: "Channel",
+ status: "new",
+ position_seconds: 0,
+ published_at: "2026-01-01T00:00:00Z",
+ watch_url: `https://www.youtube.com/watch?v=${id}`,
+ }) as unknown as Video;
+
+const actions = (over: Partial = {}): NavigationActions => ({
+ setPage: () => {},
+ openChannel: () => {},
+ closeChannel: () => {},
+ enterYtSearch: () => {},
+ exitYtSearch: () => {},
+ askYtSearch: () => {},
+ setYtCount: () => {},
+ ...over,
+});
+
+function mount({
+ source,
+ asked,
+ enabled = true,
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ nav = actions(),
+}: {
+ source: "scrape" | "api";
+ asked: boolean;
+ enabled?: boolean;
+ qc?: QueryClient;
+ nav?: NavigationActions;
+}) {
+ qc.setQueryData(qk.me(), {
+ id: 1,
+ can_write: true,
+ prefs: {},
+ search_source: source,
+ search_enabled: enabled,
+ });
+ const tree = (count: number) => (
+
+
+
+ {}}
+ view="cards"
+ setView={() => {}}
+ canRead
+ ytSearch="lofi"
+ onYtSearch={() => {}}
+ onExitYtSearch={() => {}}
+ />
+
+
+
+ );
+ const view = render(tree(20));
+ return { showing: (count: number) => view.rerender(tree(count)) };
+}
+
+const settle = () => new Promise((r) => setTimeout(r, 30));
+
+describe("Feed — a live search the address reopened", () => {
+ it("waits for a press under the API source, and spends nothing meanwhile", async () => {
+ const search = vi.spyOn(api, "searchYoutube");
+ const askYtSearch = vi.fn();
+ mount({ source: "api", asked: false, nav: actions({ askYtSearch }) });
+ expect(screen.getByText("feed.yt.notRunTitle")).toBeTruthy();
+ expect(screen.getByText("feed.yt.resultsFor")).toBeTruthy(); // the term stays on screen
+ expect(screen.getByText("feed.yt.quotaNote")).toBeTruthy();
+ await settle();
+ expect(search).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "feed.yt.runAgain" }));
+ expect(askYtSearch).toHaveBeenCalledTimes(1);
+ });
+
+ it("runs an API search asked for in this document", async () => {
+ const search = vi
+ .spyOn(api, "searchYoutube")
+ .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "api" } as never);
+ mount({ source: "api", asked: true });
+ await waitFor(() =>
+ expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"),
+ );
+ expect(search).toHaveBeenCalledWith("lofi", null, 20);
+ expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull();
+ });
+
+ it("runs by itself under the free scrape source", async () => {
+ const search = vi
+ .spyOn(api, "searchYoutube")
+ .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
+ mount({ source: "scrape", asked: false });
+ await waitFor(() =>
+ expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"),
+ );
+ expect(search).toHaveBeenCalledTimes(1);
+ expect(screen.getByText("feed.yt.freeNote")).toBeTruthy();
+ });
+
+ it("shows an API search's results still cached, without asking again", async () => {
+ const search = vi.spyOn(api, "searchYoutube");
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(qk.ytSearch("lofi", 20), {
+ pages: [{ items: [hit("c")], next_cursor: null, source: "api" }],
+ pageParams: [null],
+ });
+ mount({ source: "api", asked: false, qc });
+ expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit c");
+ expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull();
+ await settle();
+ expect(search).not.toHaveBeenCalled();
+ });
+
+ it("asks again for another count — each count is a search of its own", async () => {
+ const search = vi
+ .spyOn(api, "searchYoutube")
+ .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
+ const { showing } = mount({ source: "scrape", asked: false });
+ await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 20));
+ showing(40);
+ await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 40));
+ });
+
+ it("says the instance's own source while the first answer is on its way", () => {
+ vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {}));
+ mount({ source: "scrape", asked: false });
+ expect(screen.getByText("feed.yt.searching")).toBeTruthy();
+ expect(screen.getByText("feed.yt.freeNote")).toBeTruthy();
+ });
+
+ it("turned off on this instance, lets the server's refusal say so — no press offered", async () => {
+ vi.spyOn(api, "searchYoutube").mockRejectedValue(
+ new HttpError(409, "Live search is off", { code: "refused", reason: "search_disabled" }),
+ );
+ mount({ source: "api", asked: false, enabled: false });
+ expect(await screen.findByText("Live search is off")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull();
+ });
+});
diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts
index 059e9dc2..ff1fb34c 100644
--- a/frontend/src/modules/feed/module.ts
+++ b/frontend/src/modules/feed/module.ts
@@ -2,6 +2,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { FEED_KEYS } from "../../lib/urlState";
+import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState";
import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -16,7 +17,8 @@ export const feedModule: ModuleDef = {
Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore,
- urlKeys: FEED_KEYS,
+ // Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState).
+ urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS],
playsVideos: true,
};
diff --git a/frontend/src/modules/home/Home.dom.test.tsx b/frontend/src/modules/home/Home.dom.test.tsx
index 7dc1eb56..cfb8c0e9 100644
--- a/frontend/src/modules/home/Home.dom.test.tsx
+++ b/frontend/src/modules/home/Home.dom.test.tsx
@@ -174,8 +174,9 @@ function mountWithClient() {
}
beforeEach(() => {
- // Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
- window.history.replaceState(null, "");
+ // Home keeps its question on the history entry (Back restores it) and its budget in the address:
+ // each test starts a fresh one of both.
+ window.history.replaceState(null, "", "/");
discover.mockReset();
discoverPick.mockReset();
setPage.mockReset();
@@ -636,6 +637,37 @@ describe("Home", () => {
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked));
});
+ it("names the chosen budget in the address, and the default by leaving it out", async () => {
+ mount();
+ await screen.findByText("Hero v1");
+ fireEvent.click(screen.getByText("home.budget.long"));
+ await waitFor(() => expect(window.location.search).toBe("?hb=long"));
+ fireEvent.click(screen.getByText("home.budget.any"));
+ await waitFor(() => expect(window.location.search).toBe(""));
+ });
+
+ it("opens on the budget a link names — the question's draw is not the link's", async () => {
+ window.history.replaceState(
+ { sfHome: { budget: "short", seed: 7, press: 1, seen: ["youtube:v1"] } },
+ "",
+ "/?page=home&hb=long",
+ );
+ mount();
+ await waitFor(() => expect(discover).toHaveBeenLastCalledWith("long", 0, "en"));
+ expect(discoverPick).not.toHaveBeenCalled(); // a question left under another budget is not reused
+ });
+
+ it("an address naming no budget opens on the default, over a question left under another", async () => {
+ window.history.replaceState(
+ { sfHome: { budget: "short", seed: 7, press: 1, seen: [] } },
+ "",
+ "/?page=home",
+ );
+ mount();
+ await waitFor(() => expect(discover).toHaveBeenLastCalledWith("any", 0, "en"));
+ expect(discoverPick).not.toHaveBeenCalled();
+ });
+
// Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home
// as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a
// mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx
index 4549ddf4..42bfb30c 100644
--- a/frontend/src/modules/home/Home.tsx
+++ b/frontend/src/modules/home/Home.tsx
@@ -79,6 +79,8 @@ import {
} from "../../components/ui/mediaCardStyles";
import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
+import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState";
+import { useUrlMirror } from "../../lib/useUrlMirror";
// The Home surface — "Mit nézzek most?".
//
@@ -701,6 +703,15 @@ function readSavedQuestion(): SavedQuestion | null {
};
}
+/** The question the page opens with: the budget its address names (a link wins, and an address
+ * naming none means the default), with the rest of what the entry was left with only when that was
+ * asked under the same budget. */
+function openingQuestion(): { budget: DiscoverBudget; saved: SavedQuestion | null } {
+ const budget = budgetFrom(new URLSearchParams(window.location.search)) ?? DEFAULT_BUDGET;
+ const saved = readSavedQuestion();
+ return { budget, saved: saved?.budget === budget ? saved : null };
+}
+
export default function Home() {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -710,8 +721,10 @@ export default function Home() {
// The question on screen lives on this page's history ENTRY as well: opening a card leaves Home
// (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card
// then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
- const [saved] = useState(readSavedQuestion);
- const [budget, setBudget] = useState(saved?.budget ?? "any");
+ // The budget alone is also in the address (lib/homeUrlState): a setting, unlike the draw under it.
+ const [{ budget: openedBudget, saved }] = useState(openingQuestion);
+ const [budget, setBudget] = useState(openedBudget);
+ useUrlMirror(budgetParams(budget));
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so
// pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(saved?.seed ?? 0);
diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts
index 384aab70..2bcc583f 100644
--- a/frontend/src/modules/home/module.ts
+++ b/frontend/src/modules/home/module.ts
@@ -1,6 +1,7 @@
import { Home } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
+import { HOME_KEYS } from "../../lib/homeUrlState";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const homeModule: ModuleDef = {
@@ -11,8 +12,8 @@ export const homeModule: ModuleDef = {
icon: Home,
Page: lazyWithPreload(() => import("./Home")),
maxWidth: "80rem",
- // Nothing of its own: the question is an answer, not a setting — the same question draws other
- // cards once what you have watched moves on.
- urlKeys: [],
+ // Its time budget, a setting. Not the question's draw: the same question draws other cards once
+ // what you have watched moves on (lib/homeUrlState).
+ urlKeys: HOME_KEYS,
playsVideos: true,
};
diff --git a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx
index 6c668dce..7244bb55 100644
--- a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx
+++ b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx
@@ -380,6 +380,8 @@ describe("a row's history", () => {
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
+ askYtSearch: () => {},
+ setYtCount: () => {},
}}
>