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:
2026-10-02 03:16:37 +02:00
parent e995c664c6
commit b4ef07d28e
5 changed files with 198 additions and 19 deletions
@@ -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;
}