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
+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 { 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<string | null>(() => {
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<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
// 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
@@ -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<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>(
() => ({ 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 (
@@ -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 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<NavigationState>({
page: "feed",
channelView: null,
ytSearch: null,
ytCount: DEFAULT_YT_COUNT,
ytSearchAsked: false,
});
export const NavigationActionsContext = createContext<NavigationActions>({
setPage: () => {},
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
});
export function useNavigation(): NavigationState {
+4 -1
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
}
}
+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 { 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<Record<Page, () => 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: () => [
@@ -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]);
+27 -21
View File
@@ -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 = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
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
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
@@ -221,7 +220,8 @@ export default function Feed({
const qc = useQueryClient();
const videoMarks = useVideoMarks();
// 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
// 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;
// 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).
const [ytCount, setYtCount] = useState(20);
// the free scrape source pages through continuations until this many are collected). Named in the
// 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
// 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.
@@ -298,23 +306,11 @@ export default function Feed({
stampFetch(() => 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 (
<div className="p-4">
@@ -638,7 +635,16 @@ export default function Feed({
)}
</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>
) : ytError && !ytItems.length ? (
// 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 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,
};
+34 -2
View File
@@ -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
+15 -2
View File
@@ -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<DiscoverBudget>(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<DiscoverBudget>(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);
+4 -3
View File
@@ -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,
};
@@ -380,6 +380,8 @@ describe("a row's history", () => {
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
askYtSearch: () => {},
setYtCount: () => {},
}}
>
<QueryClientProvider client={qc}>