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
@@ -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}>