Skip to content

[utils] Explicitly register roving tab items with parent#48122

Merged
mj12albert merged 32 commits intomui:masterfrom
mj12albert:feat/roving-set-registry
Apr 2, 2026
Merged

[utils] Explicitly register roving tab items with parent#48122
mj12albert merged 32 commits intomui:masterfrom
mj12albert:feat/roving-set-registry

Conversation

@mj12albert
Copy link
Copy Markdown
Member

@mj12albert mj12albert commented Mar 28, 2026

Migration doc: https://deploy-preview-48122--material-ui.netlify.app/material-ui/migration/upgrade-to-v9/#menu-and-menulist

Previews:

Notable changes:

1. Roving items explicitly opt in

Previously, the parent (e.g. MenuList) computes and manages child indexes, injecting ref and tabindex into each children. Now an additional useRovingTabIndexItem hook is available for roving items to explicitly register themselves with the parent. This way the parent doesn't have to scan children to determine out whether something is a roving item or not. (Also a child hook wouldn't have worked inside React.Children.map)

This makes muiSkipListHighlight unnecessary, and easier to support conditional rendering/Fragments inside Menus

The idea and mechanics here are borrowed from Base UI - registry map of roving item refs, DOM order tracking

2. Boundary between useRovingTabIndex and individual components

Previously the hook required components to "express" their specific focus rules through the hook call in the form of focusableIndex, shouldFocus. These often happened inside React.Children.map e.g. in MenuList, where props are created and injected into the children. I think this is mainly due to the constraint of scanning children, but with explicit registration this is no longer an issue.

Now the hook is more generic, component specific things like Tabs selection state and MenuList's "autofocus" rules stay within the component and no longer have to be "translated" through the hook.

Fixes #33268
Fixes #34218

⚠️ The bundle size report is inaccurate

I measured the @mui/utils/useRovingTabIndex after esbuilding it and and it should be 2653 gzip (+1506) (an increase, but there's another hook in there now). The mui-bot bundle size report shows -11.31% gzip for the utils package somehow because the export surface changed probably.
By comparison, Base UI's composites are about 7069 gzip.

@mj12albert mj12albert added the type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. label Mar 28, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Mar 28, 2026

Netlify deploy preview

Bundle size report

Bundle Parsed size Gzip size
@mui/material 🔺+3.98KB(+0.79%) 🔺+1.4KB(+0.97%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils ▼-1.62KB(-10.01%) ▼-621B(-9.91%)

Details of bundle changes

Generated by 🚫 dangerJS against be593f7

@mj12albert mj12albert changed the title Feat/roving set registry [utils] roving item registration Mar 28, 2026
@siriwatknp
Copy link
Copy Markdown
Member

Tested on mui/mui-x#21858, this PR fixes the bug.

@siriwatknp siriwatknp added type: bug It doesn't behave as expected. scope: menu Changes related to the menu. scope: select Changes related to the select. scope: tabs Changes related to the tabs. and removed type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Mar 30, 2026
Copy link
Copy Markdown
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

Review comments on the registration-based roving tab index refactor.


return (
<MenuRoot
// `autoFocus` here means Menu should move focus itself, usually into MenuList or its active item.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The inverse logic (disableAutoFocus={autoFocus}) is correct but reads counter-intuitively. A named binding like:

// Menu manages its own focus; prevent Popover/Modal from racing with that.
const disablePopoverAutoFocus = autoFocus;

would make the intent immediately obvious.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh I found that Claude code really hates disableAutoFocus={autoFocus} 😆

I made the code comment more detailed instead, making another var here is confusing in other ways


// `mapTick` is only an invalidation signal. The source of truth stays in the stable item map.
const orderedItems = React.useMemo(() => {
void mapTick;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: the void mapTick pattern to force useMemo invalidation is clever but non-obvious. A one-liner like // mapTick is only read to invalidate the memo; the source of truth is itemMapRef would help.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated the comment (also added a link to the original detailed explanation)

Comment on lines +454 to +458
queueMicrotask(() => {
if (itemRef.current === null) {
unregisterItem(item.id);
}
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The queueMicrotask deferral is subtle — it avoids nested state updates during commit, but relies on the guard itemRef.current === null to handle the remove-then-re-add race. A brief inline comment explaining why a microtask (vs setTimeout or layout effect cleanup) would help future readers.

Also worth confirming queueMicrotask is available in the project's browser support matrix (it's not polyfilled in older browsers).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There shouldn't be a browser support issue; TBH I don't fully understand the Claude code comment, but queueMicrotask is a always the go-to when waiting for refs to settle

@silviuaavram
Copy link
Copy Markdown
Member

Maybe we need more jsdoc on the hooks, functions, because it's not obvious what's going on.

@mj12albert mj12albert force-pushed the feat/roving-set-registry branch from 58fe71f to 0399439 Compare March 31, 2026 08:27
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 31, 2026
@mj12albert mj12albert force-pushed the feat/roving-set-registry branch from 11daa9f to 24fcc41 Compare March 31, 2026 14:02
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 31, 2026
@mj12albert mj12albert force-pushed the feat/roving-set-registry branch 4 times, most recently from 7f20e54 to 3071ccc Compare April 1, 2026 07:12
@mj12albert mj12albert marked this pull request as ready for review April 1, 2026 09:46
});

return (
<StepButtonRoot {...rovingItemProps} {...other}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
<StepButtonRoot {...rovingItemProps} {...other}>
<StepButtonRoot disabled={disabled} {...rovingItemProps} {...other}>

stepButtonProps includes disabled (line 101). It's spread onto
RovingStepButton (line 114). Then inside RovingStepButton, disabled is
destructured out (line 61) and only passed to the hook — not back to
StepButtonRoot.

I think a test should be added to cover this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in beddbdb

<ListSlot
actions={menuListActionsRef}
autoFocus={autoFocus && (activeItemIndex === -1 || disableAutoFocusItem)}
autoFocus={autoFocus && open}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you explain why this was changed? It's hard to follow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I renamed the variables a bit to improve readability, here it becomes autoFocus={shouldManageInitialFocus}

The conditions for shouldManageInitialFocus changed because the logic of activeItemIndex === -1 || disableAutoFocusItem (whether to focus an item or the container) is centralized in MenuList, since it is the roving tab index parent.

Now we're practically just passing through autoFocus={autoFocus}, && open is to prevent a keepMounted: true menu from accidentally stealing focus via autoFocus

@mj12albert mj12albert force-pushed the feat/roving-set-registry branch from c83b7ec to 53d6ab0 Compare April 1, 2026 12:49
Copy link
Copy Markdown
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

👍

@mj12albert mj12albert requested a review from mnajdova April 1, 2026 15:01
@Janpot
Copy link
Copy Markdown
Member

Janpot commented Apr 1, 2026

I measured the @mui/utils/useRovingTabIndex after esbuilding it and and it should be 2653 gzip (+1506) (an increase, but there's another hook in there now). The mui-bot bundle size report shows -11.31% gzip for the utils package somehow because the export surface changed probably.

Ok, looking at the report

Copy link
Copy Markdown
Member

@mnajdova mnajdova left a comment

Choose a reason for hiding this comment

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

Good work!

// Focuses the item that already owns `tabIndex=0`. Consumers use this when they have
// already decided which item should be active and just need DOM focus to enter that item,
// such as `MenuList` when a menu opens.
const focusActiveItem = React.useCallback(() => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is not used anywhere yet

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Only MenuList needed this and it became focusInitialItem there

});
}, [item, registerItem]);

useEnhancedEffect(() => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why don't we do the cleanup in the effect above?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a comment, the upper effect for registration will re-run on any item (meta)data change, the lower effect for de-registration reruns only on id change, separating it prevents unregister/re-register on any metadata change

@mj12albert mj12albert enabled auto-merge (squash) April 2, 2026 10:33
@mj12albert mj12albert merged commit a2ac691 into mui:master Apr 2, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: menu Changes related to the menu. scope: select Changes related to the select. scope: tabs Changes related to the tabs. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Select] moving focus with keyboard ArrowUp or ArrowDown is not working when [disablePortal] is set MenuItems as Link break keyboard input

6 participants