// @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();
});
});