test(e4): loadInitialPage precedence + channelViewFrom (S1 fix)
Split the two URL-derived nav seeds out of NavigationProvider into navigationInit.ts so they're unit-testable and the provider stays react-refresh-clean (component-only exports). channelViewFrom also dedups the identical _chan/?chan resolve the channelView seed and the onPop handler each hand-rolled — locking the S1 fix (a popstate on a URL-only bookmark entry must restore the channel from ?chan, not close it). Pure code motion. Tests: the full loadInitialPage precedence matrix (?page > ?v > ?pl > filters/?chan > stored landing > feed, with the beats-a-lower-param cases) and channelViewFrom (_chan+name, ?chan fallback, state-over-url, closed → null). 15 tests; full FE gate green (tsc/eslint/prettier/knip).
This commit is contained in:
@@ -9,11 +9,12 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
import { setNavigator } from "../lib/nav";
|
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
|
// 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.
|
// 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;
|
const PAGE_KEY = LS.page;
|
||||||
|
|
||||||
export interface ChannelView {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationState {
|
export interface NavigationState {
|
||||||
page: Page;
|
page: Page;
|
||||||
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
||||||
@@ -49,19 +45,6 @@ export interface NavigationActions {
|
|||||||
exitYtSearch: () => void;
|
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<NavigationState>({
|
const StateContext = createContext<NavigationState>({
|
||||||
page: "feed",
|
page: "feed",
|
||||||
channelView: null,
|
channelView: null,
|
||||||
@@ -97,14 +80,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
// 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
|
// 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.
|
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
||||||
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
|
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
||||||
const st = window.history.state;
|
channelViewFrom(window.history.state, window.location.search),
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
// 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).
|
// 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
|
// 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.
|
// restores or clears them to match the entry we landed on.
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
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
|
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
||||||
// has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would
|
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
||||||
// wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic
|
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
||||||
// history.back()). Mirrors the channelView initializer's URL fallback.
|
// shares this resolution with the seed initializer above.
|
||||||
const chan =
|
setChannelView(channelViewFrom(e.state, window.location.search));
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
|||||||
@@ -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<string, string | null> }));
|
||||||
|
vi.mock("../lib/storage", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../lib/storage")>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user