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:
@@ -11,6 +11,7 @@ import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||
import { SURFACE } from "./surfaces";
|
||||
import { HiddenFilterCue } from "./HiddenFilterCue";
|
||||
import { useProgressiveRows } from "./progressiveRows";
|
||||
|
||||
// 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
|
||||
@@ -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>({
|
||||
rows,
|
||||
columns,
|
||||
@@ -72,6 +112,7 @@ export default function DataTable<T>({
|
||||
keyboardNav = false,
|
||||
reveal,
|
||||
surfacedFilters,
|
||||
narrowedBy = "",
|
||||
}: {
|
||||
rows: 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):
|
||||
// the hidden-filter cue leaves those out, it would name them twice.
|
||||
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 lang = i18n.language;
|
||||
@@ -229,6 +274,11 @@ export default function DataTable<T>({
|
||||
if (at >= 0 && !allRows) setPage(Math.floor(at / 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) {
|
||||
const next = cycleSort(sort, key);
|
||||
@@ -327,25 +377,16 @@ export default function DataTable<T>({
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<tbody ref={bodyRef}>
|
||||
{paged.map((row, i) => (
|
||||
<tr
|
||||
{shown.map((row, i) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
{...rowProps?.(row)}
|
||||
{...(keyboardNav && navItemAttrs(i))}
|
||||
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>
|
||||
row={row}
|
||||
index={i}
|
||||
columns={columns}
|
||||
rowProps={rowProps}
|
||||
rowClassName={rowClassName}
|
||||
keyboardNav={keyboardNav}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -356,7 +397,7 @@ export default function DataTable<T>({
|
||||
<>
|
||||
{cue("cards")}
|
||||
<div ref={cardsRef} className="flex flex-col gap-2">
|
||||
{paged.map((row, i) => (
|
||||
{shown.map((row, i) => (
|
||||
<div
|
||||
key={rowKey(row)}
|
||||
{...rowProps?.(row)}
|
||||
|
||||
Reference in New Issue
Block a user