From d2108c5591a87331f50bfd0328b886de09bca056 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 13:06:02 -0700 Subject: [PATCH] feat(bench): make the group script comparative against TanStack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench-runner gate claimed "TanStack Table ships no row-grouping row model of its own" and excluded it from every grouping script. That was stale: the installed @tanstack/table-core 9.1.2 ships createGroupedRowModel, rowAggregationFeature and createExpandedRowModel in the free package. The only grids whose grouping is genuinely paywalled are AG Grid (Enterprise) and MUI (Premium), and they stay excluded on that stated ground. The tanstack adapter now registers the three features plus the grouped and expanded row models — unconditionally at module scope, gated entirely by state, the same shape as pinning (#413). A `group` plan derives `grouping` from `plan.rowGroups` with everything expanded, which is the state the plan's resultRowCount arithmetic (leaves + one group row per key) describes; group rows render with the harness's own row attributes so the settle signature treats them as rows. Aggregation parity is the part that keeps the comparison honest: pretable's adapter deliberately attaches an "avg" aggregate to every numeric column so the aggregation stage is costed, and TanStack's aggregation is lazy — a grouped render that never reads the aggregated cells would skip the work entirely and flatter it. So numeric columns get `aggregationFn: "mean"` and the group rows RENDER the aggregate, forcing the computation inside the measured window. `group-expand` and the grouped streaming scripts stay pretable-only, and the gate now states the real reason — their setup and trigger run through pretable-specific machinery in bench-app.tsx — instead of the capability claim the old comment got wrong. First comparative run (S2 hypothesis, 5 repeats each, quiet machine): both adapters complete with exactly the plan-predicted 3004 rows and zero long tasks; pretable 58-66ms, tanstack 15-26ms — the same ~2-3x interaction pattern as sort and filter (#452), now with grouping measured like-for-like. Mutation-checked: dropping the aggregate, never deriving the grouping state, grouping unconditionally, and reverting the gate each redden exactly the test built to catch them. 158 bench tests, 16 runner tests, typecheck, lint. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/tanstack-adapter.test.tsx | 104 ++++++++++++++++++ apps/bench/src/tanstack-adapter.tsx | 92 +++++++++++++++- .../src/__tests__/bench-runner.test.ts | 70 +++++++++--- packages/bench-runner/src/index.ts | 29 ++++- 4 files changed, 269 insertions(+), 26 deletions(-) diff --git a/apps/bench/src/__tests__/tanstack-adapter.test.tsx b/apps/bench/src/__tests__/tanstack-adapter.test.tsx index 07ea268f..e39914b4 100644 --- a/apps/bench/src/__tests__/tanstack-adapter.test.tsx +++ b/apps/bench/src/__tests__/tanstack-adapter.test.tsx @@ -482,3 +482,107 @@ describe("TanstackAdapter column pinning", () => { } }); }); + +// The `group` interaction script's shape: `rowGroups: ["col_5"]` (an owner +// column, cardinality 4 at every scale) with an "avg" aggregate on every +// numeric column — pretable's adapter attaches those deliberately so the +// aggregation stage is costed (`applyGroupAggregates`), and a comparator that +// groups WITHOUT aggregating would measure less work and flatter itself. +// Two owners and a numeric column here, so groups and their means are both +// assertable. +const groupableDataset = { + columns: [ + { id: "col_0", header: "Message", wrap: false, widthPx: 140 }, + { id: "col_5", header: "Owner", wrap: false, widthPx: 140 }, + { id: "col_7", header: "Score", wrap: false, widthPx: 96 }, + ], + rows: [ + { id: "r1", col_0: "a", col_5: "text-core", col_7: 10 }, + { id: "r2", col_0: "b", col_5: "text-core", col_7: 30 }, + { id: "r3", col_0: "c", col_5: "layout-core", col_7: 50 }, + { id: "r4", col_0: "d", col_5: "layout-core", col_7: 70 }, + ], +}; + +function groupPlan(): BenchInteractionPlan { + return { + focusedRowId: "r2", + filters: {}, + mode: "group", + probeColumnId: "col_5", + resultRowCount: 6, + rows: groupableDataset.rows as never, + rowGroups: ["col_5"], + selectedRowId: "r2", + sort: [], + }; +} + +describe("TanstackAdapter row grouping", () => { + test("a group plan renders group rows, leaf rows, and computed aggregates", async () => { + const { container } = render( + , + ); + + await waitFor(() => { + expect( + container.querySelectorAll("[data-tanstack-group-row]").length, + ).toBe(2); + }); + + // Group rows are rows to the harness: the settle signature and the row + // walk read the same attributes off them as off leaves. + const groupRows = [ + ...container.querySelectorAll("[data-tanstack-group-row]"), + ]; + for (const row of groupRows) { + expect(row.hasAttribute("data-tanstack-row")).toBe(true); + expect(row.getAttribute("data-row-id")).toBeTruthy(); + expect(row.getAttribute("data-row-index")).toBeTruthy(); + } + + // All four leaves survive alongside the two groups... + expect(container.querySelectorAll("[data-tanstack-row]").length).toBe(6); + // ...and the published count is what the plan's arithmetic predicts + // (leaves + one group row per distinct key), or the settle detector + // refuses to complete the run against `plan.resultRowCount`. + expect( + container + .querySelector("[data-benchmark-adapter]") + ?.getAttribute("data-bench-result-row-count"), + ).toBe("6"); + + // The aggregation stage really ran: the group rows render the MEAN of + // their numeric column, which forces the computation inside the measured + // window exactly as pretable's formatAggregate does. + const texts = groupRows.map((row) => row.textContent ?? ""); + expect(texts.some((t) => t.includes("20"))).toBe(true); // mean(10, 30) + expect(texts.some((t) => t.includes("60"))).toBe(true); // mean(50, 70) + }); + + test("no plan means no grouping — the render is the ungrouped one", async () => { + // The negative arm that protects every other scenario: registering the + // grouping features must be inert until a plan asks for groups. + const { container } = render( + , + ); + + await waitFor(() => { + expect(container.querySelectorAll("[data-tanstack-row]").length).toBe(4); + }); + + expect(container.querySelectorAll("[data-tanstack-group-row]").length).toBe( + 0, + ); + expect( + container + .querySelector("[data-benchmark-adapter]") + ?.getAttribute("data-bench-result-row-count"), + ).toBe("4"); + }); +}); diff --git a/apps/bench/src/tanstack-adapter.tsx b/apps/bench/src/tanstack-adapter.tsx index 8aceadd8..08e6eb04 100644 --- a/apps/bench/src/tanstack-adapter.tsx +++ b/apps/bench/src/tanstack-adapter.tsx @@ -1,12 +1,18 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { + aggregationFns, columnFilteringFeature, + columnGroupingFeature, columnPinningFeature, columnSizingFeature, + createExpandedRowModel, createFilteredRowModel, + createGroupedRowModel, createSortedRowModel, filterFns, flexRender, + rowExpandingFeature, + rowAggregationFeature, rowSortingFeature, sortFns, tableFeatures, @@ -37,15 +43,30 @@ const OVERSCAN = 4; // unconditionally because `tableFeatures` is module scope — a scenario that // pins nothing simply leaves `columnPinning.start` empty, and `getIsPinned()` // returns false for every column, which is the pre-#413 render exactly. +// Grouping is registered the same way pinning is (#413): unconditionally at +// module scope, gated entirely by STATE. A scenario whose plan asks for no +// `rowGroups` leaves `grouping` empty, the grouped and expanded row models +// pass rows through untouched, and the render is byte-identical to before — +// the negative arm of the grouping test pins that. TanStack Table v9 ships +// all of this in the free package (`features/column-grouping`, +// `features/row-expanding`, `features/row-aggregation` in the installed +// 9.1.2); row grouping is only PAID in AG Grid (Enterprise) and MUI +// (Premium). const tanstackFeatures = tableFeatures({ columnFilteringFeature, + columnGroupingFeature, columnPinningFeature, columnSizingFeature, + rowExpandingFeature, + rowAggregationFeature, rowSortingFeature, filteredRowModel: createFilteredRowModel(), + groupedRowModel: createGroupedRowModel(), sortedRowModel: createSortedRowModel(), + expandedRowModel: createExpandedRowModel(), filterFns, sortFns, + aggregationFns, }); export interface TanstackAdapterProps { @@ -66,6 +87,7 @@ function toColumnDef( column: ScenarioColumn, scriptName: string | undefined, interactionMode: BenchInteractionPlan["mode"] | null, + sampleRow: ScenarioRow | undefined, ): ColumnDef { const def: ColumnDef = { id: column.id, @@ -73,6 +95,15 @@ function toColumnDef( header: column.header ?? column.id, enableSorting: true, enableColumnFilter: true, + // Every numeric column aggregates, mirroring pretable's + // `applyGroupAggregates` ("avg" there, `mean` here — the same fold): the + // grouping scripts deliberately cost the aggregation stage, and a + // comparator that groups without aggregating measures less work than the + // grid it is compared against. Inert until `grouping` state is non-empty, + // so no other scenario moves. + ...(sampleRow !== undefined && typeof sampleRow[column.id] === "number" + ? { aggregationFn: "mean" as const } + : {}), // TanStack's default filterFn is "auto" which maps to includesString // for strings. filter-metadata uses equals semantics in the bench // plan (see interaction-plan.ts METADATA_FILTER), so set @@ -166,8 +197,10 @@ export function TanstackAdapter({ const interactionMode = interactionPlan?.mode ?? null; const columns = useMemo( () => - dataset.columns.map((c) => toColumnDef(c, scriptName, interactionMode)), - [dataset.columns, scriptName, interactionMode], + dataset.columns.map((c) => + toColumnDef(c, scriptName, interactionMode, dataset.rows[0]), + ), + [dataset.columns, scriptName, interactionMode, dataset.rows], ); // The scenario's `pinned_left` columns, in dataset order. Empty for every @@ -178,6 +211,17 @@ export function TanstackAdapter({ [dataset.columns], ); + // The `group` script's trigger IS the plan arriving (bench-app sets the + // interaction-plan override inside the measured window), so grouping is + // derived state: plan present with rowGroups -> grouped, otherwise not. + // `expanded: true` keeps every group open, which is the state the plan's + // `resultRowCount` arithmetic (leaves + one group row per key) describes. + const grouping = useMemo( + () => + interactionPlan?.mode === "group" ? [...interactionPlan.rowGroups] : [], + [interactionPlan], + ); + const table = useTable({ features: tanstackFeatures, data, @@ -186,7 +230,12 @@ export function TanstackAdapter({ // reason `sorting` is: `runKey` remounts the adapter per run and the pinned // set is derived from the dataset, so it must follow a dataset swap rather // than latch whatever the first render saw. - state: { sorting, columnPinning: { start: pinnedColumnIds, end: [] } }, + state: { + sorting, + columnPinning: { start: pinnedColumnIds, end: [] }, + grouping, + expanded: true, + }, onSortingChange: setSorting, getRowId: (row) => String(row.id), }); @@ -384,6 +433,7 @@ export function TanstackAdapter({ > {virtualRows.map((vr) => { const row = rows[vr.index]; + const isGroupRow = row.getIsGrouped(); return (
+ {grouped + ? `${String(cell.getValue() ?? "")} (${row.subRows.length})` + : aggregated + ? String(cell.getValue() ?? "") + : ""} +
+ ); + } // Read off TanStack rather than off the dataset: the feature // owns the state, and `getStart("start")` is the running sum // of the pinned widths before this column. See diff --git a/packages/bench-runner/src/__tests__/bench-runner.test.ts b/packages/bench-runner/src/__tests__/bench-runner.test.ts index 9cbdf642..07451b6f 100644 --- a/packages/bench-runner/src/__tests__/bench-runner.test.ts +++ b/packages/bench-runner/src/__tests__/bench-runner.test.ts @@ -592,24 +592,60 @@ describe("bench-runner contract", () => { ).toEqual({ ok: true }); } - // ...and rejected, with a reason, for every other adapter. Row grouping - // is AG Grid Enterprise / MUI X Premium and absent from TanStack, so - // these numbers are absolute, never comparative. + // `group` is COMPARATIVE against TanStack: v9 ships the grouping row + // model, aggregation and expansion in the free package, and the tanstack + // adapter registers them with aggregation parity. AG Grid and MUI stay + // excluded on tier (Enterprise / Premium respectively). + expect( + validateSupportedP0aRequest({ + ...baseRequest, + adapterId: "tanstack", + scenarioId: "S2", + scriptName: "group", + }), + ).toEqual({ ok: true }); + for (const adapterId of ["ag-grid", "mui"] as const) { + expect( + validateSupportedP0aRequest({ + ...baseRequest, + adapterId, + scenarioId: "S2", + scriptName: "group", + }), + ).toEqual({ + ok: false, + reason: expect.stringContaining("Enterprise"), + }); + } + // `group-expand` stays pretable-only for a PLUMBING reason (bench-app's + // setup/trigger machinery), which the tanstack rejection must state — + // repeating the stale "absent from TanStack" claim here is exactly what + // this test previously did. + expect( + validateSupportedP0aRequest({ + ...baseRequest, + adapterId: "tanstack", + scenarioId: "S2", + scriptName: "group-expand", + }), + ).toEqual({ + ok: false, + reason: expect.stringContaining("plumbing"), + }); + for (const adapterId of ["ag-grid", "mui"] as const) { + expect( + validateSupportedP0aRequest({ + ...baseRequest, + adapterId, + scenarioId: "S2", + scriptName: "group-expand", + }), + ).toEqual({ + ok: false, + reason: expect.stringContaining("adapter"), + }); + } for (const adapterId of ["ag-grid", "tanstack", "mui"] as const) { - for (const scriptName of ["group", "group-expand"] as const) { - expect( - validateSupportedP0aRequest({ - ...baseRequest, - adapterId, - scenarioId: "S2", - scriptName, - }), - ).toEqual({ - ok: false, - reason: expect.stringContaining("adapter"), - }); - } - for (const scriptName of [ "group-updates", "group-updates-stable-keys", diff --git a/packages/bench-runner/src/index.ts b/packages/bench-runner/src/index.ts index 828e8c44..90756d89 100644 --- a/packages/bench-runner/src/index.ts +++ b/packages/bench-runner/src/index.ts @@ -485,14 +485,31 @@ export function validateSupportedP0aRequest( } if (groupingScripts.includes(request.scriptName)) { - // Row grouping is AG Grid Enterprise and MUI X Premium; TanStack Table - // ships no row-grouping row model of its own. This repo uses only the - // free tiers, so there is nothing to compare against and these numbers - // are ABSOLUTE + a regression tripwire, never a competitive claim. - if (request.adapterId !== "pretable") { + // Row grouping is a PAID tier in AG Grid (Enterprise: `RowGrouping` is in + // `EnterpriseModuleName`) and MUI (Premium: no `rowGroupingModel` in the + // installed Community package), and this repo uses only the free tiers — + // so those two are excluded on capability. TanStack Table v9 is NOT: the + // installed 9.1.2 ships `createGroupedRowModel`, `rowAggregationFeature` + // and `createExpandedRowModel` in the free package (an earlier comment + // here claimed otherwise and was stale), and the tanstack adapter now + // registers them, so `group` reads comparatively against it. + // + // `group-expand` and the grouped streaming scripts stay pretable-only for + // a PLUMBING reason, not a capability one: their setup and trigger run + // through pretable-specific machinery in bench-app.tsx + // (`waitForGroupedRowModel`, `grid.rowModel.setGroupExpanded`, the + // streaming update path), which no comparator adapter exposes yet. Until + // that exists their numbers are ABSOLUTE + a regression tripwire, never a + // competitive claim. + const groupCapableAdapters: readonly BenchAdapterId[] = + request.scriptName === "group" ? ["pretable", "tanstack"] : ["pretable"]; + if (!groupCapableAdapters.includes(request.adapterId)) { return { ok: false, - reason: `Unsupported adapter for ${request.scriptName}: ${request.adapterId} (row grouping is AG Grid Enterprise / MUI X Premium and absent from TanStack Table; pretable-only, not a comparative claim)`, + reason: + request.adapterId === "tanstack" + ? `Unsupported adapter for ${request.scriptName}: tanstack (TanStack v9 ships the grouping row model, but this script's setup/trigger plumbing in bench-app is pretable-only; see the gate comment)` + : `Unsupported adapter for ${request.scriptName}: ${request.adapterId} (row grouping is AG Grid Enterprise / MUI X Premium; free tiers only in this matrix)`, }; } }