perf(ui): a long table page paints its first rows at once and the rest in steps
The channel manager's All page (~400 rows) blocked one frame for 0.7-0.9 s on prod to mount, and every sort and search key re-rendered every row (300-400 ms). No single cell is to blame, measured column by column. DataTable now mounts the first 30 rows at once and 40 more per background transition; a new page, size, sort, filter or caller-side search (narrowedBy) starts over from the first rows. A row is its own component, so the compiler keeps the rows a step already holds.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 S4d: 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;
|
||||
|
||||
// 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 waitFor(() => expect(bodyRows()).toBe(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 waitFor(() => expect(bodyRows()).toBe(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 waitFor(() => expect(bodyRows()).toBe(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 waitFor(() => expect(bodyRows()).toBe(150));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user