diff --git a/frontend/src/lib/history.navPosition.dom.test.tsx b/frontend/src/lib/history.navPosition.dom.test.tsx index b9c080a4..779acbad 100644 --- a/frontend/src/lib/history.navPosition.dom.test.tsx +++ b/frontend/src/lib/history.navPosition.dom.test.tsx @@ -194,6 +194,37 @@ describe("rememberNavPosition · the list it comes back to (deep-review E11 roun 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); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index 4b143d64..3c5d264e 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -366,6 +366,16 @@ const RESTORE_POLL_MS = 50; * list N may be another list, and is the honest outcome. Gives up when the time is up, and at * once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused * where the scroll restore put it, never scrolled to. Returns a cancel. */ +function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null { + const at = itemAt(list, pos.index); + if (!pos.name || (at && itemName(at) === pos.name)) return at; + const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index); + const named = ownItems(list.el) + .filter((it) => itemName(it) === pos.name) + .sort((a, b) => distance(a) - distance(b)); + return named[0] ?? at; +} + export function restoreNavPosition(pos: NavPosition): () => void { const deadline = Date.now() + RESTORE_MS; let timer: ReturnType | undefined; @@ -381,9 +391,10 @@ export function restoreNavPosition(pos: NavPosition): () => void { // The item by its NAME first, its place second: a rail's contents can shift while the page was // away — Home's Continue rail leaves out whatever the hero shows, so a different hero moved every // card after it one place and the focus landed on the neighbour (prod UAT 2026-09-19). - const named = - list && pos.name ? ownItems(list.el).find((it) => itemName(it) === pos.name) : null; - const el = named ?? (list ? itemAt(list, pos.index) : null); + // The saved place still holding the saved name wins, and among several items of that name the + // nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two + // downloads of one video), and the first of them is rarely the one left behind. + const el = list ? restoreTarget(list, pos) : null; if (el) return focusItem(el, false); if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS); };