// @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 . 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(null); useListNav(ref, { enabled: true }); return (
{Array.from({ length: count }, (_, i) => (
{id} {i}
))}
); } const Page = () => ( <> ); 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(); const stamped = leaveFrom("b1", 4); expect(window.history.state.sfNav).toBeUndefined(); unmount(); landOn(stamped); await sleep(120); render(); 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(); const stamped = leaveFrom("a1", 2); unmount(); landOn(stamped); const { rerender } = render(); await sleep(120); expect(focused()).toBeUndefined(); rerender(); 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 . it("restores when a later popstate listener switches the view the focus was still in", async () => { const { unmount } = render(); 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" ? : ; } render(); 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(); const stamped = leaveFrom("b1", 4); unmount(); landOn(stamped); render(); // one list of the two: the restore keeps waiting await sleep(120); window.history.pushState({ ...rememberNavPosition(), view: "elsewhere" }, ""); render(); // 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(); const stamped = leaveFrom("b1", 4); unmount(); const { getByRole } = render(); landOn(stamped); getByRole("textbox").focus(); render(); 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 = () => ( <> ); const { unmount } = render(); const stamped = leaveFrom("a1", 2); unmount(); landOn(stamped); render( <> , ); 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(null); useListNav(ref, { enabled: true }); return (
{titles.map((title, i) => (
{title}
))}
); }; const { unmount } = render(); const stamped = leaveFrom("I Am Mother", 2); unmount(); landOn(stamped); render(); 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(null); useListNav(ref, { enabled: true }); return (
{names.map((name, i) => (
{name}
))}
); }; const names = ["GET 200", "POST 200", "POST 200", "GET 200", "POST 200"]; const first = render(); const stamped = leaveFrom("l3", 4); first.unmount(); landOn(stamped); const back = render(); 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(); 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(); const stamped = leaveFrom("a1", 2); unmount(); landOn(stamped); render( <> , ); await sleep(150); expect(focused()).toBeUndefined(); }); });