fix(channel): the channel page's address names the channel and its own view, not the page under it

Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
This commit is contained in:
2026-10-26 00:19:02 +01:00
parent 9bd7304bb9
commit 91d36b3357
17 changed files with 565 additions and 46 deletions
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { urlPage } from "./urlPage";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
@@ -225,6 +226,58 @@ describe("NavigationProvider · a link carrying another page's keys", () => {
expect(window.location.search).toBe("?page=stats&chan=UC1");
expect(result.current.channelView?.id).toBe("UC1");
});
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
render();
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns");
});
});
// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's
// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show.
describe("NavigationProvider · the channel page's address", () => {
it("names only the channel; the page under it keeps its own address for Back", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
expect(window.location.search).toBe("?chan=UC1");
act(() => window.history.back());
await vi.waitFor(() => expect(result.current.channelView).toBeNull());
expect(window.location.search).toBe("?q=music&sort=oldest");
});
it("says no page owns the address while the channel stands over it", () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(urlPage(result.current)).toBe("plex");
act(() => result.current.openChannel("UC1", "One"));
expect(urlPage(result.current)).toBeNull();
});
it("another channel opened from it starts fresh", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest");
act(() => result.current.openChannel("UC2", "Two"));
expect(window.location.search).toBe("?chan=UC2");
});
it("a page switch takes the channel page's keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n");
const { result } = render();
act(() => result.current.setPage("stats"));
expect(window.location.search).toBe("?page=stats");
});
it("closing it in place takes its keys with it", () => {
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n");
const { result } = render();
stubNavigation([false]);
act(() => result.current.closeChannel());
expect(window.location.search).toBe("");
});
});
describe("NavigationProvider · Back", () => {
@@ -2,7 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import {
channelUrl,
resolvePage,
retiredParams,
urlWithParams,
type Page,
} from "../../lib/urlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
@@ -13,6 +19,7 @@ import {
rememberNavPosition,
} from "../../lib/history";
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
import {
channelViewFrom,
loadInitialPage,
@@ -41,11 +48,20 @@ import {
function pageRootParams(page: Page): Record<string, string | null> {
return {
page: page === "feed" ? null : page,
chan: null,
...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])),
...closedChannelParams(),
...nulls(moduleUrlKeys()),
};
}
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
* a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */
const closedChannelParams = (): Record<string, string | null> => ({
chan: null,
...nulls(CHANNEL_PAGE_KEYS),
});
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
@@ -128,7 +144,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
// The channel's address names the channel alone: the page under it keeps its own keys on its
// own entry, where Back finds them, and writes none while the channel stands over it (urlPage).
const url = channelUrl(id);
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
@@ -148,7 +166,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// entry's channel marks and the ?chan param.
setChannelView(null);
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
window.history.replaceState(st, "", urlWithParams(closedChannelParams()));
}
}, []);
@@ -234,9 +252,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY);
// A link can carry another page's keys (hand-edited, or written by an older build): the page
// opens without them, so its address names only what it shows.
// opens without them, so its address names only what it shows — and a channel page open over it
// shows none of the page's own (an older build's channel links carried the feed's filters).
const shown = channelViewFrom(st, window.location.search);
const url = urlWithParams({
...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])),
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage,
});
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
//
// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05:
// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is
// still mounted — its store lives at the root — and a change in it while the channel stands over it
// (a background refetch, the header search) must not write its keys onto the channel's address. Closing
// the channel brings them back. The real provider and the real stores; only the network is absent.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
}));
import { NavigationProvider } from "./NavigationProvider";
import { ModuleStores } from "./ModuleStores";
import { useNavigationActions, type NavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed";
import { usePlexActions } from "../../modules/plex";
import { useChannelsActions } from "../../modules/channels";
import { useFilesActions } from "../../modules/files";
import { usePlaylistsActions } from "../../modules/playlists";
import { claimLocalData } from "../../lib/storage";
type Probe = {
nav: NavigationActions;
change: Record<string, () => void>;
};
const probe: { current: Probe | null } = { current: null };
function Probe() {
const nav = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const plex = usePlexActions();
const channels = useChannelsActions();
const files = useFilesActions();
const playlists = usePlaylistsActions();
// Handed out after each render (an effect, not the render itself, may write outside it).
useEffect(() => {
probe.current = {
nav,
change: {
feed: () => setFilters({ ...filters, sort: "oldest" }),
plex: () => plex.setScope("movie"),
channels: () => channels.setView("hidden"),
files: () => files.setView({ kind: "folder", id: 3 }),
playlists: () => playlists.setSelectedId(5),
},
};
});
return null;
}
// What each page's change above writes, once the page is what the address shows.
const WRITTEN: Record<string, [string, string]> = {
feed: ["sort", "oldest"],
plex: ["pscope", "movie"],
channels: ["ctab", "hidden"],
files: ["folder", "3"],
playlists: ["pl", "5"],
};
beforeEach(() => {
localStorage.clear();
claimLocalData(3);
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
const search = () => new URLSearchParams(window.location.search);
describe.each(Object.keys(WRITTEN))("a channel page over %s", (page) => {
it("keeps the page's keys off its address, and they come back when it closes", async () => {
// The page named even for the feed: a bare address opens on Home (the account's default).
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<ModuleStores>
<Probe />
</ModuleStores>
</NavigationProvider>
</QueryClientProvider>,
);
act(() => probe.current!.nav.openChannel("UC1", "One"));
act(() => probe.current!.change[page]!());
const [key, value] = WRITTEN[page]!;
expect(search().get(key)).toBeNull();
expect(search().get("chan")).toBe("UC1");
act(() => probe.current!.nav.closeChannel());
await waitFor(() => expect(search().get("chan")).toBeNull());
await waitFor(() => expect(search().get(key)).toBe(value));
});
});
@@ -0,0 +1,9 @@
import type { Page } from "../../lib/urlState";
import type { NavigationState } from "./navigationContext";
/** The page whose own keys the address bar carries: none while the channel page stands over it, so
* every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows.
* A plain function beside the context, not a hook in it: the stores' tests mock that module. */
export function urlPage(nav: Pick<NavigationState, "page" | "channelView">): Page | null {
return nav.channelView ? null : nav.page;
}
@@ -48,8 +48,9 @@ describe("ChannelNameLink", () => {
it("is an anchor whose href opens this channel on the current page", () => {
const { link } = renderLink();
expect(link.tagName).toBe("A");
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
// The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
// which named a view the channel does not show (`show=unwatched` here).
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
});
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
@@ -73,11 +74,16 @@ describe("ChannelNameLink", () => {
expect(onOpenInApp).not.toHaveBeenCalled();
});
it("does not lose the rest of the address bar", () => {
window.history.replaceState({}, "", "/?page=channels&q=abc");
// The address names only what the screen shows: the channel over the page, not the page's filters
// (a link's new tab once opened the channel under the feed's search and sort).
it("keeps the page and the shell's keys, and leaves the page's and another channel's behind", () => {
window.history.replaceState(
{},
"",
"/?page=channels&q=abc&ctab=hidden&chtab=about&m=signup#x=1",
);
const { link } = renderLink();
expect(link.getAttribute("href")).toContain("page=channels");
expect(link.getAttribute("href")).toContain("q=abc");
expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
});
it("gives a channel with no title yet an accessible name", () => {
@@ -1,15 +1,15 @@
import { forwardRef, type AnchorHTMLAttributes } from "react";
import { useTranslation } from "react-i18next";
import { isPlainLeftClick } from "../../lib/nav";
import { urlWithParams } from "../../lib/urlState";
import { channelUrl } from "../../lib/urlState";
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
// card's channel name had the same `<button>`, so it renders through here too.
//
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
// The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
// without the page's own keys) — so a new tab lands where the in-app navigation would have. A plain left click is still handled in-app, because
// the SPA path does more than navigate (the player has to close first and reclaim its own
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
<a
{...rest}
ref={ref}
href={urlWithParams({ chan: channelId })}
href={channelUrl(channelId)}
title={tooltip}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();