Files
siftlode/frontend/src/components/ui/PageScroller.dom.test.tsx
T
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00

100 lines
3.7 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 } 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 { 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(<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);
});
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);
});
});