# Benchmark

> Package `@umriss-ui/table`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/table/benchmark/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/table/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/table/llms-full.txt).

Up to two hundred thousand rows, measured in this browser rather than claimed: the first render, a scroll step, a sort, a keystroke, a page, a fold, a hidden column and a re-render of the caller.

```ts
import { useTable } from "@umriss-ui/table";
```

Demo page: https://romanhaendler.github.io/umriss-ui/table/benchmark/

The figures come from the clock around each change, with nothing added to the table. The reference figures in `packages/table/docs/capabilities.md` come from here.

## Examples

### Measure two hundred thousand rows

Load up to 200 000 rows and run a change: the times say what rendering, scrolling, sorting, searching, paging, folding and hiding cost here.

```tsx
/* What the table costs, measured from outside with the clock: the first render,
   a scroll step, a sort, a search keystroke, a page turned, a group folded, a
   column hidden and a re-render of the caller with nothing changed. The page
   adds nothing to the table. Not photographed: it measures the moment it runs.

   A figure is the synchronous time of `flushSync` around the change - render,
   commit and layout effects, not the paint. Every change but the scroll is the
   median of five runs; the scroll is the mean over a scripted scroll, timed to
   the change of the rows in the DOM because the table renders it outside
   `flushSync`. A
   production build has no React profiler, so the clock is the only instrument.

   The page is driven by a script as well as by hand: every control and every
   figure carries a `data-role`, and `status` reads idle, running or done. */

import { useRef, useState } from "react";
import { flushSync } from "react-dom";
import { Badge, Button, Stack, Text } from "@umriss-ui/core";
import { Pagination, Search, Toolbar, useTable } from "@umriss-ui/table";

interface Request {
  id: string;
  service: string;
  status: 200 | 404 | 500;
  duration: number;
  size: number;
  region: string;
}

const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const;
const REGIONS = ["eu-west", "eu-central", "us-east", "ap-south"] as const;
const STATUSES = [200, 200, 200, 200, 200, 200, 404, 500] as const;
const TONE = { 200: "success", 404: "warning", 500: "danger" } as const;

/* A linear congruential generator: the same rows on every run. */
function generate(count: number): Request[] {
  let seed = 20260317;
  const next = () => {
    seed = (seed * 1103515245 + 12345) % 2147483648;
    return seed / 2147483648;
  };
  return Array.from({ length: count }, (_, i) => ({
    id: `REQ-${String(i + 1).padStart(6, "0")}`,
    service: SERVICES[Math.floor(next() * SERVICES.length)]!,
    status: STATUSES[Math.floor(next() * STATUSES.length)]!,
    duration: Math.round(20 + next() * 900),
    size: Math.round(next() * 5000) / 10,
    region: REGIONS[Math.floor(next() * REGIONS.length)]!,
  }));
}

/** `virtual`: a virtualised table with a footer aggregate. `grouped`: the same,
    grouped by service with share bars. `paged`: pages instead of a window, the
    only mode in which a page can be turned. */
type Mode = "virtual" | "grouped" | "paged";

const SIZES = [10_000, 50_000, 200_000] as const;
const MODES: readonly Mode[] = ["virtual", "grouped", "paged"];

/* The table under measurement. `tick` is not read: a new value only makes the
   caller render this component again, with the accessors below written inline
   as the examples write them. */
function Measured({ rows, mode, onTable }: { rows: Request[]; mode: Mode; tick: number; onTable: (t: Handle) => void }) {
  const t = useTable(rows, {
    rowKey: (r) => r.id,
    virtual: mode !== "paged",
    pageSize: 50,
    defaultGrouping: mode === "grouped" ? "service" : undefined,
  });
  onTable(t);
  const { Table, Column } = t;
  return (
    <Table maxHeight="340px" stickyHeader ariaLabel="Requests">
      <Toolbar>
        <Search placeholder="Request or service" />
      </Toolbar>
      <Column value="id" label="Request" rowHeader width={130} />
      <Column value="service" label="Service" width={140} />
      <Column value="status" label="Status" width={100}>
        {(status) => <Badge tone={TONE[status]}>{status}</Badge>}
      </Column>
      <Column value="duration" label="Duration (ms)" width={130} aggregate="sum" />
      {/* A computed column, its function written inline: a new one on every
          render of the caller, which is what the re-render figure is about. */}
      <Column id="size" value={(r) => r.size} label="Size (kB)" format={{ decimals: 1 }} width={110} aggregate="sum" />
      <Column value="region" label="Region" width={120} />
      {mode === "paged" ? <Pagination /> : null}
    </Table>
  );
}

/** The calls the measurements make on the table. */
interface Handle {
  toggleSort: (column: string) => void;
  setSearch: (search: string) => void;
  setPage: (page: number) => void;
  foldAll: () => void;
  unfoldAll: () => void;
  toggleColumn: (column: string) => void;
}

const FIGURES = ["first-render", "scroll", "sort", "search", "page", "fold", "hide", "rerender"] as const;
type Figure = (typeof FIGURES)[number];
type Status = "idle" | "running" | "done";

const REPEATS = 5;
const SCROLL_STEPS = 60;
const QUERIES = ["eu", "web", "500", "pay", "us-"];

const timed = (change: () => void): number => {
  const start = performance.now();
  flushSync(change);
  return performance.now() - start;
};
const median = (values: number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
/* Lets the browser paint "running" before a measurement blocks the thread, and
   settles what a change left behind. */
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 50));

export default function Benchmark() {
  const [load, setLoad] = useState<{ rows: Request[]; mode: Mode; key: number }>({ rows: [], mode: "virtual", key: 0 });
  const [tick, setTick] = useState(0);
  const [count, setCount] = useState(String(SIZES[0]));
  const [status, setStatus] = useState<Status>("idle");
  const [figures, setFigures] = useState<Partial<Record<Figure, string>>>({});
  const table = useRef<Handle | null>(null);
  const host = useRef<HTMLDivElement>(null);
  const generated = useRef<Request[]>([]);

  /* A figure that does not apply in this mode reads "-". */
  const show = (figure: Figure, ms: number | null) =>
    setFigures((f) => ({ ...f, [figure]: ms === null ? "-" : ms.toFixed(1) }));

  const run = async (body: () => Promise<void> | void) => {
    flushSync(() => setStatus("running"));
    await settle();
    try {
      await body();
    } finally {
      setStatus("done");
    }
  };

  const open = (n: number, mode: Mode) =>
    run(async () => {
      if (generated.current.length !== n) generated.current = generate(n);
      const rows = generated.current;
      /* The previous table goes first, so the figure is a mount and no more. */
      flushSync(() => setLoad({ rows: [], mode, key: load.key + 1 }));
      await settle();
      show("first-render", timed(() => setLoad({ rows, mode, key: load.key + 2 })));
    });

  /* `change` is timed, `restore` is not; both run synchronously. */
  const repeat = (figure: Figure, applies: boolean, change: (i: number) => void, restore?: () => void) =>
    run(async () => {
      if (!applies || table.current === null) return show(figure, null);
      const times: number[] = [];
      for (let i = 0; i < REPEATS; i++) {
        times.push(timed(() => change(i)));
        if (restore) flushSync(restore);
        await settle();
      }
      show(figure, median(times));
    });

  /* The scroll area is the table's parent, the element that carries the
     table's `onScroll`. */
  const scroller = (): HTMLElement | null => host.current?.querySelector("table")?.parentElement ?? null;

  /* Each step moves the scroll position and then sends the scroll event inside
     the timing: the event reaches the table's handler the same way the browser's
     own, which comes a frame later, does. */
  const measureScroll = () =>
    run(async () => {
      const el = scroller();
      if (el === null || mode === "paged") return show("scroll", null);
      el.scrollTop = 0;
      await settle();
      let total = 0;
      for (let i = 0; i < SCROLL_STEPS; i++) {
        const start = performance.now();
        await new Promise<void>((resolve) => {
          const rows = new MutationObserver(() => {
            rows.disconnect();
            resolve();
          });
          rows.observe(el, { childList: true, subtree: true });
          el.scrollTop += 600;
          el.dispatchEvent(new Event("scroll"));
          setTimeout(() => {
            rows.disconnect();
            resolve();
          }, 1000);
        });
        total += performance.now() - start;
        /* The browser's own scroll event follows a frame later and finds the
           window already in place. */
        await new Promise((resolve) => setTimeout(resolve, 30));
      }
      show("scroll", total / SCROLL_STEPS);
    });

  const t = () => table.current!;
  const mode = load.mode;

  return (
    <Stack gap={3}>
      <Stack direction="row" gap={2} align="center" wrap>
        {SIZES.map((n) => (
          <Button
            key={n}
            size="sm"
            data-role={`rows-${n}`}
            onClick={() => {
              setCount(String(n));
              void open(n, mode);
            }}
          >
            {n.toLocaleString("en-US")} rows
          </Button>
        ))}
        <input
          type="number"
          min={1}
          max={200_000}
          aria-label="Row count"
          data-role="rows-input"
          value={count}
          onChange={(event) => setCount(event.target.value)}
        />
        <Button size="sm" data-role="load" onClick={() => void open(Math.min(200_000, Math.max(1, Number(count) || 1)), mode)}>
          Load
        </Button>
        {MODES.map((m) => (
          <Button
            key={m}
            size="sm"
            data-role={`mode-${m}`}
            aria-pressed={mode === m}
            onClick={() => void open(generated.current.length || SIZES[0], m)}
          >
            {m}
          </Button>
        ))}
      </Stack>
      <Stack direction="row" gap={2} align="center" wrap>
        <Button size="sm" data-role="measure-scroll" onClick={() => void measureScroll()}>Scroll</Button>
        <Button size="sm" data-role="measure-sort" onClick={() => void repeat("sort", true, () => t().toggleSort("duration"))}>Sort</Button>
        <Button
          size="sm"
          data-role="measure-search"
          onClick={() => void repeat("search", true, (i) => t().setSearch(QUERIES[i % QUERIES.length]!), () => t().setSearch(""))}
        >
          Search
        </Button>
        <Button size="sm" data-role="measure-page" onClick={() => void repeat("page", mode === "paged", (i) => t().setPage(2 + i), () => t().setPage(1))}>
          Page
        </Button>
        <Button size="sm" data-role="measure-fold" onClick={() => void repeat("fold", mode === "grouped", () => t().foldAll(), () => t().unfoldAll())}>
          Fold
        </Button>
        <Button size="sm" data-role="measure-hide" onClick={() => void repeat("hide", true, () => t().toggleColumn("region"), () => t().toggleColumn("region"))}>
          Hide column
        </Button>
        <Button size="sm" data-role="measure-rerender" onClick={() => void repeat("rerender", true, () => setTick((n) => n + 1))}>Re-render</Button>
      </Stack>
      <dl style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem", margin: 0 }}>
        <div>
          <Text as="dt">Status</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="status">{status}</Text>
        </div>
        <div>
          <Text as="dt">Rows</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="rows">{load.rows.length}</Text>
        </div>
        {FIGURES.map((f) => (
          <div key={f}>
            <Text as="dt">{f} (ms)</Text>
            <Text as="dd" mono style={{ margin: 0 }} data-role={f}>{figures[f] ?? "-"}</Text>
          </div>
        ))}
      </dl>
      <div ref={host}>
        {load.rows.length > 0 ? (
          <Measured
            key={`${mode}-${load.key}`}
            rows={load.rows}
            mode={mode}
            tick={tick}
            onTable={(handle) => {
              table.current = handle;
            }}
          />
        ) : null}
      </div>
    </Stack>
  );
}
```

## Keyboard

The keys of [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#keyboard-first-table), [Virtualisation](https://romanhaendler.github.io/umriss-ui/table/virtualisation/#keyboard-virtualisation) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here.

## Props on this page

From `TableProps` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`stickyHeader`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-stickyHeader) | `boolean` | — | Keeps the header row visible while scrolling. |
| [`maxHeight`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-maxHeight) | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it. |
| [`children`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). |

From `TableOptions` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`rowKey`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. |
| [`pageSize`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-pageSize) | `number` | `10` | How many rows a page holds. |
| [`defaultGrouping`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-defaultGrouping) | [`GroupingId`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-GroupingId)`<Z> \| readonly `[`GroupingId`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-GroupingId)`<Z>[]` | ungrouped, until the user groups | The application's grouping: a column or group key, or up to three, the outermost first. It is the default that `view` leaves out. |
| [`virtual`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-virtual) | `boolean \| { overscan?: number }` | — | Renders only what stands in the scroll area; paging is then off. Every row is one row pitch tall, which the table measures itself ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). |

From `FieldColumn` — the full table stands on [Column](https://romanhaendler.github.io/umriss-ui/table/column/#type-FieldColumn).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](https://romanhaendler.github.io/umriss-ui/table/column/#FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. |
| [`children`](https://romanhaendler.github.io/umriss-ui/table/column/#FieldColumn-children) | [`Presentation`](https://romanhaendler.github.io/umriss-ui/table/column/#type-Presentation)`<Z[K], Z>` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. |

From `ColumnBase` — the full table stands on [Column](https://romanhaendler.github.io/umriss-ui/table/column/#type-ColumnBase).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`label`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. |
| [`rowHeader`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. |
| [`width`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-width) | `number` | the browser's | Initial width in pixels. |

From `SearchProps` — the full table stands on [Search](https://romanhaendler.github.io/umriss-ui/table/search/#type-SearchProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`placeholder`](https://romanhaendler.github.io/umriss-ui/table/search/#SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. |

From `AggregateOptions` — the full table stands on [Aggregate](https://romanhaendler.github.io/umriss-ui/table/aggregate/#type-AggregateOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`aggregate`](https://romanhaendler.github.io/umriss-ui/table/aggregate/#AggregateOptions-aggregate) | [`AggregateFor`](https://romanhaendler.github.io/umriss-ui/table/aggregate/#type-AggregateFor)`<W, Z>` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. |

From `ToolbarProps` — the full table stands on [Toolbar](https://romanhaendler.github.io/umriss-ui/table/toolbar/#type-ToolbarProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`children`](https://romanhaendler.github.io/umriss-ui/table/toolbar/#ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. |
