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)`, }; } }