From 49fe56b6e76a900989e9ce271a0fc0a37fe44fac Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 05:47:16 +0200 Subject: [PATCH] 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. --- .../src/lib/history.navPosition.dom.test.tsx | 31 +++++++++++++++++++ frontend/src/lib/listNav.ts | 17 ++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) 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); };