Files
siftlode/frontend/src/components/ui/DataTable.progressive.dom.test.tsx
T
peter 5ba5679239 test: tests that assumed an idle machine wait for what they assert, or carry a budget for their work
Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after.
2026-10-17 01:08:15 +02:00

165 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The channel manager's "All" page (~400 rows) blocked one frame for 0.7–0.9 s on prod to
// mount, and every key in its search re-rendered every row. A long page now mounts its first rows at
// once and the rest in small background steps (progressiveRows), and a step leaves the rows it holds
// alone (a row is its own component, whose cells the React Compiler caches).
//
// These tests drive React without act(): act flushes the background steps along with the first render,
// and the first render on its own is the property under test.
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from "vitest";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { waitFor } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
import { FIRST_ROWS } from "./progressiveRows";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
let wasAct: boolean | undefined;
beforeAll(() => {
wasAct = g.IS_REACT_ACT_ENVIRONMENT;
g.IS_REACT_ACT_ENVIRONMENT = false;
});
afterAll(() => {
g.IS_REACT_ACT_ENVIRONMENT = wasAct;
});
type Row = { id: string; name: string };
const many = (n: number, prefix = "r"): Row[] =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, name: `${prefix} ${i}` }));
let root: Root | null = null;
let host: HTMLElement | null = null;
afterEach(() => {
root?.unmount();
host?.remove();
root = null;
host = null;
});
const bodyRows = () => host!.querySelectorAll("tbody tr").length;
// Waits for the page to grow to `n` rows, one background step at a time: each step must come, but on
// a busy machine all of them together can outlast one wait's budget (seen at 190 of 200).
async function grownTo(n: number) {
for (let had = bodyRows(); had < n; had = bodyRows()) {
await waitFor(() => expect(bodyRows()).toBeGreaterThan(had));
}
expect(bodyRows()).toBe(n);
}
// Mounts synchronously, so what the DOM holds right after is the first render alone.
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const draw = (r: Row[], n: string) =>
flushSync(() =>
root!.render(
<DataTable rows={r} columns={columns} rowKey={(x) => x.id} pageSize={0} narrowedBy={n} />,
),
);
draw(rows, narrowedBy);
return draw;
}
const plain: Column<Row>[] = [{ key: "name", header: "Name", render: (r) => r.name }];
describe("DataTable — a long page mounts in steps", () => {
it("paints its first rows at once and the rest after", async () => {
mount(many(200), plain);
expect(bodyRows()).toBe(FIRST_ROWS);
await grownTo(200);
});
it("does not re-render the rows it already holds as it grows", async () => {
const renders = new Map<string, number>();
const counted: Column<Row>[] = [
{
key: "name",
header: "Name",
render: (r) => {
renders.set(r.id, (renders.get(r.id) ?? 0) + 1);
return r.name;
},
},
];
mount(many(200), counted);
await grownTo(200);
// The first row was drawn by the first render, not again by each step after it.
expect(renders.get("r0")).toBe(1);
});
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
const draw = mount(many(200), plain, "");
await grownTo(200);
// New data for the same view (a refetch): the page keeps what it shows.
draw(many(200), "");
expect(bodyRows()).toBe(200);
// A search the caller applied: the urgent render is small again.
draw(many(150, "s"), "s");
expect(bodyRows()).toBe(FIRST_ROWS);
await grownTo(150);
});
// Channels turns the page to the row a priority pick moved and focuses it in the commit
// that follows. That row was at index 40 of a 50-row page: not mounted yet, focus fell to <body>.
it("mounts a revealed row in the render that turns to its page", () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const rows = many(100);
const draw = (reveal: { key: string; seq: number } | null) =>
flushSync(() =>
root!.render(
<DataTable
rows={rows}
columns={plain}
rowKey={(x) => x.id}
pageSize={50}
reveal={reveal}
/>,
),
);
draw(null);
draw({ key: "r90", seq: 1 });
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
});
// The reveal floor once held for the page's lifetime. Channels never clears its `follow`, so
// after one priority pick every later sort or search mounted every row up to that channel at once.
it("lets a revealed row go once the reader moves to another view", () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const rows = many(200);
const draw = (reveal: { key: string; seq: number } | null, narrowedBy: string) =>
flushSync(() =>
root!.render(
<DataTable
rows={rows}
columns={plain}
rowKey={(x) => x.id}
pageSize={0}
reveal={reveal}
narrowedBy={narrowedBy}
/>,
),
);
draw(null, "");
const follow = { key: "r150", seq: 1 };
draw(follow, "");
expect(host.querySelector("tbody")?.textContent).toContain("r 150");
draw(follow, "s"); // a search: a new view, the same (never cleared) reveal
expect(bodyRows()).toBe(FIRST_ROWS);
});
});