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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user