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.
243 lines
9.3 KiB
TypeScript
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();
|
|
});
|
|
});
|