feat(nav): the live YouTube search and Home's budget are named in the address

A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link
or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page
scrape, a search asked for in this document, or results still cached. An API search
reopened any other way shows its term and waits for "Search YouTube again".

Home's time budget is `hb`; the question's draw stays on the history entry.

An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and
docs/development.md name the module URL codec as the place for view state.
This commit is contained in:
2026-10-27 11:51:29 +01:00
parent 3d26ca0efa
commit 7b4e007680
20 changed files with 847 additions and 64 deletions
+1
View File
@@ -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 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 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 `<body>` | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/<id>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 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 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 | | 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 |
+6
View File
@@ -48,6 +48,12 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every 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 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. 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/<id>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 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. 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 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
+146
View File
@@ -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<void>) {
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();
});
});
@@ -21,6 +21,12 @@ import {
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import { PLAYER_KEYS } from "../../lib/playerUrlState"; import { PLAYER_KEYS } from "../../lib/playerUrlState";
import {
DEFAULT_YT_COUNT,
ytCountFrom,
ytSearchFrom,
ytSearchParams,
} from "../../lib/ytSearchUrlState";
import { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -77,14 +83,19 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// and the address bar (stamped from it below) agree. // and the address bar (stamped from it below) agree.
const me = useMe(); const me = useMe();
const page = reachablePage(chosenPage, me); const page = reachablePage(chosenPage, me);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
// Back steps player → search → feed instead of the search vanishing without a history entry. // asked to search for (sent from the search box, or "Search YouTube again" pressed). Anything else
const [ytSearch, setYtSearch] = useState<string | null>(() => { // — a reload, a link, a Back onto an older search — runs only when it costs no quota (Feed decides).
const st = window.history.state; const [ytSearch, setYtSearch] = useState<string | null>(() =>
return st?._yt && st?._ytScrape ? (st._yt as string) : null; page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
}); );
const [ytCount, setYtCountState] = useState(() =>
ytCountFrom(new URLSearchParams(window.location.search)),
);
const [ytAsked, setYtAsked] = useState<string | null>(null);
const ytSearchRef = useLatest(ytSearch);
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // 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=<id>, so Back closes it, a reload restores // detail loads) AND is mirrored to the address bar as ?chan=<id>, 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 // 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) => { const enterYtSearch = useCallback((q: string) => {
setYtSearch(q); setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it setYtAsked(q);
// once the results resolve, so a reload only restores a confirmed scrape-sourced search. const st = window.history.state ?? {};
const { _ytScrape: _drop, ...st } = window.history.state || {}; const here = new URLSearchParams(window.location.search);
if (st._yt) if (st._yt || ytSearchFrom(here) != null) {
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search // Refine the search on screen, in place, at the count it shows (one a link opened too: it
else { // is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
// New sub-view entry. window.history.replaceState(
const { _ytScrape: _dropToo, ...left } = rememberNavPosition(); { ...st, _yt: q },
window.history.pushState({ ...left, sfPage: "feed", _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(() => { const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own // 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). // 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(); if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else { else {
setYtSearch(null); setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {}; const { _yt: _q, ...st } = window.history.state ?? {};
window.history.replaceState(st, ""); 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 // 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. // the popstate handler only needs to read history.state.
useEffect(() => { useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape // The entry's `_yt` mark stays while the address still names its search (a reload of it); a mark
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // left without one (an older build's entry) goes. _chan/_chanName are always kept so a channel
// are always kept so a channel page survives F5. // page survives F5.
const st = window.history.state || {}; const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // 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 // 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)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage, page: mountPage,
}); });
if (st._yt && st._ytScrape) { if (st._yt && ytSearchFrom(new URLSearchParams(window.location.search)) != null) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url); window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else { } else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
@@ -287,9 +324,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
); );
setPageState(p); setPageState(p);
setAccountRaw(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward // The YouTube search is read from the address the browser restored, so Back/Forward opens or
// restores or clears them to match the entry we landed on. // clears it to match the entry we landed on — not asked for: an API search waits for a press.
setYtSearch((e.state?._yt as string) ?? null); 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 // 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 // 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 // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
@@ -301,12 +340,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, [mountPage]); }, [mountPage]);
const state = useMemo<NavigationState>( const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }), () => ({
[page, channelView, ytSearch], page,
channelView,
ytSearch,
ytCount,
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
}),
[page, channelView, ytSearch, ytCount, ytAsked],
); );
const actions = useMemo<NavigationActions>( const actions = useMemo<NavigationActions>(
() => ({ 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 ( return (
@@ -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<typeof import("react-i18next")>();
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 }) => (
<QueryClientProvider client={qc}>
<NavigationProvider>{children}</NavigationProvider>
</QueryClientProvider>
);
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();
});
});
@@ -1,5 +1,6 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { Page } from "../../lib/urlState"; import type { Page } from "../../lib/urlState";
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
import type { ChannelView } from "./navigationInit"; import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -15,8 +16,13 @@ export interface NavigationState {
page: Page; page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null; 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; 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 { export interface NavigationActions {
@@ -33,12 +39,17 @@ export interface NavigationActions {
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
exitYtSearch: () => void; exitYtSearch: () => void;
/** Run the search on screen: the "Search YouTube again" press. */
askYtSearch: () => void;
setYtCount: (n: number) => void;
} }
export const NavigationStateContext = createContext<NavigationState>({ export const NavigationStateContext = createContext<NavigationState>({
page: "feed", page: "feed",
channelView: null, channelView: null,
ytSearch: null, ytSearch: null,
ytCount: DEFAULT_YT_COUNT,
ytSearchAsked: false,
}); });
export const NavigationActionsContext = createContext<NavigationActions>({ export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {}, setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
exitYtSearch: () => {}, exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
}); });
export function useNavigation(): NavigationState { export function useNavigation(): NavigationState {
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Clear now", "clearNow": "Clear now",
"cleared_one": "Cleared {{count}} result", "cleared_one": "Cleared {{count}} result",
"cleared_other": "Cleared {{count}} results", "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"
} }
} }
+4 -1
View File
@@ -75,6 +75,9 @@
"clearNow": "Törlés most", "clearNow": "Törlés most",
"cleared_one": "{{count}} találat törölve", "cleared_one": "{{count}} találat törölve",
"cleared_other": "{{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"
} }
} }
+20
View File
@@ -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<string, string | null> {
return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget };
}
+31
View File
@@ -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]);
});
});
+32
View File
@@ -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<string, string | null> {
return {
[YT_SEARCH_KEY]: term,
[YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null,
};
}
+4 -1
View File
@@ -16,6 +16,8 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState"; import { FILES_KEYS } from "./lib/filesUrlState";
import { ytSearchParams } from "./lib/ytSearchUrlState";
import { budgetParams } from "./lib/homeUrlState";
import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState";
import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { playlistViewParamUpdates } from "./lib/playlistsUrlState";
import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; 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 // 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. // key has a row, so a new one cannot declare keys its codec does not write, or the other way round.
const WRITES: Partial<Record<Page, () => string[]>> = { const WRITES: Partial<Record<Page, () => string[]>> = {
feed: () => Object.keys(filterParamUpdates(FEED)), feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))],
home: () => Object.keys(budgetParams("short")),
channels: () => channels: () =>
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })), Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })),
plex: () => [ plex: () => [
@@ -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. // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
setFilters({ sync: "needsFull" }); setFilters({ sync: "needsFull" });
setView("subscribed"); // the sync filter applies to the subscriptions tab 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"); setPage("channels");
}, [setFilters, setView, setPage]); }, [setFilters, setView, setPage]);
+27 -21
View File
@@ -40,6 +40,8 @@ import {
} from "../../features/video/videoWrites"; } from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications"; import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { YT_COUNTS } from "../../lib/ytSearchUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { import {
markedRows, markedRows,
@@ -148,9 +150,6 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
return next; return next;
}; };
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the // The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does. // shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([ const SEARCH_FINAL_REASONS = new Set([
@@ -221,7 +220,8 @@ export default function Feed({
const qc = useQueryClient(); const qc = useQueryClient();
const videoMarks = useVideoMarks(); const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused. // Live search turned off on this instance: the empty feed offers no search that would be refused.
const searchOn = useMe().search_enabled; const me = useMe();
const searchOn = me.search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it // What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at // refetches the list by its filters once those land. Marked watched (by hand, or by the player at
@@ -263,8 +263,16 @@ export default function Feed({
const ytActive = !!ytSearch; const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more"; // How many live-search results to gather (the count selector replaces a manual "load more";
// the free scrape source pages through continuations until this many are collected). // the free scrape source pages through continuations until this many are collected). Named in the
const [ytCount, setYtCount] = useState(20); // address with the term, so it is the navigation's, like the term.
const { ytCount, ytSearchAsked } = useNavigation();
const { setYtCount, askYtSearch } = useNavigationActions();
// A search runs by itself only where nobody's reload or link can spend quota with it: the free
// page scrape, or a search asked for in this document. An API search reopened by a reload, a link
// or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled
// query shows all the same. Turned off on this instance it runs too: the server refuses it before
// any quota, and its refusal is what says so (no press offered that could only be refused).
const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn);
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page // How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
// must not silently re-sort the library you go back to. Default `relevance` — the provider's // must not silently re-sort the library you go back to. Default `relevance` — the provider's
// ranking, which is what the view always showed before it had a control. // ranking, which is what the view always showed before it had a control.
@@ -298,23 +306,11 @@ export default function Feed({
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)), stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive, enabled: ytRuns,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
retry: false, 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 // 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 // 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 // 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) ?? ""); const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we // 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. // 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 ( return (
<div className="p-4"> <div className="p-4">
@@ -638,7 +635,16 @@ export default function Feed({
)} )}
</div> </div>
{ytQuery.isLoading ? ( {!ytRuns && ytQuery.data === undefined ? (
// Reopened, not asked for: the term is on screen, the search waits for a press.
<div className="py-16 text-center max-w-md mx-auto">
<p className="font-semibold">{t("feed.yt.notRunTitle")}</p>
<p className="mt-1 text-sm text-muted">{t("feed.yt.notRunBody")}</p>
<Button className="mt-4" icon={<YouTubeIcon />} onClick={askYtSearch}>
{t("feed.yt.runAgain")}
</Button>
</div>
) : ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div> <div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError && !ytItems.length ? ( ) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can // A first page that failed is said as the error it is, with Retry where trying again can
@@ -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<typeof import("react-i18next")>()),
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 }[] }) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>{v.title}</li>
))}
</ol>
),
}));
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> = {}): 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) => (
<QueryClientProvider client={qc}>
<NavigationActionsContext.Provider value={nav}>
<NavigationStateContext.Provider
value={{
page: "feed",
channelView: null,
ytSearch: "lofi",
ytCount: count,
ytSearchAsked: asked,
}}
>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch="lofi"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</NavigationStateContext.Provider>
</NavigationActionsContext.Provider>
</QueryClientProvider>
);
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();
});
});
+3 -1
View File
@@ -2,6 +2,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { FEED_KEYS } from "../../lib/urlState"; import { FEED_KEYS } from "../../lib/urlState";
import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState";
import { FeedStore } from "./FeedStore"; import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -16,7 +17,8 @@ export const feedModule: ModuleDef = {
Rail, Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore, 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, playsVideos: true,
}; };
+34 -2
View File
@@ -174,8 +174,9 @@ function mountWithClient() {
} }
beforeEach(() => { beforeEach(() => {
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one. // Home keeps its question on the history entry (Back restores it) and its budget in the address:
window.history.replaceState(null, ""); // each test starts a fresh one of both.
window.history.replaceState(null, "", "/");
discover.mockReset(); discover.mockReset();
discoverPick.mockReset(); discoverPick.mockReset();
setPage.mockReset(); setPage.mockReset();
@@ -636,6 +637,37 @@ describe("Home", () => {
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked)); 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 // 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 // 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 // mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is
+15 -2
View File
@@ -79,6 +79,8 @@ import {
} from "../../components/ui/mediaCardStyles"; } from "../../components/ui/mediaCardStyles";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer"; 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?". // 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() { export default function Home() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); 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 // 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 // (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). // then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19).
const [saved] = useState(readSavedQuestion); // The budget alone is also in the address (lib/homeUrlState): a setting, unlike the draw under it.
const [budget, setBudget] = useState<DiscoverBudget>(saved?.budget ?? "any"); const [{ budget: openedBudget, saved }] = useState(openingQuestion);
const [budget, setBudget] = useState<DiscoverBudget>(openedBudget);
useUrlMirror(budgetParams(budget));
// 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so // 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. // pressing the button does not refetch the rails under the user's cursor.
const [seed, setSeed] = useState(saved?.seed ?? 0); const [seed, setSeed] = useState(saved?.seed ?? 0);
+4 -3
View File
@@ -1,6 +1,7 @@
import { Home } from "lucide-react"; import { Home } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; 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. // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const homeModule: ModuleDef = { export const homeModule: ModuleDef = {
@@ -11,8 +12,8 @@ export const homeModule: ModuleDef = {
icon: Home, icon: Home,
Page: lazyWithPreload(() => import("./Home")), Page: lazyWithPreload(() => import("./Home")),
maxWidth: "80rem", maxWidth: "80rem",
// Nothing of its own: the question is an answer, not a setting — the same question draws other // Its time budget, a setting. Not the question's draw: the same question draws other cards once
// cards once what you have watched moves on. // what you have watched moves on (lib/homeUrlState).
urlKeys: [], urlKeys: HOME_KEYS,
playsVideos: true, playsVideos: true,
}; };
@@ -380,6 +380,8 @@ describe("a row's history", () => {
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
exitYtSearch: () => {}, exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
}} }}
> >
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>