Files
siftlode/frontend/src/components/ui/PageScroller.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

272 lines
10 KiB
TypeScript

// @vitest-environment jsdom
//
// The scroll-restoration contract PageScroller implements: offsets are remembered per
// (history entry id + content key), so Back to an entry restores where you left it while a forward
// navigation (a new entry id) or a new content key opens at the top. jsdom lays nothing out, so we
// feed the <main> fake metrics, drive restoreScroll's rAF loop synchronously, and steer the entry id
// through window.history.state (what ensureNavId/peekNavId read) — firing the nav bus (notifyNav)
// stands in for a Back/Forward that doesn't re-render the scroller.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import PageScroller from "./PageScroller";
import BackToTop from "./BackToTop";
import { notifyNav } from "../../lib/history";
import { revealedNavItem } from "../../lib/listNav";
beforeAll(() => {
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
beforeEach(() => {
// restoreScroll re-applies the offset over a few rAF frames (the feed needs time to reach height);
// run rAF synchronously so a restore lands within the test tick.
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function setEntry(id: number) {
window.history.replaceState({ sfScroll: id }, "");
}
function sizedMain(container: HTMLElement): HTMLElement {
const main = container.querySelector("main")!;
// Enough scrollable room for a 300px offset (restoreScroll only sets when scrollHeight-clientHeight
// >= the target), and a writable scrollTop jsdom otherwise no-ops.
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(main, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
return main;
}
describe("PageScroller scroll memory", () => {
it("restores per history entry: a forward nav opens at the top, Back restores the offset", () => {
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
// The user scrolls entry 1 to 200 — recorded live on scroll.
main.scrollTop = 200;
fireEvent.scroll(main);
// Forward navigation → a fresh entry id, same content key → the view opens at the top.
setEntry(2);
notifyNav();
expect(main.scrollTop).toBe(0);
// Back to entry 1 → its saved offset is restored.
setEntry(1);
notifyNav();
expect(main.scrollTop).toBe(200);
});
// The page's very end, whose Back-to-top spacer only shows once scrolled, is out of
// reach when Back rebuilds the page — and the restore gave up with the page at the top.
it("Back to a spot within the end's spacer lands at the page's end, not its top", () => {
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 500; // the very end, the spacer showing
fireEvent.scroll(main);
setEntry(2);
notifyNav();
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 928 }); // no spacer
setEntry(1);
notifyNav();
expect(main.scrollTop).toBe(428);
});
it("a list still far short of the spot waits for it rather than settling early", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 600 }); // loading
setEntry(1);
notifyNav();
flush(20); // held still, but 350px short: no answer yet
expect(main.scrollTop).toBe(0);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // loaded
flush(1);
expect(main.scrollTop).toBe(450);
});
// Back focuses an item the restore did not expect — one that moved to another list while the page
// was away — and scrolls it into view: the restore's next frame put the old offset back over it.
it("gives the page up to an item brought into view while it restores", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(
<PageScroller scrollKey="e:k1">
<div id="moved" />
</PageScroller>,
);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
setEntry(1);
notifyNav(); // Back: the restore to 450 waits for its first frame
main.scrollTop = 120; // the item scrolled into view
revealedNavItem(container.querySelector("#moved")!);
flush(5);
expect(main.scrollTop).toBe(120);
});
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 500;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 928 }); // no spacer
setEntry(1);
notifyNav();
flush(12);
expect(main.scrollTop).toBe(428);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // spacer up
flush(1);
expect(main.scrollTop).toBe(500);
});
// The give-up put the page at its end 1.5 s in, wherever the reader had scrolled to.
// Every way a reader takes the page: a wheel, a finger, the scrollbar (a pointer), a key.
it.each([
["a wheel", (main: HTMLElement) => fireEvent.wheel(main)],
["a finger", (main: HTMLElement) => fireEvent.touchStart(main)],
["the scrollbar", (main: HTMLElement) => fireEvent.pointerDown(main)],
["a key", () => fireEvent.keyDown(document.body, { key: "ArrowUp" })],
])("stops where %s takes the page over, even for a spot it has not reached", (_, take) => {
const removed = vi.spyOn(document, "removeEventListener");
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 600 }); // far short
setEntry(1);
notifyNav();
flush(5);
take(main);
// and it let go of the page-wide key listener with it
expect(removed).toHaveBeenCalledWith("keydown", expect.any(Function), true);
main.scrollTop = 30; // where the reader scrolled
flush(100);
expect(main.scrollTop).toBe(30);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // loaded
flush(1);
expect(main.scrollTop).toBe(30);
});
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
setEntry(10);
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 300;
fireEvent.scroll(main);
// A new content key (e.g. a fresh feed filter) in the SAME entry starts at the top.
rerender(<PageScroller scrollKey="e10:b">x</PageScroller>);
expect(main.scrollTop).toBe(0);
// Returning to the first key restores its offset.
rerender(<PageScroller scrollKey="e10:a">x</PageScroller>);
expect(main.scrollTop).toBe(300);
});
});
// UAT: Back to top floats over the bottom-right corner, and at the end of a long table it covered
// the closing pager's "100" and "All". While it is up, the page ends a button's height later.
describe("PageScroller · room for Back to top", () => {
it("ends the page later while the button is up, and not otherwise", () => {
const { container } = render(
<>
<PageScroller scrollKey="room">
<p>rows</p>
</PageScroller>
<BackToTop />
</>,
);
const main = container.querySelector("main")!;
const spacer = () => main.querySelector(':scope > [aria-hidden="true"]');
expect(spacer()).toBeNull();
main.scrollTop = 900;
act(() => void main.dispatchEvent(new Event("scroll")));
expect(spacer()?.className).toBe("h-18");
main.scrollTop = 0;
act(() => void main.dispatchEvent(new Event("scroll")));
expect(spacer()).toBeNull();
});
});