Skip to content

In TreeDataGrid, find the parent group row with a binary search and replace getOrInsertComputed - #4227

Merged
nstepien merged 21 commits into
mainfrom
tree-grid-parent-lookup
Oct 23, 2026
Merged

nstepien merged 21 commits into
mainfrom
tree-grid-parent-lookup

Conversation

@nstepien

@nstepien nstepien commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

TreeDataGrid found each rendered row's parent group row with rows.indexOf(row), a backward walk over the rows, and childRows.indexOf(row). Without a rowKeyGetter, its own rowKeyGetter did the same lookup again. Scrolling near the end of a large group therefore scanned up to all the rows for every visible row. main has the same problem.

Changes

  • The indexes of the group rows are recorded while flattening, and getParentRowAndIndex now takes a row index and runs a binary search over them.
  • The rows of a group directly follow its group row, so a row's position in its group is rowIdx - parentIdx - 1, and childRows.indexOf is gone.
  • TreeDataGrid only passes a rowKeyGetter to DataGrid when one is provided. Otherwise, renderRow computes the same keys as before: the group ID for group rows, and startRowIndex + groupIndex + 1 for the rows of a group. DataGrid only reads rowKeyGetter for row keys and selection, and selection already requires a rowKeyGetter.
  • getParentRowAndIndex is a plain function, since no memo depends on it anymore.

Performance

Median React Profiler duration of a scroll render, over 20 scrolls near the end of the rows, in Chromium with React's production profiling build (react-dom/profiling) and React Compiler:

Rows Before After
1 group of 1M rows 451 ms 0.6 ms
1 group of 1M rows, with rowKeyGetter 192 ms 0.7 ms
1M rows, not grouped 351 ms 0.7 ms
1,000 groups of 1,000 rows, all expanded 8.0 ms 0.6 ms

Before, nearly all of that time was spent in TreeDataGrid's own scans, so the times were about the same with React's development build. After, the remaining time is React's, which is about 6 times slower in development.

Grouping

Rows are now grouped with get and set instead of getOrInsertComputed, which was slower for two reasons:

  • V8's optimizing compilers inline Map.prototype.get as a hash lookup, but have no such reduction for getOrInsertComputed, so each call goes through the builtin.
  • The callback was an arrow function created for every row and level, even though it only runs for a new group. That doubled the young generation garbage collections while grouping.

Median time to group 1M rows in Node.js 26 (V8 14.6), each version in its own process:

Levels getOrInsertComputed get and set
1 20 ms 16–17 ms
2 46–48 ms 39 ms

The groups on the last level also no longer create a map of subgroups that always stayed empty, which mostly helps with small groups. Median time and retained heap when grouping 1M rows on one level:

Groups Before After
100 groups of 10,000 rows 16 ms, 7.7 MB 16 ms, 7.7 MB
100k groups of 10 rows 42–50 ms, 43 MB 31–40 ms, 26 MB
Random keys, 1.6 rows per group on average 277–281 ms, 279 MB 218–230 ms, 168 MB
Random keys, 2.3 rows per group on average 241–250 ms, 186 MB 194–201 ms, 110 MB
1M groups of 1 row 299–301 ms, 425 MB 216 ms, 249 MB

Tests

A new test checks that, without a rowKeyGetter, a row keeps its element when an earlier group is expanded. It fails if the rows of a group are keyed by their index. vp run ci passes.

🤖 Generated with Claude Code

nstepien and others added 18 commits August 29, 2026 02:10
`TreeDataGrid` no longer asks for a function that groups a set of rows into
a `Record<string, readonly R[]>`. Instead, `getRowGroupKey(row, columnKey)`
returns the group key of a single row, and the grid builds the group tree
itself in a single pass over the rows.

Grouping and flattening are now done in one recursion which also returns the
fully-expanded row count, so `startRowIndex` and `aria-rowcount` keep their
previous meaning.

Note: groups are now ordered by the first row that belongs to them, whereas
`Object.entries()` used to sort integer-like group keys numerically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQUA3cWdaFLZSQwKAGCC3x
It defaults to `String(row[columnKey])`, which is what most grids need, so
grouping now only requires `groupBy`, `expandedGroupIds` and
`onExpandedGroupIdsChange`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQUA3cWdaFLZSQwKAGCC3x
Grouping the rows only depends on `rows`, `groupBy` and `getRowGroupKey`,
while flattening them depends on the expanded state. Keeping both in a single
`useMemo` meant that expanding or collapsing a group re-grouped every row.

`startRowIndex` and the total row count do not depend on the expanded state
either, so they are computed while grouping, and collapsed groups no longer
have to be traversed when the rows are flattened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQUA3cWdaFLZSQwKAGCC3x
Group rows are only ever created by `TreeDataGrid`, so a single set can be
shared by every instance instead of being rebuilt on every flatten. That makes
`isGroupRow` a stable module-level function, which drops it from four
dependency arrays and lets the `rows` memo return the rows alone.

`rowHeight` is no longer recomputed when a group is expanded or collapsed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQUA3cWdaFLZSQwKAGCC3x
…th-getRowGroupKey

# Conflicts:
#	README.md
#	src/TreeDataGrid.tsx
Add a test that integer-like group keys keep the order of their first row,
instead of being sorted numerically like `Object.entries()` used to.

The row grouping demo only sorted its rows by country, so its year groups
would have come out in random order. Sort the rows by year too.

Add a performance note to `getRowGroupKey`, as a new function identity
groups all the rows again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The groups are only mutated while the rows are being grouped, so build them
with a mutable type and return them from the `useMemo` as a `ReadonlyMap`
of readonly groups, like `useCalculatedColumns` does with its columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `useMemo` already depends on the rows, so it can return the row count
when there is nothing to group too, instead of falling back to
`rawRows.length` on every render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Function props are moving to `getX` names, which `getRowGroupKey` already
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th-getRowGroupKey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushing the rows of an expanded group with `push(...childRows)` throws a
RangeError once the group has more rows than browsers accept as function
arguments, so push them one by one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destructuring in the `for...of` head made React Compiler skip the component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Groups are ordered by the first row that belongs to them, so nested groups,
e.g., years under sports, were out of order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row count doesn't depend on the expanded state, so it alone didn't show
that the group was flattened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each rendered row used to look for its parent group row with `rows.indexOf`,
a backward walk, and `childRows.indexOf`, so a render scanned up to all the rows
for each visible row. The group row indexes are now recorded while flattening,
and the parent is found with a binary search over them. The row's position in
its group follows from the index of its group row, and the default row keys are
computed in `renderRow`, where the row index is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien added this pull request to stack #4228 October 23, 2026 19:52

@amanmahajan7 amanmahajan7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like CI is flaky. It keeps timing out

@nstepien

Copy link
Copy Markdown
Collaborator Author

Yes installing the webkit dependencies can be slow at times, not sure if it's an issue with GH or the remote package repos of the deps.

Base automatically changed from replace-rowGrouper-with-getRowGroupKey to main October 23, 2026 20:46
nstepien and others added 2 commits October 23, 2026 21:55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
V8 inlines `Map.prototype.get` in optimized code but not
`getOrInsertComputed`, and the callback was created for every row and level,
which doubled the young generation garbage collections while grouping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien nstepien changed the title Find the parent group row with a binary search in TreeDataGrid In TreeDataGrid, find the parent group row with a binary search and replace getOrInsertComputed Oct 23, 2026
@codecov-commenter

codecov-commenter commented Oct 23, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.96%. Comparing base (0e49dec) to head (e42a270).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4227   +/-   ##
=======================================
  Coverage   98.95%   98.96%           
=======================================
  Files          46       46           
  Lines        2017     2030   +13     
  Branches      787      792    +5     
=======================================
+ Hits         1996     2009   +13     
  Misses         21       21           
Files with missing lines Coverage Δ
src/TreeDataGrid.tsx 98.66% <100.00%> (+0.08%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Each group on the last level allocated a map that always stayed empty. With
small groups, e.g., about 2 rows per group, grouping 1M rows now takes about 20%
less time and retains 40% less memory, and large groups are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien merged commit 8faa4e4 into main Oct 23, 2026
6 of 7 checks passed
@nstepien
nstepien deleted the tree-grid-parent-lookup branch October 23, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants