Skip to content

Reduce style recalcs when moving the active cell - #4290

Draft
nstepien wants to merge 2 commits into
mainfrom
perf/row-active-class
Draft

nstepien wants to merge 2 commits into
mainfrom
perf/row-active-class

Conversation

@nstepien

@nstepien nstepien commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Moving the active cell restyled more than the cells it moves between, for two reasons:

  • The active row's border in a TreeDataGrid is drawn by .rdg-row[tabindex='0']. Chromium collects attribute invalidation sets by attribute name, not by element, so this selector makes every tabindex write on the page invalidate the style of the element it's written to, row or not. When that element is focused, Chromium also brings style up to date on the spot to check that the element is still focusable (Element::AttributeChanged calls UpdateStyleAndLayoutTreeForElement), so the invalidation turns into a forced style recalc. Moving the active cell writes tabindex on the focused element: the old cell, the old row in a TreeDataGrid, or focusable content in a cell, like the checkboxes of SelectColumn. The library's rows get the rdg-row-active class exactly when they get tabIndex={0}, so the rule can match the class instead.
  • box-sizing: inherit on the root's children. When a child uses inherit for a non-inherited property, Chromium restyles all children of its parent whenever any non-inherited style of the parent changes (ComputedStyle::ComputeDifference). The root's scroll padding changes when the active cell moves between frozen and unfrozen columns, or between the header or summary rows and the body, so each of those moves restyled every rendered row: about 60 elements in a 600px tall grid. The root's children now get box-sizing: border-box, the root's own value, and everything below them still inherits it, so no computed style changes.

Changes

  • Draw the active row's border with .rdg-row.rdg-row-active instead of .rdg-row[tabindex='0'], with the same specificity.
  • Give the root's children box-sizing: border-box instead of inheriting it.
  • The frozen-columns test that set tabindex on a row to trigger the rule now sets the class. It fails with the old selector.

This isn't a breaking change: the computed box-sizing and the size and position of every element of the library, and of custom content in cells, headers, and editors, are the same as before. Two edge cases change:

  • An app that overrides box-sizing on .rdg itself no longer passes it on to the content of the grid, which stays border-box.
  • A custom renderRow that passes tabIndex={0} to a row of a DataGrid no longer draws the border, as only active rows of a TreeDataGrid get the class.

#4267 and #4289 merge cleanly with this branch. #3976 moves the [tabindex='0'] block of src/style/row.ts into a new rowFocusable class, so whichever merges second has to carry the selector over; #3976 already conflicts with main in that file.

Alternatives

Removing the box-sizing rule altogether is the fastest option, but it's a breaking change. The library's own elements would only need box-sizing: border-box on the drag handle, which otherwise grows from 16 to 20px on hover, and their layout stays the same. But custom content falls back to the browser's defaults, which is content-box for most elements, in apps without a global reset:

Custom content in a 150px column Today and this PR Without the rule border-box on every element
Header filter <input> with inline-size: 100% and padding: 4px 133px 145px, overflows the cell 133px
Editor <input> with inline-size: 100%, padding-inline: 6px, and a 2px border 149px 165px, overflows the cell 149px
<div> with inline-size: 50px, padding-inline: 10px, and a 1px border 50px 72px 50px
Child of a box-sizing: content-box wrapper, inline-size: 50px, padding-inline: 10px 70px 70px 50px

The same in Chromium, Firefox, and WebKit. The demo site's header filters and the progress editor's dialog in CommonFeatures would change too. border-box on every element instead of inherit breaks the last case, and made scrolling 7% slower in style time.

Style recalc time per move in µs, mean of 5 rounds with rotated build order, measured on top of the first change:

Scenario First change only This PR Without the rule border-box on every element
Cells 41.6 38.2 33.8 38.6
Header cells 40.2 39.2 35.7 38.4
Into, along, and out of a SelectColumn 167.3 96.9 83.9 89.2
TreeDataGrid rows and cells 135.7 91.0 83.8 87.3

Without the rule, moves get 3.5–13 µs faster than with this PR, about 1–3% of a move. Most likely, that's because Chromium creates a ::before and an ::after for each element that a ::before or ::after rule matches, resolves their styles, and discards them when they have no content.

Verification

Chromium with production React and a 30-column grid, from CDP traces of each arrow-key move. The counts were the same in all 5 cycles per key. Style recalc passes / elements restyled per move:

Scenario main This PR #4267 #4267 + this PR
Cells, → ↓ ← ↑ 4, 3, 3, 2 = 3.00 / 4.00 3.00 / 4.00 4, 3, 4, 3 = 3.50 / 5.00 3, 2, 4, 2 = 2.75 / 4.00
Header cells, → ← 4, 3 = 3.50 / 4.00 3.50 / 4.00 4, 4 = 4.00 / 5.00 3, 4 = 3.50 / 4.00
Into, along, and out of a SelectColumn 5.50 / 38.5 4.00 / 8.00 5.50 / 38.75 4.00 / 8.00
TreeDataGrid rows and cells 3.50 / 26.2 3.50 / 7.17 3.50 / 26.5 3.17 / 7.00

Moving between a frozen and an unfrozen column now restyles 6–9 elements instead of 63–68. ← takes a pass more than → in the last column because React writes the new cell before the old one. Between focused rows in a TreeDataGrid, ↓ takes a pass fewer and ↑ a pass more, for the same reason.

Style recalc time per move in µs, mean of 5 rounds with rotated build order. An identical copy of this PR measured 38.2, 38.1, 97.0, and 88.8:

Scenario main This PR #4267 #4267 + this PR
Cells 41.2 37.2 44.1 37.7
Header cells 39.0 39.2 44.3 37.5
Into, along, and out of a SelectColumn 175.7 98.8 173.7 92.5
TreeDataGrid rows and cells 134.3 92.2 132.7 86.9

The whole key press (flushSync) went from 556 to 499 µs in and around a SelectColumn, and from 699 to 673 µs in the TreeDataGrid. The difference for plain cells is within its ±10% between rounds.

The sizes, positions, and computed box-sizing of every element in a DataGrid and a TreeDataGrid with frozen columns, summary rows, sorted, resizable, and draggable columns, an open editor, the drag handle at rest and hovered, a column's drag image, and an active row, including the ::before and ::after borders, matched main's in Chromium, Firefox, and WebKit. So did focus, scroll positions, scroll padding, and the active row's and active cell's borders after each of 45 moves. CI's visual tests pass with the existing screenshots.

vp run ci --coverage passes.

🤖 Generated with Claude Code

nstepien and others added 2 commits November 11, 2026 18:32
…dex='0']`

Chromium collects attribute invalidation sets by attribute name, so the
`[tabindex='0']` selector made every `tabindex` write invalidate the style of
the element written to. On the focused element, the write also makes Chromium
update style to check that it's still focusable, so moving the active cell
forced extra style recalcs. Rows get `rdg-row-active` exactly when they get
`tabIndex={0}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child that inherits a non-inherited property makes Chromium restyle every
child of its parent whenever one of the parent's non-inherited styles
changes. The root's scroll padding changes when the active cell moves between
frozen and unfrozen columns, or between the header or summary rows and the
body, so each of those moves restyled every row. The root's children are
now `border-box` themselves, which is the root's own `box-sizing`, and
everything below them still inherits it, so computed styles don't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien nstepien changed the title Draw the active row's border with rdg-row-active instead of [tabindex='0'] Reduce style recalcs when moving the active cell Nov 11, 2026

This branch has not been deployed

No deployments
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.

1 participant