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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user