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:
@@ -242,6 +242,7 @@ export default function ChannelDiscovery({
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
keyboardNav
|
keyboardNav
|
||||||
|
narrowedBy={q}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChannelLayoutGrid
|
<ChannelLayoutGrid
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ export default function Channels() {
|
|||||||
keyboardNav
|
keyboardNav
|
||||||
reveal={follow}
|
reveal={follow}
|
||||||
surfacedFilters={SURFACED_FILTERS}
|
surfacedFilters={SURFACED_FILTERS}
|
||||||
|
narrowedBy={q}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { navItemAttrs, useListNav } from "../../lib/listNav";
|
|||||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||||
import { SURFACE } from "./surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
import { HiddenFilterCue } from "./HiddenFilterCue";
|
import { HiddenFilterCue } from "./HiddenFilterCue";
|
||||||
|
import { useProgressiveRows } from "./progressiveRows";
|
||||||
|
|
||||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||||
@@ -48,6 +49,45 @@ function loadPersist(key?: string): Persisted {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TableRowProps<T> {
|
||||||
|
row: T;
|
||||||
|
index: number;
|
||||||
|
columns: Column<T>[];
|
||||||
|
rowProps?: (row: T) => Record<string, unknown>;
|
||||||
|
rowClassName?: (row: T) => string;
|
||||||
|
keyboardNav: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row is its own component so the React Compiler caches its cells by (row, columns, …): a long page
|
||||||
|
// grows in steps (progressiveRows), and a step must not draw the rows it already holds again. Inline in
|
||||||
|
// the body's map they all were, and the steps grew from ~70 to ~200 ms (dev build) as the page filled.
|
||||||
|
function TableRow<T>({
|
||||||
|
row,
|
||||||
|
index,
|
||||||
|
columns,
|
||||||
|
rowProps,
|
||||||
|
rowClassName,
|
||||||
|
keyboardNav,
|
||||||
|
}: TableRowProps<T>) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...rowProps?.(row)}
|
||||||
|
{...(keyboardNav && navItemAttrs(index))}
|
||||||
|
className={`border-b border-border/50 state-layer transition ${rowClassName?.(row) ?? ""}`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
|
||||||
|
>
|
||||||
|
{col.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DataTable<T>({
|
export default function DataTable<T>({
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
@@ -72,6 +112,7 @@ export default function DataTable<T>({
|
|||||||
keyboardNav = false,
|
keyboardNav = false,
|
||||||
reveal,
|
reveal,
|
||||||
surfacedFilters,
|
surfacedFilters,
|
||||||
|
narrowedBy = "",
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -131,6 +172,10 @@ export default function DataTable<T>({
|
|||||||
// Filter keys the caller shows somewhere besides their column (the channel manager's tag chips):
|
// Filter keys the caller shows somewhere besides their column (the channel manager's tag chips):
|
||||||
// the hidden-filter cue leaves those out, it would name them twice.
|
// the hidden-filter cue leaves those out, it would name them twice.
|
||||||
surfacedFilters?: string[];
|
surfacedFilters?: string[];
|
||||||
|
// What the caller narrowed `rows` by before passing them in (the channel manager's name search lives
|
||||||
|
// in the shared top bar): a change starts a long page over from its first rows, as the table's own
|
||||||
|
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
|
||||||
|
narrowedBy?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
@@ -229,6 +274,11 @@ export default function DataTable<T>({
|
|||||||
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
|
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
|
||||||
}
|
}
|
||||||
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
|
||||||
|
// A long page ("All", 100) mounts its first rows at once and the rest in the background.
|
||||||
|
const shown = useProgressiveRows(
|
||||||
|
paged,
|
||||||
|
`${safePage}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`,
|
||||||
|
);
|
||||||
|
|
||||||
function toggleSort(key: string) {
|
function toggleSort(key: string) {
|
||||||
const next = cycleSort(sort, key);
|
const next = cycleSort(sort, key);
|
||||||
@@ -327,25 +377,16 @@ export default function DataTable<T>({
|
|||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
/>
|
/>
|
||||||
<tbody ref={bodyRef}>
|
<tbody ref={bodyRef}>
|
||||||
{paged.map((row, i) => (
|
{shown.map((row, i) => (
|
||||||
<tr
|
<TableRow
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
{...rowProps?.(row)}
|
row={row}
|
||||||
{...(keyboardNav && navItemAttrs(i))}
|
index={i}
|
||||||
className={`border-b border-border/50 state-layer transition ${
|
columns={columns}
|
||||||
rowClassName?.(row) ?? ""
|
rowProps={rowProps}
|
||||||
}`}
|
rowClassName={rowClassName}
|
||||||
>
|
keyboardNav={keyboardNav}
|
||||||
{columns.map((col) => (
|
/>
|
||||||
<td
|
|
||||||
key={col.key}
|
|
||||||
style={col.width ? { width: col.width } : undefined}
|
|
||||||
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
|
|
||||||
>
|
|
||||||
{col.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -356,7 +397,7 @@ export default function DataTable<T>({
|
|||||||
<>
|
<>
|
||||||
{cue("cards")}
|
{cue("cards")}
|
||||||
<div ref={cardsRef} className="flex flex-col gap-2">
|
<div ref={cardsRef} className="flex flex-col gap-2">
|
||||||
{paged.map((row, i) => (
|
{shown.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
{...rowProps?.(row)}
|
{...rowProps?.(row)}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { startTransition, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// A long page of table rows mounts in steps: the first FIRST_ROWS at once, then STEP_ROWS more per
|
||||||
|
// background transition until all are in (E31 S4d). The channel manager's "All" page (~400 rows) took
|
||||||
|
// 0.7–0.9 s of one blocked frame to mount on prod, and every sort and every keystroke in its search
|
||||||
|
// re-rendered all of them (300–400 ms per key). No single cell is to blame: measured column by column,
|
||||||
|
// each costs its share, and all of them together cost ~9× an empty row. The first rows fill more than
|
||||||
|
// a screen, so the page paints at once. The rest come in small steps because a transition yields only
|
||||||
|
// while it renders: its commit (the rows' DOM, the tag cells' measuring layout effects) is one block,
|
||||||
|
// and the rest in one step was a 0.7 s block of its own. Between steps a key or a click goes first,
|
||||||
|
// and a new view starts over.
|
||||||
|
//
|
||||||
|
// Not virtualization: every row still ends up in the DOM, so browser find, keyboard nav past the screen,
|
||||||
|
// and the Back focus/scroll restore (which wait for the list to grow) keep working as they did.
|
||||||
|
export const FIRST_ROWS = 30;
|
||||||
|
export const STEP_ROWS = 40;
|
||||||
|
|
||||||
|
// `view` names what the rows show (page, size, sort, filters). A new view starts over from the first
|
||||||
|
// rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch)
|
||||||
|
// does not: it would drop the rows the reader is looking at.
|
||||||
|
export function useProgressiveRows<T>(rows: T[], view: string): T[] {
|
||||||
|
const [seen, setSeen] = useState(view);
|
||||||
|
const [limit, setLimit] = useState(FIRST_ROWS);
|
||||||
|
if (seen !== view) {
|
||||||
|
setSeen(view);
|
||||||
|
setLimit(FIRST_ROWS);
|
||||||
|
}
|
||||||
|
const more = rows.length > limit;
|
||||||
|
useEffect(() => {
|
||||||
|
if (more) startTransition(() => setLimit(limit + STEP_ROWS));
|
||||||
|
}, [more, limit]);
|
||||||
|
return more ? rows.slice(0, limit) : rows;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user