Files
siftlode/frontend/src/lib/history.navPosition.dom.test.tsx
T
peter 49fe56b6e7 fix(nav): Back's focus keeps its place among items of the same name
Restoring by name picked the first item of that name, so Back on the log console (repeated
http.request lines) or two same-titled downloads landed on the wrong one. The saved place wins while
it still holds the name; otherwise the nearest item of that name.
2026-09-19 05:47:16 +02:00

243 lines
9.3 KiB
TypeScript

// @vitest-environment jsdom
//
// Back hands focus to the list item a view was left from (E11 S5): opening a Plex title from Home's
// rail switches the page, which unmounts the rail, so focus used to come back to <body>. The position is
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
// state is current and a popstate carries it. Its own file: history.ts and listNav keep module state.
import { useEffect, useRef, useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { rememberNavPosition } from "./history";
import { navItemAttrs, useListNav } from "./listNav";
function List({ id, count, label }: { id: string; count: number; label?: string }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label={label}>
{Array.from({ length: count }, (_, i) => (
<div key={i} data-testid={`${id}${i}`} {...navItemAttrs(i)}>
{id}
{i}
</div>
))}
</div>
);
}
const Page = () => (
<>
<List id="a" count={2} />
<List id="b" count={3} />
</>
);
const press = (key: string) =>
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
const sleep = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
/** Walk `presses` items in, stamp the entry and open another view over it; returns the stamped state. */
function leaveFrom(item: string, presses: number): unknown {
for (let i = 0; i < presses; i++) press("j");
expect(focused()).toBe(item);
const spread = rememberNavPosition();
const stamped = window.history.state;
window.history.pushState({ ...spread, view: "other" }, "");
return stamped;
}
function landOn(state: unknown) {
window.history.replaceState(state, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
afterEach(() => {
// A restore that gave up waiting for its list polls on to its deadline; a push cancels it, and one
// left running fires after the file's jsdom is torn down ("document is not defined").
rememberNavPosition();
cleanup();
(document.activeElement as HTMLElement | null)?.blur?.();
window.history.replaceState(null, "");
});
describe("rememberNavPosition · Back focuses the item a view was left from", () => {
it("focuses the item once the page's lists are back, and spends the stamp", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
expect(window.history.state.sfNav).toBeUndefined();
unmount();
landOn(stamped);
await sleep(120);
render(<Page />);
await waitFor(() => expect(focused()).toBe("b1"));
expect(window.history.state.sfNav).toBeUndefined();
});
// Left from a1 (list 0 of 2). If the second list mounts first, "list 0" on screen is list b — so the
// restore must wait until the page has both lists again, not land on b1.
it("waits for every list the page had — the first list to mount is not list 0", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
const { rerender } = render(<List id="b" count={3} />);
await sleep(120);
expect(focused()).toBeUndefined();
rerender(<Page />);
await waitFor(() => expect(focused()).toBe("a1"));
});
// A sub-view (a Plex show page) switches back in its OWN popstate listener, registered after this
// module's: while ours runs, focus still sits in the view being left. Measured in Chrome (deep-review
// E11 round 1) — Discover card → show → `j` on the show page → Back left focus on <body>.
it("restores when a later popstate listener switches the view the focus was still in", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
function Views() {
const [view, setView] = useState<"sub" | "page">("sub");
useEffect(() => {
const onPop = () => setView("page");
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return view === "page" ? <Page /> : <List id="s" count={2} />;
}
render(<Views />);
press("j");
expect(focused()).toBe("s0");
landOn(stamped);
await waitFor(() => expect(focused()).toBe("b1"));
});
// A restore still waiting for the landed page's lists must not outlive the next push: it would
// focus "list 1, item 1" of whatever page that push opened.
it("is cancelled by the next push", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
landOn(stamped);
render(<List id="b" count={3} />); // one list of the two: the restore keeps waiting
await sleep(120);
window.history.pushState({ ...rememberNavPosition(), view: "elsewhere" }, "");
render(<Page />); // enough lists now, on the page the push opened
await sleep(150);
expect(focused()).toBeUndefined();
});
it("leaves focus where it is when something already has it", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
const { getByRole } = render(<input aria-label="search" />);
landOn(stamped);
getByRole("textbox").focus();
render(<Page />);
await sleep(150);
expect(document.activeElement).toBe(getByRole("textbox"));
});
});
describe("rememberNavPosition · the list it comes back to (deep-review E11 round 2)", () => {
// Discover: a title opened from "Recently added" and watched — the page's cache expires and
// "Continue watching" fills with that very title, a rail ABOVE. Found by its place, focus landed in
// that rail, on a title never picked.
it("finds a named list by its name when a list appeared above it", async () => {
const Rails = () => (
<>
<List id="a" count={2} label="Recently added" />
<List id="b" count={3} label="Genres" />
</>
);
const { unmount } = render(<Rails />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
render(
<>
<List id="c" count={3} label="Continue watching" />
<Rails />
</>,
);
await waitFor(() => expect(focused()).toBe("a1"));
});
// Home's Continue rail leaves out whatever the hero shows: back from a card, a different hero put a
// card back in front of it, and by its place the focus landed on the neighbour (prod UAT 2026-09-19).
it("finds the item by its name when the rail's contents moved", async () => {
const Rail = ({ titles }: { titles: string[] }) => {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label="Continue watching">
{titles.map((title, i) => (
<div key={title} data-testid={title} data-nav-name={title} {...navItemAttrs(i)}>
{title}
</div>
))}
</div>
);
};
const { unmount } = render(<Rail titles={["Knight Rider", "I Am Mother", "Dawnwalker"]} />);
const stamped = leaveFrom("I Am Mother", 2);
unmount();
landOn(stamped);
render(<Rail titles={["Harry Potter", "Knight Rider", "I Am Mother", "Dawnwalker"]} />);
await waitFor(() => expect(focused()).toBe("I Am Mother"));
});
// Names repeat — a log console's `http.request GET /api/feed 200` lines. The first of them is not
// the one left: the saved place still holding the name wins, and when the list moved, the nearest.
it("among items of one name, takes the saved place or the nearest", async () => {
const Lines = ({ names }: { names: string[] }) => {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref} aria-label="Lines">
{names.map((name, i) => (
<div key={i} data-testid={`l${i}`} data-nav-name={name} {...navItemAttrs(i)}>
{name}
</div>
))}
</div>
);
};
const names = ["GET 200", "POST 200", "POST 200", "GET 200", "POST 200"];
const first = render(<Lines names={names} />);
const stamped = leaveFrom("l3", 4);
first.unmount();
landOn(stamped);
const back = render(<Lines names={names} />);
await waitFor(() => expect(focused()).toBe("l3"));
back.unmount();
(document.activeElement as HTMLElement | null)?.blur?.();
landOn(stamped);
// A line arrived above: the left one is l4 now, a GET two places up is l1.
render(<Lines names={["new", ...names]} />);
await waitFor(() => expect(focused()).toBe("l4"));
});
it("gives up on an unnamed list when the page came back with another number of lists", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("a1", 2);
unmount();
landOn(stamped);
render(
<>
<List id="c" count={3} />
<Page />
</>,
);
await sleep(150);
expect(focused()).toBeUndefined();
});
});