From b30c60d1a54f65ff5638422f1c74e56ac655ec87 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 25 Oct 2026 20:48:14 +0100 Subject: [PATCH 01/15] feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared key literal in the app. --- docs/development.md | 7 +- .../NavigationProvider.dom.test.tsx | 32 +++++ .../navigation/NavigationProvider.tsx | 30 ++--- .../src/features/navigation/moduleRegistry.ts | 11 ++ frontend/src/lib/channelsUrlState.ts | 3 +- frontend/src/lib/filesUrlState.ts | 6 + frontend/src/lib/moduleDef.ts | 6 + frontend/src/lib/plexUrlState.ts | 5 +- frontend/src/lib/urlState.ts | 19 ++- frontend/src/moduleUrl.contract.test.ts | 125 ++++++++++++++++++ frontend/src/modules/activity/module.ts | 2 + frontend/src/modules/channels/module.ts | 2 + frontend/src/modules/config/module.ts | 1 + frontend/src/modules/feed/module.ts | 2 + frontend/src/modules/files/FilesProvider.tsx | 9 +- frontend/src/modules/files/module.ts | 2 + frontend/src/modules/home/module.ts | 3 + frontend/src/modules/notifications/module.ts | 1 + frontend/src/modules/playlists/module.ts | 2 + frontend/src/modules/plex/module.ts | 2 + frontend/src/modules/scheduler/module.ts | 1 + frontend/src/modules/settings/module.ts | 1 + frontend/src/modules/stats/module.ts | 1 + frontend/src/modules/users/module.ts | 1 + 24 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/filesUrlState.ts create mode 100644 frontend/src/moduleUrl.contract.test.ts diff --git a/docs/development.md b/docs/development.md index 0bb8d445..a81a6349 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,9 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in page renders into the floating header itself through `PageHeader`; the shell knows no page. 2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a `lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically - only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. + only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every + address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside + its codec; the shell clears them on a page switch and opens a page without another page's keys. 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's hooks). Another folder reaches the module through this file or not at all. 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in @@ -58,7 +60,8 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in returns to `ui/FilterRail`; tsc names each table still missing the new id. What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), -`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go +`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key +belongs to one module and matches what its codec writes; no undeclared key literal), the `local/layers` lint rule (imports only go down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the i18n parity test. diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 55684824..d21893eb 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -167,6 +167,19 @@ describe("NavigationProvider · setPage's same-page guard", () => { expect(window.location.search).toBe("?page=plex&keep=1"); }); + it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => { + // Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key + // missing from a hand-kept list rode along onto the next page's entry. + window.history.replaceState( + { sfPage: "files" }, + "", + "/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1", + ); + const { result } = render(); + act(() => result.current.setPage("plex")); + expect(window.location.search).toBe("?page=plex&keep=1"); + }); + it("pushes nothing when it is already that page", () => { const { result } = render(); act(() => result.current.setPage("plex")); @@ -195,6 +208,25 @@ describe("NavigationProvider · setPage's same-page guard", () => { }); }); +describe("NavigationProvider · a link carrying another page's keys", () => { + it("opens with only its own page's keys: what the page does not show, its address does not say", () => { + window.history.replaceState( + {}, + "", + "/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1", + ); + render(); + expect(window.location.search).toBe("?page=activity&at=logs&keep=1"); + }); + + it("keeps the channel page open over any page", () => { + window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all"); + const { result } = render(); + expect(window.location.search).toBe("?page=stats&chan=UC1"); + expect(result.current.channelView?.id).toBe("UC1"); + }); +}); + describe("NavigationProvider · Back", () => { // A tab that had a since-removed module open, reloaded onto the new build: its older history // entries still name that page, and Back lands on one of them. diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 37909d56..a09de092 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -3,7 +3,6 @@ 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 { ACTIVITY_KEYS } from "../../lib/activityUrlState"; import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { @@ -13,7 +12,7 @@ import { notifyNav, rememberNavPosition, } from "../../lib/history"; -import { pageTitleKey, reachablePage } from "./moduleRegistry"; +import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { channelViewFrom, loadInitialPage, @@ -37,21 +36,13 @@ import { // // The contexts, their value types and the hooks live in navigationContext.ts. -/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */ +/** The address bar of `page`'s root: the page (feed implicit) with every module's own keys cleared — a + * page switch resets to that module's root, and its store mirrors what it holds back in. */ function pageRootParams(page: Page): Record { return { page: page === "feed" ? null : page, chan: null, - v: null, - pl: null, - pscope: null, - psort: null, - pshow: null, - pf: null, - ctab: null, - csort: null, - cf: null, - ...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])), + ...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])), }; } @@ -116,12 +107,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its - // root, and the address bar reflects the page (feed is implicit) while clearing the - // view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page - // switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left - // intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to - // their page. The item it was opened from is still focused: the state setters above have not - // re-rendered anything yet. + // root, and the address bar reflects the page (feed is implicit) while clearing `chan` and + // every module's own keys (pageRootParams) — a page switch resets to that module's root, and + // each module's store mirrors its state back in on its page. The item it was opened from is + // still focused: the state setters above have not re-rendered anything yet. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, @@ -244,7 +233,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // part of the page that replaced it (an `at` tab). 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. const url = urlWithParams({ + ...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), page: mountPage === "feed" ? null : mountPage, }); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts index 2f557273..82e55d74 100644 --- a/frontend/src/features/navigation/moduleRegistry.ts +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -48,6 +48,17 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id); const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record; +// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and +// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys +// are cleared the day it declares them. +const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys); +export function moduleUrlKeys(): readonly string[] { + return ALL_URL_KEYS; +} +export function foreignUrlKeys(page: Page): readonly string[] { + return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys); +} + // Look up a module's definition by page id (every Page is covered, incl. settings). export function moduleDef(id: Page): ModuleDef { return BY_ID[id]; diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts index 3a0fdd0e..e9e64c3a 100644 --- a/frontend/src/lib/channelsUrlState.ts +++ b/frontend/src/lib/channelsUrlState.ts @@ -17,7 +17,8 @@ export interface ChannelsUrlState { filters: FilterMap; } -const KEYS = ["ctab", "csort", "cf"] as const; +export const CHANNELS_KEYS = ["ctab", "csort", "cf"] as const; +const KEYS = CHANNELS_KEYS; const VIEWS = ["subscribed", "discovery", "hidden", "blocked"]; // Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default diff --git a/frontend/src/lib/filesUrlState.ts b/frontend/src/lib/filesUrlState.ts new file mode 100644 index 00000000..4e964e01 --- /dev/null +++ b/frontend/src/lib/filesUrlState.ts @@ -0,0 +1,6 @@ +// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in +// modules/files/filesContext) and the file it turns to once (`?item=`). Here, beside the other pages' +// key lists, so the page's definition can declare them without importing its own folder. +export const FILES_FOLDER_KEY = "folder"; +export const FILES_ITEM_KEY = "item"; +export const FILES_KEYS = [FILES_FOLDER_KEY, FILES_ITEM_KEY] as const; diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index 207924d7..5bddcdec 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -44,4 +44,10 @@ export type ModuleDef = { // Static, unlike the page: it is on the boot path by design. It reads no other module's store, so // the order the stores nest in does not matter (moduleStores.contract.test). Store?: ComponentType<{ children: ReactNode }>; + // Every address-bar key this page's view writes: its filters, sort, tab, the item it shows. The URL + // IS the view, so a bookmark reopens it. The shell clears them all on a page switch, and opens a page + // with no other page's keys in its address (NavigationProvider), so one page's state never rides + // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to + // one module and none is a shell key (moduleUrl.contract.test). + urlKeys: readonly string[]; }; diff --git a/frontend/src/lib/plexUrlState.ts b/frontend/src/lib/plexUrlState.ts index 2b1c5990..c40bc375 100644 --- a/frontend/src/lib/plexUrlState.ts +++ b/frontend/src/lib/plexUrlState.ts @@ -18,7 +18,10 @@ export interface PlexUrlState { filters: PlexFilters; } -const KEYS = ["pscope", "psort", "pshow", "pf"] as const; +export const PLEX_KEYS = ["pscope", "psort", "pshow", "pf"] as const; +const KEYS = PLEX_KEYS; +// The Plex sub-view's key (show, season, info, person… — modules/plex/plexSubUrl encodes it). +export const PLEX_SUB_KEY = "v"; const SCOPES = ["movie", "show", "both"]; const SHOWS = ["all", "unwatched", "in_progress", "watched"]; diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 486ad5e9..53c269dd 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -6,7 +6,7 @@ // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. import { TAG_CATEGORIES, type FeedFilters } from "./api"; -const KEYS = [ +export const FEED_KEYS = [ "q", "show", "sort", @@ -29,6 +29,23 @@ const KEYS = [ "mindur", "maxdur", ] as const; +const KEYS = FEED_KEYS; + +// The keys the shell itself reads, whatever page is on screen: the page, the channel page (it opens +// over any page), the welcome card's mode and the one-time entry links. No module may declare one of +// these (moduleUrl.contract.test), and a page switch never clears them. +export const SHELL_KEYS = [ + "page", + "chan", + "m", + "link", + "oauth", + "admin", + "account", + "login", + "deleted", + "token", +] as const; export function filtersToParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts new file mode 100644 index 00000000..f587090f --- /dev/null +++ b/frontend/src/moduleUrl.contract.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +// +// The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the +// shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, +// derived from these declarations). That only holds while the declarations are true, so this pins +// them: each key belongs to one module and is no shell key, every codec writes only its module's keys, +// and no key literal anywhere in the app reads or writes the address bar undeclared. +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistry"; +import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api"; +import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState"; +import { channelsParamUpdates } from "./lib/channelsUrlState"; +import { plexParamUpdates } from "./lib/plexUrlState"; +import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; +import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; +import { FILES_KEYS } from "./lib/filesUrlState"; +import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl"; +import { MODE_URL } from "./shell/welcomeMode"; + +const SRC = __dirname; + +const FEED: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +// What each module's own encoders write, from the encoders themselves. Every module that declares a +// key has a row, so a new one cannot declare keys its codec does not write, or the other way round. +const WRITES: Partial string[]>> = { + feed: () => Object.keys(filterParamUpdates(FEED)), + channels: () => + Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {} })), + plex: () => [ + ...Object.keys( + plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }), + ), + PLEX_SUB_URL.key, + ], + files: () => [...FILES_KEYS], + playlists: () => ["pl"], + activity: () => [ + ...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)), + ...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)), + ], +}; + +function sources(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return sources(path); + return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : []; + }); +} + +// Key literals that reach the address bar's QUERY: the keys of an object literal handed to +// `urlWithParams` / `useUrlMirror`, and a `.get("…")` on the live query. +function queryKeyLiterals(): { key: string; where: string }[] { + const out: { key: string; where: string }[] = []; + for (const file of sources(SRC)) { + const text = readFileSync(file, "utf8"); + const where = relative(SRC, file).split("\\").join("/"); + for (const m of text.matchAll(/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g)) { + for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where }); + } + for (const m of text.matchAll( + /new URLSearchParams\(window\.location\.search\)\.get\("(\w+)"\)/g, + )) { + out.push({ key: m[1]!, where }); + } + } + return out; +} + +describe("module URL keys", () => { + const owner = new Map(); + for (const page of REGISTERED_PAGES) { + for (const key of moduleDef(page).urlKeys) owner.set(key, owner.get(key) ?? page); + } + + it("belong to one module each", () => { + const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys); + expect(all.filter((k, i) => all.indexOf(k) !== i)).toEqual([]); + }); + + it("are never a shell key", () => { + const shell: readonly string[] = SHELL_KEYS; + expect([...owner.keys()].filter((k) => shell.includes(k))).toEqual([]); + // The welcome card's sub-view key is the shell's own. + expect(shell).toContain(MODE_URL.key); + }); + + it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => { + const declared = [...moduleDef(page).urlKeys].sort(); + const writes = WRITES[page]; + if (!declared.length) { + expect(writes).toBeUndefined(); + return; + } + expect(writes, `${page} declares keys but has no WRITES row`).toBeDefined(); + expect([...new Set(writes!())].sort()).toEqual(declared); + }); + + it("cover every key literal the app reads or writes in the address bar", () => { + const shell: readonly string[] = SHELL_KEYS; + const undeclared = queryKeyLiterals().filter( + ({ key }) => !owner.has(key) && !shell.includes(key), + ); + expect(undeclared).toEqual([]); + }); + + it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { + const seen = new Set(queryKeyLiterals().map(({ key }) => key)); + for (const key of ["chan", "pl", "page", "rjob", "link"]) expect(seen).toContain(key); + }); +}); diff --git a/frontend/src/modules/activity/module.ts b/frontend/src/modules/activity/module.ts index 9eb2347a..fa95fe6b 100644 --- a/frontend/src/modules/activity/module.ts +++ b/frontend/src/modules/activity/module.ts @@ -1,6 +1,7 @@ import { ScrollText } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const activityModule: ModuleDef = { @@ -12,4 +13,5 @@ export const activityModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./Activity")), + urlKeys: ACTIVITY_KEYS, }; diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index 7e798c40..93b63b8e 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -1,6 +1,7 @@ import { Tv } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { CHANNELS_KEYS } from "../../lib/channelsUrlState"; import { ChannelsProvider } from "./ChannelsProvider"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. @@ -13,4 +14,5 @@ export const channelsModule: ModuleDef = { Page: lazyWithPreload(() => import("./Channels")), maxWidth: "96rem", Store: ChannelsProvider, + urlKeys: CHANNELS_KEYS, }; diff --git a/frontend/src/modules/config/module.ts b/frontend/src/modules/config/module.ts index 705322a4..5d0d4b17 100644 --- a/frontend/src/modules/config/module.ts +++ b/frontend/src/modules/config/module.ts @@ -12,4 +12,5 @@ export const configModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./ConfigPanel")), + urlKeys: [], }; diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index 51f7a162..3f16fb42 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -1,6 +1,7 @@ import { YouTubeIcon } from "../../lib/brandIcons"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { FEED_KEYS } from "../../lib/urlState"; import { FeedStore } from "./FeedStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -15,6 +16,7 @@ export const feedModule: ModuleDef = { Rail, Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Store: FeedStore, + urlKeys: FEED_KEYS, }; // The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 26251199..d2f15fa1 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { ALL_FILES, @@ -33,14 +34,14 @@ import { function initialView(): FilesView { return ( - parseView(new URLSearchParams(window.location.search).get("folder")) ?? + parseView(new URLSearchParams(window.location.search).get(FILES_FOLDER_KEY)) ?? parseView(getAccountRaw(LS.filesView)) ?? ALL_FILES ); } function initialFocus(): FilesFocus | null { - const id = parseItem(new URLSearchParams(window.location.search).get("item")); + const id = parseItem(new URLSearchParams(window.location.search).get(FILES_ITEM_KEY)); return id === null ? null : { id, token: 1 }; } @@ -87,8 +88,8 @@ export function FilesProvider({ children }: { children: ReactNode }) { const arrived = page === "files" ? arrivedRaw : null; useUrlMirror({ - folder: page === "files" ? viewParam(view) : null, - item: focus ? String(focus.id) : null, + [FILES_FOLDER_KEY]: page === "files" ? viewParam(view) : null, + [FILES_ITEM_KEY]: focus ? String(focus.id) : null, }); const state = useMemo( diff --git a/frontend/src/modules/files/module.ts b/frontend/src/modules/files/module.ts index 4c3d798a..5cef1c49 100644 --- a/frontend/src/modules/files/module.ts +++ b/frontend/src/modules/files/module.ts @@ -1,6 +1,7 @@ import { FolderOpen } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { FILES_KEYS } from "../../lib/filesUrlState"; import { FilesProvider } from "./FilesProvider"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -16,4 +17,5 @@ export const filesModule: ModuleDef = { Page: lazyWithPreloadAlongside(() => import("./Files"), Rail), maxWidth: "80rem", Store: FilesProvider, + urlKeys: FILES_KEYS, }; diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts index cc6c0450..e83cee01 100644 --- a/frontend/src/modules/home/module.ts +++ b/frontend/src/modules/home/module.ts @@ -11,4 +11,7 @@ export const homeModule: ModuleDef = { icon: Home, Page: lazyWithPreload(() => import("./Home")), maxWidth: "80rem", + // Nothing of its own: the question is an answer, not a setting — the same question draws other + // cards once what you have watched moves on. + urlKeys: [], }; diff --git a/frontend/src/modules/notifications/module.ts b/frontend/src/modules/notifications/module.ts index bd3072e1..ad05198d 100644 --- a/frontend/src/modules/notifications/module.ts +++ b/frontend/src/modules/notifications/module.ts @@ -11,4 +11,5 @@ export const notificationsModule: ModuleDef = { hasBadge: true, Page: lazyWithPreload(() => import("./NotificationsPanel")), maxWidth: "48rem", + urlKeys: [], }; diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index 4fba72dd..fc69335c 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -16,4 +16,6 @@ export const playlistsModule: ModuleDef = { Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail), maxWidth: "64rem", Store: PlaylistsProvider, + // The playlist shown (PlaylistsProvider, playlistsInit). + urlKeys: ["pl"], }; diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts index 4d2f9b70..70d36358 100644 --- a/frontend/src/modules/plex/module.ts +++ b/frontend/src/modules/plex/module.ts @@ -1,6 +1,7 @@ import { Clapperboard } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState"; import { PlexStore } from "./PlexStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -17,4 +18,5 @@ export const plexModule: ModuleDef = { Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail), maxWidth: "100rem", Store: PlexStore, + urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY], }; diff --git a/frontend/src/modules/scheduler/module.ts b/frontend/src/modules/scheduler/module.ts index 0511a3cc..9452e0e7 100644 --- a/frontend/src/modules/scheduler/module.ts +++ b/frontend/src/modules/scheduler/module.ts @@ -12,4 +12,5 @@ export const schedulerModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./Scheduler")), + urlKeys: [], }; diff --git a/frontend/src/modules/settings/module.ts b/frontend/src/modules/settings/module.ts index 551920fc..793c1de8 100644 --- a/frontend/src/modules/settings/module.ts +++ b/frontend/src/modules/settings/module.ts @@ -10,4 +10,5 @@ export const settingsModule: ModuleDef = { titleKey: "settings.title", icon: Settings, Page: lazyWithPreload(() => import("./SettingsPanel")), + urlKeys: [], }; diff --git a/frontend/src/modules/stats/module.ts b/frontend/src/modules/stats/module.ts index 08e70a6b..9d731d4e 100644 --- a/frontend/src/modules/stats/module.ts +++ b/frontend/src/modules/stats/module.ts @@ -10,4 +10,5 @@ export const statsModule: ModuleDef = { titleKey: "header.usageStats", icon: BarChart3, Page: lazyWithPreload(() => import("./Stats")), + urlKeys: [], }; diff --git a/frontend/src/modules/users/module.ts b/frontend/src/modules/users/module.ts index 1585f72c..5f34c253 100644 --- a/frontend/src/modules/users/module.ts +++ b/frontend/src/modules/users/module.ts @@ -13,4 +13,5 @@ export const usersModule: ModuleDef = { gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./AdminUsers")), maxWidth: "48rem", + urlKeys: [], }; From 9bd7304bb9dce2185c5af387c849cd8138420043 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 25 Oct 2026 23:24:29 +0100 Subject: [PATCH 02/15] fix(plex): a reload during a binge reopens the episode playing now The player stepped to the next item in its own state while the history entry still held the item it opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the player stay open, and the entry under an overlay is rewritten when a step lands back on it). The player's URL is the item playing (`?v=player:`), so a bookmark opens it; the list it plays from stays in the history entry. --- frontend/src/lib/history.subview.dom.test.ts | 78 ++++++++++++++ frontend/src/lib/history.ts | 46 ++++++-- .../plex/PlexBrowse.playerReload.dom.test.tsx | 102 ++++++++++++++++++ frontend/src/modules/plex/PlexBrowse.tsx | 5 +- .../plex/PlexPlayer.backspace.dom.test.tsx | 2 +- .../plex/PlexPlayer.controls.dom.test.tsx | 36 +++++++ .../modules/plex/PlexPlayer.hls.dom.test.tsx | 2 +- .../plex/PlexPlayer.panels.dom.test.tsx | 2 +- .../plex/PlexPlayer.session.dom.test.tsx | 2 +- .../plex/PlexPlayer.strictmode.dom.test.tsx | 4 +- .../plex/PlexPlayer.tabtrap.dom.test.tsx | 2 +- frontend/src/modules/plex/PlexPlayer.tsx | 21 +++- frontend/src/modules/plex/plexSubUrl.test.ts | 13 ++- frontend/src/modules/plex/plexSubUrl.ts | 15 ++- 14 files changed, 302 insertions(+), 28 deletions(-) create mode 100644 frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index f435f2e1..c7d96658 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -185,6 +185,84 @@ describe("useHistorySubview entry", () => { }); }); +// A view that moves on by itself — the Plex player stepping to the next episode — is still the same +// entry: no push, nothing for Back to step through. But the entry must say where it moved, or a reload +// reopens the item the viewer started with (the owner, 2026-10-25: F5 during a binge restarted the +// first episode). +describe("useHistorySubview follow", () => { + async function overlayOver() { + const closed = vi.fn(); + const layer = renderHook(() => useBackToClose(closed)); + await act(async () => { + await Promise.resolve(); + }); + return { closed, layer }; + } + + it("rewrites the entry it stands on, so a reload reopens the view it moved on to", () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const before = window.history.length; + act(() => result.current.follow("b")); + expect(result.current.view).toBe("b"); + expect(window.history.state?._sub).toBe("b"); + expect(window.location.search).toBe("?v=b"); + expect(window.history.length).toBe(before); + const { result: reloaded } = renderHook(() => useHistorySubview("root", url)); + expect(reloaded.current.view).toBe("b"); + }); + + it("is not a navigation: an overlay over the view stays open", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const { closed, layer } = await overlayOver(); + act(() => result.current.follow("b")); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(closed).not.toHaveBeenCalled(); + expect(window.history.state?._ov).toBeDefined(); + // A reload with the overlay up reopens the view it moved on to as well. + expect(window.history.state?._sub).toBe("b"); + layer.unmount(); // the history goes back to the view's own entry before the next test + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + }); + + it("the entry under an overlay follows too: closing the overlay lands on the moved-on view", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + const { layer } = await overlayOver(); + act(() => result.current.follow("b")); + layer.unmount(); // the overlay closes: its entry goes by a back() onto the view's own entry + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + expect(result.current.view).toBe("b"); + expect(window.history.state?._sub).toBe("b"); + expect(window.location.search).toBe("?v=b"); + }); + + it("a navigation on the same entry ends it: an overlay closing over it lands on the new view", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + act(() => result.current.follow("b")); + act(() => result.current.open("c", { replace: true })); + const { layer } = await overlayOver(); + layer.unmount(); + await waitFor(() => expect(window.history.state?._ov).toBeUndefined()); + expect(result.current.view).toBe("c"); + expect(window.history.state?._sub).toBe("c"); + }); + + it("does not follow onto another entry: Back to the view below restores that one", async () => { + const { result } = renderHook(() => useHistorySubview("root", url)); + act(() => result.current.open("a")); + act(() => result.current.open("p")); + act(() => result.current.follow("q")); + act(() => window.history.back()); + await waitFor(() => expect(result.current.view).toBe("a")); + expect(window.history.state?._sub).toBe("a"); + }); +}); + // Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page // left by a pushState with no user gesture — and that can be this page's own entry under the // sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7ece3984..cda8ea83 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -9,7 +9,7 @@ // its own button pops exactly its entry without tripping the modals underneath. // // setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. -import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { navPosition, restoreNavPosition, type NavPosition } from "./listNav"; import { urlWithParams } from "./urlState"; import { useLatest } from "./useLatest"; @@ -143,6 +143,9 @@ export function useHistorySubview( * continuation of the navigation that just arrived here, not a step the user took inside the * module. See the `jumpTo` hand-off in PlexBrowse. */ open: (next: T, opts?: { replace?: boolean }) => void; + /** The view moved on by itself (the Plex player stepped to the next episode): the entry it stands + * on says so from now on, without a navigation — the overlays over it stay open. */ + follow: (next: T) => void; back: () => void; } { // Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the @@ -161,7 +164,16 @@ export function useHistorySubview( return root; }; const [view, setView] = useState(readView); - const readLandedView = useEffectEvent(readView); + // Where `follow` moved the view, and on which entry. An overlay over the view holds an entry of its + // own (a copy of the view's state, same sfScroll id), and only the current entry can be rewritten: + // the view's own entry under the overlay still says where it was until a step lands back on it. + const followedRef = useRef<{ entry: number; view: T } | null>(null); + const readLandedView = useEffectEvent((): T => { + const followed = followedRef.current; + if (followed == null || peekNavId() !== followed.entry) return readView(); + writeSubEntry(followed.view, url); + return followed.view; + }); useEffect(() => { // Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL). const onPop = () => setView(readLandedView()); @@ -182,16 +194,15 @@ export function useHistorySubview( const go = useCallback( (next: T, opts?: { replace?: boolean }) => { setView(next); - const kept = url?.entry ? url.entry(next) : next; - const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; + // A navigation says where the view is now, on the entry it lands on or rewrites. + followedRef.current = null; if (opts?.replace) { - // Same ENTRY, so it keeps its sfScroll id: PageScroller keys offsets per (entry, view), and - // the view key has changed, so the sub-view still opens at the top without minting an id - // that would orphan the one this entry already carries. - window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); + writeSubEntry(next, url); notifyNav(); return; } + const kept = url?.entry ? url.entry(next) : next; + const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. window.history.pushState( @@ -218,9 +229,26 @@ export function useHistorySubview( }), [go, rootRef], ); + const follow = useCallback( + (next: T) => { + setView(next); + followedRef.current = { entry: ensureNavId(), view: next }; + writeSubEntry(next, url); + }, + [url], + ); // Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves" // can now write `[sub]` and mean it, instead of re-running on every parent render. - return useMemo(() => ({ view, open, back }), [view, open, back]); + return useMemo(() => ({ view, open, follow, back }), [view, open, follow, back]); +} + +// Rewrite the CURRENT entry to hold `next`. Same entry, so it keeps its sfScroll id: PageScroller keys +// offsets per (entry, view), and the view key has changed, so the sub-view still opens at the top +// without minting an id that would orphan the one this entry already carries. +function writeSubEntry(next: T, url: SubviewUrl | undefined): void { + const kept = url?.entry ? url.entry(next) : next; + const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; + window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- diff --git a/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx new file mode 100644 index 00000000..6dc141e8 --- /dev/null +++ b/frontend/src/modules/plex/PlexBrowse.playerReload.dom.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +// +// A reload reopens the item playing NOW (the owner, 2026-10-25): binge a series, press F5 mid-episode, +// and the player restarted the episode the session started with — the player moved on in its own +// state while the history entry still held the first item. The player says what it moved on to +// (PlexPlayer's own tests); this is the page writing it into the entry and the address bar. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./PlexPlayer", () => ({ + default: ({ + itemId, + queue, + onItemChange, + }: { + itemId: string; + queue?: string[]; + onItemChange: (id: string) => void; + }) => ( +
+ + player {itemId} queue {queue?.join(",") ?? "none"} + + +
+ ), +})); +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), +})); + +import PlexBrowse from "./PlexBrowse"; +import { api } from "../../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +beforeEach(() => { + vi.spyOn(api, "plexLibrary").mockResolvedValue({ + items: [], + total: 0, + offset: 0, + episodes: [], + } as never); + window.history.replaceState( + { _sub: { kind: "player", id: "e1", queue: ["e1", "e2", "e3"] }, sfScroll: 4 }, + "", + "/?page=plex&v=player:e1", + ); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +describe("the Plex player on a reload", () => { + it("reopens the item it moved on to, with the list it plays from", async () => { + mount(); + expect(await screen.findByText("player e1 queue e1,e2,e3")).toBeTruthy(); + const before = window.history.length; + fireEvent.click(screen.getByText("next")); + expect(screen.getByText("player e2 queue e1,e2,e3")).toBeTruthy(); + expect(window.history.length).toBe(before); // moving on is no step for Back + cleanup(); // F5: the page goes, the history entry stays + mount(); + expect(await screen.findByText("player e2 queue e1,e2,e3")).toBeTruthy(); + }); + + it("says the item playing now in the address bar, so a bookmark opens it", async () => { + mount(); + fireEvent.click(await screen.findByText("next")); + expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e2"); + cleanup(); + window.history.replaceState(null, "", window.location.href); // a fresh tab: the URL alone + mount(); + expect(await screen.findByText("player e2 queue none")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 28dabe9e..2764e007 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -429,6 +429,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType } if (sub.view.kind === "player") { + const { queue } = sub.view; // The fallback is portaled for the same reason the player itself is: it is a fixed // full-viewport overlay, and this tree renders inside the page scroller. return ( @@ -437,9 +438,11 @@ function PlexBrowseView({ sub }: { sub: ReturnType > sub.follow({ kind: "player", id, queue })} /> ); diff --git a/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx index daf714ee..d5cde7c7 100644 --- a/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.backspace.dom.test.tsx @@ -75,7 +75,7 @@ async function mount() { - + {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx index 8e39e5ef..3d8a436d 100644 --- a/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx @@ -135,6 +135,7 @@ function mount(opts: { first?: string; fromStart?: boolean; onClose?: () => void; + onItemChange?: (id: string) => void; }) { vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!)); const session = vi @@ -149,6 +150,7 @@ function mount(opts: { {})} + onItemChange={opts.onItemChange ?? (() => {})} queue={opts.queue} fromStart={opts.fromStart} /> @@ -795,6 +797,40 @@ describe("the C5 row", () => { }); }); +// The page's history entry keeps the item playing now, so a reload reopens THAT one (the owner, +// 2026-10-25: F5 during a binge restarted the episode the session started with). The player says +// every item it moves on to; the page writes it into the entry (PlexBrowse, useHistorySubview). +describe("the item it moves on to", () => { + it("is said on Next", async () => { + const onItemChange = vi.fn(); + mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange }); + await playingAt("/media/A", 100); + fireEvent.click(screen.getByLabelText("plex.player.next")); + expect(onItemChange).toHaveBeenCalledWith("B"); + }); + + it("is said when an item played to its end advances by itself", async () => { + localStorage.setItem( + "siftlode.plexPlayerPrefs", + JSON.stringify({ subLang: "en", upNext: false }), + ); + vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never); + const onItemChange = vi.fn(); + mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"], onItemChange }); + await playingAt("/media/A", 2990); + act(() => void video().dispatchEvent(new Event("ended"))); + expect(onItemChange).toHaveBeenCalledWith("B"); + await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B")); + }); + + it("is not said for the item already playing", async () => { + const onItemChange = vi.fn(); + mount({ details: { A: item("A", 0, "B"), B: item("B") }, onItemChange }); + await playingAt("/media/A", 100); + expect(onItemChange).not.toHaveBeenCalled(); + }); +}); + describe("Back goes by position (C5)", () => { it("more than 3 s in: back to the start of this item", async () => { // B opened at its resume point, so a session asked at 0 can only be the restart. diff --git a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx index 117ad31d..40ac9c05 100644 --- a/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.hls.dom.test.tsx @@ -124,7 +124,7 @@ function mount() { - {}} /> + {}} onItemChange={() => {}} /> , ); return session; diff --git a/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx index d797bc3d..22d521f0 100644 --- a/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.panels.dom.test.tsx @@ -75,7 +75,7 @@ async function mount() { - + {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx index 40fa3418..e8680d20 100644 --- a/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.session.dom.test.tsx @@ -127,7 +127,7 @@ function mount(opts: { const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!; const view = render( - {}} queue={opts.queue} /> + {}} onItemChange={() => {}} queue={opts.queue} /> , ); return { ...view, progress, status, qc }; diff --git a/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx index 2d8e164a..7f070626 100644 --- a/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.strictmode.dom.test.tsx @@ -89,7 +89,7 @@ describe("PlexPlayer under StrictMode", () => { render( - {}} /> + {}} onItemChange={() => {}} /> , ); @@ -112,7 +112,7 @@ describe("PlexPlayer under StrictMode", () => { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( - {}} /> + {}} onItemChange={() => {}} /> , ); const video = await waitFor(() => document.querySelector("video")!); diff --git a/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx b/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx index a2460e03..5b90302a 100644 --- a/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx +++ b/frontend/src/modules/plex/PlexPlayer.tabtrap.dom.test.tsx @@ -84,7 +84,7 @@ async function mount() { - {}} /> + {}} onItemChange={() => {}} /> , ); await waitFor(() => diff --git a/frontend/src/modules/plex/PlexPlayer.tsx b/frontend/src/modules/plex/PlexPlayer.tsx index cc805349..3e4619d7 100644 --- a/frontend/src/modules/plex/PlexPlayer.tsx +++ b/frontend/src/modules/plex/PlexPlayer.tsx @@ -133,10 +133,24 @@ const DOUBLE_TAP_MS = 300; // the queue instead of the item's own episode neighbours. // `fromStart` (the card's Restart, C5) applies to the item the player opened with; every item it // moves on to starts at its own resume point. -type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean }; +// `onItemChange` hears every item it moves on to (Next, Back, an auto-advance, a collection sibling): +// the page's history entry keeps the item playing NOW, so a reload reopens that one, not the first. +type Props = { + itemId: string; + onClose: () => void; + onItemChange: (id: string) => void; + queue?: string[]; + fromStart?: boolean; +}; type Panel = "none" | "more" | "tracks" | "settings" | "info" | "upNext"; -export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { +export default function PlexPlayer({ + itemId, + onClose, + onItemChange, + queue, + fromStart = false, +}: Props) { const { t, i18n } = useTranslation(); // A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)"). const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language); @@ -745,9 +759,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } movedOnRef.current = true; setAbs(0); setId(otherId); + onItemChange(otherId); } }, - [id, engine, seekTo, requestPlay, settleProgress, finishProgress], + [id, engine, seekTo, requestPlay, settleProgress, finishProgress, onItemChange], ); // Prev/next: from the playlist queue when playing one, else the item's own episode neighbours. diff --git a/frontend/src/modules/plex/plexSubUrl.test.ts b/frontend/src/modules/plex/plexSubUrl.test.ts index 76821e21..f90fc361 100644 --- a/frontend/src/modules/plex/plexSubUrl.test.ts +++ b/frontend/src/modules/plex/plexSubUrl.test.ts @@ -16,9 +16,15 @@ describe("PLEX_SUB_URL encode", () => { expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5"); }); - it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => { + // The item playing now — the list it plays from rides in the history entry, not the URL. + it("mirrors the player as the item it plays", () => { + expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe( + "player:m9", + ); + }); + + it("does NOT mirror the root Discover landing (encode → null)", () => { expect(U.encode({ kind: "discover" })).toBeNull(); - expect(U.encode({ kind: "player", id: "m9" })).toBeNull(); }); }); @@ -33,6 +39,7 @@ describe("PLEX_SUB_URL round-trip", () => { { kind: "person", name: "Jane Doe" }, { kind: "playlist", id: 12 }, { kind: "collection", id: "c5" }, + { kind: "player", id: "m9" }, ]; it.each(cases)("encode→decode is identity for %o", (view) => { const raw = U.encode(view); @@ -67,11 +74,11 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("info:")).toBeNull(); expect(U.decode("person:")).toBeNull(); expect(U.decode("collection:")).toBeNull(); + expect(U.decode("player:")).toBeNull(); }); it("rejects an unknown kind or garbage", () => { expect(U.decode("grid")).toBeNull(); - expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable expect(U.decode("bogus")).toBeNull(); expect(U.decode("")).toBeNull(); }); diff --git a/frontend/src/modules/plex/plexSubUrl.ts b/frontend/src/modules/plex/plexSubUrl.ts index b05bddfc..aaae63af 100644 --- a/frontend/src/modules/plex/plexSubUrl.ts +++ b/frontend/src/modules/plex/plexSubUrl.ts @@ -26,10 +26,11 @@ export type Sub = | { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key) // Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page -// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing, so it (and the -// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a -// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing -// ":" round-trips. +// — the flat browse grid, and the item playing — are bookmarkable. Discover is the ROOT landing, so it +// is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a distinct, shareable +// URL. The player's URL is the item playing NOW (the player rewrites it as it moves on); the list it +// plays from rides in the history entry. The person name is the whole tail after the first ":", so a +// name containing ":" round-trips. export const PLEX_SUB_URL: SubviewUrl = { key: "v", encode: (v) => { @@ -52,8 +53,10 @@ export const PLEX_SUB_URL: SubviewUrl = { return `playlist:${v.id}`; case "collection": return `collection:${v.id}`; + case "player": + return `player:${v.id}`; default: - return null; // discover (root) + the ephemeral player are not URL-mirrored + return null; // discover, the root, is not URL-mirrored } }, // `fromStart` is the Restart CLICK, not a property of the entry: kept in history.state it restarted @@ -75,6 +78,8 @@ export const PLEX_SUB_URL: SubviewUrl = { return arg ? { kind: "show", id: arg } : null; case "info": return arg ? { kind: "info", id: arg } : null; + case "player": + return arg ? { kind: "player", id: arg } : null; case "person": return arg ? { kind: "person", name: arg } : null; case "playlist": { From 91d36b3357af566aab595e50b3d26c89306753eb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 26 Oct 2026 00:19:02 +0100 Subject: [PATCH 03/15] fix(channel): the channel page's address names the channel and its own view, not the page under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../NavigationProvider.dom.test.tsx | 53 +++++++ .../navigation/NavigationProvider.tsx | 34 ++++- .../navigation/channelOverPage.dom.test.tsx | 100 ++++++++++++++ frontend/src/features/navigation/urlPage.ts | 9 ++ .../video/ChannelNameLink.dom.test.tsx | 18 ++- .../src/features/video/ChannelNameLink.tsx | 8 +- frontend/src/lib/channelPageUrlState.test.ts | 69 ++++++++++ frontend/src/lib/channelPageUrlState.ts | 57 ++++++++ frontend/src/lib/urlState.ts | 21 ++- frontend/src/moduleUrl.contract.test.ts | 19 ++- .../src/modules/channels/ChannelsProvider.tsx | 9 +- frontend/src/modules/feed/ChannelPage.tsx | 42 +++++- .../modules/feed/ChannelPage.url.dom.test.tsx | 130 ++++++++++++++++++ .../src/modules/feed/FeedFiltersProvider.tsx | 13 +- frontend/src/modules/files/FilesProvider.tsx | 9 +- .../modules/playlists/PlaylistsProvider.tsx | 11 +- frontend/src/modules/plex/PlexProvider.tsx | 9 +- 17 files changed, 565 insertions(+), 46 deletions(-) create mode 100644 frontend/src/features/navigation/channelOverPage.dom.test.tsx create mode 100644 frontend/src/features/navigation/urlPage.ts create mode 100644 frontend/src/lib/channelPageUrlState.test.ts create mode 100644 frontend/src/lib/channelPageUrlState.ts create mode 100644 frontend/src/modules/feed/ChannelPage.url.dom.test.tsx diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index d21893eb..aa74bceb 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -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", () => { diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index a09de092..e9200276 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -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 { 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 => ({ + 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, }); diff --git a/frontend/src/features/navigation/channelOverPage.dom.test.tsx b/frontend/src/features/navigation/channelOverPage.dom.test.tsx new file mode 100644 index 00000000..7b76ea1a --- /dev/null +++ b/frontend/src/features/navigation/channelOverPage.dom.test.tsx @@ -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()), + 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 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 = { + 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( + + + + + + + , + ); + 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)); + }); +}); diff --git a/frontend/src/features/navigation/urlPage.ts b/frontend/src/features/navigation/urlPage.ts new file mode 100644 index 00000000..b63ffbb8 --- /dev/null +++ b/frontend/src/features/navigation/urlPage.ts @@ -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): Page | null { + return nav.channelView ? null : nav.page; +} diff --git a/frontend/src/features/video/ChannelNameLink.dom.test.tsx b/frontend/src/features/video/ChannelNameLink.dom.test.tsx index 481d7a48..d4e43911 100644 --- a/frontend/src/features/video/ChannelNameLink.dom.test.tsx +++ b/frontend/src/features/video/ChannelNameLink.dom.test.tsx @@ -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=. - expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`); + // The same URL openChannel pushes: the current page plus ?chan= — 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", () => { diff --git a/frontend/src/features/video/ChannelNameLink.tsx b/frontend/src/features/video/ChannelNameLink.tsx index 1d129e5e..7270b8b1 100644 --- a/frontend/src/features/video/ChannelNameLink.tsx +++ b/frontend/src/features/video/ChannelNameLink.tsx @@ -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 ` + ), +})); vi.mock("./plexSavedViews", async (importOriginal) => ({ ...(await importOriginal()), usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), @@ -100,3 +105,39 @@ describe("the Plex player on a reload", () => { expect(await screen.findByText("player e2 queue none")).toBeTruthy(); }); }); + +describe("the Plex player opened from a playlist", () => { + const playlist = (ids: string[]) => + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ + id: 7, + title: "Road trip", + items: ids.map((id) => ({ id, title: id })), + } as never); + + it("names the playlist after the item, as it moves on too", async () => { + window.history.replaceState(null, "", "/?page=plex&v=playlist:7"); + mount(); + fireEvent.click(await screen.findByText("play from the playlist")); + expect(await screen.findByText("player e1 queue e1,e2")).toBeTruthy(); + expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e1:playlist:7"); + fireEvent.click(screen.getByText("next")); + expect(new URLSearchParams(window.location.search).get("v")).toBe("player:e2:playlist:7"); + }); + + it("a bookmark plays on through the playlist, its order rebuilt from it", async () => { + window.history.replaceState(null, "", "/?page=plex&v=player:e2:playlist:7"); + const read = playlist(["e1", "e2", "e3"]); + mount(); + expect(await screen.findByText("player e2 queue e1,e2,e3")).toBeTruthy(); + expect(read).toHaveBeenCalledWith(7, "en"); + }); + + it("a bookmark whose item has left the playlist plays it on its own neighbours", async () => { + window.history.replaceState(null, "", "/?page=plex&v=player:e9:playlist:7"); + const read = playlist(["e1", "e2"]); + mount(); + await vi.waitFor(() => expect(read).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 20)); + expect(screen.getByText("player e9 queue none")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index 2764e007..d08dfbe5 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -27,6 +27,7 @@ import { useHistorySubview } from "../../lib/history"; import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { toggleWatchState } from "../../lib/plexWatch"; import { usePlayShow } from "./usePlayShow"; +import { usePlexPlayerQueue } from "./usePlexPlayerQueue"; import SortControl from "../../components/ui/SortControl"; import FilterSelect from "../../components/ui/FilterSelect"; import PlexDiscover from "./PlexDiscover"; @@ -122,6 +123,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex(); const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); const dq = useDebounced(q.trim(), 350); + const playerQueue = usePlexPlayerQueue(sub.view); // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch. const lang = plexLang(i18n.language); @@ -429,7 +431,8 @@ function PlexBrowseView({ sub }: { sub: ReturnType } if (sub.view.kind === "player") { - const { queue } = sub.view; + const { playlist } = sub.view; + const queue = playerQueue; // The fallback is portaled for the same reason the player itself is: it is a fixed // full-viewport overlay, and this tree renders inside the page scroller. return ( @@ -442,7 +445,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType fromStart={sub.view.fromStart} onClose={sub.back} // The item playing now, with the list it plays from, is what this entry reopens. - onItemChange={(id) => sub.follow({ kind: "player", id, queue })} + onItemChange={(id) => sub.follow({ kind: "player", id, queue, playlist })} /> ); @@ -455,7 +458,7 @@ function PlexBrowseView({ sub }: { sub: ReturnType playlistId={pid} onBack={sub.back} onPlay={(itemRk, queue, fromStart) => - sub.open({ kind: "player", id: itemRk, queue, fromStart }) + sub.open({ kind: "player", id: itemRk, queue, playlist: pid, fromStart }) } onInfoCard={onInfo} /> diff --git a/frontend/src/modules/plex/plexSubUrl.test.ts b/frontend/src/modules/plex/plexSubUrl.test.ts index f90fc361..f46b49f7 100644 --- a/frontend/src/modules/plex/plexSubUrl.test.ts +++ b/frontend/src/modules/plex/plexSubUrl.test.ts @@ -16,13 +16,19 @@ describe("PLEX_SUB_URL encode", () => { expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5"); }); - // The item playing now — the list it plays from rides in the history entry, not the URL. + // The item playing now — the list it plays from rides in the history entry, a playlist's named. it("mirrors the player as the item it plays", () => { expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], fromStart: true })).toBe( "player:m9", ); }); + it("names the playlist the player plays through after the item", () => { + expect(U.encode({ kind: "player", id: "m9", queue: ["m8", "m9"], playlist: 7 })).toBe( + "player:m9:playlist:7", + ); + }); + it("does NOT mirror the root Discover landing (encode → null)", () => { expect(U.encode({ kind: "discover" })).toBeNull(); }); @@ -40,6 +46,7 @@ describe("PLEX_SUB_URL round-trip", () => { { kind: "playlist", id: 12 }, { kind: "collection", id: "c5" }, { kind: "player", id: "m9" }, + { kind: "player", id: "m9", playlist: 7 }, ]; it.each(cases)("encode→decode is identity for %o", (view) => { const raw = U.encode(view); @@ -77,6 +84,14 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("player:")).toBeNull(); }); + it("plays the item alone when the list it names is not a playlist with a valid id", () => { + expect(U.decode("player:m9:playlist:0")).toEqual({ kind: "player", id: "m9" }); + expect(U.decode("player:m9:playlist:abc")).toEqual({ kind: "player", id: "m9" }); + expect(U.decode("player:m9:season:3")).toEqual({ kind: "player", id: "m9" }); + expect(U.decode("player:m9:playlist")).toEqual({ kind: "player", id: "m9" }); + expect(U.decode("player:m9:playlist:7:x")).toBeNull(); + }); + it("rejects an unknown kind or garbage", () => { expect(U.decode("grid")).toBeNull(); expect(U.decode("bogus")).toBeNull(); diff --git a/frontend/src/modules/plex/plexSubUrl.ts b/frontend/src/modules/plex/plexSubUrl.ts index aaae63af..8c2f2e69 100644 --- a/frontend/src/modules/plex/plexSubUrl.ts +++ b/frontend/src/modules/plex/plexSubUrl.ts @@ -19,7 +19,8 @@ export type Sub = | { kind: "show"; id: string } | { kind: "season"; showId: string; seasonId: string } // `fromStart`: the card's Restart (C5) — the first item plays from 0, not its resume point. - | { kind: "player"; id: string; queue?: string[]; fromStart?: boolean } + // `playlist`: the playlist the queue is, so an address can name the list as well as the item. + | { kind: "player"; id: string; queue?: string[]; playlist?: number; fromStart?: boolean } | { kind: "info"; id: string } | { kind: "person"; name: string } | { kind: "playlist"; id: number } @@ -29,7 +30,10 @@ export type Sub = // — the flat browse grid, and the item playing — are bookmarkable. Discover is the ROOT landing, so it // is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a distinct, shareable // URL. The player's URL is the item playing NOW (the player rewrites it as it moves on); the list it -// plays from rides in the history entry. The person name is the whole tail after the first ":", so a +// plays from rides in the history entry, and a playlist's is named after it (`player::playlist:`) +// so a bookmark rebuilds the queue from the playlist's own order. A season or a show plays through the +// episode's own neighbours, the same order, so neither needs naming; a collection's queue is its sort +// or a shuffle, which no address could give back as it was. The person name is the whole tail after the first ":", so a // name containing ":" round-trips. export const PLEX_SUB_URL: SubviewUrl = { key: "v", @@ -54,7 +58,7 @@ export const PLEX_SUB_URL: SubviewUrl = { case "collection": return `collection:${v.id}`; case "player": - return `player:${v.id}`; + return ["player", v.id, ...(v.playlist != null ? ["playlist", v.playlist] : [])].join(":"); default: return null; // discover, the root, is not URL-mirrored } @@ -78,8 +82,14 @@ export const PLEX_SUB_URL: SubviewUrl = { return arg ? { kind: "show", id: arg } : null; case "info": return arg ? { kind: "info", id: arg } : null; - case "player": - return arg ? { kind: "player", id: arg } : null; + case "player": { + const [id, list, pid, ...rest] = arg.split(":"); + if (!id || rest.length > 0) return null; + if (list == null) return { kind: "player", id }; + // A list that is not a playlist with a valid id is not this address: the item alone plays. + const playlist = list === "playlist" ? parsePositiveIntId(pid) : null; + return playlist != null ? { kind: "player", id, playlist } : { kind: "player", id }; + } case "person": return arg ? { kind: "person", name: arg } : null; case "playlist": { diff --git a/frontend/src/modules/plex/usePlexPlayerQueue.ts b/frontend/src/modules/plex/usePlexPlayerQueue.ts new file mode 100644 index 00000000..5dde6903 --- /dev/null +++ b/frontend/src/modules/plex/usePlexPlayerQueue.ts @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; + +import { plexLang } from "../../i18n"; +import { api } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import type { Sub } from "./plexSubUrl"; + +/** The list a Plex player plays through. An in-app entry carries it; a bookmark names only the + * playlist it came from, so the queue is rebuilt from that playlist's own order once it is in — and + * only while the item is still in it (a list without it would leave the item no neighbours at all). + * Until then, or when it cannot be had, the item plays on its own neighbours. */ +export function usePlexPlayerQueue(view: Sub): string[] | undefined { + const { i18n } = useTranslation(); + const lang = plexLang(i18n.language); + const player = view.kind === "player" ? view : null; + const pid = player && !player.queue ? player.playlist : undefined; + const list = useQuery({ + queryKey: qk.plexPlaylist(pid ?? 0, lang), + queryFn: () => api.plexPlaylist(pid!, lang), + enabled: pid != null, + }); + if (!player) return undefined; + if (player.queue) return player.queue; + const ids = pid != null ? list.data?.items.map((i) => i.id) : undefined; + return ids?.includes(player.id) ? ids : undefined; +} From 7b4e007680089dbd2d181131e7ef289d8a0469ff Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 11:51:29 +0100 Subject: [PATCH 07/15] feat(nav): the live YouTube search and Home's budget are named in the address A YouTube search is `?yt=` (+ `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. --- CLAUDE.md | 1 + docs/development.md | 6 + frontend/e2e/bookmark.spec.ts | 146 ++++++++++++ .../navigation/NavigationProvider.tsx | 113 ++++++--- .../NavigationProvider.ytSearch.dom.test.tsx | 198 ++++++++++++++++ .../features/navigation/navigationContext.ts | 15 +- frontend/src/i18n/locales/en/feed.json | 5 +- frontend/src/i18n/locales/hu/feed.json | 5 +- frontend/src/lib/homeUrlState.ts | 20 ++ frontend/src/lib/ytSearchUrlState.test.ts | 31 +++ frontend/src/lib/ytSearchUrlState.ts | 32 +++ frontend/src/moduleUrl.contract.test.ts | 5 +- .../src/modules/channels/ChannelsProvider.tsx | 2 +- frontend/src/modules/feed/Feed.tsx | 48 ++-- .../modules/feed/Feed.ytReopen.dom.test.tsx | 218 ++++++++++++++++++ frontend/src/modules/feed/module.ts | 4 +- frontend/src/modules/home/Home.dom.test.tsx | 36 ++- frontend/src/modules/home/Home.tsx | 17 +- frontend/src/modules/home/module.ts | 7 +- .../modules/scheduler/Scheduler.dom.test.tsx | 2 + 20 files changed, 847 insertions(+), 64 deletions(-) create mode 100644 frontend/e2e/bookmark.spec.ts create mode 100644 frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx create mode 100644 frontend/src/lib/homeUrlState.ts create mode 100644 frontend/src/lib/ytSearchUrlState.test.ts create mode 100644 frontend/src/lib/ytSearchUrlState.ts create mode 100644 frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 223ce247..0534ad64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `` | +| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view | | FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it | | FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` | | FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it | diff --git a/docs/development.md b/docs/development.md index a81a6349..994a3d87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -48,6 +48,12 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside its codec; the shell clears them on a page switch and opens a page without another page's keys. + Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec + in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out), + written with `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through + `useBackToClose`'s), read back first on mount (a link wins over anything remembered). Add its round trip to + `e2e/bookmark.spec.ts`. Only what an address cannot promise to reopen stays out, said in its codec (Home's + draw, `lib/homeUrlState`). 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's hooks). Another folder reaches the module through this file or not at all. 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts new file mode 100644 index 00000000..85815399 --- /dev/null +++ b/frontend/e2e/bookmark.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { + clickVisibleChannelLink, + expectContentType, + gotoPage, + openApp, + openFirstVideo, + toggleContentType, +} from "./helpers"; + +// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW +// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view +// storage it wrote) can stand in for what the address says. One round trip per page, plus the +// overlays the shell opens over any page (the channel page, the player) and the live search. + +const STORAGE_STATE = "e2e/.auth/state.json"; + +/** The page's address, opened as a bookmark in a fresh context of the same account. */ +async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise) { + const url = page.url(); + const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" }); + const fresh = await ctx.newPage(); + await prepare?.(fresh); + await openApp(fresh, url); + return fresh; +} + +const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true }); + +test.describe("a bookmark reopens the view it was taken of", () => { + test("the feed's filters", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await toggleContentType(page, "includeLive"); + await expect(page).toHaveURL(/[?&]live=/); + const fresh = await bookmarked(browser, page); + await expectContentType(fresh, "includeLive", true); + await fresh.context().close(); + }); + + test("Home's time budget", async ({ page, browser }) => { + await openApp(page, "/?page=home"); + await page.getByRole("button", { name: "An evening", exact: true }).click(); + await expect(page).toHaveURL(/[?&]hb=long/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await fresh.context().close(); + }); + + test("a Settings tab", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "settings"); + await tab(page, "Notifications").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("an admin page's tab", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await page.getByTestId("nav-admin").click(); + await page.getByTestId("nav-admin-users").click(); + await tab(page, "Access requests").click(); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("the Channels manager's tab and name search", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "channels"); + await tab(page, "Hidden").click(); + await page.getByPlaceholder("Search channels…").fill("zz"); + await expect(page).toHaveURL(/zz/); // written once the typing settles + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true"); + await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz"); + await fresh.context().close(); + }); + + test("the Files search", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "files"); + await page.getByPlaceholder("Search files…").fill("zz"); + await expect(page).toHaveURL(/zz/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz"); + await fresh.context().close(); + }); + + test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await expect(page.getByTestId("video-card").first()).toBeVisible(); + expect(await clickVisibleChannelLink(page)).toBe(true); + const title = await page.getByTestId("channel-title").textContent(); + await tab(page, "About").click(); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? ""); + await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true"); + await fresh.context().close(); + }); + + test("the player and the video it plays", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await openFirstVideo(page); + await expect(page).toHaveURL(/[?&]watch=/); + const watch = new URL(page.url()).searchParams.get("watch"); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByTestId("player-modal")).toBeVisible(); + expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch); + await fresh.context().close(); + }); + + // The search itself never reaches YouTube here: the answer is the server's shape, served by the + // test, and the request it answers is what the address named. + test("a live YouTube search, its term and its count", async ({ page, browser }) => { + const answer = async (p: Page, seen: string[]) => { + await p.route("**/api/search/youtube?**", (route) => { + seen.push(route.request().url()); + return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); + }); + }; + await answer(page, []); + await openApp(page, "/?page=feed"); + await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); + await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); + await expect(page.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set + await page.getByRole("button", { name: "40", exact: true }).click(); + await expect(page).toHaveURL(/[?&]ytn=40/); + + const seen: string[] = []; + const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); + await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect.poll(() => seen.length).toBeGreaterThan(0); + const asked = new URL(seen[0]!).searchParams; + expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); + await fresh.context().close(); + }); +}); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 78ff10fa..a8eded41 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -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(() => { - 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(() => + page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, + ); + const [ytCount, setYtCountState] = useState(() => + ytCountFrom(new URLSearchParams(window.location.search)), + ); + const [ytAsked, setYtAsked] = useState(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=, 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( - () => ({ page, channelView, ytSearch }), - [page, channelView, ytSearch], + () => ({ + page, + channelView, + ytSearch, + ytCount, + ytSearchAsked: ytSearch != null && ytAsked === ytSearch, + }), + [page, channelView, ytSearch, ytCount, ytAsked], ); const actions = useMemo( - () => ({ 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 ( diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx new file mode 100644 index 00000000..a610ed4a --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -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(); + 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 }) => ( + + {children} + +); +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(); + }); +}); diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts index b033ae96..ab173013 100644 --- a/frontend/src/features/navigation/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -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({ page: "feed", channelView: null, ytSearch: null, + ytCount: DEFAULT_YT_COUNT, + ytSearchAsked: false, }); export const NavigationActionsContext = createContext({ setPage: () => {}, @@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext({ closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, }); export function useNavigation(): NavigationState { diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 64b53f9f..ce0941ad 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -75,6 +75,9 @@ "clearNow": "Clear now", "cleared_one": "Cleared {{count}} result", "cleared_other": "Cleared {{count}} results", - "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size." + "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", + "notRunTitle": "This search hasn't run again", + "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload or a link doesn't start one by itself.", + "runAgain": "Search YouTube again" } } diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index e2fea925..111fe5b2 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -75,6 +75,9 @@ "clearNow": "Törlés most", "cleared_one": "{{count}} találat törölve", "cleared_other": "{{count}} találat törölve", - "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább." + "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.", + "notRunTitle": "Ez a keresés nem futott újra", + "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltés vagy link nem indít el egyet magától.", + "runAgain": "Keresés újra a YouTube-on" } } diff --git a/frontend/src/lib/homeUrlState.ts b/frontend/src/lib/homeUrlState.ts new file mode 100644 index 00000000..bf3efdbf --- /dev/null +++ b/frontend/src/lib/homeUrlState.ts @@ -0,0 +1,20 @@ +// Home's time budget in the address bar (`?page=home&hb=short`): a page SETTING, like a feed filter, +// so it is a link and survives a reload. What Home draws under it is not: the question's seed, the +// press and the cards already offered stay on the history entry (Home.tsx), because the same question +// draws other cards once what you have watched moves on — an address cannot promise "that view". +import { DISCOVER_BUDGETS, type DiscoverBudget } from "./api"; + +const HOME_BUDGET_KEY = "hb"; +export const HOME_KEYS = [HOME_BUDGET_KEY] as const; +export const DEFAULT_BUDGET: DiscoverBudget = "any"; + +/** The budget a URL names, or null when it names none (or none Home offers). */ +export function budgetFrom(params: URLSearchParams): DiscoverBudget | null { + const raw = params.get(HOME_BUDGET_KEY); + return DISCOVER_BUDGETS.includes(raw as DiscoverBudget) ? (raw as DiscoverBudget) : null; +} + +/** The URL update for a budget: the default clears its key. */ +export function budgetParams(budget: DiscoverBudget): Record { + return { [HOME_BUDGET_KEY]: budget === DEFAULT_BUDGET ? null : budget }; +} diff --git a/frontend/src/lib/ytSearchUrlState.test.ts b/frontend/src/lib/ytSearchUrlState.test.ts new file mode 100644 index 00000000..e23e31c6 --- /dev/null +++ b/frontend/src/lib/ytSearchUrlState.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { ytCountFrom, ytSearchFrom, ytSearchParams } from "./ytSearchUrlState"; + +const p = (q: string) => new URLSearchParams(q); + +describe("the live YouTube search's address", () => { + it("reads the term, trimmed; a blank one names no search", () => { + expect(ytSearchFrom(p("yt=%20lofi%20"))).toBe("lofi"); + expect(ytSearchFrom(p("yt=%20%20"))).toBeNull(); + expect(ytSearchFrom(p("q=lofi"))).toBeNull(); + }); + + it("reads one of the offered counts, else the default", () => { + expect(ytCountFrom(p("ytn=60"))).toBe(60); + expect(ytCountFrom(p("ytn=61"))).toBe(20); + expect(ytCountFrom(p("ytn=x"))).toBe(20); + expect(ytCountFrom(p(""))).toBe(20); + }); + + it("writes the count only off its default, and nothing with no search", () => { + expect(ytSearchParams("lofi", 40)).toEqual({ yt: "lofi", ytn: "40" }); + expect(ytSearchParams("lofi", 20)).toEqual({ yt: "lofi", ytn: null }); + expect(ytSearchParams(null, 40)).toEqual({ yt: null, ytn: null }); + }); + + it("round-trips", () => { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(ytSearchParams("lofi beats", 100))) if (v) q.set(k, v); + expect([ytSearchFrom(q), ytCountFrom(q)]).toEqual(["lofi beats", 100]); + }); +}); diff --git a/frontend/src/lib/ytSearchUrlState.ts b/frontend/src/lib/ytSearchUrlState.ts new file mode 100644 index 00000000..6c8985ec --- /dev/null +++ b/frontend/src/lib/ytSearchUrlState.ts @@ -0,0 +1,32 @@ +// The feed's live YouTube search in the address bar (`?page=feed&yt=lofi&ytn=40`), so a search is a +// link and survives a reload. Only the term and how many results to gather: which source answers is +// the instance's setting at the moment the link opens (an admin may have switched it since), and that +// decides whether the search runs by itself (the free page scrape) or waits for a press (an API +// search spends 100 quota units, so nobody's reload or bookmark spends them unasked). + +const YT_SEARCH_KEY = "yt"; +const YT_COUNT_KEY = "ytn"; +export const YT_SEARCH_KEYS = [YT_SEARCH_KEY, YT_COUNT_KEY] as const; + +/** How many live-search results one fetch gathers; the first is the default, left out of the URL. */ +export const YT_COUNTS = [20, 40, 60, 100] as const; +export const DEFAULT_YT_COUNT = YT_COUNTS[0]; + +/** The search a URL names, or null: a blank term names none (the search box never sends one). */ +export function ytSearchFrom(params: URLSearchParams): string | null { + return params.get(YT_SEARCH_KEY)?.trim() || null; +} + +/** The result count a URL names; anything but one of the offered counts is the default. */ +export function ytCountFrom(params: URLSearchParams): number { + const n = Number(params.get(YT_COUNT_KEY)); + return (YT_COUNTS as readonly number[]).includes(n) ? n : DEFAULT_YT_COUNT; +} + +/** The URL updates for a search (null = none): a default count clears its key. */ +export function ytSearchParams(term: string | null, count: number): Record { + return { + [YT_SEARCH_KEY]: term, + [YT_COUNT_KEY]: term != null && count !== DEFAULT_YT_COUNT ? String(count) : null, + }; +} diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 54050448..8ee94f78 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -16,6 +16,8 @@ import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; import { FILES_KEYS } from "./lib/filesUrlState"; +import { ytSearchParams } from "./lib/ytSearchUrlState"; +import { budgetParams } from "./lib/homeUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; @@ -43,7 +45,8 @@ const FEED: FeedFilters = { // What each module's own encoders write, from the encoders themselves. Every module that declares a // key has a row, so a new one cannot declare keys its codec does not write, or the other way round. const WRITES: Partial string[]>> = { - feed: () => Object.keys(filterParamUpdates(FEED)), + feed: () => [...Object.keys(filterParamUpdates(FEED)), ...Object.keys(ytSearchParams("x", 40))], + home: () => Object.keys(budgetParams("short")), channels: () => Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {}, search: "" })), plex: () => [ diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 87d9c0cf..771b7134 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -185,7 +185,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see. setFilters({ sync: "needsFull" }); setView("subscribed"); // the sync filter applies to the subscriptions tab - setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips + setFiltersResetToken((n) => n + 1); // also clear the name search + tag chips setPage("channels"); }, [setFilters, setView, setPage]); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index db2ea7a9..baf69e7a 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -40,6 +40,8 @@ import { } from "../../features/video/videoWrites"; import { notify } from "../../lib/notifications"; import { useMe } from "../../lib/useMe"; +import { YT_COUNTS } from "../../lib/ytSearchUrlState"; +import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useDebounced } from "../../lib/useDebounced"; import { markedRows, @@ -148,9 +150,6 @@ const withoutWrite = (o: Overrides, id: string, seq: number): Overrides api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, - enabled: ytActive, + enabled: ytRuns, staleTime: 5 * 60_000, retry: false, }); - // Remember (in history.state) that this live search was served by the zero-quota scrape source, - // so a reload restores the results instead of dropping to the feed — re-fetching scrape pages - // costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units). - useEffect(() => { - if (!ytActive) return; - const st = window.history.state; - if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry - if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) { - window.history.replaceState({ ...st, _ytScrape: true }, ""); - } - }, [ytActive, ytSearch, ytQuery.data]); - // Switching to the relevance sort when a search starts happens atomically in the header's // input onChange (race-free with the query update). Here we only handle the reverse: when // the term is cleared, fall back to the default sort — relevance has no dropdown option and @@ -556,7 +552,8 @@ export default function Feed({ const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? ""); // The backend reports which source served the search; scrape spends no search quota, so we // drop the quota warning + "uses quota" wording in that mode. - const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape"; + // Before an answer (a search waiting for its press), the instance's own source says it. + const zeroQuota = (ytQuery.data?.pages?.[0]?.source ?? me.search_source) === "scrape"; return (
@@ -638,7 +635,16 @@ export default function Feed({ )}
- {ytQuery.isLoading ? ( + {!ytRuns && ytQuery.data === undefined ? ( + // Reopened, not asked for: the term is on screen, the search waits for a press. +
+

{t("feed.yt.notRunTitle")}

+

{t("feed.yt.notRunBody")}

+ +
+ ) : ytQuery.isLoading ? (
{t("feed.yt.searching")}
) : ytError && !ytItems.length ? ( // A first page that failed is said as the error it is, with Retry where trying again can diff --git a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx new file mode 100644 index 00000000..676a13d0 --- /dev/null +++ b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +// +// A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it +// costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search +// YouTube again" — unless its results are still cached, or it was asked for in this document. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("../../features/wizard/wizardContext", () => ({ + useWizard: () => ({ openWizard: vi.fn() }), +})); +vi.mock("./VirtualFeed", () => ({ + default: ({ items }: { items: { id: string; title: string }[] }) => ( +
    + {items.map((v) => ( +
  1. {v.title}
  2. + ))} +
+ ), +})); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { + NavigationActionsContext, + NavigationStateContext, + type NavigationActions, +} from "../../features/navigation/navigationContext"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const hit = (id: string): Video => + ({ + id, + title: `hit ${id}`, + channel_id: "UCx", + channel_title: "Channel", + status: "new", + position_seconds: 0, + published_at: "2026-01-01T00:00:00Z", + watch_url: `https://www.youtube.com/watch?v=${id}`, + }) as unknown as Video; + +const actions = (over: Partial = {}): NavigationActions => ({ + setPage: () => {}, + openChannel: () => {}, + closeChannel: () => {}, + enterYtSearch: () => {}, + exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, + ...over, +}); + +function mount({ + source, + asked, + enabled = true, + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }), + nav = actions(), +}: { + source: "scrape" | "api"; + asked: boolean; + enabled?: boolean; + qc?: QueryClient; + nav?: NavigationActions; +}) { + qc.setQueryData(qk.me(), { + id: 1, + can_write: true, + prefs: {}, + search_source: source, + search_enabled: enabled, + }); + const tree = (count: number) => ( + + + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="lofi" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + + + + ); + const view = render(tree(20)); + return { showing: (count: number) => view.rerender(tree(count)) }; +} + +const settle = () => new Promise((r) => setTimeout(r, 30)); + +describe("Feed — a live search the address reopened", () => { + it("waits for a press under the API source, and spends nothing meanwhile", async () => { + const search = vi.spyOn(api, "searchYoutube"); + const askYtSearch = vi.fn(); + mount({ source: "api", asked: false, nav: actions({ askYtSearch }) }); + expect(screen.getByText("feed.yt.notRunTitle")).toBeTruthy(); + expect(screen.getByText("feed.yt.resultsFor")).toBeTruthy(); // the term stays on screen + expect(screen.getByText("feed.yt.quotaNote")).toBeTruthy(); + await settle(); + expect(search).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "feed.yt.runAgain" })); + expect(askYtSearch).toHaveBeenCalledTimes(1); + }); + + it("runs an API search asked for in this document", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "api" } as never); + mount({ source: "api", asked: true }); + await waitFor(() => + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"), + ); + expect(search).toHaveBeenCalledWith("lofi", null, 20); + expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull(); + }); + + it("runs by itself under the free scrape source", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never); + mount({ source: "scrape", asked: false }); + await waitFor(() => + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a"), + ); + expect(search).toHaveBeenCalledTimes(1); + expect(screen.getByText("feed.yt.freeNote")).toBeTruthy(); + }); + + it("shows an API search's results still cached, without asking again", async () => { + const search = vi.spyOn(api, "searchYoutube"); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.ytSearch("lofi", 20), { + pages: [{ items: [hit("c")], next_cursor: null, source: "api" }], + pageParams: [null], + }); + mount({ source: "api", asked: false, qc }); + expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit c"); + expect(screen.queryByText("feed.yt.notRunTitle")).toBeNull(); + await settle(); + expect(search).not.toHaveBeenCalled(); + }); + + it("asks again for another count — each count is a search of its own", async () => { + const search = vi + .spyOn(api, "searchYoutube") + .mockResolvedValue({ items: [hit("a")], next_cursor: null, source: "scrape" } as never); + const { showing } = mount({ source: "scrape", asked: false }); + await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 20)); + showing(40); + await waitFor(() => expect(search).toHaveBeenCalledWith("lofi", null, 40)); + }); + + it("says the instance's own source while the first answer is on its way", () => { + vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {})); + mount({ source: "scrape", asked: false }); + expect(screen.getByText("feed.yt.searching")).toBeTruthy(); + expect(screen.getByText("feed.yt.freeNote")).toBeTruthy(); + }); + + it("turned off on this instance, lets the server's refusal say so — no press offered", async () => { + vi.spyOn(api, "searchYoutube").mockRejectedValue( + new HttpError(409, "Live search is off", { code: "refused", reason: "search_disabled" }), + ); + mount({ source: "api", asked: false, enabled: false }); + expect(await screen.findByText("Live search is off")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull(); + }); +}); diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index 059e9dc2..ff1fb34c 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -2,6 +2,7 @@ import { YouTubeIcon } from "../../lib/brandIcons"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; import { FEED_KEYS } from "../../lib/urlState"; +import { YT_SEARCH_KEYS } from "../../lib/ytSearchUrlState"; import { FeedStore } from "./FeedStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -16,7 +17,8 @@ export const feedModule: ModuleDef = { Rail, Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Store: FeedStore, - urlKeys: FEED_KEYS, + // Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState). + urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS], playsVideos: true, }; diff --git a/frontend/src/modules/home/Home.dom.test.tsx b/frontend/src/modules/home/Home.dom.test.tsx index 7dc1eb56..cfb8c0e9 100644 --- a/frontend/src/modules/home/Home.dom.test.tsx +++ b/frontend/src/modules/home/Home.dom.test.tsx @@ -174,8 +174,9 @@ function mountWithClient() { } beforeEach(() => { - // Home keeps its question on the history entry (Back restores it): each test starts a fresh one. - window.history.replaceState(null, ""); + // Home keeps its question on the history entry (Back restores it) and its budget in the address: + // each test starts a fresh one of both. + window.history.replaceState(null, "", "/"); discover.mockReset(); discoverPick.mockReset(); setPage.mockReset(); @@ -636,6 +637,37 @@ describe("Home", () => { await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked)); }); + it("names the chosen budget in the address, and the default by leaving it out", async () => { + mount(); + await screen.findByText("Hero v1"); + fireEvent.click(screen.getByText("home.budget.long")); + await waitFor(() => expect(window.location.search).toBe("?hb=long")); + fireEvent.click(screen.getByText("home.budget.any")); + await waitFor(() => expect(window.location.search).toBe("")); + }); + + it("opens on the budget a link names — the question's draw is not the link's", async () => { + window.history.replaceState( + { sfHome: { budget: "short", seed: 7, press: 1, seen: ["youtube:v1"] } }, + "", + "/?page=home&hb=long", + ); + mount(); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("long", 0, "en")); + expect(discoverPick).not.toHaveBeenCalled(); // a question left under another budget is not reused + }); + + it("an address naming no budget opens on the default, over a question left under another", async () => { + window.history.replaceState( + { sfHome: { budget: "short", seed: 7, press: 1, seen: [] } }, + "", + "/?page=home", + ); + mount(); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("any", 0, "en")); + expect(discoverPick).not.toHaveBeenCalled(); + }); + // Measured in the browser (2026-09-07), before the fix: the palette's "Meglepetés" arrived at Home // as `start=2` — two picks deep before the page had painted once — because StrictMode invokes a // mount effect twice with the same render snapshot, and a reroll is not idempotent. The token is diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index 4549ddf4..42bfb30c 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -79,6 +79,8 @@ import { } from "../../components/ui/mediaCardStyles"; import RemoteImage from "../../components/ui/RemoteImage"; import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer"; +import { budgetFrom, budgetParams, DEFAULT_BUDGET } from "../../lib/homeUrlState"; +import { useUrlMirror } from "../../lib/useUrlMirror"; // The Home surface — "Mit nézzek most?". // @@ -701,6 +703,15 @@ function readSavedQuestion(): SavedQuestion | null { }; } +/** The question the page opens with: the budget its address names (a link wins, and an address + * naming none means the default), with the rest of what the entry was left with only when that was + * asked under the same budget. */ +function openingQuestion(): { budget: DiscoverBudget; saved: SavedQuestion | null } { + const budget = budgetFrom(new URLSearchParams(window.location.search)) ?? DEFAULT_BUDGET; + const saved = readSavedQuestion(); + return { budget, saved: saved?.budget === budget ? saved : null }; +} + export default function Home() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); @@ -710,8 +721,10 @@ export default function Home() { // The question on screen lives on this page's history ENTRY as well: opening a card leaves Home // (it unmounts), and Back used to come back to "Doesn't matter" and a different hero — whose card // then left the Continue rail, so the keyboard landed on its neighbour (prod UAT 2026-09-19). - const [saved] = useState(readSavedQuestion); - const [budget, setBudget] = useState(saved?.budget ?? "any"); + // The budget alone is also in the address (lib/homeUrlState): a setting, unlike the draw under it. + const [{ budget: openedBudget, saved }] = useState(openingQuestion); + const [budget, setBudget] = useState(openedBudget); + useUrlMirror(budgetParams(budget)); // 0 = "the hero the page loaded with". Any other value is a reroll, answered by its own query so // pressing the button does not refetch the rails under the user's cursor. const [seed, setSeed] = useState(saved?.seed ?? 0); diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts index 384aab70..2bcc583f 100644 --- a/frontend/src/modules/home/module.ts +++ b/frontend/src/modules/home/module.ts @@ -1,6 +1,7 @@ import { Home } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { HOME_KEYS } from "../../lib/homeUrlState"; // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. export const homeModule: ModuleDef = { @@ -11,8 +12,8 @@ export const homeModule: ModuleDef = { icon: Home, Page: lazyWithPreload(() => import("./Home")), maxWidth: "80rem", - // Nothing of its own: the question is an answer, not a setting — the same question draws other - // cards once what you have watched moves on. - urlKeys: [], + // Its time budget, a setting. Not the question's draw: the same question draws other cards once + // what you have watched moves on (lib/homeUrlState). + urlKeys: HOME_KEYS, playsVideos: true, }; diff --git a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx index 6c668dce..7244bb55 100644 --- a/frontend/src/modules/scheduler/Scheduler.dom.test.tsx +++ b/frontend/src/modules/scheduler/Scheduler.dom.test.tsx @@ -380,6 +380,8 @@ describe("a row's history", () => { closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, + askYtSearch: () => {}, + setYtCount: () => {}, }} > From 0d5c6273f1ae89db05c7525ddf0c2a81a1236555 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 13:52:42 +0100 Subject: [PATCH 08/15] fix(nav): the address follows the view through overlays, reloads and Back (review round 1) - A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added). --- docs/development.md | 15 +- frontend/e2e/bookmark.spec.ts | 101 ++++++++++++- .../navigation/NavigationProvider.tsx | 53 +++++-- .../NavigationProvider.ytSearch.dom.test.tsx | 51 +++++++ .../src/features/navigation/moduleRegistry.ts | 4 + .../features/player/useOpenLinkedPlayer.ts | 29 +++- frontend/src/i18n/locales/en/feed.json | 4 +- frontend/src/i18n/locales/hu/feed.json | 4 +- frontend/src/i18n/locales/hu/files.json | 2 +- frontend/src/i18n/locales/hu/player.json | 2 +- frontend/src/lib/adminUsersTab.ts | 5 + frontend/src/lib/configTab.ts | 5 + frontend/src/lib/filesUrlState.ts | 24 +++- .../lib/history.reloadedNested.dom.test.tsx | 47 ++++++ .../history.reloadedOverlayNav.dom.test.tsx | 40 ++++++ frontend/src/lib/history.ts | 70 +++++++-- frontend/src/lib/logHooks.dom.test.ts | 18 ++- frontend/src/lib/logHooks.ts | 9 +- frontend/src/lib/moduleDef.ts | 4 + frontend/src/lib/playlistsUrlState.ts | 9 ++ frontend/src/lib/settingsTab.ts | 5 + frontend/src/lib/urlState.dom.test.ts | 4 +- frontend/src/lib/urlState.ts | 28 ++-- frontend/src/lib/useUrlMirror.dom.test.ts | 42 +++++- frontend/src/lib/useUrlMirror.ts | 39 ++++- frontend/src/main.tsx | 3 +- frontend/src/moduleSearch.url.dom.test.tsx | 26 +++- frontend/src/moduleUrl.contract.test.ts | 134 ++++++++++++++---- .../modules/activity/Activity.dom.test.tsx | 6 +- frontend/src/modules/activity/module.ts | 1 + frontend/src/modules/channels/module.ts | 1 + frontend/src/modules/config/ConfigPanel.tsx | 4 +- frontend/src/modules/feed/Feed.tsx | 23 +-- .../modules/feed/Feed.ytReopen.dom.test.tsx | 58 +++++++- .../src/modules/feed/FeedFiltersProvider.tsx | 6 +- .../feed/FeedFiltersProvider.url.dom.test.tsx | 82 +++++++++++ frontend/src/modules/feed/module.ts | 1 + frontend/src/modules/files/FilePreview.tsx | 4 +- .../src/modules/files/Files.menu.dom.test.tsx | 20 +++ frontend/src/modules/files/Files.tsx | 13 +- frontend/src/modules/files/FilesProvider.tsx | 25 ++-- frontend/src/modules/files/LibraryPlayer.tsx | 4 +- frontend/src/modules/files/module.ts | 1 + .../modules/playlists/Playlists.dom.test.tsx | 26 ++++ frontend/src/modules/playlists/Playlists.tsx | 11 +- .../modules/playlists/PlaylistsProvider.tsx | 12 +- frontend/src/modules/playlists/module.ts | 1 + frontend/src/modules/plex/module.ts | 1 + .../src/modules/settings/SettingsPanel.tsx | 4 +- frontend/src/modules/users/AdminUsers.tsx | 4 +- 50 files changed, 928 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/history.reloadedNested.dom.test.tsx create mode 100644 frontend/src/lib/history.reloadedOverlayNav.dom.test.tsx create mode 100644 frontend/src/modules/feed/FeedFiltersProvider.url.dom.test.tsx diff --git a/docs/development.md b/docs/development.md index 994a3d87..a5d4be5e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,11 +49,13 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside its codec; the shell clears them on a page switch and opens a page without another page's keys. Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec - in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out), - written with `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through - `useBackToClose`'s), read back first on mount (a link wins over anything remembered). Add its round trip to - `e2e/bookmark.spec.ts`. Only what an address cannot promise to reopen stays out, said in its codec (Home's - draw, `lib/homeUrlState`). + in `lib/UrlState.ts` (read the params into the view, write the view as updates, a default left out — + or always written, when an address naming none falls back to a view remembered elsewhere), written with + `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s), + always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount + (a link wins over anything remembered). A search box's key is also in `typedUrlKeys` (the client log keeps + what was typed out). Add its round trip to `e2e/bookmark.spec.ts`. Only what an address cannot promise to + reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`). 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's hooks). Another folder reaches the module through this file or not at all. 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in @@ -67,7 +69,8 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), `moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key -belongs to one module and matches what its codec writes; no undeclared key literal), the `local/layers` lint rule (imports only go +belongs to one module and matches what its codec writes; a writer outside `lib/` computes no key; no key literal +written or read from the live query undeclared; every page with keys in the bookmark net), the `local/layers` lint rule (imports only go down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the i18n parity test. diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index 85815399..fbf8d45a 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -10,8 +10,10 @@ import { // The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW // browser context, so nothing this tab remembered (its history entries, sessionStorage, the view -// storage it wrote) can stand in for what the address says. One round trip per page, plus the -// overlays the shell opens over any page (the channel page, the player) and the live search. +// storage it wrote) can stand in for what the address says. One round trip per page that names its +// view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the +// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it +// shows is the instance's own library, which a test stack may not have. const STORAGE_STATE = "e2e/.auth/state.json"; @@ -26,6 +28,13 @@ async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => P } const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true }); +const column = (p: Page, name: string) => + p.getByRole("columnheader", { name: new RegExp(`^${name}`) }); + +async function gotoAdminPage(page: Page, id: string): Promise { + await page.getByTestId("nav-admin").click(); + await page.getByTestId(`nav-admin-${id}`).click(); +} test.describe("a bookmark reopens the view it was taken of", () => { test("the feed's filters", async ({ page, browser }) => { @@ -60,14 +69,58 @@ test.describe("a bookmark reopens the view it was taken of", () => { test("an admin page's tab", async ({ page, browser }) => { await openApp(page, "/?page=feed"); - await page.getByTestId("nav-admin").click(); - await page.getByTestId("nav-admin-users").click(); + await gotoAdminPage(page, "users"); await tab(page, "Access requests").click(); const fresh = await bookmarked(browser, page); await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true"); await fresh.context().close(); }); + test("a Configuration group", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoAdminPage(page, "config"); + const second = page.getByRole("tab").nth(1); + const name = (await second.textContent()) ?? ""; + await second.click(); + await expect(page).toHaveURL(/[?&]cfgtab=/); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await fresh.context().close(); + }); + + test("the Activity tab and its time window", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoAdminPage(page, "activity"); + await tab(page, "Server log").click(); + await page.getByRole("button", { name: "30 days", exact: true }).click(); + await expect(page).toHaveURL(/[?&]aw=30d/); + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true"); + await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await fresh.context().close(); + }); + + test("a playlist and its sort", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "playlists"); + await expect(column(page, "Title")).toBeVisible(); + await column(page, "Title").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]plsort=/); + const sorted = await column(page, "Title").getAttribute("aria-sort"); + const fresh = await bookmarked(browser, page); + await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? ""); + expect(new URL(fresh.url()).searchParams.get("pl")).toBe( + new URL(page.url()).searchParams.get("pl"), + ); + await fresh.context().close(); + }); + test("the Channels manager's tab and name search", async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoPage(page, "channels"); @@ -90,6 +143,38 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); + // A file of the test's own, uploaded for it and deleted after: the account's library is left as + // it was found. + test("the Files sort, and the file open in the viewer", async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + const NAME = "bookmark-note"; + // One a run cut short left behind goes first. + const left = (await (await page.request.get("/api/files")).json()) as Record[]; + for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) + await page.request.delete(`/api/files/${String(f.id)}`); + const up = await page.request.post("/api/files/upload", { + headers: { "X-Upload-Filename": `${NAME}.txt` }, + data: Buffer.from("a note the bookmark reopens\n"), + }); + expect(up.ok(), await up.text()).toBeTruthy(); + const { id } = (await up.json()) as { id: number }; + try { + await gotoPage(page, "files"); + await column(page, "Name").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]fsort=name/); + const sorted = await column(page, "Name").getAttribute("aria-sort"); + await page.getByText(NAME, { exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`)); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible(); + await fresh.keyboard.press("Escape"); + await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? ""); + await fresh.context().close(); + } finally { + await page.request.delete(`/api/files/${id}`); + } + }); + test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { await openApp(page, "/?page=feed"); await expect(page.getByTestId("video-card").first()).toBeVisible(); @@ -114,9 +199,15 @@ test.describe("a bookmark reopens the view it was taken of", () => { }); // The search itself never reaches YouTube here: the answer is the server's shape, served by the - // test, and the request it answers is what the address named. + // test, and the request it answers is what the address named. The instance's search settings are + // pinned too (the free source, switched on): a stack left on the API source reopens the search + // waiting for its press, by design, and one with search off offers none. test("a live YouTube search, its term and its count", async ({ page, browser }) => { const answer = async (p: Page, seen: string[]) => { + await p.route("**/api/me", async (route) => { + const me = (await (await route.fetch()).json()) as Record; + return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); + }); await p.route("**/api/search/youtube?**", (route) => { seen.push(route.request().url()); return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index a8eded41..fa18c807 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -86,8 +86,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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). + // asked to search for (sent from the search box, or "Search YouTube again" pressed), on the entries + // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or + // a Forward onto a search — runs only when it costs no quota (Feed decides). const [ytSearch, setYtSearch] = useState(() => page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, ); @@ -96,10 +97,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) { ); const [ytAsked, setYtAsked] = useState(null); const ytSearchRef = useLatest(ytSearch); + const ytCountRef = useLatest(ytCount); + // The search ends: off the screen, and no longer asked for. + const endYtSearch = useCallback(() => { + setYtSearch(null); + setYtAsked(null); + }, []); // 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=, 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. + // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a + // fresh bookmark has only the ?chan param. const [channelView, setChannelView] = useState(() => channelViewFrom(window.history.state, window.location.search), ); @@ -134,6 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) { return; } setChannelView(null); // leave any open channel page when navigating via the rail + // A live search under that channel ends too: the page opens at its root, which the address + // below names — the Feed press over a channel opened from a search included. + endYtSearch(); setPageState(next); setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving @@ -151,7 +161,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { notifyNav(); then?.(); }), - [pageRef, channelViewRef], + [pageRef, channelViewRef, endYtSearch], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands @@ -179,11 +189,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the // entry's channel marks and the ?chan param. + // A live search under it (the channel opened from its results) is on screen again, and named + // again: the channel's address carried none of it. setChannelView(null); const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; - window.history.replaceState(st, "", urlWithParams(closedChannelParams())); + window.history.replaceState( + st, + "", + urlWithParams({ + ...closedChannelParams(), + ...ytSearchParams(ytSearchRef.current, ytCountRef.current), + }), + ); } - }, []); + }, [ytSearchRef, ytCountRef]); const enterYtSearch = useCallback((q: string) => { setYtSearch(q); @@ -230,11 +249,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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); + endYtSearch(); const { _yt: _q, ...st } = window.history.state ?? {}; window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } - }, []); + }, [endYtSearch]); // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted // DURING the render that changes the page, against the page we were last on: as an effect it ran @@ -244,7 +263,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); - if (page !== "feed") setYtSearch(null); + if (page !== "feed") { + setYtSearch(null); + setYtAsked(null); + } } // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): @@ -286,8 +308,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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 - // ?chan / ?v / ?pl / filter params the loaders already read. + // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the + // shell's keys (?chan, a player's) and the page's own; other pages' go (below). // A retired page's link — or the remembered last page, when that is what opened — also opens its // part of the page that replaced it (an `at` tab). const linked = new URLSearchParams(window.location.search).get("page"); @@ -325,9 +347,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setPageState(p); setAccountRaw(PAGE_KEY, p); // 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. + // clears it to match the entry we landed on. One landed on without it has ended: a Forward back + // onto it is not asked for, and an API search waits for a press. const here = new URLSearchParams(window.location.search); - setYtSearch(p === "feed" ? ytSearchFrom(here) : null); + const landed = p === "feed" ? ytSearchFrom(here) : null; + setYtSearch(landed); + if (landed == null) setYtAsked(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 diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx index a610ed4a..353f402e 100644 --- a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -188,6 +188,57 @@ describe("NavigationProvider · the YouTube search in the address", () => { expect(search().get("ytn")).toBeNull(); }); + it("a search left by Back and reached again by Forward is not asked for", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + expect(result.current.ytSearchAsked).toBe(true); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(false); + }); + + it("a search left in place is not asked for when it is opened again by a link", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi"); + const { result } = render(); + act(() => result.current.askYtSearch()); + stubNoEntryBelow(); + act(() => result.current.exitYtSearch()); + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearchAsked).toBe(false); + }); + + it("the Feed press over a channel opened from the search opens the plain feed", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.openChannel("UC1")); + act(() => result.current.setPage("feed")); + expect(result.current.channelView).toBeNull(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + + it("a channel closed in place over the search names the search again", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setYtCount(40)); + act(() => result.current.openChannel("UC1")); + expect(search().get("yt")).toBeNull(); + stubNoEntryBelow(); + act(() => result.current.closeChannel()); + expect(result.current.ytSearch).toBe("lofi"); + expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]); + }); + it("a page switch takes the search off the address", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts index 82e55d74..aa84d5ef 100644 --- a/frontend/src/features/navigation/moduleRegistry.ts +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -55,6 +55,10 @@ const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys); export function moduleUrlKeys(): readonly string[] { return ALL_URL_KEYS; } +/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */ +export function typedUrlKeys(): readonly string[] { + return ALL.flatMap((m) => m.typedUrlKeys ?? []); +} export function foreignUrlKeys(page: Page): readonly string[] { return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys); } diff --git a/frontend/src/features/player/useOpenLinkedPlayer.ts b/frontend/src/features/player/useOpenLinkedPlayer.ts index 2f4e7a3e..4731b4a2 100644 --- a/frontend/src/features/player/useOpenLinkedPlayer.ts +++ b/frontend/src/features/player/useOpenLinkedPlayer.ts @@ -4,43 +4,58 @@ import { useQuery } from "@tanstack/react-query"; import { api, type Video } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState"; -import { dropFromAddress } from "../../lib/urlState"; +import { clearParams } from "../../lib/urlState"; import { notifyActionError } from "../../lib/actionErrors"; /** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that * hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the * item is in it (`find`), so the player plays on through the list; else alone, its row fetched from - * the catalog. A row the catalog no longer has is said, not swallowed. */ + * the catalog. A row the catalog no longer has is said, not swallowed. + * + * `scope` names the list the address put it over (a playlist, a live search): when the host moves to + * another before it opened, the address under it named something else, and the player is given up — + * opened later, it would start over a list nobody linked, unasked. */ export function useOpenLinkedPlayer( ready: boolean, find: (id: string) => Video | undefined, open: (video: Video, inList: boolean) => void, + scope: string | number | null = null, ): void { const { t } = useTranslation(); const [linked] = useState(() => linkedPlayer()?.watch ?? null); + const [linkedScope] = useState(scope); // Opened (or said to be gone) once; a ref, as nothing on screen reads it. const done = useRef(linked == null); - const listed = ready && linked != null ? find(linked) : undefined; + const moved = scope !== linkedScope; + const listed = ready && !moved && linked != null ? find(linked) : undefined; const row = useQuery({ queryKey: qk.videoRow(linked ?? ""), queryFn: () => api.videoRow(linked!), - enabled: ready && linked != null && !listed, + enabled: ready && !moved && linked != null && !listed, retry: false, }); const openIt = useEffectEvent(open); const failed = useEffectEvent((err: unknown) => { notifyActionError(err, t("player.linkedFailed")); - dropFromAddress(PLAYER_KEYS); + clearParams(...PLAYER_KEYS); }); useEffect(() => { + if (done.current) return; + if (moved) { + done.current = true; + // The address goes on naming it under the new list (a reload would open it there) — unless a + // player opened since names its own. + if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS); + return; + } // Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills // it here too (a disabled query still reads the cache) — the shell's player over a page that // plays its own would open a second one. - if (done.current || !ready) return; + if (!ready) return; if (listed) openIt(listed, true); else if (row.data) openIt(row.data, false); else if (row.isError) failed(row.error); else return; done.current = true; - }, [ready, listed, row.data, row.isError, row.error]); + }, [linked, moved, ready, listed, row.data, row.isError, row.error]); } diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index ce0941ad..1572832c 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -76,8 +76,8 @@ "cleared_one": "Cleared {{count}} result", "cleared_other": "Cleared {{count}} results", "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", - "notRunTitle": "This search hasn't run again", - "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload or a link doesn't start one by itself.", + "notRunTitle": "Not searched yet", + "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link or Back doesn't start one by itself.", "runAgain": "Search YouTube again" } } diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 111fe5b2..ca264e61 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -76,8 +76,8 @@ "cleared_one": "{{count}} találat törölve", "cleared_other": "{{count}} találat törölve", "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.", - "notRunTitle": "Ez a keresés nem futott újra", - "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltés vagy link nem indít el egyet magától.", + "notRunTitle": "Ez a keresés még nem futott le", + "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről vagy visszalépéskor nem indul el magától.", "runAgain": "Keresés újra a YouTube-on" } } diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json index 727225a0..a6deff3d 100644 --- a/frontend/src/i18n/locales/hu/files.json +++ b/frontend/src/i18n/locales/hu/files.json @@ -1,5 +1,5 @@ { - "linkedGone": "A címben szereplő fájl már nincs ebben a listában", + "linkedGone": "A linkben szereplő fájl már nincs ebben a listában", "navLabel": "Fájlok", "searchPlaceholder": "Keresés a fájlok között…", "count_one": "{{count}} elem", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index e10bf28d..c0afd43a 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -1,6 +1,6 @@ { "loading": "Betöltés…", - "linkedFailed": "A címben szereplő videó nem nyitható meg", + "linkedFailed": "A linkben szereplő videó nem nyitható meg", "backToOriginal": "Vissza az eredeti videóhoz", "back": "Vissza", "previous": "Előző", diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts index 5c93cd33..8fbc307c 100644 --- a/frontend/src/lib/adminUsersTab.ts +++ b/frontend/src/lib/adminUsersTab.ts @@ -10,6 +10,11 @@ import { LS, setTabRaw } from "./storage"; export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; // The address-bar key the page mirrors its tab to. export const USERS_TAB_URL_KEY = "utab"; + +/** The address-bar delta for the Users tab shown. */ +export function usersTabParams(tab: string | null): Record { + return { [USERS_TAB_URL_KEY]: tab }; +} const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { diff --git a/frontend/src/lib/configTab.ts b/frontend/src/lib/configTab.ts index 897fc28f..d6342031 100644 --- a/frontend/src/lib/configTab.ts +++ b/frontend/src/lib/configTab.ts @@ -1,3 +1,8 @@ // The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other // pages' keys, so the page's definition can declare it without importing the lazy page. export const CONFIG_TAB_KEY = "cfgtab"; + +/** The address-bar delta for the config group shown (null: none named). */ +export function configTabParams(tab: string | null): Record { + return { [CONFIG_TAB_KEY]: tab }; +} diff --git a/frontend/src/lib/filesUrlState.ts b/frontend/src/lib/filesUrlState.ts index 073e3efd..df8265e5 100644 --- a/frontend/src/lib/filesUrlState.ts +++ b/frontend/src/lib/filesUrlState.ts @@ -1,7 +1,7 @@ // The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in // modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the // sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the -// other pages' key lists, so the page's definition can declare them without importing its own folder. +// other pages' codecs (lib/UrlState), where the URL contract test reads them all. export const FILES_FOLDER_KEY = "folder"; export const FILES_ITEM_KEY = "item"; export const FILES_SEARCH_KEY = "fq"; @@ -21,6 +21,28 @@ export const FILES_KEYS = [ export type FilesOpenFile = { open: "view" | "play"; id: number }; +/** The address-bar delta for the list (the page's store): its search, folder and the file it turns to + * — each null when not named (`null`: the page is not what the address shows). */ +export function filesListParams( + list: { search: string; folder: string | null; item: number | null } | null, +): Record { + return { + [FILES_SEARCH_KEY]: list?.search || null, + [FILES_FOLDER_KEY]: list?.folder ?? null, + [FILES_ITEM_KEY]: list?.item != null ? String(list.item) : null, + }; +} + +/** The address-bar delta for the list's sort (lib/columnSort's encoding, `none` included). */ +export function filesSortParams(sort: string | null): Record { + return { [FILES_SORT_KEY]: sort }; +} + +/** What a file open over the list names in its own entry: the viewer's, or the player's. */ +export function filesOpenParams(file: FilesOpenFile): Record { + return { [file.open === "play" ? FILES_PLAY_KEY : FILES_VIEW_KEY]: String(file.id) }; +} + /** The file an address opens over the list (a reload, a bookmark), or null. */ export function openFileFrom(params: URLSearchParams): FilesOpenFile | null { for (const [open, key] of [ diff --git a/frontend/src/lib/history.reloadedNested.dom.test.tsx b/frontend/src/lib/history.reloadedNested.dom.test.tsx new file mode 100644 index 00000000..0fe565c9 --- /dev/null +++ b/frontend/src/lib/history.reloadedNested.dom.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +// +// A reload under two overlays (a dialog, or a maximised player, over the player) loads on the upper +// one's entry; only the player reopens, and takes that entry over. The lower one's entry is still +// under it, and nothing reopens there: closing the player steps back over it too, so one close +// lands on the page — not on a dead step that looks like it. Its own file: the entry is read as the +// module loads, so it is set up before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +describe("an overlay reopened on the entry a reload under two started from", () => { + it("closes onto the page under both", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); + window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1"); + window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1"); + const depth = window.history.length; + const { useBackToClose } = await import("./history"); + + const holder: { unmount?: () => void } = {}; + const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" })); + holder.unmount = r.unmount; + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth); + + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + r.unmount(); + await landed; + await Promise.resolve(); + }); + expect(window.history.state?._ov).toBeUndefined(); + expect(window.location.search).toBe("?page=feed"); + }); + + it("a pushed overlay entry records how many it stands on", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); + const { useBackToClose } = await import("./history"); + const r = renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.state?._ovd).toBe(1); + r.unmount(); + }); +}); diff --git a/frontend/src/lib/history.reloadedOverlayNav.dom.test.tsx b/frontend/src/lib/history.reloadedOverlayNav.dom.test.tsx new file mode 100644 index 00000000..bb172201 --- /dev/null +++ b/frontend/src/lib/history.reloadedOverlayNav.dom.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +// +// The entry a reload under an open dialog started from carries the dialog's mark, and a dialog that +// does not reopen (About, the cheat sheet) leaves it there. A view pushed from that entry (a channel +// page, a search, a sub-view) must not inherit the mark: the first overlay over it took the view's +// entry over as its own, and closing it closed the view too. Its own file: the entry is read as the +// module loads, so it is set up before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +describe("a view pushed from the entry a reload under a dialog started from", () => { + it("is not taken over by the first overlay over it", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); + window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?page=feed"); + const { rememberNavPosition, useBackToClose } = await import("./history"); + + // openChannel's push, as it spreads the entry it leaves. + const left = rememberNavPosition(); + expect(left._ov).toBeUndefined(); + window.history.pushState({ ...left, _chan: "UC1" }, "", "/?chan=UC1"); + const depth = window.history.length; + + const holder: { unmount?: () => void } = {}; + const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" })); + holder.unmount = r.unmount; + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth + 1); + + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + r.unmount(); + await landed; + await Promise.resolve(); + }); + expect(window.history.state?._chan).toBe("UC1"); + expect(window.location.search).toBe("?chan=UC1"); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 6df78b70..48e7e1b5 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -87,7 +87,10 @@ if (typeof window !== "undefined") { /** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a * channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state - * must spread this, so the new entry does not inherit a position it was never left from. */ + * must spread this, so the new entry does not inherit a position it was never left from — nor an + * overlay's marks, which belong to the entry being left (an entry a reload under an open dialog + * started from carries them, and the first overlay over a view pushed from it took that view's entry + * over for its own). */ export function rememberNavPosition(): Record { // A restore still waiting for the last landing's lists belongs to the page being left: left // running, it would focus "list N, item M" of whatever page this push opens. @@ -95,7 +98,8 @@ export function rememberNavPosition(): Record { const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record; const pos = navPosition(); window.history.replaceState(pos ? { ...st, sfNav: pos } : st, ""); - return st; + const { _ov: _overlay, _ovd: _depth, ...view } = st; + return view; } // --- Is the entry a back() lands on still ours? -------------------------------------------- @@ -111,12 +115,12 @@ interface NavigationLike { entries: () => readonly { sameDocument: boolean }[]; } -/** Would one more history.back() still land in this document? */ -export function backStaysInDocument(): boolean { +/** Would `steps` more steps back (one, a history.back()) still land in this document? */ +export function backStaysInDocument(steps = 1): boolean { const nav = (window as { navigation?: NavigationLike }).navigation; const at = nav?.currentEntry?.index; if (nav == null || at == null) return true; - return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true; + return nav.entries()[at - pendingSteps - steps]?.sameDocument === true; } // --- Sub-views (value carried in history.state._sub) --------------------------------------- @@ -292,10 +296,36 @@ const nextOverlayToken = () => ++overlayCounter; let historyDepth = 0; // The entry this document was loaded on, when it was an overlay's (a reload under an open player): // the first overlay to open takes it over rather than pushing another over it — pushed, Back would -// close the player onto that same page again, one dead step per reload. Until the first push or Back. +// close the player onto that same page again, one dead step per reload. Until the first push or Back; +// a view pushed from it does not carry its mark (rememberNavPosition). `_ovd` on an overlay's entry is how many overlay +// entries it stood on, its own included: a reload under two (a dialog, or a maximised player, over +// the player) leaves the lower one's entry under the one taken over, and closing the taken-over +// overlay steps back over those too (`deadBelow`) — one Back closes it, as it opened. let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null; -// Programmatic history.back() calls still awaiting their popstate — each must be ignored. +const reloadedDepth: unknown = window.history.state?._ovd; +let deadBelow = 0; +// Programmatic history steps back still awaiting their popstate — each must be ignored — and how +// many entries they step over in all (a step over dead entries is one traversal of several). let pendingProgrammatic = 0; +let pendingSteps = 0; +const pendingStepCounts: number[] = []; +function stepBack(steps: number): void { + pendingProgrammatic++; + pendingSteps += steps; + pendingStepCounts.push(steps); + if (steps === 1) window.history.back(); + else window.history.go(-steps); +} +function landedStepBack(): void { + pendingProgrammatic--; + pendingSteps -= pendingStepCounts.shift() ?? 1; +} +// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it. +function stepsToClose(): number { + const steps = historyDepth === 0 ? 1 + deadBelow : 1; + if (historyDepth === 0) deadBelow = 0; + return steps; +} let reconcileScheduled = false; let listenerInstalled = false; // Re-checked after every reconcile — see `afterClosing`. @@ -314,19 +344,27 @@ function reconcileHistory(): void { const here = window.history.state?._ov; if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) { reloadedOverlayEntry = null; + if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1; continue; } reloadedOverlayEntry = null; - window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, ""); + window.history.pushState( + { ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow }, + "", + ); } while (historyDepth > want) { historyDepth--; - if (backStaysInDocument()) { - pendingProgrammatic++; - window.history.back(); - } else { + const steps = stepsToClose(); + if (backStaysInDocument(steps)) stepBack(steps); + else if (steps > 1 && backStaysInDocument()) stepBack(1); + else { // Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked. - const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record; + const { + _ov: _drop, + _ovd: _depth, + ...st + } = (window.history.state ?? {}) as Record; window.history.replaceState(st, ""); } } @@ -368,7 +406,7 @@ function ensureOverlayListener(): void { window.addEventListener("popstate", () => { reloadedOverlayEntry = null; if (pendingProgrammatic > 0) { - pendingProgrammatic--; // our own back(): consume and ignore + landedStepBack(); // our own back(): consume and ignore // The last one landed: an overlay that opened while it was in flight still owes its entry. if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile(); else nameOverlaysHere(); @@ -376,7 +414,11 @@ function ensureOverlayListener(): void { } // Genuine user Back: the browser already dropped one overlay entry, so close the topmost // overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs. + // The last one closing over dead entries (see `deadBelow`) steps over them too. if (historyDepth > 0) historyDepth--; + const dead = historyDepth === 0 ? deadBelow : 0; + if (historyDepth === 0) deadBelow = 0; + if (dead > 0 && backStaysInDocument(dead)) stepBack(dead); const top = overlayStack.pop(); if (top) top.cb(); scheduleReconcile(); diff --git a/frontend/src/lib/logHooks.dom.test.ts b/frontend/src/lib/logHooks.dom.test.ts index 93b697b5..57da00f4 100644 --- a/frontend/src/lib/logHooks.dom.test.ts +++ b/frontend/src/lib/logHooks.dom.test.ts @@ -10,9 +10,12 @@ import { installClientLog } from "./logHooks"; const appCopy = (text: string | null) => ["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null; +// The pages' search boxes' keys, as main.tsx hands them over from the module definitions. +const TYPED = ["fq", "yt"]; + beforeAll(() => { - installClientLog(appCopy); - installClientLog(appCopy); // a hot reload's second call must not wrap History twice + installClientLog(appCopy, TYPED); + installClientLog(appCopy, TYPED); // a hot reload's second call must not wrap History twice expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]); clear(); }); @@ -56,6 +59,17 @@ describe("client log hooks", () => { }); }); + it("keeps a page's own search box out below TRACE too", () => { + setLevel("DEBUG"); + window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words&yt=lofi"); + expect(views()[0]!.attrs!.values).toEqual({ folder: "all" }); + expect(JSON.stringify(views())).not.toContain("private"); + + setLevel("TRACE"); + window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words"); + expect(views()[1]!.attrs!.values).toEqual({ folder: "all", fq: "private words" }); + }); + it("records an overlay opening as a view of its own, and Back as a pop", () => { window.history.pushState({ sfPage: "feed", _ov: 1 }, ""); // What Back does: the browser restores the previous entry's state (the unwrapped prototype diff --git a/frontend/src/lib/logHooks.ts b/frontend/src/lib/logHooks.ts index 4c0c90c2..c2168511 100644 --- a/frontend/src/lib/logHooks.ts +++ b/frontend/src/lib/logHooks.ts @@ -14,7 +14,8 @@ import { enabled, errorAttrs, log } from "./log"; import { pathTemplate } from "./http"; // URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). -// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. +// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The +// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys). const TYPED_PARAMS = new Set(["q", "keywords", "search"]); type Via = "start" | "push" | "replace" | "pop"; @@ -168,9 +169,13 @@ let installed = false; /** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. * `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this * module stays free of the i18n runtime. */ -export function installClientLog(appCopy: AppCopy = () => null): void { +export function installClientLog( + appCopy: AppCopy = () => null, + typedParams: readonly string[] = [], +): void { if (installed) return; installed = true; + for (const k of typedParams) TYPED_PARAMS.add(k); log.info("app.start", "app loaded", { version: FRONTEND_VERSION, diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index d6471f8e..bf1dd813 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -50,6 +50,10 @@ export type ModuleDef = { // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to // one module and none is a shell key (moduleUrl.contract.test). urlKeys: readonly string[]; + // Those of them that carry what the user TYPED (a search box's text): the client log keeps their + // values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search + // box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys). + typedUrlKeys?: readonly string[]; // Its page plays YouTube videos in the player and reopens it from an address that names one (a // reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets // the shell's player for such an address: the video alone. diff --git a/frontend/src/lib/playlistsUrlState.ts b/frontend/src/lib/playlistsUrlState.ts index 553cf16f..3ada9db0 100644 --- a/frontend/src/lib/playlistsUrlState.ts +++ b/frontend/src/lib/playlistsUrlState.ts @@ -20,6 +20,15 @@ export function isStoredSort(s: SortState): boolean { return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir; } +/** The address-bar delta for the page's store: the playlist shown and the rail's search (each null + * when not named). */ +export function playlistsParams( + selected: number | null, + search: string, +): Record { + return { pl: selected != null ? String(selected) : null, plq: search || null }; +} + /** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */ export function playlistViewParamUpdates(v: PlaylistView | null): Record { if (!v) return { plsort: null, plf: null, plgroup: null }; diff --git a/frontend/src/lib/settingsTab.ts b/frontend/src/lib/settingsTab.ts index 74045e15..f5fbece8 100644 --- a/frontend/src/lib/settingsTab.ts +++ b/frontend/src/lib/settingsTab.ts @@ -6,6 +6,11 @@ import { LS, setTabRaw } from "./storage"; // address-bar key the page mirrors its tab to is here too, beside the other pages' keys. export const SETTINGS_TAB_KEY = "stab"; +/** The address-bar delta for the Settings tab shown. */ +export function settingsTabParams(tab: string | null): Record { + return { [SETTINGS_TAB_KEY]: tab }; +} + export type SettingsTabId = | "appearance" | "notifications" diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts index ab87bf44..ca7e1e47 100644 --- a/frontend/src/lib/urlState.dom.test.ts +++ b/frontend/src/lib/urlState.dom.test.ts @@ -127,13 +127,13 @@ describe("a page opened at a path that starts with //", () => { }); describe("filterParamUpdates", () => { - it("maps non-default filters to values and defaults to null (delete)", () => { + it("maps non-default filters to values and other defaults to null (delete)", () => { const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; const upd = filterParamUpdates(f); expect(upd.q).toBe("react"); expect(upd.tags).toBe("3,7"); expect(upd.show).toBe("watched"); - expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared + expect(upd.sort).toBe("newest"); // the view and the order are always named, defaults too expect(upd.scope).toBeNull(); }); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 5c380810..2c45ae38 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -80,13 +80,19 @@ export function filtersToParams(f: FeedFilters): URLSearchParams { } /** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted - * value, or null to delete it (so switching a filter back to its default clears the key). Pass `null` - * to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them. - * Feed the result into urlWithParams(). */ + * value, or null to delete it (so switching a filter back to its default clears the key) — except the + * view and the order, always written (defaults too): a feed address then always names its view, so a + * bookmark of the default feed opens the default feed, not whatever view the account stored since + * (an address naming none falls back to it). Pass `null` to clear ALL filter keys — used when leaving + * the feed, where the localStorage copy still holds them. Feed the result into urlWithParams(). */ export function filterParamUpdates(f: FeedFilters | null): Record { const p = f ? filtersToParams(f) : new URLSearchParams(); const out: Record = {}; for (const k of KEYS) out[k] = p.get(k); + if (f) { + out.show = f.show || "unwatched"; + out.sort = f.sort || "newest"; + } return out; } @@ -233,13 +239,6 @@ export function urlWithParams(updates: Record): string { return hrefHere(params); } -/** Take `keys` off the current entry's address: what a link named and the page could not show (a file - * or a video that is gone, said once) must not stay to be said again on every reload, or shared. */ -export function dropFromAddress(keys: readonly string[]): void { - const updates = Object.fromEntries(keys.map((k) => [k, null])); - window.history.replaceState(window.history.state, "", urlWithParams(updates)); -} - /** The channel page's address, opened over the page on screen: the shell's own keys (the page, an * entry link not yet read) and the channel — none of the page's keys, none of another channel's view * (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes @@ -269,10 +268,11 @@ export function hrefHere(query: URLSearchParams, hash: string = window.location. return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`; } -/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, - * ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now - * live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now - * that the query is the source of truth for navigation. No-op when none of the keys are present. */ +/** Take `keys` off the current entry's address, WITHOUT touching the nav/filter params that live + * there: TRANSIENT params once read (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, + * ?reset), and what a link named that the page could not show (a file or a video that is gone, said + * once) — left, it would be said again on every reload, or shared. Preserves history.state. No-op when + * none of the keys are present. */ export function clearParams(...keys: string[]): void { const params = new URLSearchParams(window.location.search); let changed = false; diff --git a/frontend/src/lib/useUrlMirror.dom.test.ts b/frontend/src/lib/useUrlMirror.dom.test.ts index c0c3f567..c75e92ff 100644 --- a/frontend/src/lib/useUrlMirror.dom.test.ts +++ b/frontend/src/lib/useUrlMirror.dom.test.ts @@ -5,7 +5,7 @@ // so an inline object literal (new identity every render) only fires when a param actually changes; a // null value clears a key; and every other param on the URL is preserved. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; +import { act, cleanup, renderHook } from "@testing-library/react"; import { useUrlMirror } from "./useUrlMirror"; const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); @@ -15,7 +15,10 @@ beforeEach(() => { setUrl("/"); spy = vi.spyOn(window.history, "replaceState"); }); -afterEach(() => spy.mockRestore()); +afterEach(() => { + cleanup(); + spy.mockRestore(); +}); describe("useUrlMirror", () => { it("writes the param onto the current entry on mount", () => { @@ -49,6 +52,41 @@ describe("useUrlMirror", () => { expect(window.location.search).toBe("?page=playlists&pl=5"); }); + // An entry above the view's own (a dialog's, a drawer's, a sub-view's) took a change the entry + // under it never saw: Back restores that entry's older address, and a reload would bring the old + // view back over the one on screen. + it("names the view again on the entry Back lands on", async () => { + setUrl("?page=plex&pf="); + const { rerender } = renderHook((p: Record) => useUrlMirror(p), { + initialProps: { pf: "" } as Record, + }); + window.history.pushState({ _ov: 1 }, "", window.location.href); + rerender({ pf: "genres=Drama" }); + expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama"); + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + window.history.back(); + await landed; + await new Promise((res) => setTimeout(res, 0)); + }); + expect(window.history.state).toBeNull(); + expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama"); + }); + + it("writes nothing on a landing that already names the view", async () => { + setUrl("?pl=5"); + renderHook(() => useUrlMirror({ pl: "5" })); + window.history.pushState({ _ov: 1 }, "", window.location.href); + spy.mockClear(); + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + window.history.back(); + await landed; + await new Promise((res) => setTimeout(res, 0)); + }); + expect(spy).not.toHaveBeenCalled(); + }); + it("leaves an unconsumed auth token in the fragment alone", () => { // This hook's mount effect is what ran before (and therefore before the auth card) had even // loaded, so whatever it writes to the address bar is what the card later reads. It must not eat diff --git a/frontend/src/lib/useUrlMirror.ts b/frontend/src/lib/useUrlMirror.ts index 451c1194..21d26d14 100644 --- a/frontend/src/lib/useUrlMirror.ts +++ b/frontend/src/lib/useUrlMirror.ts @@ -1,4 +1,4 @@ -import { useEffect, useEffectEvent } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; import { urlWithParams } from "./urlState"; // Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new @@ -6,17 +6,44 @@ import { urlWithParams } from "./urlState"; // value clears that key, and urlWithParams preserves every other param (so providers don't clobber each // other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and // PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place. +// +// Also whenever Back/Forward makes another entry current: the browser restores that entry's address, +// which says what the view was when it was left — a change made since on an entry above it (a dialog's +// or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one +// on screen. (One landed on by an overlay's step back that another step will leave is written too, +// and harmlessly: the next landing writes the entry that stays.) export function useUrlMirror(updates: Record): void { // The caller typically passes an inline object literal (new identity every render); key on its // serialized value so the effect only fires when a param actually changes, not every render. const dep = JSON.stringify(updates); - // The object itself is read through an effect event, so the effect's one reactive input is the - // serialized value — which is exactly what the dep list says: an effect event for the identity that - // churns, a primitive for the trigger. + // The object itself is read through an effect event, so the effect's reactive inputs are the + // serialized value and the landing count — which is exactly what the dep list says: an effect event + // for the identity that churns, primitives for the triggers. const mirror = useEffectEvent(() => { - window.history.replaceState(window.history.state, "", urlWithParams(updates)); + const href = urlWithParams(updates); + // Every mirror runs on every landing: one with nothing to change writes nothing (a browser + // throttles a page that rewrites its address too often). + if (href !== urlWithParams({})) window.history.replaceState(window.history.state, "", href); }); + // Counted in state, on a task after the popstate dispatch: every listener of it has run by then — + // a store that reads its keys back from the landed address among them (Activity) — and so has the + // render each one caused (React renders a popstate's updates at once, between listeners). A write + // inside the dispatch put the store's old value over the address a later listener was still to + // read. + const [landings, setLandings] = useState(0); + useEffect(() => { + let timer = 0; + const onPop = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => setLandings((n) => n + 1), 0); + }; + window.addEventListener("popstate", onPop); + return () => { + window.removeEventListener("popstate", onPop); + window.clearTimeout(timer); + }; + }, []); useEffect(() => { mirror(); - }, [dep]); + }, [dep, landings]); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8a439655..ec1d0d50 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { ConfirmProvider } from "./components/ui/ConfirmProvider"; import BootGate, { BootScreen } from "./shell/BootGate"; import { NavigationProvider } from "./features/navigation/NavigationProvider"; import { ModuleStores } from "./features/navigation/ModuleStores"; +import { typedUrlKeys } from "./features/navigation/moduleRegistry"; import { PrefsProvider } from "./features/prefs/PrefsProvider"; import { WizardProvider } from "./features/wizard/WizardProvider"; import "./i18n"; @@ -19,7 +20,7 @@ import { installStaleBuildReload } from "./lib/staleBuild"; import { appCopyOf } from "./i18n/appCopy"; // First, before any render: a crash or a navigation during boot belongs in the log too. -installClientLog(appCopyOf); +installClientLog(appCopyOf, typedUrlKeys()); // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. installStaleBuildReload(); diff --git a/frontend/src/moduleSearch.url.dom.test.tsx b/frontend/src/moduleSearch.url.dom.test.tsx index ba1a4342..7c46c42b 100644 --- a/frontend/src/moduleSearch.url.dom.test.tsx +++ b/frontend/src/moduleSearch.url.dom.test.tsx @@ -4,7 +4,7 @@ // the list searched (Channels, Files and Playlists kept it in memory only, Plex in this browser only). // The real navigation and the real stores, read through their hooks; only the network is absent. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { cleanup, render, waitFor } from "@testing-library/react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -14,6 +14,10 @@ vi.mock("react-i18next", async (importOriginal) => ({ })); import { NavigationProvider } from "./features/navigation/NavigationProvider"; +import { + useNavigationActions, + type NavigationActions, +} from "./features/navigation/navigationContext"; import { ModuleStores } from "./features/navigation/ModuleStores"; import { useChannels } from "./modules/channels/channelsContext"; import { useFiles } from "./modules/files/filesContext"; @@ -22,13 +26,16 @@ import { usePlex } from "./modules/plex/plexContext"; import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage"; const seen: { current: Record } = { current: {} }; +const nav: { current: NavigationActions | null } = { current: null }; function Probe() { const channels = useChannels(); const files = useFiles(); const playlists = usePlaylists(); const plex = usePlex(); + const actions = useNavigationActions(); useEffect(() => { + nav.current = actions; seen.current = { channels: channels.search, files: files.search, @@ -93,3 +100,20 @@ describe("Plex's search", () => { expect(search().get("pqf")).toBe("people"); }); }); + +// The search is the page's while it is on screen: leaving the page ends it, so a trip back through +// the rail opens the page unsearched (and the address names none). +describe.each([ + ["files", "fq", "notes"], + ["playlists", "plq", "jazz"], +])("%s's name search, once the page is left", (page, key, value) => { + it("is gone when the page is opened again", async () => { + mountAt(`/?page=${page}&${key}=${value}`, page); + await waitFor(() => expect(seen.current[page]).toBe(value)); + act(() => nav.current!.setPage("feed")); + act(() => nav.current!.setPage(page as "files" | "playlists")); + await waitFor(() => expect(seen.current[page]).toBe("")); + await new Promise((r) => setTimeout(r, 350)); // past the address write's typing pause + expect(search().get(key)).toBeNull(); + }); +}); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 8ee94f78..61cc7798 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -3,26 +3,28 @@ // The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the // shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, // derived from these declarations). That only holds while the declarations are true, so this pins -// them: each key belongs to one module and is no shell key, every codec writes only its module's keys, -// and no key literal anywhere in the app reads or writes the address bar undeclared. +// them: each key belongs to one module and is no shell key, the keys a module declares are exactly +// what its encoders write, a writer outside lib/ hands the address an encoder's result (or literal +// keys, checked here) — never a key of its own making — and every key literal read from the live +// query is declared. import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; -import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistry"; +import { moduleDef, REGISTERED_PAGES, typedUrlKeys } from "./features/navigation/moduleRegistry"; import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api"; import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState"; import { channelsParamUpdates } from "./lib/channelsUrlState"; import { plexParamUpdates, plexSearchParamUpdates } from "./lib/plexUrlState"; import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState"; import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState"; -import { FILES_KEYS } from "./lib/filesUrlState"; +import { filesListParams, filesOpenParams, filesSortParams } from "./lib/filesUrlState"; import { ytSearchParams } from "./lib/ytSearchUrlState"; import { budgetParams } from "./lib/homeUrlState"; import { PLAYER_KEYS, playerParamUpdates } from "./lib/playerUrlState"; -import { playlistViewParamUpdates } from "./lib/playlistsUrlState"; -import { SETTINGS_TAB_KEY } from "./lib/settingsTab"; -import { CONFIG_TAB_KEY } from "./lib/configTab"; -import { USERS_TAB_URL_KEY } from "./lib/adminUsersTab"; +import { playlistsParams, playlistViewParamUpdates } from "./lib/playlistsUrlState"; +import { settingsTabParams } from "./lib/settingsTab"; +import { configTabParams } from "./lib/configTab"; +import { usersTabParams } from "./lib/adminUsersTab"; import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl"; import { MODE_URL } from "./shell/welcomeMode"; import { CHANNEL_PAGE_KEYS, channelPageParamUpdates } from "./lib/channelPageUrlState"; @@ -56,13 +58,17 @@ const WRITES: Partial string[]>> = { ...Object.keys(plexSearchParamUpdates({ q: "x", field: "all" })), PLEX_SUB_URL.key, ], - files: () => [...FILES_KEYS], - settings: () => [SETTINGS_TAB_KEY], - config: () => [CONFIG_TAB_KEY], - users: () => [USERS_TAB_URL_KEY], + files: () => [ + ...Object.keys(filesListParams(null)), + ...Object.keys(filesSortParams(null)), + ...Object.keys(filesOpenParams({ open: "view", id: 1 })), + ...Object.keys(filesOpenParams({ open: "play", id: 1 })), + ], + settings: () => Object.keys(settingsTabParams(null)), + config: () => Object.keys(configTabParams(null)), + users: () => Object.keys(usersTabParams(null)), playlists: () => [ - "pl", - "plq", + ...Object.keys(playlistsParams(null, "")), ...Object.keys(playlistViewParamUpdates({ sort: null, filters: {}, groupBy: false })), ], activity: () => [ @@ -79,25 +85,57 @@ function sources(dir: string): string[] { }); } -// Key literals that reach the address bar's QUERY: the keys of an object literal handed to -// `urlWithParams` / `useUrlMirror`, and a `.get("…")` on the live query. +// The object literals handed to an address writer: `urlWithParams` / `useUrlMirror`, an overlay's +// own keys (ui/Modal's `url`, `useBackToClose`'s). +const WRITER_OBJECTS = [ + /(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g, + /\burl=\{\{([^}]*)\}/g, + /useBackToClose\([^,()]+,\s*\{([^}]*)\}/g, +]; + +// Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a +// `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the +// two-line form names its params before it reads them). The fragment (`hash`) is not the query. function queryKeyLiterals(): { key: string; where: string }[] { const out: { key: string; where: string }[] = []; for (const file of sources(SRC)) { const text = readFileSync(file, "utf8"); const where = relative(SRC, file).split("\\").join("/"); - for (const m of text.matchAll(/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g)) { - for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where }); + for (const re of WRITER_OBJECTS) { + for (const m of text.matchAll(re)) { + for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where }); + } } - for (const m of text.matchAll( - /new URLSearchParams\(window\.location\.search\)\.get\("(\w+)"\)/g, - )) { - out.push({ key: m[1]!, where }); + if (!text.includes("location.search")) continue; + for (const m of text.matchAll(/(\b\w+|\))\.(?:get|has)\("(\w+)"\)/g)) { + if (m[1] !== "hash") out.push({ key: m[2]!, where }); } } return out; } +// A key a writer computes itself (`[KEY]: …`) is one no scan can read: outside lib/ (the codecs, and +// the history plumbing they feed) a writer hands the address an encoder's result instead, and the +// encoder is what WRITES calls. +function computedWriterKeys(inLib = false): string[] { + const out: string[] = []; + for (const file of sources(SRC)) { + const where = relative(SRC, file).split("\\").join("/"); + if (where.startsWith("lib/") !== inLib) continue; + const text = readFileSync(file, "utf8"); + for (const re of WRITER_OBJECTS) { + for (const m of text.matchAll(re)) { + if (/[{,]\s*\[/.test(`{${m[1]!}`)) out.push(`${where}: ${m[0]!.slice(0, 60)}`); + } + } + } + return out; +} + +// A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState +// RETIRED) — no module writes them any more. +const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"]; + describe("module URL keys", () => { const owner = new Map(); for (const page of REGISTERED_PAGES) { @@ -154,14 +192,62 @@ describe("module URL keys", () => { it("cover every key literal the app reads or writes in the address bar", () => { const shell: readonly string[] = SHELL_KEYS; const undeclared = queryKeyLiterals().filter( - ({ key }) => !owner.has(key) && !shell.includes(key), + ({ key }) => !owner.has(key) && !shell.includes(key) && !RETIRED_LINK_KEYS.includes(key), ); expect(undeclared).toEqual([]); }); it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { const seen = new Set(queryKeyLiterals().map(({ key }) => key)); - for (const key of ["pl", "page", "rjob", "link"]) expect(seen).toContain(key); + // A writer's literal, a one-line read, a two-line read, a retired link's. + for (const key of ["page", "rjob", "link", "plq", "folder", "lq"]) expect(seen).toContain(key); + }); + + it("are written outside lib/ only through the codecs that WRITES calls", () => { + expect(computedWriterKeys()).toEqual([]); + }); + + it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { + expect(computedWriterKeys(true).some((w) => w.startsWith("lib/history.ts"))).toBe(true); + }); + + // What a search box puts in the address is what the user typed: the client log keeps it out below + // its most detailed level (lib/logHooks), from these declarations. + it("declare their typed-text keys among their own", () => { + for (const page of REGISTERED_PAGES) { + const def = moduleDef(page); + expect( + (def.typedUrlKeys ?? []).filter((k) => !def.urlKeys.includes(k)), + page, + ).toEqual([]); + } + expect([...typedUrlKeys()].sort()).toEqual(["aq", "cq", "fq", "plq", "pq", "q", "yt"]); + // …which the client log is handed as it installs. + expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain( + "installClientLog(appCopyOf, typedUrlKeys());", + ); + }); +}); + +// The bookmark net (e2e/bookmark.spec.ts) opens every page that names its view, through the rail or +// by its address — Plex excepted, by name there: its view is the instance's own library. +describe("the bookmark net", () => { + const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8"); + const opened = (page: Page) => + spec.includes(`page=${page}`) || + spec.includes(`gotoPage(page, "${page}")`) || + spec.includes(`gotoAdminPage(page, "${page}")`); + + it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))( + "takes a round trip through %s", + (page) => { + expect(opened(page)).toBe(true); + }, + ); + + it("names the page it leaves out", () => { + expect(opened("plex")).toBe(false); + expect(spec).toContain("Not Plex:"); }); }); diff --git a/frontend/src/modules/activity/Activity.dom.test.tsx b/frontend/src/modules/activity/Activity.dom.test.tsx index e2883d37..11b2f66c 100644 --- a/frontend/src/modules/activity/Activity.dom.test.tsx +++ b/frontend/src/modules/activity/Activity.dom.test.tsx @@ -297,7 +297,9 @@ describe("Activity", () => { it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => { // A browser runs the microtasks between two popstate listeners, so each listener's update - // renders on its own; a test dispatch does not. Each listener runs in its own act here. + // renders on its own; a test dispatch does not. Each listener runs in its own act here — a + // synchronous one: a browser runs no TASK between them (an async act let a timer set by one + // listener fire before the next). const listeners: EventListener[] = []; const addOther = window.addEventListener.bind(window); const removeOther = window.removeEventListener.bind(window); @@ -316,7 +318,7 @@ describe("Activity", () => { // Forward onto a pivot's entry: one run's lines over the log's whole retention. window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41"); for (const l of [...listeners]) { - await act(async () => l(new PopStateEvent("popstate"))); + act(() => l(new PopStateEvent("popstate"))); } await waitFor(() => expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null), diff --git a/frontend/src/modules/activity/module.ts b/frontend/src/modules/activity/module.ts index fa95fe6b..2a27cf5c 100644 --- a/frontend/src/modules/activity/module.ts +++ b/frontend/src/modules/activity/module.ts @@ -14,4 +14,5 @@ export const activityModule: ModuleDef = { gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./Activity")), urlKeys: ACTIVITY_KEYS, + typedUrlKeys: ["aq"], }; diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index 93b63b8e..fd411622 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -15,4 +15,5 @@ export const channelsModule: ModuleDef = { maxWidth: "96rem", Store: ChannelsProvider, urlKeys: CHANNELS_KEYS, + typedUrlKeys: ["cq"], }; diff --git a/frontend/src/modules/config/ConfigPanel.tsx b/frontend/src/modules/config/ConfigPanel.tsx index 3727db4a..f183596b 100644 --- a/frontend/src/modules/config/ConfigPanel.tsx +++ b/frontend/src/modules/config/ConfigPanel.tsx @@ -16,7 +16,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle"; import FilterSelect from "../../components/ui/FilterSelect"; import { DraftSaveBar, type SaveState } from "../../components/ui/DraftSaveBar"; import { LS, useTabPersistedState } from "../../lib/storage"; -import { CONFIG_TAB_KEY } from "../../lib/configTab"; +import { CONFIG_TAB_KEY, configTabParams } from "../../lib/configTab"; import { useUrlMirror } from "../../lib/useUrlMirror"; import { notifyActionError } from "../../lib/actionErrors"; import { Button } from "../../components/ui/Button"; @@ -192,7 +192,7 @@ export default function ConfigPanel() { const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0]; // The address names the group on screen (always: see SettingsPanel); until the registry is in, the // one it was opened on. - useUrlMirror({ [CONFIG_TAB_KEY]: (data ? activeGroup : tab) || null }); + useUrlMirror(configTabParams((data ? activeGroup : tab) || null)); if (q.isError && !data) { return ( diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index baf69e7a..83d85d7b 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -503,6 +503,10 @@ export default function Feed({ const saved = savedOverrides[v.id]; return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v; }); + // The live search's cards, in the order picked for them, and the player's queue over them. + const ytShown = (rows: Video[]): Video[] => + sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang); + const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden"); const merged: Video[] = withOverrides(loaded); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the @@ -511,16 +515,18 @@ export default function Feed({ // goes hidden still drops out ("visible affects"). const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden"); // A reload or a bookmark of the player reopens it once this list is in, the list as its queue again - // (the feed's filters are in the address under it). Not over a live search: that is not in the - // address, and a reload leaves it. + // (the feed's filters are in the address under it) — over a live search too, its results the + // queue, once they are in (or the search waits for its press, and it plays alone). Scoped to the + // list it was linked over: leaving the search first gives it up. useOpenLinkedPlayer( - !ytActive && !query.isLoading, - (id) => playerQueue.find((v) => v.id === id), + ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading, + (id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id), (video, inList) => { openVideo(video); // Not in what this list has loaded (further down it, or no longer in it): it plays alone. if (!inList) setActiveVideo((a) => a && { ...a, alone: true }); }, + ytActive ? `yt:${ytSearch}` : "feed", ); // A live search ingests new catalog videos, so the disabled feed queries still hold their stale @@ -534,12 +540,7 @@ export default function Feed({ // A separate data source rendered in the same cards/player. No watch-state view filter (the // user explicitly searched, so show everything) and no auto-pagination (each page costs quota). if (ytActive) { - const ytItems: Video[] = sortYtResults( - withOverrides(ytLoaded, true), - ytSort.key as SortKey, - ytSort.dir, - lang, - ); + const ytItems: Video[] = ytShown(ytLoaded); const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytError = ytQuery.error instanceof HttpError @@ -704,7 +705,7 @@ export default function Feed({ ({ ), })); +vi.mock("../../features/player/PlayerModal", () => ({ + default: ({ video, queue }: { video: { id: string }; queue?: { id: string }[] }) => ( +

+ playing {video.id} in {queue ? queue.map((v) => v.id).join(",") : "no list"} +

+ ), +})); + import Feed from "./Feed"; import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; @@ -48,6 +56,7 @@ beforeAll(() => { afterEach(() => { cleanup(); vi.restoreAllMocks(); + window.history.replaceState(null, "", "/"); }); const FILTERS: FeedFilters = { @@ -106,14 +115,14 @@ function mount({ search_source: source, search_enabled: enabled, }); - const tree = (count: number) => ( + const tree = (count: number, ytSearch: string | null = "lofi") => ( {}} canRead - ytSearch="lofi" + ytSearch={ytSearch} onYtSearch={() => {}} onExitYtSearch={() => {}} /> @@ -133,7 +142,10 @@ function mount({ ); const view = render(tree(20)); - return { showing: (count: number) => view.rerender(tree(count)) }; + return { + showing: (count: number) => view.rerender(tree(count)), + leave: () => view.rerender(tree(20, null)), + }; } const settle = () => new Promise((r) => setTimeout(r, 30)); @@ -216,3 +228,41 @@ describe("Feed — a live search the address reopened", () => { expect(screen.queryByRole("button", { name: "feed.yt.runAgain" })).toBeNull(); }); }); + +// A player opened over the search names itself in the address too: a reload or a bookmark opens it +// again over the search it was opened from — not later, over a list nobody linked. +describe("Feed — a player the address names over a live search", () => { + it("reopens over the results, them as its queue", async () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b"); + vi.spyOn(api, "searchYoutube").mockResolvedValue({ + items: [hit("a"), hit("b")], + next_cursor: null, + source: "scrape", + } as never); + const row = vi.spyOn(api, "videoRow"); + mount({ source: "scrape", asked: false }); + expect(await screen.findByText("playing b in a,b")).toBeTruthy(); + expect(row).not.toHaveBeenCalled(); + }); + + it("plays alone over a search that waits for its press", async () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b"); + vi.spyOn(api, "videoRow").mockResolvedValue(hit("b")); + mount({ source: "api", asked: false }); + expect(await screen.findByText("playing b in no list")).toBeTruthy(); + }); + + it("is given up when the search is left before it opened, and leaves the address", async () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi&watch=b"); + vi.spyOn(api, "searchYoutube").mockReturnValue(new Promise(() => {})); + vi.spyOn(api, "feed").mockResolvedValue({ items: [hit("b")], next_cursor: null } as never); + const row = vi.spyOn(api, "videoRow").mockResolvedValue(hit("b")); + const { leave } = mount({ source: "scrape", asked: false }); + leave(); + await settle(); + await settle(); + expect(screen.queryByText(/^playing/)).toBeNull(); + expect(row).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get("watch")).toBeNull(); + }); +}); diff --git a/frontend/src/modules/feed/FeedFiltersProvider.tsx b/frontend/src/modules/feed/FeedFiltersProvider.tsx index f2b366db..98e5526f 100644 --- a/frontend/src/modules/feed/FeedFiltersProvider.tsx +++ b/frontend/src/modules/feed/FeedFiltersProvider.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react import { type FeedFilters } from "../../lib/api"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../../lib/urlState"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { useDebounced } from "../../lib/useDebounced"; import { ownKey, writeJSON } from "../../lib/storage"; import { logFilterChange } from "../../lib/filterLog"; import { useNavigation } from "../../features/navigation/navigationContext"; @@ -56,8 +57,11 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { // keys are cleared from the URL (localStorage still holds them; they re-mirror on return to the // feed). The channel page keeps its own isolated filters, in keys of its own. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the // other providers own. + // The search text once the typing pauses, as the other pages' searches: one address-bar write per + // keystroke is churn a browser throttles. const nav = useNavigation(); - useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? filters : null)); + const typedQ = useDebounced(filters.q ?? "", 300); + useUrlMirror(filterParamUpdates(urlPage(nav) === "feed" ? { ...filters, q: typedQ } : null)); // A share link's filters (captured at mount) win over the account's stored view and are persisted // to it once, so the view the link opened also survives a later plain reload. diff --git a/frontend/src/modules/feed/FeedFiltersProvider.url.dom.test.tsx b/frontend/src/modules/feed/FeedFiltersProvider.url.dom.test.tsx new file mode 100644 index 00000000..277f8663 --- /dev/null +++ b/frontend/src/modules/feed/FeedFiltersProvider.url.dom.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +// +// The feed's address names its view. The view and the order always (defaults too): an address naming +// none falls back to the view the account stored, so a bookmark of the default feed reopened whatever +// view was stored since. The search text once the typing pauses, as the other pages' searches. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NavigationProvider } from "../../features/navigation/NavigationProvider"; +import { FeedFiltersProvider } from "./FeedFiltersProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; +import { qk } from "../../lib/queryKeys"; +import { claimLocalData, LS } from "../../lib/storage"; +import type { FeedFilters } from "../../lib/api"; + +const seen: { filters: FeedFilters | null; set: ((f: FeedFilters) => void) | null } = { + filters: null, + set: null, +}; +function Probe() { + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); + useEffect(() => { + seen.filters = filters; + seen.set = setFilters; + }); + return null; +} + +function mount() { + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 5, preferences: {} }); + render( + + + + + + + , + ); +} + +const search = () => new URLSearchParams(window.location.search); + +beforeEach(() => { + localStorage.clear(); + claimLocalData(5); +}); +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("FeedFiltersProvider · the address", () => { + it("names the default view and order", () => { + window.history.replaceState(null, "", "/?page=feed"); + mount(); + expect([search().get("show"), search().get("sort")]).toEqual(["unwatched", "newest"]); + }); + + it("a bookmark of the default feed opens it, not the view the account stored since", () => { + localStorage.setItem(LS.filters, JSON.stringify({ show: "all", includeShorts: true })); + window.history.replaceState(null, "", "/?page=feed&show=unwatched&sort=newest"); + mount(); + expect(seen.filters!.show).toBe("unwatched"); + expect(seen.filters!.includeShorts).toBe(false); + }); + + it("names the search once the typing pauses", async () => { + window.history.replaceState(null, "", "/?page=feed"); + mount(); + act(() => seen.set!({ ...seen.filters!, q: "lo" })); + act(() => seen.set!({ ...seen.filters!, q: "lofi" })); + expect(search().get("q")).toBeNull(); + await act(async () => { + await new Promise((r) => setTimeout(r, 350)); + }); + expect(search().get("q")).toBe("lofi"); + }); +}); diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index ff1fb34c..726c7721 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -19,6 +19,7 @@ export const feedModule: ModuleDef = { Store: FeedStore, // Its filters, and the live YouTube search shown in its place (lib/ytSearchUrlState). urlKeys: [...FEED_KEYS, ...YT_SEARCH_KEYS], + typedUrlKeys: ["q", "yt"], playsVideos: true, }; diff --git a/frontend/src/modules/files/FilePreview.tsx b/frontend/src/modules/files/FilePreview.tsx index 482fd2dc..4de3dace 100644 --- a/frontend/src/modules/files/FilePreview.tsx +++ b/frontend/src/modules/files/FilePreview.tsx @@ -14,7 +14,7 @@ import { formatBytes } from "../../lib/format"; import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { useLayer } from "../../lib/layerStack"; import { qk } from "../../lib/queryKeys"; -import { FILES_VIEW_KEY } from "../../lib/filesUrlState"; +import { filesOpenParams } from "../../lib/filesUrlState"; import { jobTitle } from "../../features/downloads/downloadTitle"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { ContextMenu } from "../../components/ui/ContextMenu"; @@ -129,7 +129,7 @@ export default function FilePreview({ title={{title}} onClose={onClose} maxWidth="max-w-6xl" - url={{ [FILES_VIEW_KEY]: String(file.id) }} + url={filesOpenParams({ open: "view", id: file.id })} headerActions={ <> { // Said once: the address no longer names it, so a reload or a copied link does not say it again. expect(here().has("fview")).toBe(false); }); + + // A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not + // among the ones listed, and keeps the link. + it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => { + // The case before closed a viewer: its step back lands a moment later, on whatever is current. + await new Promise((r) => setTimeout(r, 30)); + window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999"); + mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); + mocks.folders = [YT]; + mount(); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "info", message: "files.arrival.notListed" }), + ), + ); + expect(mocks.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "files.linkedGone" }), + ); + expect(here().get("fview")).toBe("9999"); + }, 30_000); // a full page of FILES_LIST_CAP rows }); describe("Files · the view", () => { diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index caf9e2fc..5ba1dc73 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -25,11 +25,12 @@ import { LS, ownKey } from "../../lib/storage"; import { useCardPager } from "../../lib/useCardPager"; import { useDebounced } from "../../lib/useDebounced"; import { useUrlMirror } from "../../lib/useUrlMirror"; -import { dropFromAddress } from "../../lib/urlState"; +import { clearParams } from "../../lib/urlState"; import { FILES_PLAY_KEY, FILES_SORT_KEY, FILES_VIEW_KEY, + filesSortParams, openFileFrom, } from "../../lib/filesUrlState"; import { useLiveQuery } from "../../lib/useLiveQuery"; @@ -231,7 +232,7 @@ export default function Files() { setLinkedSort(undefined); setPrefSort(next); }; - useUrlMirror({ [FILES_SORT_KEY]: encodeSortParamOrNone(sort) }); + useUrlMirror(filesSortParams(encodeSortParamOrNone(sort))); const params = listParams(shown); // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); @@ -286,15 +287,17 @@ export default function Files() { const [viewing, setViewing] = useState(null); // A reload or a bookmark reopens the file the address names — in the viewer or the player — once // the list it is in has come (its folder, search and sort are in the address too). Once; a file the - // list no longer holds is said, and taken off the address. + // list no longer holds is said, and taken off the address — unless the cap cut the list, which then + // cannot tell a deleted file from one beyond its end (as an arrival's notice says): the link stays. const [linkedFile] = useState(() => openFileFrom(new URLSearchParams(window.location.search))); const linkedDone = useRef(linkedFile == null); const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined; const openLinked = useEffectEvent((file: LibraryFile | undefined) => { - if (!file) { + if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") }); + else if (!file) { notify({ level: "warning", message: t("files.linkedGone") }); - dropFromAddress([FILES_VIEW_KEY, FILES_PLAY_KEY]); + clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); } else if (linkedFile?.open === "play") play(file); else setViewing(file.id); }); diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 7e9d5b7a..2f65dfcd 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -2,7 +2,12 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { useUrlMirror } from "../../lib/useUrlMirror"; import { useDebounced } from "../../lib/useDebounced"; -import { FILES_FOLDER_KEY, FILES_ITEM_KEY, FILES_SEARCH_KEY } from "../../lib/filesUrlState"; +import { + FILES_FOLDER_KEY, + FILES_ITEM_KEY, + FILES_SEARCH_KEY, + filesListParams, +} from "../../lib/filesUrlState"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { urlPage } from "../../features/navigation/urlPage"; import { @@ -24,8 +29,8 @@ import { // read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in // main.tsx around . // -// The search lives in the address alone (`?fq=`): a reload or a bookmark opens it searched, and a trip -// back to the page through the rail opens it unsearched. The view is remembered per account and +// The search is named in the address (`?fq=`), so a reload or a bookmark opens it searched, and ends +// when the page is left: a trip back to the page through the rail opens it unsearched. The view is remembered per account and // mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. A // `?folder=` in the address wins over the remembered one. A folder that no longer exists is the // page's to fall back from (it knows the folder list). @@ -89,19 +94,21 @@ export function FilesProvider({ children }: { children: ReactNode }) { ); // Only ON the Files page, and only until the page has used it. const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null; - // Leaving the page ends the arrival: back on it later, nothing is rung or turned to. + // Leaving the page ends the arrival — back on it later, nothing is rung or turned to — and the + // search: the page opens unsearched again. if (page !== "files" && arrivedRaw) setArrivedRaw(null); + if (page !== "files" && search) setSearchState(""); const arrived = page === "files" ? arrivedRaw : null; const shown = urlPage(nav) === "files"; // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); // an arrival's cleared box at once. const typed = useDebounced(search.trim(), 300, focusRaw?.token); - useUrlMirror({ - [FILES_SEARCH_KEY]: shown && typed ? typed : null, - [FILES_FOLDER_KEY]: shown ? viewParam(view) : null, - [FILES_ITEM_KEY]: shown && focus ? String(focus.id) : null, - }); + useUrlMirror( + filesListParams( + shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null, + ), + ); const state = useMemo( () => ({ search, view, focus, arrived }), diff --git a/frontend/src/modules/files/LibraryPlayer.tsx b/frontend/src/modules/files/LibraryPlayer.tsx index 379b0fe2..0f3ea7a7 100644 --- a/frontend/src/modules/files/LibraryPlayer.tsx +++ b/frontend/src/modules/files/LibraryPlayer.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import Modal from "../../components/ui/Modal"; import { api, type DownloadJob } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; -import { FILES_PLAY_KEY } from "../../lib/filesUrlState"; +import { filesOpenParams } from "../../lib/filesUrlState"; import { jobTitle } from "../../features/downloads/downloadTitle"; import { exposeVideoToBackgroundPlay } from "../../lib/browserBackgroundPlay"; import { useMediaLogRef } from "../../lib/mediaLog"; @@ -97,7 +97,7 @@ export default function LibraryPlayer({ title={title} onClose={onClose} maxWidth="max-w-4xl" - url={{ [FILES_PLAY_KEY]: String(job.id) }} + url={filesOpenParams({ open: "play", id: job.id })} > {isAudio ? (
diff --git a/frontend/src/modules/files/module.ts b/frontend/src/modules/files/module.ts index 5cef1c49..e3c2fc06 100644 --- a/frontend/src/modules/files/module.ts +++ b/frontend/src/modules/files/module.ts @@ -18,4 +18,5 @@ export const filesModule: ModuleDef = { maxWidth: "80rem", Store: FilesProvider, urlKeys: FILES_KEYS, + typedUrlKeys: ["fq"], }; diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 2fc7683c..4beca9c7 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -211,6 +211,32 @@ describe("Playlists — a player the address names", () => { expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); expect(h.player!.onRemoveFromList).toBeUndefined(); }); + + it("plays alone over a playlist emptied since", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); + vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); + mount({ 1: [] }); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); + }); + + it("is given up when another playlist is picked before it opened, and leaves the address", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); + const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); + // The linked playlist's read never answers: the opener is still waiting when the pick lands. + const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("B", 0)] }); + vi.mocked(api.playlist).mockImplementation(async (id: number) => + id === 1 ? new Promise(() => {}) : (list(id, [vid("B", 0)]) as never), + ); + qc.removeQueries({ queryKey: qk.playlist(1) }); + h.selectedId = 2; + rerender(); + await screen.findByText("Video B"); + await new Promise((r) => setTimeout(r, 30)); + expect(h.player).toBeNull(); + expect(row).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get("watch")).toBeNull(); + }); }); describe("Playlists — the row the player is launched with", () => { diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 787d6278..c28e86b9 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -583,14 +583,21 @@ function PlaylistDetail() { }); // A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue // again (the playlist and its view are in the address under it). A video the view does not show - // (filtered out, gone from the list) plays alone, and nothing removes it from a list. + // (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing + // removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its + // read refused. Scoped to the playlist it was linked over: another one picked first gives it up. + const linkedListIn = + (detail != null && (detail.items.length === 0 || items.length > 0)) || + (selectedId != null && detailQuery.isError) || + (selectedId == null && listQuery.data?.length === 0); useOpenLinkedPlayer( - detail != null && items.length > 0, + linkedListIn, (id) => shown.find((v) => v.id === id), (video, inList) => { if (inList) openPlayer(video.id); else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false }); }, + selectedId, ); async function moveItemTo(video: Video, target: Playlist) { diff --git a/frontend/src/modules/playlists/PlaylistsProvider.tsx b/frontend/src/modules/playlists/PlaylistsProvider.tsx index 5e90fa42..5b9b1d06 100644 --- a/frontend/src/modules/playlists/PlaylistsProvider.tsx +++ b/frontend/src/modules/playlists/PlaylistsProvider.tsx @@ -5,6 +5,7 @@ import { useDebounced } from "../../lib/useDebounced"; import { useNavigation } from "../../features/navigation/navigationContext"; import { urlPage } from "../../features/navigation/urlPage"; import { readSelectedId } from "./playlistsInit"; +import { playlistsParams } from "../../lib/playlistsUrlState"; import { PlaylistsActionsContext, PlaylistsStateContext, @@ -18,8 +19,8 @@ import { // FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed // in main.tsx around ). // -// The `search` term lives in the address alone (`?plq=`, a reload or a bookmark keeps it, a trip back -// through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so +// The `search` term is named in the address (`?plq=`, a reload or a bookmark keeps it) and ends when +// the page is left (a trip back through the rail starts unsearched); the selected playlist is per-account persisted (LS.playlist) so // a reload returns you to it, and named in the address too. Neither is server-synced, and an account switch does a full reload, so init-from-storage is all the // hydration these need — no per-account load effect (unlike the server-backed feed filters). // @@ -43,11 +44,10 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { // The search once the typing pauses: one address-bar write per keystroke is churn a browser throttles. const nav = useNavigation(); const shown = urlPage(nav) === "playlists"; + // Leaving the page ends the search: adjusted during the render that leaves it. + if (nav.page !== "playlists" && search) setSearchState(""); const typed = useDebounced(search.trim(), 300); - useUrlMirror({ - pl: shown && selectedId != null ? String(selectedId) : null, - plq: shown && typed ? typed : null, - }); + useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, "")); const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); const actions = useMemo( diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index 9b88fe87..fb773d1c 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -19,5 +19,6 @@ export const playlistsModule: ModuleDef = { Store: PlaylistsProvider, // The playlist shown (PlaylistsProvider, playlistsInit). urlKeys: PLAYLISTS_KEYS, + typedUrlKeys: ["plq"], playsVideos: true, }; diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts index 697c8126..458d782b 100644 --- a/frontend/src/modules/plex/module.ts +++ b/frontend/src/modules/plex/module.ts @@ -19,4 +19,5 @@ export const plexModule: ModuleDef = { maxWidth: "100rem", Store: PlexStore, urlKeys: [...PLEX_KEYS, ...PLEX_SEARCH_KEYS, PLEX_SUB_KEY], + typedUrlKeys: ["pq"], }; diff --git a/frontend/src/modules/settings/SettingsPanel.tsx b/frontend/src/modules/settings/SettingsPanel.tsx index 564c4f79..dc14ec49 100644 --- a/frontend/src/modules/settings/SettingsPanel.tsx +++ b/frontend/src/modules/settings/SettingsPanel.tsx @@ -26,7 +26,7 @@ import { api, HttpError, type Me } from "../../lib/api"; import { displayName, roleKey } from "../../lib/api/account"; import { notifyActionError } from "../../lib/actionErrors"; import { LS, useTabPersistedState } from "../../lib/storage"; -import { SETTINGS_TAB_KEY } from "../../lib/settingsTab"; +import { SETTINGS_TAB_KEY, settingsTabParams } from "../../lib/settingsTab"; import { useUrlMirror } from "../../lib/useUrlMirror"; import Avatar from "../../components/ui/Avatar"; import { notify, type NotifSettings } from "../../lib/notifications"; @@ -80,7 +80,7 @@ export default function SettingsPanel() { : "appearance"; // The address names the tab on screen, so a bookmark opens it (always written: without it, the page // opens on the tab this browser tab last showed). - useUrlMirror({ [SETTINGS_TAB_KEY]: tab }); + useUrlMirror(settingsTabParams(tab)); return (
diff --git a/frontend/src/modules/users/AdminUsers.tsx b/frontend/src/modules/users/AdminUsers.tsx index 811ffc19..8c5a39e5 100644 --- a/frontend/src/modules/users/AdminUsers.tsx +++ b/frontend/src/modules/users/AdminUsers.tsx @@ -27,7 +27,7 @@ import { displayName } from "../../lib/api/account"; import { notify } from "../../lib/notifications"; import { formatDateTime, relativeTime } from "../../lib/format"; import { invalidateUserAccess } from "../../lib/adminUsers"; -import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY } from "../../lib/adminUsersTab"; +import { ADMIN_USERS_TAB_KEY, USERS_TAB_URL_KEY, usersTabParams } from "../../lib/adminUsersTab"; import { Badge } from "../../components/ui/Badge"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; @@ -89,7 +89,7 @@ export default function AdminUsers() { ]; const active = tabs.some((x) => x.id === tab) ? tab : "roles"; // The address names the tab on screen (always: see SettingsPanel). - useUrlMirror({ [USERS_TAB_URL_KEY]: active }); + useUrlMirror(usersTabParams(active)); return ( <> {/* The tab bar is fixed chrome; the tab content scrolls under it. */} From 6d57d62ac898cb1299b8f87939ee38832d31aa0a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 16:08:52 +0100 Subject: [PATCH 09/15] fix(nav): a linked player, file and search survive what the app does by itself (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button. --- docs/development.md | 10 +- frontend/e2e/bookmark.spec.ts | 179 ++++++++-------- ...ationProvider.reloadedRewrite.dom.test.tsx | 57 +++++ .../navigation/NavigationProvider.tsx | 37 ++-- .../NavigationProvider.ytSearch.dom.test.tsx | 28 +++ .../features/navigation/navigationContext.ts | 2 +- .../player/useOpenLinkedPlayer.dom.test.tsx | 54 +++++ .../features/player/useOpenLinkedPlayer.ts | 30 ++- frontend/src/i18n/locales/en/feed.json | 4 +- frontend/src/i18n/locales/en/files.json | 1 + frontend/src/i18n/locales/hu/feed.json | 4 +- frontend/src/i18n/locales/hu/files.json | 1 + frontend/src/lib/api.ts | 2 +- frontend/src/lib/api/feed.ts | 5 + frontend/src/lib/filterMapParam.ts | 5 +- .../history.reloadedNestedBack.dom.test.tsx | 37 ++++ .../history.reloadedNestedEdge.dom.test.tsx | 43 ++++ frontend/src/lib/history.ts | 17 +- frontend/src/lib/logHooks.dom.test.ts | 4 +- frontend/src/lib/logHooks.ts | 8 +- frontend/src/lib/moduleDef.ts | 6 +- frontend/src/lib/urlState.ts | 14 +- frontend/src/lib/useActiveFilters.ts | 14 +- frontend/src/moduleSearch.url.dom.test.tsx | 2 +- frontend/src/moduleUrl.contract.test.ts | 197 +++++++++++++++++- frontend/src/modules/channels/module.ts | 2 +- frontend/src/modules/feed/Feed.tsx | 10 +- .../modules/feed/Feed.ytLoadMore.dom.test.tsx | 2 +- .../modules/feed/Feed.ytReopen.dom.test.tsx | 2 +- frontend/src/modules/feed/feedFiltersInit.ts | 13 +- .../src/modules/files/Files.menu.dom.test.tsx | 26 ++- frontend/src/modules/files/Files.tsx | 4 +- frontend/src/modules/files/FilesProvider.tsx | 6 +- .../modules/playlists/Playlists.dom.test.tsx | 48 ++++- frontend/src/modules/playlists/Playlists.tsx | 18 +- .../modules/playlists/PlaylistsProvider.tsx | 4 +- frontend/src/modules/playlists/module.ts | 2 +- frontend/src/modules/scheduler/Scheduler.tsx | 6 +- 38 files changed, 717 insertions(+), 187 deletions(-) create mode 100644 frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx create mode 100644 frontend/src/lib/history.reloadedNestedBack.dom.test.tsx create mode 100644 frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx diff --git a/docs/development.md b/docs/development.md index a5d4be5e..465b327c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,8 +53,9 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in or always written, when an address naming none falls back to a view remembered elsewhere), written with `useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s), always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount - (a link wins over anything remembered). A search box's key is also in `typedUrlKeys` (the client log keeps - what was typed out). Add its round trip to `e2e/bookmark.spec.ts`. Only what an address cannot promise to + (a link wins over anything remembered). A key that carries typed text — a search box's, a column filter + map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round + trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@" }`). Only what an address cannot promise to reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`). 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's hooks). Another folder reaches the module through this file or not at all. @@ -69,8 +70,9 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), `moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key -belongs to one module and matches what its codec writes; a writer outside `lib/` computes no key; no key literal -written or read from the live query undeclared; every page with keys in the bookmark net), the `local/layers` lint rule (imports only go +belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/` +function's result, never a key of its own; no key literal written or read from the live query undeclared; every key +sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the i18n parity test. diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index fbf8d45a..730ae30a 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -11,9 +11,9 @@ import { // The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW // browser context, so nothing this tab remembered (its history entries, sessionStorage, the view // storage it wrote) can stand in for what the address says. One round trip per page that names its -// view (moduleUrl.contract.test holds the list to the module definitions), plus the overlays the -// shell opens over any page (the channel page, the player) and the live search. Not Plex: what it -// shows is the instance's own library, which a test stack may not have. +// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions), +// plus the overlays the shell opens over any page (the channel page, the player) and the live +// search. Not Plex: what it shows is the instance's own library, which a test stack may not have. const STORAGE_STATE = "e2e/.auth/state.json"; @@ -37,7 +37,7 @@ async function gotoAdminPage(page: Page, id: string): Promise { } test.describe("a bookmark reopens the view it was taken of", () => { - test("the feed's filters", async ({ page, browser }) => { + test("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await toggleContentType(page, "includeLive"); await expect(page).toHaveURL(/[?&]live=/); @@ -46,7 +46,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("Home's time budget", async ({ page, browser }) => { + test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => { await openApp(page, "/?page=home"); await page.getByRole("button", { name: "An evening", exact: true }).click(); await expect(page).toHaveURL(/[?&]hb=long/); @@ -58,7 +58,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("a Settings tab", async ({ page, browser }) => { + test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoPage(page, "settings"); await tab(page, "Notifications").click(); @@ -67,7 +67,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("an admin page's tab", async ({ page, browser }) => { + test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoAdminPage(page, "users"); await tab(page, "Access requests").click(); @@ -76,7 +76,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("a Configuration group", async ({ page, browser }) => { + test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoAdminPage(page, "config"); const second = page.getByRole("tab").nth(1); @@ -91,7 +91,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("the Activity tab and its time window", async ({ page, browser }) => { + test("the Activity tab and its time window", { tag: "@activity" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoAdminPage(page, "activity"); await tab(page, "Server log").click(); @@ -106,7 +106,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("a playlist and its sort", async ({ page, browser }) => { + test("a playlist and its sort", { tag: "@playlists" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoPage(page, "playlists"); await expect(column(page, "Title")).toBeVisible(); @@ -121,19 +121,23 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - test("the Channels manager's tab and name search", async ({ page, browser }) => { - await openApp(page, "/?page=feed"); - await gotoPage(page, "channels"); - await tab(page, "Hidden").click(); - await page.getByPlaceholder("Search channels…").fill("zz"); - await expect(page).toHaveURL(/zz/); // written once the typing settles - const fresh = await bookmarked(browser, page); - await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true"); - await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz"); - await fresh.context().close(); - }); + test( + "the Channels manager's tab and name search", + { tag: "@channels" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + await gotoPage(page, "channels"); + await tab(page, "Hidden").click(); + await page.getByPlaceholder("Search channels…").fill("zz"); + await expect(page).toHaveURL(/zz/); // written once the typing settles + const fresh = await bookmarked(browser, page); + await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true"); + await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz"); + await fresh.context().close(); + }, + ); - test("the Files search", async ({ page, browser }) => { + test("the Files search", { tag: "@files" }, async ({ page, browser }) => { await openApp(page, "/?page=feed"); await gotoPage(page, "files"); await page.getByPlaceholder("Search files…").fill("zz"); @@ -145,35 +149,42 @@ test.describe("a bookmark reopens the view it was taken of", () => { // A file of the test's own, uploaded for it and deleted after: the account's library is left as // it was found. - test("the Files sort, and the file open in the viewer", async ({ page, browser }) => { - await openApp(page, "/?page=feed"); - const NAME = "bookmark-note"; - // One a run cut short left behind goes first. - const left = (await (await page.request.get("/api/files")).json()) as Record[]; - for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) - await page.request.delete(`/api/files/${String(f.id)}`); - const up = await page.request.post("/api/files/upload", { - headers: { "X-Upload-Filename": `${NAME}.txt` }, - data: Buffer.from("a note the bookmark reopens\n"), - }); - expect(up.ok(), await up.text()).toBeTruthy(); - const { id } = (await up.json()) as { id: number }; - try { - await gotoPage(page, "files"); - await column(page, "Name").getByRole("button").first().click(); - await expect(page).toHaveURL(/[?&]fsort=name/); - const sorted = await column(page, "Name").getAttribute("aria-sort"); - await page.getByText(NAME, { exact: true }).first().click(); - await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`)); - const fresh = await bookmarked(browser, page); - await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible(); - await fresh.keyboard.press("Escape"); - await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? ""); - await fresh.context().close(); - } finally { - await page.request.delete(`/api/files/${id}`); - } - }); + test( + "the Files sort, and the file open in the viewer", + { tag: "@files" }, + async ({ page, browser }) => { + await openApp(page, "/?page=feed"); + const NAME = "bookmark-note"; + // One a run cut short left behind goes first. + const left = (await (await page.request.get("/api/files")).json()) as Record< + string, + unknown + >[]; + for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`))) + await page.request.delete(`/api/files/${String(f.id)}`); + const up = await page.request.post("/api/files/upload", { + headers: { "X-Upload-Filename": `${NAME}.txt` }, + data: Buffer.from("a note the bookmark reopens\n"), + }); + expect(up.ok(), await up.text()).toBeTruthy(); + const { id } = (await up.json()) as { id: number }; + try { + await gotoPage(page, "files"); + await column(page, "Name").getByRole("button").first().click(); + await expect(page).toHaveURL(/[?&]fsort=name/); + const sorted = await column(page, "Name").getAttribute("aria-sort"); + await page.getByText(NAME, { exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`)); + const fresh = await bookmarked(browser, page); + await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible(); + await fresh.keyboard.press("Escape"); + await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? ""); + await fresh.context().close(); + } finally { + await page.request.delete(`/api/files/${id}`); + } + }, + ); test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { await openApp(page, "/?page=feed"); @@ -202,36 +213,40 @@ test.describe("a bookmark reopens the view it was taken of", () => { // test, and the request it answers is what the address named. The instance's search settings are // pinned too (the free source, switched on): a stack left on the API source reopens the search // waiting for its press, by design, and one with search off offers none. - test("a live YouTube search, its term and its count", async ({ page, browser }) => { - const answer = async (p: Page, seen: string[]) => { - await p.route("**/api/me", async (route) => { - const me = (await (await route.fetch()).json()) as Record; - return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); - }); - await p.route("**/api/search/youtube?**", (route) => { - seen.push(route.request().url()); - return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); - }); - }; - await answer(page, []); - await openApp(page, "/?page=feed"); - await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); - await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); - await expect(page.getByText("YouTube results for “lofi”")).toBeVisible(); - await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set - await page.getByRole("button", { name: "40", exact: true }).click(); - await expect(page).toHaveURL(/[?&]ytn=40/); + test( + "a live YouTube search, its term and its count", + { tag: "@feed" }, + async ({ page, browser }) => { + const answer = async (p: Page, seen: string[]) => { + await p.route("**/api/me", async (route) => { + const me = (await (await route.fetch()).json()) as Record; + return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } }); + }); + await p.route("**/api/search/youtube?**", (route) => { + seen.push(route.request().url()); + return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } }); + }); + }; + await answer(page, []); + await openApp(page, "/?page=feed"); + await page.getByPlaceholder("Search your subscriptions…").fill("lofi"); + await page.getByRole("button", { name: "Search YouTube", exact: true }).click(); + await expect(page.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set + await page.getByRole("button", { name: "40", exact: true }).click(); + await expect(page).toHaveURL(/[?&]ytn=40/); - const seen: string[] = []; - const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); - await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); - await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( - "aria-pressed", - "true", - ); - await expect.poll(() => seen.length).toBeGreaterThan(0); - const asked = new URL(seen[0]!).searchParams; - expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); - await fresh.context().close(); - }); + const seen: string[] = []; + const fresh = await bookmarked(browser, page, (p) => answer(p, seen)); + await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible(); + await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute( + "aria-pressed", + "true", + ); + await expect.poll(() => seen.length).toBeGreaterThan(0); + const asked = new URL(seen[0]!).searchParams; + expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]); + await fresh.context().close(); + }, + ); }); diff --git a/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx new file mode 100644 index 00000000..b104771d --- /dev/null +++ b/frontend/src/features/navigation/NavigationProvider.reloadedRewrite.dom.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +// +// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark +// still on it. A view rewritten in place there (the search refined) is another view: the next overlay +// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back +// onto the entry below, and the screen went back to the search before the refine. Its own file: the +// entry is read as the history module loads, so it is set up before the import. +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), +})); + +describe("a view rewritten in place on the entry a reload under a dialog started from", () => { + it("is where an overlay over it closes to", async () => { + window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs"); + window.history.pushState( + { sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 }, + "", + "/?page=feed&yt=dogs", + ); + const { NavigationProvider } = await import("./NavigationProvider"); + const { useNavigation, useNavigationActions } = await import("./navigationContext"); + const { useBackToClose } = await import("../../lib/history"); + const { qk } = await import("../../lib/queryKeys"); + const { claimLocalData } = await import("../../lib/storage"); + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, role: "admin" }); + claimLocalData(1); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); + act(() => nav.result.current.enterYtSearch("cats")); + + const depth = window.history.length; + const overlay = renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth + 1); + + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + overlay.unmount(); + await landed; + }); + expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats"); + expect(nav.result.current.ytSearch).toBe("cats"); + }, 30_000); // the provider's whole import graph loads inside the case (see the header) +}); diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index fa18c807..810497c8 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -17,6 +17,7 @@ import { nextNavId, notifyNav, rememberNavPosition, + replaceView, } from "../../lib/history"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; @@ -86,7 +87,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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), on the entries + // asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or // a Forward onto a search — runs only when it costs no quota (Feed decides). const [ytSearch, setYtSearch] = useState(() => @@ -136,7 +137,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { closeOverlaysThenNavigate(() => { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). - if (next === pageRef.current && !channelViewRef.current) { + // A live search on it is one too: the press opens the page's root, as over a channel. + if (next === pageRef.current && !channelViewRef.current && ytSearchRef.current == null) { then?.(); return; } @@ -161,7 +163,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { notifyNav(); then?.(); }), - [pageRef, channelViewRef, endYtSearch], + [pageRef, channelViewRef, ytSearchRef, endYtSearch], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands @@ -174,7 +176,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const url = channelUrl(id); if (window.history.state?._chan) { const st = window.history.state; - window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); + replaceView({ ...st, _chan: id, _chanName: name ?? null }, url); } else { const st = rememberNavPosition(); window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); @@ -193,9 +195,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // again: the channel's address carried none of it. setChannelView(null); const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; - window.history.replaceState( + replaceView( st, - "", urlWithParams({ ...closedChannelParams(), ...ytSearchParams(ytSearchRef.current, ytCountRef.current), @@ -212,11 +213,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { 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))), - ); + replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here)))); } else { // New sub-view entry, at the default count. setYtCountState(DEFAULT_YT_COUNT); @@ -235,9 +232,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const setYtCount = useCallback( (n: number) => { setYtCountState(n); - window.history.replaceState( - window.history.state, - "", + replaceView( + window.history.state ?? {}, urlWithParams(ytSearchParams(ytSearchRef.current, n)), ); }, @@ -251,7 +247,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { else { endYtSearch(); const { _yt: _q, ...st } = window.history.state ?? {}; - window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); + replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } }, [endYtSearch]); @@ -263,10 +259,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); - if (page !== "feed") { - setYtSearch(null); - setYtAsked(null); - } + if (page !== "feed") endYtSearch(); } // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): @@ -349,8 +342,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // 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. One landed on without it has ended: a Forward back // onto it is not asked for, and an API search waits for a press. + // A channel page opened from its results stands over it: its entry carries the search's mark + // (its address does not), and the search stays under it — closing a player over that channel + // lands there, and is no leaving of the search. const here = new URLSearchParams(window.location.search); - const landed = p === "feed" ? ytSearchFrom(here) : null; + const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null; + const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null; setYtSearch(landed); if (landed == null) setYtAsked(null); setYtCountState(ytCountFrom(here)); diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx index 353f402e..fd610561 100644 --- a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -227,6 +227,34 @@ describe("NavigationProvider · the YouTube search in the address", () => { expect(search().get("yt")).toBeNull(); }); + it("the Feed press on the search itself opens the plain feed, as over a channel", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + const depth = window.history.length; + act(() => result.current.setPage("feed")); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search + }); + + it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.openChannel("UC1")); + // A player over the channel closed: the step back lands on the channel's own entry. + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + expect(result.current.ytSearchAsked).toBe(true); + // Back off the channel: the search it stood over is still the one asked for. + window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi"); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearchAsked).toBe(true); + }); + it("a channel closed in place over the search names the search again", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); diff --git a/frontend/src/features/navigation/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts index ab173013..08d5aa7a 100644 --- a/frontend/src/features/navigation/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -39,7 +39,7 @@ export interface NavigationActions { closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; - /** Run the search on screen: the "Search YouTube again" press. */ + /** Run the search on screen: the "Search YouTube" press. */ askYtSearch: () => void; setYtCount: (n: number) => void; } diff --git a/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx b/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx index c9269080..1c19b974 100644 --- a/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx +++ b/frontend/src/features/player/useOpenLinkedPlayer.dom.test.tsx @@ -83,6 +83,60 @@ describe("useOpenLinkedPlayer", () => { expect(new URLSearchParams(window.location.search).has("watch")).toBe(false); }); + it("leaves a player opened while the row was on its way alone, and its address", async () => { + window.history.replaceState(null, "", "/?watch=Z"); + let answer: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r))); + const open = vi.fn(); + renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); + // A card clicked meanwhile: its player names itself in the address. + window.history.replaceState(null, "", "/?watch=Y"); + answer(vid("Z")); + await new Promise((r) => setTimeout(r, 20)); + expect(open).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); + }); + + it("does not clear the address of a player opened since when its catalog read fails", async () => { + window.history.replaceState(null, "", "/?watch=Z"); + let refuse: (e: Error) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r))); + renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper }); + window.history.replaceState(null, "", "/?watch=Y"); + refuse(new Error("not found")); + await new Promise((r) => setTimeout(r, 20)); + expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); + }); + + it("given up over a moved list, keeps the address of a player opened since", async () => { + window.history.replaceState(null, "", "/?watch=Z"); + const { rerender } = renderHook( + ({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope), + { wrapper, initialProps: { scope: 1 } }, + ); + window.history.replaceState(null, "", "/?watch=Y"); + rerender({ scope: 2 }); + await new Promise((r) => setTimeout(r, 20)); + expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y"); + }); + + it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => { + window.history.replaceState(null, "", "/?watch=B"); + const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B")); + const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined)); + const { rerender } = renderHook( + ({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()), + { wrapper, initialProps: { rows: true } }, + ); + await waitFor(() => expect(find).toHaveBeenCalled()); + find.mockClear(); + rerender({ rows: false }); + rerender({ rows: true }); + await new Promise((r) => setTimeout(r, 20)); + expect(find).not.toHaveBeenCalled(); + expect(row).not.toHaveBeenCalled(); + }); + it("opens nothing, and asks for nothing, for an address that names no player", async () => { window.history.replaceState(null, "", "/?q=talks"); const row = vi.spyOn(api, "videoRow"); diff --git a/frontend/src/features/player/useOpenLinkedPlayer.ts b/frontend/src/features/player/useOpenLinkedPlayer.ts index 4731b4a2..308f0f61 100644 --- a/frontend/src/features/player/useOpenLinkedPlayer.ts +++ b/frontend/src/features/player/useOpenLinkedPlayer.ts @@ -14,7 +14,8 @@ import { notifyActionError } from "../../lib/actionErrors"; * * `scope` names the list the address put it over (a playlist, a live search): when the host moves to * another before it opened, the address under it named something else, and the player is given up — - * opened later, it would start over a list nobody linked, unasked. */ + * opened later, it would start over a list nobody linked, unasked. So it is when a player opened + * meanwhile (a card clicked while the row was on its way): the address names that one now. */ export function useOpenLinkedPlayer( ready: boolean, find: (id: string) => Video | undefined, @@ -24,14 +25,17 @@ export function useOpenLinkedPlayer( const { t } = useTranslation(); const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linkedScope] = useState(scope); - // Opened (or said to be gone) once; a ref, as nothing on screen reads it. + // Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must + // not open it twice); the state stops the lookup and the row fetch on screen after it. const done = useRef(linked == null); + const [settled, setSettled] = useState(linked == null); const moved = scope !== linkedScope; - const listed = ready && !moved && linked != null ? find(linked) : undefined; + const waiting = ready && !moved && !settled && linked != null; + const listed = waiting ? find(linked) : undefined; const row = useQuery({ queryKey: qk.videoRow(linked ?? ""), queryFn: () => api.videoRow(linked!), - enabled: ready && !moved && linked != null && !listed, + enabled: waiting && !listed, retry: false, }); const openIt = useEffectEvent(open); @@ -41,12 +45,18 @@ export function useOpenLinkedPlayer( }); useEffect(() => { if (done.current) return; - if (moved) { + const settle = () => { done.current = true; - // The address goes on naming it under the new list (a reload would open it there) — unless a - // player opened since names its own. - if (linkedPlayer()?.watch === linked) clearParams(...PLAYER_KEYS); - return; + // State, not derived: a one-way decision taken as the list or the row arrives. Derived from + // `listed`, the lookup and the row fetch would come back whenever the video left the list. + setSettled(true); + }; + // A player opened since names its own: this one is not wanted any more. + if (linkedPlayer()?.watch !== linked) return settle(); + if (moved) { + // The address goes on naming it under the new list (a reload would open it there). + clearParams(...PLAYER_KEYS); + return settle(); } // Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills // it here too (a disabled query still reads the cache) — the shell's player over a page that @@ -56,6 +66,6 @@ export function useOpenLinkedPlayer( else if (row.data) openIt(row.data, false); else if (row.isError) failed(row.error); else return; - done.current = true; + settle(); }, [linked, moved, ready, listed, row.data, row.isError, row.error]); } diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 1572832c..07fb53f8 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -77,7 +77,7 @@ "cleared_other": "Cleared {{count}} results", "showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.", "notRunTitle": "Not searched yet", - "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link or Back doesn't start one by itself.", - "runAgain": "Search YouTube again" + "notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link, Back or Forward doesn't start one by itself.", + "runAgain": "Search YouTube" } } diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json index 8f7ab657..ff10fb41 100644 --- a/frontend/src/i18n/locales/en/files.json +++ b/frontend/src/i18n/locales/en/files.json @@ -1,5 +1,6 @@ { "linkedGone": "The file in the address is no longer in this list", + "linkedNotListed": "The file in the link isn't among the files listed here — it may be further down the list", "navLabel": "Files", "searchPlaceholder": "Search files…", "count_one": "{{count}} item", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index ca264e61..d76f9c49 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -77,7 +77,7 @@ "cleared_other": "{{count}} találat törölve", "showHint": "Hány találatot szerezzen egyszerre — a „Továbbiak betöltése” ennyivel tölt tovább.", "notRunTitle": "Ez a keresés még nem futott le", - "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről vagy visszalépéskor nem indul el magától.", - "runAgain": "Keresés újra a YouTube-on" + "notRunBody": "Egy YouTube API-keresés 100 egységet fogyaszt a közös napi kvótából, ezért újratöltéskor, linkről, vissza- vagy előrelépéskor nem indul el magától.", + "runAgain": "Keresés a YouTube-on" } } diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json index a6deff3d..88354b9a 100644 --- a/frontend/src/i18n/locales/hu/files.json +++ b/frontend/src/i18n/locales/hu/files.json @@ -1,5 +1,6 @@ { "linkedGone": "A linkben szereplő fájl már nincs ebben a listában", + "linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — lehet, hogy lejjebb van", "navLabel": "Fájlok", "searchPlaceholder": "Keresés a fájlok között…", "count_one": "{{count}} elem", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1fa83174..8f27f625 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,7 +20,7 @@ import { setupApi } from "./api/setup"; export type * from "./api/setup"; import { feedApi } from "./api/feed"; export type * from "./api/feed"; -export { TAKEOUT_JOB } from "./api/feed"; +export { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAKEOUT_JOB } from "./api/feed"; import { discoverApi } from "./api/discover"; export type * from "./api/discover"; export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover"; diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 408bf728..53625713 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -81,6 +81,11 @@ export interface FeedResponse { // or optional (dead surface). Re-add if a consumer ever needs it. } +/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what + * the active-filter chips read "nothing applied" against. */ +export const FEED_DEFAULT_SHOW = "unwatched"; +export const FEED_DEFAULT_SORT = "newest"; + export interface FeedFilters { tags: number[]; /** Per-category tag match (language / topic / other each independently any/all). */ diff --git a/frontend/src/lib/filterMapParam.ts b/frontend/src/lib/filterMapParam.ts index 4431a559..00b64df9 100644 --- a/frontend/src/lib/filterMapParam.ts +++ b/frontend/src/lib/filterMapParam.ts @@ -3,8 +3,9 @@ import { type FilterMap } from "./tableColumns"; // A table's column-filter map as one address-bar value (the channel manager's `cf`, a playlist's `plf`). // The map mixes scalar column filters (select, text, the sync chip) with array ones (multi), so it's // carried as a compact JSON blob rather than a nested query string — JSON round-trips both shapes -// exactly (and future multi columns for free), and the values are opaque enum/id tokens where a -// readable querystring buys little. Empty entries are dropped so a cleared funnel emits nothing. +// exactly (and future multi columns for free) where a readable querystring buys little. A text +// column's entry is what the user typed, so a map with one is a typed key (ModuleDef.typedUrlKeys). +// Empty entries are dropped so a cleared funnel emits nothing. export function encodeFilterMap(map: FilterMap): string | null { const clean: FilterMap = {}; for (const [k, v] of Object.entries(map)) { diff --git a/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx b/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx new file mode 100644 index 00000000..44a051db --- /dev/null +++ b/frontend/src/lib/history.reloadedNestedBack.dom.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +// +// A reload under two overlays: the player reopens on the upper one's entry, the lower one's is dead +// under it. A browser Back closes the player — and steps over the dead entry too, onto the page, as +// the player's own close does. Its own file: the entry is read as the module loads, so it is set up +// before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +const landing = () => + new Promise((res) => window.addEventListener("popstate", res, { once: true })); + +describe("a browser Back over an overlay reopened on the entry a reload under two started from", () => { + it("lands on the page under both", async () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); + window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1"); + window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1"); + const { useBackToClose } = await import("./history"); + + let closed = false; + renderHook(() => useBackToClose(() => (closed = true), { watch: "v1" })); + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + const first = landing(); + window.history.back(); + await first; + await landing(); // the step over the dead entry + await Promise.resolve(); + }); + expect(closed).toBe(true); + expect(window.history.state?._ov).toBeUndefined(); + expect(window.location.search).toBe("?page=feed"); + }); +}); diff --git a/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx b/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx new file mode 100644 index 00000000..ebf9836a --- /dev/null +++ b/frontend/src/lib/history.reloadedNestedEdge.dom.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +// +// A reload under two overlays, in a tab whose entries below the dead one are not this document's +// (Chrome dropped them, see backStaysInDocument): closing the reopened player cannot step over both, +// so it steps back one — onto the dead entry, still in the app — rather than leaving it. Its own file: +// the entry is read as the module loads, so it is set up before the import. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +afterEach(() => { + Reflect.deleteProperty(window, "navigation"); +}); + +describe("an overlay reopened over a dead entry with nothing of this document below it", () => { + it("closes one step back, staying in the document", async () => { + window.history.replaceState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1"); + window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1"); + const { useBackToClose } = await import("./history"); + // Two steps back would leave the document; one stays in it. + Object.defineProperty(window, "navigation", { + configurable: true, + value: { + currentEntry: { index: 2 }, + entries: () => [{ sameDocument: false }, { sameDocument: true }, { sameDocument: true }], + }, + }); + const go = vi.spyOn(window.history, "go"); + const back = vi.spyOn(window.history, "back"); + + const r = renderHook(() => useBackToClose(() => {}, { watch: "v1" })); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + r.unmount(); + await landed; + }); + expect(go).not.toHaveBeenCalled(); + expect(back).toHaveBeenCalledTimes(1); + expect(window.history.state?._ov).toBe(1); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 48e7e1b5..8086f7b7 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -253,7 +253,7 @@ export function useHistorySubview( function writeSubEntry(next: T, url: SubviewUrl | undefined): void { const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; - window.history.replaceState({ ...window.history.state, _sub: kept }, "", href); + replaceView({ ...window.history.state, _sub: kept }, href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- @@ -304,6 +304,21 @@ let historyDepth = 0; let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null; const reloadedDepth: unknown = window.history.state?._ovd; let deadBelow = 0; + +/** Rewrite the current entry in place for another view (a refined search, another channel, a + * sub-view followed): `state` and `href` as replaceState takes them. With no overlay open, the + * entry's overlay marks go — the ones a reload under an overlay left. They named the view it was + * left from, and the first overlay over the new one would take this entry over: closed, it stepped + * back onto the entry below and the screen went back to the view before the rewrite. */ +export function replaceView(state: Record, href?: string): void { + if (historyDepth > 0) { + window.history.replaceState(state, "", href); + return; + } + reloadedOverlayEntry = null; + const { _ov: _overlay, _ovd: _depth, ...view } = state; + window.history.replaceState(view, "", href); +} // Programmatic history steps back still awaiting their popstate — each must be ignored — and how // many entries they step over in all (a step over dead entries is one traversal of several). let pendingProgrammatic = 0; diff --git a/frontend/src/lib/logHooks.dom.test.ts b/frontend/src/lib/logHooks.dom.test.ts index 57da00f4..d3a9cb86 100644 --- a/frontend/src/lib/logHooks.dom.test.ts +++ b/frontend/src/lib/logHooks.dom.test.ts @@ -10,8 +10,8 @@ import { installClientLog } from "./logHooks"; const appCopy = (text: string | null) => ["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null; -// The pages' search boxes' keys, as main.tsx hands them over from the module definitions. -const TYPED = ["fq", "yt"]; +// The pages' typed keys, as main.tsx hands them over from the module definitions. +const TYPED = ["q", "fq", "yt"]; beforeAll(() => { installClientLog(appCopy, TYPED); diff --git a/frontend/src/lib/logHooks.ts b/frontend/src/lib/logHooks.ts index c2168511..fe4c041e 100644 --- a/frontend/src/lib/logHooks.ts +++ b/frontend/src/lib/logHooks.ts @@ -13,10 +13,10 @@ import { FRONTEND_SHA, FRONTEND_VERSION } from "./version"; import { enabled, errorAttrs, log } from "./log"; import { pathTemplate } from "./http"; -// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes"). -// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The -// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys). -const TYPED_PARAMS = new Set(["q", "keywords", "search"]); +// URL parameters that carry what the user TYPED — a search, a column's text filter. Logged only at +// TRACE ("Teljes"). Every other parameter is filter/sort/view state: its NAME is kept at INFO, its +// value at DEBUG. The shell has none; each page's join at install (ModuleDef.typedUrlKeys). +const TYPED_PARAMS = new Set(); type Via = "start" | "push" | "replace" | "pop"; diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index bf1dd813..17346244 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -50,9 +50,9 @@ export type ModuleDef = { // along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to // one module and none is a shell key (moduleUrl.contract.test). urlKeys: readonly string[]; - // Those of them that carry what the user TYPED (a search box's text): the client log keeps their - // values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search - // box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys). + // Those of them that carry what the user TYPED (a search box's text, a column filter map holding a + // text filter): the client log keeps their values out below its most detailed level (lib/logHooks). + // moduleUrl.contract.test sorts every key of urlKeys as typed or not, so a new one is decided. typedUrlKeys?: readonly string[]; // Its page plays YouTube videos in the player and reopens it from an address that names one (a // reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 2c45ae38..82e2ba58 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -4,7 +4,7 @@ // bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is // retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. -import { TAG_CATEGORIES, type FeedFilters } from "./api"; +import { FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, TAG_CATEGORIES, type FeedFilters } from "./api"; import { CHANNEL_PAGE_KEYS } from "./channelPageUrlState"; import { PLAYER_KEYS } from "./playerUrlState"; @@ -55,8 +55,8 @@ export const SHELL_KEYS = [ export function filtersToParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); if (f.q) p.set("q", f.q); - if (f.show && f.show !== "unwatched") p.set("show", f.show); - if (f.sort && f.sort !== "newest") p.set("sort", f.sort); + if (f.show && f.show !== FEED_DEFAULT_SHOW) p.set("show", f.show); + if (f.sort && f.sort !== FEED_DEFAULT_SORT) p.set("sort", f.sort); if (f.scope === "all") p.set("scope", "all"); // Library provenance filter (only meaningful in "all" scope); "organic" is the default. if (f.scope === "all" && f.librarySource && f.librarySource !== "organic") @@ -90,8 +90,8 @@ export function filterParamUpdates(f: FeedFilters | null): Record = {}; for (const k of KEYS) out[k] = p.get(k); if (f) { - out.show = f.show || "unwatched"; - out.sort = f.sort || "newest"; + out.show = f.show || FEED_DEFAULT_SHOW; + out.sort = f.sort || FEED_DEFAULT_SORT; } return out; } @@ -106,8 +106,8 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee }; if (params.has("q")) f.q = params.get("q") ?? ""; - if (params.has("show")) f.show = params.get("show") || "unwatched"; - if (params.has("sort")) f.sort = params.get("sort") || "newest"; + if (params.has("show")) f.show = params.get("show") || FEED_DEFAULT_SHOW; + if (params.has("sort")) f.sort = params.get("sort") || FEED_DEFAULT_SORT; if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("source")) { const s = params.get("source"); diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index c37fd6fd..ed9ea940 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { DEFAULT_TAG_MODES, type FeedFilters } from "./api"; +import { DEFAULT_TAG_MODES, FEED_DEFAULT_SHOW, FEED_DEFAULT_SORT, type FeedFilters } from "./api"; import { tagPickLabel } from "./tagPicks"; import { useChannelNames } from "./useChannelNames"; import { parseSort } from "./feedSort"; @@ -20,11 +20,11 @@ const DEFAULT_SIDEBAR_FILTERS: Omit = { tagModes: DEFAULT_TAG_MODES, untagged: false, channelIds: [], - sort: "newest", + sort: FEED_DEFAULT_SORT, includeNormal: true, includeShorts: false, includeLive: false, - show: "unwatched", + show: FEED_DEFAULT_SHOW, }; /** @@ -74,19 +74,19 @@ export function useActiveFilters( }); } - if (filters.show !== "unwatched") { + if (filters.show !== FEED_DEFAULT_SHOW) { out.push({ key: "show", label: t("sidebar.show." + filters.show), - remove: () => setFilters({ ...filters, show: "unwatched" }), + remove: () => setFilters({ ...filters, show: FEED_DEFAULT_SHOW }), }); } - if (filters.sort !== "newest" && filters.sort !== "relevance") { + if (filters.sort !== FEED_DEFAULT_SORT && filters.sort !== "relevance") { out.push({ key: "sort", label: t("feed.sortKey." + parseSort(filters.sort).key), - remove: () => setFilters({ ...filters, sort: "newest", seed: undefined }), + remove: () => setFilters({ ...filters, sort: FEED_DEFAULT_SORT, seed: undefined }), }); } diff --git a/frontend/src/moduleSearch.url.dom.test.tsx b/frontend/src/moduleSearch.url.dom.test.tsx index 7c46c42b..0e4472b0 100644 --- a/frontend/src/moduleSearch.url.dom.test.tsx +++ b/frontend/src/moduleSearch.url.dom.test.tsx @@ -113,7 +113,7 @@ describe.each([ act(() => nav.current!.setPage("feed")); act(() => nav.current!.setPage(page as "files" | "playlists")); await waitFor(() => expect(seen.current[page]).toBe("")); - await new Promise((r) => setTimeout(r, 350)); // past the address write's typing pause + // At once, not after the typing pause: a reload right after the return opens it unsearched. expect(search().get(key)).toBeNull(); }); }); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 61cc7798..01dc3773 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -4,9 +4,9 @@ // shell clears them on a page switch and opens a page without another page's keys (NavigationProvider, // derived from these declarations). That only holds while the declarations are true, so this pins // them: each key belongs to one module and is no shell key, the keys a module declares are exactly -// what its encoders write, a writer outside lib/ hands the address an encoder's result (or literal -// keys, checked here) — never a key of its own making — and every key literal read from the live -// query is declared. +// what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or +// literal keys, checked here) — never a key of its own making — every key literal read from the live +// query is declared, and every key is sorted as typed text or not (the client log's part). import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; @@ -90,8 +90,96 @@ function sources(dir: string): string[] { const WRITER_OBJECTS = [ /(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g, /\burl=\{\{([^}]*)\}/g, - /useBackToClose\([^,()]+,\s*\{([^}]*)\}/g, + // Its first argument a name, a call or an arrow (`() => setOpen(false)`). + /useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{([^}]*)\}/g, ]; +// One of each form: a regex that matched nothing in the tree would pass every scan. +const WRITER_FIXTURE = ` + useUrlMirror({ fxa: x }); + + useBackToClose(onClose, { fxc: "1" }); + useBackToClose(() => setOpen(false), { fxd: "1" }); +`; + +// Where a writer outside lib/ is handed its value: the address writers' argument, and an +// overlay's (Modal's `url`, `useBackToClose`'s second). An object literal (its keys are scanned), a +// call of a lib/ function — the codecs, and the shell's own (pageRootParams, …) — or a choice +// between them; anything else (a variable, a helper of the module's own) is a key no scan reads. +const WRITER_CALLS = /\b(useUrlMirror|urlWithParams|useBackToClose)\(|\burl=\{/g; +function closing(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c) && --depth === 0) return i; + } + return text.length; +} +/** The top-level pieces of `text` split at `sep` (outside any brackets). */ +function topLevel(text: string, sep: string): string[] { + const out: string[] = []; + let depth = 0; + let from = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) depth--; + else if (depth === 0 && c === sep) { + out.push(text.slice(from, i)); + from = i + 1; + } + } + out.push(text.slice(from)); + return out.map((s) => s.trim()); +} +const LIB_EXPORTS = new Set( + sources(join(SRC, "lib")).flatMap((f) => + [...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!), + ), +); +function writerValueOk(value: string): boolean { + const v = value.trim(); + if (v === "null" || v === "undefined") return true; + const choice = topLevel(v, "?"); + if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk); + if (v.startsWith("{")) { + // Its spreads are a lib/ function's result too. + return topLevel(v.slice(1, closing(v, 0)), ",") + .filter((piece) => piece.startsWith("...")) + .every((piece) => writerValueOk(piece.slice(3))); + } + const call = /^(\w+)\(/.exec(v); + return ( + call != null && LIB_EXPORTS.has(call[1]!) && closing(v, call[0].length - 1) === v.length - 1 + ); +} +function writerValues(text: string): string[] { + const out: string[] = []; + for (const m of text.matchAll(WRITER_CALLS)) { + const open = m.index + m[0].length - 1; + const args = topLevel(text.slice(open + 1, closing(text, open)), ","); + if (m[1] === "useBackToClose") { + if (args.length > 1) out.push(args[1]!); + } else if (args[0]) out.push(args[0]); + } + return out; +} +function unreadWriterValues(): string[] { + const out: string[] = []; + for (const file of sources(SRC)) { + const where = relative(SRC, file).split("\\").join("/"); + if (where.startsWith("lib/")) continue; + for (const value of writerValues(readFileSync(file, "utf8"))) { + // ui/Modal hands its own `url` prop on: its callers' values are the ones checked. + if (where === "components/ui/Modal.tsx" && value === "url") continue; + // The shell's page switch writes what the declarations name (moduleUrlKeys, foreignUrlKeys — + // every module's keys cleared) and the shell's own: its helpers are that, held above. + if (where === "features/navigation/NavigationProvider.tsx") continue; + if (!writerValueOk(value)) out.push(`${where}: ${value.slice(0, 60)}`); + } + } + return out; +} // Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a // `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the @@ -132,6 +220,66 @@ function computedWriterKeys(inLib = false): string[] { return out; } +// The keys whose value is what the user typed (a search box, a column filter map with a text +// filter in it), and every other one: chosen from what the page offers (a tab, a sort, a filter's +// option, an id). +const TYPED_KEYS = ["aq", "cf", "cq", "fq", "plf", "plq", "pq", "q", "yt"]; +const PICKED_KEYS = [ + "aact", + "at", + "au", + "aw", + "cfgtab", + "channel", + "csort", + "ctab", + "folder", + "fplay", + "from", + "fsort", + "fview", + "hb", + "item", + "lev", + "lgrp", + "live", + "ljob", + "lreq", + "lrun", + "lsrc", + "maxage", + "maxdur", + "mindur", + "normal", + "notag", + "pf", + "pl", + "plgroup", + "plsort", + "pqf", + "pscope", + "pshow", + "psort", + "rjob", + "rst", + "scope", + "shorts", + "show", + "sort", + "source", + "stab", + "tagmode", + "tagmode_language", + "tagmode_other", + "tagmode_topic", + "tags", + "to", + "utab", + "v", + "year", + "ytn", +]; + // A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState // RETIRED) — no module writes them any more. const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"]; @@ -200,11 +348,36 @@ describe("module URL keys", () => { it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => { const seen = new Set(queryKeyLiterals().map(({ key }) => key)); // A writer's literal, a one-line read, a two-line read, a retired link's. - for (const key of ["page", "rjob", "link", "plq", "folder", "lq"]) expect(seen).toContain(key); + for (const key of ["page", "link", "plq", "folder", "lq"]) expect(seen).toContain(key); }); - it("are written outside lib/ only through the codecs that WRITES calls", () => { + it("the writer scans see each form they are meant to", () => { + const keys = WRITER_OBJECTS.flatMap((re) => + [...WRITER_FIXTURE.matchAll(re)].map((m) => /(\w+)\s*:/.exec(m[1]!)![1]), + ); + expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd"]); + expect(writerValues(WRITER_FIXTURE)).toHaveLength(4); + }); + + it("are written outside lib/ only as a literal or a lib/ function's result", () => { expect(computedWriterKeys()).toEqual([]); + expect(unreadWriterValues()).toEqual([]); + // …which the check tells apart (a check that passed everything would pass here too). + expect(writerValueOk("{ page: p, ...activityParamUpdates(state) }")).toBe(true); + expect(writerValueOk('shown ? playlistsParams(id, q) : playlistsParams(null, "")')).toBe(true); + expect(writerValueOk("upd")).toBe(false); + expect(writerValueOk("myOwnParams(v)")).toBe(false); + expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false); + expect(writerValueOk("Object.fromEntries(rows)")).toBe(false); + }); + + // A sub-view's address key is written by the history plumbing in lib/, from its definition: each + // one is in a WRITES row (Plex's) or the shell's keys (the welcome card's), by name. + it("name their sub-views' keys", () => { + const defined = sources(SRC).flatMap((f) => + [...readFileSync(f, "utf8").matchAll(/const (\w+): SubviewUrl m[1]!), + ); + expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]); }); it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { @@ -221,7 +394,11 @@ describe("module URL keys", () => { page, ).toEqual([]); } - expect([...typedUrlKeys()].sort()).toEqual(["aq", "cq", "fq", "plq", "pq", "q", "yt"]); + // Every key sorted one way or the other, by name: a new one fails here until it is decided. + const typed = new Set(typedUrlKeys()); + const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys); + expect([...typed].sort()).toEqual(TYPED_KEYS); + expect(all.filter((k) => !typed.has(k)).sort()).toEqual(PICKED_KEYS); // …which the client log is handed as it installs. expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain( "installClientLog(appCopyOf, typedUrlKeys());", @@ -233,10 +410,10 @@ describe("module URL keys", () => { // by its address — Plex excepted, by name there: its view is the instance's own library. describe("the bookmark net", () => { const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8"); + // A test tagged with the page that opens a bookmark of what it set (`bookmarked(`). + const tests = spec.split(/\n\s*test\(/).slice(1); const opened = (page: Page) => - spec.includes(`page=${page}`) || - spec.includes(`gotoPage(page, "${page}")`) || - spec.includes(`gotoAdminPage(page, "${page}")`); + tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked(")); it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))( "takes a round trip through %s", diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index fd411622..346f9c9d 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -15,5 +15,5 @@ export const channelsModule: ModuleDef = { maxWidth: "96rem", Store: ChannelsProvider, urlKeys: CHANNELS_KEYS, - typedUrlKeys: ["cq"], + typedUrlKeys: ["cq", "cf"], }; diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 83d85d7b..29b53679 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -269,7 +269,7 @@ export default function Feed({ const { setYtCount, askYtSearch } = useNavigationActions(); // A search runs by itself only where nobody's reload or link can spend quota with it: the free // page scrape, or a search asked for in this document. An API search reopened by a reload, a link - // or Back waits for "Search YouTube again" — unless its results are still cached, which a disabled + // or Back waits for "Search YouTube" — unless its results are still cached, which a disabled // query shows all the same. Turned off on this instance it runs too: the server refuses it before // any quota, and its refusal is what says so (no press offered that could only be refused). const ytRuns = ytActive && (me.search_source !== "api" || ytSearchAsked || !searchOn); @@ -506,7 +506,7 @@ export default function Feed({ // The live search's cards, in the order picked for them, and the player's queue over them. const ytShown = (rows: Video[]): Video[] => sortYtResults(withOverrides(rows, true), ytSort.key as SortKey, ytSort.dir, lang); - const ytQueue = (rows: Video[]): Video[] => ytShown(rows).filter((v) => v.status !== "hidden"); + const ytQueueOf = (shown: Video[]): Video[] => shown.filter((v) => v.status !== "hidden"); const merged: Video[] = withOverrides(loaded); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the @@ -520,7 +520,7 @@ export default function Feed({ // list it was linked over: leaving the search first gives it up. useOpenLinkedPlayer( ytActive ? ytQuery.data !== undefined || ytQuery.isError || !ytRuns : !query.isLoading, - (id) => (ytActive ? ytQueue(ytLoaded) : playerQueue).find((v) => v.id === id), + (id) => (ytActive ? ytQueueOf(ytShown(ytLoaded)) : playerQueue).find((v) => v.id === id), (video, inList) => { openVideo(video); // Not in what this list has loaded (further down it, or no longer in it): it plays alone. @@ -541,7 +541,7 @@ export default function Feed({ // user explicitly searched, so show everything) and no auto-pagination (each page costs quota). if (ytActive) { const ytItems: Video[] = ytShown(ytLoaded); - const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); + const ytPlayerQueue: Video[] = ytQueueOf(ytItems); const ytError = ytQuery.error instanceof HttpError ? ytQuery.error.detail || t("feed.yt.error") @@ -649,7 +649,7 @@ export default function Feed({
{t("feed.yt.searching")}
) : ytError && !ytItems.length ? ( // A first page that failed is said as the error it is, with Retry where trying again can - // help (YouTube busy or unreachable): Search YouTube again with the same term reads the + // help (YouTube busy or unreachable): Search YouTube with the same term reads the // failure from the cache and sends nothing. { }); describe("Feed — a live search whose first page fails", () => { - // Search YouTube again with the same term reads the cached failure: Retry is the way to ask again. + // Search YouTube with the same term reads the cached failure: Retry is the way to ask again. it("offers Retry where trying again can help, and reads the page on it", async () => { const search = vi .spyOn(api, "searchYoutube") diff --git a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx index 224a6d17..583d79ac 100644 --- a/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx +++ b/frontend/src/modules/feed/Feed.ytReopen.dom.test.tsx @@ -2,7 +2,7 @@ // // A live YouTube search the address reopens (a reload, a link, Back) runs by itself only where it // costs no quota: the free page scrape. An API search spends 100 units, so it waits for "Search -// YouTube again" — unless its results are still cached, or it was asked for in this document. +// YouTube" — unless its results are still cached, or it was asked for in this document. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/frontend/src/modules/feed/feedFiltersInit.ts b/frontend/src/modules/feed/feedFiltersInit.ts index c203c476..0c956d1d 100644 --- a/frontend/src/modules/feed/feedFiltersInit.ts +++ b/frontend/src/modules/feed/feedFiltersInit.ts @@ -1,4 +1,11 @@ -import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api"; +import { + coerceTagModes, + coerceUntagged, + DEFAULT_TAG_MODES, + FEED_DEFAULT_SHOW, + FEED_DEFAULT_SORT, + type FeedFilters, +} from "../../lib/api"; import { LS, ownKey, readJSON } from "../../lib/storage"; // The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider @@ -10,12 +17,12 @@ export const DEFAULT_FILTERS: FeedFilters = { untagged: false, channelIds: [], q: "", - sort: "newest", + sort: FEED_DEFAULT_SORT, scope: "my", includeNormal: true, includeShorts: false, includeLive: false, - show: "unwatched", + show: FEED_DEFAULT_SHOW, }; /** The watch-state choices, in the order the filter offers them. */ diff --git a/frontend/src/modules/files/Files.menu.dom.test.tsx b/frontend/src/modules/files/Files.menu.dom.test.tsx index 26f7bb1c..676f8d02 100644 --- a/frontend/src/modules/files/Files.menu.dom.test.tsx +++ b/frontend/src/modules/files/Files.menu.dom.test.tsx @@ -170,8 +170,11 @@ const YT = folder(1, { source_key: "youtube" }); const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null }); const HUB = folder(4, { kind: "hub", retention_days: null, count: 0 }); -afterEach(() => { +afterEach(async () => { cleanup(); + // A viewer the cleanup closed steps the history back: jsdom lands that traversal a few tasks later, + // and on whatever case is current by then. Let it land here, before the address is reset. + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); vi.restoreAllMocks(); // The page names its sort in the address: one case's sort must not open the next. window.history.replaceState(null, "", "/"); @@ -813,15 +816,30 @@ describe("Files · a file the address names", () => { // A list the cap cut cannot tell a deleted file from one beyond its end: it says the file is not // among the ones listed, and keeps the link. it("past the cap with no search or sort to widen it, says it is not listed and keeps the link", async () => { - // The case before closed a viewer: its step back lands a moment later, on whatever is current. - await new Promise((r) => setTimeout(r, 30)); window.history.replaceState(null, "", "/?page=files&fsort=none&fview=9999"); mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); mocks.folders = [YT]; mount(); await waitFor(() => expect(mocks.notify).toHaveBeenCalledWith( - expect.objectContaining({ level: "info", message: "files.arrival.notListed" }), + expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), + ), + ); + expect(mocks.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "files.linkedGone" }), + ); + expect(here().get("fview")).toBe("9999"); + }, 30_000); // a full page of FILES_LIST_CAP rows + + it("past the cap, the server's sorted list cut too: not listed, and the link stays", async () => { + window.history.replaceState(null, "", "/?page=files&fsort=name:asc&fview=9999"); + mocks.files = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); + mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1)); + mocks.folders = [YT]; + mount(); + await waitFor(() => + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "info", message: "files.linkedNotListed" }), ), ); expect(mocks.notify).not.toHaveBeenCalledWith( diff --git a/frontend/src/modules/files/Files.tsx b/frontend/src/modules/files/Files.tsx index 5ba1dc73..7399601e 100644 --- a/frontend/src/modules/files/Files.tsx +++ b/frontend/src/modules/files/Files.tsx @@ -294,7 +294,9 @@ export default function Files() { const listIn = wide ? wideIn && wideQ.data !== undefined : filesQ.data !== undefined; const linkedRow = linkedFile && listIn ? rows.find((f) => f.id === linkedFile.id) : undefined; const openLinked = useEffectEvent((file: LibraryFile | undefined) => { - if (!file && capped && !wide) notify({ level: "info", message: t("files.arrival.notListed") }); + // Looked for in the list it was shown in: a cut one cannot tell (the newest files, or the first by + // its sort or search), as the arrival decides it. + if (!file && cut) notify({ level: "info", message: t("files.linkedNotListed") }); else if (!file) { notify({ level: "warning", message: t("files.linkedGone") }); clearParams(FILES_VIEW_KEY, FILES_PLAY_KEY); diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 2f65dfcd..4c8fb3dc 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -102,8 +102,10 @@ export function FilesProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "files"; // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); - // an arrival's cleared box at once. - const typed = useDebounced(search.trim(), 300, focusRaw?.token); + // an emptied box at once — an arrival's, or the page left: back on it within the pause, the address + // would name the ended search again. + const debounced = useDebounced(search.trim(), 300, focusRaw?.token); + const typed = search.trim() ? debounced : ""; useUrlMirror( filesListParams( shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null, diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 4beca9c7..1b59c041 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -133,13 +133,16 @@ let qc: QueryClient; function mount( lists: Record, prefs: Record = {}, - { strictOverCache = false } = {}, + { + strictOverCache = false, + detail, + }: { strictOverCache?: boolean; detail?: (id: number) => Promise } = {}, ) { vi.spyOn(api, "playlists").mockResolvedValue( Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, ); vi.spyOn(api, "playlist").mockImplementation( - async (id: number) => list(id, lists[id] ?? []) as never, + (detail ?? (async (id: number) => list(id, lists[id] ?? []))) as never, ); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs }); @@ -220,6 +223,47 @@ describe("Playlists — a player the address names", () => { expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); }); + it("plays alone over a playlist this account does not hold, as the rail moves on by itself", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=7&watch=Z"); + h.selectedId = 7; + // The catalog answers after the rail moved on, as in the app (the move is in the same tick). + let answer: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)) as never); + // Its read never answers: the rail moves on before it could. + const { rerender } = mount( + { 1: [vid("A", 0)] }, + {}, + { + detail: async (id) => (id === 7 ? new Promise(() => {}) : list(id, [vid("A", 0)])), + }, + ); + // The rail's keep-valid step, once the account's list is in: 7 is not in it. + await waitFor(() => expect(qc.getQueryData(qk.playlists())).toBeDefined()); + h.selectedId = 1; + rerender(); + await screen.findByText("Video A"); + act(() => answer(vid("Z", 0))); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); + expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z"); + }); + + it("plays alone when the linked playlist's read is refused", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); + vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); + mount({ 1: [vid("A", 0)] }, {}, { detail: () => Promise.reject(new Error("refused")) }); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); + }); + + it("plays alone with no playlists at all", async () => { + window.history.replaceState(null, "", "/?page=playlists&watch=Z"); + h.selectedId = null; + vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); + mount({}); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + }); + it("is given up when another playlist is picked before it opened, and leaves the address", async () => { window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index c28e86b9..5aad7d0b 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -586,18 +586,26 @@ function PlaylistDetail() { // (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing // removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its // read refused. Scoped to the playlist it was linked over: another one picked first gives it up. + // One the account's list does not hold (deleted since, another account's link) is out of reach + // as soon as that list is in: the rail then moves to another one by itself, which is no pick. + const [linkedOver] = useState(selectedId); + const linkedOutOfReach = + listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver); const linkedListIn = + linkedOutOfReach || (detail != null && (detail.items.length === 0 || items.length > 0)) || - (selectedId != null && detailQuery.isError) || - (selectedId == null && listQuery.data?.length === 0); + (selectedId != null && detailQuery.isError); useOpenLinkedPlayer( linkedListIn, - (id) => shown.find((v) => v.id === id), + (id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)), (video, inList) => { if (inList) openPlayer(video.id); - else setPlaying({ video, queue: [video], index: 0, listId: selectedId, editable: false }); + else { + const listId = linkedOutOfReach ? null : selectedId; + setPlaying({ video, queue: [video], index: 0, listId, editable: false }); + } }, - selectedId, + linkedOutOfReach ? linkedOver : selectedId, ); async function moveItemTo(video: Video, target: Playlist) { diff --git a/frontend/src/modules/playlists/PlaylistsProvider.tsx b/frontend/src/modules/playlists/PlaylistsProvider.tsx index 5b9b1d06..f8ee283c 100644 --- a/frontend/src/modules/playlists/PlaylistsProvider.tsx +++ b/frontend/src/modules/playlists/PlaylistsProvider.tsx @@ -46,7 +46,9 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "playlists"; // Leaving the page ends the search: adjusted during the render that leaves it. if (nav.page !== "playlists" && search) setSearchState(""); - const typed = useDebounced(search.trim(), 300); + // An emptied box at once: back on the page within the pause, the address would name it again. + const debounced = useDebounced(search.trim(), 300); + const typed = search.trim() ? debounced : ""; useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, "")); const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index fb773d1c..136346f6 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -19,6 +19,6 @@ export const playlistsModule: ModuleDef = { Store: PlaylistsProvider, // The playlist shown (PlaylistsProvider, playlistsInit). urlKeys: PLAYLISTS_KEYS, - typedUrlKeys: ["plq"], + typedUrlKeys: ["plq", "plf"], playsVideos: true, }; diff --git a/frontend/src/modules/scheduler/Scheduler.tsx b/frontend/src/modules/scheduler/Scheduler.tsx index 8da7744a..f3f708d5 100644 --- a/frontend/src/modules/scheduler/Scheduler.tsx +++ b/frontend/src/modules/scheduler/Scheduler.tsx @@ -47,6 +47,7 @@ import { jobFailureText, jobResultText } from "../../lib/jobResult"; import { StatusDot, type DotKey } from "../activity"; import { useNavigationActions } from "../../features/navigation/navigationContext"; import { urlWithParams } from "../../lib/urlState"; +import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "../../lib/activityUrlState"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -164,7 +165,10 @@ function HistoryButton({ job }: { job: string }) { window.history.replaceState( window.history.state, "", - urlWithParams({ page: "activity", rjob: job }), + urlWithParams({ + page: "activity", + ...activityParamUpdates({ ...DEFAULT_ACTIVITY_STATE, job }), + }), ), ) } From e2b725dba755609529b8b0fb95051a85f459ecfa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 16:34:17 +0100 Subject: [PATCH 10/15] test(e2e): the Files sort round trip starts from a known sort and puts the account's back A header press cycles the sort (asc, desc, none), so the case depended on what an earlier run left in the account: the third run in a row pressed it to none. --- frontend/e2e/bookmark.spec.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/bookmark.spec.ts b/frontend/e2e/bookmark.spec.ts index 730ae30a..a72aa2dd 100644 --- a/frontend/e2e/bookmark.spec.ts +++ b/frontend/e2e/bookmark.spec.ts @@ -147,8 +147,8 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); }); - // A file of the test's own, uploaded for it and deleted after: the account's library is left as - // it was found. + // A file of the test's own, uploaded for it and deleted after, and the account's sort put back: + // the account is left as it was found. test( "the Files sort, and the file open in the viewer", { tag: "@files" }, @@ -168,8 +168,14 @@ test.describe("a bookmark reopens the view it was taken of", () => { }); expect(up.ok(), await up.text()).toBeTruthy(); const { id } = (await up.json()) as { id: number }; + // The account's own sort, put back after: a header press cycles it (asc, desc, none), so the + // case starts from none, as the address names it, whatever an earlier run left. + const me = (await (await page.request.get("/api/me")).json()) as { + preferences?: Record; + }; + const ownSort = me.preferences?.filesSort ?? null; try { - await gotoPage(page, "files"); + await openApp(page, "/?page=files&fsort=none"); await column(page, "Name").getByRole("button").first().click(); await expect(page).toHaveURL(/[?&]fsort=name/); const sorted = await column(page, "Name").getAttribute("aria-sort"); @@ -182,6 +188,7 @@ test.describe("a bookmark reopens the view it was taken of", () => { await fresh.context().close(); } finally { await page.request.delete(`/api/files/${id}`); + await page.request.put("/api/me/preferences", { data: { filesSort: ownSort } }); } }, ); From 66e427dfa4a7eeb54addd4ff2be70ca99e23b1e5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 22:43:17 +0100 Subject: [PATCH 11/15] fix(nav): a search the address opened marks its entry, so a channel over it keeps it A search reached by a reload or a link had no _yt mark on its entry, so a channel opened from its results carried none, and a landing back on that channel (an overlay over it closing) ended the search under it. --- .../features/navigation/NavigationProvider.tsx | 13 ++++++++----- .../NavigationProvider.ytSearch.dom.test.tsx | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 810497c8..25d6ea88 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -296,9 +296,10 @@ 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(() => { - // 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. + // The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent + // from the box, a reload, a link): a channel opened from its results reads the search under it + // from that mark. 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 keeps the @@ -316,8 +317,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), page: mountPage, }); - if (st._yt && ytSearchFrom(new URLSearchParams(window.location.search)) != null) { - window.history.replaceState({ ...st, sfPage: mountPage }, "", url); + const named = + mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null; + if (named != null) { + window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx index fd610561..cc41603b 100644 --- a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -138,6 +138,12 @@ describe("NavigationProvider · the YouTube search in the address", () => { expect(window.history.state?._yt).toBe("lofi"); }); + it("marks the entry of a search the address opened (a reload, a link)", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?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 }, @@ -255,6 +261,16 @@ describe("NavigationProvider · the YouTube search in the address", () => { expect(result.current.ytSearchAsked).toBe(true); }); + it("a landing on a channel opened from a search the address opened keeps the search", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi"); + const { result } = render(); + act(() => result.current.openChannel("UC1")); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + expect(result.current.ytSearch).toBe("lofi"); + }); + it("a channel closed in place over the search names the search again", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); From 02906d0acbbfba0530219673120484ce857c22a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 27 Oct 2026 23:33:22 +0100 Subject: [PATCH 12/15] fix(nav): the address keeps what the app rewrites around it (review round 3) - The Feed press over a live search ends only the search: the feed's own view stays named. - Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload opened is cleared in place, also where the Navigation API is missing. - useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its first write and its landing writes keep them for an overlay that reopens. - Files and Playlists: a box emptied and filled again settles at once, never naming the ended search. - Playlists: a linked player is scoped to the playlist the store started with, and only the rail's own move to the first playlist is no pick. - Contract: writer objects are scanned to their matching brace, a computed or shorthand key is refused, and every useHistorySubview call hands a named definition. - Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated). - FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name. --- frontend/eslint.config.js | 3 +- .../navigation/NavigationProvider.tsx | 28 +++++-- .../NavigationProvider.ytSearch.dom.test.tsx | 27 ++++++ frontend/src/i18n/locales/en/files.json | 2 +- frontend/src/i18n/locales/hu/files.json | 2 +- frontend/src/lib/gates.snapshot.json | 2 +- ...eUrlMirror.reloadedFirstWrite.dom.test.tsx | 27 ++++++ .../useUrlMirror.reloadedRewrite.dom.test.tsx | 39 +++++++++ frontend/src/lib/useUrlMirror.ts | 14 +++- frontend/src/moduleSearch.url.dom.test.tsx | 11 ++- frontend/src/moduleUrl.contract.test.ts | 82 ++++++++++++++----- frontend/src/modules/feed/Feed.tsx | 5 +- frontend/src/modules/feed/FeedHeader.tsx | 5 +- frontend/src/modules/files/FilesProvider.tsx | 8 +- .../modules/playlists/Playlists.dom.test.tsx | 50 ++++++++++- frontend/src/modules/playlists/Playlists.tsx | 13 +-- .../modules/playlists/PlaylistsProvider.tsx | 13 ++- .../src/modules/playlists/playlistsContext.ts | 4 + 18 files changed, 282 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/useUrlMirror.reloadedFirstWrite.dom.test.tsx create mode 100644 frontend/src/lib/useUrlMirror.reloadedRewrite.dom.test.tsx diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c54c2b96..7bc0790d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -898,7 +898,8 @@ export default tseslint.config( plugins: { playwright }, rules: { "playwright/no-focused-test": "error", - "playwright/no-skipped-test": "error", + // fixme too: a test Playwright never runs, which the bookmark net would still count. + "playwright/no-skipped-test": ["error", { disallowFixme: true }], }, }, { diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 25d6ea88..269eb1e1 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -64,6 +64,13 @@ function pageRootParams(page: Page): Record { }; } +/** The address of the page under a live search, the search ended: no search, player or channel. */ +const searchEndParams = (): Record => ({ + ...closedChannelParams(), + ...nulls(PLAYER_KEYS), + ...ytSearchParams(null, DEFAULT_YT_COUNT), +}); + 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) — @@ -138,7 +145,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). // A live search on it is one too: the press opens the page's root, as over a channel. - if (next === pageRef.current && !channelViewRef.current && ytSearchRef.current == null) { + const samePage = next === pageRef.current && !channelViewRef.current; + if (samePage && ytSearchRef.current == null) { then?.(); return; } @@ -154,11 +162,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // every module's own keys (pageRootParams) — a page switch resets to that module's root, and // each module's store mirrors its state back in on its page. The item it was opened from is // still focused: the state setters above have not re-rendered anything yet. + // The press on a search over its own page ends only the search: the page's view is the one + // on screen and stays named — its store, unchanged, writes nothing back in. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", - urlWithParams(pageRootParams(next)), + urlWithParams(samePage ? searchEndParams() : pageRootParams(next)), ); notifyNav(); then?.(); @@ -215,11 +225,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // is the first entry of this document, so its new `_yt` mark has nothing of ours below it). replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here)))); } else { - // New sub-view entry, at the default count. + // New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it. setYtCountState(DEFAULT_YT_COUNT); const left = rememberNavPosition(); window.history.pushState( - { ...left, sfPage: "feed", _yt: q }, + { ...left, sfPage: "feed", _yt: q, _ytOwn: 1 }, "", urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)), ); @@ -242,11 +252,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) { 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(); + // one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is + // another site, or nothing — or there is no entry of this document below it (a full tab, see + // backStaysInDocument). + if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back(); else { endYtSearch(); - const { _yt: _q, ...st } = window.history.state ?? {}; + const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {}; replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } }, [endYtSearch]); @@ -322,7 +334,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { if (named != null) { window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url); } else { - const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; + const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st; window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); } function onPop(e: PopStateEvent) { diff --git a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx index cc41603b..17922af2 100644 --- a/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.ytSearch.dom.test.tsx @@ -243,6 +243,33 @@ describe("NavigationProvider · the YouTube search in the address", () => { expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search }); + it("the Feed press on the search keeps the feed's own view in the address", () => { + window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest"); + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + act(() => result.current.setPage("feed")); + expect(search().get("yt")).toBeNull(); + expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]); + }); + + it("a search sent from the box is left by a step back off its own entry", () => { + const { result } = render(); + act(() => result.current.enterYtSearch("lofi")); + const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); + act(() => result.current.exitYtSearch()); + expect(back).toHaveBeenCalledTimes(1); + }); + + it("a search a link opened is left in place, not by a step back off the document", () => { + window.history.replaceState(null, "", "/?page=feed&yt=lofi"); + const { result } = render(); + const back = vi.spyOn(window.history, "back"); + act(() => result.current.exitYtSearch()); + expect(back).not.toHaveBeenCalled(); + expect(result.current.ytSearch).toBeNull(); + expect(search().get("yt")).toBeNull(); + }); + it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => { const { result } = render(); act(() => result.current.enterYtSearch("lofi")); diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json index ff10fb41..404c1681 100644 --- a/frontend/src/i18n/locales/en/files.json +++ b/frontend/src/i18n/locales/en/files.json @@ -1,6 +1,6 @@ { "linkedGone": "The file in the address is no longer in this list", - "linkedNotListed": "The file in the link isn't among the files listed here — it may be further down the list", + "linkedNotListed": "The file in the link isn't among the files listed here — search for it by name", "navLabel": "Files", "searchPlaceholder": "Search files…", "count_one": "{{count}} item", diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json index 88354b9a..b45aa571 100644 --- a/frontend/src/i18n/locales/hu/files.json +++ b/frontend/src/i18n/locales/hu/files.json @@ -1,6 +1,6 @@ { "linkedGone": "A linkben szereplő fájl már nincs ebben a listában", - "linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — lehet, hogy lejjebb van", + "linkedNotListed": "A linkben szereplő fájl nincs a listázottak között — keress rá a nevére", "navLabel": "Fájlok", "searchPlaceholder": "Keresés a fájlok között…", "count_one": "{{count}} elem", diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index 7d42f338..088762a0 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -78,7 +78,7 @@ { "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false } ], "playwright/no-focused-test": [2], - "playwright/no-skipped-test": [2] + "playwright/no-skipped-test": [2, { "allowConditional": false, "disallowFixme": true }] }, "settings": null }, diff --git a/frontend/src/lib/useUrlMirror.reloadedFirstWrite.dom.test.tsx b/frontend/src/lib/useUrlMirror.reloadedFirstWrite.dom.test.tsx new file mode 100644 index 00000000..8633b55d --- /dev/null +++ b/frontend/src/lib/useUrlMirror.reloadedFirstWrite.dom.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +// +// A reload under an overlay that reopens (the player) loads on the overlay's entry, its mark on it, +// and the overlay takes that entry over as it opens again. A page's first mirror write on that load +// (the defaults its address did not name yet) is the same view, not another: the mark stays, and the +// reopened overlay still takes its entry over rather than pushing one more. Its own file: the entry +// is read as the history module loads, so it is set up before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +describe("a page's first mirror write on the entry a reload under an overlay started from", () => { + it("leaves the entry to the overlay that reopens", async () => { + window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity"); + window.history.pushState({ sfPage: "activity", _ov: 1, _ovd: 1 }, "", "/?page=activity"); + const { useUrlMirror } = await import("./useUrlMirror"); + const { useBackToClose } = await import("./history"); + renderHook(() => useUrlMirror({ at: "runs" })); + expect(new URLSearchParams(window.location.search).get("at")).toBe("runs"); + + const depth = window.history.length; + renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth); + }); +}); diff --git a/frontend/src/lib/useUrlMirror.reloadedRewrite.dom.test.tsx b/frontend/src/lib/useUrlMirror.reloadedRewrite.dom.test.tsx new file mode 100644 index 00000000..5db8f44d --- /dev/null +++ b/frontend/src/lib/useUrlMirror.reloadedRewrite.dom.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +// +// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark +// still on it. A page's view mirrored onto that entry (another tab) is another view: the next overlay +// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back +// onto the entry below, and the page read its old view back from that address. Its own file: the +// entry is read as the history module loads, so it is set up before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +describe("a view mirrored onto the entry a reload under a dialog started from", () => { + it("is where an overlay over it closes to", async () => { + window.history.replaceState({ sfPage: "activity" }, "", "/?page=activity&at=runs"); + window.history.pushState( + { sfPage: "activity", _ov: 1, _ovd: 1 }, + "", + "/?page=activity&at=runs", + ); + const { useUrlMirror } = await import("./useUrlMirror"); + const { useBackToClose } = await import("./history"); + const mirror = renderHook(({ at }) => useUrlMirror({ at }), { initialProps: { at: "runs" } }); + mirror.rerender({ at: "admin" }); + expect(new URLSearchParams(window.location.search).get("at")).toBe("admin"); + + const depth = window.history.length; + const overlay = renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth + 1); + + await act(async () => { + const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true })); + overlay.unmount(); + await landed; + }); + expect(new URLSearchParams(window.location.search).get("at")).toBe("admin"); + }); +}); diff --git a/frontend/src/lib/useUrlMirror.ts b/frontend/src/lib/useUrlMirror.ts index 21d26d14..751e1fc3 100644 --- a/frontend/src/lib/useUrlMirror.ts +++ b/frontend/src/lib/useUrlMirror.ts @@ -1,4 +1,5 @@ -import { useEffect, useEffectEvent, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { replaceView } from "./history"; import { urlWithParams } from "./urlState"; // Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new @@ -19,11 +20,20 @@ export function useUrlMirror(updates: Record): void { // The object itself is read through an effect event, so the effect's reactive inputs are the // serialized value and the landing count — which is exactly what the dep list says: an effect event // for the identity that churns, primitives for the triggers. + // A change of what this mirror writes, after its first write, is another view on the same entry + // (replaceView: it drops the marks a reload under an overlay left). Its first write on a mount and + // its write on a landing name the view the entry already shows, so the marks stay: an overlay that + // reopens after a reload still finds its entry. + const written = useRef(null); const mirror = useEffectEvent(() => { const href = urlWithParams(updates); + const changed = written.current != null && written.current !== dep; + written.current = dep; // Every mirror runs on every landing: one with nothing to change writes nothing (a browser // throttles a page that rewrites its address too often). - if (href !== urlWithParams({})) window.history.replaceState(window.history.state, "", href); + if (href === urlWithParams({})) return; + if (changed) replaceView(window.history.state ?? {}, href); + else window.history.replaceState(window.history.state, "", href); }); // Counted in state, on a task after the popstate dispatch: every listener of it has run by then — // a store that reads its keys back from the landed address among them (Activity) — and so has the diff --git a/frontend/src/moduleSearch.url.dom.test.tsx b/frontend/src/moduleSearch.url.dom.test.tsx index 0e4472b0..baae3e4e 100644 --- a/frontend/src/moduleSearch.url.dom.test.tsx +++ b/frontend/src/moduleSearch.url.dom.test.tsx @@ -20,13 +20,14 @@ import { } from "./features/navigation/navigationContext"; import { ModuleStores } from "./features/navigation/ModuleStores"; import { useChannels } from "./modules/channels/channelsContext"; -import { useFiles } from "./modules/files/filesContext"; -import { usePlaylists } from "./modules/playlists/playlistsContext"; +import { useFiles, useFilesActions } from "./modules/files/filesContext"; +import { usePlaylists, usePlaylistsActions } from "./modules/playlists/playlistsContext"; import { usePlex } from "./modules/plex/plexContext"; import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "./lib/storage"; const seen: { current: Record } = { current: {} }; const nav: { current: NavigationActions | null } = { current: null }; +const typeIn: { current: Record void> } = { current: {} }; function Probe() { const channels = useChannels(); @@ -34,8 +35,11 @@ function Probe() { const playlists = usePlaylists(); const plex = usePlex(); const actions = useNavigationActions(); + const filesActions = useFilesActions(); + const playlistsActions = usePlaylistsActions(); useEffect(() => { nav.current = actions; + typeIn.current = { files: filesActions.setSearch, playlists: playlistsActions.setSearch }; seen.current = { channels: channels.search, files: files.search, @@ -115,5 +119,8 @@ describe.each([ await waitFor(() => expect(seen.current[page]).toBe("")); // At once, not after the typing pause: a reload right after the return opens it unsearched. expect(search().get(key)).toBeNull(); + // Typing again within the pause names the new text or nothing, never the ended search. + act(() => typeIn.current[page]!("x")); + expect(search().get(key)).not.toBe(value); }); }); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index 01dc3773..b2d5a931 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -86,19 +86,21 @@ function sources(dir: string): string[] { } // The object literals handed to an address writer: `urlWithParams` / `useUrlMirror`, an overlay's -// own keys (ui/Modal's `url`, `useBackToClose`'s). +// own keys (ui/Modal's `url`, `useBackToClose`'s). Each pattern ends at the literal's opening brace; +// the literal runs to its matching close (`writerObjects`), nested arguments and all. const WRITER_OBJECTS = [ - /(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g, - /\burl=\{\{([^}]*)\}/g, + /(?:urlWithParams|useUrlMirror)\(\{/g, + /\burl=\{\{/g, // Its first argument a name, a call or an arrow (`() => setOpen(false)`). - /useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{([^}]*)\}/g, + /useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{/g, ]; // One of each form: a regex that matched nothing in the tree would pass every scan. const WRITER_FIXTURE = ` - useUrlMirror({ fxa: x }); + useUrlMirror({ fxa: x, fxf }); useBackToClose(onClose, { fxc: "1" }); useBackToClose(() => setOpen(false), { fxd: "1" }); + urlWithParams({ ...fxCodec({ ...fxState, job }), fxe: "1" }); `; // Where a writer outside lib/ is handed its value: the address writers' argument, and an @@ -132,6 +134,18 @@ function topLevel(text: string, sep: string): string[] { out.push(text.slice(from)); return out.map((s) => s.trim()); } +/** The top-level pieces of every writer's object literal in `text` (`a: x`, `...f(y)`, `[K]: v`, `b`). */ +function writerObjects(text: string): string[][] { + return WRITER_OBJECTS.flatMap((re) => + [...text.matchAll(re)].map((m) => { + const open = m.index + m[0].length - 1; + return topLevel(text.slice(open + 1, closing(text, open)), ",").filter(Boolean); + }), + ); +} +/** The key a piece names as a literal: `key: value`, or the shorthand `key`. */ +const keyOf = (piece: string) => /^(\w+)\s*:/.exec(piece)?.[1] ?? /^(\w+)$/.exec(piece)?.[1]; + const LIB_EXPORTS = new Set( sources(join(SRC, "lib")).flatMap((f) => [...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!), @@ -143,10 +157,13 @@ function writerValueOk(value: string): boolean { const choice = topLevel(v, "?"); if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk); if (v.startsWith("{")) { - // Its spreads are a lib/ function's result too. + // Its spreads are a lib/ function's result too; its other pieces a literal key and its value — + // a computed or a shorthand one is a key no literal scan reads. return topLevel(v.slice(1, closing(v, 0)), ",") - .filter((piece) => piece.startsWith("...")) - .every((piece) => writerValueOk(piece.slice(3))); + .filter(Boolean) + .every((piece) => + piece.startsWith("...") ? writerValueOk(piece.slice(3)) : /^\w+\s*:/.test(piece), + ); } const call = /^(\w+)\(/.exec(v); return ( @@ -189,9 +206,10 @@ function queryKeyLiterals(): { key: string; where: string }[] { for (const file of sources(SRC)) { const text = readFileSync(file, "utf8"); const where = relative(SRC, file).split("\\").join("/"); - for (const re of WRITER_OBJECTS) { - for (const m of text.matchAll(re)) { - for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where }); + for (const pieces of writerObjects(text)) { + for (const piece of pieces) { + const key = keyOf(piece); + if (key) out.push({ key, where }); } } if (!text.includes("location.search")) continue; @@ -211,10 +229,9 @@ function computedWriterKeys(inLib = false): string[] { const where = relative(SRC, file).split("\\").join("/"); if (where.startsWith("lib/") !== inLib) continue; const text = readFileSync(file, "utf8"); - for (const re of WRITER_OBJECTS) { - for (const m of text.matchAll(re)) { - if (/[{,]\s*\[/.test(`{${m[1]!}`)) out.push(`${where}: ${m[0]!.slice(0, 60)}`); - } + for (const pieces of writerObjects(text)) { + const computed = pieces.find((piece) => piece.startsWith("[")); + if (computed) out.push(`${where}: ${computed.slice(0, 60)}`); } } return out; @@ -352,11 +369,11 @@ describe("module URL keys", () => { }); it("the writer scans see each form they are meant to", () => { - const keys = WRITER_OBJECTS.flatMap((re) => - [...WRITER_FIXTURE.matchAll(re)].map((m) => /(\w+)\s*:/.exec(m[1]!)![1]), + const keys = writerObjects(WRITER_FIXTURE).flatMap((pieces) => + pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))), ); - expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd"]); - expect(writerValues(WRITER_FIXTURE)).toHaveLength(4); + expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf"]); + expect(writerValues(WRITER_FIXTURE)).toHaveLength(5); }); it("are written outside lib/ only as a literal or a lib/ function's result", () => { @@ -368,6 +385,9 @@ describe("module URL keys", () => { expect(writerValueOk("upd")).toBe(false); expect(writerValueOk("myOwnParams(v)")).toBe(false); expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false); + // A key no scan reads as a literal: computed, or shorthand. + expect(writerValueOk("{ page: p, [K]: v }")).toBe(false); + expect(writerValueOk("{ page: p, zz }")).toBe(false); expect(writerValueOk("Object.fromEntries(rows)")).toBe(false); }); @@ -380,6 +400,30 @@ describe("module URL keys", () => { expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]); }); + // …and every sub-view is handed one of those, by name: a definition written any other way (inline, + // `satisfies`) is one the census above does not count. + it("hand each sub-view a definition the census names", () => { + const handed = (text: string) => + [...text.matchAll(/\buseHistorySubview(?:<[^>()]*>)?\(/g)].flatMap((m) => { + const open = m.index + m[0].length - 1; + const args = topLevel(text.slice(open + 1, closing(text, open)), ","); + return args.length > 1 ? [args[1]!] : []; + }); + const named = new Set(["MODE_URL", "PLEX_SUB_URL"]); + const unnamed = (text: string) => handed(text).filter((arg) => !named.has(arg)); + const files = sources(SRC).filter( + (f) => !relative(SRC, f).split("\\").join("/").startsWith("lib/history"), + ); + expect(files.flatMap((f) => unnamed(readFileSync(f, "utf8")))).toEqual([]); + // The call sites are seen (a scan of none would pass)… + expect(new Set(files.flatMap((f) => handed(readFileSync(f, "utf8"))))).toEqual(named); + // …and the check tells an inline definition from a named one. + expect(unnamed('useHistorySubview(sub, { key: "zz", encode, decode })')).toEqual([ + '{ key: "zz", encode, decode }', + ]); + expect(unnamed("useHistorySubview(sub, PLEX_SUB_URL)")).toEqual([]); + }); + it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { expect(computedWriterKeys(true).some((w) => w.startsWith("lib/history.ts"))).toBe(true); }); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 29b53679..36143f8d 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -24,6 +24,7 @@ import { import { YouTubeIcon } from "../../lib/brandIcons"; import { api, + FEED_DEFAULT_SORT, HttpError, refusalReason, type FeedFilters, @@ -318,7 +319,7 @@ export default function Feed({ const qEmpty = !filters.q.trim(); useEffect(() => { if (qEmpty && parseSort(filters.sort).key === "relevance") { - setFilters({ ...filters, sort: buildSort("date", "desc") }); + setFilters({ ...filters, sort: FEED_DEFAULT_SORT }); } // Naming `filters` is safe precisely because the write below falsifies the condition above: the // sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was @@ -624,7 +625,7 @@ export default function Feed({ .catch((e: unknown) => notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")), ); - setFilters({ ...filters, librarySource: "organic", sort: "newest" }); + setFilters({ ...filters, librarySource: "organic", sort: FEED_DEFAULT_SORT }); onExitYtSearch(); dropFeedCaches(); qc.removeQueries({ queryKey: qk.ytSearch() }); diff --git a/frontend/src/modules/feed/FeedHeader.tsx b/frontend/src/modules/feed/FeedHeader.tsx index af2d3109..50f834f3 100644 --- a/frontend/src/modules/feed/FeedHeader.tsx +++ b/frontend/src/modules/feed/FeedHeader.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { FEED_DEFAULT_SORT } from "../../lib/api"; import { YouTubeIcon } from "../../lib/brandIcons"; import { useMe } from "../../lib/useMe"; import { PageHeader } from "../../components/ui/PageShell"; @@ -25,9 +26,9 @@ export default function FeedHeader() { placeholder={t("header.searchPlaceholder")} onChange={(q) => { // When a search first appears, rank the feed by relevance — set atomically with the - // query (race-free vs. per-keystroke updates). Only overrides the default "newest" + // query (race-free vs. per-keystroke updates). Only overrides the default // sort; a custom sort the user chose is left alone. Cleared in Feed. - const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest"; + const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === FEED_DEFAULT_SORT; setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); }} // Turned off on this instance, the box only filters: no Go that could only be refused. diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 4c8fb3dc..da980124 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -102,10 +102,10 @@ export function FilesProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "files"; // Written once the typing pauses (one address-bar write per keystroke is churn a browser throttles); - // an emptied box at once — an arrival's, or the page left: back on it within the pause, the address - // would name the ended search again. - const debounced = useDebounced(search.trim(), 300, focusRaw?.token); - const typed = search.trim() ? debounced : ""; + // a box emptied — an arrival's, or the page left — and filled again, each at once: the pause would + // name the ended search again, on its way out or before the new text settled. + const q = search.trim(); + const typed = useDebounced(q, 300, `${focusRaw?.token ?? ""}:${q === ""}`); useUrlMirror( filesListParams( shown ? { search: typed, folder: viewParam(view), item: focus ? focus.id : null } : null, diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index 1b59c041..e48d022d 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -12,6 +12,8 @@ const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true), selectedId: 1 as number | null, + // The selection the store started with (the address's, on a reload or a link). + linkedId: 1 as number | null, // Who reads the selection, so a test's change re-renders them the way the real store does. selectionReaders: new Set<() => void>(), mounted: false, @@ -49,6 +51,7 @@ vi.mock("./playlistsContext", async () => { return { usePlaylists: () => ({ selectedId: useSyncExternalStore(subscribe, () => h.selectedId), + linkedId: h.linkedId, search: "", }), usePlaylistsActions: () => ({ setSearch: () => {} }), @@ -136,8 +139,17 @@ function mount( { strictOverCache = false, detail, - }: { strictOverCache?: boolean; detail?: (id: number) => Promise } = {}, + linked = h.selectedId, + listIn = false, + }: { + strictOverCache?: boolean; + detail?: (id: number) => Promise; + linked?: number | null; + /** The account's list already in, as the rail that fetched it leaves it. */ + listIn?: boolean; + } = {}, ) { + h.linkedId = linked; vi.spyOn(api, "playlists").mockResolvedValue( Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never, ); @@ -146,6 +158,11 @@ function mount( ); qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs }); + if (listIn) + qc.setQueryData( + qk.playlists(), + Object.entries(lists).map(([id, items]) => list(Number(id), items)), + ); // Coming back to the page: the selected list is already cached, and StrictMode (dev) runs its // effects twice on that mount. if (strictOverCache && h.selectedId != null) @@ -248,6 +265,37 @@ describe("Playlists — a player the address names", () => { expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z"); }); + it("plays alone over a playlist this account does not hold, the rail moved on before the page came", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); + // The rail's keep-valid step ran before the page mounted: the selection is already 1, and 1 even + // holds the video — it is still not the playlist the link named. + h.selectedId = 1; + vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); + mount({ 1: [vid("A", 0), vid("Z", 0)] }, {}, { linked: 7, listIn: true }); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); + }); + + it("gives up over a playlist this account does not hold once another one is picked", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=7&watch=Z"); + h.selectedId = 7; + let answer: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)) as never); + const { rerender } = mount( + { 1: [vid("A", 0)], 2: [vid("B", 0)] }, + {}, + { detail: async (id) => (id === 7 ? new Promise(() => {}) : list(id, [])) }, + ); + await waitFor(() => expect(qc.getQueryData(qk.playlists())).toBeDefined()); + h.selectedId = 1; // the rail's own move + rerender(); + h.selectedId = 2; // a pick, before the video's row came + rerender(); + act(() => answer(vid("Z", 0))); + await new Promise((r) => setTimeout(r, 20)); + expect(h.player).toBeNull(); + }); + it("plays alone when the linked playlist's read is refused", async () => { window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z", 0)); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 5aad7d0b..aa020e50 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -192,7 +192,7 @@ export default function Playlists() { function PlaylistDetail() { // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the // list + its rail controls). This detail pane reacts to the selection. - const { selectedId } = usePlaylists(); + const { selectedId, linkedId } = usePlaylists(); const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); @@ -586,11 +586,14 @@ function PlaylistDetail() { // (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing // removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its // read refused. Scoped to the playlist it was linked over: another one picked first gives it up. - // One the account's list does not hold (deleted since, another account's link) is out of reach - // as soon as that list is in: the rail then moves to another one by itself, which is no pick. - const [linkedOver] = useState(selectedId); + // Linked over the playlist the store started with, not the selection this page mounted on: the rail + // may have moved that already. One the account's list does not hold (deleted since, another + // account's link) is out of reach as soon as that list is in: the rail then moves to the first one + // by itself, which is no pick — any other is. + const linkedOver = linkedId; const linkedOutOfReach = listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver); + const railMovedTo = listQuery.data?.[0]?.id ?? null; const linkedListIn = linkedOutOfReach || (detail != null && (detail.items.length === 0 || items.length > 0)) || @@ -605,7 +608,7 @@ function PlaylistDetail() { setPlaying({ video, queue: [video], index: 0, listId, editable: false }); } }, - linkedOutOfReach ? linkedOver : selectedId, + linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId, ); async function moveItemTo(video: Video, target: Playlist) { diff --git a/frontend/src/modules/playlists/PlaylistsProvider.tsx b/frontend/src/modules/playlists/PlaylistsProvider.tsx index f8ee283c..2a6c1ea3 100644 --- a/frontend/src/modules/playlists/PlaylistsProvider.tsx +++ b/frontend/src/modules/playlists/PlaylistsProvider.tsx @@ -31,6 +31,7 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { () => new URLSearchParams(window.location.search).get("plq") ?? "", ); const [selectedId, setSelectedIdState] = useState(readSelectedId); + const [linkedId] = useState(readSelectedId); const setSearch = useCallback((q: string) => setSearchState(q), []); const setSelectedId = useCallback((id: number | null) => { @@ -46,12 +47,16 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { const shown = urlPage(nav) === "playlists"; // Leaving the page ends the search: adjusted during the render that leaves it. if (nav.page !== "playlists" && search) setSearchState(""); - // An emptied box at once: back on the page within the pause, the address would name it again. - const debounced = useDebounced(search.trim(), 300); - const typed = search.trim() ? debounced : ""; + // A box emptied and filled again, each at once: the pause would name the ended search again, on + // its way out or before the new text settled. + const q = search.trim(); + const typed = useDebounced(q, 300, q === ""); useUrlMirror(shown ? playlistsParams(selectedId, typed) : playlistsParams(null, "")); - const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); + const state = useMemo( + () => ({ search, selectedId, linkedId }), + [search, selectedId, linkedId], + ); const actions = useMemo( () => ({ setSearch, setSelectedId }), [setSearch, setSelectedId], diff --git a/frontend/src/modules/playlists/playlistsContext.ts b/frontend/src/modules/playlists/playlistsContext.ts index 89dc9e7a..55d785f8 100644 --- a/frontend/src/modules/playlists/playlistsContext.ts +++ b/frontend/src/modules/playlists/playlistsContext.ts @@ -6,6 +6,9 @@ import { createContext, useContext } from "react"; export type PlaylistsState = { search: string; selectedId: number | null; + /** The selection the store started with — the playlist a reload or a link named. A player the + * address names opens over that one: the rail may have moved the selection before the page came. */ + linkedId: number | null; }; export interface PlaylistsActions { @@ -16,6 +19,7 @@ export interface PlaylistsActions { export const PlaylistsStateContext = createContext({ search: "", selectedId: null, + linkedId: null, }); export const PlaylistsActionsContext = createContext({ setSearch: () => {}, From 99a33e7fd169f53aaa6f7b3015123a49d057a9a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 28 Oct 2026 00:29:40 +0100 Subject: [PATCH 13/15] fix(history): a sub-view reads only its own module's from the entry it mounts on The entry's _sub now names its owner (_subOf, the module's address key). Signed out on a Plex season, the sign-in card took the season for one of its modes and rendered a card with an empty button; the other way round, Plex took the card's register view for one of its own. An entry an older build wrote is read as the module's only when its address names one of that module's views. --- frontend/src/lib/history.subview.dom.test.ts | 51 ++++++++++++++++++- frontend/src/lib/history.ts | 23 ++++++--- .../plex/PlexBrowse.playShow.dom.test.tsx | 2 +- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index c7d96658..3ce4e66e 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -63,7 +63,7 @@ describe("useHistorySubview seed", () => { }); it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => { - setUrl("?v=a", { _sub: "fromState" }); + setUrl("?v=a", { _sub: "fromState", _subOf: "" }); const { result } = renderHook(() => useHistorySubview("root")); expect(result.current.view).toBe("fromState"); // A URL param with no url config must NOT leak in. @@ -128,6 +128,55 @@ describe("useHistorySubview open", () => { }); }); +// One entry, two modules: the sign-in card mounts on the entry a Plex season stood on (signed out +// there), and Plex on one the card's register view stood on (signed in from it). Each reads only its +// own sub-view: another's is not one of its views (the card rendered a mode it has no button for). +describe("useHistorySubview on an entry another module's sub-view stood on", () => { + const other: SubviewUrl = { + key: "m", + encode: (v) => (v === "signin" ? null : v), + decode: (raw) => (raw === "register" ? raw : null), + }; + + it("opens at its own root, not on the other's view", () => { + const plex = renderHook(() => useHistorySubview("root", url)); + act(() => plex.result.current.open("season")); + plex.unmount(); + const card = renderHook(() => useHistorySubview("signin", other)); + expect(card.result.current.view).toBe("signin"); + }); + + it("the other way round too", () => { + const card = renderHook(() => useHistorySubview("signin", other)); + act(() => card.result.current.open("register")); + card.unmount(); + window.history.replaceState(window.history.state, "", "/"); + const plex = renderHook(() => useHistorySubview("root", url)); + expect(plex.result.current.view).toBe("root"); + }); + + it("an older build's entry, which says nothing: its own only when its address names its view", () => { + setUrl("?v=season", { _sub: "season" }); + expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe("signin"); + expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); + }); + + it("reads its own from an entry it rewrote in place, which no address names", () => { + const first = renderHook(() => useHistorySubview("root")); + act(() => first.result.current.open("x", { replace: true })); + first.unmount(); + expect(renderHook(() => useHistorySubview("root")).result.current.view).toBe("x"); + }); + + it("still reads its own", () => { + const plex = renderHook(() => useHistorySubview("root", url)); + act(() => plex.result.current.open("season")); + plex.unmount(); + window.history.replaceState(window.history.state, "", "/"); + expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); + }); +}); + describe("useHistorySubview popstate", () => { it("restores the sub-view from state._sub on Back/Forward", () => { const { result } = renderHook(() => useHistorySubview("root", url)); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 8086f7b7..182cb61c 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -139,6 +139,9 @@ export interface SubviewUrl { entry?: (view: T) => T; } +/** Whose sub-view an entry's `_sub` is: the key its module mirrors to (a state-only one has none). */ +const subOwner = (url: { key: string } | undefined): string => url?.key ?? ""; + export function useHistorySubview( root: T, url?: SubviewUrl, @@ -158,14 +161,18 @@ export function useHistorySubview( // ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a // bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and // reading history.state alone would wrongly collapse the bookmarked sub-view to root. + // The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another + // module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and + // took that season for one of its modes (a card with no button for it). An entry an older build + // wrote says nothing: its _sub is this module's when its address names one of this module's views. const readView = (): T => { - const fromState = window.history.state?._sub as T | undefined; + const st = window.history.state; + const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null; + const decoded = url && raw != null ? url.decode(raw) : null; + const owned = st?._subOf === undefined ? decoded != null : st._subOf === subOwner(url); + const fromState = owned ? (st?._sub as T | undefined) : undefined; if (fromState != null) return fromState; - if (url) { - const raw = new URLSearchParams(window.location.search).get(url.key); - const decoded = raw != null ? url.decode(raw) : null; - if (decoded != null) return decoded; - } + if (decoded != null) return decoded; return root; }; const [view, setView] = useState(readView); @@ -211,7 +218,7 @@ export function useHistorySubview( // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. window.history.pushState( - { ...rememberNavPosition(), _sub: kept, sfScroll: nextNavId() }, + { ...rememberNavPosition(), _sub: kept, _subOf: subOwner(url), sfScroll: nextNavId() }, "", href, ); @@ -253,7 +260,7 @@ export function useHistorySubview( function writeSubEntry(next: T, url: SubviewUrl | undefined): void { const kept = url?.entry ? url.entry(next) : next; const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; - replaceView({ ...window.history.state, _sub: kept }, href); + replaceView({ ...window.history.state, _sub: kept, _subOf: subOwner(url) }, href); } // --- Overlays / modals (one history entry each, nesting-safe) ------------------------------- diff --git a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx index 18af5433..152c2412 100644 --- a/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx +++ b/frontend/src/modules/plex/PlexBrowse.playShow.dom.test.tsx @@ -45,7 +45,7 @@ beforeAll(() => { globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); -beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, "")); +beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" }, _subOf: "v" }, "")); afterEach(() => { cleanup(); vi.restoreAllMocks(); From 5686759bcad20098d160cdbc3ca276902a9af040 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 28 Oct 2026 01:39:26 +0100 Subject: [PATCH 14/15] fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useUrlMirror: a change before the user has done anything since the load is the page settling (the rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload mark stays, and the player that reopens takes its entry over — one Back closes it, as before. - useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the address names under that module's key (the card's push kept the season's `v` beside its `m`). - Playlists: the rail's default selection and the page's "the rail moved there" read one helper. - moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census walks type arguments of any depth and refuses an aliased import. - A stale comment on the nav press over a live search. --- .../navigation/NavigationProvider.tsx | 2 +- frontend/src/lib/history.subview.dom.test.ts | 11 +++- frontend/src/lib/history.ts | 8 ++- .../useUrlMirror.reloadedSettle.dom.test.tsx | 37 +++++++++++ frontend/src/lib/useUrlMirror.ts | 12 +++- frontend/src/moduleUrl.contract.test.ts | 64 +++++++++++++------ .../modules/playlists/Playlists.dom.test.tsx | 20 ++++++ frontend/src/modules/playlists/Playlists.tsx | 3 +- .../src/modules/playlists/PlaylistsRail.tsx | 3 +- .../src/modules/playlists/playlistsInit.ts | 6 ++ 10 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 frontend/src/lib/useUrlMirror.reloadedSettle.dom.test.tsx diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 269eb1e1..93989d48 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -144,7 +144,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { closeOverlaysThenNavigate(() => { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). - // A live search on it is one too: the press opens the page's root, as over a channel. + // So must a live search on it, though that press ends only the search: the page's view stays. const samePage = next === pageRef.current && !channelViewRef.current; if (samePage && ytSearchRef.current == null) { then?.(); diff --git a/frontend/src/lib/history.subview.dom.test.ts b/frontend/src/lib/history.subview.dom.test.ts index 3ce4e66e..858f56b5 100644 --- a/frontend/src/lib/history.subview.dom.test.ts +++ b/frontend/src/lib/history.subview.dom.test.ts @@ -51,7 +51,7 @@ describe("useHistorySubview seed", () => { }); it("prefers history.state._sub over the URL param (in-app entry)", () => { - setUrl("?v=a", { _sub: "b" }); + setUrl("?v=a", { _sub: "b", _subOf: "v" }); const { result } = renderHook(() => useHistorySubview("root", url)); expect(result.current.view).toBe("b"); }); @@ -161,6 +161,15 @@ describe("useHistorySubview on an entry another module's sub-view stood on", () expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); }); + it("an older build's entry whose address names both: the _sub goes to the one it is the view of", () => { + // The card's push kept the season's `v` beside its own `m`. + setUrl("?v=season&m=register", { _sub: "register" }); + expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season"); + expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe( + "register", + ); + }); + it("reads its own from an entry it rewrote in place, which no address names", () => { const first = renderHook(() => useHistorySubview("root")); act(() => first.result.current.open("x", { replace: true })); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 182cb61c..52d660c8 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -164,12 +164,16 @@ export function useHistorySubview( // The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another // module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and // took that season for one of its modes (a card with no button for it). An entry an older build - // wrote says nothing: its _sub is this module's when its address names one of this module's views. + // wrote says nothing: its _sub is this module's when it is the view the address names under this + // module's key. The key alone is not enough: the card's push kept the season's `v` beside its `m`. const readView = (): T => { const st = window.history.state; const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null; const decoded = url && raw != null ? url.decode(raw) : null; - const owned = st?._subOf === undefined ? decoded != null : st._subOf === subOwner(url); + const owned = + st?._subOf === undefined + ? decoded != null && st?._sub != null && url?.encode(st._sub as T) === raw + : st._subOf === subOwner(url); const fromState = owned ? (st?._sub as T | undefined) : undefined; if (fromState != null) return fromState; if (decoded != null) return decoded; diff --git a/frontend/src/lib/useUrlMirror.reloadedSettle.dom.test.tsx b/frontend/src/lib/useUrlMirror.reloadedSettle.dom.test.tsx new file mode 100644 index 00000000..8dc04b96 --- /dev/null +++ b/frontend/src/lib/useUrlMirror.reloadedSettle.dom.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +// +// A reload under an overlay that reopens (the player) loads on the overlay's entry, its mark on it. +// A page that settles before the overlay is back (the rail steps off a playlist deleted since, before +// the player's row has loaded) has not been sent to another view: nothing the user did moved it. The +// mark stays, and the reopened overlay takes its entry over rather than pushing one more — one more +// was a Back that went nowhere. Its own file: the entry is read as the history module loads, so it is +// set up before the import. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +describe("a page settling on the entry a reload under an overlay started from", () => { + it("before the user has done anything, leaves the entry to the overlay that reopens", async () => { + Object.defineProperty(navigator, "userActivation", { + configurable: true, + value: { hasBeenActive: false }, + }); + window.history.replaceState({ sfPage: "playlists" }, "", "/?page=playlists&pl=7"); + window.history.pushState( + { sfPage: "playlists", _ov: 1, _ovd: 1 }, + "", + "/?page=playlists&pl=7&watch=Z", + ); + const { useUrlMirror } = await import("./useUrlMirror"); + const { useBackToClose } = await import("./history"); + const mirror = renderHook(({ pl }) => useUrlMirror({ pl }), { initialProps: { pl: "7" } }); + mirror.rerender({ pl: "1" }); + expect(new URLSearchParams(window.location.search).get("pl")).toBe("1"); + + const depth = window.history.length; + renderHook(() => useBackToClose(() => {})); + await act(async () => { + await Promise.resolve(); + }); + expect(window.history.length).toBe(depth); + }); +}); diff --git a/frontend/src/lib/useUrlMirror.ts b/frontend/src/lib/useUrlMirror.ts index 751e1fc3..68d3c6da 100644 --- a/frontend/src/lib/useUrlMirror.ts +++ b/frontend/src/lib/useUrlMirror.ts @@ -13,6 +13,12 @@ import { urlWithParams } from "./urlState"; // or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one // on screen. (One landed on by an overlay's step back that another step will leave is written too, // and harmlessly: the next landing writes the entry that stays.) +// Whether a click or a key press has reached the page since it loaded. A browser without the API +// counts as yes: every change is then another view, as before it existed. +const userHasActed = (): boolean => + (navigator as { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive ?? + true; + export function useUrlMirror(updates: Record): void { // The caller typically passes an inline object literal (new identity every render); key on its // serialized value so the effect only fires when a param actually changes, not every render. @@ -23,11 +29,13 @@ export function useUrlMirror(updates: Record): void { // A change of what this mirror writes, after its first write, is another view on the same entry // (replaceView: it drops the marks a reload under an overlay left). Its first write on a mount and // its write on a landing name the view the entry already shows, so the marks stay: an overlay that - // reopens after a reload still finds its entry. + // reopens after a reload still finds its entry. So does a change before the user has done anything + // since the load: that is the page settling (the rail stepping off a playlist deleted since), and + // the address under the entry names the same screen, settled again on the way back. const written = useRef(null); const mirror = useEffectEvent(() => { const href = urlWithParams(updates); - const changed = written.current != null && written.current !== dep; + const changed = written.current != null && written.current !== dep && userHasActed(); written.current = dep; // Every mirror runs on every landing: one with nothing to change writes nothing (a browser // throttles a page that rewrites its address too often). diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts index b2d5a931..791eb79d 100644 --- a/frontend/src/moduleUrl.contract.test.ts +++ b/frontend/src/moduleUrl.contract.test.ts @@ -85,22 +85,15 @@ function sources(dir: string): string[] { }); } -// The object literals handed to an address writer: `urlWithParams` / `useUrlMirror`, an overlay's -// own keys (ui/Modal's `url`, `useBackToClose`'s). Each pattern ends at the literal's opening brace; -// the literal runs to its matching close (`writerObjects`), nested arguments and all. -const WRITER_OBJECTS = [ - /(?:urlWithParams|useUrlMirror)\(\{/g, - /\burl=\{\{/g, - // Its first argument a name, a call or an arrow (`() => setOpen(false)`). - /useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{/g, -]; -// One of each form: a regex that matched nothing in the tree would pass every scan. +// One of each form a writer is handed its value in: a scan that matched nothing in the tree would +// pass every check. const WRITER_FIXTURE = ` useUrlMirror({ fxa: x, fxf }); useBackToClose(onClose, { fxc: "1" }); useBackToClose(() => setOpen(false), { fxd: "1" }); urlWithParams({ ...fxCodec({ ...fxState, job }), fxe: "1" }); + useUrlMirror(shown ? { fxg: x } : null); `; // Where a writer outside lib/ is handed its value: the address writers' argument, and an @@ -134,14 +127,32 @@ function topLevel(text: string, sep: string): string[] { out.push(text.slice(from)); return out.map((s) => s.trim()); } -/** The top-level pieces of every writer's object literal in `text` (`a: x`, `...f(y)`, `[K]: v`, `b`). */ +/** The top-level pieces of every writer's object literal in `text` (`a: x`, `...f(y)`, `[K]: v`, `b`): + * the values `writerValueOk` passes, read the same way, a choice's branches included — every literal + * it lets through is one whose keys are scanned. */ function writerObjects(text: string): string[][] { - return WRITER_OBJECTS.flatMap((re) => - [...text.matchAll(re)].map((m) => { - const open = m.index + m[0].length - 1; - return topLevel(text.slice(open + 1, closing(text, open)), ",").filter(Boolean); - }), - ); + return writerValues(text).flatMap(literalObjects); +} +function literalObjects(value: string): string[][] { + const v = value.trim(); + const choice = topLevel(v, "?"); + if (choice.length === 2) return topLevel(choice[1]!, ":").flatMap(literalObjects); + return v.startsWith("{") ? [topLevel(v.slice(1, closing(v, 0)), ",").filter(Boolean)] : []; +} +/** Where the call of the name ending at `at` opens its arguments, past a type argument list of any + * depth (`>`, `<(v: V) => V>`); null when the name is not called there (an import). */ +function callOpen(text: string, at: number): number | null { + let i = at; + while (/\s/.test(text[i] ?? "")) i++; + if (text[i] === "<") { + let depth = 0; + for (; i < text.length; i++) { + if (text[i] === "<") depth++; + else if (text[i] === ">" && text[i - 1] !== "=" && --depth === 0) break; + } + i++; + } + return text[i] === "(" ? i : null; } /** The key a piece names as a literal: `key: value`, or the shorthand `key`. */ const keyOf = (piece: string) => /^(\w+)\s*:/.exec(piece)?.[1] ?? /^(\w+)$/.exec(piece)?.[1]; @@ -372,8 +383,8 @@ describe("module URL keys", () => { const keys = writerObjects(WRITER_FIXTURE).flatMap((pieces) => pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))), ); - expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf"]); - expect(writerValues(WRITER_FIXTURE)).toHaveLength(5); + expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf", "fxg"]); + expect(writerValues(WRITER_FIXTURE)).toHaveLength(6); }); it("are written outside lib/ only as a literal or a lib/ function's result", () => { @@ -404,8 +415,9 @@ describe("module URL keys", () => { // `satisfies`) is one the census above does not count. it("hand each sub-view a definition the census names", () => { const handed = (text: string) => - [...text.matchAll(/\buseHistorySubview(?:<[^>()]*>)?\(/g)].flatMap((m) => { - const open = m.index + m[0].length - 1; + [...text.matchAll(/\buseHistorySubview\b/g)].flatMap((m) => { + const open = callOpen(text, m.index + m[0].length); + if (open == null) return []; const args = topLevel(text.slice(open + 1, closing(text, open)), ","); return args.length > 1 ? [args[1]!] : []; }); @@ -422,6 +434,16 @@ describe("module URL keys", () => { '{ key: "zz", encode, decode }', ]); expect(unnamed("useHistorySubview(sub, PLEX_SUB_URL)")).toEqual([]); + expect(unnamed('useHistorySubview>(sub, { key: "zz" })')).toEqual([ + '{ key: "zz" }', + ]); + expect(unnamed('useHistorySubview<(v: V) => V>(sub, { key: "zz" })')).toEqual([ + '{ key: "zz" }', + ]); + // Under another name it is a call no scan sees. + const aliased = (text: string) => /\buseHistorySubview\s+as\b/.test(text); + expect(files.filter((f) => aliased(readFileSync(f, "utf8")))).toEqual([]); + expect(aliased('import { useHistorySubview as useSub } from "./history";')).toBe(true); }); it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => { diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index e48d022d..d6950002 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -265,6 +265,26 @@ describe("Playlists — a player the address names", () => { expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z"); }); + it("plays alone over a playlist this account does not hold, the rail moving to the first of several", async () => { + window.history.replaceState(null, "", "/?page=playlists&pl=7&watch=Z"); + h.selectedId = 7; + let answer: (v: Video) => void = () => {}; + vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)) as never); + const { rerender } = mount( + { 1: [vid("A", 0)], 2: [vid("B", 0)] }, + {}, + { + detail: async (id) => (id === 7 ? new Promise(() => {}) : list(id, [vid("A", 0)])), + }, + ); + await waitFor(() => expect(qc.getQueryData(qk.playlists())).toBeDefined()); + h.selectedId = 1; // the rail's own move: to the first of the account's list + rerender(); + act(() => answer(vid("Z", 0))); + await waitFor(() => expect(h.player?.video.id).toBe("Z")); + expect(h.player!.queue?.map((v) => v.id)).toEqual(["Z"]); + }); + it("plays alone over a playlist this account does not hold, the rail moved on before the page came", async () => { window.history.replaceState(null, "", "/?page=playlists&pl=1&watch=Z"); // The rail's keep-valid step ran before the page mounted: the selection is already 1, and 1 even diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index aa020e50..3ac7cf0b 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -65,6 +65,7 @@ import NavGrid from "../../components/ui/NavGrid"; import { PageToolbar } from "../../components/ui/PageShell"; import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylists } from "./playlistsContext"; +import { railDefaultId } from "./playlistsInit"; import { usePref } from "../../lib/usePref"; import { navItemAttrs } from "../../lib/listNav"; import { ColumnHead } from "../../components/ui/tableHeader"; @@ -593,7 +594,7 @@ function PlaylistDetail() { const linkedOver = linkedId; const linkedOutOfReach = listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver); - const railMovedTo = listQuery.data?.[0]?.id ?? null; + const railMovedTo = railDefaultId(listQuery.data); const linkedListIn = linkedOutOfReach || (detail != null && (detail.items.length === 0 || items.length > 0)) || diff --git a/frontend/src/modules/playlists/PlaylistsRail.tsx b/frontend/src/modules/playlists/PlaylistsRail.tsx index c6b21521..e4474984 100644 --- a/frontend/src/modules/playlists/PlaylistsRail.tsx +++ b/frontend/src/modules/playlists/PlaylistsRail.tsx @@ -21,6 +21,7 @@ import { type PanelItem } from "../../components/ui/PanelGroups"; import SortControl from "../../components/ui/SortControl"; import { type SortOption } from "../../lib/columnSort"; import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; +import { railDefaultId } from "./playlistsInit"; import { useMe } from "../../lib/useMe"; import { playlistName } from "../../lib/playlistName"; import { usePlaylistActions } from "./usePlaylistActions"; @@ -105,7 +106,7 @@ export default function PlaylistsRail() { return; } if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns) + setSelectedId(railDefaultId(playlists)); } }, [playlists, selectedId, listQuery.data, setSelectedId]); diff --git a/frontend/src/modules/playlists/playlistsInit.ts b/frontend/src/modules/playlists/playlistsInit.ts index dd6ec737..84c7ea6e 100644 --- a/frontend/src/modules/playlists/playlistsInit.ts +++ b/frontend/src/modules/playlists/playlistsInit.ts @@ -12,3 +12,9 @@ export function readSelectedId(): number | null { parsePositiveIntId(getAccountRaw(LS.playlist)) ); } + +/** Where the rail puts the selection when it holds none of the account's playlists: the first one. + * The page reads the same, so a move there is told apart from a pick (a linked player's scope). */ +export function railDefaultId(list: readonly { id: number }[] | undefined): number | null { + return list?.[0]?.id ?? null; +} From 6aa6a6efdf3ff20688c9a3c132d3412c67c30edb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 28 Oct 2026 02:07:48 +0100 Subject: [PATCH 15/15] chore(release): 0.113.0 --- CHANGELOG.md | 14 ++++++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 14 ++++++++++++++ frontend/src/content/release-notes/hu.json | 14 ++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88de373e..3f4efffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.113.0 — 2026-10-28 + +### New + +- The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view. +- A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so. + +### Fixed + +- Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing. +- Signed out on a Plex page, the sign-in card could show a button with no text. +- The channel page's address names the channel and its own view, not the page under it. +- A reload during a Plex binge reopens the episode playing now, not the one it started from. + ## 0.112.1 — 2026-10-27 ### Fixed diff --git a/VERSION b/VERSION index 52afba83..5fea1768 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.112.1 +0.113.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index de3226ca..a691894b 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,18 @@ [ + { + "version": "0.113.0", + "date": "2026-10-28", + "new": [ + "The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.", + "A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so." + ], + "fixed": [ + "Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.", + "Signed out on a Plex page, the sign-in card could show a button with no text.", + "The channel page's address names the channel and its own view, not the page under it.", + "A reload during a Plex binge reopens the episode playing now, not the one it started from." + ] + }, { "version": "0.112.1", "date": "2026-10-27", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index 72277b72..7ae3364c 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,18 @@ [ + { + "version": "0.113.0", + "date": "2026-10-28", + "new": [ + "A cím megnevezi, amit látsz: a füleket, a listanézeteket, a rendezést és a kereséseket (az élő YouTube-keresést is), a YouTube-lejátszót és a fájlnézőt. Újratöltés, könyvjelző vagy megosztott link ugyanazt a nézetet nyitja meg.", + "Egy videóra mutató link egyszer újranyitja a lejátszót az oldala fölött, a Vissza bezárja. Ha az oldal már nem listázza a videót, a lejátszó csak azt az egyet játssza le, és ezt jelzi is." + ], + "fixed": [ + "A Vissza és az Előre úgy lép végig a nézeteken, ahogy láttad őket, párbeszédablakokon, lejátszókon és újratöltésen át: nyitott lejátszó alatti újratöltés után nem marad semmit sem csináló Vissza-lépés.", + "Plex-oldalon kijelentkezve a bejelentkező kártyán felirat nélküli gomb jelenhetett meg.", + "A csatornaoldal címe a csatornát és annak saját nézetét nevezi meg, nem az alatta lévő oldalt.", + "Plexben sorozatnézés közben az újratöltés az épp futó epizódot nyitja meg, nem azt, amelyikkel kezdted." + ] + }, { "version": "0.112.1", "date": "2026-10-27",