// @vitest-environment jsdom // // The card layout's pager clamps an out-of-range page DURING RENDER (E27 S2), which is the right // shape — React discards that pass, so nothing is committed on a page that no longer exists — but // it is also the shape that turns a non-convergent condition into "Too many re-renders" rather than // a visual glitch. Neither the hook nor either of its two callers (ChannelDiscovery, Channels) had // a test, so nothing held either half. This file holds both: the clamp happens, and it terminates. import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import { useCardPager } from "./useCardPager"; const rows = (n: number) => Array.from({ length: n }, (_, i) => i); const KEY = "siftlode.test.cardSize"; beforeEach(() => { localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); }); describe("useCardPager — an out-of-range page", () => { it("clamps to the last page that still exists when the list shrinks", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); expect(result.current.pagerProps.page).toBe(2); expect(result.current.paged).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29]); rerender({ n: 5 }); // an unsubscribe, a refetch — page 2 no longer exists expect(result.current.pagerProps.page).toBe(0); expect(result.current.paged).toEqual([0, 1, 2, 3, 4]); }); it("does not spring back to the stale page when the list grows again", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); rerender({ n: 5 }); rerender({ n: 30 }); // The clamp was COMMITTED, not merely displayed — `safePage` alone would have let 2 come back. expect(result.current.pagerProps.page).toBe(0); }); it("terminates on an empty list — the write falsifies its own condition", () => { const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), { initialProps: { n: 30 }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); // totalPages floors at 1, so the clamp target is 0 and the condition is false next pass. If it // were not, this line would throw "Too many re-renders" instead of returning. rerender({ n: 0 }); expect(result.current.pagerProps.page).toBe(0); expect(result.current.paged).toEqual([]); }); it("holds still in table mode, where DataTable does its own paging", () => { const { result, rerender } = renderHook(({ n, on }) => useCardPager(rows(n), KEY, on), { initialProps: { n: 30, on: false }, }); act(() => result.current.pagerProps.onSizeChange(10)); act(() => result.current.pagerProps.onPageChange(2)); rerender({ n: 5, on: false }); // the same shrink — but nothing is committed here rerender({ n: 30, on: false }); // `safePage` still clamps for DISPLAY while the list is short; what must not happen is the // clamp being written, which would lose the page for real. Growing back proves it was not. expect(result.current.pagerProps.page).toBe(2); expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode }); });