diff --git a/examples/preact/kitchen-sink/src/main.tsx b/examples/preact/kitchen-sink/src/main.tsx
index 6de73e2255..f2cdd73d2d 100644
--- a/examples/preact/kitchen-sink/src/main.tsx
+++ b/examples/preact/kitchen-sink/src/main.tsx
@@ -488,7 +488,10 @@ function App() {
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
cell: ({ row, getValue }) => (
- {row.getCanExpand() ? (
+ {/* On a grouped row the grouped-cell wrapper already renders the
+ expander, so don't render a second one here. This expander is
+ only for expanding nested sub-rows. */}
+ {row.getIsGrouped() ? null : row.getCanExpand() ? (
`${row.firstName} ${row.lastName}`,
cell: ({ row, getValue }) => (
- {row.getCanExpand() ? (
+ {/* On a grouped row the grouped-cell wrapper already renders the
+ expander, so don't render a second one here. This expander is
+ only for expanding nested sub-rows. */}
+ {row.getIsGrouped() ? null : row.getCanExpand() ? (
`${row.firstName} ${row.lastName}`,
cell: ({ row, getValue }) => (
- {row.getCanExpand() ? (
+ {/* On a grouped row the grouped-cell wrapper already renders the
+ expander, so don't render a second one here. This expander is only
+ for expanding nested sub-rows. */}
+ {row.getIsGrouped() ? null : row.getCanExpand() ? (
row_getLeafRows(row),
+ memoDeps: (row) => [row.subRows],
},
row_getParentRow: {
fn: (row) => row_getParentRow(row),
diff --git a/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts b/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts
index c29ef322bf..544498c849 100644
--- a/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts
+++ b/packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts
@@ -1,6 +1,7 @@
import {
callMemoOrStaticFn,
cloneState,
+ copyInstancePropertiesWithoutMemos,
hasOwn,
makeObjectMap,
} from '../../utils'
@@ -738,7 +739,7 @@ export function selectRowsFn<
if (isSelected) {
// Preserve prototype chain so methods like getValue() remain accessible
const cloned = Object.create(Object.getPrototypeOf(row))
- Object.assign(cloned, row)
+ copyInstancePropertiesWithoutMemos(cloned, row)
cloned.subRows = newSubRows
result.push(cloned)
}
diff --git a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts
index 6c948ca349..c602993e34 100644
--- a/packages/table-core/src/features/row-sorting/createSortedRowModel.ts
+++ b/packages/table-core/src/features/row-sorting/createSortedRowModel.ts
@@ -1,4 +1,4 @@
-import { tableMemo } from '../../utils'
+import { copyInstancePropertiesWithoutMemos, tableMemo } from '../../utils'
import { table_autoResetPageIndex } from '../row-pagination/rowPaginationFeature.utils'
import { column_getCanSort, column_getSortFn } from './rowSortingFeature.utils'
import type { Column_Internal } from '../../types/Column'
@@ -56,6 +56,10 @@ function _createSortedRowModel<
return column ? column_getCanSort(column) : false
})
+ if (!availableSorting.length) {
+ return preSortedRowModel
+ }
+
const resolvedSorting: Array<{
id: string
desc?: boolean
@@ -132,32 +136,49 @@ function _createSortedRowModel<
return rowA.index - rowB.index
}
- const sortData = (rows: Array>) => {
+ const sortData = (
+ rows: Array>,
+ ): {
+ rows: Array>
+ changed: boolean
+ } => {
const sortedData = rows.slice()
sortedData.sort(compareRows)
+ let changed = false
// If there are sub-rows, sort them. Clone only rows that need mutation
// (i.e. have subRows) so we don't corrupt the source row model.
for (let i = 0; i < sortedData.length; i++) {
const row = sortedData[i]!
+ if (row !== rows[i]) {
+ changed = true
+ }
+
if (row.subRows.length) {
- // Preserve prototype chain so methods like getValue() remain accessible
- const cloned = Object.create(Object.getPrototypeOf(row))
- Object.assign(cloned, row)
- cloned.subRows = sortData(row.subRows)
- sortedData[i] = cloned
- sortedFlatRows.push(cloned)
+ const sortedSubRows = sortData(row.subRows)
+
+ if (sortedSubRows.changed) {
+ // Preserve prototype chain so methods like getValue() remain accessible
+ const cloned = Object.create(Object.getPrototypeOf(row))
+ copyInstancePropertiesWithoutMemos(cloned, row)
+ cloned.subRows = sortedSubRows.rows
+ sortedData[i] = cloned
+ sortedFlatRows.push(cloned)
+ changed = true
+ } else {
+ sortedFlatRows.push(row)
+ }
} else {
sortedFlatRows.push(row)
}
}
- return sortedData
+ return { rows: sortedData, changed }
}
return {
- rows: sortData(preSortedRowModel.rows),
+ rows: sortData(preSortedRowModel.rows).rows,
flatRows: sortedFlatRows,
rowsById: preSortedRowModel.rowsById,
}
diff --git a/packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts b/packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts
index f731f46bbb..46737ad00c 100644
--- a/packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts
+++ b/packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts
@@ -201,8 +201,9 @@ export function column_toggleSorting<
table_setSorting(column.table, (old) => {
// Find any existing sorting for this column
- const existingSorting = old.find((d) => d.id === column.id)
const existingIndex = old.findIndex((d) => d.id === column.id)
+ const existingSorting =
+ existingIndex === -1 ? undefined : old[existingIndex]
let newSorting: SortingState = []
diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts
index c5d642d2d2..2b843820b3 100755
--- a/packages/table-core/src/utils.ts
+++ b/packages/table-core/src/utils.ts
@@ -50,6 +50,27 @@ export function cloneState(value: T): T {
return value
}
+/**
+ * Copies prototype-instance own properties without carrying over lazy memo
+ * closures that were bound to the source instance.
+ */
+export function copyInstancePropertiesWithoutMemos<
+ TTarget extends Record,
+ TSource extends Record,
+>(target: TTarget, source: TSource): TTarget & TSource {
+ const keys = Object.keys(source)
+ const targetRecord = target as Record
+
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i]!
+ if (!key.startsWith('_memo_')) {
+ targetRecord[key] = source[key]
+ }
+ }
+
+ return target as TTarget & TSource
+}
+
/**
* Creates an object intended only for string-keyed dictionary lookups.
*
diff --git a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts
index ec89349417..051a71a284 100644
--- a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts
+++ b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts
@@ -175,6 +175,41 @@ describe('rowSelectionFeature', () => {
expect(clonedParent.getValue('id')).toBe(rowModel.rows[0]!.getValue('id'))
})
+ it('should not copy memoized row APIs from the original row to cloned parent rows', () => {
+ const data = generateTestData(3, 2)
+ const columns = generateColumnDefs(data)
+
+ const table = constructTable({
+ features,
+ enableRowSelection: true,
+ renderFallbackValue: '',
+ data,
+ getSubRows: (originalRow: Person, _idx: number) => originalRow.subRows,
+ initialState: {
+ rowSelection: {
+ '0': true,
+ '0.0': true,
+ },
+ },
+ columns,
+ })
+ const rowModel = table.getCoreRowModel()
+ const originalParent = rowModel.rows[0]!
+
+ // Warm a per-row memo on the original. Clones must not copy this closure,
+ // because it is bound to originalParent.
+ originalParent.getAllCells()
+
+ const result = RowSelectionUtils.selectRowsFn(
+ rowModel,
+ table as Table_Internal,
+ )
+
+ const clonedParent = result.rows[0]!
+ expect(clonedParent).not.toBe(originalParent)
+ expect(clonedParent.getAllCells()[0]!.row).toBe(clonedParent)
+ })
+
it('should return an empty list if no rows are selected', () => {
const data = generateTestData(5)
const columns = generateColumnDefs(data)
diff --git a/packages/table-core/tests/unit/core/rows/constructRow.test.ts b/packages/table-core/tests/unit/core/rows/constructRow.test.ts
index d83c60bf9d..d74b5d423e 100644
--- a/packages/table-core/tests/unit/core/rows/constructRow.test.ts
+++ b/packages/table-core/tests/unit/core/rows/constructRow.test.ts
@@ -60,4 +60,32 @@ describe('constructRow', () => {
expect(row.subRows).toBe(subRows)
expect(row.parentId).toBe(parentId)
})
+
+ it('memoizes getLeafRows until subRows changes', () => {
+ const table = {
+ _features: { coreRowsFeature },
+ options: {},
+ } as unknown as Table_Internal
+
+ const leafA = constructRow(table, 'leaf-a', { firstName: 'A' }, 0, 1, [])
+ const leafB = constructRow(table, 'leaf-b', { firstName: 'B' }, 1, 1, [])
+ const parent = constructRow(
+ table,
+ 'parent',
+ { firstName: 'Parent' },
+ 0,
+ 0,
+ [leafA, leafB],
+ )
+
+ const firstLeafRows = parent.getLeafRows()
+ expect(parent.getLeafRows()).toBe(firstLeafRows)
+ expect(firstLeafRows).toEqual([leafA, leafB])
+
+ parent.subRows = [leafB, leafA]
+
+ const nextLeafRows = parent.getLeafRows()
+ expect(nextLeafRows).not.toBe(firstLeafRows)
+ expect(nextLeafRows).toEqual([leafB, leafA])
+ })
})
diff --git a/packages/table-core/tests/unit/features/row-sorting/createSortedRowModel.test.ts b/packages/table-core/tests/unit/features/row-sorting/createSortedRowModel.test.ts
index e3ee25aad8..32521906db 100644
--- a/packages/table-core/tests/unit/features/row-sorting/createSortedRowModel.test.ts
+++ b/packages/table-core/tests/unit/features/row-sorting/createSortedRowModel.test.ts
@@ -12,6 +12,7 @@ import type { ColumnDef } from '../../../../src'
type Person = {
firstName: string
age: number
+ subRows?: Array
}
const features = tableFeatures({
@@ -31,15 +32,19 @@ const data: Array = [
{ firstName: 'alice', age: 30 },
]
-function makeTable(sorting: Array<{ id: string; desc: boolean }>) {
+function makeTable(
+ sorting: Array<{ id: string; desc: boolean }>,
+ tableData: Array = data,
+) {
return constructTable({
- data,
+ data: tableData,
columns,
features: {
...features,
coreReactivityFeature: storeReactivityBindings(),
},
initialState: { sorting },
+ getSubRows: (row) => row.subRows,
})
}
@@ -58,6 +63,12 @@ describe('createSortedRowModel', () => {
).toEqual(['amy', 'bob', 'alice'])
})
+ it('returns the pre-sorted row model when only unknown columns are sorted', () => {
+ const table = makeTable([{ id: 'thisColumnDoesNotExist', desc: false }])
+
+ expect(table.getSortedRowModel()).toBe(table.getPreSortedRowModel())
+ })
+
it('still sorts by the remaining known columns when one sort entry is unknown', () => {
const table = makeTable([
{ id: 'thisColumnDoesNotExist', desc: false },
@@ -68,4 +79,107 @@ describe('createSortedRowModel', () => {
table.getSortedRowModel().rows.map((row) => row.original.firstName),
).toEqual(['amy', 'alice', 'bob'])
})
+
+ it('keeps branch row identity when sorted subRows are unchanged', () => {
+ const table = makeTable(
+ [{ id: 'age', desc: false }],
+ [
+ {
+ firstName: 'parent',
+ age: 20,
+ subRows: [
+ { firstName: 'child-a', age: 10 },
+ { firstName: 'child-b', age: 15 },
+ ],
+ },
+ ],
+ )
+
+ const preSortedRow = table.getPreSortedRowModel().rows[0]!
+ const sortedRow = table.getSortedRowModel().rows[0]!
+
+ expect(sortedRow).toBe(preSortedRow)
+ expect(sortedRow.subRows[0]).toBe(preSortedRow.subRows[0])
+ expect(sortedRow.subRows[1]).toBe(preSortedRow.subRows[1])
+ })
+
+ it('clones branch rows when sorted subRows change', () => {
+ const table = makeTable(
+ [{ id: 'age', desc: false }],
+ [
+ {
+ firstName: 'parent',
+ age: 20,
+ subRows: [
+ { firstName: 'child-b', age: 15 },
+ { firstName: 'child-a', age: 10 },
+ ],
+ },
+ ],
+ )
+
+ const preSortedRow = table.getPreSortedRowModel().rows[0]!
+ const sortedRow = table.getSortedRowModel().rows[0]!
+
+ expect(sortedRow).not.toBe(preSortedRow)
+ expect(sortedRow.subRows.map((row) => row.original.firstName)).toEqual([
+ 'child-a',
+ 'child-b',
+ ])
+ })
+
+ it('does not copy memoized row APIs from the original row to sorted branch clones', () => {
+ const table = makeTable(
+ [{ id: 'age', desc: false }],
+ [
+ {
+ firstName: 'parent',
+ age: 20,
+ subRows: [
+ { firstName: 'child-b', age: 15 },
+ { firstName: 'child-a', age: 10 },
+ ],
+ },
+ ],
+ )
+ const preSortedRow = table.getPreSortedRowModel().rows[0]!
+
+ // Warm a per-row memo on the original. The sorted clone must build its own
+ // memo so returned cells point back to the clone, not the source row.
+ preSortedRow.getAllCells()
+
+ const sortedRow = table.getSortedRowModel().rows[0]!
+
+ expect(sortedRow).not.toBe(preSortedRow)
+ expect(sortedRow.getAllCells()[0]!.row).toBe(sortedRow)
+ })
+
+ it('builds leaf-row memos for sorted branch clones from the clone subRows', () => {
+ const table = makeTable(
+ [{ id: 'age', desc: false }],
+ [
+ {
+ firstName: 'parent',
+ age: 20,
+ subRows: [
+ { firstName: 'child-b', age: 15 },
+ { firstName: 'child-a', age: 10 },
+ ],
+ },
+ ],
+ )
+ const preSortedRow = table.getPreSortedRowModel().rows[0]!
+
+ expect(
+ preSortedRow.getLeafRows().map((row) => row.original.firstName),
+ ).toEqual(['child-b', 'child-a'])
+
+ const sortedRow = table.getSortedRowModel().rows[0]!
+
+ expect(sortedRow).not.toBe(preSortedRow)
+ expect(sortedRow.getLeafRows()).toBe(sortedRow.getLeafRows())
+ expect(
+ sortedRow.getLeafRows().map((row) => row.original.firstName),
+ ).toEqual(['child-a', 'child-b'])
+ })
})
diff --git a/perf-done.md b/perf-done.md
index 35a9a4fb81..441a08fde2 100644
--- a/perf-done.md
+++ b/perf-done.md
@@ -7,8 +7,8 @@ Entries are sorted by adjusted effectiveness score descending.
## Counts
-- **Entries:** 49
-- **Source findings:** 47
+- **Entries:** 54
+- **Source findings:** 52
- **Cross-cutting sweeps:** 2
- 2026-07-03: #102 (C9) moved here from perf-todo.md after the row-model benchmark confirmed and the fix landed.
@@ -2253,6 +2253,48 @@ Replace with indexed `for` loops with early `return`. Removes closure-per-row.
---
+## 103. C10: Object.assign row clones copy `_memo_*` closures bound to the original row (bug) — Score: 4 (bug)
+
+**Status:** `[x]` done
+**Implementation note:** Added `copyInstancePropertiesWithoutMemos` and routed both row clone sites through it: `selectRowsFn` selected parent clones and `createSortedRowModel` branch-row clones. The helper copies enumerable own properties except `_memo_*`, so cloned rows keep ordinary caches like `_valuesCache` but rebuild memoized prototype APIs against the clone on first use. Added regressions that warm `getAllCells()` on the source row before cloning, then assert cloned rows return cells whose `cell.row` is the clone, not the source. This unblocks #131 (D14 row_getLeafRows memoization).
+
+**Location:** `packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts:736–741`; same pattern at `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:135–138`; interacts with `packages/table-core/src/utils.ts:430–445` (assignPrototypeAPIs)
+**Category:** `bug`
+
+`assignPrototypeAPIs` stores lazily created memos as own enumerable `_memo_` props whose closures captured `self = `. `Object.assign(cloned, row)` copies them, so any memoized API called on a clone executes against the ORIGINAL row: `cloned.subRows = newSubRows` is invisible to every copied memoized method (e.g. `cloned.getVisibleCells()`, `cloned.getIsSomeSelected()`), and returned cells have `cell.row === original`. Verified end-to-end. This was a pre-existing hazard for ALL per-instance memoized row APIs on any cloned row, and it blocked D14 (memoizing `row_getLeafRows`) as filed: a clone's copied `getLeafRows` memo would keep returning the flatten of the original (e.g. unsorted) subRows.
+
+**Cross-refs / gating:** Unblocks #131 (D14: memoizing row_getLeafRows).
+
+**Before**
+
+```ts
+// Preserve prototype chain so methods like getValue() remain accessible
+const cloned = Object.create(Object.getPrototypeOf(row))
+Object.assign(cloned, row)
+cloned.subRows = newSubRows
+```
+
+**After**
+
+```ts
+const cloned = Object.create(Object.getPrototypeOf(row))
+const keys = Object.keys(row)
+for (let i = 0; i < keys.length; i++) {
+ const key = keys[i]!
+ if (!key.startsWith('_memo_')) {
+ cloned[key] = (row as Record)[key]
+ }
+}
+cloned.subRows = newSubRows
+```
+
+Apply to BOTH clone sites (rowSelectionFeature.utils.ts and createSortedRowModel.ts).
+
+**Risk:** Clones lose warm caches (first call re-memoizes against the clone), which is the correct behavior; `_valuesCache` stays shared by reference (values identical, fine). Behavior delta to flag: post-fix, clone getters answer for the clone's own (e.g. filtered) subRows; that is the sane semantics but observable (e.g. `getIsAllSubRowsSelected` may flip from `'some'`-derived to `'all'`-derived on selected-model rows).
+**Verification:** CONFIRMED-BUG, severity MODERATE; scope extended to createSortedRowModel; D14 unblocked by this fix.
+
+---
+
## 104. E10: column_getAutoFilterFn's Array.isArray branch is unreachable (bug) — Score: 4 (bug)
**Status:** `[x]` done
@@ -2282,6 +2324,25 @@ if (Array.isArray(value)) {
---
+## 116. B10: createSortedRowModel: no availableSorting.length guard; branch rows cloned even when subRows unchanged — Score: 4
+
+**Status:** `[x]` done
+**Implementation note:** Added the `availableSorting.length` guard after missing/unsortable sorting entries are filtered, so a sorting state containing only unavailable ids returns `preSortedRowModel` directly. Also changed recursive sorting to return a `changed` flag computed during the existing post-sort `flatRows` walk; branch rows are cloned only when their sorted `subRows` changed order or contain a cloned descendant. Added focused row-sorting tests for unknown-only sorting returning the pre-sorted model, unchanged branch-row identity preservation, and clone-on-changed-subRows behavior.
+
+**Location:** `createSortedRowModel.ts:47–58, 130–147`
+**Category:** `micro`
+
+(a) No `availableSorting.length` guard: when all sorting ids miss, the code still pays O(R log R) no-op comparator calls + slice + flatRows rebuild + branch-row clones; (b) every branch row is cloned even when the sorted subRows are element-wise identical.
+
+**Fix:** `if (!availableSorting.length) return preSortedRowModel` (comparator provably order-preserving; edge flatRows order becomes parent-first, consistent with the existing empty-sorting branch) + identity-scan-before-clone (cloned descendants replace elements, so element-wise compare catches subtree changes).
+
+**Big-O:** O(R log R) no-op comparator calls (plus slice + flatRows rebuild + branch-row clones) avoided entirely when no sorting ids match.
+
+**Risk:** None noted; comparator is provably order-preserving, and the identity-scan-before-clone catches subtree changes via element-wise compare since cloned descendants replace elements.
+**Verification:** Verified (2026-07-01 audit).
+
+---
+
## 133. E9: aggregationFns reduce/forEach closures; dead null checks in min/max/extent — Score: 4
**Status:** `[x]` done
@@ -2299,6 +2360,23 @@ if (Array.isArray(value)) {
---
+## 142. NR1: Pre-existing crash on unknown sorting column id in createSortedRowModel (bug) — Score: 4 (bug)
+
+**Status:** `[x]` done
+**Implementation note:** Verified this was already fixed in current source by a recent row-sorting change: `availableSorting` now fetches the column and calls `column_getCanSort(column)` only when the column exists. Existing row-sorting unit coverage already asserts that an unknown sorting id does not throw, preserves pre-sorted order when all ids are unknown, and still sorts by remaining known columns. The #116 implementation added the stronger unknown-only fast path assertion that `getSortedRowModel()` returns `getPreSortedRowModel()` by reference.
+
+**Location:** `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:54–57` (dereference at `packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts:363–368`)
+**Category:** `bug`
+
+A `sorting` entry whose id matches no column makes `createSortedRowModel.ts:54-57` pass `undefined` into `column_getCanSort`, which dereferences `column.columnDef` (`rowSortingFeature.utils.ts:363-368`) → TypeError.
+
+**Fix:** Guard the `column_getCanSort` call by first checking whether `table.getColumn(sort.id)` returned a column. Unknown sorting ids are filtered out before resolving sort metadata or invoking the comparator.
+
+**Risk:** Crash (TypeError) reachable whenever a `sorting` state entry's id matches no column, e.g. after a column is removed while its sort state persists. Correctness bug, not a perf regression; independent of #88 (B8).
+**Verification:** CONFIRMED-BUG (2026-07-01 audit).
+
+---
+
## Score 3
## 53. `sortFn_datetime` compares mixed Date / string / number — Score: 3
@@ -2315,6 +2393,23 @@ Normalize `Date` → `getTime()` once at the top, then compare numbers (or fall
---
+## 131. D14: row_getLeafRows unmemoized — Score: 3
+
+**Status:** `[x]` done
+**Implementation note:** Added a per-row memo for `row_getLeafRows` with deps `[row.subRows]`. The memo now reuses the flattened descendant array across repeated calls until the row's `subRows` array reference changes. Added core-row coverage for stable memo identity plus invalidation after `subRows` reassignment, and sorted-row coverage that warms the source row's `getLeafRows()` before cloning and verifies the sorted clone computes leaf rows from its own sorted `subRows`. This relies on #103 so `_memo_getLeafRows` is not copied from source rows to clones.
+
+**Location:** `packages/table-core/src/core/rows/coreRowsFeature.ts:30–32` (`row_getLeafRows` unmemoized)
+**Category:** `micro`, `memoization`
+
+Re-flattens the whole subtree on every call; the ideal fix is a per-instance memo with deps `[row.subRows]` (all `subRows` writes are whole-array reassignments, verified).
+
+**Fix:** Add a per-instance memo with deps `[row.subRows]`. The former C10/#103 blocker has landed; verify the new memo is not copied across cloned rows before shipping.
+
+**Risk:** Must preserve whole-array `subRows` reassignment semantics and verify cloned rows build their own `_memo_getLeafRows` after #103.
+**Verification:** Verified (2026-07-01 audit); unblocked by #103 (C10 clone fix).
+
+---
+
## 18. `table_getRow` always calls `getCoreRowModel()` — Score: 3
**Status:** `[x]` done
@@ -2518,3 +2613,20 @@ Replace with an indexed loop and early exit. Low frequency; only used during sor
**Risk:** None.
---
+
+## 135. E12: column_toggleSorting scans the tiny sorting array twice — Score: 1
+
+**Status:** `[x]` done
+**Implementation note:** Replaced `old.find(...)` + `old.findIndex(...)` with a single `findIndex` and derived `existingSorting` from `old[existingIndex]`. This keeps the tiny sorting array as an array and does not introduce a memo/map structure; it only removes the second scan in the click handler.
+
+**Location:** `packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts:211–214` (`column_toggleSorting`)
+**Category:** `micro`
+
+`old.find` + `old.findIndex` scan the tiny sorting array twice.
+
+**Fix:** `findIndex` once, index into `old`. Per header click; negligible.
+
+**Risk:** None noted; per-interaction-click cost on an already-tiny (2-3 entry) array, consistent with the doctrine that these arrays don't warrant hashing.
+**Verification:** Verified (2026-07-01 audit).
+
+---
diff --git a/perf-skipped.md b/perf-skipped.md
index 78ca5714b4..ec22754523 100644
--- a/perf-skipped.md
+++ b/perf-skipped.md
@@ -7,8 +7,8 @@ Entries are sorted by adjusted effectiveness score descending.
## Counts
-- **Entries:** 10
-- **Source findings:** 10
+- **Entries:** 12
+- **Source findings:** 12
- **Cross-cutting sweeps:** 0
## Score 1
@@ -314,6 +314,79 @@ Both walk the `sorting` array; called for every visible sortable column on every
## Score 0
+## 108. B22: Row-model memoDeps systematically omit options they read (observation) — Score: 4
+
+**Status:** `[-]` skipped
+
+**Adjusted score:** 0
+**Original score:** 4
+**Score note:** Observation is too broad to implement safely as filed. The sorting-specific portion would require adding function-valued sort/column-definition inputs to memo deps, which the entry itself correctly rejects because adapter option objects/functions often change identity. Remaining primitive-flag cases should be handled as concrete scoped entries rather than this umbrella observation.
+**Implementation note:** Skipped as an implementation ticket. `createSortedRowModel` has no primitive table-option dep to add; it reads sorting state plus column-defined/function-valued sort metadata. `createExpandedRowModel` already includes `paginateExpandedRows` in deps, and other primitive flag work is better tracked by focused entries with explicit semantics/tests. Do not add function-valued options such as `sortFns`, aggregation defs, `getSubRows`, or `getIsRowExpanded` to row-model deps without a separate design for stable option identity.
+
+**Location:** `packages/table-core/src/features/column-filtering/createFilteredRowModel.ts`, `packages/table-core/src/features/row-sorting/createSortedRowModel.ts`, `packages/table-core/src/features/column-grouping/createGroupedRowModel.ts`, `packages/table-core/src/features/row-expanding/createExpandedRowModel.ts`, `packages/table-core/src/core/row-models/createCoreRowModel.ts`
+**Category:** `observation`, `memoization`
+
+createFilteredRowModel/createSortedRowModel/createGroupedRowModel/createExpandedRowModel/createCoreRowModel all read options (`filterFromLeafRows`, `maxLeafRowFilterDepth`, sort/aggregation defs, `getIsRowExpanded`, ...) that are not deps; runtime option changes serve stale models until an unrelated state change. This is a deliberate single-slot tradeoff for function-valued options (adding them would thrash memos when adapters rebuild options objects); the actionable scope is PRIMITIVE-FLAG additions only (e.g. `filterFromLeafRows`, `maxLeafRowFilterDepth`, `paginateExpandedRows`, the last already fixed in B21's micro entry).
+
+**Fix:** Add the primitive-flag options as deps only; leave the function-valued options (sort/aggregation defs, `getIsRowExpanded`) out of the dep tuples.
+
+**Risk:** Adding the function-valued options as deps would thrash memos when adapters rebuild options objects; the fix must stay scoped to primitive flags only.
+**Verification:** CONFIRMED (observation; primitive-only scope is the right boundary).
+
+---
+
+## 78. B9+E14: Sort-key precomputation (decorate-sort-undecorate), built-in sortFns only; design-level — Score: 6
+
+**Status:** `[-]` skipped
+
+**Adjusted score:** 0
+**Original score:** 6
+**Score note:** Not actionable as filed: the built-ins-only fast path would require `createSortedRowModel` to import or identity-match built-in sortFns, coupling the row model to optional sortFn code and breaking tree-shaking. A future design would need opt-in key-extractor metadata on the registered sortFn/registry, so built-ins and custom sortFns flow through the same external contract.
+**Implementation note:** Rejected after implementation spike. Importing built-in sortFns or their private comparator helpers into `createSortedRowModel` makes the row model retain built-in sortFn code even when users do not register those functions. That violates the package's feature/registry boundary. Leave the current comparator path intact. If revisited, design a tree-shake-safe API where sortFns can optionally expose a key extractor/comparator over extracted keys without the row model knowing which implementation is built in.
+
+**Location:** `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:88–112` (with `rowSortingFeature.ts:39` default `sortUndefined: 1`; `fns/sortFns.ts:18–30, 58–70` re-derive `toString(value).toLowerCase()` per comparison)
+**Category:** `big-o`
+
+Hot path: Per state-change: O(R log R) comparator invocations per sort. `row_getValue` caches in `_valuesCache`, so the accessor runs once per row; the remaining per-comparison cost with the default `sortUndefined: 1` is 2 getValue calls here plus 2 more inside the sortFn = 4 hashed lookups per comparison per column (~7M at R=100k). Worse, the text/alphanumeric sortFns rebuild `toString(value).toLowerCase()` (and, pre-E2, a regex split) on every comparison: O(R log R) string allocations that `_valuesCache` cannot amortize (~3.4M `toLowerCase` allocations per 100k-row sort). A decorate-sort-undecorate pass (one O(R) key extraction into a parallel array, index sort, then materialize) eliminates all per-comparison derivation.
+
+**Before**
+
+```ts
+if (sortUndefined) {
+ const aValue = rowA.getValue(sortEntry.id)
+ const bValue = rowB.getValue(sortEntry.id)
+
+ const aUndefined = aValue === undefined
+ const bUndefined = bValue === undefined
+ // ...
+}
+
+if (sortInt === 0) {
+ sortInt = columnInfo.sortFn(rowA, rowB, sortEntry.id)
+}
+```
+
+**After**
+
+(sketch; flat, single-column fast path, falling back to the current path for multi-column/hierarchy/custom fns)
+
+```ts
+// one O(R) pass
+const keys = new Array(sortedData.length)
+for (let i = 0; i < sortedData.length; i++) {
+ keys[i] = sortedData[i]!.getValue(entry.id)
+}
+```
+
+then a comparator over precomputed keys via an index sort, with `sortUndefined` handled on the extracted keys and stability via index pairing.
+
+**Big-O:** Per-comparison work drops from 4 hashed lookups (+ string realloc for text sorts) to 1 array read. At R=100k text sort: ~1.7M `toLowerCase` allocations → 100k; sort time typically 2-5× faster.
+
+**Risk:** Highest-risk row-model finding; design-level, not a drop-in edit. The fast path must exactly replicate `sortUndefined`/`invertSorting`/tiebreak-by-`row.index` semantics. **Built-in sortFns only** (E14's constraint): user-supplied sortFns can read arbitrary row state and must keep the current path. Sequencing: land E2 first (kills per-comparison split allocations at low risk); B9/E14 kills key re-derivation later.
+**Verification:** CONFIRMED (design-level); B9 and E14 merged (same Schwartzian direction; B9's formulation is the actionable one, E14 contributes the built-ins-only constraint).
+
+---
+
## 2. `assignPrototypeAPIs` allocates wrapper closures on every call — Score: 6
**Status:** `[-]` skipped
diff --git a/perf-todo.md b/perf-todo.md
index 8fcafbaa82..dd76a7cab5 100644
--- a/perf-todo.md
+++ b/perf-todo.md
@@ -7,8 +7,8 @@ Entries are sorted by adjusted effectiveness score descending.
## Counts
-- **Entries:** 90
-- **Source findings:** 90
+- **Entries:** 83
+- **Source findings:** 83
- **Cross-cutting sweeps:** 0
- 2026-07-03: #102 (C9) completed and moved to perf-done.md.
@@ -742,54 +742,6 @@ for (let i = 0; i < flatRows.length; i++) {
---
-## 78. B9+E14: Sort-key precomputation (decorate-sort-undecorate), built-in sortFns only; design-level — Score: 6
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:88–112` (with `rowSortingFeature.ts:39` default `sortUndefined: 1`; `fns/sortFns.ts:18–30, 58–70` re-derive `toString(value).toLowerCase()` per comparison)
-**Category:** `big-o`
-
-Hot path: Per state-change: O(R log R) comparator invocations per sort. `row_getValue` caches in `_valuesCache`, so the accessor runs once per row; the remaining per-comparison cost with the default `sortUndefined: 1` is 2 getValue calls here plus 2 more inside the sortFn = 4 hashed lookups per comparison per column (~7M at R=100k). Worse, the text/alphanumeric sortFns rebuild `toString(value).toLowerCase()` (and, pre-E2, a regex split) on every comparison: O(R log R) string allocations that `_valuesCache` cannot amortize (~3.4M `toLowerCase` allocations per 100k-row sort). A decorate-sort-undecorate pass (one O(R) key extraction into a parallel array, index sort, then materialize) eliminates all per-comparison derivation.
-
-**Before**
-
-```ts
-if (sortUndefined) {
- const aValue = rowA.getValue(sortEntry.id)
- const bValue = rowB.getValue(sortEntry.id)
-
- const aUndefined = aValue === undefined
- const bUndefined = bValue === undefined
- // ...
-}
-
-if (sortInt === 0) {
- sortInt = columnInfo.sortFn(rowA, rowB, sortEntry.id)
-}
-```
-
-**After**
-
-(sketch; flat, single-column fast path, falling back to the current path for multi-column/hierarchy/custom fns)
-
-```ts
-// one O(R) pass
-const keys = new Array(sortedData.length)
-for (let i = 0; i < sortedData.length; i++) {
- keys[i] = sortedData[i]!.getValue(entry.id)
-}
-```
-
-then a comparator over precomputed keys via an index sort, with `sortUndefined` handled on the extracted keys and stability via index pairing.
-
-**Big-O:** Per-comparison work drops from 4 hashed lookups (+ string realloc for text sorts) to 1 array read. At R=100k text sort: ~1.7M `toLowerCase` allocations → 100k; sort time typically 2-5× faster.
-
-**Risk:** Highest-risk row-model finding; design-level, not a drop-in edit. The fast path must exactly replicate `sortUndefined`/`invertSorting`/tiebreak-by-`row.index` semantics. **Built-in sortFns only** (E14's constraint): user-supplied sortFns can read arbitrary row state and must keep the current path. Sequencing: land E2 first (kills per-comparison split allocations at low risk); B9/E14 kills key re-derivation later.
-**Verification:** CONFIRMED (design-level); B9 and E14 merged (same Schwartzian direction; B9's formulation is the actionable one, E14 contributes the built-ins-only constraint).
-
----
-
## 79. B12: leafRows eagerly flattened for every group at depth ≥ 1 even if never aggregated — Score: 6
**Status:** `[ ]` not started
@@ -2056,48 +2008,6 @@ Trivial `for` loop replacement of `.reduce`.
---
-## 103. C10: Object.assign row clones copy `_memo_*` closures bound to the original row (bug) — Score: 4 (bug)
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts:736–741`; same pattern at `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:135–138`; interacts with `packages/table-core/src/utils.ts:430–445` (assignPrototypeAPIs)
-**Category:** `bug`
-
-`assignPrototypeAPIs` stores lazily created memos as own enumerable `_memo_` props whose closures captured `self = `. `Object.assign(cloned, row)` copies them, so any memoized API called on a clone executes against the ORIGINAL row: `cloned.subRows = newSubRows` is invisible to every copied memoized method (e.g. `cloned.getVisibleCells()`, `cloned.getIsSomeSelected()`), and returned cells have `cell.row === original`. Verified end-to-end. This is a pre-existing hazard for ALL per-instance memoized row APIs on any cloned row, and it BLOCKS D14 (memoizing `row_getLeafRows`) as filed: a clone's copied `getLeafRows` memo would keep returning the flatten of the original (e.g. unsorted) subRows.
-
-**Cross-refs / gating:** Blocks #131 (D14: memoizing row_getLeafRows) until this clone fix lands.
-
-**Before**
-
-```ts
-// Preserve prototype chain so methods like getValue() remain accessible
-const cloned = Object.create(Object.getPrototypeOf(row))
-Object.assign(cloned, row)
-cloned.subRows = newSubRows
-```
-
-**After**
-
-```ts
-const cloned = Object.create(Object.getPrototypeOf(row))
-const keys = Object.keys(row)
-for (let i = 0; i < keys.length; i++) {
- const key = keys[i]!
- if (!key.startsWith('_memo_')) {
- cloned[key] = (row as Record)[key]
- }
-}
-cloned.subRows = newSubRows
-```
-
-Apply to BOTH clone sites (rowSelectionFeature.utils.ts and createSortedRowModel.ts).
-
-**Risk:** Clones lose warm caches (first call re-memoizes against the clone), which is the correct behavior; `_valuesCache` stays shared by reference (values identical, fine). Behavior delta to flag: post-fix, clone getters answer for the clone's own (e.g. filtered) subRows; that is the sane semantics but observable (e.g. `getIsAllSubRowsSelected` may flip from `'some'`-derived to `'all'`-derived on selected-model rows).
-**Verification:** CONFIRMED-BUG, severity MODERATE; scope extended to createSortedRowModel; D14 blocked until this lands.
-
----
-
## 105. A10: contextDocument operator precedence: parameter always ignored; SSR ReferenceError path (bug) — Score: 4 (bug)
**Status:** `[ ]` not started
@@ -2126,23 +2036,6 @@ const contextDocument =
---
-## 108. B22: Row-model memoDeps systematically omit options they read (observation) — Score: 4
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/features/column-filtering/createFilteredRowModel.ts`, `packages/table-core/src/features/row-sorting/createSortedRowModel.ts`, `packages/table-core/src/features/column-grouping/createGroupedRowModel.ts`, `packages/table-core/src/features/row-expanding/createExpandedRowModel.ts`, `packages/table-core/src/core/row-models/createCoreRowModel.ts`
-**Category:** `observation`, `memoization`
-
-createFilteredRowModel/createSortedRowModel/createGroupedRowModel/createExpandedRowModel/createCoreRowModel all read options (`filterFromLeafRows`, `maxLeafRowFilterDepth`, sort/aggregation defs, `getIsRowExpanded`, ...) that are not deps; runtime option changes serve stale models until an unrelated state change. This is a deliberate single-slot tradeoff for function-valued options (adding them would thrash memos when adapters rebuild options objects); the actionable scope is PRIMITIVE-FLAG additions only (e.g. `filterFromLeafRows`, `maxLeafRowFilterDepth`, `paginateExpandedRows`, the last already fixed in B21's micro entry).
-
-**Fix:** Add the primitive-flag options as deps only; leave the function-valued options (sort/aggregation defs, `getIsRowExpanded`) out of the dep tuples.
-
-**Risk:** Adding the function-valued options as deps would thrash memos when adapters rebuild options objects; the fix must stay scoped to primitive flags only.
-**Verification:** CONFIRMED (observation; primitive-only scope is the right boundary).
-
----
-
## 113. A7: table_getTotalSize reduce closure calls the static header_getSize, bypassing per-header memos — Score: 4
**Status:** `[ ]` not started
@@ -2179,25 +2072,6 @@ The `.reduce` closure calls the STATIC `header_getSize` (full subtree recursion,
---
-## 116. B10: createSortedRowModel: no availableSorting.length guard; branch rows cloned even when subRows unchanged — Score: 4
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `createSortedRowModel.ts:47–58, 130–147`
-**Category:** `micro`
-
-(a) No `availableSorting.length` guard: when all sorting ids miss, the code still pays O(R log R) no-op comparator calls + slice + flatRows rebuild + branch-row clones; (b) every branch row is cloned even when the sorted subRows are element-wise identical.
-
-**Fix:** `if (!availableSorting.length) return preSortedRowModel` (comparator provably order-preserving; edge flatRows order becomes parent-first, consistent with the existing empty-sorting branch) + identity-scan-before-clone (cloned descendants replace elements, so element-wise compare catches subtree changes).
-
-**Big-O:** O(R log R) no-op comparator calls (plus slice + flatRows rebuild + branch-row clones) avoided entirely when no sorting ids match.
-
-**Risk:** None noted; comparator is provably order-preserving, and the identity-scan-before-clone catches subtree changes via element-wise compare since cloned descendants replace elements.
-**Verification:** Verified (2026-07-01 audit).
-
----
-
## 118. B14: createExpandedRowModel Object.keys(expanded ?? {}) materializes an R-sized array to test emptiness — Score: 4
**Status:** `[ ]` not started
@@ -2337,23 +2211,6 @@ Inline selectors change identity per render, so `useSyncExternalStoreWithSelecto
---
-## 142. NR1: Pre-existing crash on unknown sorting column id in createSortedRowModel (bug) — Score: 4 (bug)
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/features/row-sorting/createSortedRowModel.ts:54–57` (dereference at `packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts:363–368`)
-**Category:** `bug`
-
-A `sorting` entry whose id matches no column makes `createSortedRowModel.ts:54-57` pass `undefined` into `column_getCanSort`, which dereferences `column.columnDef` (`rowSortingFeature.utils.ts:363-368`) → TypeError.
-
-**Fix:** Not proposed in this audit; deserves an independent correctness ticket to guard against an unknown sorting column id before the `column.columnDef` dereference. #88 (B8)'s fusion incidentally removes the double fetch on this path but does not fix the crash.
-
-**Risk:** Crash (TypeError) reachable whenever a `sorting` state entry's id matches no column, e.g. after a column is removed while its sort state persists. Correctness bug, not a perf regression; independent of #88 (B8).
-**Verification:** CONFIRMED-BUG (2026-07-01 audit).
-
----
-
## 143. NR2: Solid mergeProps getter-chain accumulation on controlled-state syncs — Score: 4
**Status:** `[ ]` not started
@@ -2709,23 +2566,6 @@ Returns N×D short-lived `{colSpan, rowSpan}` objects plus per-parent arrays tha
---
-## 131. D14: row_getLeafRows unmemoized (BLOCKED on #103) — Score: 3
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/core/rows/coreRowsFeature.ts:30–32` (`row_getLeafRows` unmemoized)
-**Category:** `micro`, `memoization`
-
-Re-flattens the whole subtree on every call; the ideal fix is a per-instance memo with deps `[row.subRows]` (all `subRows` writes are whole-array reassignments, verified).
-
-**Fix:** Add a per-instance memo with deps `[row.subRows]`. **BLOCKED until the C10 clone fix (#103) lands:** `Object.assign` row clones would copy the `_memo_getLeafRows` closure bound to the original row and serve stale (e.g. unsorted) flattens on clones. Do not land this memo before #103 ships.
-
-**Risk:** High if sequenced wrong: landing this memo before #103's clone fix would silently serve stale flattens from cloned rows. Must be sequenced strictly after #103.
-**Verification:** Verified (2026-07-01 audit); blocked pending #103 (C10 clone fix).
-
----
-
## 132. D15: cloneState uses Object.defineProperty per key — Score: 3
**Status:** `[ ]` not started
@@ -3060,20 +2900,3 @@ Guard against the `undefined` return from `feature.getDefaultTableOptions?.()`.
**Risk:** None.
---
-
-## 135. E12: column_toggleSorting scans the tiny sorting array twice — Score: 1
-
-**Status:** `[ ]` not started
-**Implementation note:** _(none)_
-
-**Location:** `packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts:211–214` (`column_toggleSorting`)
-**Category:** `micro`
-
-`old.find` + `old.findIndex` scan the tiny sorting array twice.
-
-**Fix:** `findIndex` once, index into `old`. Per header click; negligible.
-
-**Risk:** None noted; per-interaction-click cost on an already-tiny (2-3 entry) array, consistent with the doctrine that these arrays don't warrant hashing.
-**Verification:** Verified (2026-07-01 audit).
-
----