diff --git a/frontend/src/toolui/components/data-table/data-table.tsx b/frontend/src/toolui/components/data-table/data-table.tsx index 2a4abfee..14cffd67 100644 --- a/frontend/src/toolui/components/data-table/data-table.tsx +++ b/frontend/src/toolui/components/data-table/data-table.tsx @@ -23,6 +23,9 @@ import { sortData, createDataTableRowKeys, getDataTableMobileDescriptionId, + effectiveTableMaxHeight, + pickLayout, + renderedRowCount, } from "./utilities"; import { renderFormattedValue } from "./formatters"; import type { @@ -218,6 +221,34 @@ function DataTableLayout({ ), [data, rowIdKey], ); + // One layout mounts, chosen by the measured width; the old pair-and-hide doubled every row. + const hostRef = React.useRef(null); + const [width, setWidth] = React.useState(null); + React.useLayoutEffect(() => { + const el = hostRef.current; + if (!el) return; + setWidth(el.offsetWidth); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (typeof w === "number") setWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const mode = pickLayout(layout, width); + // Rows are handed out a window at a time; a new payload starts over. + const [windows, setWindows] = React.useState(1); + React.useEffect(() => { setWindows(1); }, [data]); + const shown = renderedRowCount(data.length, windows); + const rows = React.useMemo(() => data.slice(0, shown), [data, shown]); + const shownKeys = React.useMemo(() => rowKeys.slice(0, shown), [rowKeys, shown]); + const hidden = data.length - shown; + const showMore = hidden > 0 ? ( + + ) : null; const mobileDescriptionId = React.useMemo( () => getDataTableMobileDescriptionId(String(id ?? "data-table")), [id], @@ -233,20 +264,14 @@ function DataTableLayout({ return (
-
+ {mode === "table" && ( +
) : ( - + + {showMore} + + ) : null} /> )}
+ )} + {mode === "cards" && (
) : (
- {data.map((row, i) => { - const rowKey = rowKeys[i]; + {rows.map((row, i) => { + const rowKey = shownKeys[i]; return ( ); })} + {showMore &&
{showMore}
}
)}
+ )} {sortAnnouncement && (
@@ -397,11 +423,17 @@ export const DataTable = Object.assign(DataTableRoot, { Provider: DataTableProvider, }) as DataTableComponent; -function DataTableContent() { +interface DataTableContentProps { + rows: DataTableRowData[]; + rowKeys: string[]; + footer: React.ReactNode; +} + +function DataTableContent({ rows, rowKeys, footer }: DataTableContentProps) { return ( <> - + ); } @@ -637,16 +669,8 @@ function DataTableHead({ ); } -function DataTableBody() { +function DataTableBody({ rows, rowKeys, footer }: DataTableContentProps) { const { data, rowIdKey } = useDataTable(); - const rowKeys = React.useMemo( - () => - createDataTableRowKeys( - data as Array>, - rowIdKey ? String(rowIdKey) : undefined, - ), - [data, rowIdKey], - ); const hasWarnedRowKeyRef = React.useRef(false); React.useEffect(() => { @@ -665,10 +689,11 @@ function DataTableBody() { return ( - {data.map((row, index) => { + {rows.map((row, index) => { const rowKey = rowKeys[index]; return ; })} + {footer} ); } diff --git a/frontend/src/toolui/components/data-table/rowWindow.test.ts b/frontend/src/toolui/components/data-table/rowWindow.test.ts new file mode 100644 index 00000000..9561251f --- /dev/null +++ b/frontend/src/toolui/components/data-table/rowWindow.test.ts @@ -0,0 +1,34 @@ +// Run: npm test (frontend/scripts/run-tests.mjs) +// +// A 5,000-row data-table in one expanded chat was 742,090 of the page's 793,776 React fibers (census, +// 2026-09-05): every row rendered, and twice, because the auto layout mounted the table AND the card view +// and hid one with a container query. Rows are now handed out a window at a time and one layout mounts. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { renderedRowCount, pickLayout, RENDER_ROW_WINDOW, CARD_LAYOUT_MAX_WIDTH } from './utilities.ts'; + +test('rows come in windows; a short table is whole, a long one is one window until asked for more', () => { + assert.equal(renderedRowCount(5, 1), 5); + assert.equal(renderedRowCount(5000, 1), RENDER_ROW_WINDOW); + assert.equal(renderedRowCount(5000, 2), 2 * RENDER_ROW_WINDOW); + assert.equal(renderedRowCount(70, 2), 70); + assert.equal(renderedRowCount(5000, 0), RENDER_ROW_WINDOW, 'zero windows still shows the first'); +}); + +test('one layout is picked from the measured width; unmeasured is the wide one, explicit layouts are honoured', () => { + assert.equal(pickLayout('auto', null), 'table'); + assert.equal(pickLayout('auto', CARD_LAYOUT_MAX_WIDTH), 'table'); + assert.equal(pickLayout('auto', CARD_LAYOUT_MAX_WIDTH - 1), 'cards'); + assert.equal(pickLayout('cards', 2000), 'cards'); + assert.equal(pickLayout('table', 100), 'table'); +}); + +test('the component maps the windowed rows, never the whole payload, and no longer mounts the hidden twin', () => { + const src = readFileSync(resolve(process.cwd(), 'src/toolui/components/data-table/data-table.tsx'), 'utf8'); + assert.doesNotMatch(src, /@md:(block|hidden)/, 'the pair-and-hide container query is what doubled every row'); + assert.match(src, /\s*\{rows\.map\(/, 'the table body maps the window'); + assert.doesNotMatch(src, /\{data\.map\(\(row/, 'nothing maps the full payload into the DOM'); + assert.match(src, /renderedRowCount\(data\.length, windows\)/); +}); diff --git a/frontend/src/toolui/components/data-table/utilities.ts b/frontend/src/toolui/components/data-table/utilities.ts index 6db6827a..1f399187 100644 --- a/frontend/src/toolui/components/data-table/utilities.ts +++ b/frontend/src/toolui/components/data-table/utilities.ts @@ -297,3 +297,41 @@ export function parseNumericLike(input: string): number | null { } return null; } + +// Long tables used to render every row, so a "collapsed" card could tower over the expanded one. +export const DEFAULT_ROW_CAP = 6; +// Roughly a header plus six rows at the table's default density; past it the body scrolls in place. +export const DEFAULT_CAPPED_MAX_HEIGHT = "312px"; + +/** The height cap the scroll container actually uses: a user's drag beats the payload's explicit maxHeight, which beats the row-count default. */ +export function effectiveTableMaxHeight( + explicit: string | undefined, + userPx: number | null, + rowCount: number, +): string | undefined { + if (userPx != null) return `${userPx}px`; + if (explicit) return explicit; + return rowCount > DEFAULT_ROW_CAP ? DEFAULT_CAPPED_MAX_HEIGHT : undefined; +} + +// Rendering every row is the cost, not showing it: a 5,000-row payload put 370,000 React fibers under ONE +// widget, and twice over, because the auto layout mounted the table and the card view together and hid one +// with a container query (census 2026-09-05: 742,090 of the page's 793,776 fibers). A window is what a reader +// reaches by scrolling the capped body a few times; a "Show more" row hands out the next one. +export const RENDER_ROW_WINDOW = 60; + +export function renderedRowCount(total: number, windows: number): number { + return Math.min(total, Math.max(1, windows) * RENDER_ROW_WINDOW); +} + +// The container query flipped to cards under --container-md; the same number now decides which ONE layout mounts. +export const CARD_LAYOUT_MAX_WIDTH = 448; + +export type ResolvedTableLayout = "table" | "cards"; + +/** Unmeasured (first paint, no ResizeObserver) reads as the wide layout; a narrow container flips to cards on the same frame. */ +export function pickLayout(layout: "auto" | ResolvedTableLayout, widthPx: number | null): ResolvedTableLayout { + if (layout !== "auto") return layout; + if (widthPx == null) return "table"; + return widthPx < CARD_LAYOUT_MAX_WIDTH ? "cards" : "table"; +}