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/fuzzy-tables-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/table-core': patch
---

Fix hierarchical filtered row models so `flatRows` lists parents before descendants in both filtering modes, preserves filter metadata on cloned rows, and round-trips nested data correctly through worker-backed row models.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function filterRowModelFromLeafs<
Partial<Row_ColumnFiltering<TFeatures, TData>> = []

// Filter from children up first
for (let row of rowsToFilter) {
for (const row of rowsToFilter) {
const newRow = constructRow(
table,
row.id,
Expand All @@ -63,39 +63,30 @@ function filterRowModelFromLeafs<
) as Row<TFeatures, TData> &
Partial<Row_ColumnFiltering<TFeatures, TData>>
newRow.columnFilters = row.columnFilters
newRow.columnFiltersMeta = row.columnFiltersMeta

if (row.subRows.length && depth < maxDepth) {
newRow.subRows = recurseFilterRows(row.subRows, depth + 1)
row = newRow

if (filterRow(row) && !newRow.subRows.length) {
filteredRows.push(row)
newFilteredRowsById[row.id] = row
newFilteredFlatRows.push(row)
continue
}

if (filterRow(row) || newRow.subRows.length) {
filteredRows.push(row)
newFilteredRowsById[row.id] = row
newFilteredFlatRows.push(row)
continue
if (newRow.subRows.length || filterRow(newRow)) {
Comment thread
KevinVandy marked this conversation as resolved.
filteredRows.push(newRow)
}
} else {
row = newRow
if (filterRow(row)) {
filteredRows.push(row)
newFilteredRowsById[row.id] = row
newFilteredFlatRows.push(row)
if (filterRow(newRow)) {
newRow.subRows = row.subRows
filteredRows.push(newRow)
}
}
}

return filteredRows
}

const rows = recurseFilterRows(rowsToFilter)
addSubRowsToFlatArrays(rows, newFilteredFlatRows, newFilteredRowsById)

return {
rows: recurseFilterRows(rowsToFilter),
rows,
flatRows: newFilteredFlatRows,
rowsById: newFilteredRowsById,
}
Expand Down Expand Up @@ -123,7 +114,7 @@ function filterRowModelFromRoot<
const filteredRows: Array<Row<TFeatures, TData>> = []

// Apply the filter to any subRows
for (let row of rowsToFilter) {
for (const row of rowsToFilter) {
const pass = filterRow(row)

if (pass) {
Expand All @@ -136,26 +127,35 @@ function filterRowModelFromRoot<
row.depth,
undefined,
row.parentId,
)
) as Row<TFeatures, TData> &
Partial<Row_ColumnFiltering<TFeatures, TData>>
const filterData = row as Row<TFeatures, TData> &
Partial<Row_ColumnFiltering<TFeatures, TData>>
newRow.columnFilters = filterData.columnFilters
newRow.columnFiltersMeta = filterData.columnFiltersMeta

filteredRows.push(newRow)
newFilteredFlatRows.push(newRow)
newFilteredRowsById[newRow.id] = newRow

newRow.subRows = recurseFilterRows(row.subRows, depth + 1)
row = newRow
}
} else {
filteredRows.push(row)
newFilteredFlatRows.push(row)
newFilteredRowsById[row.id] = row

filteredRows.push(row)
newFilteredFlatRows.push(row)
newFilteredRowsById[row.id] = row

// When maxLeafRowFilterDepth stops the recursion, the kept row's
// subtree stays visible through row.subRows, so those descendants
// must still enter flatRows and rowsById to keep the flat
// representation (and anything derived from it, like facet counts)
// consistent with the visible tree
if (row.subRows.length && depth >= maxDepth) {
addSubRowsToFlatArrays(
row.subRows,
newFilteredFlatRows,
newFilteredRowsById,
)
// When maxLeafRowFilterDepth stops the recursion, the kept row's
// subtree stays visible through row.subRows, so those descendants
// must still enter flatRows and rowsById to keep the flat
// representation (and anything derived from it, like facet counts)
// consistent with the visible tree
if (row.subRows.length && depth >= maxDepth) {
addSubRowsToFlatArrays(
row.subRows,
newFilteredFlatRows,
newFilteredRowsById,
)
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/table-core/src/worker/createTableWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
tableWorkerPipeline,
tableWorkerStageStateDeps,
} from './tableWorkerProtocol'
import { applyFilterDataToCoreRows } from './rebuildRowModel'
import type { Table_Internal } from '../types/Table'
import type { TableFeature } from '../types/TableFeatures'
import type { TableWorkerDataPayload } from './rebuildRowModel'
Expand Down Expand Up @@ -236,6 +237,9 @@ function handleResult(
[TableWorkerStage, TableWorkerStagePayload]
>) {
if (payload.kind === 'unchanged') continue
if (stage === 'filtered') {
applyFilterDataToCoreRows(table.getCoreRowModel().flatRows, payload)
}
bridge.results[stage] = payload
bridge.stageVersions[stage] = (bridge.stageVersions[stage] ?? 0) + 1
anyChanged = true
Expand Down
2 changes: 2 additions & 0 deletions packages/table-core/src/worker/initTableWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ export function initTableWorker<
stages[stage] = serializeRowModel(
model,
coreIndexById,
table.getCoreRowModel().flatRows,
aggregateColumnIds,
transfer,
stage,
)
}

Expand Down
83 changes: 81 additions & 2 deletions packages/table-core/src/worker/rebuildRowModel.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
import { constructRow } from '../core/rows/constructRow'
import { hasOwn } from '../utils'
import { copyInstancePropertiesWithoutMemos, hasOwn } from '../utils'
import type { RowModel } from '../core/row-models/coreRowModelsFeature.types'
import type { Table_Internal } from '../types/Table'
import type { TableFeatures } from '../types/TableFeatures'
import type { RowData } from '../types/type-utils'
import type {
TableWorkerFilterData,
TableWorkerRowNode,
TableWorkerStage,
TableWorkerStagePayload,
} from './tableWorkerProtocol'

function applyFilterData(row: any, filterData?: TableWorkerFilterData) {
if (filterData) {
row.columnFilters = filterData.columnFilters
row.columnFiltersMeta = filterData.columnFiltersMeta
}
}

export function applyFilterDataToCoreRows(
coreFlatRows: Array<any>,
payload: TableWorkerDataPayload,
) {
if (payload.kind === 'flat') {
if (!payload.filterData) return
for (let i = 0; i < payload.indices.length; i++) {
applyFilterData(coreFlatRows[payload.indices[i]!], payload.filterData[i])
}
return
}

const applyToNodes = (nodes: Array<TableWorkerRowNode>) => {
for (const node of nodes) {
if (typeof node === 'number') continue
if (!('groupingColumnId' in node)) {
applyFilterData(coreFlatRows[node.index], node.filterData)
}
applyToNodes(node.children)
}
}
applyToNodes(payload.children)
}

/** Payloads that carry data; `unchanged` never reaches the rebuilder. */
export type TableWorkerDataPayload = Exclude<
TableWorkerStagePayload,
Expand Down Expand Up @@ -46,7 +78,7 @@ 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 === 'sorted'
const flattenParentsFirst = stage === 'filtered' || stage === 'sorted'

if (payload.kind === 'flat') {
const { indices } = payload
Expand All @@ -57,6 +89,7 @@ export function rebuildRowModel<
row.depth = 0
row.parentId = undefined
}
applyFilterData(row, payload.filterData?.[i])
rows[i] = row
}
return { rows, flatRows: rows, rowsById: core.rowsById }
Expand Down Expand Up @@ -84,6 +117,52 @@ export function rebuildRowModel<
continue
}

if (!('groupingColumnId' in node)) {
const coreRow: any = core.flatRows[node.index]!
const flatIndex = flattenParentsFirst ? flatRows.length : -1
if (flattenParentsFirst) {
flatRows.push(undefined)
}

const subRows = rebuildRows(node.children, depth + 1, coreRow.id)
let row = coreRow
const subRowsChanged =
subRows.length !== coreRow.subRows.length ||
subRows.some((subRow, index) => subRow !== coreRow.subRows[index])

if (stage === 'filtered' && coreRow.subRows.length) {
row = constructRow(
table,
coreRow.id,
coreRow.original,
coreRow.index,
coreRow.depth,
undefined,
coreRow.parentId,
)
row.subRows = subRows
} else if (subRowsChanged) {
row = Object.create(Object.getPrototypeOf(coreRow))
copyInstancePropertiesWithoutMemos(row, coreRow)
row.subRows = subRows
}

applyFilterData(row, node.filterData)

row.depth = depth
row.parentId = parentId
if (flattenParentsFirst) {
flatRows[flatIndex] = row
} else {
flatRows.push(row)
}
if (row !== coreRow) {
rowsById[row.id] = row
}
rows[i] = row
continue
}

// Sorted flatRows preserve the recursive rows order. Reserve the
// synthetic parent's position before rebuilding its descendants, then
// fill it once the row can be constructed from those descendants.
Expand Down
71 changes: 65 additions & 6 deletions packages/table-core/src/worker/serializeRowModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { RowModel } from '../core/row-models/coreRowModelsFeature.types'
import type {
TableWorkerFilterData,
TableWorkerRowNode,
TableWorkerStage,
TableWorkerStagePayload,
} from './tableWorkerProtocol'

Expand All @@ -14,14 +16,36 @@ function isCloneSafe(value: unknown): boolean {
function serializeRows(
rows: Array<any>,
coreIndexById: Record<string, number>,
coreFlatRows: Array<any>,
aggregateColumnIds: Array<string>,
stage: TableWorkerStage,
): Array<TableWorkerRowNode> {
const nodes = new Array<TableWorkerRowNode>(rows.length)
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row.groupingColumnId == null) {
// Data row: its position in `options.data` is all the main thread needs.
nodes[i] = coreIndexById[row.id]!
const index = coreIndexById[row.id]!
const coreRow = coreFlatRows[index]!
// A true leaf needs only its core-row position. Branch rows must carry
// their row-model children because filtering and sorting can replace or
// reorder (or remove all of) the core subtree. Filtered rows also carry
// the flags and metadata computed by the worker.
nodes[i] =
stage === 'filtered' || row.subRows.length || coreRow.subRows.length
? {
index,
children: serializeRows(
row.subRows,
coreIndexById,
coreFlatRows,
aggregateColumnIds,
stage,
),
...(stage === 'filtered'
? { filterData: serializeFilterData(row) }
: {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
: index
continue
}
// Synthetic group row: compute aggregates eagerly (the expensive per-group
Expand Down Expand Up @@ -50,7 +74,13 @@ function serializeRows(
groupingValue: row.groupingValue,
index: row.index,
aggregates,
children: serializeRows(row.subRows, coreIndexById, aggregateColumnIds),
children: serializeRows(
row.subRows,
coreIndexById,
coreFlatRows,
aggregateColumnIds,
stage,
),
}
}
return nodes
Expand All @@ -59,22 +89,51 @@ function serializeRows(
export function serializeRowModel(
model: RowModel<any, any>,
coreIndexById: Record<string, number>,
coreFlatRows: Array<any>,
aggregateColumnIds: Array<string>,
transfer: Array<Transferable>,
stage: TableWorkerStage,
): TableWorkerStagePayload {
// Flat fast path: no synthetic rows anywhere (flatRows === rows for flat
// data). A Uint32Array permutation transfers at zero-copy cost.
if (model.flatRows.length === model.rows.length) {
const canUseFlatPayload =
model.flatRows.length === model.rows.length &&
model.rows.every((row) => {
const coreRow = coreFlatRows[coreIndexById[row.id]!]!
return row.groupingColumnId == null && !coreRow.subRows.length
})

if (canUseFlatPayload) {
const indices = new Uint32Array(model.rows.length)
const filterData =
stage === 'filtered'
? new Array<TableWorkerFilterData>(model.rows.length)
: undefined
for (let i = 0; i < indices.length; i++) {
indices[i] = coreIndexById[model.rows[i]!.id]!
if (filterData) {
filterData[i] = serializeFilterData(model.rows[i]!)
}
}
transfer.push(indices.buffer)
return { kind: 'flat', indices }
return { kind: 'flat', indices, filterData }
}

return {
kind: 'tree',
children: serializeRows(model.rows, coreIndexById, aggregateColumnIds),
children: serializeRows(
model.rows,
coreIndexById,
coreFlatRows,
aggregateColumnIds,
stage,
),
}
}

function serializeFilterData(row: any): TableWorkerFilterData {
return {
columnFilters: row.columnFilters,
columnFiltersMeta: row.columnFiltersMeta,
}
}
Loading
Loading