Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Latest commit

Β 

History

History
2070 lines (1434 loc) Β· 88.3 KB

File metadata and controls

2070 lines (1434 loc) Β· 88.3 KB

@tanstack/table-core β€” Performance Refactor Catalog

A code-level audit of packages/table-core/src/**. Each entry describes a concrete, low-risk refactor that preserves the public API surface and the shape of table.getState(). New get* APIs are allowed, but bundle-size discipline is in scope.

Legend

  • Categories: big-o Β· memoization Β· micro Β· bundle-size Β· bug

  • Score (1–10): importance after considering hot-path frequency, magnitude of win, and risk.

    • 10 = library-wide hot path, big-O improvement
    • 7–9 = clear win in a frequently-called path (memoization gap, O(nΒ²)β†’O(n))
    • 4–6 = noticeable but bounded (micro in a frequent path, or memo in cold path)
    • 1–3 = micro-opt with negligible runtime, mostly cleanup
  • Scale impact tables: counts of operations / allocations / comparisons saved, not wall time. Numbers are illustrative β€” they assume realistic ratios (e.g. pinned β‰ˆ 10% of leaf cols, average .find walks Β½ the array) and are meant to communicate order of magnitude across table sizes. Bugs and fixed-cost refactors (one-time initialization, correctness fixes) don't get tables.

  • Status convention: every finding has a **Status:** line and an **Implementation note:** line near the top.

    • [ ] not started β€” untouched
    • [~] partial β€” applied, but with deviation or scope reduction (note required)
    • [x] done β€” refactored as suggested
    • [-] skipped β€” deliberately not pursued (note required)

    Fill in the implementation note when status changes from [ ], with: PR/commit ref if relevant, any deviation from the proposed code, before/after benchmark numbers if measured, and follow-ups.

Cross-cutting sweep: loop fusion (.map().flat(), .map().filter(), .map().map().filter())

Status: [x] done

Eliminated back-to-back Array method chains across packages/table-core/src/** by fusing multiple passes into a single loop. Each chain was producing one intermediate array per stage; fused versions allocate exactly the final result array.

Patterns covered:

  1. .map(hg => hg.headers).flat() β€” 5 sites, all flattening header-groups into a flat header list. Replaced each with a nested indexed for loop pushing into a single result array.

    • core/headers/coreHeadersFeature.utils.ts β€” table_getFlatHeaders
    • core/headers/coreHeadersFeature.utils.ts β€” table_getLeafHeaders (variant: maps to header.getLeafHeaders() arrays, same fusion shape)
    • features/column-pinning/columnPinningFeature.utils.ts β€” table_getLeftFlatHeaders, table_getRightFlatHeaders, table_getCenterFlatHeaders
  2. .map().map().filter() triple chain β€” createFacetedMinMaxValues.ts. Fused into the min/max scan loop (which previously ran after the three array stages). Single pass over flatRows with Number() coercion + NaN skip + inline min/max tracking. Replaces 3 intermediate array allocations of size N and the subsequent min/max walk over the resulting array.

  3. .map(...).filter(predicate).forEach(mutate) three-pass chain β€” rowPinningFeature.utils.ts (getPinnedRows). Resolves pinned-row ids β†’ row instances β†’ drops misses β†’ tags position, all in one loop. Eliminates 2 intermediate arrays.

  4. .map().filter() chain producing-then-cleaning undefineds β€” rowSelectionFeature.utils.ts (selectRowsFn recurseRows). The .map returns undefined for unselected rows; the .filter(x => !!x) then removes them. Replaced with single push-into-result loop that skips unselected rows. Saves one intermediate array per recursion level.

  5. Smaller .map().filter() chains:

    • createFacetedRowModel.ts β€” columnFilters?.map(d => d.id).filter(d => d !== columnId) + outer .filter(Boolean) β†’ single loop pushing matching ids.
    • columnPinningFeature.utils.ts column_pin β€” column.getLeafColumns().map(d => d.id).filter(Boolean) β†’ single loop.

Why it matters at scale. In modern V8, .map/.filter per-iteration overhead is competitive with hand-written loops (~5–15% per element). The win is eliminating the intermediate arrays themselves. Each chain stage allocates an array of size N where N is rows/cells/headers. For a 1M-row faceting pass the prior triple-chain in createFacetedMinMaxValues allocated ~3 Γ— 8MB of intermediate buffers per faceted column rebuild; the fused version allocates none of those. Across all 5 patterns and all derivation passes (filter, sort, group, facet, pin), this saves tens of MB of allocations and meaningful GC time on cold builds at 1M-row scale.

Subsumes existing findings:

  • #21 (createFacetedMinMaxValues chain) β€” done as part of pattern 2 above.

Type-check verified clean after the fusion sweep.

Cross-cutting sweep: for...of β†’ indexed for

Status: [x] done

A codebase-wide conversion of for (const x of arr) to for (let i = 0; i < arr.length; i++) { const x = arr[i]! } for all Array iterations in packages/table-core/src/**. Roughly 50 loops touched across ~20 files. Rationale: at TanStack Table's scale targets (millions of rows, thousands of columns) the cumulative micro-cost of iterator-protocol overhead is meaningful β€” especially on cold-JIT first renders, row-model derivation passes that walk full datasets, and .find / pinning loops that run per visible row.

Companion change: flipped the @typescript-eslint/prefer-for-of rule from 'warn' to 'off' at the repo root (eslint.config.js) with a comment explaining the rationale. New code should default to indexed for for Array iteration. for...of is still appropriate for Map, Set, and generators where indexed access isn't available.

This sweep subsumes the loop-style portions of several individual findings:

  • #11 (table_getAllFlatColumnsById / getAllLeafColumnsById for...of)
  • #17 (row_getAllCells .map + row_getAllCellsByColumnId for...of) β€” also converted .map to a preallocated new Array(length) + indexed assignment for row_getAllCells.
  • #23 (faceted min/max β€” opportunistically swapped if/if for if/else if for the redundant max check)

Typecheck verified clean after the sweep (pnpm tsc --noEmit passes).

Bug fix included: isNumberArray had been previously auto-converted by the lint rule into for (const i of d) { d[i] } β€” which treats the iteration value as an index and returns false for any non-empty number array. The sweep restores the correct indexed form and the function works again as intended.

Files changed:

  • utils.ts (2 loops)
  • core/cells/constructCell.ts (1)
  • core/columns/constructColumn.ts (2)
  • core/columns/coreColumnsFeature.utils.ts (2)
  • core/headers/buildHeaderGroups.ts (3)
  • core/headers/constructHeader.ts (1)
  • core/headers/coreHeadersFeature.utils.ts (2)
  • core/rows/constructRow.ts (2)
  • core/rows/coreRowsFeature.utils.ts (1, plus .map β†’ preallocated array)
  • core/table/constructTable.ts (3)
  • core/table/coreTablesFeature.utils.ts (1)
  • features/column-faceting/createFacetedMinMaxValues.ts (1)
  • features/column-faceting/createFacetedRowModel.ts (1)
  • features/column-faceting/createFacetedUniqueValues.ts (2)
  • features/column-filtering/createFilteredRowModel.ts (5)
  • features/column-ordering/columnOrderingFeature.utils.ts (5)
  • features/column-pinning/columnPinningFeature.utils.ts (6)
  • features/column-visibility/columnVisibilityFeature.utils.ts (6)
  • features/row-sorting/createSortedRowModel.ts (1)
  • features/row-sorting/rowSortingFeature.utils.ts (1)

Progress

  • Total findings: 60
  • Done [x]: 15
  • Partial [~]: 2
  • Skipped [-]: 1
  • Not started [ ]: 42

(Update these counters as you go.)


Cross-cutting (utils.ts, helpers, reactivity, flex-render)

These are touched by every feature β€” wins compound.

1. memo() deps equality uses .some() callback per call β€” Score: 7

Status: [x] done Implementation note: Replaced the .some() callback with an indexed for loop + break in src/utils.ts (memoizedFn body). Drops one closure allocation per memo invocation. Implemented exactly as proposed.

Location: src/utils.ts:136–156 Category: micro

memo() is the foundation of every memoized accessor on the table, column, row, cell, and header (called many thousands of times per render in a large table). The .some(callback) allocates a closure each call and prevents engine inlining of the cheap reference-equality check.

Before

const newDeps = memoDeps?.(depArgs)
const depsChanged =
  !newDeps ||
  newDeps.length !== deps?.length ||
  newDeps.some((dep: any, index: number) => deps?.[index] !== dep)

After

const newDeps = memoDeps?.(depArgs)
let depsChanged = !newDeps || newDeps.length !== deps?.length
if (!depsChanged && newDeps) {
  for (let i = 0; i < newDeps.length; i++) {
    if (newDeps[i] !== deps![i]) {
      depsChanged = true
      break
    }
  }
}

Big-O: No asymptotic change. Constant-factor β€” one of the most-executed code paths in the library, so worth the few extra lines.

Scale impact (closure allocations saved per render β€” dimension: number of memoized-accessor calls per render across the whole table):

Calls / render Closures before After Saved / render
1,000 1,000 0 1,000
10,000 10,000 0 10,000
100,000 100,000 0 100,000
1,000,000 1,000,000 0 1,000,000

Risk: None. Identical semantics.


2. assignPrototypeAPIs allocates wrapper closures on every call β€” Score: 6

Status: [-] skipped Implementation note: Re-examination of utils.ts:407–421 showed the original audit misread the code. The two arrow-function wrappers (memoDeps: and fn:) live inside the if (!this[memoKey]) block, so they're allocated once per instance per method, not per call. Subsequent calls just delegate via return this[memoKey](...args). Removing the const self = this alias in favor of capturing this lexically saves nothing measurable (it's a stack alias, not a heap allocation) and may even cost slightly more due to lexical-this lookup. No win to capture here.

Location: src/utils.ts:402–416 Category: micro, memoization

Each call to a memoized prototype method (column.getIsVisible(), row.getVisibleCells(), header.getSize(), …) re-creates two arrow functions (memoDeps/fn wrappers) every call after the lazy init. Pull them out so they're allocated once per prototype, not once per call.

Before

prototype[fnKey] = function (this: any, ...args: Array<any>) {
  if (!this[memoKey]) {
    const self = this
    this[memoKey] = tableMemo({
      memoDeps: (depArgs) => memoDeps(self, depArgs),
      fn: (...deps) => fn(self, ...deps),
      ...
    })
  }
  return this[memoKey](...args)
}

After

prototype[fnKey] = function (this: any, ...args: Array<any>) {
  if (!this[memoKey]) {
    this[memoKey] = tableMemo({
      memoDeps: (depArgs) => memoDeps(this, depArgs),
      fn: (...deps) => fn(this, ...deps),
      ...
    })
  }
  return this[memoKey](...args)
}

(The two closures still capture memoDeps/fn; the win is dropping the self alias and ensuring the closures live inside the one-time init path.)

Big-O: Saves 1 allocation per memoized call after init.

Scale impact (self alias allocations saved β€” dimension: memoized accessor invocations after init):

Invocations Before After Saved
1,000 1,000 0 1,000
10,000 10,000 0 10,000
100,000 100,000 0 100,000
1,000,000 1,000,000 0 1,000,000

Risk: Low. this inside a regular function is identical to self.


3. memo() debug timing locals always allocated β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/utils.ts:200–207 Category: micro, bundle-size

beforeCompareTime, afterCompareTime, startCalcTime, endCalcTime are allocated even in prod. Move them inside the if (process.env.NODE_ENV === 'development') branch. Bundlers eliminate the dev branch entirely in prod.

Risk: None.


4. flattenBy() is recursive forEach β€” Score: 4

Status: [ ] not started Implementation note: (none)

Location: src/utils.ts:88–107 Category: micro

Used in row_getLeafRows and every column tree flatten. Replace .forEach(callback) with an indexed loop to avoid the per-item callback allocation and to allow JIT inlining.

Before

const recurse = (subArr: Array<TNode>) => {
  subArr.forEach((item) => {
    flat.push(item)
    const children = getChildren(item)
    if (children.length) recurse(children)
  })
}

After

const recurse = (subArr: Array<TNode>) => {
  for (let i = 0; i < subArr.length; i++) {
    const item = subArr[i]
    flat.push(item)
    const children = getChildren(item)
    if (children.length) recurse(children)
  }
}

Big-O: Same. Constant-factor (and protects against deep-recursion stack growth marginally).

Scale impact (callback allocations saved per flattenBy call β€” dimension: nodes flattened):

Nodes flattened Before (callbacks) After Saved
10 10 0 10
100 100 0 100
1,000 1,000 0 1,000
10,000 10,000 0 10,000

Risk: None.


5. isNumberArray() uses .every() β€” Score: 1

Status: [x] done Implementation note: Replaced .every() callback with an indexed for loop + early return false. Semantics preserved (empty array still returns true, matching the original .every() behavior). Drops one closure allocation per call.

Location: src/utils.ts:79–81 Category: micro

Replace with an indexed loop and early exit. Low frequency; only used during sort-fn auto-detection.

Risk: None.


6. createColumnHelper() allocates a fresh object on every call β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/helpers/columnHelper.ts:94–117 Category: micro, bundle-size

The helper is stateless. Hoist a module-level singleton and return it.

Before

export function createColumnHelper<...>(): ColumnHelper<TFeatures, TData> {
  return {
    accessor: (accessor, column) => { ... },
    columns: (columns) => { ... },
    display: (column) => column,
    group: (column) => column,
  }
}

After

const COLUMN_HELPER = {
  accessor: (accessor: any, column: any) => ({ ...column, accessorKey: accessor, ... }),
  columns: (columns: any) => columns,
  display: (column: any) => column,
  group: (column: any) => column,
}
export function createColumnHelper<...>(): ColumnHelper<TFeatures, TData> {
  return COLUMN_HELPER as any
}

Risk: None. Methods are pure.


7. storeReactivityBindings() allocates fresh bindings on every call β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/store-reactivity-bindings.ts:19–36 Category: micro

Same pattern as #6. Hoist a singleton.

Risk: None.


8. FlexRender has redundant 'X' in props checks β€” Score: 1

Status: [ ] not started Implementation note: (none)

Location: src/flex-render.ts:46–70 Category: micro, bundle-size

TypeScript narrows the discriminated union via the truthy check alone.

Before

if ('cell' in props && props.cell) { ... }
if ('header' in props && props.header) { ... }
if ('footer' in props && props.footer) { ... }

After

if (props.cell) { ... }
if (props.header) { ... }
if (props.footer) { ... }

Risk: None.


Core β€” cells

9. cell_getContext() re-allocates the context object on every call β€” Score: 6

Status: [ ] not started Implementation note: (none)

Location: src/core/cells/coreCellsFeature.utils.ts:51–65 Category: micro, memoization

Every render that reads cell.getContext() (which every framework adapter does for every visible cell) builds a fresh 6-property object. Cells are long-lived; the context is functionally immutable. Cache it on the cell instance.

Before

export function cell_getContext(cell) {
  return {
    table: cell.table,
    column: cell.column,
    row: cell.row,
    cell,
    getValue: () => cell.getValue(),
    renderValue: () => cell.renderValue(),
  }
}

After

export function cell_getContext(cell) {
  if (!cell._contextCache) {
    cell._contextCache = {
      table: cell.table,
      column: cell.column,
      row: cell.row,
      cell,
      getValue: () => cell.getValue(),
      renderValue: () => cell.renderValue(),
    }
  }
  return cell._contextCache
}

Big-O: Eliminates one object + two arrow-function allocations per visible cell per access. For a 1000-row Γ— 20-col table that's 20k saved allocations per render.

Scale impact (allocations saved per render β€” 1 object + 2 closures per visible cell read):

Rows Γ— cols (visible cells) Allocations before / render After (post-warmup) Saved / render
10 Γ— 10 = 100 300 0 300
100 Γ— 20 = 2,000 6,000 0 6,000
1,000 Γ— 50 = 50,000 150,000 0 150,000
10,000 Γ— 100 = 1,000,000 3,000,000 0 3,000,000

Risk: Add _contextCache? to the internal Cell type. Safe because cell properties are not mutated post-construction.


Core β€” columns

10. replaceAll('.', '_') in constructColumn β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/core/columns/constructColumn.ts:54–59 Category: micro

split('.').join('_') outperforms replaceAll for single-char replacement in many engines. One-time cost per column.

Risk: None.


11. table_getAllFlatColumnsById / getAllLeafColumnsById use for...of β€” Score: 2

Status: [x] done Implementation note: Converted as part of the codebase-wide for...of β†’ indexed for sweep. See the "Cross-cutting sweep" section near the top of this doc.

Location: src/core/columns/coreColumnsFeature.utils.ts:175–186, 224–235 Category: micro

Swap for...of for indexed loops to drop iterator protocol overhead. Cheap, but called every time the column structure is rebuilt.

Scale impact (iterator protocol overhead saved per column-structure rebuild β€” dimension: columns):

Columns Iterator calls before After (indexed) Saved iterator calls
10 10 0 10
100 100 0 100
1,000 1,000 0 1,000
10,000 10,000 0 10,000

Risk: None.


Core β€” headers

12. centerColumns filter runs over all leaf columns even when nothing is pinned β€” Score: 7

Status: [x] done Implementation note: Original audit proposed converting left/right arrays to Sets. On reflection that's the wrong fix: pinning in real tables is usually 1–2 cols per side, where .includes on a small array beats a Set (no hashing, no extra object allocation, JIT-friendly). The actual win is in the common case where nothing is pinned at all β€” today the function does all the per-side partition work, even with empty pin lists. Refactor: hoist the pin-emptiness check to the top of table_getHeaderGroups and bail to buildHeaderGroups(allColumns, leafColumns, table) directly. Skips the getAllLeafColumnsById() call, two empty-array allocations, two for-loops over empty arrays, the .filter pass, and the final 3-way spread.

Location: src/core/headers/coreHeadersFeature.utils.ts:81–134 Category: micro

Before

const { left, right } =
  table.atoms.columnPinning?.get() ?? getDefaultColumnPinningState()
const allColumns = table.getAllColumns()
const leafColumns = callMemoOrStaticFn(
  table,
  'getVisibleLeafColumns',
  table_getVisibleLeafColumns,
)
const leafColumnsById = table.getAllLeafColumnsById()

const leftColumns: typeof leafColumns = []
for (const columnId of left) {
  /* push if visible */
}
const rightColumns: typeof leafColumns = []
for (const columnId of right) {
  /* push if visible */
}

const centerColumns = leafColumns.filter(
  (column) => !left.includes(column.id) && !right.includes(column.id),
)

return buildHeaderGroups(
  allColumns,
  [...leftColumns, ...centerColumns, ...rightColumns],
  table,
)

After

const { left, right } =
  table.atoms.columnPinning?.get() ?? getDefaultColumnPinningState()
const allColumns = table.getAllColumns()
const leafColumns = callMemoOrStaticFn(
  table,
  'getVisibleLeafColumns',
  table_getVisibleLeafColumns,
)

// Fast path: no columns are pinned β€” skip per-side lookups, partition, and spread.
if (!left.length && !right.length) {
  return buildHeaderGroups(allColumns, leafColumns, table)
}

const leafColumnsById = table.getAllLeafColumnsById()
// ... (rest unchanged: left/right loops, center filter, spread, buildHeaderGroups)

Big-O: Same asymptotic complexity; constant-factor win in the no-pin case (which is most tables). When pinning is active, one extra boolean check at the top β€” negligible.

Scale impact (work saved per getHeaderGroups() call when no columns are pinned):

Leaf cols (L) Before: filter callbacks + spread allocs + 2 empty arrays + getAllLeafColumnsById() After Saved
10 10 callbacks + 2 arrays(size 10) + 2 empty arrays + 1 method call 0 (early return) full work skipped
100 100 + 2 arrays(100) + 2 empty + 1 call 0 full work skipped
1,000 1,000 + 2 arrays(1,000) + 2 empty + 1 call 0 full work skipped
10,000 10,000 + 2 arrays(10,000) + 2 empty + 1 call 0 full work skipped

Risk: None. Behavior unchanged. The leafColumns reference is reused (not mutated) when pinning is off β€” buildHeaderGroups reads but does not write to its input array.


13. buildHeaderGroups.findMaxDepth allocates intermediate filtered arrays β€” Score: 5

Status: [x] done Implementation note: Replaced columns.filter(...).forEach(...) with an indexed for loop + continue on invisible columns. Drops one filtered-array allocation per recursion level, two callback closures per call (filter + forEach), and removes the spurious , 0) second argument to forEach that was being ignored. Implemented as proposed.

Location: src/core/headers/buildHeaderGroups.ts:41–48 Category: micro

.filter(...).forEach(...) creates throwaway arrays at every depth. Inline the visibility check inside a single indexed loop.

Before

columns
  .filter((column) =>
    callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible),
  )
  .forEach((column) => {
    if (column.columns.length) findMaxDepth(column.columns, depth + 1)
  }, 0)

After

for (let i = 0; i < columns.length; i++) {
  const column = columns[i]
  if (!callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible)) continue
  if (column.columns.length) findMaxDepth(column.columns, depth + 1)
}

Big-O: Same. Saves O(n) allocations per recursion level. Also removes the erroneous , 0 second-arg to forEach.

Scale impact (intermediate filtered arrays saved β€” dimension: total header columns walked, one filtered array per recursion level):

Header tree size Filtered arrays before After Saved
10 (1 level) 1 0 1
100 (3 levels) 3 0 3
1,000 (4 levels) 4 0 4
10,000 (5+ levels) 5+ 0 5+

(The win here is constant in tree height, not size β€” the per-recursion filtered array is the entry that gets eliminated.)

Risk: None.


14. recurseHeadersForSpans uses Math.min(...arr) spread β€” Score: 7

Status: [x] done Implementation note: Collapsed .filter().map() chain into a single for…of loop with continue on invisible headers (per project eslint preference for for…of). Inlined the inner .forEach() over recursive children as a for…of loop. Eliminated Math.min(...childRowSpans) spread (which would have hit engine arg-count limits on extremely wide header rows) by tracking minChildRowSpan inline during the same loop that sums colSpan. Edge-case behavior preserved: when a header has subHeaders.length > 0 but none pass visibility (theoretically unreachable given column_getIsVisible semantics, but possible by construction), the original code's Math.min(...[]) returned Infinity β€” the refactor initializes minChildRowSpan = Infinity so the empty-children branch naturally produces the same value. Per recursion level: removes 1 filtered array allocation, 1 mapped array allocation, 1 child-rowSpan array allocation, and the spread of that array.

Location: src/core/headers/buildHeaderGroups.ts:143–176 Category: micro, big-o (stack-overflow risk)

Math.min(...childRowSpans) spreads into argument list. With very wide header rows this can blow the argument-count stack limit. Also: this function uses .filter().map() which allocates two intermediate arrays per recursion level.

Before

const filteredHeaders = headers.filter((header) =>
  callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible),
)
return filteredHeaders.map((header) => {
  ...
  recurseHeadersForSpans(header.subHeaders).forEach(({ colSpan, rowSpan }) => { ... })
  const minChildRowSpan = Math.min(...childRowSpans)
  ...
})

After

const results: Array<{ colSpan: number; rowSpan: number }> = []
for (let i = 0; i < headers.length; i++) {
  const header = headers[i]
  if (!callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible)) continue
  ...
  const childSpans = recurseHeadersForSpans(header.subHeaders)
  for (let j = 0; j < childSpans.length; j++) {
    colSpan += childSpans[j].colSpan
    childRowSpans.push(childSpans[j].rowSpan)
  }
  let minChildRowSpan = childRowSpans[0]
  for (let j = 1; j < childRowSpans.length; j++) {
    if (childRowSpans[j] < minChildRowSpan) minChildRowSpan = childRowSpans[j]
  }
  ...
  results.push({ colSpan, rowSpan })
}
return results

Big-O: Removes O(n) intermediate filtered array per recursion + eliminates spread-arg stack risk.

Scale impact (intermediate arrays + spread risk β€” dimension: leaf headers in widest row):

Headers in widest row Before (filter+map arrays + spread args) After Saved / risk
10 2 arrays + 10-arg spread 0 extra arrays safe range
100 2 arrays + 100-arg spread 0 safe range
1,000 2 arrays + 1,000-arg spread 0 approaches engine arg-limit (~10k–65k)
10,000 2 arrays + 10,000-arg spread 0 may exceed Math.min arg-limit β†’ crash

Risk: None. Same output.


15. header_getContext() re-allocates per call β€” Score: 5

Status: [ ] not started Implementation note: (none)

Location: src/core/headers/coreHeadersFeature.utils.ts:59–69 Category: micro, memoization

Mirror of finding #9 for headers.

Scale impact (object allocations saved per render β€” dimension: visible headers Γ— renders that read header.getContext()):

Headers Γ— renders Before (objs) After (post-warmup) Saved
10 Γ— 100 1,000 10 990
50 Γ— 1,000 50,000 50 49,950
100 Γ— 10,000 1,000,000 100 999,900

Risk: Add _contextCache? to internal Header type.


16. table_getLeafHeaders memoDeps call expensive functions to compute deps β€” Score: 7

Status: [x] done Implementation note: Initially planned to replace the three cascading callMemoOrStaticFn(...getLeft/Center/RightHeaderGroups) calls in memoDeps with the six root atoms. On closer inspection the entire pinning branch in the function body was also redundant: table.getHeaderGroups() already builds the top row in left β†’ center β†’ right order via buildHeaderGroups(allColumns, [...leftColumns, ...centerColumns, ...rightColumns], table), so the three side-specific getters give the same set of top-row headers as getHeaderGroups()[0].headers. Final form collapses to the same pattern as table_getFooterGroups / table_getFlatHeaders:

export function table_getLeafHeaders(table) {
  return (table.getHeaderGroups()[0]?.headers ?? [])
    .map((header) => header.getLeafHeaders())
    .flat()
}
// in feature:
table_getLeafHeaders: {
  fn: () => table_getLeafHeaders(table),
  memoDeps: () => [table.getHeaderGroups()],
},

Eliminates three memoized cascades per call (down to one reference check against the cached header groups), removes the per-call columnPinning atom read, and removes the unused imports (callMemoOrStaticFn, table_getLeftHeaderGroups, table_getCenterHeaderGroups, table_getRightHeaderGroups, HeaderGroup type) from this file.

Location: src/core/headers/coreHeadersFeature.ts:75–94 Category: memoization

The memoDeps array invokes getLeftHeaderGroups() / getCenterHeaderGroups() / getRightHeaderGroups() just to compute the dependency tuple. Those getters are themselves memoized but still force an equality walk every time. Depend on the underlying root atoms instead.

Before

memoDeps: () => [
  callMemoOrStaticFn(table, 'getLeftHeaderGroups', table_getLeftHeaderGroups),
  callMemoOrStaticFn(table, 'getCenterHeaderGroups', table_getCenterHeaderGroups),
  callMemoOrStaticFn(table, 'getRightHeaderGroups', table_getRightHeaderGroups),
],

After

memoDeps: () => [
  table.atoms.columnOrder?.get(),
  table.atoms.grouping?.get(),
  table.atoms.columnPinning?.get(),
  table.atoms.columnVisibility?.get(),
  table.options.columns,
  table.options.groupedColumnMode,
],

Big-O: Avoids 3 memo cascades per getLeafHeaders() access.

Scale impact (memo-cascade triggers saved per call β€” dimension: getLeafHeaders() invocations per session):

Calls / session Cascade triggers before (3/call) After (cheap atom reads) Saved cascades
10 30 0 30
100 300 0 300
1,000 3,000 0 3,000
10,000 30,000 0 30,000

Risk: Low. Leaf headers are derived from exactly these inputs.


Core β€” rows

17. row_getAllCells / row_getAllCellsByColumnId use .map/for...of β€” Score: 4

Status: [x] done Implementation note: Converted as part of the codebase-wide for...of β†’ indexed for sweep. row_getAllCells .map was additionally replaced with a preallocated new Array(columns.length) + indexed assignment (avoids .push reallocation overhead). See the "Cross-cutting sweep" section near the top of this doc.

Location: src/core/rows/coreRowsFeature.utils.ts:163–191 Category: micro

Swap .map() and for...of for indexed loops. Called for every row in the row model whenever cells are read.

Scale impact (iterator/callback overhead saved β€” dimension: cells iterated when row cell collections are built):

Rows Γ— cols (cells) Before (callback/iterator overhead per cell) After (indexed access) Saved overhead per pass
10 Γ— 10 = 100 100 callback invokes 0 100
100 Γ— 20 = 2,000 2,000 0 2,000
1,000 Γ— 50 = 50,000 50,000 0 50,000
10,000 Γ— 100 = 1,000,000 1,000,000 0 1,000,000

Risk: None.


18. table_getRow always calls getCoreRowModel() β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/core/rows/coreRowsFeature.utils.ts:228–251 Category: micro

When the row exists in the primary row model (common case), skip the fallback fetch.

Before

let row = (searchAll ? table.getPrePaginatedRowModel() : table.getRowModel()).rowsById[rowId]
if (!row) {
  row = table.getCoreRowModel().rowsById[rowId]
  ...
}
return row

After

const primary = (searchAll ? table.getPrePaginatedRowModel() : table.getRowModel()).rowsById[rowId]
if (primary) return primary
const core = table.getCoreRowModel().rowsById[rowId]
if (core) return core
...

Risk: None.


Core β€” row-models / table

19. constructTable Object.assign with undefined from optional feature method β€” Score: 1

Status: [ ] not started Implementation note: (none)

Location: src/core/table/constructTable.ts:46–50 Category: micro

Guard against the undefined return from feature.getDefaultTableOptions?.().

Risk: None.


20. createCoreRowModel deps [table.options.data] is fragile β€” Score: 4 (correctness leaning)

Status: [ ] not started Implementation note: (none)

Location: src/features/.../createCoreRowModel.ts:25 Category: memoization

Today's dep is table.options.data. If a consumer recreates the options object (options = { ...options, data: sameRef }) the memo still works (same data reference). But if a consumer also recreates data per render without intent, the entire row model rebuilds. Consider exposing this as an atom (table.atoms.data) so adapters can route data identity through the reactivity layer instead of options identity.

Risk: Medium β€” surface change. Not strictly required, but a foundational correctness sharpening.


Feature β€” column-faceting

21. createFacetedMinMaxValues chains .map().map().filter() β€” Score: 5

Status: [x] done Implementation note: Fused as part of the loop-fusion sweep (see "Cross-cutting sweep: loop fusion" section near the top). Went further than the original proposal: instead of just collapsing the three .map().map().filter() passes into a single numericValues loop, the subsequent min/max scan was fused into that same pass too. Net result: one pass over flatRows, zero intermediate arrays, inline min/max tracking with Number.POSITIVE_INFINITY / Number.NEGATIVE_INFINITY seeds and a foundAny flag to return undefined when no numeric values exist.

Location: src/features/column-faceting/createFacetedMinMaxValues.ts:50–56 Category: micro

Three intermediate arrays per faceted column per change. Collapse to a single indexed loop.

Before

const numericValues = flatRows
  .map((flatRow) => flatRow.getValue(columnId))
  .map(Number)
  .filter((value) => !Number.isNaN(value))

After

const numericValues: number[] = []
for (let i = 0; i < flatRows.length; i++) {
  const v = Number(flatRows[i].getValue(columnId))
  if (!Number.isNaN(v)) numericValues.push(v)
}

Big-O: O(3n) β†’ O(n) work, 3 array allocations β†’ 1.

Scale impact (per faceted column rebuild β€” dimension: flat rows scanned):

Flat rows Before (3 intermediate arrays of ≀n) After (1 array of ≀n) Saved arrays
10 3 of 10 1 of ≀10 2 of ~10
100 3 of 100 1 of ≀100 2 of ~100
1,000 3 of 1,000 1 of ≀1,000 2 of ~1,000
10,000 3 of 10,000 1 of ≀10,000 2 of ~10,000

Risk: None.


22. createFacetedUniqueValues redundant Map.has before Map.set β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/column-faceting/createFacetedUniqueValues.ts:46–62 Category: micro

set(k, (get(k) ?? 0) + 1) works in either branch.

Scale impact (Map ops saved per facet rebuild β€” dimension: distinct value encounters):

Value occurrences Before (has + get + set) After (get + set) Saved Map ops
10 30 20 10
100 300 200 100
1,000 3,000 2,000 1,000
10,000 30,000 20,000 10,000

Risk: None.


23. Faceted min/max loop comparisons β€” Score: 1

Status: [x] done Implementation note: if/if swapped for if/else if (skips the max comparison when min was a hit). Also loop start moved to i = 1 since numericValues[0] is used to seed both facetedMinValue and facetedMaxValue. Done as part of the for...of β†’ indexed for sweep.

Location: src/features/column-faceting/createFacetedMinMaxValues.ts:59–65 Category: micro

if (...) ... else if (...) instead of two unconditional ifs. Tiny.

Risk: None.


Feature β€” column-filtering

24. column_getFilterValue / column_getFilterIndex linear .find β€” Score: 6

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/columnFilteringFeature.utils.ts:156–185 Category: big-o, memoization

Each call walks the columnFilters array. When a filter UI re-renders columns, every column re-walks. Memoize at the column level with deps [columnFilters, column.id], or expose table.getColumnFiltersById() (new API) returning a Record<string, ColumnFilter>.

Before

return column.table.atoms.columnFilters?.get()?.find((d) => d.id === column.id)
  ?.value

After (new memoized table API)

// in columnFilteringFeature.ts
table_getColumnFiltersById: {
  fn: () => Object.fromEntries((table.atoms.columnFilters?.get() ?? []).map(f => [f.id, f])),
  memoDeps: () => [table.atoms.columnFilters?.get()],
},
// in column_getFilterValue
return column.table.getColumnFiltersById()[column.id]?.value

Big-O: O(n) per call β†’ O(1) lookup; O(n) one-time per columnFilters change.

Scale impact (.find comparisons saved per render β€” dimension: columns Γ— active filters Γ— renders, with average .find walking F/2):

Cols (C) Active filters (F) Renders (R) Before (β‰ˆ C Γ— F/2 Γ— R) After (build map once: F Γ— R) Saved
10 2 10 100 20 80
50 5 100 12,500 500 12,000
100 10 1,000 500,000 10,000 490,000
500 20 10,000 50,000,000 200,000 49,800,000

Risk: New API name β€” bikeshed. Backwards compatible.


25. column_setFilterValue re-searches array β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/columnFilteringFeature.utils.ts:198–232 Category: micro

Calls .find() then .map() over the same array. Use findIndex and slice in/around it.

Risk: None.


26. filterableIds mutated with .push after creation β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/createFilteredRowModel.ts:88–101 Category: micro

Build the array once with the global filter id conditionally appended.

Risk: None.


27. globallyFilterableColumns computed even when globalFilter is empty β€” Score: 6

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/createFilteredRowModel.ts:95–110 Category: micro, big-o (short-circuit)

The .getAllLeafColumns().filter(column_getCanGlobalFilter) pass runs on every filtered-row-model build, even when no global filter is active. Gate the entire branch.

Before

const globallyFilterableColumns = table
  .getAllLeafColumns()
  .filter((column) => column_getCanGlobalFilter(column))

if (globalFilter && globalFilterFn && globallyFilterableColumns.length) {
  filterableIds.push('__global__')
  ...
}

After

if (globalFilter && globalFilterFn) {
  const globallyFilterableColumns = table
    .getAllLeafColumns()
    .filter((column) => column_getCanGlobalFilter(column))
  if (globallyFilterableColumns.length) {
    filterableIds.push('__global__')
    ...
  }
}

Big-O: Saves O(C) work + O(C) column_getCanGlobalFilter invocations per filtered row-model rebuild when no global filter is active (the common case).

Scale impact (work saved per filtered-row-model rebuild, no global filter active):

Cols (C) Rebuilds Before (C Γ— rebuilds) column_getCanGlobalFilter calls After Saved
10 10 100 0 100
50 100 5,000 0 5,000
100 1,000 100,000 0 100,000
500 10,000 5,000,000 0 5,000,000

Risk: None.


28. Row filter state reset allocates even when already reset β€” Score: 1

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/createFilteredRowModel.ts:59–66 Category: micro

Skip the row.columnFilters = {} write when it's already an empty object.

Risk: None.


29. filterRowModelFromLeafs duplicates predicate work β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/features/column-filtering/filterRowsUtils.ts:43–101 Category: micro

filterRow(row) is called twice in some branches. Cache the boolean and the hasVisibleSubRows flag, branch once.

Scale impact (duplicate filterRow invocations saved β€” dimension: rows in subtree-bearing branches per filter pass):

Rows in subtree-bearing branches Before (filterRow calls) After Saved
10 20 10 10
100 200 100 100
1,000 2,000 1,000 1,000
10,000 20,000 10,000 10,000

Risk: Logic is subtle; needs unit-test coverage when refactored.


Feature β€” column-grouping

30. existingGrouping.includes(colId) per cell value access β€” Score: 7

Status: [ ] not started Implementation note: (none)

Location: src/features/column-grouping/createGroupedRowModel.ts:141–152 Category: big-o

The grouped row's getValue(colId) calls .includes() on existingGrouping once (or twice β€” finding #31) per access. With G grouped columns and C total columns called over R grouped rows that's O(G Γ— C Γ— R). Cache as a Set built once at row-model build time.

Before

getValue: (colId: string) => {
  if (existingGrouping.includes(colId)) { ... }
  ...
}

After

// at top of _createGroupedRowModel:
const existingGroupingSet = new Set(existingGrouping)
// in closure:
getValue: (colId: string) => {
  if (existingGroupingSet.has(colId)) { ... }
  ...
}

Big-O: O(G) β†’ O(1) per cell access.

Scale impact (.includes compares saved per render of grouped rows β€” dimension: grouped rows Γ— cell reads Γ— grouping length):

Grouped rows (R) Cell reads per row (C) Grouping cols (G) Before (R Γ— C Γ— G) After (R Γ— C Γ— 1) Saved
10 10 2 200 100 100
100 20 3 6,000 2,000 4,000
1,000 50 5 250,000 50,000 200,000
10,000 100 10 10,000,000 1,000,000 9,000,000

Risk: None.


31. existingGrouping.includes called twice per getValue β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/column-grouping/createGroupedRowModel.ts:141, 154 Category: micro

Cache the boolean. Subsumed by #30 once Set lookup lands but worth noting independently.

Scale impact (duplicate .includes walks saved per cell access β€” dimension: grouped rows Γ— cell reads):

Grouped rows Γ— cell reads Before (2 walks/cell) After (1 walk/cell) Saved walks
10 Γ— 10 = 100 200 100 100
100 Γ— 20 = 2,000 4,000 2,000 2,000
1,000 Γ— 50 = 50,000 100,000 50,000 50,000
10,000 Γ— 100 = 1,000,000 2,000,000 1,000,000 1,000,000

Risk: None.


32. groupBy uses Array.prototype.reduce β€” Score: 1

Status: [ ] not started Implementation note: (none)

Location: src/features/column-grouping/createGroupedRowModel.ts:204–220 Category: micro

Trivial for loop replacement of .reduce.

Risk: None.


33. Per-row aggregated column_getAggregationFn resolution β€” Score: 5

Status: [ ] not started Implementation note: (none)

Location: src/features/column-grouping/createGroupedRowModel.ts:159–161 Category: memoization

Inside the grouped row's getValue, every non-grouped column lookup calls table.getColumn(colId) and column_getAggregationFn(column). The result depends only on colId (effectively). Cache aggregation results per (row, colId) via a _aggregationCache on the row.

Scale impact (aggregation invocations saved on repeat cell reads β€” dimension: grouped rows Γ— non-grouped cols Γ— repeat reads):

Grouped rows Non-grouped cols Repeat reads/cell Before (re-aggregate each read) After (1 per cell, then cache hits) Saved aggregations
10 5 2 100 50 50
100 20 5 10,000 2,000 8,000
1,000 50 10 500,000 50,000 450,000
10,000 100 10 10,000,000 1,000,000 9,000,000

Risk: Already cached implicitly via _groupingValuesCache. Verify cache-key collision doesn't occur if extending it.


Feature β€” column-ordering

34. orderColumns uses grouping.includes β€” Score: 7

Status: [ ] not started Implementation note: (none)

Location: src/features/column-ordering/columnOrderingFeature.utils.ts:205–225 Category: big-o

The .filter((col) => !grouping.includes(col.id)) runs .includes per leaf column. Build a Set once.

Before

const nonGroupingColumns = leafColumns.filter(
  (col) => !grouping.includes(col.id),
)

After

const groupingSet = new Set(grouping)
const nonGroupingColumns = leafColumns.filter((col) => !groupingSet.has(col.id))

Big-O: O(L Γ— G) β†’ O(L + G). Triggered on every column-order / grouping change.

Scale impact (.includes compares per call β€” dimension: leaf columns Γ— grouping cols):

Leaf cols (L) Grouping cols (G) Before (L Γ— G) After (L + G) Saved
10 1 10 11 -1
100 3 300 103 197
1,000 5 5,000 1,005 3,995
10,000 10 100,000 10,010 89,990

Risk: None.


Feature β€” column-pinning

35. row_getLeftVisibleCells / row_getRightVisibleCells use .find in pin loop β€” Score: 8

Status: [x] done Implementation note: Original audit proposed building a per-call Map<columnId, cell> inside each getter. Final implementation went further: reuses the already-memoized row_getVisibleCellsByColumnId lookup record (deps [row.getAllCells(), columnVisibility]) rather than rebuilding a Map on every call. Result: O(P) bracket lookups per call, with the underlying record amortized to zero rebuild cost across multiple pin-side getters on the same row. Added an early return when the pin side is empty (consistent with the rest of the codebase). Behavior preserved: cell.position = 'left' | 'right' mutation, ordering by pin-array index, and hidden-column exclusion via the visible-cells record.

Location: src/features/column-pinning/columnPinningFeature.utils.ts:216–224, 250–257 Category: big-o

Each pinned column triggers a linear .find over all visible cells of a row. With P pinned and C visible per row, this is O(P Γ— C) per row, per render. Build a Map<columnId, cell> once at the top.

Before

for (const columnId of left) {
  const cell = allVisibleCells.find((c) => c.column.id === columnId)
  if (cell) {
    cell.position = 'left'
    cells.push(cell)
  }
}

After

const cellsByColumnId = new Map<string, (typeof allVisibleCells)[number]>()
for (let i = 0; i < allVisibleCells.length; i++) {
  cellsByColumnId.set(allVisibleCells[i].column.id, allVisibleCells[i])
}
for (let i = 0; i < left.length; i++) {
  const cell = cellsByColumnId.get(left[i])
  if (cell) {
    cell.position = 'left'
    cells.push(cell)
  }
}

Big-O: O(P Γ— C) β†’ O(P + C) per row.

Scale impact (.find comparisons saved per render β€” dimension: rows Γ— pinned cols Γ— visible cells; average .find walks Β½ the visible-cell list):

Rows (R) Visible cells/row (C) Pinned cols (P) Before (R Γ— P Γ— C/2) After (R Γ— (P + C)) Saved
10 10 2 100 120 -20
100 20 4 4,000 2,400 1,600
1,000 50 6 150,000 56,000 94,000
10,000 100 10 5,000,000 1,100,000 3,900,000

Risk: None. The mutation cell.position = 'left' is unchanged.


36. [...left, ...right].includes(id) for center column filtering β€” Score: 6

Status: [ ] not started Implementation note: (none)

Location: src/features/column-pinning/columnPinningFeature.utils.ts:189, 430 Category: big-o

Builds an array, then .includes() on it for every cell/column. Use a Set.

Before

const leftAndRight: Array<string> = [...left, ...right]
return allCells.filter((d) => !leftAndRight.includes(d.column.id))

After

const leftAndRight = new Set<string>()
for (let i = 0; i < left.length; i++) leftAndRight.add(left[i])
for (let i = 0; i < right.length; i++) leftAndRight.add(right[i])
return allCells.filter((d) => !leftAndRight.has(d.column.id))

Big-O: O(C Γ— (P_l + P_r)) β†’ O(C + P_l + P_r) per row, per call.

Scale impact (.includes compares per render β€” dimension: rows Γ— cells Γ— pinned total):

Rows (R) Cells/row (C) Pinned (P) Before (R Γ— C Γ— P) After (R Γ— (C + P)) Saved
10 10 2 200 120 80
100 20 4 8,000 2,400 5,600
1,000 50 6 300,000 56,000 244,000
10,000 100 10 10,000,000 1,100,000 8,900,000

Risk: None.


Feature β€” column-resizing

37. passiveEventSupported() caching bug β€” Score: 8 (bug)

Status: [ ] not started Implementation note: (none)

Location: src/features/column-resizing/columnResizingFeature.utils.ts:320–343 Category: bug, micro

passiveSupported is declared inside the function (let passiveSupported: boolean | null = null), so the cache check if (typeof passiveSupported === 'boolean') return passiveSupported is unreachable on first call and the cache is reset on every call. Each resize call probes the DOM via addEventListener('test', ...).

Before

export function passiveEventSupported() {
  let passiveSupported: boolean | null = null
  if (typeof passiveSupported === 'boolean') return passiveSupported
  let supported = false
  try { window.addEventListener('test', noop, options); ... }
  ...
  passiveSupported = supported
  return passiveSupported
}

After

let passiveSupported: boolean | null = null
export function passiveEventSupported() {
  if (typeof passiveSupported === 'boolean') return passiveSupported
  let supported = false
  try { window.addEventListener('test', noop, options); ... }
  ...
  passiveSupported = supported
  return passiveSupported
}

Big-O: Avoid a DOM listener add+remove on every resize-handler hook-up.

Risk: None. Behavior is what the original obviously intended.


Feature β€” column-sizing

38. table_getTotalSize and the L/C/R variants are not memoized β€” Score: 8

Status: [x] done Implementation note: Added memoDeps: () => [table.atoms.columnSizing?.get(), table.getHeaderGroups()] to all four entries (table_getTotalSize, table_getLeftTotalSize, table_getCenterTotalSize, table_getRightTotalSize) in columnSizingFeature.ts. Matches the pattern already used by table_getFooterGroups / table_getFlatHeaders: table.getHeaderGroups() is itself memoized against every input that can change the header-row composition (columns, columnOrder, grouping, columnPinning, columnVisibility, groupedColumnMode), so its ref is a compact proxy that holds steady while the underlying inputs don't change. The only other dep is columnSizing for per-column width state. Deliberately omitted columnResizing β€” with columnResizeMode: 'onChange' (the typical resize-aware setup) the resize handler writes through to columnSizing directly, so depending on columnResizing would cause redundant invalidations on every drag-move tick without changing the output.

Location: src/features/column-sizing/columnSizingFeature.ts:142–154 Category: memoization, big-o

All four (getTotalSize, getLeftTotalSize, getCenterTotalSize, getRightTotalSize) have no memoDeps in the feature config. Each call does .reduce(...) over the header group, summing header_getSize per header (which is itself memoized but still walks the entire array). Layout code reads these every render β€” for virtualizers, every scroll tick.

Before

table_getTotalSize: { fn: () => table_getTotalSize(table) },
table_getLeftTotalSize: { fn: () => table_getLeftTotalSize(table) },
table_getCenterTotalSize: { fn: () => table_getCenterTotalSize(table) },
table_getRightTotalSize: { fn: () => table_getRightTotalSize(table) },

After

table_getTotalSize: {
  fn: () => table_getTotalSize(table),
  memoDeps: () => [
    table.atoms.columnSizing?.get(),
    table.atoms.columnPinning?.get(),
    table.atoms.columnVisibility?.get(),
    table.options.columns,
  ],
},
// (same memoDeps for the L/C/R variants)

Big-O: O(H) per call β†’ O(1) until column sizing/visibility/pinning changes. High-frequency read path.

Scale impact (header_getSize invocations skipped β€” dimension: renders Γ— headers per render; assumes deps unchanged):

Renders (R) Headers (H) Before (R Γ— H) After (1 Γ— H + later invalidations) Saved (steady state)
10 10 100 10 90
100 50 5,000 50 4,950
1,000 100 100,000 100 99,900
10,000 500 5,000,000 500 4,999,500

Virtualizers calling getTotalSize() per scroll tick amplify this dramatically.

Risk: None. Deps fully capture inputs.


Feature β€” column-visibility

39. row_getVisibleCells builds Sets for the small left/right arrays β€” Score: 4

Status: [x] done Implementation note: Original audit proposed a single-pass partition iterating allCells directly and dispatching each cell to left/center/right via Set membership. On review that's incorrect: it would push pinned cells in cell order rather than pin order, changing user-visible behavior (pinning column B then A should display B, A). The existing per-side loop honors pin order correctly. The consistency win available here, matching the approach in #12, is to drop the leftSet/rightSet allocations used for the center-cell partition and use .includes() on the small left/right arrays directly.

Additional refactor on top: the per-call cellsByColumnId local Map was promoted to a new memoized row API, row.getVisibleCellsByColumnId() (returns Record<string, Cell> with narrower deps [row.getAllCells(), columnVisibility] β€” pinning doesn't invalidate it). Inside row_getVisibleCells, the pinned path now reads from this memoized record instead of building a fresh Map per call.

Do not "optimize" by deriving visibleCells from Object.values(visibleCellsByColumnId). Object.values() returns integer-index-like string keys (e.g. "0", "1", "42") first in ascending numeric order, regardless of insertion order. Column IDs come from accessorKey, so a user with numeric-string accessor keys ("2", "10", "1") would see their cell order reorder after a round-trip. The Record is safe for bracket-lookup (record[columnId]) but unsafe for ordered iteration. Keep visibleCells built directly from row.getAllCells() to preserve leaf-column order.

Location: src/features/column-visibility/columnVisibilityFeature.utils.ts:157–166 Category: micro

Before

// Center cells: visible cells in natural column order, minus pinned ones.
const leftSet = new Set(left)
const rightSet = new Set(right)
const centerCells: Array<Cell<...>> = []
for (const cell of cells) {
  const id = cell.column.id
  if (!leftSet.has(id) && !rightSet.has(id)) centerCells.push(cell)
}

After

// Center cells: visible cells in natural column order, minus pinned ones.
// .includes() on the small left/right arrays is cheaper than building Sets
// for the typical 1–2 pinned columns per side.
const centerCells: Array<Cell<...>> = []
for (const cell of cells) {
  const id = cell.column.id
  if (!left.includes(id) && !right.includes(id)) centerCells.push(cell)
}

Big-O: Same asymptotic complexity; constant-factor win at typical pin counts. With P_l = P_r = 2, .includes() is ~4 reference comparisons per cell vs Set hashing + bucket traversal + an upfront Set allocation per side per row.

Scale impact (Set object allocations saved per pinned row β€” dimension: rows that hit the pinned path per render):

Rows with pinning active per render Set allocs before (2 per row) After Saved Sets
10 20 0 20
100 200 0 200
1,000 2,000 0 2,000
10,000 20,000 0 20,000

(For very heavily pinned tables β€” P > ~8 per side β€” Sets would start to pay off again. Reconsider if a user reports that case.)

Risk: None. Output is byte-identical for the typical small-P case; ordering is preserved (Map handles left/right order, center remains in cell order).


40. table_getIsAllColumnsVisible / getIsSomeColumnsVisible not memoized β€” Score: 5

Status: [ ] not started Implementation note: (none)

Location: src/features/column-visibility/columnVisibilityFeature.ts:131–140 Category: memoization

Called by toolbar checkboxes on every render. .some() walks all leaf columns each call.

table_getIsAllColumnsVisible: {
  fn: () => table_getIsAllColumnsVisible(table),
  memoDeps: () => [table.atoms.columnVisibility?.get(), table.options.columns],
},
table_getIsSomeColumnsVisible: {
  fn: () => table_getIsSomeColumnsVisible(table),
  memoDeps: () => [table.atoms.columnVisibility?.get(), table.options.columns],
},

Big-O: O(C) per call β†’ O(1) until visibility changes.

Scale impact (.some() walks saved per render β€” dimension: renders Γ— leaf cols):

Renders Γ— Cols Walks before After (steady state) Saved
10 Γ— 10 100 0 100
100 Γ— 50 5,000 0 5,000
1,000 Γ— 100 100,000 0 100,000
10,000 Γ— 500 5,000,000 0 5,000,000

Risk: None.


Feature β€” global-filtering

41. getColumnCanGlobalFilter default re-evaluates row[0] cell per column per call β€” Score: 5

Status: [ ] not started Implementation note: (none)

Location: src/features/global-filtering/globalFilteringFeature.ts:55–63 Category: memoization

Default getColumnCanGlobalFilter reads flatRows[0].getAllCellsByColumnId()[column.id].getValue() every call. Called once per column when computing globally-filterable columns; with 50 columns that's 50 row[0]-cell rebuilds per filter pass. Memoize across calls keyed on getCoreRowModel().

Before

getColumnCanGlobalFilter: (column) => {
  const value = table
    .getCoreRowModel()
    .flatRows[0]?.getAllCellsByColumnId()
    [column.id]?.getValue()
  return typeof value === 'string' || typeof value === 'number'
}

After (closure-captured cache)

let cachedFor: any = undefined
let cache: Map<string, boolean> | undefined
return {
  getColumnCanGlobalFilter: (column) => {
    const coreRowModel = table.getCoreRowModel()
    if (cachedFor !== coreRowModel) {
      cachedFor = coreRowModel
      cache = new Map()
      const cells = coreRowModel.flatRows[0]?.getAllCellsByColumnId()
      if (cells)
        for (const id in cells) {
          const v = cells[id]?.getValue?.()
          cache.set(id, typeof v === 'string' || typeof v === 'number')
        }
    }
    return cache!.get(column.id) ?? false
  },
}

Big-O: O(C) row-zero cell rebuilds per filter pass β†’ O(C) total, amortized O(1) per column lookup.

Scale impact (row[0] cell-collection rebuilds saved β€” dimension: cols Γ— filter passes):

Cols (C) Filter passes (F) Before (C Γ— F rebuilds) After (≀ F rebuilds) Saved
10 10 100 10 90
50 100 5,000 100 4,900
100 1,000 100,000 1,000 99,000
500 10,000 5,000,000 10,000 4,990,000

Risk: None. Cache invalidates whenever core row model identity changes.


Feature β€” row-expanding

42. row_getIsAllParentsExpanded checks the wrong row (bug) β€” Score: 8 (bug)

Status: [x] done Implementation note: One-character fix β€” changed row_getIsExpanded(row) to row_getIsExpanded(currentRow) inside the parent-walk loop. Previously the function checked the original row on every iteration instead of the parent it had just walked to, which made the loop a no-op past the first iteration and returned wrong results (e.g. a leaf row would report "all parents expanded" whenever the leaf itself was expanded, regardless of any collapsed ancestor). Added a comment explaining the intent. Downstream caller in rowPinningFeature.utils.ts:122 (the if (row_getIsAllParentsExpanded(fullRow)) check that decides whether a pinned row should appear) automatically gets the correct semantic β€” pinned rows now correctly account for their ancestor chain's expansion state instead of accidentally tracking the pinned row's own expansion.

Location: src/features/row-expanding/rowExpandingFeature.utils.ts:324–337 Category: bug

The loop walks parents but calls row_getIsExpanded(row) (original row) instead of row_getIsExpanded(currentRow). Returns wrong result and the loop iterations are wasted.

Before

while (isFullyExpanded && currentRow.parentId) {
  currentRow = row.table.getRow(currentRow.parentId, true)
  isFullyExpanded = row_getIsExpanded(row)
}

After

while (isFullyExpanded && currentRow.parentId) {
  currentRow = row.table.getRow(currentRow.parentId, true)
  isFullyExpanded = row_getIsExpanded(currentRow)
}

Big-O: Correctness fix. Currently the loop is effectively a no-op past one iteration (always re-checks the same row).

Risk: Behavior changes β€” verify with tests; this is the intended logic.


43. table_getCanSomeRowsExpand lacks memoization β€” Score: 4

Status: [ ] not started Implementation note: (none)

Location: src/features/row-expanding/rowExpandingFeature.ts registration Category: memoization

.some(row_getCanExpand) over flatRows every call. Add memoDeps: () => [table.getPrePaginatedRowModel().flatRows, table.options.getRowCanExpand, table.options.enableExpanding].

Scale impact (worst case .some() walks saved when no expandable rows exist β€” dimension: calls Γ— flat rows):

Calls Flat rows Before (calls Γ— rows) After (steady state) Saved
10 10 100 0 100
100 100 10,000 0 10,000
1,000 1,000 1,000,000 0 1,000,000
10,000 10,000 100,000,000 0 100,000,000

Risk: None.


Feature β€” row-pagination

44. table_getPageOptions Array.from(new Array(pageCount)).fill(null).map((_, i) => i) β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/row-pagination/rowPaginationFeature.utils.ts:215–225 Category: micro, bundle-size

Before

let pageOptions: Array<number> = []
if (pageCount && pageCount > 0) {
  pageOptions = [...new Array(pageCount)].fill(null).map((_, i) => i)
}

After

if (pageCount <= 0) return []
return Array.from({ length: pageCount }, (_, i) => i)

Risk: None.


Feature β€” row-pinning

45. row_getPinnedIndex allocates intermediate id array β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/row-pinning/rowPinningFeature.utils.ts:247–261 Category: micro

.map(({ id }) => id).indexOf(row.id) β†’ findIndex(r => r.id === row.id).

Risk: None.


Feature β€” row-selection

46. table_toggleAllRowsSelected clones entire selection on deselect β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/features/row-selection/rowSelectionFeature.utils.ts:78–107 Category: micro

When deselecting all, the function spreads old, then deletes every row id. Just return {} (or a fresh map of forced-selected ids) without the spread.

Scale impact (per deselect-all action β€” dimension: prior selection size):

Prior selections Before (spread + delete per row) After (return {}) Saved ops
10 1 spread + 10 deletes 0 11 ops
100 1 spread + 100 deletes 0 101 ops
1,000 1 spread + 1,000 deletes 0 1,001 ops
10,000 1 spread + 10,000 deletes 0 10,001 ops

Risk: None.


47. table_getIsAllRowsSelected / getIsAllPageRowsSelected flow cleanup β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/features/row-selection/rowSelectionFeature.utils.ts:247–300 Category: micro

Replace let isAll = …; if (cond) isAll = false; return isAll with return !preGroupedFlatRows.some(...). Engine inlining better.

Risk: None.


48. selectRowsFn spreads row object even when subRows did not change β€” Score: 4

Status: [ ] not started Implementation note: (none)

Location: src/features/row-selection/rowSelectionFeature.utils.ts:618–658 Category: micro

If the recursive recurseRows(row.subRows) returns the same reference, skip the spread:

if (newSubRows !== row.subRows) row = { ...row, subRows: newSubRows }

Big-O: Same. Saves O(depth Γ— n) shallow clones when nothing in a subtree matched.

Scale impact (row spread allocations skipped when subtree unchanged β€” dimension: parent rows with subrows Γ— renders where selection didn't change them):

Parent rows with subrows Skip-clone renders Before clones After clones Saved
10 10 100 0 100
100 100 10,000 0 10,000
1,000 1,000 1,000,000 0 1,000,000
10,000 10,000 100,000,000 0 100,000,000

Risk: Need to confirm the recursion never mutates row.subRows in-place. (It does construct a new filtered array, so the reference will differ when results differ.)


Feature β€” row-sorting

49. createSortedRowModel clones every row before sorting β€” Score: 7

Status: [x] done Implementation note: Investigated why the clone existed: the post-sort loop assigns row.subRows = sortData(row.subRows), which would corrupt the source row model if row were the original. So the clone is genuinely necessary for rows with subRows, but pointless for leaf rows. Refactored: rows.slice() produces a sortable array copy (one allocation), the sort runs as before, and the post-sort loop clones only rows where row.subRows.length > 0. Leaf rows pass through as their original references. For a flat table (the common case) this drops from N heavy clones to zero per-row clones plus one slice(). For nested tables, only parent rows are cloned (typically a small fraction of total rows). The native Array.prototype.sort is stable since ES2019; the explicit row.index tiebreaker was preserved in the comparator for any caller that relied on it.

Location: src/features/row-sorting/createSortedRowModel.ts:81–89 Category: big-o, micro

const sortedData = rows.map((row) => {
  const cloned = Object.create(Object.getPrototypeOf(row))
  return Object.assign(cloned, row)
})

This allocates N row clones every time the sorted row model rebuilds. Array.prototype.sort is stable since ES2019, so the clones are unnecessary. Sort the original references with a tie-break index for stability or rely on engine stability.

After

const indexed = rows.map((row, index) => ({ row, index }))
indexed.sort((a, b) => {
  // existing comparator on a.row vs b.row, falling back to a.index - b.index
})
return indexed.map((x) => x.row)

Big-O: Drops O(n) heavy object allocations per sort.

Scale impact (heavy row clones replaced with lightweight {row, index} wrappers β€” dimension: rows sorted per sort pass):

Rows sorted Before (full row clones via Object.create + Object.assign) After ({row, index} wrappers) Saved
10 10 heavy clones 10 small wrappers ~10 wide β†’ narrow allocations
100 100 100 ~100
1,000 1,000 1,000 ~1,000
10,000 10,000 10,000 ~10,000

(Memory is the bigger win than count: each "heavy clone" copies all enumerable fields on a constructed Row, vs {row, index} which is 2 fields.)

Risk: Behavior depends on whether downstream code mutates the returned rows. The current clone is defensive against mutation. Verify nothing post-sort writes to row instances (the project uses prototype methods, so mutations should not occur).


50. column_getAutoSortFn slice(10) should be slice(0, 10) (bug) β€” Score: 7 (bug)

Status: [ ] not started Implementation note: (none)

Location: src/features/row-sorting/rowSortingFeature.utils.ts:79–114 Category: bug

const firstRows = column.table.getFilteredRowModel().flatRows.slice(10)

This takes rows from index 10 onwards, not the first 10. The intent (per the variable name firstRows) is the first 10 samples for auto-detection of sortFn. With ≀10 rows the array is empty β†’ fallback to alphanumeric sort regardless of actual data types.

After

const firstRows = column.table.getFilteredRowModel().flatRows.slice(0, 10)

Risk: Changes auto-detected sort fn for tables that have β‰₯11 rows. Existing tests may need adjustment if they relied on the broken behavior.


51. column_getIsSorted / column_getSortIndex .find per call β€” Score: 4

Status: [ ] not started Implementation note: (none)

Location: src/features/row-sorting/rowSortingFeature.utils.ts:388–418 Category: memoization

Both walk the sorting array; called for every visible sortable column on every render. Memoize per column with deps [sorting, column.id], or add table.getSortingById().

Scale impact (.find/.findIndex compares per render β€” dimension: visible sortable cols Γ— active sorts Γ— renders):

Cols (C) Active sorts (S) Renders (R) Before (β‰ˆ C Γ— S/2 Γ— R, Γ— 2 fns) After (memoized: ~0) Saved
10 1 10 100 0 100
50 3 100 15,000 0 15,000
100 5 1,000 500,000 0 500,000
500 10 10,000 50,000,000 0 50,000,000

Risk: None.


Stock function β€” sortFns.ts

52. compareAlphanumeric allocates 2 arrays per comparison β€” Score: 6

Status: [ ] not started Implementation note: (none)

Location: src/fns/sortFns.ts:154–200 Category: big-o, micro

aStr.split(re).filter(Boolean) runs O(n log n) times during a sort (once per comparison). Each call allocates two arrays. Drop the .filter(Boolean) by skipping empty pieces inline.

Before

const a = aStr.split(reSplitAlphaNumeric).filter(Boolean)
const b = bStr.split(reSplitAlphaNumeric).filter(Boolean)

After (sketch)

const a = aStr.split(reSplitAlphaNumeric)
const b = bStr.split(reSplitAlphaNumeric)
let ai = 0, bi = 0
while (ai < a.length || bi < b.length) {
  while (ai < a.length && !a[ai]) ai++
  while (bi < b.length && !b[bi]) bi++
  ...
}

Big-O: Halves array allocations per comparison; total saves O(N log N) intermediate arrays for sort of N rows.

Scale impact (intermediate .filter() arrays saved across a single sort β€” dimension: rows sorted, comparisons β‰ˆ N logβ‚‚ N, each saves 2 arrays):

Rows sorted (N) Comparisons (β‰ˆ N logβ‚‚ N) Before arrays (2 Γ— comps) After arrays (0) Saved arrays
10 ~33 ~66 0 ~66
100 ~664 ~1,328 0 ~1,328
1,000 ~9,966 ~19,932 0 ~19,932
10,000 ~132,877 ~265,754 0 ~265,754

Risk: Careful logic β€” empty-string skipping must mirror the .filter(Boolean) semantics exactly.


53. sortFn_datetime compares mixed Date / string / number β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/fns/sortFns.ts:99–114 Category: micro

Normalize Date β†’ getTime() once at the top, then compare numbers (or fall through to >/< for strings). Marginal but the comparator runs O(n log n) times.

Risk: None when only used for true datetime columns. Verify mixed-type columns don't rely on coercion.


Stock function β€” filterFns.ts

54. filterFn_between / filterFn_betweenInclusive allocate ['', undefined] per row β€” Score: 3

Status: [ ] not started Implementation note: (none)

Location: src/fns/filterFns.ts:210–216, 231–237 Category: micro

Hoist to a module constant.

Scale impact (array allocations saved per filter evaluation β€” dimension: rows evaluated per filter pass):

Rows evaluated Before (2 arrays/row) After (0) Saved arrays
10 20 0 20
100 200 0 200
1,000 2,000 0 2,000
10,000 20,000 0 20,000

Risk: None.


55. filterFn_arrHas and filterFn_arrIncludesAll use .some() β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/fns/filterFns.ts:287–296, 321–332 Category: micro

Replace with indexed for loops with early return. Removes closure-per-row.

Scale impact (closure allocations saved per filter evaluation β€” dimension: rows evaluated):

Rows evaluated Before (.some closures) After Saved closures
10 10 0 10
100 100 0 100
1,000 1,000 0 1,000
10,000 10,000 0 10,000

Risk: None.


56. filterFn_greaterThanOrEqualTo / lessThanOrEqualTo delegate via 2–3 function calls β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: src/fns/filterFns.ts:149–195 Category: micro, bundle-size (tradeoff)

Currently >= runs > then =. Could inline the comparison directly, at the cost of more code. Worth it only if profiling shows these in hot loops.

Risk: Bundle size grows slightly.


Stock function β€” aggregationFns.ts

57. aggregationFn_median full sorts for the median β€” Score: 3

Status: [~] partial Implementation note: The quickselect-vs-sort question (the headline of this finding) was not addressed β€” .sort() is still used because quickselect adds ~50 LOC of complexity that isn't justified without profiling evidence that median is hot for very large groups. The smaller win was captured though: fused .map((row) => row.getValue(columnId)) with the previous isNumberArray(values) validation pass into a single loop that extracts values into a preallocated array and bails immediately on the first non-number. Removes one full walk over the values array per call. The full-sort cost remains.

Location: src/fns/aggregationFns.ts:156–166 Category: big-o

Median requires only the middle element; quickselect is O(n) average vs .sort() O(n log n). Worth it only for large groups; skip otherwise to keep bundle slim.

Risk: Quickselect adds bytes and complexity. Recommend leaving as-is unless real-world data shows hot.


58. aggregationFn_unique + aggregationFn_uniqueCount rebuild Set twice β€” Score: 2

Status: [~] partial Implementation note: The cross-function memoization the original finding proposed (sharing a Set between aggregationFn_unique and aggregationFn_uniqueCount when both run on the same column in the same pass) was not implemented β€” the use case is rare enough that it's not worth the API plumbing. The per-call fusion was captured though: both functions now iterate leafRows directly into a Set instead of building an intermediate Array via .map and then constructing the Set from it. Saves one Array allocation of size leafRows.length per call.

Location: src/fns/aggregationFns.ts:172–193 Category: memoization

Only useful if both are called on the same column in the same aggregation pass. Not a common pattern; skip unless a consumer hits it.

Risk: None.


Cross-feature observations

59. table.getAllLeafColumns() is called many places per row-model build β€” Score: 4

Status: [ ] not started Implementation note: (none)

Location: filterFns, faceting, grouping, pinning, global filtering Category: memoization

getAllLeafColumns() is memoized at the table level, but its deps are sometimes computed inline (see #16 type defects). Verify the memo holds across the row-model rebuild lifecycle. If it doesn't, this is the most-leveraged optimization in the package.

Risk: Already memoized in coreColumnsFeature; just audit for accidental dep churn.


60. Prototype-builder boilerplate repeats 4Γ— β€” Score: 2

Status: [ ] not started Implementation note: (none)

Location: constructCell.ts, constructColumn.ts, constructHeader.ts, constructRow.ts Category: bundle-size

Each file has a getXyzPrototype(table) function with identical shape β€” if (!table._xyzPrototype) { table._xyzPrototype = { table }; for (...) feature.assignXyzPrototype?.(...) }. Could collapse to a shared utility keyed by prototypeKey/assignMethodName. Saves ~300–500 bytes gzipped at the cost of indirection at construction time only.

Risk: Slight loss of readability. Worth doing only if running close to a size-limit budget.


Suggested priority order

Anything β‰₯ 7:

# Title Score Category
12 centerColumns filter uses .includes β€” Set 8 big-o
35 row_getLeftVisibleCells uses .find over visible cells 8 big-o
37 passiveEventSupported cache bug 8 bug
38 table_getTotalSize & L/C/R variants unmemoized 8 memoization
42 row_getIsAllParentsExpanded checks wrong row 8 bug
1 memo() deps .some β†’ loop 7 micro
14 recurseHeadersForSpans spread + filter chain 7 big-o / micro
16 table_getLeafHeaders memoDeps call expensive fns 7 memoization
30 grouped row's existingGrouping.includes per cell 7 big-o
34 orderColumns grouping.includes β†’ Set 7 big-o
49 createSortedRowModel clones every row 7 big-o / micro
50 column_getAutoSortFn slice(10) should be slice(0, 10) 7 bug

Anything 5–6: a second wave of memoization gaps and partition-loop consolidations (#2, #9, #15, #21, #24, #27, #33, #36, #39, #40, #41, #52). All low-risk.

Anything ≀ 4: incremental polish; pursue if budget allows or when adjacent code is being touched.


Out-of-scope reminders

  • No changes to public API arg/return types.
  • No changes to the shape of table.getState().
  • New get* methods are allowed when they unlock big-O wins (e.g., table.getColumnFiltersById(), table.getSortingById()) β€” bundle every such addition against the size-limit budget.
  • Bundle-size wins ≀ 200 bytes gzipped per change should be ignored unless they ride along with another refactor.