Skip to content

[composite] Check the tab stop's visibility in the frame after mount - #6247

Open
arimendelow wants to merge 3 commits into
mui:masterfrom
arimendelow:composite-defer-visibility
Open

arimendelow wants to merge 3 commits into
mui:masterfrom
arimendelow:composite-defer-visibility

Conversation

@arimendelow

@arimendelow arimendelow commented Nov 15, 2026 •

Copy link
Copy Markdown

Fixes #6239.

Problem

When a composite mounts (Toggle Group, Toolbar, Navigation Menu, Radio Group with no value, Menubar, and any CompositeRoot without an active item), useCompositeRoot's onMapChange checks whether the default tab stop can hold focus with isListIndexDisabled. That calls isElementVisible, which runs getComputedStyle and checkVisibility(). It runs in the layout phase of the commit that mounts the composite, while the DOM has pending changes, so the browser recalculates style (and layout, for checkVisibility) for the whole document before the first paint. The disabledIndices layout effect does the same each time disabledIndices changes, which Toolbar does after every item map change.

On a busy screen that is a forced style recalculation per composite. In one app's call screen at 4× CPU, composite registration was about 30% of the worst main-thread block.

Change

  • isListIndexDisabled is split: isListIndexDisabledByAttributes holds the checks that read no style (disabledIndices, :disabled, aria-disabled/data-disabled); isListIndexDisabled is that plus the visibility check, unchanged in result.
  • On first population, and in the disabledIndices effect, the attribute checks still run in the commit, so a disabled default item never holds the tab stop in the first frame.
  • The visibility check runs in the next animation frame, requested from a passive effect while it is pending and cancelled in its cleanup, so Strict Mode's effect replay requests it again rather than losing it. It checks only the visibility of the item the commit checked, and only while that item still holds the tab stop, so a later change (an item disabled while focused, a key that moved the tab stop) is left to the code that handles it, as on master. Style is calculated for that frame anyway, so the read there costs nothing extra. An item hidden by styles holds tabindex="0" for one frame, while it can't be seen or reached.

Measured

A minimal Vite app with Base UI built from source, mounting each composite inside flushSync in Chromium and counting getComputedStyle calls during the mount:

Mount master this PR
ToggleGroup, 3 toggles 1 0
ToggleGroup, first toggle visibility: hidden 3 0
Toolbar, 3 buttons, first disabled 3 0
NavigationMenu, 2 links 1 0
RadioGroup, no value 1 0

Tab stops (tabindex of each item) are unchanged after the next frame: a hidden first toggle ends on the second (-1,0,-1), a disabled first toggle moves at mount (-1,0), and the Toolbar case is the same as master.

Tests

  • New: the root reads no computed style while it mounts (rendered synchronously, so no frame runs first); a first item hidden by styles loses the tab stop in the frame after mount; an aria-disabled first item loses it in the mount commit.
  • skips items that cannot hold the tab stop now waits for the frame before checking the first tab stop, since its first item is display: none.

An earlier attempt at the same reads, #6134, was closed by its author without review.

@pkg-pr-new

pkg-pr-new Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

commit: 1bace58

@code-infra-dashboard

code-infra-dashboard Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+254B(+0.05%) 🔺+50B(+0.03%)

Details of bundle changes

Performance

No regressions in 36 benchmarks · ran 3m 19s · details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 1bace58
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6af9982dc3c8ba0f8c425763
😎 Deploy Preview https://deploy-preview-6247--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

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.

[composite] isListIndexDisabled forces a computed-style read per item on mount

1 participant