// @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
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(); 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(x); 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(x); 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(x); 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(
, ); 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(x); 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(x); 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(x); 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(x); expect(main.scrollTop).toBe(0); // Returning to the first key restores its offset. rerender(x); 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( <>

rows

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