fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)

- 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.
This commit is contained in:
2026-10-28 01:39:26 +01:00
parent 99a33e7fd1
commit 5686759bca
10 changed files with 137 additions and 29 deletions
+43 -21
View File
@@ -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 });
<Modal url={{ fxb: String(id) }} />
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 (`<Array<X>>`, `<(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>(sub, PLEX_SUB_URL)")).toEqual([]);
expect(unnamed('useHistorySubview<Array<string>>(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)", () => {