Files
siftlode/frontend/src/lib/listNav.restoreMoved.dom.test.tsx
T
peter e3a4593acf fix(nav): Back looks for a moved item only in the page's lists, once its own list has had its chance, and its reveal stops the scroll restore
The cross-list match took a same-named row of the app-wide download tray, and took it at the first look while a virtualized list had yet to render the item. The reveal of a moved item ran before the scroll restore's first frame, which put the old offset back over it. The pending restore's name is readable, for a page that folds rows away.
2026-10-29 00:47:52 +01:00

154 lines
5.3 KiB
TypeScript

// @vitest-environment jsdom
//
// Back to a page whose item changed lists while it was away. The notification center holds a row
// that asked for the reader in "Needs you" for the visit; on the next visit the same row, read now,
// sits in the stream below. The restore matched the unnamed list by its place, missed the name there
// and fell back to the saved index — the focus landed on the neighbour, and Enter opened the wrong
// notification. Its own file: listNav keeps module-level state.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useRef } from "react";
import {
NAV_REVEAL_EVENT,
navItemAttrs,
navPosition,
restoreNavPosition,
useListNav,
} from "./listNav";
afterEach(cleanup);
// `count`: a virtualized list's, which counts rows it has yet to render.
function List({ items, count }: { items: string[]; count?: number }) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, count });
return (
<div ref={ref}>
{items.map((t, i) => (
<div key={`${t}-${i}`} {...navItemAttrs(i, t)}>
<button data-nav-open="">{t}</button>
</div>
))}
</div>
);
}
// `tray`: a list of the app's own, outside the page (the running downloads), whose rows can share a
// name with the page's.
const Page = ({
lists,
tray,
counts = [],
}: {
lists: string[][];
tray?: string[];
counts?: number[];
}) => (
<>
<main>
{lists.map((items, i) => (
<List key={i} items={items} count={counts[i]} />
))}
</main>
{tray && <List items={tray} />}
</>
);
const button = (name: string) =>
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
const focused = () => document.activeElement?.closest("[data-nav-index]")?.textContent;
const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
async function leaveAndReturn(
before: string[][],
from: string,
after: string[][],
tray?: string[],
) {
const { unmount } = render(<Page lists={before} tray={tray} />);
button(from).focus();
const pos = navPosition()!;
unmount(); // the page switch
render(<Page lists={after} tray={tray} />); // Back
const cancel = restoreNavPosition(pos);
await wait(120);
cancel();
}
describe("Back to an item that moved to another list", () => {
it("finds it by its name in the list it moved to, not its neighbour at the old place", async () => {
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
["download"],
["subscribed", "older"],
]);
expect(focused()).toBe("subscribed");
});
it("finds it when the list it left is gone altogether", async () => {
await leaveAndReturn([["subscribed"], ["older"]], "subscribed", [["subscribed", "older"]]);
expect(focused()).toBe("subscribed");
});
it("does not guess between two items of that name elsewhere: the saved place", async () => {
await leaveAndReturn([["twin", "download"], ["older"]], "twin", [
["download"],
["twin", "older", "twin"],
]);
expect(focused()).toBe("download");
});
it("brings an item found in another list into view; one in its own list stays put", async () => {
// The scroll restore put the page where the item WAS: found elsewhere, it would hold the focus
// off screen.
const reveal = vi.fn();
Element.prototype.scrollIntoView = reveal;
// ...and tells the page's scroll restore, which would otherwise put the old offset back over it.
const told = vi.fn();
document.addEventListener(NAV_REVEAL_EVENT, told);
await leaveAndReturn([["subscribed", "download"], ["older"]], "subscribed", [
["download"],
["subscribed", "older"],
]);
expect(reveal).toHaveBeenCalledTimes(1);
expect(told).toHaveBeenCalledTimes(1);
cleanup();
reveal.mockClear();
told.mockClear();
await leaveAndReturn([["subscribed", "download"]], "download", [["download", "subscribed"]]);
expect(focused()).toBe("download");
expect(reveal).not.toHaveBeenCalled();
expect(told).not.toHaveBeenCalled();
document.removeEventListener(NAV_REVEAL_EVENT, told);
});
it("never takes it from a list outside the page: the download tray's row of that name", async () => {
// A feed card and its download in the tray are both named by the video's title.
await leaveAndReturn(
[["subscribed", "download"]],
"subscribed",
[["download"]],
["subscribed"],
);
expect(focused()).toBe("download");
});
it("waits for its own list to render the place before looking in another one", async () => {
// A virtualized list puts its rows back a frame after the page: the item is still to come at
// the first look, and the one of that name in the rail below is not it.
const { unmount } = render(<Page lists={[["older", "subscribed"], ["subscribed-2"]]} />);
button("subscribed").focus();
const pos = navPosition()!;
unmount();
const { rerender } = render(<Page lists={[["older"], ["subscribed"]]} counts={[2]} />);
const cancel = restoreNavPosition(pos);
await wait(60);
rerender(<Page lists={[["older", "subscribed"], ["subscribed"]]} counts={[2]} />);
await wait(120);
cancel();
expect(document.activeElement?.closest("main > div")).toBe(
document.querySelector("main")!.firstElementChild,
);
});
});