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:
@@ -144,7 +144,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
closeOverlaysThenNavigate(() => {
|
closeOverlaysThenNavigate(() => {
|
||||||
// A channel page overlays the content column, so a nav click must close it even when the
|
// 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).
|
// 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;
|
const samePage = next === pageRef.current && !channelViewRef.current;
|
||||||
if (samePage && ytSearchRef.current == null) {
|
if (samePage && ytSearchRef.current == null) {
|
||||||
then?.();
|
then?.();
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("useHistorySubview seed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers history.state._sub over the URL param (in-app entry)", () => {
|
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));
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
||||||
expect(result.current.view).toBe("b");
|
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");
|
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", () => {
|
it("reads its own from an entry it rewrote in place, which no address names", () => {
|
||||||
const first = renderHook(() => useHistorySubview("root"));
|
const first = renderHook(() => useHistorySubview("root"));
|
||||||
act(() => first.result.current.open("x", { replace: true }));
|
act(() => first.result.current.open("x", { replace: true }));
|
||||||
|
|||||||
@@ -164,12 +164,16 @@ export function useHistorySubview<T>(
|
|||||||
// The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another
|
// 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
|
// 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
|
// 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 readView = (): T => {
|
||||||
const st = window.history.state;
|
const st = window.history.state;
|
||||||
const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null;
|
const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null;
|
||||||
const decoded = url && raw != null ? url.decode(raw) : 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;
|
const fromState = owned ? (st?._sub as T | undefined) : undefined;
|
||||||
if (fromState != null) return fromState;
|
if (fromState != null) return fromState;
|
||||||
if (decoded != null) return decoded;
|
if (decoded != null) return decoded;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// 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,
|
// 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.)
|
// 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<string, string | null>): void {
|
export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||||
// The caller typically passes an inline object literal (new identity every render); key on its
|
// 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.
|
// serialized value so the effect only fires when a param actually changes, not every render.
|
||||||
@@ -23,11 +29,13 @@ export function useUrlMirror(updates: Record<string, string | null>): void {
|
|||||||
// A change of what this mirror writes, after its first write, is another view on the same entry
|
// 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
|
// (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
|
// 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<string | null>(null);
|
const written = useRef<string | null>(null);
|
||||||
const mirror = useEffectEvent(() => {
|
const mirror = useEffectEvent(() => {
|
||||||
const href = urlWithParams(updates);
|
const href = urlWithParams(updates);
|
||||||
const changed = written.current != null && written.current !== dep;
|
const changed = written.current != null && written.current !== dep && userHasActed();
|
||||||
written.current = dep;
|
written.current = dep;
|
||||||
// Every mirror runs on every landing: one with nothing to change writes nothing (a browser
|
// Every mirror runs on every landing: one with nothing to change writes nothing (a browser
|
||||||
// throttles a page that rewrites its address too often).
|
// throttles a page that rewrites its address too often).
|
||||||
|
|||||||
@@ -85,22 +85,15 @@ function sources(dir: string): string[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The object literals handed to an address writer: `urlWithParams` / `useUrlMirror`, an overlay's
|
// One of each form a writer is handed its value in: a scan that matched nothing in the tree would
|
||||||
// own keys (ui/Modal's `url`, `useBackToClose`'s). Each pattern ends at the literal's opening brace;
|
// pass every check.
|
||||||
// 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.
|
|
||||||
const WRITER_FIXTURE = `
|
const WRITER_FIXTURE = `
|
||||||
useUrlMirror({ fxa: x, fxf });
|
useUrlMirror({ fxa: x, fxf });
|
||||||
<Modal url={{ fxb: String(id) }} />
|
<Modal url={{ fxb: String(id) }} />
|
||||||
useBackToClose(onClose, { fxc: "1" });
|
useBackToClose(onClose, { fxc: "1" });
|
||||||
useBackToClose(() => setOpen(false), { fxd: "1" });
|
useBackToClose(() => setOpen(false), { fxd: "1" });
|
||||||
urlWithParams({ ...fxCodec({ ...fxState, job }), fxe: "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
|
// 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));
|
out.push(text.slice(from));
|
||||||
return out.map((s) => s.trim());
|
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[][] {
|
function writerObjects(text: string): string[][] {
|
||||||
return WRITER_OBJECTS.flatMap((re) =>
|
return writerValues(text).flatMap(literalObjects);
|
||||||
[...text.matchAll(re)].map((m) => {
|
}
|
||||||
const open = m.index + m[0].length - 1;
|
function literalObjects(value: string): string[][] {
|
||||||
return topLevel(text.slice(open + 1, closing(text, open)), ",").filter(Boolean);
|
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`. */
|
/** 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 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) =>
|
const keys = writerObjects(WRITER_FIXTURE).flatMap((pieces) =>
|
||||||
pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))),
|
pieces.flatMap((piece) => (piece.startsWith("...") ? [] : (keyOf(piece) ?? []))),
|
||||||
);
|
);
|
||||||
expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf"]);
|
expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd", "fxe", "fxf", "fxg"]);
|
||||||
expect(writerValues(WRITER_FIXTURE)).toHaveLength(5);
|
expect(writerValues(WRITER_FIXTURE)).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("are written outside lib/ only as a literal or a lib/ function's result", () => {
|
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.
|
// `satisfies`) is one the census above does not count.
|
||||||
it("hand each sub-view a definition the census names", () => {
|
it("hand each sub-view a definition the census names", () => {
|
||||||
const handed = (text: string) =>
|
const handed = (text: string) =>
|
||||||
[...text.matchAll(/\buseHistorySubview(?:<[^>()]*>)?\(/g)].flatMap((m) => {
|
[...text.matchAll(/\buseHistorySubview\b/g)].flatMap((m) => {
|
||||||
const open = m.index + m[0].length - 1;
|
const open = callOpen(text, m.index + m[0].length);
|
||||||
|
if (open == null) return [];
|
||||||
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
|
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
|
||||||
return args.length > 1 ? [args[1]!] : [];
|
return args.length > 1 ? [args[1]!] : [];
|
||||||
});
|
});
|
||||||
@@ -422,6 +434,16 @@ describe("module URL keys", () => {
|
|||||||
'{ key: "zz", encode, decode }',
|
'{ key: "zz", encode, decode }',
|
||||||
]);
|
]);
|
||||||
expect(unnamed("useHistorySubview<Sub>(sub, PLEX_SUB_URL)")).toEqual([]);
|
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)", () => {
|
it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => {
|
||||||
|
|||||||
@@ -265,6 +265,26 @@ describe("Playlists — a player the address names", () => {
|
|||||||
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Z");
|
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 () => {
|
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");
|
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
|
// The rail's keep-valid step ran before the page mounted: the selection is already 1, and 1 even
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import NavGrid from "../../components/ui/NavGrid";
|
|||||||
import { PageToolbar } from "../../components/ui/PageShell";
|
import { PageToolbar } from "../../components/ui/PageShell";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
import { usePlaylists } from "./playlistsContext";
|
import { usePlaylists } from "./playlistsContext";
|
||||||
|
import { railDefaultId } from "./playlistsInit";
|
||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { navItemAttrs } from "../../lib/listNav";
|
import { navItemAttrs } from "../../lib/listNav";
|
||||||
import { ColumnHead } from "../../components/ui/tableHeader";
|
import { ColumnHead } from "../../components/ui/tableHeader";
|
||||||
@@ -593,7 +594,7 @@ function PlaylistDetail() {
|
|||||||
const linkedOver = linkedId;
|
const linkedOver = linkedId;
|
||||||
const linkedOutOfReach =
|
const linkedOutOfReach =
|
||||||
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
|
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
|
||||||
const railMovedTo = listQuery.data?.[0]?.id ?? null;
|
const railMovedTo = railDefaultId(listQuery.data);
|
||||||
const linkedListIn =
|
const linkedListIn =
|
||||||
linkedOutOfReach ||
|
linkedOutOfReach ||
|
||||||
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
|
(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 SortControl from "../../components/ui/SortControl";
|
||||||
import { type SortOption } from "../../lib/columnSort";
|
import { type SortOption } from "../../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||||
|
import { railDefaultId } from "./playlistsInit";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { playlistName } from "../../lib/playlistName";
|
import { playlistName } from "../../lib/playlistName";
|
||||||
import { usePlaylistActions } from "./usePlaylistActions";
|
import { usePlaylistActions } from "./usePlaylistActions";
|
||||||
@@ -105,7 +106,7 @@ export default function PlaylistsRail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
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]);
|
}, [playlists, selectedId, listQuery.data, setSelectedId]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ export function readSelectedId(): number | null {
|
|||||||
parsePositiveIntId(getAccountRaw(LS.playlist))
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user