mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[eric] toolui: a data-table renders a 60-row window and one layout; 5,000 rows were 742K of the page's 794K fibers, mounted twice
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit d2bb3c82b948790ec340a886834a3ad9f0109962)
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const [width, setWidth] = React.useState<number | null>(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 ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setWindows((n) => n + 1)} data-slot="data-table-show-more">
|
||||
Show more rows ({hidden.toLocaleString()} more)
|
||||
</Button>
|
||||
) : null;
|
||||
const mobileDescriptionId = React.useMemo(
|
||||
() => getDataTableMobileDescriptionId(String(id ?? "data-table")),
|
||||
[id],
|
||||
@@ -233,20 +264,14 @@ function DataTableLayout({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={cn("@container w-full min-w-80", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="data-table"
|
||||
data-layout={layout}
|
||||
data-layout={mode}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
layout === "table"
|
||||
? "block"
|
||||
: layout === "cards"
|
||||
? "hidden"
|
||||
: "hidden @md:block",
|
||||
)}
|
||||
>
|
||||
{mode === "table" && (
|
||||
<div className="block">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -277,21 +302,20 @@ function DataTableLayout({
|
||||
{data.length === 0 ? (
|
||||
<DataTableEmpty message={emptyMessage} />
|
||||
) : (
|
||||
<DataTableContent />
|
||||
<DataTableContent rows={rows as unknown as DataTableRowData[]} rowKeys={shownKeys} footer={showMore ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={Math.max(1, columns.length)} className="py-1 text-center">{showMore}</TableCell>
|
||||
</TableRow>
|
||||
) : null} />
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "cards" && (
|
||||
<div
|
||||
className={cn(
|
||||
layout === "cards"
|
||||
? ""
|
||||
: layout === "table"
|
||||
? "hidden"
|
||||
: "@md:hidden",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Data table (mobile card view)"
|
||||
aria-describedby={mobileDescriptionId}
|
||||
@@ -308,8 +332,8 @@ function DataTableLayout({
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
|
||||
{data.map((row, i) => {
|
||||
const rowKey = rowKeys[i];
|
||||
{rows.map((row, i) => {
|
||||
const rowKey = shownKeys[i];
|
||||
return (
|
||||
<DataTableAccordionCard
|
||||
key={rowKey}
|
||||
@@ -320,9 +344,11 @@ function DataTableLayout({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showMore && <div className="border-t py-1 text-center">{showMore}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortAnnouncement && (
|
||||
<div className="sr-only" aria-live="polite">
|
||||
@@ -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 (
|
||||
<>
|
||||
<DataTableHeader />
|
||||
<DataTableBody />
|
||||
<DataTableBody rows={rows} rowKeys={rowKeys} footer={footer} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -637,16 +669,8 @@ function DataTableHead({
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBody() {
|
||||
function DataTableBody({ rows, rowKeys, footer }: DataTableContentProps) {
|
||||
const { data, rowIdKey } = useDataTable<DataTableRowData>();
|
||||
const rowKeys = React.useMemo(
|
||||
() =>
|
||||
createDataTableRowKeys(
|
||||
data as Array<Record<string, unknown>>,
|
||||
rowIdKey ? String(rowIdKey) : undefined,
|
||||
),
|
||||
[data, rowIdKey],
|
||||
);
|
||||
const hasWarnedRowKeyRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -665,10 +689,11 @@ function DataTableBody() {
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{data.map((row, index) => {
|
||||
{rows.map((row, index) => {
|
||||
const rowKey = rowKeys[index];
|
||||
return <DataTableRow key={rowKey} row={row} />;
|
||||
})}
|
||||
{footer}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, /<TableBody>\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\)/);
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user