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
+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,
};