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.
This commit is contained in:
2026-10-27 23:33:22 +01:00
parent 66e427dfa4
commit 02906d0acb
18 changed files with 282 additions and 53 deletions
+63 -19
View File
@@ -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 });
<Modal url={{ fxb: String(id) }} />
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>(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);
});