diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 336984a5..869d293d 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -9,11 +9,12 @@ import { type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { hasFilterParams, isPage, readPage, urlWithParams, type Page } from "../lib/urlState"; +import { urlWithParams, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; -import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { setNavigator } from "../lib/nav"; +import { channelViewFrom, loadInitialPage, type ChannelView } from "./navigationInit"; // Owns the app's navigation + browser-history state, lifted out of the App god component so pages // consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree. @@ -28,11 +29,6 @@ import { setNavigator } from "../lib/nav"; const PAGE_KEY = LS.page; -export interface ChannelView { - id: string; - name?: string; -} - export interface NavigationState { page: Page; /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ @@ -49,19 +45,6 @@ export interface NavigationActions { exitYtSearch: () => void; } -function loadInitialPage(): Page { - const params = new URLSearchParams(window.location.search); - if (params.has("page")) return readPage(); - // A view-specific param implies its page, so a bookmarked deep link lands on the right module even - // without an explicit ?page. Feed filters (or a channel overlay) imply the feed. - if (params.has("v")) return "plex"; - if (params.has("pl")) return "playlists"; - if (hasFilterParams(params) || params.has("chan")) return "feed"; - // A bare URL → the account's stored landing page. - const stored = getAccountRaw(PAGE_KEY); - return isPage(stored) ? stored : "feed"; -} - const StateContext = createContext({ page: "feed", channelView: null, @@ -97,14 +80,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // detail loads) AND is mirrored to the address bar as ?chan=, 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 // entry reads history.state._chan; a fresh bookmark has only the ?chan param. - const [channelView, setChannelView] = useState(() => { - const st = window.history.state; - const id = - (st?._chan as string | undefined) ?? - new URLSearchParams(window.location.search).get("chan") ?? - undefined; - return id ? { id, name: (st?._chanName as string) ?? undefined } : null; - }); + const [channelView, setChannelView] = useState(() => + channelViewFrom(window.history.state, window.location.search), + ); // Refs so the STABLE action callbacks can read the latest state without depending on it — a dep // would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard). @@ -244,15 +222,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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); - // Fall back to the ?chan URL param (the browser restored the landed entry's URL) when the entry - // has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would - // wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic - // history.back()). Mirrors the channelView initializer's URL fallback. - const chan = - (e.state?._chan as string | undefined) ?? - new URLSearchParams(window.location.search).get("chan") ?? - undefined; - setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null); + // 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 + // shares this resolution with the seed initializer above. + setChannelView(channelViewFrom(e.state, window.location.search)); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); diff --git a/frontend/src/components/navigation.dom.test.ts b/frontend/src/components/navigation.dom.test.ts new file mode 100644 index 00000000..9a0bc15e --- /dev/null +++ b/frontend/src/components/navigation.dom.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +// +// NavigationProvider's URL-derived seeds, backfilling the E4 deep-review's deferred coverage: +// • loadInitialPage — the bookmark deep-link precedence (?page > ?v > ?pl > filters/?chan > the +// account's stored landing page > feed). +// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the +// onPop handler (the S1 fix): a popstate landing on a URL-only bookmark entry must restore the +// channel from ?chan, not close it. +// Only getAccountRaw is stubbed (the account's stored landing page); everything else is real. +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { stored } = vi.hoisted(() => ({ stored: {} as Record })); +vi.mock("../lib/storage", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null }; +}); + +import { loadInitialPage, channelViewFrom } from "./navigationInit"; +import { LS } from "../lib/storage"; + +const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); + +beforeEach(() => { + setUrl("/"); + for (const k of Object.keys(stored)) delete stored[k]; +}); + +describe("loadInitialPage precedence", () => { + it("?page wins and is validated", () => { + setUrl("?page=stats"); + expect(loadInitialPage()).toBe("stats"); + }); + it("an invalid ?page falls back to feed", () => { + setUrl("?page=bogus"); + expect(loadInitialPage()).toBe("feed"); + }); + it("?v implies the plex module", () => { + setUrl("?v=show:1"); + expect(loadInitialPage()).toBe("plex"); + }); + it("?pl implies playlists", () => { + setUrl("?pl=3"); + expect(loadInitialPage()).toBe("playlists"); + }); + it("a filter param implies the feed", () => { + setUrl("?tags=1,2"); + expect(loadInitialPage()).toBe("feed"); + }); + it("?chan (the channel overlay) implies the feed", () => { + setUrl("?chan=UC1"); + expect(loadInitialPage()).toBe("feed"); + }); + it("?page beats a lower-precedence param", () => { + setUrl("?page=stats&v=show:1"); + expect(loadInitialPage()).toBe("stats"); + }); + it("?v beats ?pl and filters", () => { + setUrl("?v=show:1&pl=3&tags=1"); + expect(loadInitialPage()).toBe("plex"); + }); + it("a bare URL uses the account's stored landing page", () => { + stored[LS.page] = "downloads"; + expect(loadInitialPage()).toBe("downloads"); + }); + it("a bare URL with no / invalid stored page defaults to feed", () => { + expect(loadInitialPage()).toBe("feed"); + stored[LS.page] = "nope"; + expect(loadInitialPage()).toBe("feed"); + }); +}); + +describe("channelViewFrom", () => { + it("reads _chan (+ _chanName) from an in-app entry", () => { + expect(channelViewFrom({ _chan: "UC1", _chanName: "Chan" }, "")).toEqual({ + id: "UC1", + name: "Chan", + }); + }); + it("_chan without a name yields an undefined name", () => { + expect(channelViewFrom({ _chan: "UC1" }, "")).toEqual({ id: "UC1", name: undefined }); + }); + it("falls back to the ?chan URL param when the entry has no _chan (fresh bookmark) — the S1 fix", () => { + expect(channelViewFrom(null, "?chan=UC9")).toEqual({ id: "UC9", name: undefined }); + }); + it("prefers _chan over the URL param", () => { + expect(channelViewFrom({ _chan: "UCstate" }, "?chan=UCurl")).toEqual({ + id: "UCstate", + name: undefined, + }); + }); + it("returns null when neither is present (channel closed)", () => { + expect(channelViewFrom({}, "")).toBeNull(); + expect(channelViewFrom(null, "/")).toBeNull(); + }); +}); diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts new file mode 100644 index 00000000..a57b0c03 --- /dev/null +++ b/frontend/src/components/navigationInit.ts @@ -0,0 +1,39 @@ +// URL-derived navigation seeds, split out of NavigationProvider so they stay unit-testable AND the +// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing +// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the +// channelView seed and the onPop handler. +import { hasFilterParams, isPage, readPage, type Page } from "../lib/urlState"; +import { getAccountRaw, LS } from "../lib/storage"; + +const PAGE_KEY = LS.page; + +export interface ChannelView { + id: string; + name?: string; +} + +export function loadInitialPage(): Page { + const params = new URLSearchParams(window.location.search); + if (params.has("page")) return readPage(); + // A view-specific param implies its page, so a bookmarked deep link lands on the right module even + // without an explicit ?page. Feed filters (or a channel overlay) imply the feed. + if (params.has("v")) return "plex"; + if (params.has("pl")) return "playlists"; + if (hasFilterParams(params) || params.has("chan")) return "feed"; + // A bare URL → the account's stored landing page. + const stored = getAccountRaw(PAGE_KEY); + return isPage(stored) ? stored : "feed"; +} + +/** Resolve the open channel page from a landed history entry: the in-app `_chan` id (with its optional + * `_chanName`), else the ?chan URL param — a fresh ?chan bookmark's entry is URL-only. The seed AND + * the popstate handler share this ONE resolver so a popstate that lands on a bookmark entry (no + * `_chan`) restores the channel from the URL instead of wrongly closing it. */ +export function channelViewFrom( + state: { _chan?: unknown; _chanName?: unknown } | null | undefined, + search: string, +): ChannelView | null { + const id = + (state?._chan as string | undefined) ?? new URLSearchParams(search).get("chan") ?? undefined; + return id ? { id, name: (state?._chanName as string) ?? undefined } : null; +}