diff --git a/packages/table-core/src/core/cells/constructCell.ts b/packages/table-core/src/core/cells/constructCell.ts index 63e16ac737..a63832dd9e 100644 --- a/packages/table-core/src/core/cells/constructCell.ts +++ b/packages/table-core/src/core/cells/constructCell.ts @@ -1,3 +1,4 @@ +import { warmInstanceShape } from '../../utils' import type { Table_Internal } from '../../types/Table' import type { CellData, RowData } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' @@ -6,22 +7,69 @@ import type { Cell } from '../../types/Cell' import type { Column } from '../../types/Column' import type { Cell_CoreProperties } from './coreCellsFeature.types' +type CellConstructor< + TFeatures extends TableFeatures, + TData extends RowData, +> = new ( + column: Column, + row: Row, + id: string, +) => Cell_CoreProperties + /** - * Creates or retrieves the cell prototype for a table. - * The prototype is cached on the table and shared by all cell instances. + * Creates or retrieves the cell constructor for a table. + * + * Cells are allocated through a per-table constructor function (rather than + * `Object.create`) so the engine learns the exact number of fields a cell + * needs and stores them in-object. The constructor's prototype carries the + * feature APIs and is shared by all cells. */ -function getCellPrototype< +function getCellConstructor< TFeatures extends TableFeatures, TData extends RowData, ->(table: Table_Internal): object { - if (!table._cellPrototype) { - table._cellPrototype = { table } +>(table: Table_Internal): CellConstructor { + if (!table._cellConstructor) { + const cellPrototype: Record = { table } const features = Object.values(table._features) for (let i = 0; i < features.length; i++) { - features[i]!.assignCellPrototype?.(table._cellPrototype, table) + features[i]!.assignCellPrototype?.(cellPrototype, table) } + + // Every core own property is declared here (memo storage as `undefined`) + // so later writes are value writes that never change the cell's hidden + // class. + function TableCell( + this: any, + column: Column, + row: Row, + id: string, + ) { + this._memoGetContext = undefined + this._memos = undefined + this.column = column + this.id = id + this.row = row + } + TableCell.prototype = cellPrototype + + table._cellPrototype = cellPrototype + table._cellConstructor = TableCell as unknown as CellConstructor< + TFeatures, + TData + > + + // Discarded warmup cell: pre-marks every declared field as mutable on + // the shared cell shape before any real cell exists or any code + // optimizes against it. + warmInstanceShape( + constructCell( + { id: '' } as Column, + { id: '' } as Row, + table, + ) as Record, + ) } - return table._cellPrototype + return table._cellConstructor as CellConstructor } /** @@ -38,18 +86,8 @@ export function constructCell< row: Row, table: Table_Internal, ): Cell { - // Create cell with shared prototype for memory efficiency - const cellPrototype = getCellPrototype(table) - const cell = Object.create(cellPrototype) as Cell_CoreProperties< - TFeatures, - TData, - TValue - > - - // Only assign instance-specific properties - cell.column = column - cell.id = `${row.id}_${column.id}` - cell.row = row + const CellCtor = getCellConstructor(table) + const cell = new CellCtor(column, row, `${row.id}_${column.id}`) // Initialize instance-specific data for features that need it const initFns = table._cellInstanceInitFns diff --git a/packages/table-core/src/core/cells/coreCellsFeature.ts b/packages/table-core/src/core/cells/coreCellsFeature.ts index e79384620d..ce1a55ee00 100644 --- a/packages/table-core/src/core/cells/coreCellsFeature.ts +++ b/packages/table-core/src/core/cells/coreCellsFeature.ts @@ -21,6 +21,9 @@ export const coreCellsFeature: TableFeature = { cell_getContext: { fn: (cell) => cell_getContext(cell), memoDeps: (cell) => [cell], + // Called for every rendered cell; a dedicated slot keeps the memo + // load monomorphic. Declared in constructCell. + memoSlot: '_memoGetContext', }, }) }, diff --git a/packages/table-core/src/core/cells/coreCellsFeature.types.ts b/packages/table-core/src/core/cells/coreCellsFeature.types.ts index a2a1832620..059da51967 100644 --- a/packages/table-core/src/core/cells/coreCellsFeature.types.ts +++ b/packages/table-core/src/core/cells/coreCellsFeature.types.ts @@ -23,6 +23,18 @@ export interface Cell_CoreProperties< in out TData extends RowData, TValue extends CellData = CellData, > { + /** + * Dedicated memo slot for the render-hot `getContext` API. Declared at + * construction so creating the memo never changes the cell's hidden class. + * @internal + */ + _memoGetContext?: (...args: Array) => any + /** + * Holder for lazily created memoized API state. Declared at construction so + * creating a memo never changes the cell's hidden class. + * @internal + */ + _memos?: Record) => any> /** * The associated Column object for the cell. */ diff --git a/packages/table-core/src/core/columns/constructColumn.ts b/packages/table-core/src/core/columns/constructColumn.ts index b26f42f7d6..69c6893a66 100644 --- a/packages/table-core/src/core/columns/constructColumn.ts +++ b/packages/table-core/src/core/columns/constructColumn.ts @@ -108,7 +108,9 @@ export function constructColumn< TValue > - // Only assign instance-specific properties + // Only assign instance-specific properties. `_memos` is declared up front + // so memoized API calls never change the column's hidden class. + column._memos = undefined column.accessorFn = accessorFn column.columnDef = resolvedColumnDef as ColumnDef column.columns = [] diff --git a/packages/table-core/src/core/columns/coreColumnsFeature.types.ts b/packages/table-core/src/core/columns/coreColumnsFeature.types.ts index 2cd8780410..7ae6804e03 100644 --- a/packages/table-core/src/core/columns/coreColumnsFeature.types.ts +++ b/packages/table-core/src/core/columns/coreColumnsFeature.types.ts @@ -9,6 +9,12 @@ export interface Column_CoreProperties< in out TData extends RowData, TValue extends CellData = CellData, > { + /** + * Holder for lazily created memoized API state. Declared at construction so + * creating a memo never changes the column's hidden class. + * @internal + */ + _memos?: Record) => any> /** * The resolved accessor function to use when extracting the value for the column from each row. Will only be defined if the column def has a valid accessor key or function defined. */ diff --git a/packages/table-core/src/core/headers/buildHeaderGroups.ts b/packages/table-core/src/core/headers/buildHeaderGroups.ts index c7e7420174..4e6b652e24 100644 --- a/packages/table-core/src/core/headers/buildHeaderGroups.ts +++ b/packages/table-core/src/core/headers/buildHeaderGroups.ts @@ -139,6 +139,7 @@ function constructHeaderGroup< pendingParentHeaders.push(header) } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required by the TS version compatibility matrix headerGroup.headers.push(headerToGroup as Header) headerToGroup.headerGroup = headerGroup } diff --git a/packages/table-core/src/core/headers/constructHeader.ts b/packages/table-core/src/core/headers/constructHeader.ts index d8f1bb5361..9bc940969d 100644 --- a/packages/table-core/src/core/headers/constructHeader.ts +++ b/packages/table-core/src/core/headers/constructHeader.ts @@ -51,7 +51,9 @@ export function constructHeader< TValue > - // Only assign instance-specific properties + // Only assign instance-specific properties. `_memos` is declared up front + // so memoized API calls never change the header's hidden class. + header._memos = undefined header.colSpan = 0 header.column = column header.depth = options.depth diff --git a/packages/table-core/src/core/headers/coreHeadersFeature.types.ts b/packages/table-core/src/core/headers/coreHeadersFeature.types.ts index 8c267d1894..9a9b0a34fb 100644 --- a/packages/table-core/src/core/headers/coreHeadersFeature.types.ts +++ b/packages/table-core/src/core/headers/coreHeadersFeature.types.ts @@ -53,6 +53,12 @@ export interface Header_CoreProperties< in out TData extends RowData, TValue extends CellData = CellData, > { + /** + * Holder for lazily created memoized API state. Declared at construction so + * creating a memo never changes the header's hidden class. + * @internal + */ + _memos?: Record) => any> /** * The col-span for the header. */ diff --git a/packages/table-core/src/core/rows/constructRow.ts b/packages/table-core/src/core/rows/constructRow.ts index 9fecbb23ed..76ca2c8ab8 100644 --- a/packages/table-core/src/core/rows/constructRow.ts +++ b/packages/table-core/src/core/rows/constructRow.ts @@ -1,26 +1,88 @@ -import { makeObjectMap } from '../../utils' +import { makeObjectMap, warmInstanceShape } from '../../utils' import type { Table_Internal } from '../../types/Table' import type { RowData } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' import type { Row } from '../../types/Row' import type { Row_CoreProperties } from './coreRowsFeature.types' +type RowConstructor< + TFeatures extends TableFeatures, + TData extends RowData, +> = new ( + id: string, + original: TData, + rowIndex: number, + depth: number, + parentId: string | undefined, + subRows: Array>, +) => Row_CoreProperties + /** - * Creates or retrieves the row prototype for a table. - * The prototype is cached on the table and shared by all row instances. + * Creates or retrieves the row constructor for a table. + * + * Rows are allocated through a per-table constructor function (rather than + * `Object.create`) so the engine learns the exact number of fields a row + * needs and stores them in-object, instead of spilling into an out-of-line + * property backing store that reallocates as fields are assigned. The + * constructor's prototype carries the feature APIs and is shared by all rows. */ -function getRowPrototype< +function getRowConstructor< TFeatures extends TableFeatures, TData extends RowData, ->(table: Table_Internal): object { - if (!table._rowPrototype) { - table._rowPrototype = { table } +>(table: Table_Internal): RowConstructor { + if (!table._rowConstructor) { + const rowPrototype: Record = { table } const features = Object.values(table._features) for (let i = 0; i < features.length; i++) { - features[i]!.assignRowPrototype?.(table._rowPrototype, table) + features[i]!.assignRowPrototype?.(rowPrototype, table) + } + + // Every core own property is declared here (as `undefined` when it has no + // value yet) so later writes are value writes that never change the row's + // hidden class. + function TableRow( + this: any, + id: string, + original: TData, + rowIndex: number, + depth: number, + parentId: string | undefined, + subRows: Array>, + ) { + this._cellsCache = undefined + this._displayIndexCache = -1 + this._memoGetAllCells = undefined + this._memoGetAllCellsByColumnId = undefined + this._memos = undefined + this._uniqueValuesCache = undefined + this._valuesCache = makeObjectMap() + this.depth = depth + this.id = id + this.index = rowIndex + this.original = original + this.originalSubRows = undefined + this.parentId = parentId + this.subRows = subRows } + TableRow.prototype = rowPrototype + + table._rowPrototype = rowPrototype + table._rowConstructor = TableRow as unknown as RowConstructor< + TFeatures, + TData + > + + // Discarded warmup row: pre-marks every declared field as mutable on the + // shared row shape before any real row exists or any code optimizes + // against it. + warmInstanceShape( + constructRow(table, '', undefined as unknown as TData, -1, -1) as Record< + string, + unknown + >, + ) } - return table._rowPrototype + return table._rowConstructor as RowConstructor } /** @@ -40,23 +102,15 @@ export const constructRow = < subRows?: Array>, parentId?: string, ): Row => { - // Create row with shared prototype for memory efficiency - const rowPrototype = getRowPrototype(table) - const row = Object.create(rowPrototype) as Row_CoreProperties< - TFeatures, - TData - > - - // Only assign instance-specific properties - row._displayIndexCache = -1 - row._uniqueValuesCache = makeObjectMap() - row._valuesCache = makeObjectMap() - row.depth = depth - row.id = id - row.index = rowIndex - row.original = original - row.parentId = parentId - row.subRows = subRows ?? [] + const RowCtor = getRowConstructor(table) + const row = new RowCtor( + id, + original, + rowIndex, + depth, + parentId, + subRows ?? [], + ) // Initialize instance-specific data (e.g., caches) for features that need it const initFns = table._rowInstanceInitFns diff --git a/packages/table-core/src/core/rows/coreRowsFeature.ts b/packages/table-core/src/core/rows/coreRowsFeature.ts index 5c96a05522..b9de73073c 100644 --- a/packages/table-core/src/core/rows/coreRowsFeature.ts +++ b/packages/table-core/src/core/rows/coreRowsFeature.ts @@ -28,10 +28,14 @@ export const coreRowsFeature: TableFeature = { row_getAllCellsByColumnId: { fn: (row) => row_getAllCellsByColumnId(row), memoDeps: (row) => [row.getAllCells()], + // Called per row by pinned-region cell reads; dedicated slots keep + // these render-hot memo loads monomorphic. Declared in constructRow. + memoSlot: '_memoGetAllCellsByColumnId', }, row_getAllCells: { fn: (row) => row_getAllCells(row), memoDeps: (row) => [row.table.getAllLeafColumns()], + memoSlot: '_memoGetAllCells', }, row_getLeafRows: { fn: (row) => row_getLeafRows(row), diff --git a/packages/table-core/src/core/rows/coreRowsFeature.types.ts b/packages/table-core/src/core/rows/coreRowsFeature.types.ts index 8a38bed8c9..ebd213e434 100644 --- a/packages/table-core/src/core/rows/coreRowsFeature.types.ts +++ b/packages/table-core/src/core/rows/coreRowsFeature.types.ts @@ -23,7 +23,24 @@ export interface Row_CoreProperties< * @internal */ _displayIndexCache: number - _uniqueValuesCache: Record + /** + * Dedicated memo slot for the render-hot `getAllCells` API. Declared at + * construction so creating the memo never changes the row's hidden class. + * @internal + */ + _memoGetAllCells?: (...args: Array) => any + /** + * Dedicated memo slot for the `getAllCellsByColumnId` API. + * @internal + */ + _memoGetAllCellsByColumnId?: (...args: Array) => any + /** + * Holder for lazily created memoized API state. Declared at construction so + * creating a memo never changes the row's hidden class. + * @internal + */ + _memos?: Record) => any> + _uniqueValuesCache?: Record _valuesCache: Record /** * The depth of the row (if nested or grouped) relative to the root row array. diff --git a/packages/table-core/src/core/rows/coreRowsFeature.utils.ts b/packages/table-core/src/core/rows/coreRowsFeature.utils.ts index f37ce79828..c6d0ec3d1f 100644 --- a/packages/table-core/src/core/rows/coreRowsFeature.utils.ts +++ b/packages/table-core/src/core/rows/coreRowsFeature.utils.ts @@ -109,8 +109,13 @@ export function row_getUniqueValues< TFeatures extends TableFeatures, TData extends RowData, >(row: Row, columnId: string) { - if (hasOwn(row._uniqueValuesCache, columnId)) { - return row._uniqueValuesCache[columnId] + // Allocated on first use: only faceting/grouping paths read unique values, + // so most rows never pay for the map. The slot itself is declared at + // construction, keeping this a value write. + const uniqueValuesCache = (row._uniqueValuesCache ??= makeObjectMap()) + + if (hasOwn(uniqueValuesCache, columnId)) { + return uniqueValuesCache[columnId] } const column = row.table.getColumn(columnId) @@ -120,16 +125,16 @@ export function row_getUniqueValues< } if (!column.columnDef.getUniqueValues) { - row._uniqueValuesCache[columnId] = [row.getValue(columnId)] - return row._uniqueValuesCache[columnId] + uniqueValuesCache[columnId] = [row.getValue(columnId)] + return uniqueValuesCache[columnId] } - row._uniqueValuesCache[columnId] = column.columnDef.getUniqueValues( + uniqueValuesCache[columnId] = column.columnDef.getUniqueValues( row.original, row.index, ) - return row._uniqueValuesCache[columnId] + return uniqueValuesCache[columnId] } /** diff --git a/packages/table-core/src/core/table/coreTablesFeature.types.ts b/packages/table-core/src/core/table/coreTablesFeature.types.ts index 715455cc67..8c144bf1cd 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.types.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.types.ts @@ -165,6 +165,11 @@ export interface Table_CoreProperties< * Cache of the `initCellInstanceData` functions for features that define one. */ _cellInstanceInitFns: Array> + /** + * Constructor cache for Cell objects. Cells allocate through a per-table + * constructor so the engine stores their fields in-object. + */ + _cellConstructor?: new (...args: Array) => object /** * Prototype cache for Cell objects - shared by all cells in this table */ @@ -207,6 +212,11 @@ export interface Table_CoreProperties< * The row models that are enabled for the table. */ readonly _rowModels: CachedRowModels + /** + * Constructor cache for Row objects. Rows allocate through a per-table + * constructor so the engine stores their fields in-object. + */ + _rowConstructor?: new (...args: Array) => object /** * Prototype cache for Row objects - shared by all rows in this table */ diff --git a/packages/table-core/src/features/column-filtering/columnFilteringFeature.ts b/packages/table-core/src/features/column-filtering/columnFilteringFeature.ts index a6d36e3b5c..41adb36ec2 100644 --- a/packages/table-core/src/features/column-filtering/columnFilteringFeature.ts +++ b/packages/table-core/src/features/column-filtering/columnFilteringFeature.ts @@ -18,6 +18,15 @@ import { } from './columnFilteringFeature.utils' import type { TableFeature } from '../../types/TableFeatures' +// Shared initial values for the per-row filter maps: rows keep one hidden +// class and `row.columnFilters(Meta)` stays always-defined for userland +// readers (fuzzy-sort reads meta directly), without paying for two map +// allocations per row at construction. Frozen because every filter pass +// assigns fresh maps before writing; a stray write to the shared map would +// throw instead of leaking across rows. +const initialColumnFilters = Object.freeze(makeObjectMap()) +const initialColumnFiltersMeta = Object.freeze(makeObjectMap()) + /** * Feature that adds per-column filtering state, options, and column/table filter APIs. */ @@ -70,8 +79,8 @@ export const columnFilteringFeature: TableFeature = { }, initRowInstanceData: (row) => { - ;(row as any).columnFilters = makeObjectMap() - ;(row as any).columnFiltersMeta = makeObjectMap() + ;(row as any).columnFilters = initialColumnFilters + ;(row as any).columnFiltersMeta = initialColumnFiltersMeta }, constructTableAPIs: (table) => { diff --git a/packages/table-core/src/features/column-grouping/columnGroupingFeature.ts b/packages/table-core/src/features/column-grouping/columnGroupingFeature.ts index 94a680d154..d230b102df 100644 --- a/packages/table-core/src/features/column-grouping/columnGroupingFeature.ts +++ b/packages/table-core/src/features/column-grouping/columnGroupingFeature.ts @@ -1,9 +1,9 @@ import { assignPrototypeAPIs, assignTableAPIs, - makeObjectMap, makeStateUpdater, } from '../../utils' +import { row_getValue } from '../../core/rows/coreRowsFeature.utils' import { cell_getIsGrouped, cell_getIsPlaceholder, @@ -13,6 +13,7 @@ import { column_getToggleGroupingHandler, column_toggleGrouping, getDefaultGroupingState, + row_getGroupedValue, row_getGroupingValue, row_getIsGrouped, table_resetGrouping, @@ -78,10 +79,30 @@ export const columnGroupingFeature: TableFeature = { fn: (row, columnId) => row_getGroupingValue(row, columnId), }, }) + + // Overrides the core `row.getValue` (core features assign first) so + // grouped rows resolve grouping and aggregated values through the shared + // prototype instead of a per-row closure, which would fork the row's + // hidden class. Assigned directly with fixed arity: `getValue` is the + // hottest row API in grouped pipelines (groupBy and every aggregation + // read it), so it skips the generic rest-args wrapper. + prototype.getValue = function (this: any, columnId: string) { + return this.groupingColumnId === undefined + ? row_getValue(this, columnId) + : row_getGroupedValue(this, columnId) + } }, initRowInstanceData: (row) => { - ;(row as any)._groupingValuesCache = makeObjectMap() + // Declared up front (`undefined` until the grouped row model assigns + // them) so grouped rows share the leaf rows' hidden class. + const groupingRow = row as any + groupingRow._aggregationValuesCache = undefined + groupingRow._groupedRows = undefined + groupingRow._groupingValuesCache = undefined + groupingRow.groupingColumnId = undefined + groupingRow.groupingValue = undefined + groupingRow.leafRows = undefined }, constructTableAPIs: (table) => { diff --git a/packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts b/packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts index 0f2c3cf70b..753edcd8d4 100644 --- a/packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts +++ b/packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts @@ -55,8 +55,24 @@ export interface Column_ColumnGrouping { toggleGrouping: () => void } -export interface Row_ColumnGrouping { - _groupingValuesCache: Record +export interface Row_ColumnGrouping< + in out TFeatures extends TableFeatures = TableFeatures, + in out TData extends RowData = RowData, +> { + /** + * Cache of aggregated values computed for this grouped row, keyed by column + * id. Populated lazily by grouped `row.getValue` reads. + * @internal + */ + _aggregationValuesCache?: Record + /** + * The rows the grouped row model partitioned into this group, before + * aggregation normalization. Grouped `row.getValue` reads resolve grouping + * values and aggregations from these rows. + * @internal + */ + _groupedRows?: Array> + _groupingValuesCache?: Record /** * Reads the value used to group this row for a column id. */ @@ -73,6 +89,11 @@ export interface Row_ColumnGrouping { * If this row is grouped, this is the unique/shared value for the `groupingColumnId` for all of the rows in this group. */ groupingValue?: unknown + /** + * If this row is grouped, the normalized unique leaf rows this group + * aggregates over. + */ + leafRows?: Array> } export interface Cell_ColumnGrouping { diff --git a/packages/table-core/src/features/column-grouping/columnGroupingFeature.utils.ts b/packages/table-core/src/features/column-grouping/columnGroupingFeature.utils.ts index 51384e979d..d4ed0eef65 100644 --- a/packages/table-core/src/features/column-grouping/columnGroupingFeature.utils.ts +++ b/packages/table-core/src/features/column-grouping/columnGroupingFeature.utils.ts @@ -1,4 +1,7 @@ -import { cloneState, hasOwn, setStateSlice } from '../../utils' +import { cloneState, hasOwn, makeObjectMap, setStateSlice } from '../../utils' +import { row_getValue } from '../../core/rows/coreRowsFeature.utils' +import { table_getColumn } from '../../core/columns/coreColumnsFeature.utils' +import { aggregateColumnValue } from '../row-aggregation/rowAggregationFeature.utils' import type { Column_Internal } from '../../types/Column' import type { CellData, RowData, Updater } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' @@ -187,7 +190,7 @@ export function table_resetGrouping< export function row_getIsGrouped< TFeatures extends TableFeatures, TData extends RowData, ->(row: Row & Partial) { +>(row: Row & Partial>) { return !!row.groupingColumnId } @@ -205,7 +208,10 @@ export function row_getIsGrouped< export function row_getGroupingValue< TFeatures extends TableFeatures, TData extends RowData, ->(row: Row & Partial, columnId: string) { +>( + row: Row & Partial>, + columnId: string, +) { if (row._groupingValuesCache && hasOwn(row._groupingValuesCache, columnId)) { return row._groupingValuesCache[columnId] } @@ -219,15 +225,111 @@ export function row_getGroupingValue< return row.getValue(columnId) } - if (row._groupingValuesCache) { - row._groupingValuesCache[columnId] = column.columnDef.getGroupingValue( - row.original, - row.index, - row, - ) + // Allocated on first use; the slot is declared at construction so this is + // a value write. + const groupingValuesCache = (row._groupingValuesCache ??= makeObjectMap()) + groupingValuesCache[columnId] = column.columnDef.getGroupingValue( + row.original, + row.index, + row, + ) + + return groupingValuesCache[columnId] +} + +/** + * Grouping-aware implementation behind `row.getValue` when the grouping + * feature is registered. + * + * Leaf rows fall through to the core accessor read. Grouped rows resolve + * grouping-column values from their partition and other columns from the + * aggregation cache, replacing the per-row `getValue` closure the grouped row + * model used to install (an own property that forked row hidden classes). + */ +export function row_getGroupingAwareValue< + TFeatures extends TableFeatures, + TData extends RowData, +>( + row: Row & Partial>, + columnId: string, +) { + if (row.groupingColumnId === undefined) { + return row_getValue(row, columnId) } + return row_getGroupedValue(row, columnId) +} - return row._groupingValuesCache?.[columnId] +/** + * Resolves `row.getValue` for a grouped row. + * + * The active grouping column and ancestor grouping columns expose their + * inherited grouping values (cached in `_valuesCache`). Other columns resolve + * through the aggregation cache, computing and caching the aggregation on + * first read when the aggregation feature is registered. + * + * Leaf rows must use `row_getValue` (or `row_getGroupingAwareValue`, which + * branches on `groupingColumnId`) instead. + */ +export function row_getGroupedValue< + TFeatures extends TableFeatures, + TData extends RowData, +>( + row: Row & Partial>, + columnId: string, +) { + const table = row.table + + // Mirror the grouped row model's `existingGrouping` filter (grouping ids + // whose columns exist) without allocating the filtered array: resolve this + // column's index among the existing grouped columns. + const grouping = table.atoms.grouping?.get() ?? [] + let groupingIndex = -1 + let existingIndex = 0 + for (let i = 0; i < grouping.length; i++) { + const groupedColumnId = grouping[i]! + if (!table_getColumn(table, groupedColumnId)) continue + if (groupedColumnId === columnId) { + groupingIndex = existingIndex + break + } + existingIndex++ + } + + // Columns grouped at deeper levels than this row are still eligible for + // aggregation below. + if (groupingIndex !== -1 && groupingIndex <= row.depth) { + if (hasOwn(row._valuesCache, columnId)) { + return row._valuesCache[columnId] + } + + const groupedRows = row._groupedRows + if (groupedRows?.[0]) { + row._valuesCache[columnId] = + groupedRows[0].getValue(columnId) ?? undefined + } + + return row._valuesCache[columnId] + } + + const aggregationCache = row._aggregationValuesCache + if (aggregationCache && hasOwn(aggregationCache, columnId)) { + return aggregationCache[columnId] + } + + const column = table.getColumn(columnId) + if (typeof (column as any)?.getAggregationFns !== 'function') { + return undefined + } + + const cache = (row._aggregationValuesCache ??= makeObjectMap()) + cache[columnId] = aggregateColumnValue({ + subRows: row.subRows, + column: column!, + groupingRow: row, + rows: row._groupedRows ?? row.leafRows ?? [], + uniqueRows: true, + }) + return cache[columnId] } /** @@ -245,7 +347,8 @@ export function cell_getIsGrouped< TData extends RowData, TValue extends CellData = CellData, >(cell: Cell) { - const row = cell.row as Row & Partial + const row = cell.row as Row & + Partial> return ( column_getIsGrouped(cell.column) && cell.column.id === row.groupingColumnId ) diff --git a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts index 718fcc8a26..7c20d7484b 100644 --- a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts +++ b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts @@ -3,10 +3,7 @@ import { constructRow } from '../../core/rows/constructRow' import { table_getColumn } from '../../core/columns/coreColumnsFeature.utils' import { table_autoResetExpanded } from '../row-expanding/rowExpandingFeature.utils' import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' -import { - aggregateColumnValue, - normalizeUniqueAggregationRows, -} from '../row-aggregation/rowAggregationFeature.utils' +import { normalizeUniqueAggregationRows } from '../row-aggregation/rowAggregationFeature.utils' import type { Row_ColumnGrouping } from './columnGroupingFeature.types' import type { Column_Internal } from '../../types/Column' import type { TableFeatures } from '../../types/TableFeatures' @@ -92,7 +89,7 @@ function _createGroupedRowModel< ) const groupedFlatRows: Array> & - Partial = [] + Partial> = [] const groupedRowsById = makeObjectMap>() // Recursively group the data @@ -158,53 +155,18 @@ function _createGroupedRowModel< depth, undefined, parentId, - ) as Row & Partial - - Object.assign(row, { - groupingColumnId: columnId, - groupingValue, - subRows, - leafRows, - getValue: (colId: string) => { - const groupingIndex = existingGrouping.indexOf(colId) - - // The active grouping column and ancestor grouping columns expose - // their inherited grouping values. Columns grouped at deeper - // levels are still eligible for aggregation here. - if (groupingIndex !== -1 && groupingIndex <= depth) { - if (hasOwn(row._valuesCache, colId)) { - return row._valuesCache[colId] - } - - if (groupedRows[0]) { - row._valuesCache[colId] = - groupedRows[0].getValue(colId) ?? undefined - } - - return row._valuesCache[colId] - } - - const aggregationCache = (row as any)._aggregationValuesCache as - Record | undefined - if (aggregationCache && hasOwn(aggregationCache, colId)) { - return aggregationCache[colId] - } - - const column = table.getColumn(colId) as any - if (typeof column.getAggregationFns !== 'function') return undefined - - const cache = ((row as any)._aggregationValuesCache ??= - makeObjectMap()) - cache[colId] = aggregateColumnValue({ - subRows, - column, - groupingRow: row, - rows: groupedRows, - uniqueRows: true, - }) - return cache[colId] - }, - }) + ) as Row & + Partial> + + // Value writes to properties declared by the grouping feature's + // initRowInstanceData, so group rows keep the shared row hidden + // class. Grouped/aggregated `getValue` reads resolve through the + // prototype override in columnGroupingFeature. + row.groupingColumnId = columnId + row.groupingValue = groupingValue + row.subRows = subRows + row.leafRows = leafRows + row._groupedRows = groupedRows subRows.forEach((subRow) => { groupedFlatRows.push(subRow) @@ -269,17 +231,13 @@ function groupBy( const row = rows[i]! let groupingValue if (getGroupingValue) { - const cache = (row as any)._groupingValuesCache as - Record | undefined - if (cache && hasOwn(cache, columnId)) { - groupingValue = cache[columnId] - } else if (cache) { - groupingValue = cache[columnId] = getGroupingValue( - row.original, - row.index, - row, - ) - } + // Allocated on first use; the slot is declared by initRowInstanceData + // so this is a value write. + const cache = ((row as any)._groupingValuesCache ??= + makeObjectMap()) as Record + groupingValue = hasOwn(cache, columnId) + ? cache[columnId] + : (cache[columnId] = getGroupingValue(row.original, row.index, row)) } else { groupingValue = row.getValue(columnId) } diff --git a/packages/table-core/src/features/column-pinning/columnPinningFeature.ts b/packages/table-core/src/features/column-pinning/columnPinningFeature.ts index e02665e562..003d521cae 100644 --- a/packages/table-core/src/features/column-pinning/columnPinningFeature.ts +++ b/packages/table-core/src/features/column-pinning/columnPinningFeature.ts @@ -111,6 +111,12 @@ export const columnPinningFeature: TableFeature = { }) }, + initCellInstanceData: (cell) => { + // Declared up front so marking a cell with its pinned region never + // changes its hidden class. + ;(cell as any).position = undefined + }, + constructTableAPIs: (table) => { assignTableAPIs('columnPinningFeature', table, { table_setColumnPinning: { diff --git a/packages/table-core/src/features/column-visibility/columnVisibilityFeature.ts b/packages/table-core/src/features/column-visibility/columnVisibilityFeature.ts index 31b6d81b7e..930e726019 100644 --- a/packages/table-core/src/features/column-visibility/columnVisibilityFeature.ts +++ b/packages/table-core/src/features/column-visibility/columnVisibilityFeature.ts @@ -70,6 +70,9 @@ export const columnVisibilityFeature: TableFeature = { table.atoms.columnPinning?.get(), table.atoms.columnVisibility?.get(), ], + // Called for every rendered row; dedicated slots (declared in + // initRowInstanceData) keep these memo loads monomorphic. + memoSlot: '_memoGetVisibleCells', }, row_getVisibleCellsByColumnId: { fn: (row) => row_getVisibleCellsByColumnId(row), @@ -77,10 +80,19 @@ export const columnVisibilityFeature: TableFeature = { row.getAllCells(), table.atoms.columnVisibility?.get(), ], + memoSlot: '_memoGetVisibleCellsByColumnId', }, }) }, + initRowInstanceData: (row) => { + // Declared up front so first memoized calls never change the row's + // hidden class. + const visibilityRow = row as any + visibilityRow._memoGetVisibleCells = undefined + visibilityRow._memoGetVisibleCellsByColumnId = undefined + }, + constructTableAPIs: (table) => { assignTableAPIs('columnVisibilityFeature', table, { table_getVisibleFlatColumns: { diff --git a/packages/table-core/src/features/column-visibility/columnVisibilityFeature.types.ts b/packages/table-core/src/features/column-visibility/columnVisibilityFeature.types.ts index 0580be7b26..4bc11571ae 100644 --- a/packages/table-core/src/features/column-visibility/columnVisibilityFeature.types.ts +++ b/packages/table-core/src/features/column-visibility/columnVisibilityFeature.types.ts @@ -79,6 +79,18 @@ export interface Row_ColumnVisibility< in out TFeatures extends TableFeatures, in out TData extends RowData, > { + /** + * Dedicated memo slot for the render-hot `getVisibleCells` API. Declared in + * `initRowInstanceData` so creating the memo never changes the row's hidden + * class. + * @internal + */ + _memoGetVisibleCells?: (...args: Array) => any + /** + * Dedicated memo slot for the `getVisibleCellsByColumnId` API. + * @internal + */ + _memoGetVisibleCellsByColumnId?: (...args: Array) => any /** * Gets this row's cells for currently visible columns. */ diff --git a/packages/table-core/src/features/row-pinning/rowPinningFeature.ts b/packages/table-core/src/features/row-pinning/rowPinningFeature.ts index cbd075e2b4..ba5bff8488 100644 --- a/packages/table-core/src/features/row-pinning/rowPinningFeature.ts +++ b/packages/table-core/src/features/row-pinning/rowPinningFeature.ts @@ -60,6 +60,12 @@ export const rowPinningFeature: TableFeature = { }) }, + initRowInstanceData: (row) => { + // Declared up front so marking a row with its pinned region never + // changes its hidden class. + ;(row as any).position = undefined + }, + constructTableAPIs: (table) => { assignTableAPIs('rowPinningFeature', table, { table_setRowPinning: { diff --git a/packages/table-core/src/features/row-pinning/rowPinningFeature.types.ts b/packages/table-core/src/features/row-pinning/rowPinningFeature.types.ts index 08d21b5b2d..4d30ef1e0d 100644 --- a/packages/table-core/src/features/row-pinning/rowPinningFeature.types.ts +++ b/packages/table-core/src/features/row-pinning/rowPinningFeature.types.ts @@ -60,6 +60,11 @@ export interface Row_RowPinning { includeLeafRows?: boolean, includeParentRows?: boolean, ) => void + /** + * If this row was returned by `getTopRows`/`getBottomRows`, the region it + * was collected for. Managed by those APIs; `undefined` otherwise. + */ + position?: 'top' | 'bottom' } export interface Table_RowPinning< diff --git a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts index dee54c1932..910dbad7e0 100644 --- a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts +++ b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts @@ -3,6 +3,7 @@ import { skipFirstRun, tableMemo, } from '../../utils' +import { constructRow } from '../../core/rows/constructRow' import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils' import { column_getCanSort, column_getSortFn } from './rowSortingFeature.utils' import type { Column_Internal } from '../../types/Column' @@ -172,8 +173,19 @@ function _createSortedRowModel< const sortedSubRows = sortData(row.subRows) if (sortedSubRows.changed) { - // Preserve prototype chain so methods like getValue() remain accessible - const cloned = Object.create(Object.getPrototypeOf(row)) + // Rebuild through constructRow so the clone declares its own + // properties in the canonical order (same hidden class as every + // other row), then copy instance values from the source row. Value + // caches are shared with the source by reference, as before. + const cloned = constructRow( + table, + row.id, + row.original, + row.index, + row.depth, + undefined, + row.parentId, + ) copyInstancePropertiesWithoutMemos(cloned, row) cloned.subRows = sortedSubRows.rows sortedData[i] = cloned diff --git a/packages/table-core/src/types/Row.ts b/packages/table-core/src/types/Row.ts index 5834a6d46e..03031b2d50 100644 --- a/packages/table-core/src/types/Row.ts +++ b/packages/table-core/src/types/Row.ts @@ -21,7 +21,7 @@ export interface Row_FeatureMap< > { rowAggregationFeature: Row_RowAggregation columnFilteringFeature: Row_ColumnFiltering - columnGroupingFeature: Row_ColumnGrouping + columnGroupingFeature: Row_ColumnGrouping columnPinningFeature: Row_ColumnPinning columnVisibilityFeature: Row_ColumnVisibility rowExpandingFeature: Row_RowExpanding diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts index 47c49c5533..d42ccacaba 100755 --- a/packages/table-core/src/utils.ts +++ b/packages/table-core/src/utils.ts @@ -51,8 +51,8 @@ export function cloneState(value: T): T { } /** - * Copies prototype-instance own properties without carrying over lazy memo - * closures or the per-row cell cache, both of which are bound to the source + * Copies prototype-instance own properties without carrying over the memo + * holder or the per-row cell cache, both of which are bound to the source * instance (cached cells reference the source row). */ export function copyInstancePropertiesWithoutMemos< @@ -64,7 +64,8 @@ export function copyInstancePropertiesWithoutMemos< for (let i = 0; i < keys.length; i++) { const key = keys[i]! - if (!key.startsWith('_memo_') && key !== '_cellsCache') { + // `_memo` covers the `_memos` holder and dedicated `_memo` slots. + if (!key.startsWith('_memo') && key !== '_cellsCache') { targetRecord[key] = source[key] } } @@ -82,6 +83,38 @@ export function makeObjectMap(): Record { return Object.create(null) as Record } +/** + * Rewrites every own property of a throwaway instance with a different value + * of a compatible kind. + * + * Called once per table on a discarded instance right after its shared shape + * is created, this pre-marks each declared field as mutable in the engine. + * Without it, fields that hold the same value on every instance (the + * `undefined`-declared slots) are assumed constant by V8, and the first real + * write (a grouped row's `groupingColumnId`, a memo slot, a pinned + * `position`) deoptimizes every function that embedded that assumption; one + * wave per field per table. + */ +export function warmInstanceShape(instance: Record): void { + const keys = Object.keys(instance) + for (let i = 0; i < keys.length; i++) { + const key = keys[i]! + // Custom features may declare read-only or accessor instance data; those + // fields cannot be rewritten (and accessor reads could have side effects). + const descriptor = Object.getOwnPropertyDescriptor(instance, key) + if (!descriptor?.writable) continue + const value = instance[key] + instance[key] = + typeof value === 'number' + ? value + 1 + : typeof value === 'string' + ? `${value}~` + : value === undefined + ? null + : makeObjectMap() + } +} + /** * Checks whether an object owns a key, including null-prototype dictionaries. */ @@ -569,6 +602,15 @@ export function assignTableAPIs< export interface PrototypeAPI<_TDeps extends ReadonlyArray, _TDepArgs> { fn: (self: any, ...args: any) => any memoDeps?: (self: any, depArgs?: any) => [...any] | undefined + /** + * Own-property slot name for this API's memo state, for render-hot APIs + * where the `_memos` holder's per-call dictionary lookup is measurable. + * The declaring feature must pre-declare the slot as `undefined` at + * construction (in the constructor's fixed property list for core features, + * or in `init*InstanceData` for plugins) so first calls stay shape-neutral. + * Slot names must start with `_memo` so instance-copy helpers skip them. + */ + memoSlot?: `_memo${string}` } export type PrototypeAPIObject< @@ -580,8 +622,9 @@ export type PrototypeAPIObject< * Assigns API methods to a prototype object for memory-efficient method sharing. * All instances created with this prototype will share the same method references. * - * For memoized methods, the memo state is lazily created and stored on each instance. - * This provides the best of both worlds: shared method code + per-instance caching. + * For memoized methods, the memo state is lazily created and stored in the + * instance's pre-declared `_memos` holder. This provides shared method code + + * per-instance caching without hidden-class transitions after construction. */ export function assignPrototypeAPIs< TFeatures extends TableFeatures, @@ -594,28 +637,40 @@ export function assignPrototypeAPIs< table: Table_Internal, apis: PrototypeAPIObject>, ): void { - for (const [staticFnName, { fn, memoDeps }] of Object.entries(apis)) { + for (const [staticFnName, { fn, memoDeps, memoSlot }] of Object.entries( + apis, + )) { const { fnKey, fnName } = getFunctionNameInfo(staticFnName) if (memoDeps) { - // For memoized methods, create a function that lazily initializes - // the memo on first access and stores it on the instance - const memoKey = `_memo_${fnKey}` + const makeMemo = (self: any) => + tableMemo({ + memoDeps: (depArgs) => memoDeps(self, depArgs), + fn: (...deps) => fn(self, ...deps), + fnName, + objectId: self.id, + table, + feature, + }) - prototype[fnKey] = function (this: any, ...args: Array) { - // Lazily create memo on first access for this instance - if (!this[memoKey]) { - const self = this - this[memoKey] = tableMemo({ - memoDeps: (depArgs) => memoDeps(self, depArgs), - fn: (...deps) => fn(self, ...deps), - fnName, - objectId: self.id, - table, - feature, - }) + // Memoized methods keep their memo state in pre-declared instance + // storage, so first calls never add own properties (adding one would + // fork the instance's hidden class). Memo closures themselves are still + // created lazily; untouched instances only pay for the declared slots. + if (memoSlot) { + // Render-hot APIs read the memo from a dedicated own slot, keeping + // the per-call load monomorphic. + prototype[fnKey] = function (this: any, ...args: Array) { + const memoizedFn = this[memoSlot] ?? (this[memoSlot] = makeMemo(this)) + return memoizedFn(...args) + } + } else { + prototype[fnKey] = function (this: any, ...args: Array) { + const memos = (this._memos ??= + makeObjectMap<(...fnArgs: Array) => any>()) + const memoizedFn = memos[fnKey] ?? (memos[fnKey] = makeMemo(this)) + return memoizedFn(...args) } - return this[memoKey](...args) } } else { // Non-memoized methods just call the static function with `this` diff --git a/packages/table-core/src/worker/rebuildRowModel.ts b/packages/table-core/src/worker/rebuildRowModel.ts index 630d476669..5861b9c4c2 100644 --- a/packages/table-core/src/worker/rebuildRowModel.ts +++ b/packages/table-core/src/worker/rebuildRowModel.ts @@ -1,5 +1,5 @@ import { constructRow } from '../core/rows/constructRow' -import { hasOwn } from '../utils' +import { makeObjectMap } from '../utils' import type { RowModel } from '../core/row-models/coreRowModelsFeature.types' import type { Table_Internal } from '../types/Table' import type { TableFeatures } from '../types/TableFeatures' @@ -106,14 +106,25 @@ export function rebuildRowModel< parentId, ) const aggregates = node.aggregates - Object.assign(row, { - groupingColumnId: node.groupingColumnId, - groupingValue: node.groupingValue, - subRows, - leafRows, - getValue: (columnId: string) => - hasOwn(aggregates, columnId) ? aggregates[columnId] : undefined, - }) + // Value writes to properties declared by the grouping feature's + // initRowInstanceData; synthetic group rows keep the shared row shape. + row.groupingColumnId = node.groupingColumnId + row.groupingValue = node.groupingValue + row.subRows = subRows + row.leafRows = leafRows + row._groupedRows = leafRows + // Pre-seed both value caches with the worker-computed aggregates so the + // grouping-aware prototype `getValue` serves them without recomputing: + // grouping-column reads check _valuesCache, aggregated reads check + // _aggregationValuesCache. + const aggregationValuesCache = makeObjectMap() + const aggregateKeys = Object.keys(aggregates) + for (let k = 0; k < aggregateKeys.length; k++) { + const key = aggregateKeys[k]! + row._valuesCache[key] = aggregates[key] + aggregationValuesCache[key] = aggregates[key] + } + row._aggregationValuesCache = aggregationValuesCache if (flattenParentsFirst) { flatRows[flatIndex] = row diff --git a/packages/table-core/tests/unit/core/cells/constructCell.test.ts b/packages/table-core/tests/unit/core/cells/constructCell.test.ts index a83afc38a7..8baccbd9f7 100644 --- a/packages/table-core/tests/unit/core/cells/constructCell.test.ts +++ b/packages/table-core/tests/unit/core/cells/constructCell.test.ts @@ -75,11 +75,13 @@ describe('constructCell', () => { `${cell.row.id}_${cell.column.id}`, ) } - expect(initCount).toBe(2) + // 2 real cells + the discarded shape-warmup cell constructed alongside + // the shared cell prototype. + expect(initCount).toBe(3) // Cells are cached per row/column pair, so re-access constructs no new cells const secondCells = rows.flatMap((row) => row.getAllCells()) expect(secondCells[0]).toBe(firstCells[0]) - expect(initCount).toBe(2) + expect(initCount).toBe(3) }) }) diff --git a/packages/table-core/tests/unit/shapeStability.test.ts b/packages/table-core/tests/unit/shapeStability.test.ts new file mode 100644 index 0000000000..648efa4e30 --- /dev/null +++ b/packages/table-core/tests/unit/shapeStability.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest' +import { + aggregationFns, + columnFilteringFeature, + columnGroupingFeature, + columnPinningFeature, + constructTable, + createFilteredRowModel, + createGroupedRowModel, + createSortedRowModel, + rowAggregationFeature, + rowPinningFeature, + rowSortingFeature, +} from '../../src' +import { testFeatures } from '../fixtures/features' +import type { ColumnDef, Row, TableOptions } from '../../src' + +// Every instance kind (row, cell, column, header) must keep the same own +// properties, in the same insertion order, for its entire lifetime. Own-key +// order equality is the observable proxy for hidden-class (shape) equality: +// two objects created from the same prototype whose own keys were inserted in +// the same order share a hidden class in V8. These tests guard against +// post-construction property additions (Object.assign on group rows, lazy +// `_memo_*`/cache installs, pinned `position` marks) that previously forked +// instance shapes and made property access megamorphic. + +function ownKeys(obj: object): string { + return Object.keys(obj).join() +} + +function expectSameOwnKeys(objects: Array) { + expect(objects.length).toBeGreaterThan(1) + const expected = ownKeys(objects[0]!) + for (let i = 1; i < objects.length; i++) { + expect(ownKeys(objects[i]!)).toBe(expected) + } +} + +describe('row shape stability with grouping, aggregation, and sorting', () => { + type Sale = { + status: string + label: string + amount: number + } + + const features = testFeatures({ + rowAggregationFeature, + columnGroupingFeature, + rowSortingFeature, + groupedRowModel: createGroupedRowModel(), + sortedRowModel: createSortedRowModel(), + aggregationFns, + }) + + const data: Array = [ + { status: 'a', label: 'one', amount: 10 }, + { status: 'a', label: 'two', amount: 20 }, + { status: 'b', label: 'one', amount: 30 }, + { status: 'b', label: 'two', amount: 40 }, + ] + + const columns: Array> = [ + { accessorKey: 'status', id: 'status' }, + { + accessorKey: 'label', + id: 'label', + sortFn: (rowA, rowB, columnId) => + String(rowA.getValue(columnId) ?? '').localeCompare( + String(rowB.getValue(columnId) ?? ''), + ), + }, + { accessorKey: 'amount', id: 'amount' }, + { id: 'actions', header: 'Actions' }, + ] + + function makeTable( + options?: Partial< + Omit, 'data' | 'columns' | 'features'> + >, + ) { + return constructTable({ features, data, columns, ...options }) + } + + it('group rows, leaf rows, and sorted clones share one own-key order', () => { + const table = makeTable({ + initialState: { + grouping: ['status'], + sorting: [{ id: 'label', desc: true }], + }, + }) + + const flatRows = table.getRowModel().flatRows + const groupRows = flatRows.filter((row) => row.getIsGrouped()) + const leafRows = flatRows.filter((row) => !row.getIsGrouped()) + + // Sorting desc flips each group's sub-row order, so group parents are + // clones rebuilt by the sorted row model. + expect(groupRows.length).toBeGreaterThan(0) + expect(leafRows.length).toBeGreaterThan(0) + + expectSameOwnKeys(flatRows) + }) + + it('group rows resolve getValue through the prototype, not an own closure', () => { + const table = makeTable({ initialState: { grouping: ['status'] } }) + + const groupRow = table + .getRowModel() + .flatRows.find((row) => row.getIsGrouped())! + + expect(Object.getOwnPropertyNames(groupRow)).not.toContain('getValue') + // The prototype override resolves for every row in the table. + expect(groupRow.getValue).toBe( + table.getRowModel().flatRows.find((row) => !row.getIsGrouped())!.getValue, + ) + }) + + it('grouped getValue semantics: grouping values, aggregates, and caching', () => { + const table = makeTable({ initialState: { grouping: ['status'] } }) + + const rows = table.getRowModel().rows + const groupA = rows.find((row) => row.groupingValue === 'a')! + const groupB = rows.find((row) => row.groupingValue === 'b')! + + // The active grouping column exposes the shared grouping value. + expect(groupA.getValue('status')).toBe('a') + expect(groupB.getValue('status')).toBe('b') + // Number columns auto-aggregate with sum. + expect(groupA.getValue('amount')).toBe(30) + expect(groupB.getValue('amount')).toBe(70) + // Cached reads return the same value. + expect(groupA.getValue('amount')).toBe(30) + // Display columns with no accessor and no aggregation stay undefined. + expect(groupA.getValue('actions')).toBeUndefined() + }) + + it('calling every row API never changes a row shape', () => { + const table = makeTable({ + initialState: { + grouping: ['status'], + sorting: [{ id: 'label', desc: true }], + }, + }) + + const flatRows = table.getRowModel().flatRows + const before = flatRows.map((row) => ownKeys(row)) + + for (const row of flatRows) { + row.getValue('amount') + row.renderValue('label') + row.getUniqueValues('status') + row.getIsGrouped() + row.getGroupingValue('status') + row.getLeafRows() + row.getParentRow() + row.getParentRows() + row.getAllCells() + row.getAllCellsByColumnId() + } + + flatRows.forEach((row, index) => { + expect(ownKeys(row)).toBe(before[index]) + }) + expectSameOwnKeys(flatRows) + }) + + it('memoized row APIs still cache through the _memos holder', () => { + const table = makeTable({ initialState: { grouping: ['status'] } }) + + const row = table.getRowModel().rows[0]! + const first = row.getAllCells() + + expect(row.getAllCells()).toBe(first) + expect(ownKeys(row)).toContain('_memos') + }) +}) + +describe('row and cell shape stability with pinning, filtering, and sub-rows', () => { + type TreeNode = { + id: string + name: string + size: number + children?: Array + } + + const features = testFeatures({ + columnFilteringFeature, + columnPinningFeature, + rowPinningFeature, + filteredRowModel: createFilteredRowModel(), + }) + + const data: Array = [ + { + id: 'r1', + name: 'alpha', + size: 1, + children: [ + { id: 'r1a', name: 'alpha-child', size: 2 }, + { id: 'r1b', name: 'beta-child', size: 3 }, + ], + }, + { id: 'r2', name: 'beta', size: 4 }, + { id: 'r3', name: 'gamma', size: 5 }, + ] + + const columns: Array> = [ + { + accessorKey: 'name', + id: 'name', + filterFn: (row, columnId, filterValue: string) => + String(row.getValue(columnId)).includes(filterValue), + }, + { accessorKey: 'size', id: 'size' }, + ] + + function makeTable( + options?: Partial< + Omit< + TableOptions, + 'data' | 'columns' | 'features' + > + >, + ) { + return constructTable({ + features, + data, + columns, + getRowId: (node) => node.id, + getSubRows: (node) => node.children, + ...options, + }) + } + + it('filter clones and rows with originalSubRows share one own-key order', () => { + const table = makeTable({ + initialState: { columnFilters: [{ id: 'name', value: 'a' }] }, + }) + + const flatRows = table.getRowModel().flatRows + // The root-down filter keeps the passing parent and clones it because it + // has sub-rows; leaf rows are reused as-is. + expect(flatRows.length).toBeGreaterThan(1) + + expectSameOwnKeys(flatRows) + expectSameOwnKeys([...flatRows, ...table.getCoreRowModel().flatRows]) + }) + + it('pinning a row marks position without changing its shape', () => { + const table = makeTable({ + initialState: { rowPinning: { top: ['r2'], bottom: [] } }, + }) + + const flatRows = table.getRowModel().flatRows + const before = flatRows.map((row) => ownKeys(row)) + + const topRows = table.getTopRows() + expect(topRows.map((row) => row.id)).toEqual(['r2']) + expect(topRows[0]!.position).toBe('top') + + flatRows.forEach((row, index) => { + expect(ownKeys(row)).toBe(before[index]) + }) + expectSameOwnKeys(flatRows) + }) + + it('pinned and center cells share one own-key order across cell APIs', () => { + const table = makeTable({ + initialState: { columnPinning: { start: ['name'], end: [] } }, + }) + + const row = table.getRowModel().rows[0]! + const startCells = row.getStartVisibleCells() + const centerCells = row.getCenterVisibleCells() + + expect(startCells.length).toBeGreaterThan(0) + expect(centerCells.length).toBeGreaterThan(0) + expect((startCells[0] as any).position).toBe('start') + expect((centerCells[0] as any).position).toBeUndefined() + + const allCells = [...startCells, ...centerCells] + const before = allCells.map((cell) => ownKeys(cell)) + + for (const cell of allCells) { + cell.getValue() + cell.renderValue() + cell.getContext() + } + + allCells.forEach((cell, index) => { + expect(ownKeys(cell)).toBe(before[index]) + }) + expectSameOwnKeys(allCells) + }) + + it('column and header APIs never change column or header shapes', () => { + const table = makeTable({ + initialState: { columnPinning: { start: ['name'], end: [] } }, + }) + + const allColumns = table.getAllLeafColumns() + const columnKeysBefore = allColumns.map((column) => ownKeys(column)) + + for (const column of allColumns) { + column.getIsPinned() + column.getFlatColumns() + column.getLeafColumns() + } + + allColumns.forEach((column, index) => { + expect(ownKeys(column)).toBe(columnKeysBefore[index]) + }) + expectSameOwnKeys(allColumns) + + const headers = table.getFlatHeaders() + const headerKeysBefore = headers.map((header) => ownKeys(header)) + + for (const header of headers) { + header.getContext() + header.getLeafHeaders() + } + + headers.forEach((header, index) => { + expect(ownKeys(header)).toBe(headerKeysBefore[index]) + }) + expectSameOwnKeys(headers) + }) +}) + +describe('shape stability across interaction histories', () => { + type Item = { id: string; name: string } + + const features = testFeatures({}) + + const data: Array = [ + { id: '1', name: 'one' }, + { id: '2', name: 'two' }, + { id: '3', name: 'three' }, + ] + + const columns: Array> = [ + { accessorKey: 'name', id: 'name' }, + ] + + it('rows keep one shape regardless of which APIs ran first', () => { + const table = constructTable({ features, data, columns }) + const rows = table.getRowModel().rows as Array> + + // Different call orders per row previously forked hidden-class + // transition chains; with the `_memos` holder the own keys stay fixed. + rows[0]!.getAllCells() + rows[0]!.getValue('name') + rows[1]!.getValue('name') + rows[1]!.getLeafRows() + rows[1]!.getAllCells() + // rows[2] never touched. + + expectSameOwnKeys(rows) + }) +}) diff --git a/packages/table-core/tests/unit/utils.test.ts b/packages/table-core/tests/unit/utils.test.ts index b839d48c7c..3ef5eafe9c 100644 --- a/packages/table-core/tests/unit/utils.test.ts +++ b/packages/table-core/tests/unit/utils.test.ts @@ -129,11 +129,11 @@ describe('cloneState', () => { }) describe('copyInstancePropertiesWithoutMemos', () => { - test('copies own properties but skips memo closures and the cells cache', () => { + test('copies own properties but skips the memo holder and the cells cache', () => { const source = { id: '0', depth: 1, - _memo_getAllCells: () => {}, + _memos: { getAllCells: () => {} }, _cellsCache: new WeakMap(), } const target: Record = {} @@ -142,7 +142,7 @@ describe('copyInstancePropertiesWithoutMemos', () => { expect(target['id']).toBe('0') expect(target['depth']).toBe(1) - expect(target['_memo_getAllCells']).toBeUndefined() + expect(target['_memos']).toBeUndefined() expect(target['_cellsCache']).toBeUndefined() }) })