Files
siftlode/frontend/src/components/ui/Pager.dom.test.tsx
T
peter 7e1aeeb9fe refactor(frontend): honest effect deps in the app shell and the ui layer (E27 S2)
Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:

A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
  component is recycled across items in the virtualized feed, so after one avatar 429s the
  next item's slot committed the fallback initial over a perfectly good image and only the
  post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
  funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
  told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
  hidden, so a fresh open starts blank because mounting already means that -- and the ranking
  memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
  keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
  The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
  include `t` -- which a language switch legitimately changes, and which would otherwise have
  re-fired the toast.

Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
  module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
  on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
  language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
  so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
  be stable, which is what lets the effects name it instead of switching the check off.

Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.

Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.

Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
2026-09-20 23:57:08 +02:00

113 lines
3.9 KiB
TypeScript

// @vitest-environment jsdom
import { useLayoutEffect } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
import Pager from "./Pager";
afterEach(cleanup);
// Rows per page is a short fixed set, so it is the house's segmented control (debt burn 2026-09),
// not a native <select>: one named group, the current size pressed, "All" as size 0.
describe("Pager · rows per page", () => {
it("offers each size and All as one named group, and reports the pick", () => {
const onSizeChange = vi.fn();
render(
<Pager
page={0}
size={25}
total={120}
pageSizeOptions={[10, 25, 50, 100]}
onPageChange={() => {}}
onSizeChange={onSizeChange}
sizeLabel="Rows per page"
/>,
);
const group = screen.getByRole("group", { name: "Rows per page" });
expect(group.querySelector('[aria-pressed="true"]')?.textContent).toBe("25");
fireEvent.click(screen.getByRole("button", { name: "50" }));
fireEvent.click(screen.getByRole("button", { name: "datatable.all" }));
expect(onSizeChange.mock.calls).toEqual([[50], [0]]);
});
});
// The page box DERIVES the current page (E27 S2): it holds a draft only while being edited, so the
// parent moving the page needs no re-sync effect. The layout-phase probe is what proves "derived"
// rather than "re-synced after paint" — a passive effect could not have run yet when it reads.
describe("Pager · the page box follows the page without an effect", () => {
function Probe({ page, painted }: { page: number; painted: string[] }) {
useLayoutEffect(() => {
painted.push(
(document.querySelector('input[type="number"]') as HTMLInputElement | null)?.value ?? "",
);
});
return (
<Pager
page={page}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={() => {}}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>
);
}
it("shows the new page in the first frame after the parent moves it", () => {
const painted: string[] = [];
const { rerender } = render(<Probe page={0} painted={painted} />);
expect(screen.getByLabelText("datatable.pager.pageLabel")).toHaveProperty("value", "1");
painted.length = 0;
rerender(<Probe page={4} painted={painted} />);
expect(painted[0]).toBe("5");
});
it("keeps what you type until you commit it, then follows the page again", () => {
const onPageChange = vi.fn();
render(
<Pager
page={0}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "7" } });
expect(box).toHaveProperty("value", "7");
fireEvent.keyDown(box, { key: "Enter" });
expect(onPageChange).toHaveBeenCalledWith(6);
// The parent in this test never moves, so dropping the draft must put page 1 back — proof the
// box stopped holding its own copy rather than merely being overwritten.
expect(box).toHaveProperty("value", "1");
});
it("discards an unparseable entry instead of committing it", () => {
const onPageChange = vi.fn();
render(
<Pager
page={2}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "abc" } });
fireEvent.blur(box);
expect(onPageChange).not.toHaveBeenCalled();
expect(box).toHaveProperty("value", "3");
});
});