From 7caa2636e484375f8815f531f49ba7a36a6bafcc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 08:04:00 +0200 Subject: [PATCH] test(nav): cover PageScroller save/restore keying (F12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders PageScroller and drives restoreScroll's rAF synchronously with fake metrics: asserts a forward nav (new entry id) opens at top and Back restores the offset, and that a new content key within one entry resets to top while returning restores — the per-(entry,key) contract only the id primitives were covering before. --- .../src/components/PageScroller.dom.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/src/components/PageScroller.dom.test.tsx diff --git a/frontend/src/components/PageScroller.dom.test.tsx b/frontend/src/components/PageScroller.dom.test.tsx new file mode 100644 index 00000000..872857be --- /dev/null +++ b/frontend/src/components/PageScroller.dom.test.tsx @@ -0,0 +1,99 @@ +// @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 } 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 { notifyNav } from "../lib/history"; + +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); + }); + + 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); + }); +});