[utils] Explicitly register roving tab items with parent#48122
[utils] Explicitly register roving tab items with parent#48122mj12albert merged 32 commits intomui:masterfrom
Conversation
Netlify deploy preview
Bundle size report
|
|
Tested on mui/mui-x#21858, this PR fixes the bug. |
siriwatknp
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Updated the comment (also added a link to the original detailed explanation)
| queueMicrotask(() => { | ||
| if (itemRef.current === null) { | ||
| unregisterItem(item.id); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
|
Maybe we need more jsdoc on the hooks, functions, because it's not obvious what's going on. |
58fe71f to
0399439
Compare
11daa9f to
24fcc41
Compare
7f20e54 to
3071ccc
Compare
| }); | ||
|
|
||
| return ( | ||
| <StepButtonRoot {...rovingItemProps} {...other}> |
There was a problem hiding this comment.
| <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.
| <ListSlot | ||
| actions={menuListActionsRef} | ||
| autoFocus={autoFocus && (activeItemIndex === -1 || disableAutoFocusItem)} | ||
| autoFocus={autoFocus && open} |
There was a problem hiding this comment.
Can you explain why this was changed? It's hard to follow.
There was a problem hiding this comment.
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
c83b7ec to
53d6ab0
Compare
Ok, looking at the report
|
| // 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(() => { |
There was a problem hiding this comment.
this is not used anywhere yet
There was a problem hiding this comment.
Only MenuList needed this and it became focusInitialItem there
| }); | ||
| }, [item, registerItem]); | ||
|
|
||
| useEnhancedEffect(() => { |
There was a problem hiding this comment.
why don't we do the cleanup in the effect above?
There was a problem hiding this comment.
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
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, injectingrefandtabindexinto each children. Now an additionaluseRovingTabIndexItemhook 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 insideReact.Children.map)This makes
muiSkipListHighlightunnecessary, and easier to support conditional rendering/Fragments insideMenusThe 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 insideReact.Children.mape.g. inMenuList, 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
I measured the
@mui/utils/useRovingTabIndexafteresbuilding it and and it should be2653 gzip (+1506)(an increase, but there's another hook in there now). Themui-botbundle size report shows-11.31% gzipfor the utils package somehow because the export surface changed probably.By comparison, Base UI's composites are about
7069 gzip.