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:
2026-08-20 05:21:13 +02:00
parent 0401dae689
commit 062015ba27
3 changed files with 145 additions and 37 deletions
+11 -37
View File
@@ -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<NavigationState>({
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=<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
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
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<ChannelView | null>(() =>
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);