fix(nav): a linked player, file and search survive what the app does by itself (review round 2)

- 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.
This commit is contained in:
2026-10-27 16:08:52 +01:00
parent 0d5c6273f1
commit 6d57d62ac8
38 changed files with 717 additions and 187 deletions
+187 -10
View File
@@ -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 });
<Modal url={{ fxb: String(id) }} />
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</g)].map((m) => 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",