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
@@ -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<never>(() => {}) : 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
+2 -1
View File
@@ -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)) ||
@@ -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]);
@@ -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;
}