Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-groups-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/table-core': patch
---

Emit grouped and downstream worker `flatRows` in parent-first preorder.
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,11 @@ function _createGroupedRowModel<
return rows.map((row) => {
row.depth = depth

// Every row is pushed into flatRows/rowsById exactly once, by its
// parent frame: rows returned here are pushed by the caller (the
// parent group's loop or the root loop), so only descendants below
// the terminal depth are pushed here.
groupedFlatRows.push(row)
groupedRowsById[row.id] = row

if (row.subRows.length) {
row.subRows = groupUpRecursively(row.subRows, depth + 1, row.id)
for (let i = 0; i < row.subRows.length; i++) {
const subRow = row.subRows[i]!
groupedFlatRows.push(subRow)
groupedRowsById[subRow.id] = subRow
}
}

return row
Expand All @@ -135,6 +129,10 @@ function _createGroupedRowModel<
let id = `${columnId}:${groupingValue}`
id = parentId ? `${parentId}>${id}` : id

// Reserve this group's position before its descendants are built.
const flatIndex = groupedFlatRows.length
groupedFlatRows.push(undefined as unknown as Row<TFeatures, TData>)

// First, Recurse to group sub rows before aggregation
const subRows = groupUpRecursively(groupedRows, depth + 1, id)

Expand Down Expand Up @@ -206,10 +204,8 @@ function _createGroupedRowModel<
},
})

subRows.forEach((subRow) => {
groupedFlatRows.push(subRow)
groupedRowsById[subRow.id] = subRow
})
groupedFlatRows[flatIndex] = row
groupedRowsById[id] = row

return row
},
Expand All @@ -220,11 +216,6 @@ function _createGroupedRowModel<

const groupedRows = groupUpRecursively(rowModel.rows, 0)

groupedRows.forEach((subRow) => {
groupedFlatRows.push(subRow)
groupedRowsById[subRow.id] = subRow
})

return {
rows: groupedRows,
flatRows: groupedFlatRows,
Expand Down
21 changes: 20 additions & 1 deletion packages/table-core/src/worker/rebuildRowModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export function rebuildRowModel<
// filtered model never touches them. Without this distinction a filtered
// rebuild could zero depths assigned by a grouped/sorted tree rebuild.
const resetDepths = stage !== 'filtered'
const flattenParentsFirst = stage === 'filtered' || stage === 'sorted'
const flattenParentsFirst =
stage === 'filtered' || stage === 'grouped' || stage === 'sorted'

if (payload.kind === 'flat') {
const { indices } = payload
Expand Down Expand Up @@ -207,5 +208,23 @@ export function rebuildRowModel<

const rows = rebuildRows(payload.children, 0, undefined)

if (stage === 'expanded') {
// Expanded rows are serialized inline as well as beneath their parents.
// Rebuild flatRows from the finished tree so each row appears once and
// parents retain their pipeline-wide preorder contract.
flatRows.length = 0
const seen = new Set<string>()
const flattenRows = (nestedRows: Array<any>) => {
for (let i = 0; i < nestedRows.length; i++) {
const row = nestedRows[i]
if (seen.has(row.id)) continue
seen.add(row.id)
flatRows.push(row)
flattenRows(row.subRows)
}
}
flattenRows(rows)
}

return { rows, flatRows, rowsById }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
columnFilteringFeature,
columnGroupingFeature,
constructTable,
createExpandedRowModel,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
globalFilteringFeature,
rowAggregationFeature,
rowExpandingFeature,
rowPaginationFeature,
rowSortingFeature,
} from '../../../../src'
import { testFeatures } from '../../../fixtures/features'
import type { ColumnDef, Row, RowModel } from '../../../../src'

interface PipelineRow {
group: string
name: string
subRows?: Array<PipelineRow>
}

const features = testFeatures({
columnFilteringFeature,
columnGroupingFeature,
globalFilteringFeature,
rowAggregationFeature,
rowExpandingFeature,
rowPaginationFeature,
rowSortingFeature,
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel(),
groupedRowModel: createGroupedRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns,
})

const data: Array<PipelineRow> = [
{
group: 'b',
name: 'keep-b',
subRows: [
{
group: 'b',
name: 'keep-b2',
subRows: [{ group: 'b', name: 'keep-b2a' }],
},
{ group: 'b', name: 'keep-b1' },
],
},
{
group: 'a',
name: 'keep-a',
subRows: [{ group: 'a', name: 'keep-a1' }],
},
]

const columns: Array<ColumnDef<typeof features, PipelineRow, any>> = [
{ accessorKey: 'group', id: 'group' },
{ accessorKey: 'name', id: 'name' },
]

function preorderIds(rows: Array<Row<typeof features, PipelineRow>>) {
const result: Array<string> = []
const seen = new Set<string>()

const visit = (nestedRows: Array<Row<typeof features, PipelineRow>>) => {
for (let i = 0; i < nestedRows.length; i++) {
const row = nestedRows[i]!
if (seen.has(row.id)) continue
seen.add(row.id)
result.push(row.id)
visit(row.subRows)
}
}

visit(rows)
return result
}

function expectPreorder(model: RowModel<typeof features, PipelineRow>) {
const flatIds = model.flatRows.map((row) => row.id)
expect(flatIds).toEqual(preorderIds(model.rows))
expect(new Set(flatIds).size).toBe(flatIds.length)
}

describe('row-model pipeline flatRows ordering', () => {
it('keeps parents before descendants through every hierarchical stage', () => {
const table = constructTable<typeof features, PipelineRow>({
features,
columns,
data,
getSubRows: (row) => row.subRows,
initialState: {
columnFilters: [{ id: 'name', value: 'keep' }],
expanded: true,
grouping: ['group'],
pagination: { pageIndex: 0, pageSize: 1 },
sorting: [{ id: 'name', desc: false }],
},
})

const models = [
table.getCoreRowModel(),
table.getFilteredRowModel(),
table.getGroupedRowModel(),
table.getSortedRowModel(),
table.getExpandedRowModel(),
table.getPaginatedRowModel(),
]

for (let i = 0; i < models.length; i++) {
expectPreorder(models[i]!)
}
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import {
rowAggregationFeature,
aggregationFns,
columnGroupingFeature,
constructTable,
createGroupedRowModel,
rowAggregationFeature,
} from '../../../../src'
import { testFeatures } from '../../../fixtures/features'
import { generateTestData } from '../../../fixtures/data/generateTestData'
Expand Down Expand Up @@ -67,6 +67,15 @@ describe('createGroupedRowModel flatRows contain every row exactly once', () =>
expect(rowModel.flatRows.length).toBe(7)
expectUniqueFlatRowIds(rowModel)
expect(Object.keys(rowModel.rowsById).length).toBe(7)
expect(rowModel.flatRows.map((row) => row.id)).toEqual([
'status:a',
'0',
'1',
'2',
'status:b',
'3',
'4',
])
})

it('two-level grouping over flat data', () => {
Expand All @@ -89,6 +98,17 @@ describe('createGroupedRowModel flatRows contain every row exactly once', () =>
expect(ids.has('status:a>firstName:x')).toBe(true)
expect(ids.has('status:a>firstName:y')).toBe(true)
expect(ids.has('status:b>firstName:x')).toBe(true)
expect(rowModel.flatRows.map((row) => row.id)).toEqual([
'status:a',
'status:a>firstName:x',
'0',
'1',
'status:a>firstName:y',
'2',
'status:b',
'status:b>firstName:x',
'3',
])
})

it('single-level grouping over tree data keeps descendants below the terminal depth exactly once', () => {
Expand Down Expand Up @@ -124,6 +144,24 @@ describe('createGroupedRowModel flatRows contain every row exactly once', () =>
expect(rowModel.rowsById['0']!.depth).toBe(1)
expect(rowModel.rowsById['0.0']!.depth).toBe(2)
expect(rowModel.rowsById['0.0.0']!.depth).toBe(3)
expect(rowModel.flatRows.map((row) => row.id)).toEqual([
'status:single',
'0',
'0.0',
'0.0.0',
'0.0.1',
'0.1',
'0.1.0',
'0.1.1',
'status:complicated',
'1',
'1.0',
'1.0.0',
'1.0.1',
'1.1',
'1.1.0',
'1.1.1',
])
})

it('groups rows with undefined grouping values exactly once', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/table-core/tests/unit/worker/serializeRebuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
columnFilteringFeature,
columnGroupingFeature,
constructTable,
createExpandedRowModel,
createFilteredRowModel,
createGroupedRowModel,
createSortedRowModel,
filterFns,
globalFilteringFeature,
rowAggregationFeature,
rowExpandingFeature,
rowSortingFeature,
sortFns,
} from '../../../src'
Expand Down Expand Up @@ -44,7 +46,9 @@ const features = testFeatures({
columnFilteringFeature,
columnGroupingFeature,
globalFilteringFeature,
rowExpandingFeature,
rowSortingFeature,
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel(),
groupedRowModel: createGroupedRowModel(),
sortedRowModel: createSortedRowModel(),
Expand Down Expand Up @@ -268,6 +272,7 @@ describe('serializeRowModel -> rebuildRowModel round trip', () => {
expect(payload.kind).toBe('tree')
expect(ids(rebuilt.rows)).toEqual(ids(model.rows))
expect(ids(rebuilt.flatRows)).toEqual(ids(model.flatRows))
expect(rebuilt.flatRows[0]).toBe(rebuilt.rows[0])
expect(ids(rebuilt.rows[0]!.subRows)).toEqual(ids(model.rows[0]!.subRows))
expect(ids(rebuilt.rows[0]!.subRows[0]!.subRows)).toEqual(
ids(model.rows[0]!.subRows[0]!.subRows),
Expand Down Expand Up @@ -507,6 +512,9 @@ describe('serializeRowModel -> rebuildRowModel round trip', () => {
const firstLeaf = firstSubGroup.subRows[0]!
expect(firstLeaf.depth).toBe(2)
expect(firstLeaf.parentId).toBe(firstSubGroup.id)
expect(ids(rebuilt.flatRows)).toEqual(ids(model.flatRows))
expect(rebuilt.flatRows[0]).toBe(firstGroup)
expect(rebuilt.flatRows[1]).toBe(firstSubGroup)
})

it('round-trips parent-first sorted flatRows through nested groups', () => {
Expand All @@ -523,6 +531,19 @@ describe('serializeRowModel -> rebuildRowModel round trip', () => {
expect(ids(rebuilt.flatRows)).toEqual(ids(model.flatRows))
})

it('round-trips parent-first expanded flatRows through groups', () => {
const data = makeData(12)
const workerTable = makeTable(data)
const mainTable = makeTable(data)
workerTable.baseAtoms.grouping.set(['status'])
workerTable.baseAtoms.expanded.set(true)

const model = workerTable.getExpandedRowModel()
const { rebuilt } = roundTrip(workerTable, mainTable, model, 'expanded')

expect(ids(rebuilt.flatRows)).toEqual(ids(model.flatRows))
})

it('does not reset depths when rebuilding a filtered payload (regression)', () => {
const data = makeData(12)
const workerTable = makeTable(data)
Expand Down
13 changes: 10 additions & 3 deletions perf-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ medians under `warmups: 0` β€” measurement noise (see N5). The two real non-wins
**Implementation note:** Fixed 2026-07-03 with the exactly-once push scheme described below: the
terminal-branch push (:86–87) was removed and replaced with a post-recursion push of the
reassigned `row.subRows` (covers descendants below terminal depth); the parent-group and root
loops are unchanged, so every row is pushed by its parent (or the root loop) exactly once.
loops were unchanged, so every row was pushed by its parent (or the root loop) exactly once.
Design review verified the scheme for single/multi-level grouping, flat and tree data, undefined
grouping values, AND a bonus instance the original finding missed: grouping on only-nonexistent
column ids (`existingGrouping.length === 0` after filtering) reached the terminal branch at depth
Expand All @@ -66,10 +66,17 @@ in-repo consumer reads that order; the sorted model already emits postorder), an
forwards grouped flatRows by reference) β€” correct, flagged for the release note. Regression
coverage in `tests/implementation/features/column-grouping/createGroupedRowModel.test.ts`
(single-level flat 12β†’7, two-level flat 13β†’9, tree-below-terminal 18β†’16, undefined grouping
values 8β†’5, nonexistent-column grouping 6β†’3 flatRows; all with duplicate-id checks). The
values 8β†’5, nonexistent-column grouping 6β†’3 flatRows; all with duplicate-id checks). A
2026-08-25 follow-up changed terminal rows to push before descending and synthetic group rows to
reserve their flat-array position before their descendants are built. This removes accepted
delta (a): group rows and tree data now appear before their descendants, matching core,
filtered, sorted, and paginated `flatRows`, without adding another traversal. Exact single-level,
multi-level, tree-data, and worker round-trip ordering tests cover the corrected contract. The
worker expanded-stage rebuild also deduplicates and restores preorder so the corrected grouping
order survives the downstream pipeline. Accepted delta (b), the exactly-once row count, remains. The
benchmark comparison layer gained a known-delta allowlist annotating the intentional v8↔v9
`outputFlatRows` mismatch for grouping scenarios.
**Location:** `packages/table-core/src/features/column-grouping/createGroupedRowModel.ts:86–87` (terminal-branch push) and `:179–182` (parent group's subRows push); v8 has the identical double-push in `table-v8/packages/table-core/src/utils/getGroupedRowModel.ts`
**Location:** `packages/table-core/src/features/column-grouping/createGroupedRowModel.ts`; v8 has the identical double-push in `table-v8/packages/table-core/src/utils/getGroupedRowModel.ts`
**Category:** `bug`, `allocation`, `big-o`

**Benchmark evidence:** for R=400,000 flat rows grouped into 20 groups, `outputFlatRowsMedian`
Expand Down
Loading