Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf, memory: Improve performance and memory use for large datasets #5927

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
155 changes: 103 additions & 52 deletions packages/table-core/src/core/row.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface CoreRow<TData extends RowData> {
_getAllCellsByColumnId: () => Record<string, Cell<TData, unknown>>
_uniqueValuesCache: Record<string, unknown>
_valuesCache: Record<string, unknown>
clone: () => Row<TData>
/**
* The depth of the row (if nested or grouped) relative to the root row array.
* @link [API Docs](https://tanstack.com/table/v8/docs/api/core/row#depth)
Expand Down Expand Up @@ -92,26 +93,29 @@ export interface CoreRow<TData extends RowData> {
subRows: Row<TData>[]
}

export const createRow = <TData extends RowData>(
table: Table<TData>,
id: string,
original: TData,
rowIndex: number,
depth: number,
subRows?: Row<TData>[],
parentId?: string
): Row<TData> => {
let row: CoreRow<TData> = {
id,
index: rowIndex,
original,
depth,
parentId,
_valuesCache: {},
_uniqueValuesCache: {},
getValue: columnId => {
if (row._valuesCache.hasOwnProperty(columnId)) {
return row._valuesCache[columnId]
const rowProtosByTable = new WeakMap<Table<any>, any>()

/**
* Creates a table-specific row prototype object to hold shared row methods, including from all the
* features that have been registered on the table.
*/
export function getRowProto<TData extends RowData>(table: Table<TData>) {
let rowProto = rowProtosByTable.get(table)

if (!rowProto) {
const proto = {} as CoreRow<TData>

proto.clone = function () {
return Object.assign(Object.create(Object.getPrototypeOf(this)), this)
}

// Make the default fallback value available on the proto itself to avoid duplicating it on every row instance
// even if it's not used. This is safe as long as we don't mutate the value directly.
proto.subRows = [] as const

proto.getValue = function (columnId: string) {
if (this._valuesCache.hasOwnProperty(columnId)) {
return this._valuesCache[columnId]
}

const column = table.getColumn(columnId)
Expand All @@ -120,16 +124,22 @@ export const createRow = <TData extends RowData>(
return undefined
}

row._valuesCache[columnId] = column.accessorFn(
row.original as TData,
rowIndex
this._valuesCache[columnId] = column.accessorFn(
this.original as TData,
this.index
)

return row._valuesCache[columnId] as any
},
getUniqueValues: columnId => {
if (row._uniqueValuesCache.hasOwnProperty(columnId)) {
return row._uniqueValuesCache[columnId]
return this._valuesCache[columnId] as any
}

proto.getUniqueValues = function (columnId: string) {
if (!this.hasOwnProperty('_uniqueValuesCache')) {
// lazy-init cache on the instance
this._uniqueValuesCache = {}
}

if (this._uniqueValuesCache.hasOwnProperty(columnId)) {
return this._uniqueValuesCache[columnId]
}

const column = table.getColumn(columnId)
Expand All @@ -139,46 +149,58 @@ export const createRow = <TData extends RowData>(
}

if (!column.columnDef.getUniqueValues) {
row._uniqueValuesCache[columnId] = [row.getValue(columnId)]
return row._uniqueValuesCache[columnId]
this._uniqueValuesCache[columnId] = [this.getValue(columnId)]
return this._uniqueValuesCache[columnId]
}

row._uniqueValuesCache[columnId] = column.columnDef.getUniqueValues(
row.original as TData,
rowIndex
this._uniqueValuesCache[columnId] = column.columnDef.getUniqueValues(
this.original as TData,
this.index
)

return row._uniqueValuesCache[columnId] as any
},
renderValue: columnId =>
row.getValue(columnId) ?? table.options.renderFallbackValue,
subRows: subRows ?? [],
getLeafRows: () => flattenBy(row.subRows, d => d.subRows),
getParentRow: () =>
row.parentId ? table.getRow(row.parentId, true) : undefined,
getParentRows: () => {
return this._uniqueValuesCache[columnId] as any
}

proto.renderValue = function (columnId: string) {
return this.getValue(columnId) ?? table.options.renderFallbackValue
}

proto.getLeafRows = function () {
return flattenBy(this.subRows, d => d.subRows)
}

proto.getParentRow = function () {
return this.parentId ? table.getRow(this.parentId, true) : undefined
}

proto.getParentRows = function () {
let parentRows: Row<TData>[] = []
let currentRow = row
let currentRow = this
while (true) {
const parentRow = currentRow.getParentRow()
if (!parentRow) break
parentRows.push(parentRow)
currentRow = parentRow
}
return parentRows.reverse()
},
getAllCells: memo(
() => [table.getAllLeafColumns()],
leafColumns => {
}

proto.getAllCells = memo(
function (this: Row<TData>) {
return [this, table.getAllLeafColumns()]
},
(row, leafColumns) => {
return leafColumns.map(column => {
return createCell(table, row as Row<TData>, column, column.id)
return createCell(table, row, column, column.id)
})
},
getMemoOptions(table.options, 'debugRows', 'getAllCells')
),
)

_getAllCellsByColumnId: memo(
() => [row.getAllCells()],
proto._getAllCellsByColumnId = memo(
function (this: Row<TData>) {
return [this.getAllCells()]
},
allCells => {
return allCells.reduce(
(acc, cell) => {
Expand All @@ -189,7 +211,36 @@ export const createRow = <TData extends RowData>(
)
},
getMemoOptions(table.options, 'debugRows', 'getAllCellsByColumnId')
),
)

rowProtosByTable.set(table, proto)
rowProto = proto
}

return rowProto as CoreRow<TData>
}

export const createRow = <TData extends RowData>(
table: Table<TData>,
id: string,
original: TData,
rowIndex: number,
depth: number,
subRows?: Row<TData>[],
parentId?: string
): Row<TData> => {
const row: CoreRow<TData> = Object.create(getRowProto(table))
Object.assign(row, {
id,
index: rowIndex,
original,
depth,
parentId,
_valuesCache: {},
})

if (subRows) {
row.subRows = subRows
}

for (let i = 0; i < table._features.length; i++) {
Expand Down
32 changes: 23 additions & 9 deletions packages/table-core/src/features/ColumnFiltering.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RowModel } from '..'
import { getRowProto, RowModel } from '..'
import { BuiltInFilterFn, filterFns } from '../filterFns'
import {
Column,
Expand Down Expand Up @@ -362,14 +362,6 @@ export const ColumnFiltering: TableFeature = {
}
},

createRow: <TData extends RowData>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the core createRow function, we still call these feature.createRow functions if they exist, passing them the row and table instance. That should prevent breaking changes for existing custom features, but we may want to recommend custom features to take the same approach (i.e. extend the prototype). @KevinVandy what do you think about this?

I haven't thought all the details through but something like retaining a createRow function in each feature, and in the core createRow function both calling the feature.createRow function with the row and table instances (to prevent breaking changes for existing custom features), and also merging its prototype onto the core createRow prototype.

That way we could also retain the createRow functions in the core features, (just move the methods onto the prototype), and wouldn't need the getRowProto and Object.assign() approach I think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to generally recommending people use the same approach for implementing custom features. I considered making things more explicit by adding methods like initRowProto() to TableFeature interface, but decided against it for simplicity's sake, plus this is more of an internal implementation detail than a public API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of pattern will be useful to think about in the alpha branch though

row: Row<TData>,
_table: Table<TData>
): void => {
row.columnFilters = {}
row.columnFiltersMeta = {}
},

createTable: <TData extends RowData>(table: Table<TData>): void => {
table.setColumnFilters = (updater: Updater<ColumnFiltersState>) => {
const leafColumns = table.getAllLeafColumns()
Expand Down Expand Up @@ -411,6 +403,28 @@ export const ColumnFiltering: TableFeature = {

return table._getFilteredRowModel()
}

// Lazy-init the backing caches on the instance so we don't take up memory for rows that don't need it
Object.defineProperties(getRowProto(table), {
columnFilters: {
get() {
return (this._columnFilters ??= {})
},
set(value) {
this._columnFilters = value
},
enumerable: true,
},
columnFiltersMeta: {
get() {
return (this._columnFiltersMeta ??= {})
},
set(value) {
this._columnFiltersMeta = value
},
enumerable: true,
},
})
},
}

Expand Down
48 changes: 27 additions & 21 deletions packages/table-core/src/features/ColumnGrouping.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RowModel } from '..'
import { getRowProto, RowModel } from '..'
import { BuiltInAggregationFn, aggregationFns } from '../aggregationFns'
import {
AggregationFns,
Expand Down Expand Up @@ -353,31 +353,37 @@ export const ColumnGrouping: TableFeature = {

return table._getGroupedRowModel()
}
},

createRow: <TData extends RowData>(
row: Row<TData>,
table: Table<TData>
): void => {
row.getIsGrouped = () => !!row.groupingColumnId
row.getGroupingValue = columnId => {
if (row._groupingValuesCache.hasOwnProperty(columnId)) {
return row._groupingValuesCache[columnId]
}
Object.defineProperty(getRowProto(table), '_groupingValuesCache', {
get() {
// Lazy-init the backing cache on the instance so we don't take up memory for rows that don't need it
return (this.__groupingValuesCache ??= {})
},
enumerable: true,
})

Object.assign(getRowProto(table), {
getIsGrouped() {
return !!this.groupingColumnId
},
getGroupingValue(columnId) {
if (this._groupingValuesCache.hasOwnProperty(columnId)) {
return this._groupingValuesCache[columnId]
}

const column = table.getColumn(columnId)
const column = table.getColumn(columnId)

if (!column?.columnDef.getGroupingValue) {
return row.getValue(columnId)
}
if (!column?.columnDef.getGroupingValue) {
return this.getValue(columnId)
}

row._groupingValuesCache[columnId] = column.columnDef.getGroupingValue(
row.original
)
this._groupingValuesCache[columnId] = column.columnDef.getGroupingValue(
this.original
)

return row._groupingValuesCache[columnId]
}
row._groupingValuesCache = {}
return this._groupingValuesCache[columnId]
},
} as GroupingRow & Row<any>)
},

createCell: <TData extends RowData, TValue>(
Expand Down
Loading